feat: improve mobile experience
This commit is contained in:
21
index.html
21
index.html
@@ -2,18 +2,21 @@
|
|||||||
<html lang="en">
|
<html lang="en">
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8" />
|
<meta charset="UTF-8" />
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
|
||||||
|
|
||||||
<title>Jumble</title>
|
<title>Jumble</title>
|
||||||
<meta name="description" content="A beautiful nostr client focused on browsing relay feeds">
|
<meta name="description" content="A beautiful nostr client focused on browsing relay feeds" />
|
||||||
<meta name="keywords" content="jumble, nostr, web, client, relay, feed, social, pwa, simple, clean">
|
<meta
|
||||||
|
name="keywords"
|
||||||
|
content="jumble, nostr, web, client, relay, feed, social, pwa, simple, clean"
|
||||||
|
/>
|
||||||
|
|
||||||
<meta name="apple-mobile-web-app-title" content="Jumble" />
|
<meta name="apple-mobile-web-app-title" content="Jumble" />
|
||||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png">
|
<link rel="apple-touch-icon" href="/apple-touch-icon.png" />
|
||||||
<link rel="icon" href="/favicon.ico" sizes="48x48">
|
<link rel="icon" href="/favicon.ico" sizes="48x48" />
|
||||||
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml">
|
<link rel="icon" href="/favicon.svg" sizes="any" type="image/svg+xml" />
|
||||||
<meta name="theme-color" content="#000000" media="(prefers-color-scheme: dark)">
|
<meta name="theme-color" content="#09090b" media="(prefers-color-scheme: dark)" />
|
||||||
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)">
|
<meta name="theme-color" content="#FFFFFF" media="(prefers-color-scheme: light)" />
|
||||||
|
|
||||||
<meta property="og:url" content="https://jumble.social" />
|
<meta property="og:url" content="https://jumble.social" />
|
||||||
<meta property="og:type" content="website" />
|
<meta property="og:type" content="website" />
|
||||||
@@ -28,7 +31,7 @@
|
|||||||
/>
|
/>
|
||||||
<style>
|
<style>
|
||||||
body {
|
body {
|
||||||
background-color: #FFFFFF;
|
background-color: #ffffff;
|
||||||
}
|
}
|
||||||
@media (prefers-color-scheme: dark) {
|
@media (prefers-color-scheme: dark) {
|
||||||
body {
|
body {
|
||||||
|
|||||||
93
package-lock.json
generated
93
package-lock.json
generated
@@ -11,6 +11,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nextui-org/image": "^2.2.3",
|
"@nextui-org/image": "^2.2.3",
|
||||||
"@noble/hashes": "^1.6.1",
|
"@noble/hashes": "^1.6.1",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
@@ -18,6 +19,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
"@radix-ui/react-scroll-area": "1.2.0",
|
"@radix-ui/react-scroll-area": "1.2.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
@@ -29,7 +31,6 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
|
||||||
"lru-cache": "^11.0.2",
|
"lru-cache": "^11.0.2",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"nostr-tools": "^2.10.4",
|
"nostr-tools": "^2.10.4",
|
||||||
@@ -43,6 +44,7 @@
|
|||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"yet-another-react-lightbox": "^3.21.7",
|
"yet-another-react-lightbox": "^3.21.7",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
@@ -2545,6 +2547,33 @@
|
|||||||
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
|
||||||
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="
|
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA=="
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-alert-dialog": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-alert-dialog/-/react-alert-dialog-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-A6Kh23qZDLy3PSU4bh2UJZznOrUdHImIXqF8YtUa6CN73f8EOO9XlXSCd9IHyPvIquTaa/kwaSWzZTtUvgXVGw==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/primitive": "1.1.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-dialog": "1.1.4",
|
||||||
|
"@radix-ui/react-primitive": "2.0.1",
|
||||||
|
"@radix-ui/react-slot": "1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-arrow": {
|
"node_modules/@radix-ui/react-arrow": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.1.tgz",
|
||||||
@@ -3170,6 +3199,48 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/@radix-ui/react-select": {
|
||||||
|
"version": "2.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.1.4.tgz",
|
||||||
|
"integrity": "sha512-pOkb2u8KgO47j/h7AylCj7dJsm69BXcjkrvTqMptFqsE2i0p8lHkfgneXKjAgPzBMivnoMyt8o4KiV4wYzDdyQ==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/number": "1.1.0",
|
||||||
|
"@radix-ui/primitive": "1.1.1",
|
||||||
|
"@radix-ui/react-collection": "1.1.1",
|
||||||
|
"@radix-ui/react-compose-refs": "1.1.1",
|
||||||
|
"@radix-ui/react-context": "1.1.1",
|
||||||
|
"@radix-ui/react-direction": "1.1.0",
|
||||||
|
"@radix-ui/react-dismissable-layer": "1.1.3",
|
||||||
|
"@radix-ui/react-focus-guards": "1.1.1",
|
||||||
|
"@radix-ui/react-focus-scope": "1.1.1",
|
||||||
|
"@radix-ui/react-id": "1.1.0",
|
||||||
|
"@radix-ui/react-popper": "1.2.1",
|
||||||
|
"@radix-ui/react-portal": "1.1.3",
|
||||||
|
"@radix-ui/react-primitive": "2.0.1",
|
||||||
|
"@radix-ui/react-slot": "1.1.1",
|
||||||
|
"@radix-ui/react-use-callback-ref": "1.1.0",
|
||||||
|
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||||
|
"@radix-ui/react-use-layout-effect": "1.1.0",
|
||||||
|
"@radix-ui/react-use-previous": "1.1.0",
|
||||||
|
"@radix-ui/react-visually-hidden": "1.1.1",
|
||||||
|
"aria-hidden": "^1.1.1",
|
||||||
|
"react-remove-scroll": "^2.6.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"@types/react": "*",
|
||||||
|
"@types/react-dom": "*",
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
|
||||||
|
},
|
||||||
|
"peerDependenciesMeta": {
|
||||||
|
"@types/react": {
|
||||||
|
"optional": true
|
||||||
|
},
|
||||||
|
"@types/react-dom": {
|
||||||
|
"optional": true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/@radix-ui/react-separator": {
|
"node_modules/@radix-ui/react-separator": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.1.tgz",
|
||||||
@@ -6504,14 +6575,6 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/i18next-browser-languagedetector": {
|
|
||||||
"version": "8.0.2",
|
|
||||||
"resolved": "https://registry.npmjs.org/i18next-browser-languagedetector/-/i18next-browser-languagedetector-8.0.2.tgz",
|
|
||||||
"integrity": "sha512-shBvPmnIyZeD2VU5jVGIOWP7u9qNG3Lj7mpaiPFpbJ3LVfHZJvVzKR4v1Cb91wAOFpNw442N+LGPzHOHsten2g==",
|
|
||||||
"dependencies": {
|
|
||||||
"@babel/runtime": "^7.23.2"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"node_modules/idb": {
|
"node_modules/idb": {
|
||||||
"version": "7.1.1",
|
"version": "7.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/idb/-/idb-7.1.1.tgz",
|
||||||
@@ -9326,6 +9389,18 @@
|
|||||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||||
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw=="
|
||||||
},
|
},
|
||||||
|
"node_modules/vaul": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/vaul/-/vaul-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-ZFkClGpWyI2WUQjdLJ/BaGuV6AVQiJ3uELGk3OYtP+B6yCO7Cmn9vPFXVJkRaGkOJu3m8bQMgtyzNHixULceQA==",
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "^1.1.1"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc",
|
||||||
|
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0.0 || ^19.0.0-rc"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/vite": {
|
"node_modules/vite": {
|
||||||
"version": "6.0.5",
|
"version": "6.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/vite/-/vite-6.0.5.tgz",
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@nextui-org/image": "^2.2.3",
|
"@nextui-org/image": "^2.2.3",
|
||||||
"@noble/hashes": "^1.6.1",
|
"@noble/hashes": "^1.6.1",
|
||||||
|
"@radix-ui/react-alert-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-avatar": "^1.1.2",
|
"@radix-ui/react-avatar": "^1.1.2",
|
||||||
"@radix-ui/react-dialog": "^1.1.4",
|
"@radix-ui/react-dialog": "^1.1.4",
|
||||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
"@radix-ui/react-label": "^2.1.1",
|
"@radix-ui/react-label": "^2.1.1",
|
||||||
"@radix-ui/react-popover": "^1.1.4",
|
"@radix-ui/react-popover": "^1.1.4",
|
||||||
"@radix-ui/react-scroll-area": "1.2.0",
|
"@radix-ui/react-scroll-area": "1.2.0",
|
||||||
|
"@radix-ui/react-select": "^2.1.4",
|
||||||
"@radix-ui/react-separator": "^1.1.1",
|
"@radix-ui/react-separator": "^1.1.1",
|
||||||
"@radix-ui/react-slot": "^1.1.1",
|
"@radix-ui/react-slot": "^1.1.1",
|
||||||
"@radix-ui/react-switch": "^1.1.2",
|
"@radix-ui/react-switch": "^1.1.2",
|
||||||
@@ -39,7 +41,6 @@
|
|||||||
"dayjs": "^1.11.13",
|
"dayjs": "^1.11.13",
|
||||||
"framer-motion": "^11.15.0",
|
"framer-motion": "^11.15.0",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.0.2",
|
|
||||||
"lru-cache": "^11.0.2",
|
"lru-cache": "^11.0.2",
|
||||||
"lucide-react": "^0.469.0",
|
"lucide-react": "^0.469.0",
|
||||||
"nostr-tools": "^2.10.4",
|
"nostr-tools": "^2.10.4",
|
||||||
@@ -53,6 +54,7 @@
|
|||||||
"react-string-replace": "^1.1.1",
|
"react-string-replace": "^1.1.1",
|
||||||
"tailwind-merge": "^2.5.5",
|
"tailwind-merge": "^2.5.5",
|
||||||
"tailwindcss-animate": "^1.0.7",
|
"tailwindcss-animate": "^1.0.7",
|
||||||
|
"vaul": "^1.1.2",
|
||||||
"yet-another-react-lightbox": "^3.21.7",
|
"yet-another-react-lightbox": "^3.21.7",
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
|
|||||||
18
src/App.tsx
18
src/App.tsx
@@ -4,7 +4,7 @@ import './index.css'
|
|||||||
import { Toaster } from '@/components/ui/toaster'
|
import { Toaster } from '@/components/ui/toaster'
|
||||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||||
import { PageManager } from './PageManager'
|
import { PageManager } from './PageManager'
|
||||||
import NoteListPage from './pages/primary/NoteListPage'
|
import { FeedProvider } from './providers/FeedProvider'
|
||||||
import { FollowListProvider } from './providers/FollowListProvider'
|
import { FollowListProvider } from './providers/FollowListProvider'
|
||||||
import { NostrProvider } from './providers/NostrProvider'
|
import { NostrProvider } from './providers/NostrProvider'
|
||||||
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
import { NoteStatsProvider } from './providers/NoteStatsProvider'
|
||||||
@@ -13,23 +13,21 @@ import { ScreenSizeProvider } from './providers/ScreenSizeProvider'
|
|||||||
|
|
||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<div className="h-screen">
|
<ThemeProvider>
|
||||||
<ThemeProvider>
|
<ScreenSizeProvider>
|
||||||
<ScreenSizeProvider>
|
<FeedProvider>
|
||||||
<RelaySettingsProvider>
|
<RelaySettingsProvider>
|
||||||
<NostrProvider>
|
<NostrProvider>
|
||||||
<FollowListProvider>
|
<FollowListProvider>
|
||||||
<NoteStatsProvider>
|
<NoteStatsProvider>
|
||||||
<PageManager>
|
<PageManager />
|
||||||
<NoteListPage />
|
|
||||||
</PageManager>
|
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</NoteStatsProvider>
|
</NoteStatsProvider>
|
||||||
</FollowListProvider>
|
</FollowListProvider>
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</RelaySettingsProvider>
|
</RelaySettingsProvider>
|
||||||
</ScreenSizeProvider>
|
</FeedProvider>
|
||||||
</ThemeProvider>
|
</ScreenSizeProvider>
|
||||||
</div>
|
</ThemeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,18 +1,26 @@
|
|||||||
import Sidebar from '@/components/Sidebar'
|
import Sidebar from '@/components/Sidebar'
|
||||||
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
import { ResizableHandle, ResizablePanel, ResizablePanelGroup } from '@/components/ui/resizable'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import NoteListPage from '@/pages/primary/NoteListPage'
|
||||||
import HomePage from '@/pages/secondary/HomePage'
|
import HomePage from '@/pages/secondary/HomePage'
|
||||||
import { cloneElement, createContext, useContext, useEffect, useState } from 'react'
|
import { cloneElement, createContext, useContext, useEffect, useState } from 'react'
|
||||||
|
import MePage from './pages/primary/MePage'
|
||||||
|
import NotificationListPage from './pages/primary/NotificationListPage'
|
||||||
import { useScreenSize } from './providers/ScreenSizeProvider'
|
import { useScreenSize } from './providers/ScreenSizeProvider'
|
||||||
import { routes } from './routes'
|
import { routes } from './routes'
|
||||||
|
|
||||||
|
export type TPrimaryPageName = keyof typeof PRIMARY_PAGE_MAP
|
||||||
|
|
||||||
type TPrimaryPageContext = {
|
type TPrimaryPageContext = {
|
||||||
refresh: () => void
|
navigate: (page: TPrimaryPageName) => void
|
||||||
|
current: TPrimaryPageName | null
|
||||||
}
|
}
|
||||||
|
|
||||||
type TSecondaryPageContext = {
|
type TSecondaryPageContext = {
|
||||||
push: (url: string) => void
|
push: (url: string) => void
|
||||||
pop: () => void
|
pop: () => void
|
||||||
|
currentIndex: number
|
||||||
}
|
}
|
||||||
|
|
||||||
type TStackItem = {
|
type TStackItem = {
|
||||||
@@ -21,6 +29,12 @@ type TStackItem = {
|
|||||||
component: React.ReactNode | null
|
component: React.ReactNode | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const PRIMARY_PAGE_MAP = {
|
||||||
|
home: <NoteListPage />,
|
||||||
|
notifications: <NotificationListPage />,
|
||||||
|
me: <MePage />
|
||||||
|
}
|
||||||
|
|
||||||
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
|
const PrimaryPageContext = createContext<TPrimaryPageContext | undefined>(undefined)
|
||||||
|
|
||||||
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
|
const SecondaryPageContext = createContext<TSecondaryPageContext | undefined>(undefined)
|
||||||
@@ -41,39 +55,42 @@ export function useSecondaryPage() {
|
|||||||
return context
|
return context
|
||||||
}
|
}
|
||||||
|
|
||||||
export function PageManager({
|
export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
||||||
children,
|
const [currentPrimaryPage, setCurrentPrimaryPage] = useState<TPrimaryPageName>('home')
|
||||||
maxStackSize = 5
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode
|
|
||||||
maxStackSize?: number
|
|
||||||
}) {
|
|
||||||
const [primaryPageKey, setPrimaryPageKey] = useState<number>(0)
|
|
||||||
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
|
const [secondaryStack, setSecondaryStack] = useState<TStackItem[]>([])
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.location.pathname !== '/') {
|
if (window.location.pathname !== '/') {
|
||||||
pushSecondary(window.location.pathname + window.location.search)
|
pushSecondaryPage(window.location.pathname + window.location.search)
|
||||||
}
|
}
|
||||||
|
|
||||||
const onPopState = (e: PopStateEvent) => {
|
const onPopState = (e: PopStateEvent) => {
|
||||||
const state = e.state ?? { index: -1, url: '/' }
|
const state = e.state ?? { index: -1, url: '/' }
|
||||||
setSecondaryStack((pre) => {
|
setSecondaryStack((pre) => {
|
||||||
const currentItem = pre[pre.length - 1]
|
const currentItem = pre[pre.length - 1]
|
||||||
const currentIndex = currentItem ? currentItem.index : -1
|
const currentIndex = currentItem ? currentItem.index : 0
|
||||||
if (state.index === currentIndex) {
|
if (state.index === currentIndex) {
|
||||||
return pre
|
if (currentIndex !== 0) return pre
|
||||||
|
|
||||||
|
window.history.replaceState(null, '', '/')
|
||||||
|
return []
|
||||||
}
|
}
|
||||||
|
// Go back
|
||||||
if (state.index < currentIndex) {
|
if (state.index < currentIndex) {
|
||||||
const newStack = pre.filter((item) => item.index <= state.index)
|
const newStack = pre.filter((item) => item.index <= state.index)
|
||||||
const topItem = newStack[newStack.length - 1]
|
const topItem = newStack[newStack.length - 1]
|
||||||
|
// Load the component if it's not cached
|
||||||
if (topItem && !topItem.component) {
|
if (topItem && !topItem.component) {
|
||||||
topItem.component = findAndCreateComponent(topItem.url)
|
topItem.component = findAndCreateComponent(topItem.url, state.index)
|
||||||
|
}
|
||||||
|
if (newStack.length === 0) {
|
||||||
|
window.history.replaceState(null, '', '/')
|
||||||
}
|
}
|
||||||
return newStack
|
return newStack
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Go forward
|
||||||
const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
|
const { newStack } = pushNewPageToStack(pre, state.url, maxStackSize)
|
||||||
return newStack
|
return newStack
|
||||||
})
|
})
|
||||||
@@ -86,9 +103,14 @@ export function PageManager({
|
|||||||
}
|
}
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
const refreshPrimary = () => setPrimaryPageKey((prevKey) => prevKey + 1)
|
const navigatePrimaryPage = (page: TPrimaryPageName) => {
|
||||||
|
setCurrentPrimaryPage(page)
|
||||||
|
if (isSmallScreen) {
|
||||||
|
clearSecondaryPages()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const pushSecondary = (url: string) => {
|
const pushSecondaryPage = (url: string) => {
|
||||||
setSecondaryStack((prevStack) => {
|
setSecondaryStack((prevStack) => {
|
||||||
if (isCurrentPage(prevStack, url)) return prevStack
|
if (isCurrentPage(prevStack, url)) return prevStack
|
||||||
|
|
||||||
@@ -100,62 +122,96 @@ export function PageManager({
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
const popSecondary = () => {
|
const popSecondaryPage = () => {
|
||||||
window.history.back()
|
window.history.go(-1)
|
||||||
|
}
|
||||||
|
|
||||||
|
const clearSecondaryPages = () => {
|
||||||
|
if (secondaryStack.length === 0) return
|
||||||
|
window.history.go(-secondaryStack.length)
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}>
|
<PrimaryPageContext.Provider
|
||||||
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
|
value={{
|
||||||
<div className="h-full">
|
navigate: navigatePrimaryPage,
|
||||||
{!!secondaryStack.length &&
|
current: secondaryStack.length === 0 ? currentPrimaryPage : null
|
||||||
secondaryStack.map((item, index) => (
|
}}
|
||||||
<div
|
>
|
||||||
key={item.index}
|
<SecondaryPageContext.Provider
|
||||||
className="absolute top-0 left-0 w-full h-full bg-background"
|
value={{
|
||||||
style={{
|
push: pushSecondaryPage,
|
||||||
display: index === secondaryStack.length - 1 ? 'block' : 'none'
|
pop: popSecondaryPage,
|
||||||
}}
|
currentIndex: secondaryStack.length
|
||||||
>
|
? secondaryStack[secondaryStack.length - 1].index
|
||||||
{item.component}
|
: 0
|
||||||
</div>
|
}}
|
||||||
))}
|
>
|
||||||
|
{!!secondaryStack.length &&
|
||||||
|
secondaryStack.map((item, index) => (
|
||||||
|
<div
|
||||||
|
key={item.index}
|
||||||
|
style={{
|
||||||
|
display: index === secondaryStack.length - 1 ? 'block' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{item.component}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{Object.entries(PRIMARY_PAGE_MAP).map(([pageName, page]) => (
|
||||||
<div
|
<div
|
||||||
key={primaryPageKey}
|
key={pageName}
|
||||||
className="absolute top-0 left-0 w-full h-full bg-background"
|
style={{
|
||||||
style={{ display: !secondaryStack.length ? 'block' : 'none' }}
|
display:
|
||||||
|
secondaryStack.length === 0 && currentPrimaryPage === pageName ? 'block' : 'none'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{page}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
))}
|
||||||
</SecondaryPageContext.Provider>
|
</SecondaryPageContext.Provider>
|
||||||
</PrimaryPageContext.Provider>
|
</PrimaryPageContext.Provider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrimaryPageContext.Provider value={{ refresh: refreshPrimary }}>
|
<PrimaryPageContext.Provider
|
||||||
<SecondaryPageContext.Provider value={{ push: pushSecondary, pop: popSecondary }}>
|
value={{
|
||||||
<div className="flex h-full">
|
navigate: navigatePrimaryPage,
|
||||||
|
current: currentPrimaryPage
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<SecondaryPageContext.Provider
|
||||||
|
value={{
|
||||||
|
push: pushSecondaryPage,
|
||||||
|
pop: popSecondaryPage,
|
||||||
|
currentIndex: secondaryStack.length ? secondaryStack[secondaryStack.length - 1].index : 0
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex h-screen overflow-hidden">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
|
<Separator orientation="vertical" />
|
||||||
<ResizablePanelGroup direction="horizontal">
|
<ResizablePanelGroup direction="horizontal">
|
||||||
<ResizablePanel minSize={30}>
|
<ResizablePanel minSize={30}>
|
||||||
<div key={primaryPageKey} className="h-full">
|
<div
|
||||||
{children}
|
style={{
|
||||||
|
display: !currentPrimaryPage || currentPrimaryPage === 'home' ? 'block' : 'none'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<NoteListPage />
|
||||||
|
</div>
|
||||||
|
<div style={{ display: currentPrimaryPage === 'notifications' ? 'block' : 'none' }}>
|
||||||
|
<NotificationListPage />
|
||||||
</div>
|
</div>
|
||||||
</ResizablePanel>
|
</ResizablePanel>
|
||||||
<ResizableHandle />
|
<ResizableHandle />
|
||||||
<ResizablePanel minSize={30} className="relative">
|
<ResizablePanel minSize={30}>
|
||||||
{secondaryStack.length ? (
|
{secondaryStack.length ? (
|
||||||
secondaryStack.map((item, index) => (
|
secondaryStack.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
key={item.index}
|
key={item.index}
|
||||||
className="absolute top-0 left-0 w-full h-full bg-background"
|
style={{ display: index === secondaryStack.length - 1 ? 'block' : 'none' }}
|
||||||
style={{
|
|
||||||
zIndex: index + 1,
|
|
||||||
display: index === secondaryStack.length - 1 ? 'block' : 'none'
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
{item.component}
|
{item.component}
|
||||||
</div>
|
</div>
|
||||||
@@ -206,26 +262,29 @@ function isCurrentPage(stack: TStackItem[], url: string) {
|
|||||||
return currentPage.url === url
|
return currentPage.url === url
|
||||||
}
|
}
|
||||||
|
|
||||||
function findAndCreateComponent(url: string) {
|
function findAndCreateComponent(url: string, index: number) {
|
||||||
const path = url.split('?')[0]
|
const path = url.split('?')[0]
|
||||||
for (const { matcher, element } of routes) {
|
for (const { matcher, element } of routes) {
|
||||||
const match = matcher(path)
|
const match = matcher(path)
|
||||||
if (!match) continue
|
if (!match) continue
|
||||||
|
|
||||||
if (!element) return null
|
if (!element) return null
|
||||||
return cloneElement(element, match.params)
|
return cloneElement(element, { ...match.params, index } as any)
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
function pushNewPageToStack(stack: TStackItem[], url: string, maxStackSize = 5) {
|
function pushNewPageToStack(stack: TStackItem[], url: string, maxStackSize = 5) {
|
||||||
const component = findAndCreateComponent(url)
|
const currentItem = stack[stack.length - 1]
|
||||||
|
const currentIndex = currentItem ? currentItem.index + 1 : 0
|
||||||
|
|
||||||
|
const component = findAndCreateComponent(url, currentIndex)
|
||||||
if (!component) return { newStack: stack, newItem: null }
|
if (!component) return { newStack: stack, newItem: null }
|
||||||
|
|
||||||
const currentStack = stack[stack.length - 1]
|
const newItem = { component, url, index: currentItem ? currentItem.index + 1 : 0 }
|
||||||
const newItem = { component, url, index: currentStack ? currentStack.index + 1 : 0 }
|
|
||||||
const newStack = [...stack, newItem]
|
const newStack = [...stack, newItem]
|
||||||
const lastCachedIndex = newStack.findIndex((stack) => stack.component)
|
const lastCachedIndex = newStack.findIndex((stack) => stack.component)
|
||||||
|
// Clear the oldest cached component if there are too many cached components
|
||||||
if (newStack.length - lastCachedIndex > maxStackSize) {
|
if (newStack.length - lastCachedIndex > maxStackSize) {
|
||||||
newStack[lastCachedIndex].component = null
|
newStack[lastCachedIndex].component = null
|
||||||
}
|
}
|
||||||
|
|||||||
24
src/assets/Icon.tsx
Normal file
24
src/assets/Icon.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
export default function Icon({ className }: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<svg
|
||||||
|
viewBox="0 0 1080 1228"
|
||||||
|
version="1.1"
|
||||||
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
|
xmlnsXlink="http://www.w3.org/1999/xlink"
|
||||||
|
xmlSpace="preserve"
|
||||||
|
style={{
|
||||||
|
fill: 'currentcolor',
|
||||||
|
fillRule: 'evenodd',
|
||||||
|
clipRule: 'evenodd',
|
||||||
|
strokeLinejoin: 'round',
|
||||||
|
strokeMiterlimit: 2
|
||||||
|
}}
|
||||||
|
className={className}
|
||||||
|
>
|
||||||
|
<path
|
||||||
|
id="Icon-Curve-Cut"
|
||||||
|
d="M360.047,1225.75c-31.046,-3.901 -75.11,-14.46 -106.756,-25.58c-101.676,-35.727 -175.164,-93.066 -215.387,-168.055c-12.079,-22.521 -30.071,-71.422 -27.297,-74.195c0.736,-0.736 11.648,5.578 24.249,14.031c135.436,90.86 301.047,169.043 465.056,219.547l32.77,10.091l-20.27,7.416c-43.455,15.896 -105.159,22.678 -152.365,16.745Zm166.293,-59.234c-168.523,-50.004 -331.475,-126.514 -481.755,-226.196c-37.737,-25.031 -41.489,-28.372 -43.419,-38.663c-3.585,-19.109 1.498,-83.894 9.798,-124.886c7.343,-36.266 27.664,-106.034 32.278,-110.818c2.023,-2.099 217.924,48.207 221.274,51.557c0.975,0.975 -1.132,11.339 -4.682,23.032c-24.542,80.842 -27.217,127.586 -9.935,173.593c22.507,59.917 114.521,99.888 177.281,77.012c29.23,-10.654 56.593,-41.085 82.629,-91.894c29.288,-57.155 32.348,-64.988 196.483,-503.076c81.138,-216.562 148.499,-394.821 149.692,-396.131c2.1,-2.304 217.949,76.926 223.076,81.884c2.056,1.988 -262.476,712.505 -307.806,826.747c-18.422,46.426 -56.939,123.045 -77.918,154.993c-10.157,15.469 -30.753,40.901 -45.769,56.515c-27.821,28.93 -66.46,58.952 -75.447,58.621c-2.738,-0.106 -23.339,-5.631 -45.78,-12.29Z"
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
)
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
@@ -1,48 +1,61 @@
|
|||||||
import {
|
import { Dialog, DialogContent, DialogTrigger } from '@/components/ui/dialog'
|
||||||
Dialog,
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
DialogContent,
|
import { Drawer, DrawerContent, DrawerTrigger } from '../ui/drawer'
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle,
|
|
||||||
DialogTrigger
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
|
||||||
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
|
export default function AboutInfoDialog({ children }: { children: React.ReactNode }) {
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
|
||||||
|
const content = (
|
||||||
|
<>
|
||||||
|
<div className="text-xl font-semibold">Jumble</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
A beautiful nostr client focused on browsing relay feeds
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Made by{' '}
|
||||||
|
<Username
|
||||||
|
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
|
||||||
|
className="inline-block text-primary"
|
||||||
|
showAt
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
Source code:{' '}
|
||||||
|
<a
|
||||||
|
href="https://github.com/CodyTseng/jumble"
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-primary hover:underline"
|
||||||
|
>
|
||||||
|
GitHub
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
If you like this project, you can buy me a coffee ☕️ <br />
|
||||||
|
<div className="font-semibold">⚡️ codytseng@getalby.com ⚡️</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
Version: v{__APP_VERSION__} ({__GIT_COMMIT__})
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
return (
|
||||||
|
<Drawer>
|
||||||
|
<DrawerTrigger asChild>{children}</DrawerTrigger>
|
||||||
|
<DrawerContent>
|
||||||
|
<div className="p-4">{content}</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog>
|
||||||
<DialogTrigger asChild>{children}</DialogTrigger>
|
<DialogTrigger asChild>{children}</DialogTrigger>
|
||||||
<DialogContent>
|
<DialogContent>{content}</DialogContent>
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>Jumble</DialogTitle>
|
|
||||||
<DialogDescription>
|
|
||||||
A beautiful nostr client focused on browsing relay feeds
|
|
||||||
</DialogDescription>
|
|
||||||
</DialogHeader>
|
|
||||||
<div>
|
|
||||||
Made by{' '}
|
|
||||||
<Username
|
|
||||||
userId={'npub1syjmjy0dp62dhccq3g97fr87tngvpvzey08llyt6ul58m2zqpzps9wf6wl'}
|
|
||||||
className="inline-block text-primary"
|
|
||||||
showAt
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
Source code:{' '}
|
|
||||||
<a
|
|
||||||
href="https://github.com/CodyTseng/jumble"
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-primary hover:underline"
|
|
||||||
>
|
|
||||||
GitHub
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
If you like this project, you can buy me a coffee ☕️ <br />
|
|
||||||
<div className="font-semibold">⚡️ codytseng@getalby.com ⚡️</div>
|
|
||||||
</div>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
|
||||||
import { LogIn } from 'lucide-react'
|
|
||||||
|
|
||||||
export default function LoginButton({
|
|
||||||
variant = 'titlebar'
|
|
||||||
}: {
|
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
|
||||||
}) {
|
|
||||||
const { checkLogin } = useNostr()
|
|
||||||
|
|
||||||
let triggerComponent: React.ReactNode
|
|
||||||
if (variant === 'titlebar' || variant === 'small-screen-titlebar') {
|
|
||||||
triggerComponent = <LogIn />
|
|
||||||
} else {
|
|
||||||
triggerComponent = (
|
|
||||||
<>
|
|
||||||
<LogIn size={16} />
|
|
||||||
<div>Login</div>
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button variant={variant} size={variant} onClick={() => checkLogin()}>
|
|
||||||
{triggerComponent}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,91 +0,0 @@
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
DropdownMenu,
|
|
||||||
DropdownMenuContent,
|
|
||||||
DropdownMenuItem,
|
|
||||||
DropdownMenuTrigger
|
|
||||||
} from '@/components/ui/dropdown-menu'
|
|
||||||
import { useFetchProfile } from '@/hooks'
|
|
||||||
import { toProfile } from '@/lib/link'
|
|
||||||
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
|
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import LoginDialog from '../LoginDialog'
|
|
||||||
|
|
||||||
export default function ProfileButton({
|
|
||||||
variant = 'titlebar'
|
|
||||||
}: {
|
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { removeAccount, account } = useNostr()
|
|
||||||
const pubkey = account?.pubkey
|
|
||||||
const { profile } = useFetchProfile(pubkey)
|
|
||||||
const { push } = useSecondaryPage()
|
|
||||||
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
|
|
||||||
if (!pubkey) return null
|
|
||||||
|
|
||||||
const defaultAvatar = generateImageByPubkey(pubkey)
|
|
||||||
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar }
|
|
||||||
|
|
||||||
let triggerComponent: React.ReactNode
|
|
||||||
if (variant === 'titlebar') {
|
|
||||||
triggerComponent = (
|
|
||||||
<button>
|
|
||||||
<Avatar className="ml-2 w-6 h-6 hover:opacity-90">
|
|
||||||
<AvatarImage src={avatar} />
|
|
||||||
<AvatarFallback>
|
|
||||||
<img src={defaultAvatar} />
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
} else if (variant === 'small-screen-titlebar') {
|
|
||||||
triggerComponent = (
|
|
||||||
<button>
|
|
||||||
<Avatar className="w-8 h-8 hover:opacity-90">
|
|
||||||
<AvatarImage src={avatar} />
|
|
||||||
<AvatarFallback>
|
|
||||||
<img src={defaultAvatar} />
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
</button>
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
triggerComponent = (
|
|
||||||
<Button variant="sidebar" size="sidebar" className="border hover:bg-muted px-2">
|
|
||||||
<div className="flex gap-2 items-center flex-1 w-0">
|
|
||||||
<Avatar className="w-10 h-10">
|
|
||||||
<AvatarImage src={avatar} />
|
|
||||||
<AvatarFallback>
|
|
||||||
<img src={defaultAvatar} />
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="truncate font-semibold text-lg">{username}</div>
|
|
||||||
</div>
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<DropdownMenu>
|
|
||||||
<DropdownMenuTrigger asChild>{triggerComponent}</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent>
|
|
||||||
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
|
|
||||||
{t('Accounts')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
<DropdownMenuItem
|
|
||||||
className="text-destructive focus:text-destructive"
|
|
||||||
onClick={() => removeAccount(account)}
|
|
||||||
>
|
|
||||||
{t('Logout')}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
|
||||||
</DropdownMenu>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,17 +0,0 @@
|
|||||||
import { useNostr } from '@/providers/NostrProvider'
|
|
||||||
import LoginButton from './LoginButton'
|
|
||||||
import ProfileButton from './ProfileButton'
|
|
||||||
|
|
||||||
export default function AccountButton({
|
|
||||||
variant = 'titlebar'
|
|
||||||
}: {
|
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
|
||||||
}) {
|
|
||||||
const { pubkey } = useNostr()
|
|
||||||
|
|
||||||
if (pubkey) {
|
|
||||||
return <ProfileButton variant={variant} />
|
|
||||||
} else {
|
|
||||||
return <LoginButton variant={variant} />
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,13 +4,13 @@ import { formatPubkey } from '@/lib/pubkey'
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { TAccountPointer, TSignerType } from '@/types'
|
import { TAccountPointer, TSignerType } from '@/types'
|
||||||
import { Loader, Trash2 } from 'lucide-react'
|
import { Loader } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { SimpleUserAvatar } from '../UserAvatar'
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
import { SimpleUsername } from '../Username'
|
import { SimpleUsername } from '../Username'
|
||||||
|
|
||||||
export default function AccountList({ afterSwitch }: { afterSwitch: () => void }) {
|
export default function AccountList({ afterSwitch }: { afterSwitch: () => void }) {
|
||||||
const { accounts, account, switchAccount, removeAccount } = useNostr()
|
const { accounts, account, switchAccount } = useNostr()
|
||||||
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
|
const [switchingAccount, setSwitchingAccount] = useState<TAccountPointer | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -20,9 +20,7 @@ export default function AccountList({ afterSwitch }: { afterSwitch: () => void }
|
|||||||
key={`${act.pubkey}-${act.signerType}`}
|
key={`${act.pubkey}-${act.signerType}`}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative rounded-lg',
|
'relative rounded-lg',
|
||||||
isSameAccount(act, account)
|
isSameAccount(act, account) ? 'border border-primary' : 'clickable'
|
||||||
? 'border border-primary'
|
|
||||||
: 'cursor-pointer hover:bg-muted/60'
|
|
||||||
)}
|
)}
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
if (isSameAccount(act, account)) return
|
if (isSameAccount(act, account)) return
|
||||||
@@ -44,14 +42,6 @@ export default function AccountList({ afterSwitch }: { afterSwitch: () => void }
|
|||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2 items-center">
|
<div className="flex gap-2 items-center">
|
||||||
<SignerTypeBadge signerType={act.signerType} />
|
<SignerTypeBadge signerType={act.signerType} />
|
||||||
<Trash2
|
|
||||||
size={16}
|
|
||||||
className="text-muted-foreground hover:text-destructive cursor-pointer"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
removeAccount(act)
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{switchingAccount && isSameAccount(act, switchingAccount) && (
|
{switchingAccount && isSameAccount(act, switchingAccount) && (
|
||||||
|
|||||||
@@ -8,15 +8,15 @@ import AccountList from '../AccountList'
|
|||||||
import BunkerLogin from './BunkerLogin'
|
import BunkerLogin from './BunkerLogin'
|
||||||
import PrivateKeyLogin from './NsecLogin'
|
import PrivateKeyLogin from './NsecLogin'
|
||||||
|
|
||||||
export default function AccountManager({ close }: { close: () => void }) {
|
export default function AccountManager({ close }: { close?: () => void }) {
|
||||||
const [loginMethod, setLoginMethod] = useState<TSignerType | null>(null)
|
const [loginMethod, setLoginMethod] = useState<TSignerType | null>(null)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{loginMethod === 'nsec' ? (
|
{loginMethod === 'nsec' ? (
|
||||||
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} />
|
<PrivateKeyLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
|
||||||
) : loginMethod === 'bunker' ? (
|
) : loginMethod === 'bunker' ? (
|
||||||
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close()} />
|
<BunkerLogin back={() => setLoginMethod(null)} onLoginSuccess={() => close?.()} />
|
||||||
) : (
|
) : (
|
||||||
<AccountManagerNav setLoginMethod={setLoginMethod} close={close} />
|
<AccountManagerNav setLoginMethod={setLoginMethod} close={close} />
|
||||||
)}
|
)}
|
||||||
@@ -29,18 +29,18 @@ function AccountManagerNav({
|
|||||||
close
|
close
|
||||||
}: {
|
}: {
|
||||||
setLoginMethod: (method: TSignerType) => void
|
setLoginMethod: (method: TSignerType) => void
|
||||||
close: () => void
|
close?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { nip07Login, accounts } = useNostr()
|
const { nip07Login, accounts } = useNostr()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div onClick={(e) => e.stopPropagation()} className="flex flex-col gap-4">
|
||||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||||
{t('Add an Account')}
|
{t('Add an Account')}
|
||||||
</div>
|
</div>
|
||||||
{!!window.nostr && (
|
{!!window.nostr && (
|
||||||
<Button onClick={() => nip07Login().then(() => close())} className="w-full">
|
<Button onClick={() => nip07Login().then(() => close?.())} className="w-full">
|
||||||
{t('Login with Browser Extension')}
|
{t('Login with Browser Extension')}
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
@@ -56,9 +56,9 @@ function AccountManagerNav({
|
|||||||
<div className="text-center text-muted-foreground text-sm font-semibold">
|
<div className="text-center text-muted-foreground text-sm font-semibold">
|
||||||
{t('Logged in Accounts')}
|
{t('Logged in Accounts')}
|
||||||
</div>
|
</div>
|
||||||
<AccountList afterSwitch={() => close()} />
|
<AccountList afterSwitch={() => close?.()} />
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,10 +5,10 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
export default function BackButton({
|
export default function BackButton({
|
||||||
hide = false,
|
hide = false,
|
||||||
variant = 'titlebar'
|
children
|
||||||
}: {
|
}: {
|
||||||
hide?: boolean
|
hide?: boolean
|
||||||
variant?: 'titlebar' | 'small-screen-titlebar'
|
children?: React.ReactNode
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pop } = useSecondaryPage()
|
const { pop } = useSecondaryPage()
|
||||||
@@ -16,8 +16,15 @@ export default function BackButton({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!hide && (
|
{!hide && (
|
||||||
<Button variant={variant} size={variant} title={t('back')} onClick={() => pop()}>
|
<Button
|
||||||
|
className="flex gap-1 items-center w-fit max-w-full justify-start pl-2 pr-3"
|
||||||
|
variant="ghost"
|
||||||
|
size="titlebar-icon"
|
||||||
|
title={t('back')}
|
||||||
|
onClick={() => pop()}
|
||||||
|
>
|
||||||
<ChevronLeft />
|
<ChevronLeft />
|
||||||
|
<div className="truncate text-lg font-semibold">{children}</div>
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
29
src/components/BottomNavigationBar/AccountButton.tsx
Normal file
29
src/components/BottomNavigationBar/AccountButton.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { UserRound } from 'lucide-react'
|
||||||
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
|
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||||
|
|
||||||
|
export default function AccountButton() {
|
||||||
|
const { navigate, current } = usePrimaryPage()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomNavigationBarItem
|
||||||
|
onClick={() => {
|
||||||
|
navigate('me')
|
||||||
|
}}
|
||||||
|
active={current === 'me'}
|
||||||
|
>
|
||||||
|
{pubkey ? (
|
||||||
|
<SimpleUserAvatar
|
||||||
|
userId={pubkey}
|
||||||
|
size="small"
|
||||||
|
className={current === 'me' ? 'ring-primary ring-1' : ''}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<UserRound />
|
||||||
|
)}
|
||||||
|
</BottomNavigationBarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import { MouseEventHandler } from 'react'
|
||||||
|
|
||||||
|
export default function BottomNavigationBarItem({
|
||||||
|
children,
|
||||||
|
active = false,
|
||||||
|
onClick
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode
|
||||||
|
active?: boolean
|
||||||
|
onClick: MouseEventHandler
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'flex shadow-none items-center bg-transparent w-full h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-4 rounded-lg xl:justify-start text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4',
|
||||||
|
active && 'text-primary disabled:opacity-100'
|
||||||
|
)}
|
||||||
|
disabled={active}
|
||||||
|
variant="ghost"
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/components/BottomNavigationBar/HomeButton.tsx
Normal file
13
src/components/BottomNavigationBar/HomeButton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { Home } from 'lucide-react'
|
||||||
|
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||||
|
|
||||||
|
export default function HomeButton() {
|
||||||
|
const { navigate, current } = usePrimaryPage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomNavigationBarItem active={current === 'home'} onClick={() => navigate('home')}>
|
||||||
|
<Home />
|
||||||
|
</BottomNavigationBarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
16
src/components/BottomNavigationBar/NotificationsButton.tsx
Normal file
16
src/components/BottomNavigationBar/NotificationsButton.tsx
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { Bell } from 'lucide-react'
|
||||||
|
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||||
|
|
||||||
|
export default function NotificationsButton() {
|
||||||
|
const { navigate, current } = usePrimaryPage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<BottomNavigationBarItem
|
||||||
|
active={current === 'notifications'}
|
||||||
|
onClick={() => navigate('notifications')}
|
||||||
|
>
|
||||||
|
<Bell />
|
||||||
|
</BottomNavigationBarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
22
src/components/BottomNavigationBar/PostButton.tsx
Normal file
22
src/components/BottomNavigationBar/PostButton.tsx
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
import PostEditor from '@/components/PostEditor'
|
||||||
|
import { PencilLine } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import BottomNavigationBarItem from './BottomNavigationBarItem'
|
||||||
|
|
||||||
|
export default function PostButton() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<BottomNavigationBarItem
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilLine />
|
||||||
|
</BottomNavigationBarItem>
|
||||||
|
<PostEditor open={open} setOpen={setOpen} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
25
src/components/BottomNavigationBar/index.tsx
Normal file
25
src/components/BottomNavigationBar/index.tsx
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import HomeButton from './HomeButton'
|
||||||
|
import NotificationsButton from './NotificationsButton'
|
||||||
|
import PostButton from './PostButton'
|
||||||
|
import AccountButton from './AccountButton'
|
||||||
|
|
||||||
|
export default function BottomNavigationBar({ visible = true }: { visible?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'fixed bottom-0 w-full z-20 bg-background/90 backdrop-blur-xl duration-700 transition-transform flex items-center justify-around [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
visible ? '' : 'translate-y-full'
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
height: 'calc(3rem + env(safe-area-inset-bottom))',
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<HomeButton />
|
||||||
|
<PostButton />
|
||||||
|
<NotificationsButton />
|
||||||
|
<AccountButton />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
103
src/components/FeedSwitcher/index.tsx
Normal file
103
src/components/FeedSwitcher/index.tsx
Normal file
@@ -0,0 +1,103 @@
|
|||||||
|
import { toRelaySettings } from '@/lib/link'
|
||||||
|
import { simplifyUrl } from '@/lib/url'
|
||||||
|
import { SecondaryPageLink } from '@/PageManager'
|
||||||
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||||
|
import { Circle, CircleCheck } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function FeedSwitcher({ close }: { close?: () => void }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { feedType, setFeedType } = useFeed()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const { relayGroups, temporaryRelayUrls, switchRelayGroup } = useRelaySettings()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{pubkey && (
|
||||||
|
<FeedSwitcherItem
|
||||||
|
itemName={t('Following')}
|
||||||
|
isActive={feedType === 'following'}
|
||||||
|
onClick={() => {
|
||||||
|
setFeedType('following')
|
||||||
|
close?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex justify-between px-2">
|
||||||
|
<div className="text-muted-foreground text-sm font-semibold">{t('relay feeds')}</div>
|
||||||
|
<SecondaryPageLink
|
||||||
|
to={toRelaySettings()}
|
||||||
|
className="text-highlight text-sm font-semibold"
|
||||||
|
onClick={() => close?.()}
|
||||||
|
>
|
||||||
|
{t('edit')}
|
||||||
|
</SecondaryPageLink>
|
||||||
|
</div>
|
||||||
|
{temporaryRelayUrls.length > 0 && (
|
||||||
|
<FeedSwitcherItem
|
||||||
|
key="temporary"
|
||||||
|
itemName={
|
||||||
|
temporaryRelayUrls.length === 1 ? simplifyUrl(temporaryRelayUrls[0]) : t('Temporary')
|
||||||
|
}
|
||||||
|
isActive={feedType === 'relays'}
|
||||||
|
temporary
|
||||||
|
onClick={() => {
|
||||||
|
setFeedType('relays')
|
||||||
|
close?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{relayGroups
|
||||||
|
.filter((group) => group.relayUrls.length > 0)
|
||||||
|
.map((group) => (
|
||||||
|
<FeedSwitcherItem
|
||||||
|
key={group.groupName}
|
||||||
|
itemName={
|
||||||
|
group.relayUrls.length === 1 ? simplifyUrl(group.relayUrls[0]) : group.groupName
|
||||||
|
}
|
||||||
|
isActive={feedType === 'relays' && group.isActive && temporaryRelayUrls.length === 0}
|
||||||
|
onClick={() => {
|
||||||
|
switchRelayGroup(group.groupName)
|
||||||
|
close?.()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedSwitcherItem({
|
||||||
|
itemName,
|
||||||
|
isActive,
|
||||||
|
temporary = false,
|
||||||
|
onClick
|
||||||
|
}: {
|
||||||
|
itemName: string
|
||||||
|
isActive: boolean
|
||||||
|
temporary?: boolean
|
||||||
|
onClick: () => void
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={`w-full border rounded-lg p-4 ${isActive ? 'border-highlight bg-highlight/5' : 'clickable'} ${temporary ? 'border-dashed' : ''}`}
|
||||||
|
onClick={onClick}
|
||||||
|
>
|
||||||
|
<div className="flex space-x-2 items-center">
|
||||||
|
<FeedToggle isActive={isActive} />
|
||||||
|
<div className="font-semibold">{itemName}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function FeedToggle({ isActive }: { isActive: boolean }) {
|
||||||
|
return isActive ? (
|
||||||
|
<CircleCheck size={18} className="text-highlight shrink-0" />
|
||||||
|
) : (
|
||||||
|
<Circle size={18} className="text-muted-foreground shrink-0" />
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -10,11 +10,11 @@ export default function FollowButton({ pubkey }: { pubkey: string }) {
|
|||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { toast } = useToast()
|
const { toast } = useToast()
|
||||||
const { pubkey: accountPubkey, checkLogin } = useNostr()
|
const { pubkey: accountPubkey, checkLogin } = useNostr()
|
||||||
const { followListEvent, followings, isReady, follow, unfollow } = useFollowList()
|
const { followListEvent, followings, isFetching, follow, unfollow } = useFollowList()
|
||||||
const [updating, setUpdating] = useState(false)
|
const [updating, setUpdating] = useState(false)
|
||||||
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
|
const isFollowing = useMemo(() => followings.includes(pubkey), [followings, pubkey])
|
||||||
|
|
||||||
if (!accountPubkey || !isReady || (pubkey && pubkey === accountPubkey)) return null
|
if (!accountPubkey || isFetching || (pubkey && pubkey === accountPubkey)) return null
|
||||||
|
|
||||||
const handleFollow = async (e: React.MouseEvent) => {
|
const handleFollow = async (e: React.MouseEvent) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
|
|||||||
@@ -5,6 +5,8 @@ import {
|
|||||||
DialogHeader,
|
DialogHeader,
|
||||||
DialogTitle
|
DialogTitle
|
||||||
} from '@/components/ui/dialog'
|
} from '@/components/ui/dialog'
|
||||||
|
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { Dispatch } from 'react'
|
import { Dispatch } from 'react'
|
||||||
import AccountManager from '../AccountManager'
|
import AccountManager from '../AccountManager'
|
||||||
|
|
||||||
@@ -15,6 +17,25 @@ export default function LoginDialog({
|
|||||||
open: boolean
|
open: boolean
|
||||||
setOpen: Dispatch<boolean>
|
setOpen: Dispatch<boolean>
|
||||||
}) {
|
}) {
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerContent
|
||||||
|
className="max-h-[90vh]"
|
||||||
|
style={{
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex flex-col p-4 gap-4 overflow-auto">
|
||||||
|
<AccountManager close={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogContent className="w-96">
|
<DialogContent className="w-96">
|
||||||
|
|||||||
88
src/components/LogoutDialog/index.tsx
Normal file
88
src/components/LogoutDialog/index.tsx
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle
|
||||||
|
} from '@/components/ui/drawer'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function LogoutDialog({
|
||||||
|
open = false,
|
||||||
|
setOpen
|
||||||
|
}: {
|
||||||
|
open: boolean
|
||||||
|
setOpen: (open: boolean) => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
const { account, removeAccount } = useNostr()
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
return (
|
||||||
|
<Drawer defaultOpen={false} open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerContent>
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle>{t('Logout')}</DrawerTitle>
|
||||||
|
<DrawerDescription>{t('Are you sure you want to logout?')}</DrawerDescription>
|
||||||
|
</DrawerHeader>
|
||||||
|
<DrawerFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)} className="w-full">
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (account) {
|
||||||
|
setOpen(false)
|
||||||
|
removeAccount(account)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
className="w-full"
|
||||||
|
>
|
||||||
|
{t('Logout')}
|
||||||
|
</Button>
|
||||||
|
</DrawerFooter>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AlertDialog defaultOpen={false} open={open} onOpenChange={setOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('Logout')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{t('Are you sure you want to logout?')}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
variant="destructive"
|
||||||
|
onClick={() => {
|
||||||
|
if (account) {
|
||||||
|
removeAccount(account)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Logout')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { getParentEventId, getRootEventId } from '@/lib/event'
|
import { getParentEventId, getRootEventId } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
@@ -32,11 +33,10 @@ export default function ShortTextNoteCard({
|
|||||||
push(toNote(event))
|
push(toNote(event))
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<RepostDescription reposter={reposter} className="max-sm:hidden pl-4" />
|
|
||||||
<div
|
<div
|
||||||
className={`hover:bg-muted/50 text-left cursor-pointer ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3 sm:py-4 sm:border sm:rounded-lg max-sm:border-b'}`}
|
className={`clickable text-left ${embedded ? 'p-2 sm:p-3 border rounded-lg' : 'px-4 py-3'}`}
|
||||||
>
|
>
|
||||||
<RepostDescription reposter={reposter} className="sm:hidden" />
|
<RepostDescription reposter={reposter} />
|
||||||
<Note
|
<Note
|
||||||
size={embedded ? 'small' : 'normal'}
|
size={embedded ? 'small' : 'normal'}
|
||||||
event={event}
|
event={event}
|
||||||
@@ -44,6 +44,7 @@ export default function ShortTextNoteCard({
|
|||||||
hideStats={embedded}
|
hideStats={embedded}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
{!embedded && <Separator />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,8 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { useFetchRelayInfos } from '@/hooks'
|
import { useFetchRelayInfos } from '@/hooks'
|
||||||
import { isReplyNoteEvent } from '@/lib/event'
|
import { isReplyNoteEvent } from '@/lib/event'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, Filter, kinds } from 'nostr-tools'
|
import { Event, Filter, kinds } from 'nostr-tools'
|
||||||
@@ -33,7 +31,7 @@ export default function NoteList({
|
|||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
const [hasMore, setHasMore] = useState<boolean>(true)
|
const [hasMore, setHasMore] = useState<boolean>(true)
|
||||||
const [initialized, setInitialized] = useState(false)
|
const [refreshing, setRefreshing] = useState(true)
|
||||||
const [displayReplies, setDisplayReplies] = useState(false)
|
const [displayReplies, setDisplayReplies] = useState(false)
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
const noteFilter = useMemo(() => {
|
const noteFilter = useMemo(() => {
|
||||||
@@ -48,16 +46,20 @@ export default function NoteList({
|
|||||||
if (isFetchingRelayInfo || relayUrls.length === 0) return
|
if (isFetchingRelayInfo || relayUrls.length === 0) return
|
||||||
|
|
||||||
async function init() {
|
async function init() {
|
||||||
setInitialized(false)
|
setRefreshing(true)
|
||||||
setEvents([])
|
setEvents([])
|
||||||
setNewEvents([])
|
setNewEvents([])
|
||||||
setHasMore(true)
|
setHasMore(true)
|
||||||
|
|
||||||
|
let eventCount = 0
|
||||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||||
[...relayUrls],
|
[...relayUrls],
|
||||||
noteFilter,
|
noteFilter,
|
||||||
{
|
{
|
||||||
onEvents: (events, eosed) => {
|
onEvents: (events, eosed) => {
|
||||||
|
if (eventCount > events.length) return
|
||||||
|
eventCount = events.length
|
||||||
|
|
||||||
if (events.length > 0) {
|
if (events.length > 0) {
|
||||||
setEvents(events)
|
setEvents(events)
|
||||||
}
|
}
|
||||||
@@ -65,7 +67,7 @@ export default function NoteList({
|
|||||||
setHasMore(false)
|
setHasMore(false)
|
||||||
}
|
}
|
||||||
if (eosed) {
|
if (eosed) {
|
||||||
setInitialized(true)
|
setRefreshing(false)
|
||||||
setHasMore(events.length > 0)
|
setHasMore(events.length > 0)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@@ -100,7 +102,7 @@ export default function NoteList({
|
|||||||
])
|
])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized) return
|
if (refreshing) return
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
root: null,
|
root: null,
|
||||||
@@ -125,10 +127,10 @@ export default function NoteList({
|
|||||||
observerInstance.unobserve(currentBottomRef)
|
observerInstance.unobserve(currentBottomRef)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [initialized, hasMore, events, timelineKey])
|
}, [refreshing, hasMore, events, timelineKey])
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (!timelineKey) return
|
if (!timelineKey || refreshing) return
|
||||||
|
|
||||||
const newEvents = await client.loadMoreTimeline(
|
const newEvents = await client.loadMoreTimeline(
|
||||||
timelineKey,
|
timelineKey,
|
||||||
@@ -148,36 +150,35 @@ export default function NoteList({
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={cn('space-y-2 sm:space-y-4', className)}>
|
<div className={cn('space-y-2 sm:space-y-2', className)}>
|
||||||
<DisplayRepliesSwitch displayReplies={displayReplies} setDisplayReplies={setDisplayReplies} />
|
<DisplayRepliesSwitch displayReplies={displayReplies} setDisplayReplies={setDisplayReplies} />
|
||||||
<PullToRefresh
|
<div className="space-y-2 sm:space-y-2">
|
||||||
onRefresh={async () =>
|
{newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && (
|
||||||
new Promise((resolve) => {
|
<div className="flex justify-center w-full max-sm:mt-2">
|
||||||
setRefreshCount((pre) => pre + 1)
|
<Button size="lg" onClick={showNewEvents}>
|
||||||
setTimeout(resolve, 1000)
|
{t('show new notes')}
|
||||||
})
|
</Button>
|
||||||
}
|
</div>
|
||||||
pullingContent=""
|
)}
|
||||||
>
|
|
||||||
<div className="space-y-2 sm:space-y-4">
|
<PullToRefresh
|
||||||
{newEvents.filter((event) => displayReplies || !isReplyNoteEvent(event)).length > 0 && (
|
onRefresh={async () => {
|
||||||
<div className="flex justify-center w-full max-sm:mt-2">
|
setRefreshCount((count) => count + 1)
|
||||||
<Button size="lg" onClick={showNewEvents}>
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
{t('show new notes')}
|
}}
|
||||||
</Button>
|
pullingContent=""
|
||||||
</div>
|
>
|
||||||
)}
|
<div>
|
||||||
<div className="flex flex-col sm:gap-4">
|
|
||||||
{events
|
{events
|
||||||
.filter((event) => displayReplies || !isReplyNoteEvent(event))
|
.filter((event) => displayReplies || !isReplyNoteEvent(event))
|
||||||
.map((event) => (
|
.map((event) => (
|
||||||
<NoteCard key={event.id} className="w-full" event={event} />
|
<NoteCard key={event.id} className="w-full" event={event} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</PullToRefresh>
|
||||||
</PullToRefresh>
|
</div>
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
{hasMore ? (
|
{hasMore || refreshing ? (
|
||||||
<div ref={bottomRef}>{t('loading...')}</div>
|
<div ref={bottomRef}>{t('loading...')}</div>
|
||||||
) : events.length ? (
|
) : events.length ? (
|
||||||
t('no more notes')
|
t('no more notes')
|
||||||
@@ -201,38 +202,28 @@ function DisplayRepliesSwitch({
|
|||||||
setDisplayReplies: (value: boolean) => void
|
setDisplayReplies: (value: boolean) => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
|
||||||
|
|
||||||
if (isSmallScreen) {
|
|
||||||
return (
|
|
||||||
<div>
|
|
||||||
<div className="flex">
|
|
||||||
<div
|
|
||||||
className={`w-1/2 text-center py-2 font-semibold hover:bg-muted cursor-pointer rounded-lg ${displayReplies ? 'text-muted-foreground' : ''}`}
|
|
||||||
onClick={() => setDisplayReplies(false)}
|
|
||||||
>
|
|
||||||
{t('Notes')}
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`w-1/2 text-center py-2 font-semibold hover:bg-muted cursor-pointer rounded-lg ${displayReplies ? '' : 'text-muted-foreground'}`}
|
|
||||||
onClick={() => setDisplayReplies(true)}
|
|
||||||
>
|
|
||||||
{t('Notes & Replies')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={`w-1/2 px-4 transition-transform duration-500 ${displayReplies ? 'translate-x-full' : ''}`}
|
|
||||||
>
|
|
||||||
<div className="w-full h-1 bg-primary rounded-full" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex justify-end gap-2">
|
<div>
|
||||||
<div>{t('Display replies')}</div>
|
<div className="flex">
|
||||||
<Switch checked={displayReplies} onCheckedChange={setDisplayReplies} />
|
<div
|
||||||
|
className={`w-1/2 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${displayReplies ? 'text-muted-foreground' : ''}`}
|
||||||
|
onClick={() => setDisplayReplies(false)}
|
||||||
|
>
|
||||||
|
{t('Notes')}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-1/2 text-center py-2 font-semibold clickable cursor-pointer rounded-lg ${displayReplies ? '' : 'text-muted-foreground'}`}
|
||||||
|
onClick={() => setDisplayReplies(true)}
|
||||||
|
>
|
||||||
|
{t('Notes & Replies')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={`w-1/2 px-4 sm:px-6 transition-transform duration-500 ${displayReplies ? 'translate-x-full' : ''}`}
|
||||||
|
>
|
||||||
|
<div className="w-full h-1 bg-primary rounded-full" />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,16 +1,14 @@
|
|||||||
import { useNostr } from '@/providers/NostrProvider'
|
|
||||||
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
import { useNoteStats } from '@/providers/NoteStatsProvider'
|
||||||
import { MessageCircle } from 'lucide-react'
|
import { MessageCircle } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import PostDialog from '../PostDialog'
|
|
||||||
import { formatCount } from './utils'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import PostEditor from '../PostEditor'
|
||||||
|
import { formatCount } from './utils'
|
||||||
|
|
||||||
export default function ReplyButton({ event }: { event: Event }) {
|
export default function ReplyButton({ event }: { event: Event }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { noteStatsMap } = useNoteStats()
|
const { noteStatsMap } = useNoteStats()
|
||||||
const { pubkey } = useNostr()
|
|
||||||
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
|
const { replyCount } = useMemo(() => noteStatsMap.get(event.id) ?? {}, [noteStatsMap, event.id])
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -18,7 +16,6 @@ export default function ReplyButton({ event }: { event: Event }) {
|
|||||||
<>
|
<>
|
||||||
<button
|
<button
|
||||||
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400"
|
className="flex gap-1 items-center text-muted-foreground enabled:hover:text-blue-400"
|
||||||
disabled={!pubkey}
|
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
setOpen(true)
|
setOpen(true)
|
||||||
@@ -28,7 +25,7 @@ export default function ReplyButton({ event }: { event: Event }) {
|
|||||||
<MessageCircle size={16} />
|
<MessageCircle size={16} />
|
||||||
<div className="text-sm">{formatCount(replyCount)}</div>
|
<div className="text-sm">{formatCount(replyCount)}</div>
|
||||||
</button>
|
</button>
|
||||||
<PostDialog parentEvent={event} open={open} setOpen={setOpen} />
|
<PostEditor parentEvent={event} open={open} setOpen={setOpen} />
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import client from '@/services/client.service'
|
|||||||
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
import { Loader, PencilLine, Repeat } from 'lucide-react'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
import PostDialog from '../PostDialog'
|
import PostEditor from '../PostEditor'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -111,7 +111,7 @@ export default function RepostButton({
|
|||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<PostDialog
|
<PostEditor
|
||||||
open={isPostDialogOpen}
|
open={isPostDialogOpen}
|
||||||
setOpen={setIsPostDialogOpen}
|
setOpen={setIsPostDialogOpen}
|
||||||
defaultContent={'\nnostr:' + getSharableEventId(event)}
|
defaultContent={'\nnostr:' + getSharableEventId(event)}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ export default function NoteStats({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className={cn('flex justify-between', className)}>
|
<div className={cn('flex justify-between', className)}>
|
||||||
<div className="flex gap-4 h-4 items-center">
|
<div className="flex gap-4 h-4 items-center" onClick={(e) => e.stopPropagation()}>
|
||||||
<ReplyButton event={event} />
|
<ReplyButton event={event} />
|
||||||
<RepostButton event={event} canFetch={fetchIfNotExisting} />
|
<RepostButton event={event} canFetch={fetchIfNotExisting} />
|
||||||
<LikeButton event={event} canFetch={fetchIfNotExisting} />
|
<LikeButton event={event} canFetch={fetchIfNotExisting} />
|
||||||
|
|||||||
@@ -1,39 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { toNotifications } from '@/lib/link'
|
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
|
||||||
import { Bell } from 'lucide-react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export default function NotificationButton({
|
|
||||||
variant = 'titlebar'
|
|
||||||
}: {
|
|
||||||
variant?: 'sidebar' | 'titlebar' | 'small-screen-titlebar'
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { push } = useSecondaryPage()
|
|
||||||
|
|
||||||
if (variant === 'sidebar') {
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant={variant}
|
|
||||||
size={variant}
|
|
||||||
title={t('notifications')}
|
|
||||||
onClick={() => push(toNotifications())}
|
|
||||||
>
|
|
||||||
<Bell />
|
|
||||||
{t('Notifications')}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
variant={variant}
|
|
||||||
size={variant}
|
|
||||||
title={t('notifications')}
|
|
||||||
onClick={() => push(toNotifications())}
|
|
||||||
>
|
|
||||||
<Bell />
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -9,18 +9,18 @@ import { Heart, MessageCircle, Repeat } from 'lucide-react'
|
|||||||
import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
|
import { Event, kinds, nip19, validateEvent } from 'nostr-tools'
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import PullToRefresh from 'react-simple-pull-to-refresh'
|
||||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import PullToRefresh from 'react-simple-pull-to-refresh'
|
|
||||||
|
|
||||||
const LIMIT = 50
|
const LIMIT = 50
|
||||||
|
|
||||||
export default function NotificationList() {
|
export default function NotificationList() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
|
||||||
const [refreshCount, setRefreshCount] = useState(0)
|
const [refreshCount, setRefreshCount] = useState(0)
|
||||||
const [initialized, setInitialized] = useState(false)
|
const [timelineKey, setTimelineKey] = useState<string | undefined>(undefined)
|
||||||
|
const [refreshing, setRefreshing] = useState(true)
|
||||||
const [notifications, setNotifications] = useState<Event[]>([])
|
const [notifications, setNotifications] = useState<Event[]>([])
|
||||||
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
const [until, setUntil] = useState<number | undefined>(dayjs().unix())
|
||||||
const bottomRef = useRef<HTMLDivElement | null>(null)
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
||||||
@@ -32,7 +32,9 @@ export default function NotificationList() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
setRefreshing(true)
|
||||||
const relayList = await client.fetchRelayList(pubkey)
|
const relayList = await client.fetchRelayList(pubkey)
|
||||||
|
let eventCount = 0
|
||||||
const { closer, timelineKey } = await client.subscribeTimeline(
|
const { closer, timelineKey } = await client.subscribeTimeline(
|
||||||
relayList.read.length >= 4
|
relayList.read.length >= 4
|
||||||
? relayList.read
|
? relayList.read
|
||||||
@@ -44,10 +46,12 @@ export default function NotificationList() {
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
onEvents: (events, eosed) => {
|
onEvents: (events, eosed) => {
|
||||||
setNotifications(events.filter((event) => event.pubkey !== pubkey))
|
if (eventCount > events.length) return
|
||||||
|
eventCount = events.length
|
||||||
setUntil(events.length >= LIMIT ? events[events.length - 1].created_at - 1 : undefined)
|
setUntil(events.length >= LIMIT ? events[events.length - 1].created_at - 1 : undefined)
|
||||||
|
setNotifications(events.filter((event) => event.pubkey !== pubkey))
|
||||||
if (eosed) {
|
if (eosed) {
|
||||||
setInitialized(true)
|
setRefreshing(false)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onNew: (event) => {
|
onNew: (event) => {
|
||||||
@@ -67,7 +71,7 @@ export default function NotificationList() {
|
|||||||
}, [pubkey, refreshCount])
|
}, [pubkey, refreshCount])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!initialized) return
|
if (refreshing) return
|
||||||
|
|
||||||
const options = {
|
const options = {
|
||||||
root: null,
|
root: null,
|
||||||
@@ -92,10 +96,10 @@ export default function NotificationList() {
|
|||||||
observerInstance.unobserve(currentBottomRef)
|
observerInstance.unobserve(currentBottomRef)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [until, initialized, timelineKey])
|
}, [until, refreshing, timelineKey])
|
||||||
|
|
||||||
const loadMore = async () => {
|
const loadMore = async () => {
|
||||||
if (!pubkey || !timelineKey || !until) return
|
if (!pubkey || !timelineKey || !until || refreshing) return
|
||||||
const notifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
const notifications = await client.loadMoreTimeline(timelineKey, until, LIMIT)
|
||||||
if (notifications.length === 0) {
|
if (notifications.length === 0) {
|
||||||
setUntil(undefined)
|
setUntil(undefined)
|
||||||
@@ -111,12 +115,10 @@ export default function NotificationList() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PullToRefresh
|
<PullToRefresh
|
||||||
onRefresh={async () =>
|
onRefresh={async () => {
|
||||||
new Promise((resolve) => {
|
setRefreshCount((count) => count + 1)
|
||||||
setRefreshCount((pre) => pre + 1)
|
await new Promise((resolve) => setTimeout(resolve, 1000))
|
||||||
setTimeout(resolve, 1000)
|
}}
|
||||||
})
|
|
||||||
}
|
|
||||||
pullingContent=""
|
pullingContent=""
|
||||||
>
|
>
|
||||||
<div>
|
<div>
|
||||||
@@ -124,7 +126,11 @@ export default function NotificationList() {
|
|||||||
<NotificationItem key={notification.id} notification={notification} />
|
<NotificationItem key={notification.id} notification={notification} />
|
||||||
))}
|
))}
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
{until ? <div ref={bottomRef}>{t('loading...')}</div> : t('no more notifications')}
|
{until || refreshing ? (
|
||||||
|
<div ref={bottomRef}>{t('loading...')}</div>
|
||||||
|
) : (
|
||||||
|
t('no more notifications')
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</PullToRefresh>
|
</PullToRefresh>
|
||||||
|
|||||||
@@ -1,32 +0,0 @@
|
|||||||
import PostDialog from '@/components/PostDialog'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { PencilLine } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export default function PostButton({
|
|
||||||
variant = 'titlebar'
|
|
||||||
}: {
|
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button
|
|
||||||
variant={variant}
|
|
||||||
size={variant}
|
|
||||||
title={t('New post')}
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setOpen(true)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<PencilLine />
|
|
||||||
{variant === 'sidebar' && <div>{t('Post')}</div>}
|
|
||||||
</Button>
|
|
||||||
<PostDialog open={open} setOpen={setOpen} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -1,187 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import {
|
|
||||||
Dialog,
|
|
||||||
DialogContent,
|
|
||||||
DialogDescription,
|
|
||||||
DialogHeader,
|
|
||||||
DialogTitle
|
|
||||||
} from '@/components/ui/dialog'
|
|
||||||
import { Label } from '@/components/ui/label'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import { Switch } from '@/components/ui/switch'
|
|
||||||
import { Textarea } from '@/components/ui/textarea'
|
|
||||||
import { StorageKey } from '@/constants'
|
|
||||||
import { useToast } from '@/hooks/use-toast'
|
|
||||||
import { createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
|
||||||
import client from '@/services/client.service'
|
|
||||||
import { ChevronDown, LoaderCircle } from 'lucide-react'
|
|
||||||
import { Event } from 'nostr-tools'
|
|
||||||
import { Dispatch, useEffect, useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import UserAvatar from '../UserAvatar'
|
|
||||||
import Mentions from './Metions'
|
|
||||||
import Preview from './Preview'
|
|
||||||
import Uploader from './Uploader'
|
|
||||||
|
|
||||||
export default function PostDialog({
|
|
||||||
defaultContent = '',
|
|
||||||
parentEvent,
|
|
||||||
open,
|
|
||||||
setOpen
|
|
||||||
}: {
|
|
||||||
defaultContent?: string
|
|
||||||
parentEvent?: Event
|
|
||||||
open: boolean
|
|
||||||
setOpen: Dispatch<boolean>
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { toast } = useToast()
|
|
||||||
const { publish, checkLogin } = useNostr()
|
|
||||||
const [content, setContent] = useState(defaultContent)
|
|
||||||
const [posting, setPosting] = useState(false)
|
|
||||||
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
|
||||||
const [addClientTag, setAddClientTag] = useState(false)
|
|
||||||
const canPost = !!content && !posting
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
|
|
||||||
}, [])
|
|
||||||
|
|
||||||
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
||||||
setContent(e.target.value)
|
|
||||||
}
|
|
||||||
|
|
||||||
const post = async (e: React.MouseEvent) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
checkLogin(async () => {
|
|
||||||
if (!canPost) {
|
|
||||||
setOpen(false)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
setPosting(true)
|
|
||||||
try {
|
|
||||||
const additionalRelayUrls: string[] = []
|
|
||||||
if (parentEvent) {
|
|
||||||
const relayList = await client.fetchRelayList(parentEvent.pubkey)
|
|
||||||
additionalRelayUrls.push(...relayList.read.slice(0, 5))
|
|
||||||
}
|
|
||||||
const draftEvent = await createShortTextNoteDraftEvent(content, {
|
|
||||||
parentEvent,
|
|
||||||
addClientTag
|
|
||||||
})
|
|
||||||
await publish(draftEvent, additionalRelayUrls)
|
|
||||||
setContent('')
|
|
||||||
setOpen(false)
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof AggregateError) {
|
|
||||||
error.errors.forEach((e) =>
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: t('Failed to post'),
|
|
||||||
description: e.message
|
|
||||||
})
|
|
||||||
)
|
|
||||||
} else if (error instanceof Error) {
|
|
||||||
toast({
|
|
||||||
variant: 'destructive',
|
|
||||||
title: t('Failed to post'),
|
|
||||||
description: error.message
|
|
||||||
})
|
|
||||||
}
|
|
||||||
console.error(error)
|
|
||||||
return
|
|
||||||
} finally {
|
|
||||||
setPosting(false)
|
|
||||||
}
|
|
||||||
toast({
|
|
||||||
title: t('Post successful'),
|
|
||||||
description: t('Your post has been published')
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
const onAddClientTagChange = (checked: boolean) => {
|
|
||||||
setAddClientTag(checked)
|
|
||||||
window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString())
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Dialog open={open} onOpenChange={setOpen}>
|
|
||||||
<DialogContent className="p-0" withoutClose>
|
|
||||||
<ScrollArea className="px-4 h-full max-h-screen">
|
|
||||||
<div className="space-y-4 px-2 py-6">
|
|
||||||
<DialogHeader>
|
|
||||||
<DialogTitle>
|
|
||||||
{parentEvent ? (
|
|
||||||
<div className="flex gap-2 items-center max-w-full">
|
|
||||||
<div className="shrink-0">{t('Reply to')}</div>
|
|
||||||
<UserAvatar userId={parentEvent.pubkey} size="tiny" />
|
|
||||||
<div className="truncate">{parentEvent.content}</div>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
t('New post')
|
|
||||||
)}
|
|
||||||
</DialogTitle>
|
|
||||||
<DialogDescription className="hidden" />
|
|
||||||
</DialogHeader>
|
|
||||||
<Textarea
|
|
||||||
className="h-32"
|
|
||||||
onChange={handleTextareaChange}
|
|
||||||
value={content}
|
|
||||||
placeholder={t('Write something...')}
|
|
||||||
/>
|
|
||||||
{content && <Preview content={content} />}
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Uploader setContent={setContent} />
|
|
||||||
<Button
|
|
||||||
variant="link"
|
|
||||||
className="text-foreground gap-0 px-0"
|
|
||||||
onClick={() => setShowMoreOptions((pre) => !pre)}
|
|
||||||
>
|
|
||||||
{t('More options')}
|
|
||||||
<ChevronDown
|
|
||||||
className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`}
|
|
||||||
/>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<Mentions content={content} parentEvent={parentEvent} />
|
|
||||||
<Button
|
|
||||||
variant="secondary"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.stopPropagation()
|
|
||||||
setOpen(false)
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t('Cancel')}
|
|
||||||
</Button>
|
|
||||||
<Button type="submit" disabled={!canPost} onClick={post}>
|
|
||||||
{posting && <LoaderCircle className="animate-spin" />}
|
|
||||||
{parentEvent ? t('Reply') : t('Post')}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{showMoreOptions && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<div className="flex items-center space-x-2">
|
|
||||||
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label>
|
|
||||||
<Switch
|
|
||||||
id="add-client-tag"
|
|
||||||
checked={addClientTag}
|
|
||||||
onCheckedChange={onAddClientTagChange}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="text-muted-foreground text-xs">
|
|
||||||
{t('Show others this was sent via Jumble')}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</DialogContent>
|
|
||||||
</Dialog>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
174
src/components/PostEditor/PostContent.tsx
Normal file
174
src/components/PostEditor/PostContent.tsx
Normal file
@@ -0,0 +1,174 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Textarea } from '@/components/ui/textarea'
|
||||||
|
import { StorageKey } from '@/constants'
|
||||||
|
import { useToast } from '@/hooks/use-toast'
|
||||||
|
import { createShortTextNoteDraftEvent } from '@/lib/draft-event'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import { ChevronDown, LoaderCircle } from 'lucide-react'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import Mentions from './Mentions'
|
||||||
|
import Preview from './Preview'
|
||||||
|
import Uploader from './Uploader'
|
||||||
|
|
||||||
|
export default function PostContent({
|
||||||
|
defaultContent = '',
|
||||||
|
parentEvent,
|
||||||
|
close
|
||||||
|
}: {
|
||||||
|
defaultContent?: string
|
||||||
|
parentEvent?: Event
|
||||||
|
close: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { toast } = useToast()
|
||||||
|
const { publish, checkLogin } = useNostr()
|
||||||
|
const [content, setContent] = useState(defaultContent)
|
||||||
|
const [posting, setPosting] = useState(false)
|
||||||
|
const [showMoreOptions, setShowMoreOptions] = useState(false)
|
||||||
|
const [addClientTag, setAddClientTag] = useState(false)
|
||||||
|
const canPost = !!content && !posting
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setAddClientTag(window.localStorage.getItem(StorageKey.ADD_CLIENT_TAG) === 'true')
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleTextareaChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
||||||
|
setContent(e.target.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const post = async (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
checkLogin(async () => {
|
||||||
|
if (!canPost) {
|
||||||
|
close()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
setPosting(true)
|
||||||
|
try {
|
||||||
|
const additionalRelayUrls: string[] = []
|
||||||
|
if (parentEvent) {
|
||||||
|
const relayList = await client.fetchRelayList(parentEvent.pubkey)
|
||||||
|
additionalRelayUrls.push(...relayList.read.slice(0, 5))
|
||||||
|
}
|
||||||
|
const draftEvent = await createShortTextNoteDraftEvent(content, {
|
||||||
|
parentEvent,
|
||||||
|
addClientTag
|
||||||
|
})
|
||||||
|
await publish(draftEvent, additionalRelayUrls)
|
||||||
|
setContent('')
|
||||||
|
close()
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof AggregateError) {
|
||||||
|
error.errors.forEach((e) =>
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('Failed to post'),
|
||||||
|
description: e.message
|
||||||
|
})
|
||||||
|
)
|
||||||
|
} else if (error instanceof Error) {
|
||||||
|
toast({
|
||||||
|
variant: 'destructive',
|
||||||
|
title: t('Failed to post'),
|
||||||
|
description: error.message
|
||||||
|
})
|
||||||
|
}
|
||||||
|
console.error(error)
|
||||||
|
return
|
||||||
|
} finally {
|
||||||
|
setPosting(false)
|
||||||
|
}
|
||||||
|
toast({
|
||||||
|
title: t('Post successful'),
|
||||||
|
description: t('Your post has been published')
|
||||||
|
})
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const onAddClientTagChange = (checked: boolean) => {
|
||||||
|
setAddClientTag(checked)
|
||||||
|
window.localStorage.setItem(StorageKey.ADD_CLIENT_TAG, checked.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Textarea
|
||||||
|
className="h-32"
|
||||||
|
onChange={handleTextareaChange}
|
||||||
|
value={content}
|
||||||
|
placeholder={t('Write something...')}
|
||||||
|
/>
|
||||||
|
{content && <Preview content={content} />}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Uploader setContent={setContent} />
|
||||||
|
<Button
|
||||||
|
variant="link"
|
||||||
|
className="text-foreground gap-0 px-0"
|
||||||
|
onClick={() => setShowMoreOptions((pre) => !pre)}
|
||||||
|
>
|
||||||
|
{t('More options')}
|
||||||
|
<ChevronDown
|
||||||
|
className={`transition-transform ${showMoreOptions ? 'rotate-180' : ''}`}
|
||||||
|
/>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<Mentions content={content} parentEvent={parentEvent} />
|
||||||
|
<div className="flex gap-2 items-center max-sm:hidden">
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button type="submit" disabled={!canPost} onClick={post}>
|
||||||
|
{posting && <LoaderCircle className="animate-spin" />}
|
||||||
|
{parentEvent ? t('Reply') : t('Post')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{showMoreOptions && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<Label htmlFor="add-client-tag">{t('Add client tag')}</Label>
|
||||||
|
<Switch
|
||||||
|
id="add-client-tag"
|
||||||
|
checked={addClientTag}
|
||||||
|
onCheckedChange={onAddClientTagChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="text-muted-foreground text-xs">
|
||||||
|
{t('Show others this was sent via Jumble')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2 items-center justify-around sm:hidden">
|
||||||
|
<Button
|
||||||
|
className="w-full"
|
||||||
|
variant="secondary"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
close()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button className="w-full" type="submit" disabled={!canPost} onClick={post}>
|
||||||
|
{posting && <LoaderCircle className="animate-spin" />}
|
||||||
|
{parentEvent ? t('Reply') : t('Post')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/components/PostEditor/Title.tsx
Normal file
17
src/components/PostEditor/Title.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
|
|
||||||
|
export default function Title({ parentEvent }: { parentEvent?: Event }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return parentEvent ? (
|
||||||
|
<div className="flex gap-2 items-center w-full">
|
||||||
|
<div className="shrink-0">{t('Reply to')}</div>
|
||||||
|
<SimpleUserAvatar userId={parentEvent.pubkey} size="tiny" />
|
||||||
|
<div className="flex-1 w-0 truncate">{parentEvent.content}</div>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
t('New post')
|
||||||
|
)
|
||||||
|
}
|
||||||
78
src/components/PostEditor/index.tsx
Normal file
78
src/components/PostEditor/index.tsx
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
Drawer,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerDescription,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerTitle
|
||||||
|
} from '@/components/ui/drawer'
|
||||||
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import { Event } from 'nostr-tools'
|
||||||
|
import { Dispatch } from 'react'
|
||||||
|
import PostContent from './PostContent'
|
||||||
|
import Title from './Title'
|
||||||
|
|
||||||
|
export default function PostEditor({
|
||||||
|
defaultContent = '',
|
||||||
|
parentEvent,
|
||||||
|
open,
|
||||||
|
setOpen
|
||||||
|
}: {
|
||||||
|
defaultContent?: string
|
||||||
|
parentEvent?: Event
|
||||||
|
open: boolean
|
||||||
|
setOpen: Dispatch<boolean>
|
||||||
|
}) {
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
return (
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerContent className="h-full">
|
||||||
|
<DrawerHeader>
|
||||||
|
<DrawerTitle className="text-start">
|
||||||
|
<Title parentEvent={parentEvent} />
|
||||||
|
</DrawerTitle>
|
||||||
|
<DrawerDescription className="hidden" />
|
||||||
|
</DrawerHeader>
|
||||||
|
<div className="overflow-auto py-2 px-4">
|
||||||
|
<PostContent
|
||||||
|
defaultContent={defaultContent}
|
||||||
|
parentEvent={parentEvent}
|
||||||
|
close={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogContent className="p-0" withoutClose>
|
||||||
|
<ScrollArea className="px-4 h-full max-h-screen">
|
||||||
|
<div className="space-y-4 px-2 py-6">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
<Title parentEvent={parentEvent} />
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription className="hidden" />
|
||||||
|
</DialogHeader>
|
||||||
|
<PostContent
|
||||||
|
defaultContent={defaultContent}
|
||||||
|
parentEvent={parentEvent}
|
||||||
|
close={() => setOpen(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</ScrollArea>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -17,7 +17,7 @@ export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full hover:text-foreground cursor-pointer"
|
className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full clickable"
|
||||||
onClick={() => copyNpub()}
|
onClick={() => copyNpub()}
|
||||||
>
|
>
|
||||||
<div>{formatNpub(npub, 24)}</div>
|
<div>{formatNpub(npub, 24)}</div>
|
||||||
@@ -1,13 +1,33 @@
|
|||||||
|
import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { QrCode } from 'lucide-react'
|
import { QrCode } from 'lucide-react'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
|
||||||
import { QRCodeSVG } from 'qrcode.react'
|
import { QRCodeSVG } from 'qrcode.react'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
export default function QrCodePopover({ pubkey }: { pubkey: string }) {
|
export default function QrCodePopover({ pubkey }: { pubkey: string }) {
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
|
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
|
||||||
if (!npub) return null
|
if (!npub) return null
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
return (
|
||||||
|
<Drawer>
|
||||||
|
<DrawerTrigger>
|
||||||
|
<div className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground">
|
||||||
|
<QrCode size={14} />
|
||||||
|
</div>
|
||||||
|
</DrawerTrigger>
|
||||||
|
<DrawerContent className="h-1/2">
|
||||||
|
<div className="flex justify-center items-center h-full">
|
||||||
|
<QRCodeSVG size={300} value={`nostr:${npub}`} />
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Popover>
|
<Popover>
|
||||||
<PopoverTrigger>
|
<PopoverTrigger>
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { usePrimaryPage } from '@/PageManager'
|
|
||||||
import { RefreshCcw } from 'lucide-react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export default function RefreshButton({
|
|
||||||
variant = 'titlebar'
|
|
||||||
}: {
|
|
||||||
variant?: 'titlebar' | 'sidebar'
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { refresh } = usePrimaryPage()
|
|
||||||
return (
|
|
||||||
<Button variant={variant} size={variant} onClick={refresh} title={t('Refresh')}>
|
|
||||||
<RefreshCcw />
|
|
||||||
{variant === 'sidebar' && <div>{t('Refresh')}</div>}
|
|
||||||
</Button>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -54,7 +54,7 @@ export default function RelaySettings({ hideTitle = false }: { hideTitle?: boole
|
|||||||
<RelayGroup key={index} group={group} />
|
<RelayGroup key={index} group={group} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
{relayGroups.length < 5 && (
|
{relayGroups.length < 10 && (
|
||||||
<>
|
<>
|
||||||
<Separator className="my-4" />
|
<Separator className="my-4" />
|
||||||
<div className="w-full border rounded-lg p-4">
|
<div className="w-full border rounded-lg p-4">
|
||||||
|
|||||||
@@ -1,50 +0,0 @@
|
|||||||
import RelaySettings from '@/components/RelaySettings'
|
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import { toRelaySettings } from '@/lib/link'
|
|
||||||
import { SecondaryPageLink } from '@/PageManager'
|
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
|
||||||
import { Server } from 'lucide-react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export default function RelaySettingsButton({
|
|
||||||
variant = 'titlebar'
|
|
||||||
}: {
|
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const { isSmallScreen } = useScreenSize()
|
|
||||||
|
|
||||||
if (isSmallScreen) {
|
|
||||||
return (
|
|
||||||
<SecondaryPageLink to={toRelaySettings()}>
|
|
||||||
<Button variant={variant} size={variant} title={t('Relay settings')}>
|
|
||||||
<Server />
|
|
||||||
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>}
|
|
||||||
</Button>
|
|
||||||
</SecondaryPageLink>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button variant={variant} size={variant} title={t('Relay settings')}>
|
|
||||||
<Server />
|
|
||||||
{variant === 'sidebar' && <div>{t('SidebarRelays')}</div>}
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent
|
|
||||||
className="w-96 h-[450px] p-0"
|
|
||||||
side={variant === 'titlebar' ? 'bottom' : 'right'}
|
|
||||||
>
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="p-4">
|
|
||||||
<RelaySettings />
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import Content from '../Content'
|
|||||||
import { FormattedTimestamp } from '../FormattedTimestamp'
|
import { FormattedTimestamp } from '../FormattedTimestamp'
|
||||||
import LikeButton from '../NoteStats/LikeButton'
|
import LikeButton from '../NoteStats/LikeButton'
|
||||||
import ParentNotePreview from '../ParentNotePreview'
|
import ParentNotePreview from '../ParentNotePreview'
|
||||||
import PostDialog from '../PostDialog'
|
import PostEditor from '../PostEditor'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
|
||||||
@@ -51,7 +51,7 @@ export default function ReplyNote({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<LikeButton event={event} variant="reply" />
|
<LikeButton event={event} variant="reply" />
|
||||||
<PostDialog parentEvent={event} open={isPostDialogOpen} setOpen={setIsPostDialogOpen} />
|
<PostEditor parentEvent={event} open={isPostDialogOpen} setOpen={setIsPostDialogOpen} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { ChevronUp } from 'lucide-react'
|
import { ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
export default function ScrollToTopButton({
|
export default function ScrollToTopButton({
|
||||||
@@ -11,20 +12,35 @@ export default function ScrollToTopButton({
|
|||||||
className?: string
|
className?: string
|
||||||
visible?: boolean
|
visible?: boolean
|
||||||
}) {
|
}) {
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
|
||||||
const handleScrollToTop = () => {
|
const handleScrollToTop = () => {
|
||||||
|
if (isSmallScreen) {
|
||||||
|
window.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
|
return
|
||||||
|
}
|
||||||
scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
scrollAreaRef.current?.scrollTo({ top: 0, behavior: 'smooth' })
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Button
|
<div
|
||||||
variant="secondary-2"
|
|
||||||
className={cn(
|
className={cn(
|
||||||
`absolute bottom-6 right-6 rounded-full w-12 h-12 p-0 hover:text-background transition-transform ${visible ? '' : 'translate-y-20'}`,
|
`sticky z-20 flex justify-end pr-3 transition-opacity duration-700 ${visible ? '' : 'opacity-0'}`,
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={handleScrollToTop}
|
style={{
|
||||||
|
bottom: isSmallScreen
|
||||||
|
? 'calc(env(safe-area-inset-bottom) + 3.75rem)'
|
||||||
|
: 'calc(env(safe-area-inset-bottom) + 0.75rem)'
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ChevronUp />
|
<Button
|
||||||
</Button>
|
variant="secondary-2"
|
||||||
|
className="rounded-full w-12 h-12 p-0 hover:text-background"
|
||||||
|
onClick={handleScrollToTop}
|
||||||
|
>
|
||||||
|
<ChevronUp />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +0,0 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Search } from 'lucide-react'
|
|
||||||
import { useState } from 'react'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
import { SearchDialog } from '../SearchDialog'
|
|
||||||
|
|
||||||
export default function RefreshButton({
|
|
||||||
variant = 'titlebar'
|
|
||||||
}: {
|
|
||||||
variant?: 'titlebar' | 'sidebar' | 'small-screen-titlebar'
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const [open, setOpen] = useState(false)
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<Button variant={variant} size={variant} onClick={() => setOpen(true)} title={t('Search')}>
|
|
||||||
<Search />
|
|
||||||
{variant === 'sidebar' && <div>{t('Search')}</div>}
|
|
||||||
</Button>
|
|
||||||
<SearchDialog open={open} setOpen={setOpen} />
|
|
||||||
</>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
89
src/components/Sidebar/AccountButton.tsx
Normal file
89
src/components/Sidebar/AccountButton.tsx
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '@/components/ui/dropdown-menu'
|
||||||
|
import { useFetchProfile } from '@/hooks'
|
||||||
|
import { toProfile, toSettings } from '@/lib/link'
|
||||||
|
import { formatPubkey, generateImageByPubkey } from '@/lib/pubkey'
|
||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { LogIn } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import LoginDialog from '../LoginDialog'
|
||||||
|
import LogoutDialog from '../LogoutDialog'
|
||||||
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
|
export default function AccountButton() {
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
|
||||||
|
if (pubkey) {
|
||||||
|
return <ProfileButton />
|
||||||
|
} else {
|
||||||
|
return <LoginButton />
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function ProfileButton() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { account } = useNostr()
|
||||||
|
const pubkey = account?.pubkey
|
||||||
|
const { profile } = useFetchProfile(pubkey)
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
|
||||||
|
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
|
||||||
|
if (!pubkey) return null
|
||||||
|
|
||||||
|
const defaultAvatar = generateImageByPubkey(pubkey)
|
||||||
|
const { username, avatar } = profile || { username: formatPubkey(pubkey), avatar: defaultAvatar }
|
||||||
|
|
||||||
|
return (
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
className="clickable shadow-none p-2 xl:px-2 xl:py-2 w-12 h-12 xl:w-full xl:h-auto flex items-center bg-transparent text-foreground hover:text-accent-foreground rounded-lg justify-start gap-4 text-lg font-semibold"
|
||||||
|
>
|
||||||
|
<div className="flex gap-2 items-center flex-1 w-0">
|
||||||
|
<Avatar className="w-8 h-8">
|
||||||
|
<AvatarImage src={avatar} />
|
||||||
|
<AvatarFallback>
|
||||||
|
<img src={defaultAvatar} />
|
||||||
|
</AvatarFallback>
|
||||||
|
</Avatar>
|
||||||
|
<div className="truncate font-semibold text-lg">{username}</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent>
|
||||||
|
<DropdownMenuItem onClick={() => push(toProfile(pubkey))}>{t('Profile')}</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => push(toSettings())}>{t('Settings')}</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setLoginDialogOpen(true)}>
|
||||||
|
{t('Switch account')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => setLogoutDialogOpen(true)}
|
||||||
|
>
|
||||||
|
{t('Logout')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
||||||
|
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
|
||||||
|
</DropdownMenu>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function LoginButton() {
|
||||||
|
const { checkLogin } = useNostr()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem onClick={() => checkLogin()} title="Login">
|
||||||
|
<LogIn strokeWidth={3} />
|
||||||
|
</SidebarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/components/Sidebar/HomeButton.tsx
Normal file
13
src/components/Sidebar/HomeButton.tsx
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { Home } from 'lucide-react'
|
||||||
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
|
export default function HomeButton() {
|
||||||
|
const { navigate, current } = usePrimaryPage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem title="Home" onClick={() => navigate('home')} active={current === 'home'}>
|
||||||
|
<Home strokeWidth={3} />
|
||||||
|
</SidebarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
17
src/components/Sidebar/NotificationButton.tsx
Normal file
17
src/components/Sidebar/NotificationButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { Bell } from 'lucide-react'
|
||||||
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
|
export default function NotificationsButton() {
|
||||||
|
const { navigate, current } = usePrimaryPage()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem
|
||||||
|
title="Notifications"
|
||||||
|
onClick={() => navigate('notifications')}
|
||||||
|
active={current === 'notifications'}
|
||||||
|
>
|
||||||
|
<Bell strokeWidth={3} />
|
||||||
|
</SidebarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/components/Sidebar/PostButton.tsx
Normal file
24
src/components/Sidebar/PostButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import PostEditor from '@/components/PostEditor'
|
||||||
|
import { PencilLine } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
|
export default function PostButton() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarItem
|
||||||
|
title="New post"
|
||||||
|
description="Post"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<PencilLine strokeWidth={3} />
|
||||||
|
</SidebarItem>
|
||||||
|
<PostEditor open={open} setOpen={setOpen} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
24
src/components/Sidebar/SearchButton.tsx
Normal file
24
src/components/Sidebar/SearchButton.tsx
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { SearchDialog } from '../SearchDialog'
|
||||||
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
|
export default function SearchButton() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<SidebarItem
|
||||||
|
title="Search"
|
||||||
|
description="Search"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
setOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Search strokeWidth={3} />
|
||||||
|
</SidebarItem>
|
||||||
|
<SearchDialog open={open} setOpen={setOpen} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
31
src/components/Sidebar/SidebarItem.tsx
Normal file
31
src/components/Sidebar/SidebarItem.tsx
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { Button, ButtonProps } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { forwardRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
const SidebarItem = forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
ButtonProps & { title: string; description?: string; active?: boolean }
|
||||||
|
>(({ children, title, description, className, active, ...props }, ref) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'flex shadow-none items-center bg-transparent w-12 h-12 xl:w-full xl:h-auto p-3 m-0 xl:py-2 xl:px-4 rounded-lg xl:justify-start gap-4 text-lg font-semibold [&_svg]:size-full xl:[&_svg]:size-4',
|
||||||
|
active && 'text-primary disabled:opacity-100',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
disabled={active}
|
||||||
|
variant="ghost"
|
||||||
|
title={t(title)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<div className="max-xl:hidden">{t(description ?? title)}</div>
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
SidebarItem.displayName = 'SidebarItem'
|
||||||
|
export default SidebarItem
|
||||||
@@ -1,35 +1,25 @@
|
|||||||
|
import Icon from '@/assets/Icon'
|
||||||
import Logo from '@/assets/Logo'
|
import Logo from '@/assets/Logo'
|
||||||
import { Button } from '@/components/ui/button'
|
import AccountButton from './AccountButton'
|
||||||
import { Info } from 'lucide-react'
|
import HomeButton from './HomeButton'
|
||||||
import { useTranslation } from 'react-i18next'
|
import NotificationsButton from './NotificationButton'
|
||||||
import AboutInfoDialog from '../AboutInfoDialog'
|
import PostButton from './PostButton'
|
||||||
import AccountButton from '../AccountButton'
|
import SearchButton from './SearchButton'
|
||||||
import NotificationButton from '../NotificationButton'
|
|
||||||
import PostButton from '../PostButton'
|
|
||||||
import RelaySettingsButton from '../RelaySettingsButton'
|
|
||||||
import SearchButton from '../SearchButton'
|
|
||||||
|
|
||||||
export default function PrimaryPageSidebar() {
|
export default function PrimaryPageSidebar() {
|
||||||
const { t } = useTranslation()
|
|
||||||
return (
|
return (
|
||||||
<div className="w-52 h-full shrink-0 hidden xl:flex flex-col pb-8 pt-10 pl-4 justify-between relative">
|
<div className="w-16 xl:w-52 hidden sm:flex flex-col pb-2 pt-4 px-2 justify-between h-full shrink-0">
|
||||||
<div className="absolute top-0 left-0 h-11 w-full" />
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<div className="ml-4 mb-8 w-40">
|
<div className="px-2 mb-10 w-full">
|
||||||
<Logo />
|
<Icon className="xl:hidden" />
|
||||||
|
<Logo className="max-xl:hidden" />
|
||||||
</div>
|
</div>
|
||||||
<PostButton variant="sidebar" />
|
<HomeButton />
|
||||||
<RelaySettingsButton variant="sidebar" />
|
<NotificationsButton />
|
||||||
<NotificationButton variant="sidebar" />
|
<SearchButton />
|
||||||
<SearchButton variant="sidebar" />
|
<PostButton />
|
||||||
<AboutInfoDialog>
|
|
||||||
<Button variant="sidebar" size="sidebar">
|
|
||||||
<Info />
|
|
||||||
{t('About')}
|
|
||||||
</Button>
|
|
||||||
</AboutInfoDialog>
|
|
||||||
</div>
|
</div>
|
||||||
<AccountButton variant="sidebar" />
|
<AccountButton />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,11 +3,7 @@ import { useTheme } from '@/providers/ThemeProvider'
|
|||||||
import { Moon, Sun, SunMoon } from 'lucide-react'
|
import { Moon, Sun, SunMoon } from 'lucide-react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function ThemeToggle({
|
export default function ThemeToggle() {
|
||||||
variant = 'titlebar'
|
|
||||||
}: {
|
|
||||||
variant?: 'titlebar' | 'small-screen-titlebar'
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { themeSetting, setThemeSetting } = useTheme()
|
const { themeSetting, setThemeSetting } = useTheme()
|
||||||
|
|
||||||
@@ -15,8 +11,8 @@ export default function ThemeToggle({
|
|||||||
<>
|
<>
|
||||||
{themeSetting === 'system' ? (
|
{themeSetting === 'system' ? (
|
||||||
<Button
|
<Button
|
||||||
variant={variant}
|
variant="ghost"
|
||||||
size={variant}
|
size="titlebar-icon"
|
||||||
onClick={() => setThemeSetting('light')}
|
onClick={() => setThemeSetting('light')}
|
||||||
title={t('switch to light theme')}
|
title={t('switch to light theme')}
|
||||||
>
|
>
|
||||||
@@ -24,8 +20,8 @@ export default function ThemeToggle({
|
|||||||
</Button>
|
</Button>
|
||||||
) : themeSetting === 'light' ? (
|
) : themeSetting === 'light' ? (
|
||||||
<Button
|
<Button
|
||||||
variant={variant}
|
variant="ghost"
|
||||||
size={variant}
|
size="titlebar-icon"
|
||||||
onClick={() => setThemeSetting('dark')}
|
onClick={() => setThemeSetting('dark')}
|
||||||
title={t('switch to dark theme')}
|
title={t('switch to dark theme')}
|
||||||
>
|
>
|
||||||
@@ -33,8 +29,8 @@ export default function ThemeToggle({
|
|||||||
</Button>
|
</Button>
|
||||||
) : (
|
) : (
|
||||||
<Button
|
<Button
|
||||||
variant={variant}
|
variant="ghost"
|
||||||
size={variant}
|
size="titlebar-icon"
|
||||||
onClick={() => setThemeSetting('system')}
|
onClick={() => setThemeSetting('system')}
|
||||||
title={t('switch to system theme')}
|
title={t('switch to system theme')}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ export function Titlebar({
|
|||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
'absolute top-0 w-full h-9 max-sm:h-11 z-50 bg-background/80 backdrop-blur-md flex items-center font-semibold gap-1 px-2 duration-700 transition-transform',
|
'sticky top-0 w-full z-20 bg-background/90 backdrop-blur-xl duration-700 transition-transform [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
visible ? '' : '-translate-y-full',
|
visible ? '' : '-translate-y-full',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { useMemo } from 'react'
|
|||||||
|
|
||||||
const UserAvatarSizeCnMap = {
|
const UserAvatarSizeCnMap = {
|
||||||
large: 'w-24 h-24',
|
large: 'w-24 h-24',
|
||||||
|
big: 'w-16 h-16',
|
||||||
normal: 'w-10 h-10',
|
normal: 'w-10 h-10',
|
||||||
small: 'w-7 h-7',
|
small: 'w-7 h-7',
|
||||||
tiny: 'w-4 h-4'
|
tiny: 'w-4 h-4'
|
||||||
@@ -23,7 +24,7 @@ export default function UserAvatar({
|
|||||||
}: {
|
}: {
|
||||||
userId: string
|
userId: string
|
||||||
className?: string
|
className?: string
|
||||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
|
||||||
}) {
|
}) {
|
||||||
const { profile } = useFetchProfile(userId)
|
const { profile } = useFetchProfile(userId)
|
||||||
const defaultAvatar = useMemo(
|
const defaultAvatar = useMemo(
|
||||||
@@ -62,7 +63,7 @@ export function SimpleUserAvatar({
|
|||||||
onClick
|
onClick
|
||||||
}: {
|
}: {
|
||||||
userId: string
|
userId: string
|
||||||
size?: 'large' | 'normal' | 'small' | 'tiny'
|
size?: 'large' | 'big' | 'normal' | 'small' | 'tiny'
|
||||||
className?: string
|
className?: string
|
||||||
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
onClick?: (e: React.MouseEvent<HTMLDivElement, MouseEvent>) => void
|
||||||
}) {
|
}) {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { Image } from '@nextui-org/image'
|
|
||||||
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
|
import { useFetchWebMetadata } from '@/hooks/useFetchWebMetadata'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import { Image } from '@nextui-org/image'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
export default function WebPreview({
|
export default function WebPreview({
|
||||||
@@ -12,6 +13,7 @@ export default function WebPreview({
|
|||||||
className?: string
|
className?: string
|
||||||
size?: 'normal' | 'small'
|
size?: 'normal' | 'small'
|
||||||
}) {
|
}) {
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
const { title, description, image } = useFetchWebMetadata(url)
|
const { title, description, image } = useFetchWebMetadata(url)
|
||||||
const hostname = useMemo(() => {
|
const hostname = useMemo(() => {
|
||||||
try {
|
try {
|
||||||
@@ -25,9 +27,21 @@ export default function WebPreview({
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (isSmallScreen && image) {
|
||||||
|
return (
|
||||||
|
<div className="relative border rounded-lg w-full h-44">
|
||||||
|
<Image src={image} className="rounded-lg object-cover w-full h-full" removeWrapper />
|
||||||
|
<div className="absolute bottom-0 z-10 bg-muted/70 px-2 py-1 w-full rounded-b-lg">
|
||||||
|
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||||
|
<div className="font-semibold line-clamp-1">{title}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={cn('p-0 hover:bg-muted/50 cursor-pointer flex w-full', className)}
|
className={cn('p-0 clickable flex w-full border rounded-lg', className)}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
window.open(url, '_blank')
|
window.open(url, '_blank')
|
||||||
@@ -36,11 +50,11 @@ export default function WebPreview({
|
|||||||
{image && (
|
{image && (
|
||||||
<Image
|
<Image
|
||||||
src={image}
|
src={image}
|
||||||
className={`rounded-l-lg object-cover w-2/5 ${size === 'normal' ? 'h-44' : 'h-24'}`}
|
className={`rounded-l-lg object-cover ${size === 'normal' ? 'h-44' : 'h-24'}`}
|
||||||
removeWrapper
|
removeWrapper
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<div className={`flex-1 w-0 p-2 border ${image ? 'rounded-r-lg' : 'rounded-lg'}`}>
|
<div className="flex-1 w-0 p-2">
|
||||||
<div className="text-xs text-muted-foreground">{hostname}</div>
|
<div className="text-xs text-muted-foreground">{hostname}</div>
|
||||||
<div className={`font-semibold ${size === 'normal' ? 'line-clamp-2' : 'line-clamp-1'}`}>
|
<div className={`font-semibold ${size === 'normal' ? 'line-clamp-2' : 'line-clamp-1'}`}>
|
||||||
{title}
|
{title}
|
||||||
|
|||||||
121
src/components/ui/alert-dialog.tsx
Normal file
121
src/components/ui/alert-dialog.tsx
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as AlertDialogPrimitive from '@radix-ui/react-alert-dialog'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { buttonVariants } from '@/components/ui/button'
|
||||||
|
import { VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
|
const AlertDialog = AlertDialogPrimitive.Root
|
||||||
|
|
||||||
|
const AlertDialogTrigger = AlertDialogPrimitive.Trigger
|
||||||
|
|
||||||
|
const AlertDialogPortal = AlertDialogPrimitive.Portal
|
||||||
|
|
||||||
|
const AlertDialogOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogOverlay.displayName = AlertDialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const AlertDialogContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Content>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPortal>
|
||||||
|
<AlertDialogOverlay />
|
||||||
|
<AlertDialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</AlertDialogPortal>
|
||||||
|
))
|
||||||
|
AlertDialogContent.displayName = AlertDialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const AlertDialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||||
|
)
|
||||||
|
AlertDialogHeader.displayName = 'AlertDialogHeader'
|
||||||
|
|
||||||
|
const AlertDialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
AlertDialogFooter.displayName = 'AlertDialogFooter'
|
||||||
|
|
||||||
|
const AlertDialogTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogTitle.displayName = AlertDialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const AlertDialogDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogDescription.displayName = AlertDialogPrimitive.Description.displayName
|
||||||
|
|
||||||
|
const AlertDialogAction = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Action>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Action> &
|
||||||
|
VariantProps<typeof buttonVariants>
|
||||||
|
>(({ className, variant, size, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Action
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogAction.displayName = AlertDialogPrimitive.Action.displayName
|
||||||
|
|
||||||
|
const AlertDialogCancel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof AlertDialogPrimitive.Cancel>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof AlertDialogPrimitive.Cancel>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<AlertDialogPrimitive.Cancel
|
||||||
|
ref={ref}
|
||||||
|
className={cn(buttonVariants({ variant: 'outline' }), 'mt-2 sm:mt-0', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
AlertDialogCancel.displayName = AlertDialogPrimitive.Cancel.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogPortal,
|
||||||
|
AlertDialogOverlay,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel
|
||||||
|
}
|
||||||
@@ -15,20 +15,15 @@ const buttonVariants = cva(
|
|||||||
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||||
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
|
'secondary-2': 'bg-secondary text-secondary-foreground hover:bg-highlight',
|
||||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
ghost: 'clickable hover:text-accent-foreground',
|
||||||
link: 'text-primary underline-offset-4 hover:underline',
|
link: 'text-primary underline-offset-4 hover:underline'
|
||||||
titlebar: 'hover:bg-accent hover:text-accent-foreground',
|
|
||||||
sidebar: 'hover:bg-accent hover:text-accent-foreground',
|
|
||||||
'small-screen-titlebar': 'hover:bg-accent hover:text-accent-foreground'
|
|
||||||
},
|
},
|
||||||
size: {
|
size: {
|
||||||
default: 'h-9 px-4 py-2',
|
default: 'h-9 px-4 py-2',
|
||||||
sm: 'h-8 rounded-md px-3 text-xs',
|
sm: 'h-8 rounded-md px-3 text-xs',
|
||||||
lg: 'h-10 rounded-md px-8',
|
lg: 'h-10 rounded-md px-8',
|
||||||
icon: 'h-9 w-9',
|
icon: 'h-9 w-9',
|
||||||
titlebar: 'h-7 w-7 rounded-full',
|
'titlebar-icon': 'h-10 w-10 rounded-lg'
|
||||||
sidebar: 'w-full flex py-2 px-4 rounded-full justify-start gap-4 text-lg font-semibold',
|
|
||||||
'small-screen-titlebar': 'h-8 w-8 rounded-full'
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
defaultVariants: {
|
defaultVariants: {
|
||||||
|
|||||||
101
src/components/ui/drawer.tsx
Normal file
101
src/components/ui/drawer.tsx
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { Drawer as DrawerPrimitive } from 'vaul'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Drawer = ({
|
||||||
|
shouldScaleBackground = true,
|
||||||
|
...props
|
||||||
|
}: React.ComponentProps<typeof DrawerPrimitive.Root>) => (
|
||||||
|
<DrawerPrimitive.Root shouldScaleBackground={shouldScaleBackground} {...props} />
|
||||||
|
)
|
||||||
|
Drawer.displayName = 'Drawer'
|
||||||
|
|
||||||
|
const DrawerTrigger = DrawerPrimitive.Trigger
|
||||||
|
|
||||||
|
const DrawerPortal = DrawerPrimitive.Portal
|
||||||
|
|
||||||
|
const DrawerClose = DrawerPrimitive.Close
|
||||||
|
|
||||||
|
const DrawerOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn('fixed inset-0 z-50 bg-black/80', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerOverlay.displayName = DrawerPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DrawerContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DrawerPortal>
|
||||||
|
<DrawerOverlay />
|
||||||
|
<DrawerPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-x-0 bottom-0 z-50 mt-24 flex h-auto flex-col rounded-t-[10px] sm:border bg-background',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||||
|
}}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="mx-auto mt-4 h-2 w-[100px] rounded-full bg-muted" />
|
||||||
|
{children}
|
||||||
|
</DrawerPrimitive.Content>
|
||||||
|
</DrawerPortal>
|
||||||
|
))
|
||||||
|
DrawerContent.displayName = 'DrawerContent'
|
||||||
|
|
||||||
|
const DrawerHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('grid gap-1.5 p-4 text-center sm:text-left', className)} {...props} />
|
||||||
|
)
|
||||||
|
DrawerHeader.displayName = 'DrawerHeader'
|
||||||
|
|
||||||
|
const DrawerFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('mt-auto flex flex-col gap-2 p-4', className)} {...props} />
|
||||||
|
)
|
||||||
|
DrawerFooter.displayName = 'DrawerFooter'
|
||||||
|
|
||||||
|
const DrawerTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerTitle.displayName = DrawerPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const DrawerDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof DrawerPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DrawerPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DrawerPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DrawerDescription.displayName = DrawerPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Drawer,
|
||||||
|
DrawerPortal,
|
||||||
|
DrawerOverlay,
|
||||||
|
DrawerTrigger,
|
||||||
|
DrawerClose,
|
||||||
|
DrawerContent,
|
||||||
|
DrawerHeader,
|
||||||
|
DrawerFooter,
|
||||||
|
DrawerTitle,
|
||||||
|
DrawerDescription
|
||||||
|
}
|
||||||
@@ -1,23 +1,18 @@
|
|||||||
import * as React from "react"
|
import * as React from 'react'
|
||||||
import * as LabelPrimitive from "@radix-ui/react-label"
|
import * as LabelPrimitive from '@radix-ui/react-label'
|
||||||
import { cva, type VariantProps } from "class-variance-authority"
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
|
||||||
import { cn } from "@/lib/utils"
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
const labelVariants = cva(
|
const labelVariants = cva(
|
||||||
"text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70"
|
'text-sm font-medium leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70'
|
||||||
)
|
)
|
||||||
|
|
||||||
const Label = React.forwardRef<
|
const Label = React.forwardRef<
|
||||||
React.ElementRef<typeof LabelPrimitive.Root>,
|
React.ElementRef<typeof LabelPrimitive.Root>,
|
||||||
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> &
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root> & VariantProps<typeof labelVariants>
|
||||||
VariantProps<typeof labelVariants>
|
|
||||||
>(({ className, ...props }, ref) => (
|
>(({ className, ...props }, ref) => (
|
||||||
<LabelPrimitive.Root
|
<LabelPrimitive.Root ref={ref} className={cn(labelVariants(), className)} {...props} />
|
||||||
ref={ref}
|
|
||||||
className={cn(labelVariants(), className)}
|
|
||||||
{...props}
|
|
||||||
/>
|
|
||||||
))
|
))
|
||||||
Label.displayName = LabelPrimitive.Root.displayName
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
|||||||
150
src/components/ui/select.tsx
Normal file
150
src/components/ui/select.tsx
Normal file
@@ -0,0 +1,150 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SelectPrimitive from '@radix-ui/react-select'
|
||||||
|
import { Check, ChevronDown, ChevronUp } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Select = SelectPrimitive.Root
|
||||||
|
|
||||||
|
const SelectGroup = SelectPrimitive.Group
|
||||||
|
|
||||||
|
const SelectValue = SelectPrimitive.Value
|
||||||
|
|
||||||
|
const SelectTrigger = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Trigger>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Trigger
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full items-center justify-between whitespace-nowrap rounded-md border border-input bg-transparent px-3 py-2 text-sm shadow-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-1 focus:ring-ring disabled:cursor-not-allowed disabled:opacity-50 [&>span]:line-clamp-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDown className="h-4 w-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
))
|
||||||
|
SelectTrigger.displayName = SelectPrimitive.Trigger.displayName
|
||||||
|
|
||||||
|
const SelectScrollUpButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollUpButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollUpButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollUpButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronUp className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollUpButton>
|
||||||
|
))
|
||||||
|
SelectScrollUpButton.displayName = SelectPrimitive.ScrollUpButton.displayName
|
||||||
|
|
||||||
|
const SelectScrollDownButton = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.ScrollDownButton>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.ScrollDownButton>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.ScrollDownButton
|
||||||
|
ref={ref}
|
||||||
|
className={cn('flex cursor-default items-center justify-center py-1', className)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDown className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
))
|
||||||
|
SelectScrollDownButton.displayName = SelectPrimitive.ScrollDownButton.displayName
|
||||||
|
|
||||||
|
const SelectContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Content>
|
||||||
|
>(({ className, children, position = 'popper', ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Portal>
|
||||||
|
<SelectPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative z-50 max-h-96 min-w-[8rem] overflow-hidden rounded-md border bg-popover text-popover-foreground shadow-md data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2',
|
||||||
|
position === 'popper' &&
|
||||||
|
'data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
position={position}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<SelectScrollUpButton />
|
||||||
|
<SelectPrimitive.Viewport
|
||||||
|
className={cn(
|
||||||
|
'p-1',
|
||||||
|
position === 'popper' &&
|
||||||
|
'h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)]'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
))
|
||||||
|
SelectContent.displayName = SelectPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SelectLabel = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Label>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Label>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Label
|
||||||
|
ref={ref}
|
||||||
|
className={cn('px-2 py-1.5 text-sm font-semibold', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectLabel.displayName = SelectPrimitive.Label.displayName
|
||||||
|
|
||||||
|
const SelectItem = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Item>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-2 pr-8 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<span className="absolute right-2 flex h-3.5 w-3.5 items-center justify-center">
|
||||||
|
<SelectPrimitive.ItemIndicator>
|
||||||
|
<Check className="h-4 w-4" />
|
||||||
|
</SelectPrimitive.ItemIndicator>
|
||||||
|
</span>
|
||||||
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
|
</SelectPrimitive.Item>
|
||||||
|
))
|
||||||
|
SelectItem.displayName = SelectPrimitive.Item.displayName
|
||||||
|
|
||||||
|
const SelectSeparator = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SelectPrimitive.Separator>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Separator>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SelectPrimitive.Separator
|
||||||
|
ref={ref}
|
||||||
|
className={cn('-mx-1 my-1 h-px bg-muted', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SelectSeparator.displayName = SelectPrimitive.Separator.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Select,
|
||||||
|
SelectGroup,
|
||||||
|
SelectValue,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectContent,
|
||||||
|
SelectLabel,
|
||||||
|
SelectItem,
|
||||||
|
SelectSeparator,
|
||||||
|
SelectScrollUpButton,
|
||||||
|
SelectScrollDownButton
|
||||||
|
}
|
||||||
119
src/components/ui/sheet.tsx
Normal file
119
src/components/ui/sheet.tsx
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import * as SheetPrimitive from '@radix-ui/react-dialog'
|
||||||
|
import { cva, type VariantProps } from 'class-variance-authority'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
const Sheet = SheetPrimitive.Root
|
||||||
|
|
||||||
|
const SheetTrigger = SheetPrimitive.Trigger
|
||||||
|
|
||||||
|
const SheetClose = SheetPrimitive.Close
|
||||||
|
|
||||||
|
const SheetPortal = SheetPrimitive.Portal
|
||||||
|
|
||||||
|
const SheetOverlay = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Overlay
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
ref={ref}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetOverlay.displayName = SheetPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const sheetVariants = cva(
|
||||||
|
'fixed z-50 gap-4 bg-background p-6 shadow-lg transition ease-in-out data-[state=closed]:duration-300 data-[state=open]:duration-500 data-[state=open]:animate-in data-[state=closed]:animate-out',
|
||||||
|
{
|
||||||
|
variants: {
|
||||||
|
side: {
|
||||||
|
top: 'inset-x-0 top-0 border-b data-[state=closed]:slide-out-to-top data-[state=open]:slide-in-from-top',
|
||||||
|
bottom:
|
||||||
|
'inset-x-0 bottom-0 border-t data-[state=closed]:slide-out-to-bottom data-[state=open]:slide-in-from-bottom',
|
||||||
|
left: 'inset-y-0 left-0 h-full w-3/4 border-r data-[state=closed]:slide-out-to-left data-[state=open]:slide-in-from-left sm:max-w-sm',
|
||||||
|
right:
|
||||||
|
'inset-y-0 right-0 h-full w-3/4 border-l data-[state=closed]:slide-out-to-right data-[state=open]:slide-in-from-right sm:max-w-sm'
|
||||||
|
}
|
||||||
|
},
|
||||||
|
defaultVariants: {
|
||||||
|
side: 'right'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
interface SheetContentProps
|
||||||
|
extends React.ComponentPropsWithoutRef<typeof SheetPrimitive.Content>,
|
||||||
|
VariantProps<typeof sheetVariants> {}
|
||||||
|
|
||||||
|
const SheetContent = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Content>,
|
||||||
|
SheetContentProps
|
||||||
|
>(({ side = 'right', className, children, ...props }, ref) => (
|
||||||
|
<SheetPortal>
|
||||||
|
<SheetOverlay />
|
||||||
|
<SheetPrimitive.Content ref={ref} className={cn(sheetVariants({ side }), className)} {...props}>
|
||||||
|
<SheetPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-secondary">
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
<span className="sr-only">Close</span>
|
||||||
|
</SheetPrimitive.Close>
|
||||||
|
{children}
|
||||||
|
</SheetPrimitive.Content>
|
||||||
|
</SheetPortal>
|
||||||
|
))
|
||||||
|
SheetContent.displayName = SheetPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const SheetHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-2 text-center sm:text-left', className)} {...props} />
|
||||||
|
)
|
||||||
|
SheetHeader.displayName = 'SheetHeader'
|
||||||
|
|
||||||
|
const SheetFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div
|
||||||
|
className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
SheetFooter.displayName = 'SheetFooter'
|
||||||
|
|
||||||
|
const SheetTitle = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold text-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetTitle.displayName = SheetPrimitive.Title.displayName
|
||||||
|
|
||||||
|
const SheetDescription = React.forwardRef<
|
||||||
|
React.ElementRef<typeof SheetPrimitive.Description>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof SheetPrimitive.Description>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<SheetPrimitive.Description
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm text-muted-foreground', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
SheetDescription.displayName = SheetPrimitive.Description.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Sheet,
|
||||||
|
SheetPortal,
|
||||||
|
SheetOverlay,
|
||||||
|
SheetTrigger,
|
||||||
|
SheetClose,
|
||||||
|
SheetContent,
|
||||||
|
SheetHeader,
|
||||||
|
SheetFooter,
|
||||||
|
SheetTitle,
|
||||||
|
SheetDescription
|
||||||
|
}
|
||||||
@@ -6,26 +6,32 @@ import { useEffect, useState } from 'react'
|
|||||||
export function useFetchFollowings(pubkey?: string | null) {
|
export function useFetchFollowings(pubkey?: string | null) {
|
||||||
const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
|
const [followListEvent, setFollowListEvent] = useState<Event | null>(null)
|
||||||
const [followings, setFollowings] = useState<string[]>([])
|
const [followings, setFollowings] = useState<string[]>([])
|
||||||
|
const [isFetching, setIsFetching] = useState(true)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
if (!pubkey) return
|
try {
|
||||||
|
setIsFetching(true)
|
||||||
|
if (!pubkey) return
|
||||||
|
|
||||||
const event = await client.fetchFollowListEvent(pubkey)
|
const event = await client.fetchFollowListEvent(pubkey)
|
||||||
if (!event) return
|
if (!event) return
|
||||||
|
|
||||||
setFollowListEvent(event)
|
setFollowListEvent(event)
|
||||||
setFollowings(
|
setFollowings(
|
||||||
event.tags
|
event.tags
|
||||||
.filter(tagNameEquals('p'))
|
.filter(tagNameEquals('p'))
|
||||||
.map(([, pubkey]) => pubkey)
|
.map(([, pubkey]) => pubkey)
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.reverse()
|
.reverse()
|
||||||
)
|
)
|
||||||
|
} finally {
|
||||||
|
setIsFetching(false)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init()
|
init()
|
||||||
}, [pubkey])
|
}, [pubkey])
|
||||||
|
|
||||||
return { followings, followListEvent }
|
return { followings, followListEvent, isFetching }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ export default {
|
|||||||
About: 'About',
|
About: 'About',
|
||||||
'New post': 'New post',
|
'New post': 'New post',
|
||||||
Post: 'Post',
|
Post: 'Post',
|
||||||
|
Home: 'Home',
|
||||||
'Relay settings': 'Relay settings',
|
'Relay settings': 'Relay settings',
|
||||||
|
Settings: 'Settings',
|
||||||
SidebarRelays: 'Relays',
|
SidebarRelays: 'Relays',
|
||||||
Refresh: 'Refresh',
|
Refresh: 'Refresh',
|
||||||
Profile: 'Profile',
|
Profile: 'Profile',
|
||||||
@@ -44,9 +46,8 @@ export default {
|
|||||||
'switch to light theme': 'switch to light theme',
|
'switch to light theme': 'switch to light theme',
|
||||||
'switch to dark theme': 'switch to dark theme',
|
'switch to dark theme': 'switch to dark theme',
|
||||||
'switch to system theme': 'switch to system theme',
|
'switch to system theme': 'switch to system theme',
|
||||||
note: 'note',
|
Note: 'Note',
|
||||||
"username's following": "{{username}}'s following",
|
"username's following": "{{username}}'s following",
|
||||||
following: 'following',
|
|
||||||
Login: 'Login',
|
Login: 'Login',
|
||||||
'Follows you': 'Follows you',
|
'Follows you': 'Follows you',
|
||||||
'relay collection name already exists': 'relay collection name already exists',
|
'relay collection name already exists': 'relay collection name already exists',
|
||||||
@@ -67,16 +68,14 @@ export default {
|
|||||||
'no replies': 'no replies',
|
'no replies': 'no replies',
|
||||||
'Reply to': 'Reply to',
|
'Reply to': 'Reply to',
|
||||||
Search: 'Search',
|
Search: 'Search',
|
||||||
search: 'search',
|
|
||||||
'The relays you are connected to do not support search':
|
'The relays you are connected to do not support search':
|
||||||
'The relays you are connected to do not support search',
|
'The relays you are connected to do not support search',
|
||||||
'supports search': 'supports search',
|
'supports search': 'supports search',
|
||||||
'Show more...': 'Show more...',
|
'Show more...': 'Show more...',
|
||||||
'all users': 'all users',
|
'All users': 'All users',
|
||||||
'Display replies': 'Display replies',
|
'Display replies': 'Display replies',
|
||||||
Notes: 'Notes',
|
Notes: 'Notes',
|
||||||
'Notes & Replies': 'Notes & Replies',
|
'Notes & Replies': 'Notes & Replies',
|
||||||
notifications: 'notifications',
|
|
||||||
Notifications: 'Notifications',
|
Notifications: 'Notifications',
|
||||||
'no more notifications': 'no more notifications',
|
'no more notifications': 'no more notifications',
|
||||||
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.':
|
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.':
|
||||||
@@ -87,9 +86,21 @@ export default {
|
|||||||
'reload notes': 'reload notes',
|
'reload notes': 'reload notes',
|
||||||
'Logged in Accounts': 'Logged in Accounts',
|
'Logged in Accounts': 'Logged in Accounts',
|
||||||
'Add an Account': 'Add an Account',
|
'Add an Account': 'Add an Account',
|
||||||
Accounts: 'Accounts',
|
|
||||||
'More options': 'More options',
|
'More options': 'More options',
|
||||||
'Add client tag': 'Add client tag',
|
'Add client tag': 'Add client tag',
|
||||||
'Show others this was sent via Jumble': 'Show others this was sent via Jumble'
|
'Show others this was sent via Jumble': 'Show others this was sent via Jumble',
|
||||||
|
'Are you sure you want to logout?': 'Are you sure you want to logout?',
|
||||||
|
'relay feeds': 'relay feeds',
|
||||||
|
edit: 'edit',
|
||||||
|
Languages: 'Languages',
|
||||||
|
English: 'English',
|
||||||
|
Chinese: 'Chinese',
|
||||||
|
Theme: 'Theme',
|
||||||
|
System: 'System',
|
||||||
|
Light: 'Light',
|
||||||
|
Dark: 'Dark',
|
||||||
|
Temporary: 'Temporary',
|
||||||
|
'Choose a relay collection': 'Choose a relay collection',
|
||||||
|
'Switch account': 'Switch account'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
|
import dayjs from 'dayjs'
|
||||||
import i18n from 'i18next'
|
import i18n from 'i18next'
|
||||||
import { initReactI18next } from 'react-i18next'
|
import { initReactI18next } from 'react-i18next'
|
||||||
import LanguageDetector from 'i18next-browser-languagedetector'
|
|
||||||
import en from './en'
|
import en from './en'
|
||||||
import zh from './zh'
|
import zh from './zh'
|
||||||
import dayjs from 'dayjs'
|
|
||||||
|
|
||||||
const resources = {
|
const resources = {
|
||||||
en,
|
en,
|
||||||
@@ -11,7 +10,21 @@ const resources = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
i18n
|
i18n
|
||||||
.use(LanguageDetector)
|
.use({
|
||||||
|
type: 'languageDetector',
|
||||||
|
detect: function () {
|
||||||
|
const lng = localStorage.getItem('i18nextLng')
|
||||||
|
if (lng === 'zh' || lng === 'en') {
|
||||||
|
return lng
|
||||||
|
}
|
||||||
|
return undefined
|
||||||
|
},
|
||||||
|
cacheUserLanguage: function (lng: string) {
|
||||||
|
if (lng === 'zh' || lng === 'en') {
|
||||||
|
localStorage.setItem('i18nextLng', lng)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
.use(initReactI18next)
|
.use(initReactI18next)
|
||||||
.init({
|
.init({
|
||||||
fallbackLng: 'en',
|
fallbackLng: 'en',
|
||||||
|
|||||||
@@ -4,7 +4,9 @@ export default {
|
|||||||
About: '关于',
|
About: '关于',
|
||||||
'New post': '发布新笔记',
|
'New post': '发布新笔记',
|
||||||
Post: '发布笔记',
|
Post: '发布笔记',
|
||||||
|
Home: '主页',
|
||||||
'Relay settings': '服务器设置',
|
'Relay settings': '服务器设置',
|
||||||
|
Settings: '设置',
|
||||||
SidebarRelays: '服务器',
|
SidebarRelays: '服务器',
|
||||||
Refresh: '刷新列表',
|
Refresh: '刷新列表',
|
||||||
Profile: '个人资料',
|
Profile: '个人资料',
|
||||||
@@ -44,9 +46,8 @@ export default {
|
|||||||
'switch to light theme': '切换到浅色主题',
|
'switch to light theme': '切换到浅色主题',
|
||||||
'switch to dark theme': '切换到深色主题',
|
'switch to dark theme': '切换到深色主题',
|
||||||
'switch to system theme': '切换到系统主题',
|
'switch to system theme': '切换到系统主题',
|
||||||
note: '笔记',
|
Note: '笔记',
|
||||||
"username's following": '{{username}} 的关注',
|
"username's following": '{{username}} 的关注',
|
||||||
following: '关注',
|
|
||||||
Login: '登录',
|
Login: '登录',
|
||||||
'Follows you': '关注了你',
|
'Follows you': '关注了你',
|
||||||
'relay collection name already exists': '服务器组名已存在',
|
'relay collection name already exists': '服务器组名已存在',
|
||||||
@@ -67,15 +68,13 @@ export default {
|
|||||||
'no replies': '暂无回复',
|
'no replies': '暂无回复',
|
||||||
'Reply to': '回复',
|
'Reply to': '回复',
|
||||||
Search: '搜索',
|
Search: '搜索',
|
||||||
search: '搜索',
|
|
||||||
'The relays you are connected to do not support search': '您连接的服务器不支持搜索',
|
'The relays you are connected to do not support search': '您连接的服务器不支持搜索',
|
||||||
'supports search': '支持搜索',
|
'supports search': '支持搜索',
|
||||||
'Show more...': '查看更多...',
|
'Show more...': '查看更多...',
|
||||||
'all users': '所有用户',
|
'All users': '所有用户',
|
||||||
'Display replies': '显示回复',
|
'Display replies': '显示回复',
|
||||||
Notes: '笔记',
|
Notes: '笔记',
|
||||||
'Notes & Replies': '笔记 & 回复',
|
'Notes & Replies': '笔记 & 回复',
|
||||||
notifications: '通知',
|
|
||||||
Notifications: '通知',
|
Notifications: '通知',
|
||||||
'no more notifications': '到底了',
|
'no more notifications': '到底了',
|
||||||
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.':
|
'Using private key login is insecure. It is recommended to use a browser extension for login, such as alby, nostr-keyx or nos2x.':
|
||||||
@@ -86,9 +85,21 @@ export default {
|
|||||||
'reload notes': '重新加载笔记',
|
'reload notes': '重新加载笔记',
|
||||||
'Logged in Accounts': '已登录账户',
|
'Logged in Accounts': '已登录账户',
|
||||||
'Add an Account': '添加账户',
|
'Add an Account': '添加账户',
|
||||||
Accounts: '多帐户',
|
|
||||||
'More options': '更多选项',
|
'More options': '更多选项',
|
||||||
'Add client tag': '添加客户端标签',
|
'Add client tag': '添加客户端标签',
|
||||||
'Show others this was sent via Jumble': '告诉别人这是通过 Jumble 发送的'
|
'Show others this was sent via Jumble': '告诉别人这是通过 Jumble 发送的',
|
||||||
|
'Are you sure you want to logout?': '确定要退出登录吗?',
|
||||||
|
'relay feeds': '服务器信息流',
|
||||||
|
edit: '编辑',
|
||||||
|
Languages: '语言',
|
||||||
|
English: '英语',
|
||||||
|
Chinese: '中文',
|
||||||
|
Theme: '主题',
|
||||||
|
System: '系统',
|
||||||
|
Light: '浅色',
|
||||||
|
Dark: '深色',
|
||||||
|
Temporary: '临时',
|
||||||
|
'Choose a relay collection': '选择一个服务器组',
|
||||||
|
'Switch account': '切换账户'
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@
|
|||||||
@tailwind utilities;
|
@tailwind utilities;
|
||||||
@layer base {
|
@layer base {
|
||||||
* {
|
* {
|
||||||
|
@apply border-border;
|
||||||
-webkit-font-smoothing: antialiased;
|
-webkit-font-smoothing: antialiased;
|
||||||
-moz-osx-font-smoothing: grayscale;
|
-moz-osx-font-smoothing: grayscale;
|
||||||
text-rendering: optimizeLegibility;
|
text-rendering: optimizeLegibility;
|
||||||
@@ -18,13 +19,28 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
body {
|
body {
|
||||||
|
@apply bg-background text-foreground;
|
||||||
font-family: 'Inter', sans-serif;
|
font-family: 'Inter', sans-serif;
|
||||||
overflow: hidden;
|
|
||||||
-webkit-overflow-scrolling: touch;
|
-webkit-overflow-scrolling: touch;
|
||||||
text-size-adjust: 100%;
|
text-size-adjust: 100%;
|
||||||
-webkit-text-size-adjust: 100%;
|
-webkit-text-size-adjust: 100%;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.clickable {
|
||||||
|
cursor: pointer;
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
|
||||||
|
&:active {
|
||||||
|
background-color: hsl(var(--muted) / 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (hover: hover) and (pointer: fine) {
|
||||||
|
.clickable:hover {
|
||||||
|
background-color: hsl(var(--muted) / 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
:root {
|
:root {
|
||||||
--background: 0 0% 100%;
|
--background: 0 0% 100%;
|
||||||
--foreground: 240 10% 3.9%;
|
--foreground: 240 10% 3.9%;
|
||||||
@@ -81,11 +97,3 @@
|
|||||||
--highlight: 259 43% 56%;
|
--highlight: 259 43% 56%;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@layer base {
|
|
||||||
* {
|
|
||||||
@apply border-border;
|
|
||||||
}
|
|
||||||
body {
|
|
||||||
@apply bg-background text-foreground;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -1,70 +1,112 @@
|
|||||||
import Logo from '@/assets/Logo'
|
import BottomNavigationBar from '@/components/BottomNavigationBar'
|
||||||
import AccountButton from '@/components/AccountButton'
|
|
||||||
import NotificationButton from '@/components/NotificationButton'
|
|
||||||
import PostButton from '@/components/PostButton'
|
|
||||||
import RelaySettingsButton from '@/components/RelaySettingsButton'
|
|
||||||
import ScrollToTopButton from '@/components/ScrollToTopButton'
|
import ScrollToTopButton from '@/components/ScrollToTopButton'
|
||||||
import SearchButton from '@/components/SearchButton'
|
|
||||||
import ThemeToggle from '@/components/ThemeToggle'
|
|
||||||
import { Titlebar } from '@/components/Titlebar'
|
import { Titlebar } from '@/components/Titlebar'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { TPrimaryPageName, usePrimaryPage } from '@/PageManager'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
import { forwardRef, useEffect, useImperativeHandle, useRef, useState } from 'react'
|
||||||
|
|
||||||
const PrimaryPageLayout = forwardRef(({ children }: { children?: React.ReactNode }, ref) => {
|
const PrimaryPageLayout = forwardRef(
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
(
|
||||||
const [visible, setVisible] = useState(true)
|
{
|
||||||
const [lastScrollTop, setLastScrollTop] = useState(0)
|
children,
|
||||||
|
titlebar,
|
||||||
|
pageName,
|
||||||
|
displayScrollToTopButton = false
|
||||||
|
}: {
|
||||||
|
children?: React.ReactNode
|
||||||
|
titlebar?: React.ReactNode
|
||||||
|
pageName: TPrimaryPageName
|
||||||
|
displayScrollToTopButton?: boolean
|
||||||
|
},
|
||||||
|
ref
|
||||||
|
) => {
|
||||||
|
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [visible, setVisible] = useState(true)
|
||||||
|
const [lastScrollTop, setLastScrollTop] = useState(0)
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
const { current } = usePrimaryPage()
|
||||||
|
|
||||||
useImperativeHandle(
|
useImperativeHandle(
|
||||||
ref,
|
ref,
|
||||||
() => ({
|
() => ({
|
||||||
scrollToTop: () => {
|
scrollToTop: () => {
|
||||||
scrollAreaRef.current?.scrollTo({ top: 0 })
|
if (isSmallScreen) {
|
||||||
}
|
window.scrollTo({ top: 0 })
|
||||||
}),
|
return
|
||||||
[]
|
}
|
||||||
)
|
scrollAreaRef.current?.scrollTo({ top: 0 })
|
||||||
|
}
|
||||||
|
}),
|
||||||
|
[]
|
||||||
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const handleScroll = () => {
|
if (isSmallScreen) {
|
||||||
const scrollTop = scrollAreaRef.current?.scrollTop || 0
|
window.scrollTo({ top: 0 })
|
||||||
const diff = scrollTop - lastScrollTop
|
|
||||||
if (scrollTop <= 100) {
|
|
||||||
setVisible(true)
|
setVisible(true)
|
||||||
setLastScrollTop(scrollTop)
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
}, [current])
|
||||||
|
|
||||||
if (diff > 20) {
|
useEffect(() => {
|
||||||
setVisible(false)
|
if (current !== pageName) return
|
||||||
setLastScrollTop(scrollTop)
|
|
||||||
} else if (diff < -20) {
|
const handleScroll = () => {
|
||||||
setVisible(true)
|
const scrollTop = (isSmallScreen ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0
|
||||||
setLastScrollTop(scrollTop)
|
const diff = scrollTop - lastScrollTop
|
||||||
|
if (scrollTop <= 800) {
|
||||||
|
setVisible(true)
|
||||||
|
setLastScrollTop(scrollTop)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if (diff > 20) {
|
||||||
|
setVisible(false)
|
||||||
|
setLastScrollTop(scrollTop)
|
||||||
|
} else if (diff < -20) {
|
||||||
|
setVisible(true)
|
||||||
|
setLastScrollTop(scrollTop)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const scrollArea = scrollAreaRef.current
|
if (isSmallScreen) {
|
||||||
scrollArea?.addEventListener('scroll', handleScroll)
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
return () => {
|
||||||
|
window.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return () => {
|
scrollAreaRef.current?.addEventListener('scroll', handleScroll)
|
||||||
scrollArea?.removeEventListener('scroll', handleScroll)
|
return () => {
|
||||||
}
|
scrollAreaRef.current?.removeEventListener('scroll', handleScroll)
|
||||||
}, [lastScrollTop])
|
}
|
||||||
|
}, [lastScrollTop, isSmallScreen, current])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea
|
<ScrollArea
|
||||||
ref={scrollAreaRef}
|
className="sm:h-screen sm:overflow-auto"
|
||||||
className="h-full w-full"
|
scrollBarClassName="sm:z-50"
|
||||||
scrollBarClassName="pt-9 xl:pt-0 max-sm:pt-11"
|
ref={scrollAreaRef}
|
||||||
>
|
style={{
|
||||||
<PrimaryPageTitlebar visible={visible} />
|
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||||
<div className="sm:px-4 pb-4 pt-11 xl:pt-4">{children}</div>
|
}}
|
||||||
<ScrollToTopButton scrollAreaRef={scrollAreaRef} visible={visible && lastScrollTop > 500} />
|
>
|
||||||
</ScrollArea>
|
{titlebar && (
|
||||||
)
|
<PrimaryPageTitlebar visible={!isSmallScreen || visible}>{titlebar}</PrimaryPageTitlebar>
|
||||||
})
|
)}
|
||||||
|
<div className="overflow-x-hidden">{children}</div>
|
||||||
|
{displayScrollToTopButton && (
|
||||||
|
<ScrollToTopButton
|
||||||
|
scrollAreaRef={scrollAreaRef}
|
||||||
|
visible={visible && lastScrollTop > 500}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
{isSmallScreen && <BottomNavigationBar visible={visible} />}
|
||||||
|
</ScrollArea>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
PrimaryPageLayout.displayName = 'PrimaryPageLayout'
|
PrimaryPageLayout.displayName = 'PrimaryPageLayout'
|
||||||
export default PrimaryPageLayout
|
export default PrimaryPageLayout
|
||||||
|
|
||||||
@@ -72,43 +114,16 @@ export type TPrimaryPageLayoutRef = {
|
|||||||
scrollToTop: () => void
|
scrollToTop: () => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function PrimaryPageTitlebar({ visible = true }: { visible?: boolean }) {
|
function PrimaryPageTitlebar({
|
||||||
const { isSmallScreen } = useScreenSize()
|
children,
|
||||||
|
visible = true
|
||||||
if (isSmallScreen) {
|
}: {
|
||||||
return (
|
children?: React.ReactNode
|
||||||
<Titlebar
|
visible?: boolean
|
||||||
className="justify-between px-4 transition-transform duration-500"
|
}) {
|
||||||
visible={visible}
|
|
||||||
>
|
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<div className="-translate-y-0.5">
|
|
||||||
<Logo className="h-8" />
|
|
||||||
</div>
|
|
||||||
<ThemeToggle variant="small-screen-titlebar" />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-1 items-center">
|
|
||||||
<SearchButton variant="small-screen-titlebar" />
|
|
||||||
<PostButton variant="small-screen-titlebar" />
|
|
||||||
<RelaySettingsButton variant="small-screen-titlebar" />
|
|
||||||
<NotificationButton variant="small-screen-titlebar" />
|
|
||||||
<AccountButton variant="small-screen-titlebar" />
|
|
||||||
</div>
|
|
||||||
</Titlebar>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Titlebar className="justify-between xl:hidden">
|
<Titlebar className="h-12 p-1" visible={visible}>
|
||||||
<div className="flex gap-2 items-center">
|
{children}
|
||||||
<AccountButton />
|
|
||||||
<PostButton />
|
|
||||||
<SearchButton />
|
|
||||||
</div>
|
|
||||||
<div className="flex gap-2 items-center">
|
|
||||||
<RelaySettingsButton />
|
|
||||||
<NotificationButton />
|
|
||||||
</div>
|
|
||||||
</Titlebar>
|
</Titlebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,45 @@
|
|||||||
import BackButton from '@/components/BackButton'
|
import BackButton from '@/components/BackButton'
|
||||||
|
import BottomNavigationBar from '@/components/BottomNavigationBar'
|
||||||
import ScrollToTopButton from '@/components/ScrollToTopButton'
|
import ScrollToTopButton from '@/components/ScrollToTopButton'
|
||||||
import ThemeToggle from '@/components/ThemeToggle'
|
import ThemeToggle from '@/components/ThemeToggle'
|
||||||
import { Titlebar } from '@/components/Titlebar'
|
import { Titlebar } from '@/components/Titlebar'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
export default function SecondaryPageLayout({
|
export default function SecondaryPageLayout({
|
||||||
children,
|
children,
|
||||||
|
index,
|
||||||
titlebarContent,
|
titlebarContent,
|
||||||
hideBackButton = false,
|
hideBackButton = false,
|
||||||
hideScrollToTopButton = false
|
displayScrollToTopButton = false
|
||||||
}: {
|
}: {
|
||||||
children?: React.ReactNode
|
children?: React.ReactNode
|
||||||
|
index?: number
|
||||||
titlebarContent?: React.ReactNode
|
titlebarContent?: React.ReactNode
|
||||||
hideBackButton?: boolean
|
hideBackButton?: boolean
|
||||||
hideScrollToTopButton?: boolean
|
displayScrollToTopButton?: boolean
|
||||||
}): JSX.Element {
|
}): JSX.Element {
|
||||||
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
const scrollAreaRef = useRef<HTMLDivElement>(null)
|
||||||
const [visible, setVisible] = useState(true)
|
const [visible, setVisible] = useState(true)
|
||||||
const [lastScrollTop, setLastScrollTop] = useState(0)
|
const [lastScrollTop, setLastScrollTop] = useState(0)
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
const { currentIndex } = useSecondaryPage()
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (isSmallScreen) {
|
||||||
|
window.scrollTo({ top: 0 })
|
||||||
|
setVisible(true)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentIndex !== index) return
|
||||||
|
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const scrollTop = scrollAreaRef.current?.scrollTop || 0
|
const scrollTop = (isSmallScreen ? window.scrollY : scrollAreaRef.current?.scrollTop) || 0
|
||||||
const diff = scrollTop - lastScrollTop
|
const diff = scrollTop - lastScrollTop
|
||||||
if (scrollTop <= 100) {
|
if (scrollTop <= 100) {
|
||||||
setVisible(true)
|
setVisible(true)
|
||||||
@@ -40,26 +56,38 @@ export default function SecondaryPageLayout({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const scrollArea = scrollAreaRef.current
|
if (isSmallScreen) {
|
||||||
scrollArea?.addEventListener('scroll', handleScroll)
|
window.addEventListener('scroll', handleScroll)
|
||||||
|
return () => {
|
||||||
return () => {
|
window.removeEventListener('scroll', handleScroll)
|
||||||
scrollArea?.removeEventListener('scroll', handleScroll)
|
}
|
||||||
}
|
}
|
||||||
}, [lastScrollTop])
|
|
||||||
|
scrollAreaRef.current?.addEventListener('scroll', handleScroll)
|
||||||
|
return () => {
|
||||||
|
scrollAreaRef.current?.removeEventListener('scroll', handleScroll)
|
||||||
|
}
|
||||||
|
}, [lastScrollTop, isSmallScreen, currentIndex])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollArea ref={scrollAreaRef} className="h-full" scrollBarClassName="sm:pt-9 pt-11">
|
<ScrollArea
|
||||||
|
className="sm:h-screen sm:overflow-auto"
|
||||||
|
scrollBarClassName="sm:z-50"
|
||||||
|
ref={scrollAreaRef}
|
||||||
|
style={{
|
||||||
|
paddingBottom: 'env(safe-area-inset-bottom)'
|
||||||
|
}}
|
||||||
|
>
|
||||||
<SecondaryPageTitlebar
|
<SecondaryPageTitlebar
|
||||||
content={titlebarContent}
|
content={titlebarContent}
|
||||||
hideBackButton={hideBackButton}
|
hideBackButton={hideBackButton}
|
||||||
visible={visible}
|
visible={visible}
|
||||||
/>
|
/>
|
||||||
<div className="sm:px-4 pb-4 pt-11 w-full h-full">{children}</div>
|
<div className="pb-4 mt-2">{children}</div>
|
||||||
<ScrollToTopButton
|
{displayScrollToTopButton && (
|
||||||
scrollAreaRef={scrollAreaRef}
|
<ScrollToTopButton scrollAreaRef={scrollAreaRef} visible={visible && lastScrollTop > 500} />
|
||||||
visible={!hideScrollToTopButton && visible && lastScrollTop > 500}
|
)}
|
||||||
/>
|
{isSmallScreen && <BottomNavigationBar visible={visible} />}
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -77,18 +105,16 @@ export function SecondaryPageTitlebar({
|
|||||||
|
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
<Titlebar className="pl-2" visible={visible}>
|
<Titlebar className="h-12 flex gap-1 p-1 items-center font-semibold" visible={visible}>
|
||||||
<BackButton hide={hideBackButton} variant="small-screen-titlebar" />
|
<BackButton hide={hideBackButton}>{content}</BackButton>
|
||||||
<div className="truncate text-lg">{content}</div>
|
|
||||||
</Titlebar>
|
</Titlebar>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Titlebar className="justify-between">
|
<Titlebar className="h-12 flex gap-1 p-1 justify-between items-center font-semibold">
|
||||||
<div className="flex items-center gap-1 flex-1 w-0">
|
<div className="flex items-center gap-1 flex-1 w-0">
|
||||||
<BackButton hide={hideBackButton} />
|
<BackButton hide={hideBackButton}>{content}</BackButton>
|
||||||
<div className="truncate text-lg">{content}</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex-shrink-0 flex items-center">
|
<div className="flex-shrink-0 flex items-center">
|
||||||
<ThemeToggle />
|
<ThemeToggle />
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ export const toFollowingList = (pubkey: string) => {
|
|||||||
return `/users/${npub}/following`
|
return `/users/${npub}/following`
|
||||||
}
|
}
|
||||||
export const toRelaySettings = () => '/relay-settings'
|
export const toRelaySettings = () => '/relay-settings'
|
||||||
export const toNotifications = () => '/notifications'
|
export const toSettings = () => '/settings'
|
||||||
|
|
||||||
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
|
export const toNoStrudelProfile = (id: string) => `https://nostrudel.ninja/#/u/${id}`
|
||||||
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
|
export const toNoStrudelNote = (id: string) => `https://nostrudel.ninja/#/n/${id}`
|
||||||
|
|||||||
@@ -14,3 +14,7 @@ export function normalizeUrl(url: string): string {
|
|||||||
p.hash = ''
|
p.hash = ''
|
||||||
return p.toString()
|
return p.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function simplifyUrl(url: string): string {
|
||||||
|
return url.replace('wss://', '').replace('ws://', '').replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|||||||
107
src/pages/primary/MePage/index.tsx
Normal file
107
src/pages/primary/MePage/index.tsx
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import AccountManager from '@/components/AccountManager'
|
||||||
|
import LoginDialog from '@/components/LoginDialog'
|
||||||
|
import LogoutDialog from '@/components/LogoutDialog'
|
||||||
|
import PubkeyCopy from '@/components/PubkeyCopy'
|
||||||
|
import QrCodePopover from '@/components/QrCodePopover'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { SimpleUserAvatar } from '@/components/UserAvatar'
|
||||||
|
import { SimpleUsername } from '@/components/Username'
|
||||||
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||||
|
import { toProfile, toSettings } from '@/lib/link'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { ArrowDownUp, ChevronRight, LogOut, Settings, UserRound } from 'lucide-react'
|
||||||
|
import { HTMLProps, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function MePage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const [loginDialogOpen, setLoginDialogOpen] = useState(false)
|
||||||
|
const [logoutDialogOpen, setLogoutDialogOpen] = useState(false)
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
return (
|
||||||
|
<PrimaryPageLayout pageName="home">
|
||||||
|
<div className="flex flex-col p-4 gap-4 overflow-auto">
|
||||||
|
<AccountManager />
|
||||||
|
</div>
|
||||||
|
</PrimaryPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<PrimaryPageLayout pageName="home">
|
||||||
|
<div className="flex gap-4 items-center p-4">
|
||||||
|
<SimpleUserAvatar userId={pubkey} size="big" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<SimpleUsername
|
||||||
|
className="text-xl font-semibold truncate"
|
||||||
|
userId={pubkey}
|
||||||
|
skeletonClassName="h-6 w-32"
|
||||||
|
/>
|
||||||
|
<div className="flex gap-1 mt-1">
|
||||||
|
<PubkeyCopy pubkey={pubkey} />
|
||||||
|
<QrCodePopover pubkey={pubkey} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-4">
|
||||||
|
<ItemGroup>
|
||||||
|
<Item onClick={() => push(toProfile(pubkey))}>
|
||||||
|
<UserRound />
|
||||||
|
{t('Profile')}
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Item onClick={() => push(toSettings())}>
|
||||||
|
<Settings />
|
||||||
|
{t('Settings')}
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Item onClick={() => setLoginDialogOpen(true)}>
|
||||||
|
<ArrowDownUp /> {t('Switch account')}
|
||||||
|
</Item>
|
||||||
|
<Separator className="bg-background" />
|
||||||
|
<Item
|
||||||
|
className="text-destructive focus:text-destructive"
|
||||||
|
onClick={() => setLogoutDialogOpen(true)}
|
||||||
|
hideChevron
|
||||||
|
>
|
||||||
|
<LogOut />
|
||||||
|
{t('Logout')}
|
||||||
|
</Item>
|
||||||
|
</ItemGroup>
|
||||||
|
</div>
|
||||||
|
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
||||||
|
<LogoutDialog open={logoutDialogOpen} setOpen={setLogoutDialogOpen} />
|
||||||
|
</PrimaryPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function Item({
|
||||||
|
children,
|
||||||
|
className,
|
||||||
|
hideChevron = false,
|
||||||
|
...props
|
||||||
|
}: HTMLProps<HTMLDivElement> & { hideChevron?: boolean }) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between px-4 py-2 w-full clickable rounded-lg [&_svg]:size-4 [&_svg]:shrink-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<div className="flex items-center gap-4">{children}</div>
|
||||||
|
{!hideChevron && <ChevronRight />}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function ItemGroup({ children }: { children: React.ReactNode }) {
|
||||||
|
return <div className="rounded-lg m-4 bg-muted/40">{children}</div>
|
||||||
|
}
|
||||||
74
src/pages/primary/NoteListPage/FeedButton.tsx
Normal file
74
src/pages/primary/NoteListPage/FeedButton.tsx
Normal file
@@ -0,0 +1,74 @@
|
|||||||
|
import FeedSwitcher from '@/components/FeedSwitcher'
|
||||||
|
import { Drawer, DrawerContent } from '@/components/ui/drawer'
|
||||||
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { simplifyUrl } from '@/lib/url'
|
||||||
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
|
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||||
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import { ChevronDown, Server, UsersRound } from 'lucide-react'
|
||||||
|
import { forwardRef, HTMLAttributes, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function FeedButton() {
|
||||||
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
if (isSmallScreen) {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FeedSwitcherTrigger onClick={() => setOpen(true)} />
|
||||||
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
|
<DrawerContent className="max-h-[80vh]">
|
||||||
|
<div className="p-4 overflow-auto">
|
||||||
|
<FeedSwitcher close={() => setOpen(false)} />
|
||||||
|
</div>
|
||||||
|
</DrawerContent>
|
||||||
|
</Drawer>
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Popover open={open} onOpenChange={setOpen}>
|
||||||
|
<PopoverTrigger asChild>
|
||||||
|
<FeedSwitcherTrigger />
|
||||||
|
</PopoverTrigger>
|
||||||
|
<PopoverContent side="bottom" className="w-96 p-4 max-h-[80vh] overflow-auto">
|
||||||
|
<FeedSwitcher close={() => setOpen(false)} />
|
||||||
|
</PopoverContent>
|
||||||
|
</Popover>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeedSwitcherTrigger = forwardRef<HTMLDivElement, HTMLAttributes<HTMLDivElement>>(
|
||||||
|
(props, ref) => {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { feedType } = useFeed()
|
||||||
|
const { relayGroups, temporaryRelayUrls } = useRelaySettings()
|
||||||
|
const activeGroup = relayGroups.find((group) => group.isActive)
|
||||||
|
const title =
|
||||||
|
feedType === 'following'
|
||||||
|
? t('Following')
|
||||||
|
: temporaryRelayUrls.length > 0
|
||||||
|
? temporaryRelayUrls.length === 1
|
||||||
|
? simplifyUrl(temporaryRelayUrls[0])
|
||||||
|
: t('Temporary')
|
||||||
|
: activeGroup
|
||||||
|
? activeGroup.relayUrls.length === 1
|
||||||
|
? simplifyUrl(activeGroup.relayUrls[0])
|
||||||
|
: activeGroup.groupName
|
||||||
|
: t('Choose a relay collection')
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 clickable px-3 h-full rounded-lg"
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{feedType === 'following' ? <UsersRound /> : <Server />}
|
||||||
|
<div className="text-lg font-semibold">{title}</div>
|
||||||
|
<ChevronDown />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
17
src/pages/primary/NoteListPage/SearchButton.tsx
Normal file
17
src/pages/primary/NoteListPage/SearchButton.tsx
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
import { SearchDialog } from '@/components/SearchDialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Search } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
|
||||||
|
export default function SearchButton() {
|
||||||
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<Button variant="ghost" size="titlebar-icon" onClick={() => setOpen(true)}>
|
||||||
|
<Search />
|
||||||
|
</Button>
|
||||||
|
<SearchDialog open={open} setOpen={setOpen} />
|
||||||
|
</>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,48 +1,69 @@
|
|||||||
import NoteList from '@/components/NoteList'
|
import NoteList from '@/components/NoteList'
|
||||||
import RelaySettings from '@/components/RelaySettings'
|
import { BIG_RELAY_URLS } from '@/constants'
|
||||||
import { Button } from '@/components/ui/button'
|
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
|
||||||
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||||
|
import { useFeed } from '@/providers/FeedProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||||
import { useEffect, useRef } from 'react'
|
import { useEffect, useMemo, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import FeedButton from './FeedButton'
|
||||||
|
import SearchButton from './SearchButton'
|
||||||
|
|
||||||
export default function NoteListPage() {
|
export default function NoteListPage() {
|
||||||
|
const { t } = useTranslation()
|
||||||
const layoutRef = useRef<{ scrollToTop: () => void }>(null)
|
const layoutRef = useRef<{ scrollToTop: () => void }>(null)
|
||||||
const { relayUrls } = useRelaySettings()
|
const { feedType } = useFeed()
|
||||||
const relayUrlsString = JSON.stringify(relayUrls)
|
const { relayUrls, temporaryRelayUrls } = useRelaySettings()
|
||||||
|
const { pubkey, relayList, followings } = useNostr()
|
||||||
|
const urls = useMemo(() => {
|
||||||
|
return feedType === 'following'
|
||||||
|
? relayList?.read.length
|
||||||
|
? relayList.read.slice(0, 4)
|
||||||
|
: BIG_RELAY_URLS
|
||||||
|
: temporaryRelayUrls.length > 0
|
||||||
|
? temporaryRelayUrls
|
||||||
|
: relayUrls
|
||||||
|
}, [feedType, relayUrls, relayList, temporaryRelayUrls])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (layoutRef.current) {
|
if (layoutRef.current) {
|
||||||
layoutRef.current.scrollToTop()
|
layoutRef.current.scrollToTop()
|
||||||
}
|
}
|
||||||
}, [relayUrlsString])
|
}, [JSON.stringify(relayUrls), feedType])
|
||||||
|
|
||||||
if (!relayUrls.length) {
|
|
||||||
return (
|
|
||||||
<PrimaryPageLayout>
|
|
||||||
<div className="w-full text-center">
|
|
||||||
<Popover>
|
|
||||||
<PopoverTrigger asChild>
|
|
||||||
<Button title="relay settings" size="lg">
|
|
||||||
Choose a relay group
|
|
||||||
</Button>
|
|
||||||
</PopoverTrigger>
|
|
||||||
<PopoverContent className="w-96 h-[450px] p-0">
|
|
||||||
<ScrollArea className="h-full">
|
|
||||||
<div className="p-4">
|
|
||||||
<RelaySettings />
|
|
||||||
</div>
|
|
||||||
</ScrollArea>
|
|
||||||
</PopoverContent>
|
|
||||||
</Popover>
|
|
||||||
</div>
|
|
||||||
</PrimaryPageLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<PrimaryPageLayout ref={layoutRef}>
|
<PrimaryPageLayout
|
||||||
<NoteList relayUrls={relayUrls} />
|
pageName="home"
|
||||||
|
ref={layoutRef}
|
||||||
|
titlebar={<NoteListPageTitlebar />}
|
||||||
|
displayScrollToTopButton
|
||||||
|
>
|
||||||
|
{!!urls.length && (feedType === 'relays' || (relayList && followings)) ? (
|
||||||
|
<NoteList
|
||||||
|
relayUrls={urls}
|
||||||
|
filter={
|
||||||
|
feedType === 'following'
|
||||||
|
? {
|
||||||
|
authors:
|
||||||
|
pubkey && !followings?.includes(pubkey)
|
||||||
|
? [...(followings ?? []), pubkey]
|
||||||
|
: (followings ?? [])
|
||||||
|
}
|
||||||
|
: {}
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="text-center text-sm text-muted-foreground">{t('loading...')}</div>
|
||||||
|
)}
|
||||||
</PrimaryPageLayout>
|
</PrimaryPageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function NoteListPageTitlebar() {
|
||||||
|
return (
|
||||||
|
<div className="flex gap-1 items-center h-full justify-between">
|
||||||
|
<FeedButton />
|
||||||
|
<SearchButton />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
29
src/pages/primary/NotificationListPage/index.tsx
Normal file
29
src/pages/primary/NotificationListPage/index.tsx
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
import NotificationList from '@/components/NotificationList'
|
||||||
|
import PrimaryPageLayout from '@/layouts/PrimaryPageLayout'
|
||||||
|
import { Bell } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function NotificationListPage() {
|
||||||
|
return (
|
||||||
|
<PrimaryPageLayout
|
||||||
|
pageName="notifications"
|
||||||
|
titlebar={<NotificationListPageTitlebar />}
|
||||||
|
displayScrollToTopButton
|
||||||
|
>
|
||||||
|
<div className="px-4">
|
||||||
|
<NotificationList />
|
||||||
|
</div>
|
||||||
|
</PrimaryPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function NotificationListPageTitlebar() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex gap-2 items-center h-full pl-3">
|
||||||
|
<Bell />
|
||||||
|
<div className="text-lg font-semibold">{t('Notifications')}</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -4,7 +4,7 @@ import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
|||||||
import { useEffect, useRef, useState } from 'react'
|
import { useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function FollowingListPage({ id }: { id?: string }) {
|
export default function FollowingListPage({ id, index }: { id?: string; index?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { profile } = useFetchProfile(id)
|
const { profile } = useFetchProfile(id)
|
||||||
const { followings } = useFetchFollowings(profile?.pubkey)
|
const { followings } = useFetchFollowings(profile?.pubkey)
|
||||||
@@ -45,13 +45,15 @@ export default function FollowingListPage({ id }: { id?: string }) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout
|
<SecondaryPageLayout
|
||||||
|
index={index}
|
||||||
titlebarContent={
|
titlebarContent={
|
||||||
profile?.username
|
profile?.username
|
||||||
? t("username's following", { username: profile.username })
|
? t("username's following", { username: profile.username })
|
||||||
: t('following')
|
: t('Following')
|
||||||
}
|
}
|
||||||
|
displayScrollToTopButton
|
||||||
>
|
>
|
||||||
<div className="space-y-2 max-sm:px-4">
|
<div className="space-y-2 px-4">
|
||||||
{visibleFollowings.map((pubkey, index) => (
|
{visibleFollowings.map((pubkey, index) => (
|
||||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage({ index }: { index?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout hideBackButton hideScrollToTopButton>
|
<SecondaryPageLayout index={index} hideBackButton>
|
||||||
<div className="text-muted-foreground w-full h-full flex items-center justify-center">
|
<div className="text-muted-foreground w-full h-screen flex items-center justify-center">
|
||||||
{t('Welcome! 🥳')}
|
{t('Welcome! 🥳')}
|
||||||
</div>
|
</div>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
|
|
||||||
export default function LoadingPage({ title }: { title?: string }) {
|
export default function LoadingPage({ title, index }: { title?: string; index?: number }) {
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={title}>
|
<SecondaryPageLayout index={index} titlebarContent={title}>
|
||||||
<div className="text-muted-foreground text-center">
|
<div className="text-muted-foreground text-center">
|
||||||
<div>Loading...</div>
|
<div>Loading...</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,11 +1,11 @@
|
|||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function NotFoundPage() {
|
export default function NotFoundPage({ index }: { index?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout hideBackButton>
|
<SecondaryPageLayout index={index} hideBackButton>
|
||||||
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2">
|
<div className="text-muted-foreground w-full h-full flex flex-col items-center justify-center gap-2">
|
||||||
<div>{t('Lost in the void')} 🌌</div>
|
<div>{t('Lost in the void')} 🌌</div>
|
||||||
<div>(404)</div>
|
<div>(404)</div>
|
||||||
|
|||||||
@@ -1,13 +1,13 @@
|
|||||||
import NoteList from '@/components/NoteList'
|
import NoteList from '@/components/NoteList'
|
||||||
import { useSearchParams } from '@/hooks'
|
import { useSearchParams } from '@/hooks'
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { isWebsocketUrl } from '@/lib/url'
|
import { isWebsocketUrl, simplifyUrl } from '@/lib/url'
|
||||||
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
||||||
import { Filter } from 'nostr-tools'
|
import { Filter } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function NoteListPage() {
|
export default function NoteListPage({ index }: { index?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { relayUrls, searchableRelayUrls } = useRelaySettings()
|
const { relayUrls, searchableRelayUrls } = useRelaySettings()
|
||||||
const { searchParams } = useSearchParams()
|
const { searchParams } = useSearchParams()
|
||||||
@@ -27,18 +27,18 @@ export default function NoteListPage() {
|
|||||||
}
|
}
|
||||||
const search = searchParams.get('s')
|
const search = searchParams.get('s')
|
||||||
if (search) {
|
if (search) {
|
||||||
return { title: `${t('search')}: ${search}`, filter: { search }, urls: searchableRelayUrls }
|
return { title: `${t('Search')}: ${search}`, filter: { search }, urls: searchableRelayUrls }
|
||||||
}
|
}
|
||||||
const relayUrl = searchParams.get('relay')
|
const relayUrl = searchParams.get('relay')
|
||||||
if (relayUrl && isWebsocketUrl(relayUrl)) {
|
if (relayUrl && isWebsocketUrl(relayUrl)) {
|
||||||
return { title: relayUrl, urls: [relayUrl] }
|
return { title: simplifyUrl(relayUrl), urls: [relayUrl] }
|
||||||
}
|
}
|
||||||
return { urls: relayUrls }
|
return { urls: relayUrls }
|
||||||
}, [searchParams, relayUrlsString])
|
}, [searchParams, relayUrlsString])
|
||||||
|
|
||||||
if (filter?.search && searchableRelayUrls.length === 0) {
|
if (filter?.search && searchableRelayUrls.length === 0) {
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={title}>
|
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
{t('The relays you are connected to do not support search')}
|
{t('The relays you are connected to do not support search')}
|
||||||
</div>
|
</div>
|
||||||
@@ -47,7 +47,7 @@ export default function NoteListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={title}>
|
<SecondaryPageLayout index={index} titlebarContent={title}>
|
||||||
<NoteList key={title} filter={filter} relayUrls={urls} />
|
<NoteList key={title} filter={filter} relayUrls={urls} />
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ import { useMemo } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import NotFoundPage from '../NotFoundPage'
|
import NotFoundPage from '../NotFoundPage'
|
||||||
|
|
||||||
export default function NotePage({ id }: { id?: string }) {
|
export default function NotePage({ id, index }: { id?: string; index?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { event, isFetching } = useFetchEvent(id)
|
const { event, isFetching } = useFetchEvent(id)
|
||||||
const parentEventId = useMemo(() => getParentEventId(event), [event])
|
const parentEventId = useMemo(() => getParentEventId(event), [event])
|
||||||
@@ -22,8 +22,8 @@ export default function NotePage({ id }: { id?: string }) {
|
|||||||
|
|
||||||
if (!event && isFetching) {
|
if (!event && isFetching) {
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={t('note')}>
|
<SecondaryPageLayout index={index} titlebarContent={t('Note')} displayScrollToTopButton>
|
||||||
<div className="max-sm:px-4">
|
<div className="px-4">
|
||||||
<Skeleton className="w-10 h-10 rounded-full" />
|
<Skeleton className="w-10 h-10 rounded-full" />
|
||||||
</div>
|
</div>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
@@ -32,14 +32,14 @@ export default function NotePage({ id }: { id?: string }) {
|
|||||||
if (!event) return <NotFoundPage />
|
if (!event) return <NotFoundPage />
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={t('note')}>
|
<SecondaryPageLayout index={index} titlebarContent={t('Note')}>
|
||||||
<div className="max-sm:px-4">
|
<div className="px-4">
|
||||||
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
|
<ParentNote key={`root-note-${event.id}`} eventId={rootEventId} />
|
||||||
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
|
<ParentNote key={`parent-note-${event.id}`} eventId={parentEventId} />
|
||||||
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
|
<Note key={`note-${event.id}`} event={event} fetchNoteStats />
|
||||||
</div>
|
</div>
|
||||||
<Separator className="mb-2 mt-4" />
|
<Separator className="mb-2 mt-4" />
|
||||||
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} className="max-sm:px-2" />
|
<ReplyNoteList key={`reply-note-list-${event.id}`} event={event} className="px-2" />
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -52,7 +52,7 @@ function ParentNote({ eventId }: { eventId?: string }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card
|
<Card
|
||||||
className="flex space-x-1 p-1 items-center hover:bg-muted/50 cursor-pointer text-sm text-muted-foreground hover:text-foreground"
|
className="flex space-x-1 p-1 items-center clickable text-sm text-muted-foreground hover:text-foreground"
|
||||||
onClick={() => push(toNote(event))}
|
onClick={() => push(toNote(event))}
|
||||||
>
|
>
|
||||||
<UserAvatar userId={event.pubkey} size="tiny" />
|
<UserAvatar userId={event.pubkey} size="tiny" />
|
||||||
|
|||||||
@@ -1,15 +0,0 @@
|
|||||||
import NotificationList from '@/components/NotificationList'
|
|
||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
|
||||||
import { useTranslation } from 'react-i18next'
|
|
||||||
|
|
||||||
export default function NotificationListPage() {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SecondaryPageLayout titlebarContent={t('notifications')}>
|
|
||||||
<div className="max-sm:px-4">
|
|
||||||
<NotificationList />
|
|
||||||
</div>
|
|
||||||
</SecondaryPageLayout>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
const LIMIT = 50
|
const LIMIT = 50
|
||||||
|
|
||||||
export default function ProfileListPage() {
|
export default function ProfileListPage({ index }: { index?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { searchParams } = useSearchParams()
|
const { searchParams } = useSearchParams()
|
||||||
const { relayUrls, searchableRelayUrls } = useRelaySettings()
|
const { relayUrls, searchableRelayUrls } = useRelaySettings()
|
||||||
@@ -30,7 +30,7 @@ export default function ProfileListPage() {
|
|||||||
return filter.search ? searchableRelayUrls : relayUrls
|
return filter.search ? searchableRelayUrls : relayUrls
|
||||||
}, [relayUrls, searchableRelayUrls, filter])
|
}, [relayUrls, searchableRelayUrls, filter])
|
||||||
const title = useMemo(() => {
|
const title = useMemo(() => {
|
||||||
return filter.search ? `${t('search')}: ${filter.search}` : t('all users')
|
return filter.search ? `${t('Search')}: ${filter.search}` : t('All users')
|
||||||
}, [filter])
|
}, [filter])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -78,8 +78,8 @@ export default function ProfileListPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={title}>
|
<SecondaryPageLayout index={index} titlebarContent={title} displayScrollToTopButton>
|
||||||
<div className="space-y-2 max-sm:px-4">
|
<div className="space-y-2 px-4">
|
||||||
{Array.from(pubkeySet).map((pubkey, index) => (
|
{Array.from(pubkeySet).map((pubkey, index) => (
|
||||||
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
<UserItem key={`${index}-${pubkey}`} pubkey={pubkey} />
|
||||||
))}
|
))}
|
||||||
|
|||||||
@@ -3,8 +3,9 @@ import Nip05 from '@/components/Nip05'
|
|||||||
import NoteList from '@/components/NoteList'
|
import NoteList from '@/components/NoteList'
|
||||||
import ProfileAbout from '@/components/ProfileAbout'
|
import ProfileAbout from '@/components/ProfileAbout'
|
||||||
import ProfileBanner from '@/components/ProfileBanner'
|
import ProfileBanner from '@/components/ProfileBanner'
|
||||||
|
import PubkeyCopy from '@/components/PubkeyCopy'
|
||||||
|
import QrCodePopover from '@/components/QrCodePopover'
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'
|
||||||
import { Separator } from '@/components/ui/separator'
|
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
import { useFetchFollowings, useFetchProfile } from '@/hooks'
|
||||||
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
||||||
@@ -18,10 +19,8 @@ import { useRelaySettings } from '@/providers/RelaySettingsProvider'
|
|||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import NotFoundPage from '../NotFoundPage'
|
import NotFoundPage from '../NotFoundPage'
|
||||||
import PubkeyCopy from './PubkeyCopy'
|
|
||||||
import QrCodePopover from './QrCodePopover'
|
|
||||||
|
|
||||||
export default function ProfilePage({ id }: { id?: string }) {
|
export default function ProfilePage({ id, index }: { id?: string; index?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { profile, isFetching } = useFetchProfile(id)
|
const { profile, isFetching } = useFetchProfile(id)
|
||||||
const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey)
|
const { relayList, isFetching: isFetchingRelayInfo } = useFetchRelayList(profile?.pubkey)
|
||||||
@@ -46,8 +45,8 @@ export default function ProfilePage({ id }: { id?: string }) {
|
|||||||
|
|
||||||
if (!profile && isFetching) {
|
if (!profile && isFetching) {
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout>
|
<SecondaryPageLayout index={index}>
|
||||||
<div className="max-sm:px-4">
|
<div className="px-4">
|
||||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||||
<Skeleton className="w-full h-full object-cover rounded-lg" />
|
<Skeleton className="w-full h-full object-cover rounded-lg" />
|
||||||
<Skeleton className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background rounded-full" />
|
<Skeleton className="w-24 h-24 absolute bottom-0 left-4 translate-y-1/2 border-4 border-background rounded-full" />
|
||||||
@@ -62,8 +61,8 @@ export default function ProfilePage({ id }: { id?: string }) {
|
|||||||
|
|
||||||
const { banner, username, nip05, about, avatar, pubkey } = profile
|
const { banner, username, nip05, about, avatar, pubkey } = profile
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={username}>
|
<SecondaryPageLayout index={index} titlebarContent={username} displayScrollToTopButton>
|
||||||
<div className="max-sm:px-4">
|
<div className="px-4">
|
||||||
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
<div className="relative bg-cover bg-center w-full aspect-[21/9] rounded-lg mb-2">
|
||||||
<ProfileBanner
|
<ProfileBanner
|
||||||
banner={banner}
|
banner={banner}
|
||||||
@@ -102,9 +101,8 @@ export default function ProfilePage({ id }: { id?: string }) {
|
|||||||
</SecondaryPageLink>
|
</SecondaryPageLink>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Separator className="hidden sm:block mt-4 sm:my-4" />
|
|
||||||
{!isFetchingRelayInfo && (
|
{!isFetchingRelayInfo && (
|
||||||
<NoteList filter={{ authors: [pubkey] }} relayUrls={relayUrls} className="max-sm:mt-2" />
|
<NoteList filter={{ authors: [pubkey] }} relayUrls={relayUrls} className="mt-2" />
|
||||||
)}
|
)}
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import RelaySettings from '@/components/RelaySettings'
|
|||||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
export default function RelaySettingsPage() {
|
export default function RelaySettingsPage({ index }: { index?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SecondaryPageLayout titlebarContent={t('Relay settings')}>
|
<SecondaryPageLayout index={index} titlebarContent={t('Relay settings')}>
|
||||||
<div className="max-sm:px-4">
|
<div className="px-4">
|
||||||
<RelaySettings hideTitle />
|
<RelaySettings hideTitle />
|
||||||
</div>
|
</div>
|
||||||
</SecondaryPageLayout>
|
</SecondaryPageLayout>
|
||||||
|
|||||||
70
src/pages/secondary/SettingsPage/index.tsx
Normal file
70
src/pages/secondary/SettingsPage/index.tsx
Normal file
@@ -0,0 +1,70 @@
|
|||||||
|
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
||||||
|
import { Select, SelectContent, SelectItem, SelectTrigger } from '@/components/ui/select'
|
||||||
|
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||||
|
import { useTheme } from '@/providers/ThemeProvider'
|
||||||
|
import { TLanguage } from '@/types'
|
||||||
|
import { SelectValue } from '@radix-ui/react-select'
|
||||||
|
import { ChevronRight, Info, Languages, SunMoon } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function SettingsPage({ index }: { index?: number }) {
|
||||||
|
const { t, i18n } = useTranslation()
|
||||||
|
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
||||||
|
const { themeSetting, setThemeSetting } = useTheme()
|
||||||
|
|
||||||
|
const handleLanguageChange = (value: TLanguage) => {
|
||||||
|
i18n.changeLanguage(value)
|
||||||
|
setLanguage(value)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SecondaryPageLayout index={index} titlebarContent={t('Settings')}>
|
||||||
|
<div className="flex justify-between items-center px-4 py-2 [&_svg]:size-4 [&_svg]:shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Languages />
|
||||||
|
<div>{t('Languages')}</div>
|
||||||
|
</div>
|
||||||
|
<Select defaultValue="en" value={language} onValueChange={handleLanguageChange}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="en">{t('English')}</SelectItem>
|
||||||
|
<SelectItem value="zh">{t('Chinese')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between items-center px-4 py-2 [&_svg]:size-4 [&_svg]:shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<SunMoon />
|
||||||
|
<div>{t('Theme')}</div>
|
||||||
|
</div>
|
||||||
|
<Select defaultValue="system" value={themeSetting} onValueChange={setThemeSetting}>
|
||||||
|
<SelectTrigger className="w-32">
|
||||||
|
<SelectValue />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="system">{t('System')}</SelectItem>
|
||||||
|
<SelectItem value="light">{t('Light')}</SelectItem>
|
||||||
|
<SelectItem value="dark">{t('Dark')}</SelectItem>
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
<AboutInfoDialog>
|
||||||
|
<div className="flex clickable justify-between items-center px-4 py-2 h-[52px] rounded-lg [&_svg]:size-4 [&_svg]:shrink-0">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<Info />
|
||||||
|
<div>{t('About')}</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 items-center">
|
||||||
|
<div className="text-muted-foreground">
|
||||||
|
v{__APP_VERSION__} ({__GIT_COMMIT__})
|
||||||
|
</div>
|
||||||
|
<ChevronRight />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</AboutInfoDialog>
|
||||||
|
</SecondaryPageLayout>
|
||||||
|
)
|
||||||
|
}
|
||||||
23
src/providers/FeedProvider.tsx
Normal file
23
src/providers/FeedProvider.tsx
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
import { TFeedType } from '@/types'
|
||||||
|
import { createContext, useContext, useState } from 'react'
|
||||||
|
|
||||||
|
type TFeedContext = {
|
||||||
|
feedType: TFeedType
|
||||||
|
setFeedType: (feedType: TFeedType) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const FeedContext = createContext<TFeedContext | undefined>(undefined)
|
||||||
|
|
||||||
|
export const useFeed = () => {
|
||||||
|
const context = useContext(FeedContext)
|
||||||
|
if (!context) {
|
||||||
|
throw new Error('useFeed must be used within a FeedProvider')
|
||||||
|
}
|
||||||
|
return context
|
||||||
|
}
|
||||||
|
|
||||||
|
export function FeedProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [feedType, setFeedType] = useState<TFeedType>('relays')
|
||||||
|
|
||||||
|
return <FeedContext.Provider value={{ feedType, setFeedType }}>{children}</FeedContext.Provider>
|
||||||
|
}
|
||||||
@@ -9,7 +9,7 @@ import { useNostr } from './NostrProvider'
|
|||||||
type TFollowListContext = {
|
type TFollowListContext = {
|
||||||
followListEvent: Event | undefined
|
followListEvent: Event | undefined
|
||||||
followings: string[]
|
followings: string[]
|
||||||
isReady: boolean
|
isFetching: boolean
|
||||||
follow: (pubkey: string) => Promise<void>
|
follow: (pubkey: string) => Promise<void>
|
||||||
unfollow: (pubkey: string) => Promise<void>
|
unfollow: (pubkey: string) => Promise<void>
|
||||||
}
|
}
|
||||||
@@ -27,33 +27,35 @@ export const useFollowList = () => {
|
|||||||
export function FollowListProvider({ children }: { children: React.ReactNode }) {
|
export function FollowListProvider({ children }: { children: React.ReactNode }) {
|
||||||
const { pubkey: accountPubkey, publish } = useNostr()
|
const { pubkey: accountPubkey, publish } = useNostr()
|
||||||
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
|
const [followListEvent, setFollowListEvent] = useState<Event | undefined>(undefined)
|
||||||
const [isReady, setIsReady] = useState(false)
|
const [isFetching, setIsFetching] = useState(true)
|
||||||
const followings = useMemo(
|
const followings = useMemo(() => {
|
||||||
() =>
|
return Array.from(
|
||||||
followListEvent?.tags
|
new Set(
|
||||||
.filter(tagNameEquals('p'))
|
followListEvent?.tags
|
||||||
.map(([, pubkey]) => pubkey)
|
.filter(tagNameEquals('p'))
|
||||||
.filter(Boolean)
|
.map(([, pubkey]) => pubkey)
|
||||||
.reverse() ?? [],
|
.filter(Boolean)
|
||||||
[followListEvent]
|
.reverse() ?? []
|
||||||
)
|
)
|
||||||
|
)
|
||||||
|
}, [followListEvent])
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!accountPubkey) return
|
if (!accountPubkey) return
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
setIsReady(false)
|
setIsFetching(true)
|
||||||
setFollowListEvent(undefined)
|
setFollowListEvent(undefined)
|
||||||
const event = await client.fetchFollowListEvent(accountPubkey)
|
const event = await client.fetchFollowListEvent(accountPubkey)
|
||||||
setFollowListEvent(event)
|
setFollowListEvent(event)
|
||||||
setIsReady(true)
|
setIsFetching(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
init()
|
init()
|
||||||
}, [accountPubkey])
|
}, [accountPubkey])
|
||||||
|
|
||||||
const follow = async (pubkey: string) => {
|
const follow = async (pubkey: string) => {
|
||||||
if (!isReady || !accountPubkey) return
|
if (isFetching || !accountPubkey) return
|
||||||
|
|
||||||
const newFollowListDraftEvent: TDraftEvent = {
|
const newFollowListDraftEvent: TDraftEvent = {
|
||||||
kind: kinds.Contacts,
|
kind: kinds.Contacts,
|
||||||
@@ -67,7 +69,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
|
|||||||
}
|
}
|
||||||
|
|
||||||
const unfollow = async (pubkey: string) => {
|
const unfollow = async (pubkey: string) => {
|
||||||
if (!isReady || !accountPubkey || !followListEvent) return
|
if (isFetching || !accountPubkey || !followListEvent) return
|
||||||
|
|
||||||
const newFollowListDraftEvent: TDraftEvent = {
|
const newFollowListDraftEvent: TDraftEvent = {
|
||||||
kind: kinds.Contacts,
|
kind: kinds.Contacts,
|
||||||
@@ -87,7 +89,7 @@ export function FollowListProvider({ children }: { children: React.ReactNode })
|
|||||||
value={{
|
value={{
|
||||||
followListEvent,
|
followListEvent,
|
||||||
followings,
|
followings,
|
||||||
isReady,
|
isFetching,
|
||||||
follow,
|
follow,
|
||||||
unfollow
|
unfollow
|
||||||
}}
|
}}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
import LoginDialog from '@/components/LoginDialog'
|
import LoginDialog from '@/components/LoginDialog'
|
||||||
import { useToast } from '@/hooks'
|
import { useFetchFollowings, useToast } from '@/hooks'
|
||||||
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
import { useFetchRelayList } from '@/hooks/useFetchRelayList'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import storage from '@/services/storage.service'
|
import storage from '@/services/storage.service'
|
||||||
import { ISigner, TAccount, TAccountPointer, TDraftEvent } from '@/types'
|
import { ISigner, TAccount, TAccountPointer, TDraftEvent, TRelayList } from '@/types'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { createContext, useContext, useEffect, useState } from 'react'
|
import { createContext, useContext, useEffect, useState } from 'react'
|
||||||
@@ -14,6 +14,8 @@ import { NsecSigner } from './nsec.signer'
|
|||||||
|
|
||||||
type TNostrContext = {
|
type TNostrContext = {
|
||||||
pubkey: string | null
|
pubkey: string | null
|
||||||
|
relayList: TRelayList | null
|
||||||
|
followings: string[] | null
|
||||||
account: TAccountPointer | null
|
account: TAccountPointer | null
|
||||||
accounts: TAccountPointer[]
|
accounts: TAccountPointer[]
|
||||||
switchAccount: (account: TAccountPointer | null) => Promise<void>
|
switchAccount: (account: TAccountPointer | null) => Promise<void>
|
||||||
@@ -46,7 +48,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [signer, setSigner] = useState<ISigner | null>(null)
|
const [signer, setSigner] = useState<ISigner | null>(null)
|
||||||
const [openLoginDialog, setOpenLoginDialog] = useState(false)
|
const [openLoginDialog, setOpenLoginDialog] = useState(false)
|
||||||
const { relayUrls: currentRelayUrls } = useRelaySettings()
|
const { relayUrls: currentRelayUrls } = useRelaySettings()
|
||||||
const { relayList } = useFetchRelayList(account?.pubkey)
|
const { relayList, isFetching: isFetchingRelayList } = useFetchRelayList(account?.pubkey)
|
||||||
|
const { followings, isFetching: isFetchingFollowings } = useFetchFollowings(account?.pubkey)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
@@ -224,6 +227,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
|||||||
<NostrContext.Provider
|
<NostrContext.Provider
|
||||||
value={{
|
value={{
|
||||||
pubkey: account?.pubkey ?? null,
|
pubkey: account?.pubkey ?? null,
|
||||||
|
relayList: isFetchingRelayList ? null : relayList,
|
||||||
|
followings: isFetchingFollowings ? null : followings,
|
||||||
account,
|
account,
|
||||||
accounts: storage
|
accounts: storage
|
||||||
.getAccounts()
|
.getAccounts()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import client from '@/services/client.service'
|
|||||||
import storage from '@/services/storage.service'
|
import storage from '@/services/storage.service'
|
||||||
import { TRelayGroup } from '@/types'
|
import { TRelayGroup } from '@/types'
|
||||||
import { createContext, Dispatch, useContext, useEffect, useState } from 'react'
|
import { createContext, Dispatch, useContext, useEffect, useState } from 'react'
|
||||||
|
import { useFeed } from './FeedProvider'
|
||||||
|
|
||||||
type TRelaySettingsContext = {
|
type TRelaySettingsContext = {
|
||||||
relayGroups: TRelayGroup[]
|
relayGroups: TRelayGroup[]
|
||||||
@@ -31,6 +32,7 @@ export const useRelaySettings = () => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function RelaySettingsProvider({ children }: { children: React.ReactNode }) {
|
export function RelaySettingsProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const { setFeedType } = useFeed()
|
||||||
const [relayGroups, setRelayGroups] = useState<TRelayGroup[]>([])
|
const [relayGroups, setRelayGroups] = useState<TRelayGroup[]>([])
|
||||||
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([])
|
const [temporaryRelayUrls, setTemporaryRelayUrls] = useState<string[]>([])
|
||||||
const [relayUrls, setRelayUrls] = useState<string[]>(
|
const [relayUrls, setRelayUrls] = useState<string[]>(
|
||||||
@@ -49,6 +51,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
|
|||||||
.map((url) => normalizeUrl(url))
|
.map((url) => normalizeUrl(url))
|
||||||
if (tempRelays.length) {
|
if (tempRelays.length) {
|
||||||
setTemporaryRelayUrls(tempRelays)
|
setTemporaryRelayUrls(tempRelays)
|
||||||
|
setFeedType('relays')
|
||||||
}
|
}
|
||||||
const storedGroups = storage.getRelayGroups()
|
const storedGroups = storage.getRelayGroups()
|
||||||
setRelayGroups(storedGroups)
|
setRelayGroups(storedGroups)
|
||||||
@@ -93,6 +96,7 @@ export function RelaySettingsProvider({ children }: { children: React.ReactNode
|
|||||||
isActive: group.groupName === groupName
|
isActive: group.groupName === groupName
|
||||||
}))
|
}))
|
||||||
)
|
)
|
||||||
|
setFeedType('relays')
|
||||||
setTemporaryRelayUrls([])
|
setTemporaryRelayUrls([])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ type ThemeProviderState = {
|
|||||||
setThemeSetting: (themeSetting: TThemeSetting) => Promise<void>
|
setThemeSetting: (themeSetting: TThemeSetting) => Promise<void>
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getSystemTheme() {
|
function getSystemTheme() {
|
||||||
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
return window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -26,9 +26,9 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
const themeSetting = await storage.getThemeSetting()
|
const themeSetting = storage.getThemeSetting()
|
||||||
if (themeSetting === 'system') {
|
if (themeSetting === 'system') {
|
||||||
setTheme(await getSystemTheme())
|
setTheme(getSystemTheme())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTheme(themeSetting)
|
setTheme(themeSetting)
|
||||||
@@ -65,10 +65,10 @@ export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
|
|||||||
const value = {
|
const value = {
|
||||||
themeSetting: themeSetting,
|
themeSetting: themeSetting,
|
||||||
setThemeSetting: async (themeSetting: TThemeSetting) => {
|
setThemeSetting: async (themeSetting: TThemeSetting) => {
|
||||||
await storage.setThemeSetting(themeSetting)
|
storage.setThemeSetting(themeSetting)
|
||||||
setThemeSetting(themeSetting)
|
setThemeSetting(themeSetting)
|
||||||
if (themeSetting === 'system') {
|
if (themeSetting === 'system') {
|
||||||
setTheme(await getSystemTheme())
|
setTheme(getSystemTheme())
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
setTheme(themeSetting)
|
setTheme(themeSetting)
|
||||||
|
|||||||
@@ -4,10 +4,10 @@ import FollowingListPage from './pages/secondary/FollowingListPage'
|
|||||||
import HomePage from './pages/secondary/HomePage'
|
import HomePage from './pages/secondary/HomePage'
|
||||||
import NoteListPage from './pages/secondary/NoteListPage'
|
import NoteListPage from './pages/secondary/NoteListPage'
|
||||||
import NotePage from './pages/secondary/NotePage'
|
import NotePage from './pages/secondary/NotePage'
|
||||||
import NotificationListPage from './pages/secondary/NotificationListPage'
|
|
||||||
import ProfileListPage from './pages/secondary/ProfileListPage'
|
import ProfileListPage from './pages/secondary/ProfileListPage'
|
||||||
import ProfilePage from './pages/secondary/ProfilePage'
|
import ProfilePage from './pages/secondary/ProfilePage'
|
||||||
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
|
import RelaySettingsPage from './pages/secondary/RelaySettingsPage'
|
||||||
|
import SettingsPage from './pages/secondary/SettingsPage'
|
||||||
|
|
||||||
const ROUTES = [
|
const ROUTES = [
|
||||||
{ path: '/', element: <HomePage /> },
|
{ path: '/', element: <HomePage /> },
|
||||||
@@ -17,7 +17,7 @@ const ROUTES = [
|
|||||||
{ path: '/users/:id', element: <ProfilePage /> },
|
{ path: '/users/:id', element: <ProfilePage /> },
|
||||||
{ path: '/users/:id/following', element: <FollowingListPage /> },
|
{ path: '/users/:id/following', element: <FollowingListPage /> },
|
||||||
{ path: '/relay-settings', element: <RelaySettingsPage /> },
|
{ path: '/relay-settings', element: <RelaySettingsPage /> },
|
||||||
{ path: '/notifications', element: <NotificationListPage /> }
|
{ path: '/settings', element: <SettingsPage /> }
|
||||||
]
|
]
|
||||||
|
|
||||||
export const routes = ROUTES.map(({ path, element }) => ({
|
export const routes = ROUTES.map(({ path, element }) => ({
|
||||||
|
|||||||
@@ -63,3 +63,7 @@ export type TAccount = {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
|
||||||
|
|
||||||
|
export type TFeedType = 'following' | 'relays'
|
||||||
|
|
||||||
|
export type TLanguage = 'en' | 'zh'
|
||||||
|
|||||||
3
src/vite-env.d.ts
vendored
3
src/vite-env.d.ts
vendored
@@ -5,4 +5,7 @@ declare global {
|
|||||||
interface Window {
|
interface Window {
|
||||||
nostr?: TNip07
|
nostr?: TNip07
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const __GIT_COMMIT__: string
|
||||||
|
const __APP_VERSION__: string
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,15 @@
|
|||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react'
|
||||||
|
import { execSync } from 'child_process'
|
||||||
import path from 'path'
|
import path from 'path'
|
||||||
import { defineConfig } from 'vite'
|
import { defineConfig } from 'vite'
|
||||||
import { VitePWA } from 'vite-plugin-pwa'
|
import { VitePWA } from 'vite-plugin-pwa'
|
||||||
|
|
||||||
// https://vite.dev/config/
|
// https://vite.dev/config/
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
|
define: {
|
||||||
|
__GIT_COMMIT__: JSON.stringify(execSync('git rev-parse --short HEAD').toString().trim()),
|
||||||
|
__APP_VERSION__: JSON.stringify(require('./package.json').version)
|
||||||
|
},
|
||||||
resolve: {
|
resolve: {
|
||||||
alias: {
|
alias: {
|
||||||
'@': path.resolve(__dirname, './src')
|
'@': path.resolve(__dirname, './src')
|
||||||
|
|||||||
Reference in New Issue
Block a user