Compare commits
16 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b23ea04d0 | ||
|
|
ecd7c36400 | ||
|
|
08f75a902d | ||
|
|
8a9795a53a | ||
|
|
d1ec24b85a | ||
|
|
4c3e8d5cc7 | ||
|
|
158f3d77d3 | ||
|
|
f54c73f0eb | ||
|
|
1d58162890 | ||
|
|
9820a1c6c0 | ||
|
|
ad5f9cccf9 | ||
|
|
2e3b854037 | ||
|
|
fecd4fdd45 | ||
|
|
f78138c7c4 | ||
|
|
cdfd034c68 | ||
|
|
12e02dd05b |
31
.claude/commands/deploy.md
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
# Deploy Command
|
||||||
|
|
||||||
|
Deploy smesh to the VPS at mleku.dev, serving on port 3008 behind smesh.mleku.dev.
|
||||||
|
|
||||||
|
## Instructions
|
||||||
|
|
||||||
|
1. Build the project locally:
|
||||||
|
```bash
|
||||||
|
npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
2. If build fails, fix any errors and retry before proceeding.
|
||||||
|
|
||||||
|
3. Sync the dist folder to the VPS:
|
||||||
|
```bash
|
||||||
|
rsync -avz --delete dist/ mleku.dev:~/smesh/dist/
|
||||||
|
```
|
||||||
|
|
||||||
|
4. Restart the smesh service on the VPS:
|
||||||
|
```bash
|
||||||
|
ssh mleku.dev "sudo systemctl restart smesh"
|
||||||
|
```
|
||||||
|
|
||||||
|
5. Verify the service is running:
|
||||||
|
```bash
|
||||||
|
ssh mleku.dev "sudo systemctl status smesh"
|
||||||
|
```
|
||||||
|
|
||||||
|
6. Report the deployment status and the URL: https://smesh.mleku.dev
|
||||||
|
|
||||||
|
$ARGUMENTS
|
||||||
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"additionalDirectories": [
|
||||||
|
"/home/mleku/src/git.mleku.dev/mleku/coracle"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
1310
DDD_ANALYSIS.md
899
package-lock.json
generated
14
package.json
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.2.0",
|
"version": "0.4.1",
|
||||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -17,7 +17,10 @@
|
|||||||
"build": "tsc -b && vite build",
|
"build": "tsc -b && vite build",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"format": "prettier --write .",
|
"format": "prettier --write .",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview",
|
||||||
|
"test": "vitest",
|
||||||
|
"test:run": "vitest run",
|
||||||
|
"test:coverage": "vitest run --coverage"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -53,6 +56,7 @@
|
|||||||
"@tiptap/react": "^2.12.0",
|
"@tiptap/react": "^2.12.0",
|
||||||
"@tiptap/starter-kit": "^2.12.0",
|
"@tiptap/starter-kit": "^2.12.0",
|
||||||
"@tiptap/suggestion": "^2.12.0",
|
"@tiptap/suggestion": "^2.12.0",
|
||||||
|
"@types/qrcode": "^1.5.6",
|
||||||
"@webbtc/webln-types": "^3.0.0",
|
"@webbtc/webln-types": "^3.0.0",
|
||||||
"blossom-client-sdk": "^4.1.0",
|
"blossom-client-sdk": "^4.1.0",
|
||||||
"blurhash": "^2.0.5",
|
"blurhash": "^2.0.5",
|
||||||
@@ -66,6 +70,7 @@
|
|||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"franc-min": "^6.2.0",
|
"franc-min": "^6.2.0",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"jotai": "^2.15.0",
|
"jotai": "^2.15.0",
|
||||||
@@ -77,6 +82,7 @@
|
|||||||
"path-to-regexp": "^8.2.0",
|
"path-to-regexp": "^8.2.0",
|
||||||
"qr-code-styling": "^1.9.2",
|
"qr-code-styling": "^1.9.2",
|
||||||
"qr-scanner": "^1.4.2",
|
"qr-scanner": "^1.4.2",
|
||||||
|
"qrcode": "^1.5.4",
|
||||||
"react": "^18.3.1",
|
"react": "^18.3.1",
|
||||||
"react-dom": "^18.3.1",
|
"react-dom": "^18.3.1",
|
||||||
"react-i18next": "^15.2.0",
|
"react-i18next": "^15.2.0",
|
||||||
@@ -100,6 +106,7 @@
|
|||||||
"@types/react-dom": "^18.3.5",
|
"@types/react-dom": "^18.3.5",
|
||||||
"@types/uri-templates": "^0.1.34",
|
"@types/uri-templates": "^0.1.34",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
"@vitejs/plugin-react": "^4.3.4",
|
||||||
|
"@vitest/coverage-v8": "^4.0.16",
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9.17.0",
|
"eslint": "^9.17.0",
|
||||||
"eslint-plugin-react-hooks": "^5.0.0",
|
"eslint-plugin-react-hooks": "^5.0.0",
|
||||||
@@ -111,6 +118,7 @@
|
|||||||
"typescript": "~5.6.2",
|
"typescript": "~5.6.2",
|
||||||
"typescript-eslint": "^8.18.1",
|
"typescript-eslint": "^8.18.1",
|
||||||
"vite": "^6.0.3",
|
"vite": "^6.0.3",
|
||||||
"vite-plugin-pwa": "^0.21.1"
|
"vite-plugin-pwa": "^0.21.1",
|
||||||
|
"vitest": "^4.0.16"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 8.4 KiB After Width: | Height: | Size: 745 B |
|
Before Width: | Height: | Size: 9.4 KiB After Width: | Height: | Size: 5.3 KiB |
|
Before Width: | Height: | Size: 9.7 KiB After Width: | Height: | Size: 287 B |
|
Before Width: | Height: | Size: 9.5 KiB After Width: | Height: | Size: 2.0 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 7.2 KiB |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 513 B |
22
resources/icon-apple-touch.svg
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width="200"
|
||||||
|
height="200">
|
||||||
|
|
||||||
|
<!-- Dark background -->
|
||||||
|
<rect x="0" y="0" width="100" height="100" fill="#171717" />
|
||||||
|
|
||||||
|
<!-- Grid icon (white strokes) -->
|
||||||
|
<g fill="none" stroke="white" stroke-width="12" stroke-linecap="round">
|
||||||
|
<!-- Vertical lines -->
|
||||||
|
<line x1="25" y1="10" x2="25" y2="90" />
|
||||||
|
<line x1="50" y1="10" x2="50" y2="90" />
|
||||||
|
<line x1="75" y1="10" x2="75" y2="90" />
|
||||||
|
|
||||||
|
<!-- Horizontal lines -->
|
||||||
|
<line x1="10" y1="25" x2="90" y2="25" />
|
||||||
|
<line x1="10" y1="50" x2="90" y2="50" />
|
||||||
|
<line x1="10" y1="75" x2="90" y2="75" />
|
||||||
|
</g>
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 650 B |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 513 B |
20
resources/icon-white.svg
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg"
|
||||||
|
viewBox="0 0 100 100"
|
||||||
|
width="200"
|
||||||
|
height="200"
|
||||||
|
fill="none"
|
||||||
|
stroke="white"
|
||||||
|
stroke-width="12"
|
||||||
|
stroke-linecap="round">
|
||||||
|
|
||||||
|
<!-- Vertical lines -->
|
||||||
|
<line x1="25" y1="10" x2="25" y2="90" />
|
||||||
|
<line x1="50" y1="10" x2="50" y2="90" />
|
||||||
|
<line x1="75" y1="10" x2="75" y2="90" />
|
||||||
|
|
||||||
|
<!-- Horizontal lines -->
|
||||||
|
<line x1="10" y1="25" x2="90" y2="25" />
|
||||||
|
<line x1="10" y1="50" x2="90" y2="50" />
|
||||||
|
<line x1="10" y1="75" x2="90" y2="75" />
|
||||||
|
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 513 B |
|
Before Width: | Height: | Size: 47 KiB After Width: | Height: | Size: 513 B |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 5.0 KiB |
|
Before Width: | Height: | Size: 7.9 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 91 KiB After Width: | Height: | Size: 38 KiB |
|
Before Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
15
src/App.tsx
@@ -4,18 +4,23 @@ import './index.css'
|
|||||||
import { Toaster } from '@/components/ui/sonner'
|
import { Toaster } from '@/components/ui/sonner'
|
||||||
import { BookmarksProvider } from '@/providers/BookmarksProvider'
|
import { BookmarksProvider } from '@/providers/BookmarksProvider'
|
||||||
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
|
import { ContentPolicyProvider } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { EventHandlerProvider } from '@/providers/EventHandlerProvider'
|
||||||
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
|
import { DeletedEventProvider } from '@/providers/DeletedEventProvider'
|
||||||
|
import { DMProvider } from '@/providers/DMProvider'
|
||||||
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
|
import { EmojiPackProvider } from '@/providers/EmojiPackProvider'
|
||||||
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
|
import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
|
||||||
import { FeedProvider } from '@/providers/FeedProvider'
|
import { FeedProvider } from '@/providers/FeedProvider'
|
||||||
import { FollowListProvider } from '@/providers/FollowListProvider'
|
import { FollowListProvider } from '@/providers/FollowListProvider'
|
||||||
import { KindFilterProvider } from '@/providers/KindFilterProvider'
|
import { KindFilterProvider } from '@/providers/KindFilterProvider'
|
||||||
|
import { SocialGraphFilterProvider } from '@/providers/SocialGraphFilterProvider'
|
||||||
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
||||||
import { MuteListProvider } from '@/providers/MuteListProvider'
|
import { MuteListProvider } from '@/providers/MuteListProvider'
|
||||||
import { NostrProvider } from '@/providers/NostrProvider'
|
import { NostrProvider } from '@/providers/NostrProvider'
|
||||||
|
import { NRCProvider } from '@/providers/NRCProvider'
|
||||||
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
|
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
|
||||||
import { PinListProvider } from '@/providers/PinListProvider'
|
import { PinListProvider } from '@/providers/PinListProvider'
|
||||||
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
||||||
|
import { RepositoryProvider } from '@/providers/RepositoryProvider'
|
||||||
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
import { ScreenSizeProvider } from '@/providers/ScreenSizeProvider'
|
||||||
import { SettingsSyncProvider } from '@/providers/SettingsSyncProvider'
|
import { SettingsSyncProvider } from '@/providers/SettingsSyncProvider'
|
||||||
import { ThemeProvider } from '@/providers/ThemeProvider'
|
import { ThemeProvider } from '@/providers/ThemeProvider'
|
||||||
@@ -27,17 +32,21 @@ import { PageManager } from './PageManager'
|
|||||||
export default function App(): JSX.Element {
|
export default function App(): JSX.Element {
|
||||||
return (
|
return (
|
||||||
<ScreenSizeProvider>
|
<ScreenSizeProvider>
|
||||||
|
<EventHandlerProvider>
|
||||||
<UserPreferencesProvider>
|
<UserPreferencesProvider>
|
||||||
<ThemeProvider>
|
<ThemeProvider>
|
||||||
<ContentPolicyProvider>
|
<ContentPolicyProvider>
|
||||||
<DeletedEventProvider>
|
<DeletedEventProvider>
|
||||||
<PasswordPromptProvider>
|
<PasswordPromptProvider>
|
||||||
<NostrProvider>
|
<NostrProvider>
|
||||||
|
<NRCProvider>
|
||||||
|
<RepositoryProvider>
|
||||||
<SettingsSyncProvider>
|
<SettingsSyncProvider>
|
||||||
<ZapProvider>
|
<ZapProvider>
|
||||||
<FavoriteRelaysProvider>
|
<FavoriteRelaysProvider>
|
||||||
<FollowListProvider>
|
<FollowListProvider>
|
||||||
<MuteListProvider>
|
<MuteListProvider>
|
||||||
|
<DMProvider>
|
||||||
<UserTrustProvider>
|
<UserTrustProvider>
|
||||||
<BookmarksProvider>
|
<BookmarksProvider>
|
||||||
<EmojiPackProvider>
|
<EmojiPackProvider>
|
||||||
@@ -45,10 +54,12 @@ export default function App(): JSX.Element {
|
|||||||
<PinnedUsersProvider>
|
<PinnedUsersProvider>
|
||||||
<FeedProvider>
|
<FeedProvider>
|
||||||
<MediaUploadServiceProvider>
|
<MediaUploadServiceProvider>
|
||||||
|
<SocialGraphFilterProvider>
|
||||||
<KindFilterProvider>
|
<KindFilterProvider>
|
||||||
<PageManager />
|
<PageManager />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</KindFilterProvider>
|
</KindFilterProvider>
|
||||||
|
</SocialGraphFilterProvider>
|
||||||
</MediaUploadServiceProvider>
|
</MediaUploadServiceProvider>
|
||||||
</FeedProvider>
|
</FeedProvider>
|
||||||
</PinnedUsersProvider>
|
</PinnedUsersProvider>
|
||||||
@@ -56,17 +67,21 @@ export default function App(): JSX.Element {
|
|||||||
</EmojiPackProvider>
|
</EmojiPackProvider>
|
||||||
</BookmarksProvider>
|
</BookmarksProvider>
|
||||||
</UserTrustProvider>
|
</UserTrustProvider>
|
||||||
|
</DMProvider>
|
||||||
</MuteListProvider>
|
</MuteListProvider>
|
||||||
</FollowListProvider>
|
</FollowListProvider>
|
||||||
</FavoriteRelaysProvider>
|
</FavoriteRelaysProvider>
|
||||||
</ZapProvider>
|
</ZapProvider>
|
||||||
</SettingsSyncProvider>
|
</SettingsSyncProvider>
|
||||||
|
</RepositoryProvider>
|
||||||
|
</NRCProvider>
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</PasswordPromptProvider>
|
</PasswordPromptProvider>
|
||||||
</DeletedEventProvider>
|
</DeletedEventProvider>
|
||||||
</ContentPolicyProvider>
|
</ContentPolicyProvider>
|
||||||
</ThemeProvider>
|
</ThemeProvider>
|
||||||
</UserPreferencesProvider>
|
</UserPreferencesProvider>
|
||||||
|
</EventHandlerProvider>
|
||||||
</ScreenSizeProvider>
|
</ScreenSizeProvider>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
|
import ActionModeOverlay from '@/components/ActionModeOverlay'
|
||||||
import Sidebar from '@/components/Sidebar'
|
import Sidebar from '@/components/Sidebar'
|
||||||
import SidebarDrawer from '@/components/SidebarDrawer'
|
import SidebarDrawer from '@/components/SidebarDrawer'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
|
import { CurrentRelaysProvider } from '@/providers/CurrentRelaysProvider'
|
||||||
|
import { KeyboardNavigationProvider } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { TPageRef } from '@/types'
|
import { TPageRef } from '@/types'
|
||||||
import {
|
import {
|
||||||
cloneElement,
|
cloneElement,
|
||||||
@@ -321,6 +323,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
|||||||
<SidebarDrawerContext.Provider value={sidebarDrawerContext}>
|
<SidebarDrawerContext.Provider value={sidebarDrawerContext}>
|
||||||
<CurrentRelaysProvider>
|
<CurrentRelaysProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
|
<KeyboardNavigationProvider
|
||||||
|
secondaryStackLength={secondaryStack.length}
|
||||||
|
sidebarDrawerOpen={sidebarDrawerOpen}
|
||||||
|
onBack={() => popSecondaryPage()}
|
||||||
|
onCloseSecondary={() => clearSecondaryPages()}
|
||||||
|
>
|
||||||
{!!secondaryStack.length &&
|
{!!secondaryStack.length &&
|
||||||
secondaryStack.map((item, index) => (
|
secondaryStack.map((item, index) => (
|
||||||
<div
|
<div
|
||||||
@@ -349,6 +357,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
|||||||
/>
|
/>
|
||||||
<TooManyRelaysAlertDialog />
|
<TooManyRelaysAlertDialog />
|
||||||
<CreateWalletGuideToast />
|
<CreateWalletGuideToast />
|
||||||
|
<ActionModeOverlay />
|
||||||
|
</KeyboardNavigationProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</CurrentRelaysProvider>
|
</CurrentRelaysProvider>
|
||||||
</SidebarDrawerContext.Provider>
|
</SidebarDrawerContext.Provider>
|
||||||
@@ -377,6 +387,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
|||||||
>
|
>
|
||||||
<CurrentRelaysProvider>
|
<CurrentRelaysProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
|
<KeyboardNavigationProvider
|
||||||
|
secondaryStackLength={secondaryStack.length}
|
||||||
|
sidebarDrawerOpen={false}
|
||||||
|
onBack={() => popSecondaryPage()}
|
||||||
|
onCloseSecondary={() => clearSecondaryPages()}
|
||||||
|
>
|
||||||
<div className="flex lg:justify-around w-full bg-chrome-background">
|
<div className="flex lg:justify-around w-full bg-chrome-background">
|
||||||
<div className="sticky top-0 lg:w-full flex justify-end self-start h-[var(--vh)]">
|
<div className="sticky top-0 lg:w-full flex justify-end self-start h-[var(--vh)]">
|
||||||
<Sidebar />
|
<Sidebar />
|
||||||
@@ -412,6 +428,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
|||||||
<TooManyRelaysAlertDialog />
|
<TooManyRelaysAlertDialog />
|
||||||
<CreateWalletGuideToast />
|
<CreateWalletGuideToast />
|
||||||
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
|
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
|
||||||
|
<ActionModeOverlay />
|
||||||
|
</KeyboardNavigationProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</CurrentRelaysProvider>
|
</CurrentRelaysProvider>
|
||||||
</SecondaryPageContext.Provider>
|
</SecondaryPageContext.Provider>
|
||||||
@@ -436,6 +454,12 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
|||||||
>
|
>
|
||||||
<CurrentRelaysProvider>
|
<CurrentRelaysProvider>
|
||||||
<NotificationProvider>
|
<NotificationProvider>
|
||||||
|
<KeyboardNavigationProvider
|
||||||
|
secondaryStackLength={secondaryStack.length}
|
||||||
|
sidebarDrawerOpen={false}
|
||||||
|
onBack={() => popSecondaryPage()}
|
||||||
|
onCloseSecondary={() => clearSecondaryPages()}
|
||||||
|
>
|
||||||
<div className="flex flex-col items-center bg-surface-background">
|
<div className="flex flex-col items-center bg-surface-background">
|
||||||
<div
|
<div
|
||||||
className="flex h-[var(--vh)] w-full bg-surface-background"
|
className="flex h-[var(--vh)] w-full bg-surface-background"
|
||||||
@@ -492,6 +516,8 @@ export function PageManager({ maxStackSize = 5 }: { maxStackSize?: number }) {
|
|||||||
<TooManyRelaysAlertDialog />
|
<TooManyRelaysAlertDialog />
|
||||||
<CreateWalletGuideToast />
|
<CreateWalletGuideToast />
|
||||||
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
|
<BackgroundAudio className="fixed bottom-20 right-0 z-50 w-80 rounded-l-full rounded-r-none overflow-hidden shadow-lg border" />
|
||||||
|
<ActionModeOverlay />
|
||||||
|
</KeyboardNavigationProvider>
|
||||||
</NotificationProvider>
|
</NotificationProvider>
|
||||||
</CurrentRelaysProvider>
|
</CurrentRelaysProvider>
|
||||||
</SecondaryPageContext.Provider>
|
</SecondaryPageContext.Provider>
|
||||||
|
|||||||
257
src/application/handlers/ContentEventHandlers.ts
Normal file
@@ -0,0 +1,257 @@
|
|||||||
|
import {
|
||||||
|
EventBookmarked,
|
||||||
|
EventUnbookmarked,
|
||||||
|
BookmarkListPublished,
|
||||||
|
NotePinned,
|
||||||
|
NoteUnpinned,
|
||||||
|
PinsLimitExceeded,
|
||||||
|
PinListPublished,
|
||||||
|
ReactionAdded,
|
||||||
|
ContentReposted
|
||||||
|
} from '@/domain/content'
|
||||||
|
import { EventHandler, eventDispatcher } from '@/domain/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handlers for content domain events
|
||||||
|
*
|
||||||
|
* These handlers coordinate cross-context updates when content events occur.
|
||||||
|
* They enable real-time UI updates and cross-context coordination.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for updating reaction counts in UI
|
||||||
|
*/
|
||||||
|
export type UpdateReactionCountCallback = (eventId: string, emoji: string, delta: number) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for updating repost counts in UI
|
||||||
|
*/
|
||||||
|
export type UpdateRepostCountCallback = (eventId: string, delta: number) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for creating notifications
|
||||||
|
*/
|
||||||
|
export type CreateNotificationCallback = (
|
||||||
|
type: 'reaction' | 'repost' | 'mention' | 'reply',
|
||||||
|
actorPubkey: string,
|
||||||
|
targetEventId: string
|
||||||
|
) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for showing toast messages
|
||||||
|
*/
|
||||||
|
export type ShowToastCallback = (message: string, type: 'info' | 'warning' | 'error') => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback for updating profile pinned notes
|
||||||
|
*/
|
||||||
|
export type UpdateProfilePinsCallback = (pubkey: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service callbacks for cross-context coordination
|
||||||
|
*/
|
||||||
|
export interface ContentHandlerCallbacks {
|
||||||
|
onUpdateReactionCount?: UpdateReactionCountCallback
|
||||||
|
onUpdateRepostCount?: UpdateRepostCountCallback
|
||||||
|
onCreateNotification?: CreateNotificationCallback
|
||||||
|
onShowToast?: ShowToastCallback
|
||||||
|
onUpdateProfilePins?: UpdateProfilePinsCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
let callbacks: ContentHandlerCallbacks = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the callbacks for cross-context coordination
|
||||||
|
* Call this during provider initialization
|
||||||
|
*/
|
||||||
|
export function setContentHandlerCallbacks(newCallbacks: ContentHandlerCallbacks): void {
|
||||||
|
callbacks = { ...callbacks, ...newCallbacks }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all callbacks (for cleanup/testing)
|
||||||
|
*/
|
||||||
|
export function clearContentHandlerCallbacks(): void {
|
||||||
|
callbacks = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for event bookmarked
|
||||||
|
* Can be used to:
|
||||||
|
* - Update bookmark count displays
|
||||||
|
* - Prefetch bookmarked content for offline access
|
||||||
|
*/
|
||||||
|
export const handleEventBookmarked: EventHandler<EventBookmarked> = async (event) => {
|
||||||
|
console.debug('[ContentEventHandler] Event bookmarked:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
bookmarkedEventId: event.bookmarkedEventId,
|
||||||
|
type: event.bookmarkType
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Trigger prefetch of bookmarked content
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for event unbookmarked
|
||||||
|
*/
|
||||||
|
export const handleEventUnbookmarked: EventHandler<EventUnbookmarked> = async (event) => {
|
||||||
|
console.debug('[ContentEventHandler] Event unbookmarked:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
unbookmarkedEventId: event.unbookmarkedEventId
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for bookmark list published
|
||||||
|
*/
|
||||||
|
export const handleBookmarkListPublished: EventHandler<BookmarkListPublished> = async (event) => {
|
||||||
|
console.debug('[ContentEventHandler] Bookmark list published:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
bookmarkCount: event.bookmarkCount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for note pinned
|
||||||
|
* Coordinates with:
|
||||||
|
* - Profile context: Update pinned notes display
|
||||||
|
* - Cache context: Ensure pinned content is cached
|
||||||
|
*/
|
||||||
|
export const handleNotePinned: EventHandler<NotePinned> = async (event) => {
|
||||||
|
console.debug('[ContentEventHandler] Note pinned:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
pinnedEventId: event.pinnedEventId.hex
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update profile display to show new pinned note
|
||||||
|
if (callbacks.onUpdateProfilePins) {
|
||||||
|
callbacks.onUpdateProfilePins(event.actor.hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for note unpinned
|
||||||
|
* Coordinates with:
|
||||||
|
* - Profile context: Update pinned notes display
|
||||||
|
*/
|
||||||
|
export const handleNoteUnpinned: EventHandler<NoteUnpinned> = async (event) => {
|
||||||
|
console.debug('[ContentEventHandler] Note unpinned:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
unpinnedEventId: event.unpinnedEventId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update profile display to remove unpinned note
|
||||||
|
if (callbacks.onUpdateProfilePins) {
|
||||||
|
callbacks.onUpdateProfilePins(event.actor.hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for pins limit exceeded
|
||||||
|
* Coordinates with:
|
||||||
|
* - UI context: Show toast notification about removed pins
|
||||||
|
*/
|
||||||
|
export const handlePinsLimitExceeded: EventHandler<PinsLimitExceeded> = async (event) => {
|
||||||
|
console.debug('[ContentEventHandler] Pins limit exceeded:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
removedCount: event.removedEventIds.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Show toast notification about removed pins
|
||||||
|
if (callbacks.onShowToast) {
|
||||||
|
callbacks.onShowToast(
|
||||||
|
`Pin limit reached. ${event.removedEventIds.length} older pin(s) were removed.`,
|
||||||
|
'warning'
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for pin list published
|
||||||
|
*/
|
||||||
|
export const handlePinListPublished: EventHandler<PinListPublished> = async (event) => {
|
||||||
|
console.debug('[ContentEventHandler] Pin list published:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
pinCount: event.pinCount
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for reaction added
|
||||||
|
* Coordinates with:
|
||||||
|
* - UI context: Update reaction counts in real-time
|
||||||
|
* - Notification context: Create notification for content author
|
||||||
|
*/
|
||||||
|
export const handleReactionAdded: EventHandler<ReactionAdded> = async (event) => {
|
||||||
|
console.debug('[ContentEventHandler] Reaction added:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
targetEventId: event.targetEventId.hex,
|
||||||
|
targetAuthor: event.targetAuthor.formatted,
|
||||||
|
emoji: event.emoji,
|
||||||
|
isLike: event.isLike
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update reaction count in UI
|
||||||
|
if (callbacks.onUpdateReactionCount) {
|
||||||
|
callbacks.onUpdateReactionCount(event.targetEventId.hex, event.emoji, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification for the content author (if not self)
|
||||||
|
if (callbacks.onCreateNotification && event.actor.hex !== event.targetAuthor.hex) {
|
||||||
|
callbacks.onCreateNotification('reaction', event.actor.hex, event.targetEventId.hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for content reposted
|
||||||
|
* Coordinates with:
|
||||||
|
* - UI context: Update repost counts in real-time
|
||||||
|
* - Notification context: Create notification for original author
|
||||||
|
*/
|
||||||
|
export const handleContentReposted: EventHandler<ContentReposted> = async (event) => {
|
||||||
|
console.debug('[ContentEventHandler] Content reposted:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
originalEventId: event.originalEventId.hex,
|
||||||
|
originalAuthor: event.originalAuthor.formatted
|
||||||
|
})
|
||||||
|
|
||||||
|
// Update repost count in UI
|
||||||
|
if (callbacks.onUpdateRepostCount) {
|
||||||
|
callbacks.onUpdateRepostCount(event.originalEventId.hex, 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create notification for the original author (if not self)
|
||||||
|
if (callbacks.onCreateNotification && event.actor.hex !== event.originalAuthor.hex) {
|
||||||
|
callbacks.onCreateNotification('repost', event.actor.hex, event.originalEventId.hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all content event handlers with the event dispatcher
|
||||||
|
*/
|
||||||
|
export function registerContentEventHandlers(): void {
|
||||||
|
eventDispatcher.on('content.event_bookmarked', handleEventBookmarked)
|
||||||
|
eventDispatcher.on('content.event_unbookmarked', handleEventUnbookmarked)
|
||||||
|
eventDispatcher.on('content.bookmark_list_published', handleBookmarkListPublished)
|
||||||
|
eventDispatcher.on('content.note_pinned', handleNotePinned)
|
||||||
|
eventDispatcher.on('content.note_unpinned', handleNoteUnpinned)
|
||||||
|
eventDispatcher.on('content.pins_limit_exceeded', handlePinsLimitExceeded)
|
||||||
|
eventDispatcher.on('content.pin_list_published', handlePinListPublished)
|
||||||
|
eventDispatcher.on('content.reaction_added', handleReactionAdded)
|
||||||
|
eventDispatcher.on('content.reposted', handleContentReposted)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister all content event handlers
|
||||||
|
*/
|
||||||
|
export function unregisterContentEventHandlers(): void {
|
||||||
|
eventDispatcher.off('content.event_bookmarked', handleEventBookmarked)
|
||||||
|
eventDispatcher.off('content.event_unbookmarked', handleEventUnbookmarked)
|
||||||
|
eventDispatcher.off('content.bookmark_list_published', handleBookmarkListPublished)
|
||||||
|
eventDispatcher.off('content.note_pinned', handleNotePinned)
|
||||||
|
eventDispatcher.off('content.note_unpinned', handleNoteUnpinned)
|
||||||
|
eventDispatcher.off('content.pins_limit_exceeded', handlePinsLimitExceeded)
|
||||||
|
eventDispatcher.off('content.pin_list_published', handlePinListPublished)
|
||||||
|
eventDispatcher.off('content.reaction_added', handleReactionAdded)
|
||||||
|
eventDispatcher.off('content.reposted', handleContentReposted)
|
||||||
|
}
|
||||||
215
src/application/handlers/FeedEventHandlers.ts
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
import {
|
||||||
|
FeedSwitched,
|
||||||
|
ContentFilterUpdated,
|
||||||
|
FeedRefreshed,
|
||||||
|
NoteCreated,
|
||||||
|
NoteDeleted,
|
||||||
|
NoteReplied,
|
||||||
|
UsersMentioned,
|
||||||
|
TimelineEventsReceived,
|
||||||
|
TimelineEOSED
|
||||||
|
} from '@/domain/feed/events'
|
||||||
|
import { EventHandler, eventDispatcher } from '@/domain/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handlers for Feed domain events
|
||||||
|
*
|
||||||
|
* These handlers coordinate cross-context updates when feed events occur.
|
||||||
|
* They enable coordination between Feed, Social, Content, and UI contexts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for feed switched events
|
||||||
|
* Can be used to:
|
||||||
|
* - Clear timeline caches for the old feed
|
||||||
|
* - Prefetch content for the new feed
|
||||||
|
* - Update URL/navigation state
|
||||||
|
* - Log analytics
|
||||||
|
*/
|
||||||
|
export const handleFeedSwitched: EventHandler<FeedSwitched> = async (event) => {
|
||||||
|
console.debug('[FeedEventHandler] Feed switched:', {
|
||||||
|
owner: event.owner?.formatted,
|
||||||
|
fromType: event.fromType?.value ?? 'none',
|
||||||
|
toType: event.toType.value,
|
||||||
|
relaySetId: event.relaySetId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Clear old timeline cache
|
||||||
|
// Future: Trigger new timeline fetch
|
||||||
|
// Future: Update analytics
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for content filter updated events
|
||||||
|
* Can be used to:
|
||||||
|
* - Re-filter current timeline with new settings
|
||||||
|
* - Persist filter preferences
|
||||||
|
* - Update filter indicators in UI
|
||||||
|
*/
|
||||||
|
export const handleContentFilterUpdated: EventHandler<ContentFilterUpdated> = async (event) => {
|
||||||
|
console.debug('[FeedEventHandler] Content filter updated:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
hideRepliesChanged: event.previousFilter.hideReplies !== event.newFilter.hideReplies,
|
||||||
|
hideRepostsChanged: event.previousFilter.hideReposts !== event.newFilter.hideReposts,
|
||||||
|
nsfwPolicyChanged: event.previousFilter.nsfwPolicy !== event.newFilter.nsfwPolicy
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Trigger timeline re-filter
|
||||||
|
// Future: Persist filter preferences
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for feed refreshed events
|
||||||
|
* Can be used to:
|
||||||
|
* - Update last refresh timestamp display
|
||||||
|
* - Trigger background data fetch
|
||||||
|
* - Reset scroll position indicators
|
||||||
|
*/
|
||||||
|
export const handleFeedRefreshed: EventHandler<FeedRefreshed> = async (event) => {
|
||||||
|
console.debug('[FeedEventHandler] Feed refreshed:', {
|
||||||
|
owner: event.owner?.formatted,
|
||||||
|
feedType: event.feedType.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Update refresh timestamp in UI
|
||||||
|
// Future: Trigger stale data cleanup
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for note created events
|
||||||
|
* Can be used to:
|
||||||
|
* - Add note to local timeline immediately (optimistic UI)
|
||||||
|
* - Create notifications for mentioned users
|
||||||
|
* - Update post count displays
|
||||||
|
*/
|
||||||
|
export const handleNoteCreated: EventHandler<NoteCreated> = async (event) => {
|
||||||
|
console.debug('[FeedEventHandler] Note created:', {
|
||||||
|
author: event.author.formatted,
|
||||||
|
noteId: event.noteId.hex,
|
||||||
|
mentionCount: event.mentions.length,
|
||||||
|
isReply: event.isReply,
|
||||||
|
isQuote: event.isQuote
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Add to local timeline if author is self
|
||||||
|
// Future: Create mention notifications
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for note deleted events
|
||||||
|
* Can be used to:
|
||||||
|
* - Remove note from all timelines
|
||||||
|
* - Update reply counts on parent notes
|
||||||
|
* - Clean up cached data
|
||||||
|
*/
|
||||||
|
export const handleNoteDeleted: EventHandler<NoteDeleted> = async (event) => {
|
||||||
|
console.debug('[FeedEventHandler] Note deleted:', {
|
||||||
|
author: event.author.formatted,
|
||||||
|
noteId: event.noteId.hex
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Remove from timeline display
|
||||||
|
// Future: Remove from caches
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for note replied events
|
||||||
|
* Can be used to:
|
||||||
|
* - Increment reply count on parent note
|
||||||
|
* - Create notification for parent note author
|
||||||
|
* - Update thread view if open
|
||||||
|
*/
|
||||||
|
export const handleNoteReplied: EventHandler<NoteReplied> = async (event) => {
|
||||||
|
console.debug('[FeedEventHandler] Note replied:', {
|
||||||
|
replier: event.replier.formatted,
|
||||||
|
replyNoteId: event.replyNoteId.hex,
|
||||||
|
originalNoteId: event.originalNoteId.hex,
|
||||||
|
originalAuthor: event.originalAuthor.formatted
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Increment reply count
|
||||||
|
// Future: Create reply notification for parent author
|
||||||
|
// Future: Update thread view
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for users mentioned events
|
||||||
|
* Can be used to:
|
||||||
|
* - Create mention notifications for each mentioned user
|
||||||
|
* - Highlight mentions in the source note
|
||||||
|
*/
|
||||||
|
export const handleUsersMentioned: EventHandler<UsersMentioned> = async (event) => {
|
||||||
|
console.debug('[FeedEventHandler] Users mentioned:', {
|
||||||
|
author: event.author.formatted,
|
||||||
|
noteId: event.noteId.hex,
|
||||||
|
mentionedCount: event.mentionedPubkeys.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Create mention notifications
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for timeline events received
|
||||||
|
* Can be used to:
|
||||||
|
* - Update event cache
|
||||||
|
* - Trigger profile/metadata fetches for new authors
|
||||||
|
* - Update unread counts
|
||||||
|
*/
|
||||||
|
export const handleTimelineEventsReceived: EventHandler<TimelineEventsReceived> = async (event) => {
|
||||||
|
console.debug('[FeedEventHandler] Timeline events received:', {
|
||||||
|
feedType: event.feedType.value,
|
||||||
|
eventCount: event.eventCount,
|
||||||
|
newestTimestamp: event.newestTimestamp.unix,
|
||||||
|
isHistorical: event.isHistorical
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Prefetch profiles for new authors
|
||||||
|
// Future: Update new post indicators
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for timeline EOSE (end of stored events)
|
||||||
|
* Can be used to:
|
||||||
|
* - Mark initial load as complete
|
||||||
|
* - Switch from loading to live mode
|
||||||
|
* - Update loading indicators
|
||||||
|
*/
|
||||||
|
export const handleTimelineEOSED: EventHandler<TimelineEOSED> = async (event) => {
|
||||||
|
console.debug('[FeedEventHandler] Timeline EOSE:', {
|
||||||
|
feedType: event.feedType.value,
|
||||||
|
totalEvents: event.totalEvents
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Update loading state
|
||||||
|
// Future: Show "up to date" indicator
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all feed event handlers with the event dispatcher
|
||||||
|
*/
|
||||||
|
export function registerFeedEventHandlers(): void {
|
||||||
|
eventDispatcher.on('feed.switched', handleFeedSwitched)
|
||||||
|
eventDispatcher.on('feed.content_filter_updated', handleContentFilterUpdated)
|
||||||
|
eventDispatcher.on('feed.refreshed', handleFeedRefreshed)
|
||||||
|
eventDispatcher.on('feed.note_created', handleNoteCreated)
|
||||||
|
eventDispatcher.on('feed.note_deleted', handleNoteDeleted)
|
||||||
|
eventDispatcher.on('feed.note_replied', handleNoteReplied)
|
||||||
|
eventDispatcher.on('feed.users_mentioned', handleUsersMentioned)
|
||||||
|
eventDispatcher.on('feed.timeline_events_received', handleTimelineEventsReceived)
|
||||||
|
eventDispatcher.on('feed.timeline_eosed', handleTimelineEOSED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister all feed event handlers
|
||||||
|
*/
|
||||||
|
export function unregisterFeedEventHandlers(): void {
|
||||||
|
eventDispatcher.off('feed.switched', handleFeedSwitched)
|
||||||
|
eventDispatcher.off('feed.content_filter_updated', handleContentFilterUpdated)
|
||||||
|
eventDispatcher.off('feed.refreshed', handleFeedRefreshed)
|
||||||
|
eventDispatcher.off('feed.note_created', handleNoteCreated)
|
||||||
|
eventDispatcher.off('feed.note_deleted', handleNoteDeleted)
|
||||||
|
eventDispatcher.off('feed.note_replied', handleNoteReplied)
|
||||||
|
eventDispatcher.off('feed.users_mentioned', handleUsersMentioned)
|
||||||
|
eventDispatcher.off('feed.timeline_events_received', handleTimelineEventsReceived)
|
||||||
|
eventDispatcher.off('feed.timeline_eosed', handleTimelineEOSED)
|
||||||
|
}
|
||||||
220
src/application/handlers/RelayEventHandlers.ts
Normal file
@@ -0,0 +1,220 @@
|
|||||||
|
import {
|
||||||
|
FavoriteRelayAdded,
|
||||||
|
FavoriteRelayRemoved,
|
||||||
|
FavoriteRelaysPublished,
|
||||||
|
RelaySetCreated,
|
||||||
|
RelaySetUpdated,
|
||||||
|
RelaySetDeleted,
|
||||||
|
MailboxRelayAdded,
|
||||||
|
MailboxRelayRemoved,
|
||||||
|
MailboxRelayScopeChanged,
|
||||||
|
RelayListPublished
|
||||||
|
} from '@/domain/relay/events'
|
||||||
|
import { EventHandler, eventDispatcher } from '@/domain/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handlers for Relay domain events
|
||||||
|
*
|
||||||
|
* These handlers coordinate cross-context updates when relay configuration changes.
|
||||||
|
* They enable coordination between Relay, Feed, and Identity contexts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for favorite relay added events
|
||||||
|
* Can be used to:
|
||||||
|
* - Update relay picker UI
|
||||||
|
* - Add relay to connection pool
|
||||||
|
*/
|
||||||
|
export const handleFavoriteRelayAdded: EventHandler<FavoriteRelayAdded> = async (event) => {
|
||||||
|
console.debug('[RelayEventHandler] Favorite relay added:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
relay: event.relayUrl.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Update relay picker options
|
||||||
|
// Future: Pre-connect to new favorite relay
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for favorite relay removed events
|
||||||
|
* Can be used to:
|
||||||
|
* - Update relay picker UI
|
||||||
|
* - Close connection if no longer needed
|
||||||
|
*/
|
||||||
|
export const handleFavoriteRelayRemoved: EventHandler<FavoriteRelayRemoved> = async (event) => {
|
||||||
|
console.debug('[RelayEventHandler] Favorite relay removed:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
relay: event.relayUrl.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Update relay picker options
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for favorite relays published events
|
||||||
|
* Can be used to:
|
||||||
|
* - Invalidate relay preference caches
|
||||||
|
* - Sync with remote state
|
||||||
|
*/
|
||||||
|
export const handleFavoriteRelaysPublished: EventHandler<FavoriteRelaysPublished> = async (event) => {
|
||||||
|
console.debug('[RelayEventHandler] Favorite relays published:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
relayCount: event.relayCount
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Invalidate caches
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for relay set created events
|
||||||
|
* Can be used to:
|
||||||
|
* - Update feed type options in UI
|
||||||
|
* - Add new relay set to navigation
|
||||||
|
*/
|
||||||
|
export const handleRelaySetCreated: EventHandler<RelaySetCreated> = async (event) => {
|
||||||
|
console.debug('[RelayEventHandler] Relay set created:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
setId: event.setId,
|
||||||
|
name: event.name,
|
||||||
|
relayCount: event.relays.length
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Update feed selector options
|
||||||
|
// Future: Add to relay set navigation
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for relay set updated events
|
||||||
|
* Can be used to:
|
||||||
|
* - Refresh active feed if using this relay set
|
||||||
|
* - Update relay set display
|
||||||
|
*/
|
||||||
|
export const handleRelaySetUpdated: EventHandler<RelaySetUpdated> = async (event) => {
|
||||||
|
console.debug('[RelayEventHandler] Relay set updated:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
setId: event.setId,
|
||||||
|
nameChanged: event.nameChanged,
|
||||||
|
changes: {
|
||||||
|
addedCount: event.changes.addedRelays?.length ?? 0,
|
||||||
|
removedCount: event.changes.removedRelays?.length ?? 0
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Refresh feed if currently using this relay set
|
||||||
|
// Future: Update relay set display
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for relay set deleted events
|
||||||
|
* Can be used to:
|
||||||
|
* - Switch to different feed if current feed uses deleted set
|
||||||
|
* - Remove from navigation
|
||||||
|
*/
|
||||||
|
export const handleRelaySetDeleted: EventHandler<RelaySetDeleted> = async (event) => {
|
||||||
|
console.debug('[RelayEventHandler] Relay set deleted:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
setId: event.setId
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Switch feed if currently using this relay set
|
||||||
|
// Future: Remove from feed selector options
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for mailbox relay added events
|
||||||
|
* Can be used to:
|
||||||
|
* - Update relay list display
|
||||||
|
* - Connect to new mailbox relay
|
||||||
|
*/
|
||||||
|
export const handleMailboxRelayAdded: EventHandler<MailboxRelayAdded> = async (event) => {
|
||||||
|
console.debug('[RelayEventHandler] Mailbox relay added:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
relay: event.relayUrl.value,
|
||||||
|
scope: event.scope
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Update relay list in settings
|
||||||
|
// Future: Connect to relay based on scope
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for mailbox relay removed events
|
||||||
|
* Can be used to:
|
||||||
|
* - Update relay list display
|
||||||
|
* - Disconnect if no longer needed
|
||||||
|
*/
|
||||||
|
export const handleMailboxRelayRemoved: EventHandler<MailboxRelayRemoved> = async (event) => {
|
||||||
|
console.debug('[RelayEventHandler] Mailbox relay removed:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
relay: event.relayUrl.value
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Update relay list in settings
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for mailbox relay scope changed events
|
||||||
|
* Can be used to:
|
||||||
|
* - Update relay list display
|
||||||
|
* - Adjust connection strategy
|
||||||
|
*/
|
||||||
|
export const handleMailboxRelayScopeChanged: EventHandler<MailboxRelayScopeChanged> = async (event) => {
|
||||||
|
console.debug('[RelayEventHandler] Mailbox relay scope changed:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
relay: event.relayUrl.value,
|
||||||
|
fromScope: event.fromScope,
|
||||||
|
toScope: event.toScope
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Update relay list in settings
|
||||||
|
// Future: Adjust write/read connection strategy
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for relay list published events
|
||||||
|
* Can be used to:
|
||||||
|
* - Invalidate relay caches
|
||||||
|
* - Trigger feed refresh if relay configuration changed
|
||||||
|
*/
|
||||||
|
export const handleRelayListPublished: EventHandler<RelayListPublished> = async (event) => {
|
||||||
|
console.debug('[RelayEventHandler] Relay list published:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
readRelayCount: event.readRelayCount,
|
||||||
|
writeRelayCount: event.writeRelayCount
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Invalidate relay caches
|
||||||
|
// Future: Trigger feed refresh if needed
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all relay event handlers with the event dispatcher
|
||||||
|
*/
|
||||||
|
export function registerRelayEventHandlers(): void {
|
||||||
|
eventDispatcher.on('relay.favorite_added', handleFavoriteRelayAdded)
|
||||||
|
eventDispatcher.on('relay.favorite_removed', handleFavoriteRelayRemoved)
|
||||||
|
eventDispatcher.on('relay.favorites_published', handleFavoriteRelaysPublished)
|
||||||
|
eventDispatcher.on('relay.set_created', handleRelaySetCreated)
|
||||||
|
eventDispatcher.on('relay.set_updated', handleRelaySetUpdated)
|
||||||
|
eventDispatcher.on('relay.set_deleted', handleRelaySetDeleted)
|
||||||
|
eventDispatcher.on('relay.mailbox_added', handleMailboxRelayAdded)
|
||||||
|
eventDispatcher.on('relay.mailbox_removed', handleMailboxRelayRemoved)
|
||||||
|
eventDispatcher.on('relay.mailbox_scope_changed', handleMailboxRelayScopeChanged)
|
||||||
|
eventDispatcher.on('relay.list_published', handleRelayListPublished)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister all relay event handlers
|
||||||
|
*/
|
||||||
|
export function unregisterRelayEventHandlers(): void {
|
||||||
|
eventDispatcher.off('relay.favorite_added', handleFavoriteRelayAdded)
|
||||||
|
eventDispatcher.off('relay.favorite_removed', handleFavoriteRelayRemoved)
|
||||||
|
eventDispatcher.off('relay.favorites_published', handleFavoriteRelaysPublished)
|
||||||
|
eventDispatcher.off('relay.set_created', handleRelaySetCreated)
|
||||||
|
eventDispatcher.off('relay.set_updated', handleRelaySetUpdated)
|
||||||
|
eventDispatcher.off('relay.set_deleted', handleRelaySetDeleted)
|
||||||
|
eventDispatcher.off('relay.mailbox_added', handleMailboxRelayAdded)
|
||||||
|
eventDispatcher.off('relay.mailbox_removed', handleMailboxRelayRemoved)
|
||||||
|
eventDispatcher.off('relay.mailbox_scope_changed', handleMailboxRelayScopeChanged)
|
||||||
|
eventDispatcher.off('relay.list_published', handleRelayListPublished)
|
||||||
|
}
|
||||||
205
src/application/handlers/SocialEventHandlers.ts
Normal file
@@ -0,0 +1,205 @@
|
|||||||
|
import {
|
||||||
|
UserFollowed,
|
||||||
|
UserUnfollowed,
|
||||||
|
UserMuted,
|
||||||
|
UserUnmuted,
|
||||||
|
MuteVisibilityChanged,
|
||||||
|
FollowListPublished,
|
||||||
|
MuteListPublished
|
||||||
|
} from '@/domain/social/events'
|
||||||
|
import { EventHandler, eventDispatcher } from '@/domain/shared'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handlers for social domain events
|
||||||
|
*
|
||||||
|
* These handlers coordinate cross-context updates when social events occur.
|
||||||
|
* They bridge the Social context with Feed, Notification, and Cache contexts.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback type for feed refresh requests
|
||||||
|
*/
|
||||||
|
export type FeedRefreshCallback = () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback type for content refiltering requests
|
||||||
|
*/
|
||||||
|
export type RefilterCallback = () => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Callback type for profile prefetch requests
|
||||||
|
*/
|
||||||
|
export type PrefetchProfileCallback = (pubkey: string) => void
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Service callbacks that can be injected for cross-context coordination
|
||||||
|
*/
|
||||||
|
export interface SocialHandlerCallbacks {
|
||||||
|
onFeedRefreshNeeded?: FeedRefreshCallback
|
||||||
|
onRefilterNeeded?: RefilterCallback
|
||||||
|
onPrefetchProfile?: PrefetchProfileCallback
|
||||||
|
}
|
||||||
|
|
||||||
|
let callbacks: SocialHandlerCallbacks = {}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Set the callbacks for cross-context coordination
|
||||||
|
* Call this during provider initialization
|
||||||
|
*/
|
||||||
|
export function setSocialHandlerCallbacks(newCallbacks: SocialHandlerCallbacks): void {
|
||||||
|
callbacks = { ...callbacks, ...newCallbacks }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Clear all callbacks (for cleanup/testing)
|
||||||
|
*/
|
||||||
|
export function clearSocialHandlerCallbacks(): void {
|
||||||
|
callbacks = {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for user followed events
|
||||||
|
* Coordinates with:
|
||||||
|
* - Feed context: Add followed user's content to timeline
|
||||||
|
* - Cache context: Prefetch followed user's profile and notes
|
||||||
|
*/
|
||||||
|
export const handleUserFollowed: EventHandler<UserFollowed> = async (event) => {
|
||||||
|
console.debug('[SocialEventHandler] User followed:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
followed: event.followed.formatted,
|
||||||
|
petname: event.petname
|
||||||
|
})
|
||||||
|
|
||||||
|
// Prefetch the followed user's profile for better UX
|
||||||
|
if (callbacks.onPrefetchProfile) {
|
||||||
|
callbacks.onPrefetchProfile(event.followed.hex)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for user unfollowed events
|
||||||
|
* Can be used to:
|
||||||
|
* - Update feed context to exclude unfollowed user's content
|
||||||
|
* - Clean up cached data for unfollowed user
|
||||||
|
*/
|
||||||
|
export const handleUserUnfollowed: EventHandler<UserUnfollowed> = async (event) => {
|
||||||
|
console.debug('[SocialEventHandler] User unfollowed:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
unfollowed: event.unfollowed.formatted
|
||||||
|
})
|
||||||
|
|
||||||
|
// Future: Dispatch to feed context to update content sources
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for user muted events
|
||||||
|
* Coordinates with:
|
||||||
|
* - Feed context: Refilter timeline to hide muted user's content
|
||||||
|
* - Notification context: Filter notifications from muted user
|
||||||
|
* - DM context: Update DM filtering
|
||||||
|
*/
|
||||||
|
export const handleUserMuted: EventHandler<UserMuted> = async (event) => {
|
||||||
|
console.debug('[SocialEventHandler] User muted:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
muted: event.muted.formatted,
|
||||||
|
visibility: event.visibility
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger immediate refiltering of current timeline
|
||||||
|
if (callbacks.onRefilterNeeded) {
|
||||||
|
callbacks.onRefilterNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for user unmuted events
|
||||||
|
* Coordinates with:
|
||||||
|
* - Feed context: Refilter timeline to show unmuted user's content
|
||||||
|
* - Notification context: Restore notifications from unmuted user
|
||||||
|
*/
|
||||||
|
export const handleUserUnmuted: EventHandler<UserUnmuted> = async (event) => {
|
||||||
|
console.debug('[SocialEventHandler] User unmuted:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
unmuted: event.unmuted.formatted
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger refiltering to restore unmuted user's content
|
||||||
|
if (callbacks.onRefilterNeeded) {
|
||||||
|
callbacks.onRefilterNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for mute visibility changed events
|
||||||
|
*/
|
||||||
|
export const handleMuteVisibilityChanged: EventHandler<MuteVisibilityChanged> = async (event) => {
|
||||||
|
console.debug('[SocialEventHandler] Mute visibility changed:', {
|
||||||
|
actor: event.actor.formatted,
|
||||||
|
target: event.target.formatted,
|
||||||
|
from: event.from,
|
||||||
|
to: event.to
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for follow list published events
|
||||||
|
* Coordinates with:
|
||||||
|
* - Feed context: Refresh following feed with new list
|
||||||
|
* - Cache context: Invalidate author caches
|
||||||
|
*/
|
||||||
|
export const handleFollowListPublished: EventHandler<FollowListPublished> = async (event) => {
|
||||||
|
console.debug('[SocialEventHandler] Follow list published:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
followingCount: event.followingCount
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger feed refresh to reflect new following list
|
||||||
|
if (callbacks.onFeedRefreshNeeded) {
|
||||||
|
callbacks.onFeedRefreshNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Handler for mute list published events
|
||||||
|
* Coordinates with:
|
||||||
|
* - Feed context: Refilter timeline with new mute list
|
||||||
|
* - Notification context: Update notification filtering
|
||||||
|
*/
|
||||||
|
export const handleMuteListPublished: EventHandler<MuteListPublished> = async (event) => {
|
||||||
|
console.debug('[SocialEventHandler] Mute list published:', {
|
||||||
|
owner: event.owner.formatted,
|
||||||
|
publicMuteCount: event.publicMuteCount,
|
||||||
|
privateMuteCount: event.privateMuteCount
|
||||||
|
})
|
||||||
|
|
||||||
|
// Trigger refiltering with updated mute list
|
||||||
|
if (callbacks.onRefilterNeeded) {
|
||||||
|
callbacks.onRefilterNeeded()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Register all social event handlers with the event dispatcher
|
||||||
|
*/
|
||||||
|
export function registerSocialEventHandlers(): void {
|
||||||
|
eventDispatcher.on('social.user_followed', handleUserFollowed)
|
||||||
|
eventDispatcher.on('social.user_unfollowed', handleUserUnfollowed)
|
||||||
|
eventDispatcher.on('social.user_muted', handleUserMuted)
|
||||||
|
eventDispatcher.on('social.user_unmuted', handleUserUnmuted)
|
||||||
|
eventDispatcher.on('social.mute_visibility_changed', handleMuteVisibilityChanged)
|
||||||
|
eventDispatcher.on('social.follow_list_published', handleFollowListPublished)
|
||||||
|
eventDispatcher.on('social.mute_list_published', handleMuteListPublished)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Unregister all social event handlers
|
||||||
|
*/
|
||||||
|
export function unregisterSocialEventHandlers(): void {
|
||||||
|
eventDispatcher.off('social.user_followed', handleUserFollowed)
|
||||||
|
eventDispatcher.off('social.user_unfollowed', handleUserUnfollowed)
|
||||||
|
eventDispatcher.off('social.user_muted', handleUserMuted)
|
||||||
|
eventDispatcher.off('social.user_unmuted', handleUserUnmuted)
|
||||||
|
eventDispatcher.off('social.mute_visibility_changed', handleMuteVisibilityChanged)
|
||||||
|
eventDispatcher.off('social.follow_list_published', handleFollowListPublished)
|
||||||
|
eventDispatcher.off('social.mute_list_published', handleMuteListPublished)
|
||||||
|
}
|
||||||
121
src/application/handlers/index.ts
Normal file
@@ -0,0 +1,121 @@
|
|||||||
|
/**
|
||||||
|
* Domain Event Handlers
|
||||||
|
*
|
||||||
|
* Application-level handlers that coordinate cross-context updates
|
||||||
|
* when domain events occur.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Social Event Handlers
|
||||||
|
export {
|
||||||
|
registerSocialEventHandlers,
|
||||||
|
unregisterSocialEventHandlers,
|
||||||
|
setSocialHandlerCallbacks,
|
||||||
|
clearSocialHandlerCallbacks,
|
||||||
|
handleUserFollowed,
|
||||||
|
handleUserUnfollowed,
|
||||||
|
handleUserMuted,
|
||||||
|
handleUserUnmuted,
|
||||||
|
handleMuteVisibilityChanged,
|
||||||
|
handleFollowListPublished,
|
||||||
|
handleMuteListPublished,
|
||||||
|
type SocialHandlerCallbacks,
|
||||||
|
type FeedRefreshCallback,
|
||||||
|
type RefilterCallback,
|
||||||
|
type PrefetchProfileCallback
|
||||||
|
} from './SocialEventHandlers'
|
||||||
|
|
||||||
|
// Content Event Handlers
|
||||||
|
export {
|
||||||
|
registerContentEventHandlers,
|
||||||
|
unregisterContentEventHandlers,
|
||||||
|
setContentHandlerCallbacks,
|
||||||
|
clearContentHandlerCallbacks,
|
||||||
|
handleEventBookmarked,
|
||||||
|
handleEventUnbookmarked,
|
||||||
|
handleBookmarkListPublished,
|
||||||
|
handleNotePinned,
|
||||||
|
handleNoteUnpinned,
|
||||||
|
handlePinsLimitExceeded,
|
||||||
|
handlePinListPublished,
|
||||||
|
handleReactionAdded,
|
||||||
|
handleContentReposted,
|
||||||
|
type ContentHandlerCallbacks,
|
||||||
|
type UpdateReactionCountCallback,
|
||||||
|
type UpdateRepostCountCallback,
|
||||||
|
type CreateNotificationCallback,
|
||||||
|
type ShowToastCallback,
|
||||||
|
type UpdateProfilePinsCallback
|
||||||
|
} from './ContentEventHandlers'
|
||||||
|
|
||||||
|
// Feed Event Handlers
|
||||||
|
export {
|
||||||
|
registerFeedEventHandlers,
|
||||||
|
unregisterFeedEventHandlers,
|
||||||
|
handleFeedSwitched,
|
||||||
|
handleContentFilterUpdated,
|
||||||
|
handleFeedRefreshed,
|
||||||
|
handleNoteCreated,
|
||||||
|
handleNoteDeleted,
|
||||||
|
handleNoteReplied,
|
||||||
|
handleUsersMentioned,
|
||||||
|
handleTimelineEventsReceived,
|
||||||
|
handleTimelineEOSED
|
||||||
|
} from './FeedEventHandlers'
|
||||||
|
|
||||||
|
// Relay Event Handlers
|
||||||
|
export {
|
||||||
|
registerRelayEventHandlers,
|
||||||
|
unregisterRelayEventHandlers,
|
||||||
|
handleFavoriteRelayAdded,
|
||||||
|
handleFavoriteRelayRemoved,
|
||||||
|
handleFavoriteRelaysPublished,
|
||||||
|
handleRelaySetCreated,
|
||||||
|
handleRelaySetUpdated,
|
||||||
|
handleRelaySetDeleted,
|
||||||
|
handleMailboxRelayAdded,
|
||||||
|
handleMailboxRelayRemoved,
|
||||||
|
handleMailboxRelayScopeChanged,
|
||||||
|
handleRelayListPublished
|
||||||
|
} from './RelayEventHandlers'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Initialize all domain event handlers
|
||||||
|
*
|
||||||
|
* Call this once during application startup to register all handlers
|
||||||
|
* with the event dispatcher.
|
||||||
|
*/
|
||||||
|
export function initializeEventHandlers(): void {
|
||||||
|
const { registerSocialEventHandlers } = require('./SocialEventHandlers')
|
||||||
|
const { registerContentEventHandlers } = require('./ContentEventHandlers')
|
||||||
|
const { registerFeedEventHandlers } = require('./FeedEventHandlers')
|
||||||
|
const { registerRelayEventHandlers } = require('./RelayEventHandlers')
|
||||||
|
|
||||||
|
registerSocialEventHandlers()
|
||||||
|
registerContentEventHandlers()
|
||||||
|
registerFeedEventHandlers()
|
||||||
|
registerRelayEventHandlers()
|
||||||
|
|
||||||
|
console.debug('[EventHandlers] All domain event handlers registered')
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Cleanup all domain event handlers
|
||||||
|
*
|
||||||
|
* Call this during application shutdown or for testing purposes.
|
||||||
|
*/
|
||||||
|
export function cleanupEventHandlers(): void {
|
||||||
|
const { unregisterSocialEventHandlers, clearSocialHandlerCallbacks } = require('./SocialEventHandlers')
|
||||||
|
const { unregisterContentEventHandlers, clearContentHandlerCallbacks } = require('./ContentEventHandlers')
|
||||||
|
const { unregisterFeedEventHandlers } = require('./FeedEventHandlers')
|
||||||
|
const { unregisterRelayEventHandlers } = require('./RelayEventHandlers')
|
||||||
|
|
||||||
|
unregisterSocialEventHandlers()
|
||||||
|
unregisterContentEventHandlers()
|
||||||
|
unregisterFeedEventHandlers()
|
||||||
|
unregisterRelayEventHandlers()
|
||||||
|
|
||||||
|
clearSocialHandlerCallbacks()
|
||||||
|
clearContentHandlerCallbacks()
|
||||||
|
|
||||||
|
console.debug('[EventHandlers] All domain event handlers unregistered')
|
||||||
|
}
|
||||||
@@ -10,3 +10,13 @@ export type { RelaySelectorOptions } from './RelaySelector'
|
|||||||
|
|
||||||
export { PublishingService, publishingService } from './PublishingService'
|
export { PublishingService, publishingService } from './PublishingService'
|
||||||
export type { DraftEvent, PublishNoteOptions } from './PublishingService'
|
export type { DraftEvent, PublishNoteOptions } from './PublishingService'
|
||||||
|
|
||||||
|
// Event Handlers
|
||||||
|
export {
|
||||||
|
initializeEventHandlers,
|
||||||
|
cleanupEventHandlers,
|
||||||
|
registerSocialEventHandlers,
|
||||||
|
unregisterSocialEventHandlers,
|
||||||
|
registerContentEventHandlers,
|
||||||
|
unregisterContentEventHandlers
|
||||||
|
} from './handlers'
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 9.1 KiB |
|
Before Width: | Height: | Size: 11 KiB After Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 7.0 KiB After Width: | Height: | Size: 2.9 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 8.3 KiB |
@@ -1,6 +1,6 @@
|
|||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { isSameAccount } from '@/lib/account'
|
import { isSameAccount } from '@/lib/account'
|
||||||
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 } from '@/types'
|
import { TAccountPointer } from '@/types'
|
||||||
@@ -43,7 +43,7 @@ export default function AccountList({
|
|||||||
<div className="flex-1 w-0">
|
<div className="flex-1 w-0">
|
||||||
<SimpleUsername userId={act.pubkey} className="font-semibold truncate" />
|
<SimpleUsername userId={act.pubkey} className="font-semibold truncate" />
|
||||||
<div className="text-sm rounded-full bg-muted px-2 w-fit">
|
<div className="text-sm rounded-full bg-muted px-2 w-fit">
|
||||||
{formatPubkey(act.pubkey)}
|
{Pubkey.tryFromString(act.pubkey)?.formatNpub(12) ?? act.pubkey.slice(0, 8)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,10 +1,16 @@
|
|||||||
|
import QrScannerModal from '@/components/QrScannerModal'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { ArrowLeft, Loader2, Server } from 'lucide-react'
|
import { BunkerSigner } from '@/providers/NostrProvider/bunker.signer'
|
||||||
import { useState } from 'react'
|
import { ArrowLeft, Loader2, QrCode, Server, Copy, Check, ScanLine } from 'lucide-react'
|
||||||
|
import { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
|
||||||
|
// Default relay for bunker connections - can be configured
|
||||||
|
const DEFAULT_BUNKER_RELAY = 'wss://relay.nsec.app'
|
||||||
|
|
||||||
export default function BunkerLogin({
|
export default function BunkerLogin({
|
||||||
back,
|
back,
|
||||||
@@ -14,19 +20,88 @@ export default function BunkerLogin({
|
|||||||
onLoginSuccess: () => void
|
onLoginSuccess: () => void
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { bunkerLogin } = useNostr()
|
const { bunkerLoginWithSigner, bunkerLogin } = useNostr()
|
||||||
|
const [mode, setMode] = useState<'choose' | 'scan' | 'paste'>('choose')
|
||||||
const [bunkerUrl, setBunkerUrl] = useState('')
|
const [bunkerUrl, setBunkerUrl] = useState('')
|
||||||
|
const [relayUrl, setRelayUrl] = useState(DEFAULT_BUNKER_RELAY)
|
||||||
const [loading, setLoading] = useState(false)
|
const [loading, setLoading] = useState(false)
|
||||||
const [error, setError] = useState<string | null>(null)
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [connectUrl, setConnectUrl] = useState<string | null>(null)
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||||||
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [showScanner, setShowScanner] = useState(false)
|
||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
// Generate QR code when in scan mode
|
||||||
|
useEffect(() => {
|
||||||
|
if (mode !== 'scan') return
|
||||||
|
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const startConnection = async () => {
|
||||||
|
setLoading(true)
|
||||||
|
setError(null)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const { connectUrl, signer: signerPromise } = await BunkerSigner.awaitSignerConnection(
|
||||||
|
relayUrl,
|
||||||
|
undefined,
|
||||||
|
120000 // 2 minute timeout
|
||||||
|
)
|
||||||
|
|
||||||
|
if (cancelled) return
|
||||||
|
|
||||||
|
setConnectUrl(connectUrl)
|
||||||
|
|
||||||
|
// Generate QR code
|
||||||
|
const qr = await QRCode.toDataURL(connectUrl, {
|
||||||
|
width: 256,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: '#000000', light: '#ffffff' }
|
||||||
|
})
|
||||||
|
setQrDataUrl(qr)
|
||||||
|
setLoading(false)
|
||||||
|
|
||||||
|
// Wait for signer to connect
|
||||||
|
const signer = await signerPromise
|
||||||
|
|
||||||
|
if (cancelled) {
|
||||||
|
signer.disconnect()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the user's pubkey from the signer
|
||||||
|
const pubkey = await signer.getPublicKey()
|
||||||
|
|
||||||
|
// Complete login
|
||||||
|
await bunkerLoginWithSigner(signer, pubkey)
|
||||||
|
onLoginSuccess()
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setError((err as Error).message)
|
||||||
|
setLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
startConnection()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [mode, relayUrl, bunkerLoginWithSigner, onLoginSuccess])
|
||||||
|
|
||||||
|
const handleScan = (result: string) => {
|
||||||
|
setBunkerUrl(result)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
|
const handlePasteSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!bunkerUrl.trim()) {
|
if (!bunkerUrl.trim()) {
|
||||||
setError(t('Please enter a bunker URL'))
|
setError(t('Please enter a bunker URL'))
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate bunker URL format
|
|
||||||
if (!bunkerUrl.startsWith('bunker://')) {
|
if (!bunkerUrl.startsWith('bunker://')) {
|
||||||
setError(t('Invalid bunker URL format. Must start with bunker://'))
|
setError(t('Invalid bunker URL format. Must start with bunker://'))
|
||||||
return
|
return
|
||||||
@@ -36,6 +111,7 @@ export default function BunkerLogin({
|
|||||||
setError(null)
|
setError(null)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Use the existing bunkerLogin flow for bunker:// URLs
|
||||||
await bunkerLogin(bunkerUrl.trim())
|
await bunkerLogin(bunkerUrl.trim())
|
||||||
onLoginSuccess()
|
onLoginSuccess()
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -45,6 +121,15 @@ export default function BunkerLogin({
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const copyToClipboard = async () => {
|
||||||
|
if (connectUrl) {
|
||||||
|
await navigator.clipboard.writeText(connectUrl)
|
||||||
|
setCopied(true)
|
||||||
|
setTimeout(() => setCopied(false), 2000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'choose') {
|
||||||
return (
|
return (
|
||||||
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
@@ -57,9 +142,153 @@ export default function BunkerLogin({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="space-y-4">
|
<div className="space-y-3">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-3 h-auto py-4"
|
||||||
|
onClick={() => setMode('scan')}
|
||||||
|
>
|
||||||
|
<QrCode className="size-6" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">{t('Show QR Code')}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('Scan with Amber or another NIP-46 signer')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="w-full justify-start gap-3 h-auto py-4"
|
||||||
|
onClick={() => setMode('paste')}
|
||||||
|
>
|
||||||
|
<Server className="size-6" />
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="font-medium">{t('Paste Bunker URL')}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('Enter a bunker:// URL from your signer')}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-muted-foreground space-y-2 pt-2">
|
||||||
|
<p>
|
||||||
|
<strong>{t('What is a bunker?')}</strong>
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{t(
|
||||||
|
'A bunker (NIP-46) is a remote signing service that keeps your private key secure while allowing you to sign Nostr events. Your key never leaves the bunker.'
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (mode === 'scan') {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<QrCode className="size-5" />
|
||||||
|
<span className="font-semibold">{t('Scan with Signer')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="relayUrl">{t('Relay URL')}</Label>
|
||||||
|
<Input
|
||||||
|
id="relayUrl"
|
||||||
|
type="text"
|
||||||
|
value={relayUrl}
|
||||||
|
onChange={(e) => setRelayUrl(e.target.value)}
|
||||||
|
disabled={loading || !!qrDataUrl}
|
||||||
|
className="font-mono text-sm"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !qrDataUrl && (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{qrDataUrl && (
|
||||||
|
<div className="flex flex-col items-center gap-4">
|
||||||
|
<div
|
||||||
|
className="relative cursor-pointer rounded-lg overflow-hidden"
|
||||||
|
onClick={copyToClipboard}
|
||||||
|
title={t('Click to copy URL')}
|
||||||
|
>
|
||||||
|
<img src={qrDataUrl} alt="Bunker QR Code" className="w-64 h-64" />
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/50 opacity-0 hover:opacity-100 transition-opacity">
|
||||||
|
{copied ? (
|
||||||
|
<Check className="size-8 text-white" />
|
||||||
|
) : (
|
||||||
|
<Copy className="size-8 text-white" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="text-sm text-muted-foreground text-center">
|
||||||
|
{t('Scan this QR code with Amber or your NIP-46 signer')}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
<span className="text-sm text-muted-foreground">{t('Waiting for connection...')}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connectUrl && (
|
||||||
|
<div className="w-full">
|
||||||
|
<Label className="text-xs text-muted-foreground">{t('Connection URL')}</Label>
|
||||||
|
<div className="flex gap-2 mt-1">
|
||||||
|
<Input
|
||||||
|
value={connectUrl}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button size="icon" variant="outline" onClick={copyToClipboard}>
|
||||||
|
{copied ? <Check className="size-4" /> : <Copy className="size-4" />}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && <div className="text-sm text-destructive text-center">{error}</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Paste mode
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{showScanner && (
|
||||||
|
<QrScannerModal onScan={handleScan} onClose={() => setShowScanner(false)} />
|
||||||
|
)}
|
||||||
|
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
|
||||||
|
<ArrowLeft className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Server className="size-5" />
|
||||||
|
<span className="font-semibold">{t('Paste Bunker URL')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<form onSubmit={handlePasteSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
|
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="bunkerUrl"
|
id="bunkerUrl"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -69,6 +298,17 @@ export default function BunkerLogin({
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowScanner(true)}
|
||||||
|
disabled={loading}
|
||||||
|
title={t('Scan QR code')}
|
||||||
|
>
|
||||||
|
<ScanLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
'Enter the bunker connection URL. This is typically provided by your signing device or service.'
|
'Enter the bunker connection URL. This is typically provided by your signing device or service.'
|
||||||
@@ -89,17 +329,7 @@ export default function BunkerLogin({
|
|||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="text-xs text-muted-foreground space-y-2">
|
|
||||||
<p>
|
|
||||||
<strong>{t('What is a bunker?')}</strong>
|
|
||||||
</p>
|
|
||||||
<p>
|
|
||||||
{t(
|
|
||||||
'A bunker (NIP-46) is a remote signing service that keeps your private key secure while allowing you to sign Nostr events. Your key never leaves the bunker.'
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,78 +1,12 @@
|
|||||||
|
import QrScannerModal from '@/components/QrScannerModal'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { ScanLine, X } from 'lucide-react'
|
import { ScanLine } from 'lucide-react'
|
||||||
import QrScanner from 'qr-scanner'
|
|
||||||
|
|
||||||
function QrScannerModal({
|
|
||||||
onScan,
|
|
||||||
onClose
|
|
||||||
}: {
|
|
||||||
onScan: (result: string) => void
|
|
||||||
onClose: () => void
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation()
|
|
||||||
const videoRef = useRef<HTMLVideoElement>(null)
|
|
||||||
const scannerRef = useRef<QrScanner | null>(null)
|
|
||||||
const [error, setError] = useState<string | null>(null)
|
|
||||||
|
|
||||||
const handleScan = useCallback(
|
|
||||||
(result: QrScanner.ScanResult) => {
|
|
||||||
onScan(result.data)
|
|
||||||
onClose()
|
|
||||||
},
|
|
||||||
[onScan, onClose]
|
|
||||||
)
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!videoRef.current) return
|
|
||||||
|
|
||||||
const scanner = new QrScanner(videoRef.current, handleScan, {
|
|
||||||
preferredCamera: 'environment',
|
|
||||||
highlightScanRegion: true,
|
|
||||||
highlightCodeOutline: true
|
|
||||||
})
|
|
||||||
|
|
||||||
scannerRef.current = scanner
|
|
||||||
|
|
||||||
scanner.start().catch(() => {
|
|
||||||
setError(t('Failed to access camera'))
|
|
||||||
})
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
scanner.destroy()
|
|
||||||
}
|
|
||||||
}, [handleScan, t])
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
|
|
||||||
<div className="relative w-full max-w-sm mx-4">
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
className="absolute -top-12 right-0 text-white hover:bg-white/20"
|
|
||||||
onClick={onClose}
|
|
||||||
>
|
|
||||||
<X className="h-6 w-6" />
|
|
||||||
</Button>
|
|
||||||
<div className="rounded-lg overflow-hidden bg-black">
|
|
||||||
{error ? (
|
|
||||||
<div className="p-8 text-center text-destructive">{error}</div>
|
|
||||||
) : (
|
|
||||||
<video ref={videoRef} className="w-full" />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-center text-white/70 text-sm mt-4">
|
|
||||||
{t('Point camera at QR code')}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function PrivateKeyLogin({
|
export default function PrivateKeyLogin({
|
||||||
back,
|
back,
|
||||||
|
|||||||
41
src/components/ActionModeOverlay/index.tsx
Normal file
@@ -0,0 +1,41 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { TActionType, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
|
import { MessageSquare, Repeat2, Quote, Heart, Zap } from 'lucide-react'
|
||||||
|
|
||||||
|
const ACTIONS: { type: TActionType; icon: typeof MessageSquare; label: string }[] = [
|
||||||
|
{ type: 'reply', icon: MessageSquare, label: 'Reply' },
|
||||||
|
{ type: 'repost', icon: Repeat2, label: 'Repost' },
|
||||||
|
{ type: 'quote', icon: Quote, label: 'Quote' },
|
||||||
|
{ type: 'react', icon: Heart, label: 'React' },
|
||||||
|
{ type: 'zap', icon: Zap, label: 'Zap' }
|
||||||
|
]
|
||||||
|
|
||||||
|
export default function ActionModeOverlay() {
|
||||||
|
const { actionMode, isEnabled } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
if (!isEnabled || !actionMode.active) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-20 left-1/2 -translate-x-1/2 z-50 pointer-events-none">
|
||||||
|
<div className="flex gap-1 bg-background/95 backdrop-blur-sm border rounded-full px-3 py-2 shadow-lg">
|
||||||
|
{ACTIONS.map(({ type, icon: Icon, label }) => (
|
||||||
|
<div
|
||||||
|
key={type}
|
||||||
|
className={cn(
|
||||||
|
'flex flex-col items-center gap-1 p-2 rounded-full transition-all duration-150',
|
||||||
|
actionMode.selectedAction === type
|
||||||
|
? 'bg-primary text-primary-foreground scale-110'
|
||||||
|
: 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
title={label}
|
||||||
|
>
|
||||||
|
<Icon className="size-5" />
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="text-center text-xs text-muted-foreground mt-2">
|
||||||
|
Tab to cycle, Enter to activate, Esc to cancel
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { userIdToPubkey } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import { useFollowList } from '@/providers/FollowListProvider'
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
import { UserRoundCheck } from 'lucide-react'
|
import { UserRoundCheck } from 'lucide-react'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
@@ -10,7 +10,7 @@ export default function FollowingBadge({ pubkey, userId }: { pubkey?: string; us
|
|||||||
const isFollowing = useMemo(() => {
|
const isFollowing = useMemo(() => {
|
||||||
if (pubkey) return followingSet.has(pubkey)
|
if (pubkey) return followingSet.has(pubkey)
|
||||||
|
|
||||||
return userId ? followingSet.has(userIdToPubkey(userId)) : false
|
return userId ? followingSet.has(Pubkey.tryFromString(userId)?.hex ?? userId) : false
|
||||||
}, [followingSet, pubkey, userId])
|
}, [followingSet, pubkey, userId])
|
||||||
|
|
||||||
if (!isFollowing) return null
|
if (!isFollowing) return null
|
||||||
|
|||||||
208
src/components/Help/index.tsx
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
import {
|
||||||
|
Accordion,
|
||||||
|
AccordionContent,
|
||||||
|
AccordionItem,
|
||||||
|
AccordionTrigger
|
||||||
|
} from '@/components/ui/accordion'
|
||||||
|
import { Keyboard, Layout, MessageSquare, Settings, User, Zap } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function Help() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-4 py-4">
|
||||||
|
<Accordion type="single" collapsible className="space-y-2">
|
||||||
|
<AccordionItem value="keyboard" className="border rounded-lg px-4">
|
||||||
|
<AccordionTrigger className="py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Keyboard className="size-5 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{t('Keyboard Navigation')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-4">
|
||||||
|
<div className="space-y-4 text-sm text-muted-foreground">
|
||||||
|
<p>{t('Navigate the app entirely with your keyboard:')}</p>
|
||||||
|
<p className="font-medium">{t('Toggle Keyboard Mode:')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<KeyBinding keys={['⇧K']} description={t('Toggle keyboard navigation on/off')} />
|
||||||
|
<KeyBinding keys={['Esc', 'Esc', 'Esc']} description={t('Triple-Escape to quickly exit keyboard mode')} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs opacity-70">{t('You can also click the keyboard button in the sidebar to toggle.')}</p>
|
||||||
|
<p className="font-medium mt-4">{t('Movement:')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<KeyBinding keys={['↑', '↓']} altKeys={['k', 'j']} description={t('Move between items in a list')} />
|
||||||
|
<KeyBinding keys={['Tab']} description={t('Switch to next column (Shift+Tab for previous)')} />
|
||||||
|
<KeyBinding keys={['Page Up']} description={t('Jump to top and focus first item')} />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium mt-4">{t('Actions:')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<KeyBinding keys={['→', 'Enter']} altKeys={['l']} description={t('Activate the selected item')} />
|
||||||
|
<KeyBinding keys={['←']} altKeys={['h']} description={t('Go back (close panel or move to sidebar)')} />
|
||||||
|
<KeyBinding keys={['Escape']} description={t('Close current view or cancel')} />
|
||||||
|
</div>
|
||||||
|
<p className="font-medium mt-4">{t('Note Actions (when a note is selected):')}</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<KeyBinding keys={['r']} description={t('Reply')} />
|
||||||
|
<KeyBinding keys={['p']} description={t('Repost')} />
|
||||||
|
<KeyBinding keys={['q']} description={t('Quote')} />
|
||||||
|
<KeyBinding keys={['R']} description={t('React with emoji')} />
|
||||||
|
<KeyBinding keys={['z']} description={t('Zap (send sats)')} />
|
||||||
|
</div>
|
||||||
|
<p className="text-xs opacity-70 pt-2">{t('Selected items are centered on screen for easy viewing.')}</p>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="layout" className="border rounded-lg px-4">
|
||||||
|
<AccordionTrigger className="py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Layout className="size-5 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{t('Layout & Navigation')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-4">
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p>{t('The app uses a multi-column layout:')}</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1.5 ml-2">
|
||||||
|
<li>{t('Sidebar: Quick access to main sections')}</li>
|
||||||
|
<li>{t('Primary column: Feed, notifications, inbox, search')}</li>
|
||||||
|
<li>{t('Secondary column: Note details, user profiles, relay info')}</li>
|
||||||
|
</ul>
|
||||||
|
<p>{t('On mobile or single-column mode, pages stack on top of each other.')}</p>
|
||||||
|
<p>{t('Use the columns button at the bottom of the sidebar to switch between layouts.')}</p>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="posting" className="border rounded-lg px-4">
|
||||||
|
<AccordionTrigger className="py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<MessageSquare className="size-5 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{t('Posting & Interactions')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-4">
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p><strong>{t('Creating Posts:')}</strong></p>
|
||||||
|
<ul className="list-disc list-inside space-y-1.5 ml-2">
|
||||||
|
<li>{t('Click the post button in the sidebar to compose a new note')}</li>
|
||||||
|
<li>{t('Use @ to mention users and # for hashtags')}</li>
|
||||||
|
<li>{t('Drag and drop images or use the attachment button')}</li>
|
||||||
|
</ul>
|
||||||
|
<p className="pt-2"><strong>{t('Interacting with Notes:')}</strong></p>
|
||||||
|
<ul className="list-disc list-inside space-y-1.5 ml-2">
|
||||||
|
<li>{t('Reply: Continue the conversation')}</li>
|
||||||
|
<li>{t('Repost: Share to your followers')}</li>
|
||||||
|
<li>{t('Quote: Repost with your own comment')}</li>
|
||||||
|
<li>{t('React: Like or add emoji reactions')}</li>
|
||||||
|
<li>{t('Zap: Send Bitcoin tips via Lightning')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="zaps" className="border rounded-lg px-4">
|
||||||
|
<AccordionTrigger className="py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Zap className="size-5 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{t('Zaps & Lightning')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-4">
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p>{t('Zaps are Bitcoin tips sent via the Lightning Network:')}</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1.5 ml-2">
|
||||||
|
<li>{t('To receive zaps, add a Lightning address to your profile')}</li>
|
||||||
|
<li>{t('To send zaps, connect a Lightning wallet in Settings')}</li>
|
||||||
|
<li>{t('Click the zap icon on any note to send sats')}</li>
|
||||||
|
<li>{t('Long-press for custom zap amounts')}</li>
|
||||||
|
</ul>
|
||||||
|
<p className="pt-2">{t('Supported wallets include Alby, NWC-compatible wallets, and Cashu mints.')}</p>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="accounts" className="border rounded-lg px-4">
|
||||||
|
<AccordionTrigger className="py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<User className="size-5 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{t('Account & Login')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-4">
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<p>{t('Nostr uses public/private key pairs for identity:')}</p>
|
||||||
|
<ul className="list-disc list-inside space-y-1.5 ml-2">
|
||||||
|
<li><strong>npub</strong>: {t('Your public key (share freely)')}</li>
|
||||||
|
<li><strong>nsec</strong>: {t('Your private key (keep secret!)')}</li>
|
||||||
|
</ul>
|
||||||
|
<p className="pt-2"><strong>{t('Login Methods:')}</strong></p>
|
||||||
|
<ul className="list-disc list-inside space-y-1.5 ml-2">
|
||||||
|
<li><strong>{t('Browser Extension (NIP-07)')}</strong>: {t('Recommended. Uses extensions like Alby or nos2x')}</li>
|
||||||
|
<li><strong>{t('Remote Signer (NIP-46)')}</strong>: {t('Connect to bunker signers like Amber or nsecBunker')}</li>
|
||||||
|
<li><strong>{t('Private Key')}</strong>: {t('Enter nsec directly (less secure)')}</li>
|
||||||
|
<li><strong>{t('View Only')}</strong>: {t('Browse with an npub without signing')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
|
||||||
|
<AccordionItem value="settings" className="border rounded-lg px-4">
|
||||||
|
<AccordionTrigger className="py-3">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<Settings className="size-5 text-muted-foreground" />
|
||||||
|
<span className="font-medium">{t('Settings Overview')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="pb-4">
|
||||||
|
<div className="space-y-3 text-sm text-muted-foreground">
|
||||||
|
<ul className="list-disc list-inside space-y-1.5 ml-2">
|
||||||
|
<li><strong>{t('General')}</strong>: {t('Language, content preferences, mutes')}</li>
|
||||||
|
<li><strong>{t('Appearance')}</strong>: {t('Theme, layout, visual options')}</li>
|
||||||
|
<li><strong>{t('Relays')}</strong>: {t('Configure which relays to read from and write to')}</li>
|
||||||
|
<li><strong>{t('Posts')}</strong>: {t('Posting preferences and default settings')}</li>
|
||||||
|
<li><strong>{t('Wallet')}</strong>: {t('Lightning wallet connection for zaps')}</li>
|
||||||
|
<li><strong>{t('Emoji Packs')}</strong>: {t('Custom emoji sets')}</li>
|
||||||
|
<li><strong>{t('System')}</strong>: {t('Debug tools and app information')}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</Accordion>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function KeyBinding({
|
||||||
|
keys,
|
||||||
|
altKeys,
|
||||||
|
description
|
||||||
|
}: {
|
||||||
|
keys: string[]
|
||||||
|
altKeys?: string[]
|
||||||
|
description: string
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{keys.map((key) => (
|
||||||
|
<kbd key={key} className="px-2 py-1 text-xs font-mono bg-muted border rounded">
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
{altKeys && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-muted-foreground mx-1">/</span>
|
||||||
|
{altKeys.map((key) => (
|
||||||
|
<kbd key={key} className="px-2 py-1 text-xs font-mono bg-muted border rounded">
|
||||||
|
{key}
|
||||||
|
</kbd>
|
||||||
|
))}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<span>{description}</span>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
113
src/components/Inbox/ConversationItem.tsx
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
|
import { formatTimestamp } from '@/lib/timestamp'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import { TConversation, TProfile } from '@/types'
|
||||||
|
import { Lock, Users, X } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
|
||||||
|
interface ConversationItemProps {
|
||||||
|
conversation: TConversation
|
||||||
|
isActive: boolean
|
||||||
|
isFollowing: boolean
|
||||||
|
onClick: () => void
|
||||||
|
onClose?: () => void
|
||||||
|
navIndex?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversationItem({
|
||||||
|
conversation,
|
||||||
|
isActive,
|
||||||
|
isFollowing,
|
||||||
|
onClick,
|
||||||
|
onClose,
|
||||||
|
navIndex
|
||||||
|
}: ConversationItemProps) {
|
||||||
|
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const handleActivate = useCallback(() => {
|
||||||
|
buttonRef.current?.click()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { ref: navRef, isSelected } = useKeyboardNavigable(1, navIndex ?? 0, {
|
||||||
|
meta: { type: 'sidebar', onActivate: handleActivate }
|
||||||
|
})
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchProfileData = async () => {
|
||||||
|
try {
|
||||||
|
const profileData = await client.fetchProfile(conversation.partnerPubkey)
|
||||||
|
if (profileData) {
|
||||||
|
setProfile(profileData)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch profile:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProfileData()
|
||||||
|
}, [conversation.partnerPubkey])
|
||||||
|
|
||||||
|
const displayName = profile?.username || conversation.partnerPubkey.slice(0, 8) + '...'
|
||||||
|
const formattedTime = formatTimestamp(conversation.lastMessageAt)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={navRef} className="scroll-mt-[6.5rem]">
|
||||||
|
<button
|
||||||
|
ref={buttonRef}
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
'w-full flex items-start gap-3 p-3 hover:bg-accent/50 transition-colors text-left',
|
||||||
|
isActive && 'bg-accent',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-inset'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<UserAvatar userId={conversation.partnerPubkey} className="size-10 flex-shrink-0" />
|
||||||
|
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center justify-between gap-2">
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<span className="font-medium text-sm truncate">{displayName}</span>
|
||||||
|
{isFollowing && (
|
||||||
|
<span className="text-xs text-primary flex-shrink-0" title="Following">
|
||||||
|
<Users className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
<span className="text-xs text-muted-foreground">{formattedTime}</span>
|
||||||
|
{isActive && onClose && (
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onClose()
|
||||||
|
}}
|
||||||
|
className="p-0.5 rounded hover:bg-muted-foreground/20 transition-colors"
|
||||||
|
title="Close conversation"
|
||||||
|
>
|
||||||
|
<X className="size-4 text-muted-foreground" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-1.5 mt-0.5">
|
||||||
|
{conversation.preferredEncryption === 'nip17' && (
|
||||||
|
<span title="NIP-17 encrypted">
|
||||||
|
<Lock className="size-3 text-green-500 flex-shrink-0" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
<p className="text-sm text-muted-foreground truncate">{conversation.lastMessagePreview}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{conversation.unreadCount > 0 && (
|
||||||
|
<span className="inline-flex items-center justify-center size-5 text-xs rounded-full bg-primary text-primary-foreground mt-1">
|
||||||
|
{conversation.unreadCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
159
src/components/Inbox/ConversationList.tsx
Normal file
@@ -0,0 +1,159 @@
|
|||||||
|
import { toDMConversation } from '@/lib/link'
|
||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
|
import storage from '@/services/local-storage.service'
|
||||||
|
import { Check, Loader2, MessageSquare, MoreVertical, RefreshCw } from 'lucide-react'
|
||||||
|
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '../ui/dropdown-menu'
|
||||||
|
import { ScrollArea } from '../ui/scroll-area'
|
||||||
|
import ConversationItem from './ConversationItem'
|
||||||
|
|
||||||
|
export default function ConversationList() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { push, pop } = useSecondaryPage()
|
||||||
|
const {
|
||||||
|
conversations,
|
||||||
|
currentConversation,
|
||||||
|
selectConversation,
|
||||||
|
refreshConversations,
|
||||||
|
loadMoreConversations,
|
||||||
|
hasMoreConversations,
|
||||||
|
isLoading
|
||||||
|
} = useDM()
|
||||||
|
const { followingSet } = useFollowList()
|
||||||
|
const { mutePubkeySet } = useMuteList()
|
||||||
|
const loadMoreRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [filterMode, setFilterMode] = useState<'all' | 'follows'>(() =>
|
||||||
|
storage.getDMConversationFilter()
|
||||||
|
)
|
||||||
|
|
||||||
|
// Filter and sort conversations
|
||||||
|
const sortedConversations = useMemo(() => {
|
||||||
|
let filtered = [...conversations]
|
||||||
|
|
||||||
|
if (filterMode === 'follows') {
|
||||||
|
// Only show conversations from follows, and hide muted users
|
||||||
|
filtered = filtered.filter(
|
||||||
|
(c) => followingSet.has(c.partnerPubkey) && !mutePubkeySet.has(c.partnerPubkey)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return filtered.sort((a, b) => b.lastMessageAt - a.lastMessageAt)
|
||||||
|
}, [conversations, filterMode, followingSet, mutePubkeySet])
|
||||||
|
|
||||||
|
const handleFilterChange = (mode: 'all' | 'follows') => {
|
||||||
|
setFilterMode(mode)
|
||||||
|
storage.setDMConversationFilter(mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Infinite scroll: load more when sentinel is visible
|
||||||
|
const handleIntersection = useCallback(
|
||||||
|
(entries: IntersectionObserverEntry[]) => {
|
||||||
|
const [entry] = entries
|
||||||
|
if (entry.isIntersecting && hasMoreConversations && !isLoading) {
|
||||||
|
loadMoreConversations()
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[hasMoreConversations, isLoading, loadMoreConversations]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const observer = new IntersectionObserver(handleIntersection, {
|
||||||
|
root: null,
|
||||||
|
rootMargin: '100px',
|
||||||
|
threshold: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
if (loadMoreRef.current) {
|
||||||
|
observer.observe(loadMoreRef.current)
|
||||||
|
}
|
||||||
|
|
||||||
|
return () => observer.disconnect()
|
||||||
|
}, [handleIntersection])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
<div className="flex items-center justify-between p-3 border-b">
|
||||||
|
<span className="font-medium text-sm">{t('Conversations')}</span>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
onClick={refreshConversations}
|
||||||
|
disabled={isLoading}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`size-4 ${isLoading ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={() => handleFilterChange('follows')}>
|
||||||
|
{filterMode === 'follows' && <Check className="size-4 mr-2" />}
|
||||||
|
<span className={filterMode !== 'follows' ? 'ml-6' : ''}>
|
||||||
|
{t('Only show follows')}
|
||||||
|
</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => handleFilterChange('all')}>
|
||||||
|
{filterMode === 'all' && <Check className="size-4 mr-2" />}
|
||||||
|
<span className={filterMode !== 'all' ? 'ml-6' : ''}>{t('Show all')}</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ScrollArea className="flex-1">
|
||||||
|
{sortedConversations.length === 0 && !isLoading ? (
|
||||||
|
<div className="flex flex-col items-center justify-center h-48 gap-2 text-muted-foreground px-4">
|
||||||
|
<MessageSquare className="size-8" />
|
||||||
|
<p className="text-sm text-center">{t('No conversations yet')}</p>
|
||||||
|
<p className="text-xs text-center">{t('Start a conversation by visiting a profile')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="divide-y">
|
||||||
|
{sortedConversations.map((conversation, index) => (
|
||||||
|
<ConversationItem
|
||||||
|
key={conversation.partnerPubkey}
|
||||||
|
conversation={conversation}
|
||||||
|
isActive={currentConversation === conversation.partnerPubkey}
|
||||||
|
isFollowing={followingSet.has(conversation.partnerPubkey)}
|
||||||
|
navIndex={index}
|
||||||
|
onClick={() => {
|
||||||
|
// If already viewing a different conversation, pop first to replace
|
||||||
|
if (currentConversation && currentConversation !== conversation.partnerPubkey) {
|
||||||
|
pop()
|
||||||
|
}
|
||||||
|
push(toDMConversation(conversation.partnerPubkey))
|
||||||
|
}}
|
||||||
|
onClose={() => {
|
||||||
|
selectConversation(null)
|
||||||
|
pop()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{/* Sentinel element for infinite scroll */}
|
||||||
|
{hasMoreConversations && (
|
||||||
|
<div ref={loadMoreRef} className="flex justify-center py-4">
|
||||||
|
<Loader2 className="size-5 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
313
src/components/Inbox/ConversationSettingsModal.tsx
Normal file
@@ -0,0 +1,313 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Checkbox } from '@/components/ui/checkbox'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
|
import { TRelayList } from '@/types'
|
||||||
|
import { Check, Loader2, Lock, LockOpen, User, Users, Zap } from 'lucide-react'
|
||||||
|
import { useEffect, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
type EncryptionPreference = 'auto' | 'nip04' | 'nip17'
|
||||||
|
|
||||||
|
interface ConversationSettingsModalProps {
|
||||||
|
partnerPubkey: string | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
selectedRelays: string[]
|
||||||
|
onSelectedRelaysChange: (relays: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
type RelayInfo = {
|
||||||
|
url: string
|
||||||
|
isYours: boolean
|
||||||
|
isTheirs: boolean
|
||||||
|
isShared: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ConversationSettingsModal({
|
||||||
|
partnerPubkey,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
selectedRelays,
|
||||||
|
onSelectedRelaysChange
|
||||||
|
}: ConversationSettingsModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { pubkey, relayList: myRelayList, hasNip44Support } = useNostr()
|
||||||
|
const [partnerRelayList, setPartnerRelayList] = useState<TRelayList | null>(null)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
const [relays, setRelays] = useState<RelayInfo[]>([])
|
||||||
|
const [encryptionPreference, setEncryptionPreference] = useState<EncryptionPreference>('auto')
|
||||||
|
|
||||||
|
// Fetch partner's relay list when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !partnerPubkey) return
|
||||||
|
|
||||||
|
const fetchPartnerRelays = async () => {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const relayList = await client.fetchRelayList(partnerPubkey)
|
||||||
|
setPartnerRelayList(relayList)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch partner relay list:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchPartnerRelays()
|
||||||
|
}, [open, partnerPubkey])
|
||||||
|
|
||||||
|
// Load encryption preference when modal opens
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open || !partnerPubkey || !pubkey) return
|
||||||
|
|
||||||
|
const loadEncryptionPreference = async () => {
|
||||||
|
const saved = await indexedDb.getConversationEncryptionPreference(pubkey, partnerPubkey)
|
||||||
|
setEncryptionPreference(saved || 'auto')
|
||||||
|
}
|
||||||
|
loadEncryptionPreference()
|
||||||
|
}, [open, partnerPubkey, pubkey])
|
||||||
|
|
||||||
|
// Save encryption preference when it changes
|
||||||
|
const handleEncryptionChange = async (value: EncryptionPreference) => {
|
||||||
|
setEncryptionPreference(value)
|
||||||
|
if (pubkey && partnerPubkey) {
|
||||||
|
await indexedDb.putConversationEncryptionPreference(pubkey, partnerPubkey, value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build relay list when data is available
|
||||||
|
useEffect(() => {
|
||||||
|
if (!myRelayList || !partnerRelayList) return
|
||||||
|
|
||||||
|
const myWriteRelays = new Set(myRelayList.write.map((r) => r.replace(/\/$/, '')))
|
||||||
|
const theirReadRelays = new Set(partnerRelayList.read.map((r) => r.replace(/\/$/, '')))
|
||||||
|
|
||||||
|
// Combine all relays
|
||||||
|
const allRelayUrls = new Set<string>()
|
||||||
|
myRelayList.write.forEach((r) => allRelayUrls.add(r.replace(/\/$/, '')))
|
||||||
|
partnerRelayList.read.forEach((r) => allRelayUrls.add(r.replace(/\/$/, '')))
|
||||||
|
|
||||||
|
const relayInfos: RelayInfo[] = Array.from(allRelayUrls).map((url) => {
|
||||||
|
const normalizedUrl = url.replace(/\/$/, '')
|
||||||
|
const isYours = myWriteRelays.has(normalizedUrl)
|
||||||
|
const isTheirs = theirReadRelays.has(normalizedUrl)
|
||||||
|
return {
|
||||||
|
url,
|
||||||
|
isYours,
|
||||||
|
isTheirs,
|
||||||
|
isShared: isYours && isTheirs
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// Sort: shared first, then yours, then theirs
|
||||||
|
relayInfos.sort((a, b) => {
|
||||||
|
if (a.isShared && !b.isShared) return -1
|
||||||
|
if (!a.isShared && b.isShared) return 1
|
||||||
|
if (a.isYours && !b.isYours) return -1
|
||||||
|
if (!a.isYours && b.isYours) return 1
|
||||||
|
return a.url.localeCompare(b.url)
|
||||||
|
})
|
||||||
|
|
||||||
|
setRelays(relayInfos)
|
||||||
|
|
||||||
|
// If no relays selected yet, default to shared relays
|
||||||
|
if (selectedRelays.length === 0) {
|
||||||
|
const sharedRelays = relayInfos.filter((r) => r.isShared).map((r) => r.url)
|
||||||
|
if (sharedRelays.length > 0) {
|
||||||
|
onSelectedRelaysChange(sharedRelays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [myRelayList, partnerRelayList])
|
||||||
|
|
||||||
|
const toggleRelay = (url: string) => {
|
||||||
|
if (selectedRelays.includes(url)) {
|
||||||
|
onSelectedRelaysChange(selectedRelays.filter((r) => r !== url))
|
||||||
|
} else {
|
||||||
|
onSelectedRelaysChange([...selectedRelays, url])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAllShared = () => {
|
||||||
|
const sharedUrls = relays.filter((r) => r.isShared).map((r) => r.url)
|
||||||
|
onSelectedRelaysChange(sharedUrls)
|
||||||
|
}
|
||||||
|
|
||||||
|
const selectAll = () => {
|
||||||
|
onSelectedRelaysChange(relays.map((r) => r.url))
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRelayUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.hostname + (parsed.pathname !== '/' ? parsed.pathname : '')
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!partnerPubkey || !pubkey) return null
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-md max-h-[80vh] flex flex-col">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Conversation Settings')}</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="flex-1 overflow-hidden flex flex-col gap-4">
|
||||||
|
{/* Encryption Preference */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label className="text-sm font-medium">{t('Encryption')}</Label>
|
||||||
|
<RadioGroup
|
||||||
|
value={encryptionPreference}
|
||||||
|
onValueChange={(value) => handleEncryptionChange(value as EncryptionPreference)}
|
||||||
|
className="grid grid-cols-3 gap-2"
|
||||||
|
>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="auto" id="enc-auto" />
|
||||||
|
<Label
|
||||||
|
htmlFor="enc-auto"
|
||||||
|
className="flex items-center gap-1 text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<Zap className="size-3" />
|
||||||
|
{t('Auto')}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem value="nip04" id="enc-nip04" />
|
||||||
|
<Label
|
||||||
|
htmlFor="enc-nip04"
|
||||||
|
className="flex items-center gap-1 text-xs cursor-pointer"
|
||||||
|
>
|
||||||
|
<LockOpen className="size-3" />
|
||||||
|
NIP-04
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center space-x-2">
|
||||||
|
<RadioGroupItem
|
||||||
|
value="nip17"
|
||||||
|
id="enc-nip17"
|
||||||
|
disabled={!hasNip44Support}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor="enc-nip17"
|
||||||
|
className={`flex items-center gap-1 text-xs cursor-pointer ${!hasNip44Support ? 'opacity-50' : ''}`}
|
||||||
|
>
|
||||||
|
<Lock className="size-3" />
|
||||||
|
NIP-17
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
</RadioGroup>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{encryptionPreference === 'auto'
|
||||||
|
? t('Matches existing conversation encryption, or sends both on first message')
|
||||||
|
: encryptionPreference === 'nip04'
|
||||||
|
? t('Classic encryption (NIP-04) - compatible with all clients')
|
||||||
|
: t('Modern encryption (NIP-17) - more private with metadata protection')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="border-t pt-4">
|
||||||
|
<Label className="text-sm font-medium">{t('Relays')}</Label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Legend */}
|
||||||
|
<div className="flex flex-wrap gap-3 text-xs text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<User className="size-3" />
|
||||||
|
<span>{t('You')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Users className="size-3" />
|
||||||
|
<span>{t('Them')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<div className="size-3 rounded bg-green-500/20 border border-green-500/50" />
|
||||||
|
<span>{t('Shared')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Check className="size-3" />
|
||||||
|
<span>{t('Selected for sending')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Quick actions */}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button variant="outline" size="sm" onClick={selectAllShared}>
|
||||||
|
{t('Select shared')}
|
||||||
|
</Button>
|
||||||
|
<Button variant="outline" size="sm" onClick={selectAll}>
|
||||||
|
{t('Select all')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Relay list */}
|
||||||
|
<div className="flex-1 overflow-y-auto space-y-1 min-h-0">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : relays.length === 0 ? (
|
||||||
|
<p className="text-sm text-muted-foreground text-center py-4">
|
||||||
|
{t('No relay information available')}
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
relays.map((relay) => (
|
||||||
|
<div
|
||||||
|
key={relay.url}
|
||||||
|
className={`flex items-center gap-2 p-2 rounded-lg cursor-pointer hover:bg-accent/50 transition-colors ${
|
||||||
|
relay.isShared ? 'bg-green-500/10 border border-green-500/30' : 'bg-muted/50'
|
||||||
|
}`}
|
||||||
|
onClick={() => toggleRelay(relay.url)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={selectedRelays.includes(relay.url)}
|
||||||
|
onCheckedChange={() => toggleRelay(relay.url)}
|
||||||
|
/>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<span className="text-sm font-mono truncate block" title={relay.url}>
|
||||||
|
{formatRelayUrl(relay.url)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1 flex-shrink-0">
|
||||||
|
{relay.isYours && (
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded bg-blue-500/20 text-blue-600 dark:text-blue-400"
|
||||||
|
title={t('Your write relay')}
|
||||||
|
>
|
||||||
|
<User className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{relay.isTheirs && (
|
||||||
|
<span
|
||||||
|
className="text-xs px-1.5 py-0.5 rounded bg-purple-500/20 text-purple-600 dark:text-purple-400"
|
||||||
|
title={t('Their read relay')}
|
||||||
|
>
|
||||||
|
<Users className="size-3" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Info text */}
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('Selected relays will be used when sending new messages in this conversation.')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
40
src/components/Inbox/InboxContent.tsx
Normal file
@@ -0,0 +1,40 @@
|
|||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { Loader2, RefreshCw } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import ConversationList from './ConversationList'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
|
||||||
|
export default function InboxContent() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { isLoading, error, refreshConversations } = useDM()
|
||||||
|
|
||||||
|
if (isLoading) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center h-64">
|
||||||
|
<div className="flex flex-col items-center gap-2 text-muted-foreground">
|
||||||
|
<Loader2 className="size-8 animate-spin" />
|
||||||
|
<span className="text-sm">{t('Loading messages...')}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col items-center justify-center h-64 gap-4 text-muted-foreground">
|
||||||
|
<p>{error}</p>
|
||||||
|
<Button onClick={refreshConversations} variant="outline" size="sm" className="gap-2">
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
{t('Retry')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Conversations list - clicking opens in secondary panel (or overlay on mobile)
|
||||||
|
return (
|
||||||
|
<div className="h-[calc(100vh-8rem)]">
|
||||||
|
<ConversationList />
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
148
src/components/Inbox/MessageComposer.tsx
Normal file
@@ -0,0 +1,148 @@
|
|||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { AlertCircle, ChevronDown, ChevronUp, Loader2, Send } from 'lucide-react'
|
||||||
|
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import { Textarea } from '../ui/textarea'
|
||||||
|
|
||||||
|
export default function MessageComposer() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { sendMessage, currentConversation } = useDM()
|
||||||
|
const { relayList } = useNostr()
|
||||||
|
const [message, setMessage] = useState('')
|
||||||
|
const [isSending, setIsSending] = useState(false)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
const [showRelays, setShowRelays] = useState(false)
|
||||||
|
const [selectedRelays, setSelectedRelays] = useState<Set<string>>(new Set())
|
||||||
|
const textareaRef = useRef<HTMLTextAreaElement>(null)
|
||||||
|
|
||||||
|
// Get user's write relays
|
||||||
|
const writeRelays = useMemo(() => relayList?.write || [], [relayList])
|
||||||
|
|
||||||
|
// Initialize selected relays when write relays change
|
||||||
|
useEffect(() => {
|
||||||
|
if (writeRelays.length > 0 && selectedRelays.size === 0) {
|
||||||
|
setSelectedRelays(new Set(writeRelays))
|
||||||
|
}
|
||||||
|
}, [writeRelays])
|
||||||
|
|
||||||
|
const toggleRelay = (url: string) => {
|
||||||
|
setSelectedRelays((prev) => {
|
||||||
|
const next = new Set(prev)
|
||||||
|
if (next.has(url)) {
|
||||||
|
// Don't allow deselecting all relays
|
||||||
|
if (next.size > 1) {
|
||||||
|
next.delete(url)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
next.add(url)
|
||||||
|
}
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleSend = async () => {
|
||||||
|
if (!message.trim() || !currentConversation || isSending) return
|
||||||
|
|
||||||
|
setIsSending(true)
|
||||||
|
setError(null)
|
||||||
|
try {
|
||||||
|
const relaysToUse = Array.from(selectedRelays)
|
||||||
|
await sendMessage(message.trim(), relaysToUse.length > 0 ? relaysToUse : undefined)
|
||||||
|
setMessage('')
|
||||||
|
// Return focus to input after sending
|
||||||
|
textareaRef.current?.focus()
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to send message:', err)
|
||||||
|
setError(err instanceof Error ? err.message : t('Failed to send message'))
|
||||||
|
} finally {
|
||||||
|
setIsSending(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
handleSend()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format relay URL for display
|
||||||
|
const formatRelayUrl = (url: string) => {
|
||||||
|
return url.replace(/^wss?:\/\//, '').replace(/\/$/, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-3 space-y-2">
|
||||||
|
{error && (
|
||||||
|
<div className="flex items-center gap-2 text-destructive text-xs">
|
||||||
|
<AlertCircle className="size-3 flex-shrink-0" />
|
||||||
|
<span>{error}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Relay selector */}
|
||||||
|
{writeRelays.length > 0 && (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<button
|
||||||
|
onClick={() => setShowRelays(!showRelays)}
|
||||||
|
className="flex items-center gap-1 text-xs text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
>
|
||||||
|
{showRelays ? <ChevronUp className="size-3" /> : <ChevronDown className="size-3" />}
|
||||||
|
<span>
|
||||||
|
{t('Relays')} ({selectedRelays.size}/{writeRelays.length})
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
{showRelays && (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{writeRelays.map((url) => (
|
||||||
|
<button
|
||||||
|
key={url}
|
||||||
|
onClick={() => toggleRelay(url)}
|
||||||
|
className={cn(
|
||||||
|
'text-xs px-2 py-0.5 rounded-full border transition-colors',
|
||||||
|
selectedRelays.has(url)
|
||||||
|
? 'bg-primary text-primary-foreground border-primary'
|
||||||
|
: 'bg-muted text-muted-foreground border-muted hover:border-primary/50'
|
||||||
|
)}
|
||||||
|
title={url}
|
||||||
|
>
|
||||||
|
{formatRelayUrl(url)}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex items-end gap-2">
|
||||||
|
<Textarea
|
||||||
|
ref={textareaRef}
|
||||||
|
value={message}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMessage(e.target.value)
|
||||||
|
if (error) setError(null)
|
||||||
|
}}
|
||||||
|
onKeyDown={handleKeyDown}
|
||||||
|
placeholder={t('Type a message...')}
|
||||||
|
className="min-h-[40px] max-h-32 resize-none"
|
||||||
|
disabled={isSending || !currentConversation}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
onClick={handleSend}
|
||||||
|
disabled={!message.trim() || isSending || !currentConversation}
|
||||||
|
size="icon"
|
||||||
|
className="flex-shrink-0"
|
||||||
|
>
|
||||||
|
{isSending ? (
|
||||||
|
<Loader2 className="size-4 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<Send className="size-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
120
src/components/Inbox/MessageContent.tsx
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import {
|
||||||
|
EmbeddedEventParser,
|
||||||
|
EmbeddedMentionParser,
|
||||||
|
EmbeddedUrlParser,
|
||||||
|
parseContent
|
||||||
|
} from '@/lib/content-parser'
|
||||||
|
import { toNote, toProfile } from '@/lib/link'
|
||||||
|
import { truncateUrl } from '@/lib/url'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
|
||||||
|
interface MessageContentProps {
|
||||||
|
content: string
|
||||||
|
className?: string
|
||||||
|
/** If true, links will be styled for dark background (primary-foreground color) */
|
||||||
|
isOwnMessage?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders DM message content with linkified URLs and nostr entities.
|
||||||
|
* - URLs open in new tab
|
||||||
|
* - nostr:npub/nprofile opens user profile in secondary pane
|
||||||
|
* - nostr:note1/nevent opens note in secondary pane
|
||||||
|
*/
|
||||||
|
export default function MessageContent({ content, className, isOwnMessage }: MessageContentProps) {
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
|
||||||
|
const nodes = useMemo(() => {
|
||||||
|
return parseContent(content, [EmbeddedEventParser, EmbeddedMentionParser, EmbeddedUrlParser])
|
||||||
|
}, [content])
|
||||||
|
|
||||||
|
const linkClass = cn(
|
||||||
|
'underline cursor-pointer hover:opacity-80',
|
||||||
|
isOwnMessage ? 'text-primary-foreground' : 'text-primary'
|
||||||
|
)
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={cn('whitespace-pre-wrap break-words', className)}>
|
||||||
|
{nodes.map((node, index) => {
|
||||||
|
if (node.type === 'text') {
|
||||||
|
return node.data
|
||||||
|
}
|
||||||
|
|
||||||
|
// URLs - open in new tab
|
||||||
|
if (node.type === 'url' || node.type === 'image' || node.type === 'media') {
|
||||||
|
const url = node.data as string
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={linkClass}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{truncateUrl(url)}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// YouTube and X posts - open in new tab
|
||||||
|
if (node.type === 'youtube' || node.type === 'x-post') {
|
||||||
|
const url = node.data as string
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
key={index}
|
||||||
|
href={url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className={linkClass}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
|
{truncateUrl(url)}
|
||||||
|
</a>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nostr: mention (npub/nprofile) - open profile in secondary pane
|
||||||
|
if (node.type === 'mention') {
|
||||||
|
const bech32 = (node.data as string).replace('nostr:', '')
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className={linkClass}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
push(toProfile(bech32))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
@{bech32.slice(0, 12)}...
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// nostr: event (note1/nevent/naddr) - open note in secondary pane
|
||||||
|
if (node.type === 'event') {
|
||||||
|
const bech32 = (node.data as string).replace('nostr:', '')
|
||||||
|
// Determine display based on prefix
|
||||||
|
const isNote = bech32.startsWith('note1')
|
||||||
|
const prefix = isNote ? 'note' : bech32.startsWith('nevent') ? 'nevent' : 'naddr'
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={index}
|
||||||
|
className={linkClass}
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
push(toNote(bech32))
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{prefix}:{bech32.slice(prefix.length, prefix.length + 8)}...
|
||||||
|
</button>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
})}
|
||||||
|
</span>
|
||||||
|
)
|
||||||
|
}
|
||||||
133
src/components/Inbox/MessageInfoModal.tsx
Normal file
@@ -0,0 +1,133 @@
|
|||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import dmService from '@/services/dm.service'
|
||||||
|
import { TDirectMessage } from '@/types'
|
||||||
|
import { Loader2, RefreshCw, Server } from 'lucide-react'
|
||||||
|
import { useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
interface MessageInfoModalProps {
|
||||||
|
message: TDirectMessage | null
|
||||||
|
open: boolean
|
||||||
|
onOpenChange: (open: boolean) => void
|
||||||
|
onRelaysUpdated?: (relays: string[]) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageInfoModal({
|
||||||
|
message,
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
onRelaysUpdated
|
||||||
|
}: MessageInfoModalProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const [isChecking, setIsChecking] = useState(false)
|
||||||
|
const [additionalRelays, setAdditionalRelays] = useState<string[]>([])
|
||||||
|
|
||||||
|
if (!message) return null
|
||||||
|
|
||||||
|
const allRelays = [...(message.seenOnRelays || []), ...additionalRelays]
|
||||||
|
const uniqueRelays = [...new Set(allRelays)]
|
||||||
|
|
||||||
|
const handleCheckOtherRelays = async () => {
|
||||||
|
setIsChecking(true)
|
||||||
|
try {
|
||||||
|
const foundRelays = await dmService.checkOtherRelaysForEvent(
|
||||||
|
message.id,
|
||||||
|
uniqueRelays
|
||||||
|
)
|
||||||
|
if (foundRelays.length > 0) {
|
||||||
|
const newRelays = [...additionalRelays, ...foundRelays]
|
||||||
|
setAdditionalRelays(newRelays)
|
||||||
|
onRelaysUpdated?.([...(message.seenOnRelays || []), ...newRelays])
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to check other relays:', error)
|
||||||
|
} finally {
|
||||||
|
setIsChecking(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatRelayUrl = (url: string) => {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(url)
|
||||||
|
return parsed.hostname + (parsed.pathname !== '/' ? parsed.pathname : '')
|
||||||
|
} catch {
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent className="max-w-sm">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle className="flex items-center gap-2">
|
||||||
|
<Server className="size-4" />
|
||||||
|
{t('Message Info')}
|
||||||
|
</DialogTitle>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Protocol */}
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t('Encryption')}
|
||||||
|
</span>
|
||||||
|
<p className="text-sm mt-1">
|
||||||
|
{message.encryptionType === 'nip17' ? 'NIP-44 (Gift Wrap)' : 'NIP-04 (Legacy)'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Relays */}
|
||||||
|
<div>
|
||||||
|
<span className="text-sm font-medium text-muted-foreground">
|
||||||
|
{t('Seen on relays')}
|
||||||
|
</span>
|
||||||
|
{uniqueRelays.length > 0 ? (
|
||||||
|
<ul className="mt-1 space-y-1">
|
||||||
|
{uniqueRelays.map((relay) => (
|
||||||
|
<li
|
||||||
|
key={relay}
|
||||||
|
className="text-sm font-mono bg-muted px-2 py-1 rounded truncate"
|
||||||
|
title={relay}
|
||||||
|
>
|
||||||
|
{formatRelayUrl(relay)}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground mt-1">
|
||||||
|
{t('No relay information available')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Check other relays button */}
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
className="w-full"
|
||||||
|
onClick={handleCheckOtherRelays}
|
||||||
|
disabled={isChecking}
|
||||||
|
>
|
||||||
|
{isChecking ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="size-4 mr-2 animate-spin" />
|
||||||
|
{t('Checking...')}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<RefreshCw className="size-4 mr-2" />
|
||||||
|
{t('Check for other relays')}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
)
|
||||||
|
}
|
||||||
461
src/components/Inbox/MessageView.tsx
Normal file
@@ -0,0 +1,461 @@
|
|||||||
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
|
import { formatTimestamp } from '@/lib/timestamp'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
|
import { TDirectMessage, TProfile } from '@/types'
|
||||||
|
import { ChevronDown, ChevronUp, Loader2, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
|
||||||
|
import { useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { Button } from '../ui/button'
|
||||||
|
import { ScrollArea } from '../ui/scroll-area'
|
||||||
|
import { Checkbox } from '../ui/checkbox'
|
||||||
|
import {
|
||||||
|
DropdownMenu,
|
||||||
|
DropdownMenuContent,
|
||||||
|
DropdownMenuItem,
|
||||||
|
DropdownMenuTrigger
|
||||||
|
} from '../ui/dropdown-menu'
|
||||||
|
import MessageComposer from './MessageComposer'
|
||||||
|
import MessageContent from './MessageContent'
|
||||||
|
import MessageInfoModal from './MessageInfoModal'
|
||||||
|
import ConversationSettingsModal from './ConversationSettingsModal'
|
||||||
|
import { useFollowList } from '@/providers/FollowListProvider'
|
||||||
|
|
||||||
|
interface MessageViewProps {
|
||||||
|
onBack?: () => void
|
||||||
|
hideHeader?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MessageView({ onBack, hideHeader }: MessageViewProps) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const {
|
||||||
|
currentConversation,
|
||||||
|
messages,
|
||||||
|
isLoadingConversation,
|
||||||
|
isNewConversation,
|
||||||
|
clearNewConversationFlag,
|
||||||
|
reloadConversation,
|
||||||
|
// Selection mode
|
||||||
|
selectedMessages,
|
||||||
|
isSelectionMode,
|
||||||
|
toggleMessageSelection,
|
||||||
|
clearSelection,
|
||||||
|
deleteSelectedMessages,
|
||||||
|
deleteAllInConversation,
|
||||||
|
undeleteAllInConversation
|
||||||
|
} = useDM()
|
||||||
|
const { followingSet } = useFollowList()
|
||||||
|
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null)
|
||||||
|
const [selectedMessage, setSelectedMessage] = useState<TDirectMessage | null>(null)
|
||||||
|
const [messageInfoOpen, setMessageInfoOpen] = useState(false)
|
||||||
|
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||||
|
const [selectedRelays, setSelectedRelays] = useState<string[]>([])
|
||||||
|
const [showPulse, setShowPulse] = useState(false)
|
||||||
|
const [showJumpButton, setShowJumpButton] = useState(false)
|
||||||
|
const [newMessageCount, setNewMessageCount] = useState(0)
|
||||||
|
const lastMessageCountRef = useRef(0)
|
||||||
|
const isAtBottomRef = useRef(true)
|
||||||
|
// Progressive loading: start with 20 messages, load more on demand
|
||||||
|
const [visibleLimit, setVisibleLimit] = useState(20)
|
||||||
|
const LOAD_MORE_INCREMENT = 20
|
||||||
|
|
||||||
|
const isFollowing = currentConversation ? followingSet.has(currentConversation) : false
|
||||||
|
|
||||||
|
// Calculate visible messages (show most recent, allow loading older)
|
||||||
|
const hasMoreMessages = messages.length > visibleLimit
|
||||||
|
const visibleMessages = hasMoreMessages
|
||||||
|
? messages.slice(-visibleLimit) // Show last N messages (most recent)
|
||||||
|
: messages
|
||||||
|
|
||||||
|
// Load more older messages
|
||||||
|
const loadMoreMessages = () => {
|
||||||
|
setVisibleLimit((prev) => prev + LOAD_MORE_INCREMENT)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset visible limit when conversation changes
|
||||||
|
useEffect(() => {
|
||||||
|
setVisibleLimit(20)
|
||||||
|
}, [currentConversation])
|
||||||
|
|
||||||
|
// Handle pulsing animation for new conversations
|
||||||
|
useEffect(() => {
|
||||||
|
if (isNewConversation) {
|
||||||
|
setShowPulse(true)
|
||||||
|
const timer = setTimeout(() => {
|
||||||
|
setShowPulse(false)
|
||||||
|
clearNewConversationFlag()
|
||||||
|
}, 10000)
|
||||||
|
return () => clearTimeout(timer)
|
||||||
|
}
|
||||||
|
}, [isNewConversation, clearNewConversationFlag])
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentConversation) return
|
||||||
|
|
||||||
|
const fetchProfileData = async () => {
|
||||||
|
try {
|
||||||
|
const profileData = await client.fetchProfile(currentConversation)
|
||||||
|
if (profileData) {
|
||||||
|
setProfile(profileData)
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to fetch profile:', error)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
fetchProfileData()
|
||||||
|
}, [currentConversation])
|
||||||
|
|
||||||
|
// Load saved relay settings when conversation changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (!currentConversation || !pubkey) return
|
||||||
|
|
||||||
|
const loadRelaySettings = async () => {
|
||||||
|
const saved = await indexedDb.getConversationRelaySettings(pubkey, currentConversation)
|
||||||
|
setSelectedRelays(saved || [])
|
||||||
|
}
|
||||||
|
loadRelaySettings()
|
||||||
|
}, [currentConversation, pubkey])
|
||||||
|
|
||||||
|
// Save relay settings when they change
|
||||||
|
const handleRelaysChange = async (relays: string[]) => {
|
||||||
|
setSelectedRelays(relays)
|
||||||
|
if (pubkey && currentConversation) {
|
||||||
|
await indexedDb.putConversationRelaySettings(pubkey, currentConversation, relays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle scroll position tracking
|
||||||
|
const handleScroll = () => {
|
||||||
|
if (!scrollRef.current) return
|
||||||
|
const { scrollTop, scrollHeight, clientHeight } = scrollRef.current
|
||||||
|
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
|
||||||
|
const atBottom = distanceFromBottom < 100 // 100px threshold
|
||||||
|
|
||||||
|
isAtBottomRef.current = atBottom
|
||||||
|
setShowJumpButton(!atBottom)
|
||||||
|
|
||||||
|
// Reset new message count when user scrolls to bottom
|
||||||
|
if (atBottom) {
|
||||||
|
setNewMessageCount(0)
|
||||||
|
lastMessageCountRef.current = messages.length
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Track new messages when scrolled up
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isAtBottomRef.current && messages.length > lastMessageCountRef.current) {
|
||||||
|
setNewMessageCount(messages.length - lastMessageCountRef.current)
|
||||||
|
} else if (isAtBottomRef.current) {
|
||||||
|
lastMessageCountRef.current = messages.length
|
||||||
|
}
|
||||||
|
}, [messages.length])
|
||||||
|
|
||||||
|
// Scroll to bottom when messages change (only if already at bottom)
|
||||||
|
useEffect(() => {
|
||||||
|
if (scrollRef.current && isAtBottomRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
|
lastMessageCountRef.current = messages.length
|
||||||
|
}
|
||||||
|
}, [messages])
|
||||||
|
|
||||||
|
// Scroll to bottom function
|
||||||
|
const scrollToBottom = () => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTo({
|
||||||
|
top: scrollRef.current.scrollHeight,
|
||||||
|
behavior: 'smooth'
|
||||||
|
})
|
||||||
|
setNewMessageCount(0)
|
||||||
|
lastMessageCountRef.current = messages.length
|
||||||
|
isAtBottomRef.current = true
|
||||||
|
setShowJumpButton(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reset scroll state when conversation changes
|
||||||
|
useEffect(() => {
|
||||||
|
isAtBottomRef.current = true
|
||||||
|
setShowJumpButton(false)
|
||||||
|
setNewMessageCount(0)
|
||||||
|
lastMessageCountRef.current = 0
|
||||||
|
}, [currentConversation])
|
||||||
|
|
||||||
|
// Scroll to bottom when conversation opens and messages are loaded
|
||||||
|
const hasMessages = messages.length > 0
|
||||||
|
useEffect(() => {
|
||||||
|
if (currentConversation && hasMessages && scrollRef.current) {
|
||||||
|
// Use requestAnimationFrame to ensure DOM is ready
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
if (scrollRef.current) {
|
||||||
|
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||||
|
lastMessageCountRef.current = messages.length
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}, [currentConversation, hasMessages])
|
||||||
|
|
||||||
|
if (!currentConversation || !pubkey) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
const displayName = profile?.username || currentConversation.slice(0, 8) + '...'
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header - show when not hidden, or when in selection mode */}
|
||||||
|
{(!hideHeader || isSelectionMode) && (
|
||||||
|
<div className="flex items-center gap-3 p-3 border-b">
|
||||||
|
{isSelectionMode ? (
|
||||||
|
// Selection mode header
|
||||||
|
<>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={clearSelection}
|
||||||
|
className="size-8"
|
||||||
|
title={t('Cancel')}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Trash2 className="size-4 text-destructive" />
|
||||||
|
<span className="font-medium text-sm">{t('Delete')}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex-1" />
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={deleteSelectedMessages}
|
||||||
|
disabled={selectedMessages.size === 0}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{t('Selected')} ({selectedMessages.size})
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="destructive"
|
||||||
|
size="sm"
|
||||||
|
onClick={deleteAllInConversation}
|
||||||
|
className="text-xs"
|
||||||
|
>
|
||||||
|
{t('All')}
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
// Normal header
|
||||||
|
<>
|
||||||
|
<UserAvatar userId={currentConversation} className="size-8" />
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="font-medium text-sm truncate">{displayName}</span>
|
||||||
|
{isFollowing && (
|
||||||
|
<span title="Following">
|
||||||
|
<Users className="size-3 text-primary" />
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{profile?.nip05 && (
|
||||||
|
<span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
title={t('Reload messages')}
|
||||||
|
onClick={reloadConversation}
|
||||||
|
disabled={isLoadingConversation}
|
||||||
|
>
|
||||||
|
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
|
||||||
|
title={t('Conversation settings')}
|
||||||
|
onClick={() => {
|
||||||
|
setShowPulse(false)
|
||||||
|
clearNewConversationFlag()
|
||||||
|
setSettingsOpen(true)
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Settings className="size-4" />
|
||||||
|
</Button>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button variant="ghost" size="icon" className="size-8">
|
||||||
|
<MoreVertical className="size-4" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent align="end">
|
||||||
|
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
|
||||||
|
<Trash2 className="size-4 mr-2" />
|
||||||
|
{t('Delete All')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={undeleteAllInConversation}>
|
||||||
|
<Undo2 className="size-4 mr-2" />
|
||||||
|
{t('Undelete All')}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{onBack && (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="size-8"
|
||||||
|
title={t('Close conversation')}
|
||||||
|
onClick={onBack}
|
||||||
|
>
|
||||||
|
<X className="size-4" />
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messages */}
|
||||||
|
<div className="flex-1 relative overflow-hidden">
|
||||||
|
<ScrollArea ref={scrollRef} className="h-full p-3" onScrollCapture={handleScroll}>
|
||||||
|
{isLoadingConversation && messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full">
|
||||||
|
<Loader2 className="size-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : messages.length === 0 ? (
|
||||||
|
<div className="flex items-center justify-center h-full text-muted-foreground">
|
||||||
|
<p className="text-sm">{t('No messages yet. Send one to start the conversation!')}</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{/* Load more button at top */}
|
||||||
|
{hasMoreMessages && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={loadMoreMessages}
|
||||||
|
className="text-xs text-muted-foreground"
|
||||||
|
>
|
||||||
|
<ChevronUp className="size-4 mr-1" />
|
||||||
|
{t('Load older messages')} ({messages.length - visibleLimit} more)
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isLoadingConversation && (
|
||||||
|
<div className="flex justify-center py-2">
|
||||||
|
<Loader2 className="size-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{visibleMessages.map((message) => {
|
||||||
|
const isOwn = message.senderPubkey === pubkey
|
||||||
|
const isSelected = selectedMessages.has(message.id)
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={message.id}
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-2 group',
|
||||||
|
isOwn ? 'flex-row-reverse' : 'flex-row'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{/* Checkbox - shows on hover or when in selection mode */}
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex-shrink-0 transition-opacity',
|
||||||
|
isSelectionMode || isSelected
|
||||||
|
? 'opacity-100'
|
||||||
|
: 'opacity-0 group-hover:opacity-100'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={() => toggleMessageSelection(message.id)}
|
||||||
|
className="mt-2"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'max-w-[80%] rounded-lg px-3 py-2',
|
||||||
|
isOwn
|
||||||
|
? 'bg-primary text-primary-foreground'
|
||||||
|
: 'bg-muted',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-2'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<MessageContent
|
||||||
|
content={message.content}
|
||||||
|
className="text-sm"
|
||||||
|
isOwnMessage={isOwn}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between gap-2 mt-1 text-xs',
|
||||||
|
isOwn ? 'text-primary-foreground/70' : 'text-muted-foreground'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span>{formatTimestamp(message.createdAt)}</span>
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setSelectedMessage(message)
|
||||||
|
setMessageInfoOpen(true)
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'font-mono opacity-60 hover:opacity-100 transition-opacity',
|
||||||
|
isOwn ? 'hover:text-primary-foreground' : 'hover:text-foreground'
|
||||||
|
)}
|
||||||
|
title={t('Message info')}
|
||||||
|
>
|
||||||
|
{message.encryptionType === 'nip17' ? '44' : '4'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</ScrollArea>
|
||||||
|
|
||||||
|
{/* Jump to newest button */}
|
||||||
|
{showJumpButton && (
|
||||||
|
<Button
|
||||||
|
onClick={scrollToBottom}
|
||||||
|
className="absolute bottom-4 right-4 rounded-full shadow-lg size-10 p-0"
|
||||||
|
size="icon"
|
||||||
|
>
|
||||||
|
<ChevronDown className="size-5" />
|
||||||
|
{newMessageCount > 0 && (
|
||||||
|
<span className="absolute -top-1 -right-1 bg-destructive text-destructive-foreground rounded-full min-w-5 h-5 flex items-center justify-center text-xs font-medium px-1">
|
||||||
|
{newMessageCount > 99 ? '99+' : newMessageCount}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Composer */}
|
||||||
|
<div className="border-t">
|
||||||
|
<MessageComposer />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Message Info Modal */}
|
||||||
|
<MessageInfoModal
|
||||||
|
message={selectedMessage}
|
||||||
|
open={messageInfoOpen}
|
||||||
|
onOpenChange={setMessageInfoOpen}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* Conversation Settings Modal */}
|
||||||
|
<ConversationSettingsModal
|
||||||
|
partnerPubkey={currentConversation}
|
||||||
|
open={settingsOpen}
|
||||||
|
onOpenChange={setSettingsOpen}
|
||||||
|
selectedRelays={selectedRelays}
|
||||||
|
onSelectedRelaysChange={handleRelaysChange}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -3,10 +3,13 @@ import { Checkbox } from '@/components/ui/checkbox'
|
|||||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import SocialGraphFilter from '@/components/SocialGraphFilter'
|
||||||
import { ExtendedKind } from '@/constants'
|
import { ExtendedKind } from '@/constants'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useKindFilter } from '@/providers/KindFilterProvider'
|
import { useKindFilter } from '@/providers/KindFilterProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
|
||||||
import { ListFilter } from 'lucide-react'
|
import { ListFilter } from 'lucide-react'
|
||||||
import { kinds } from 'nostr-tools'
|
import { kinds } from 'nostr-tools'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
@@ -34,22 +37,35 @@ const ALL_KINDS = KIND_FILTER_OPTIONS.flatMap(({ kindGroup }) => kindGroup)
|
|||||||
|
|
||||||
export default function KindFilter({
|
export default function KindFilter({
|
||||||
showKinds,
|
showKinds,
|
||||||
onShowKindsChange
|
onShowKindsChange,
|
||||||
|
showSocialGraphFilter = false
|
||||||
}: {
|
}: {
|
||||||
showKinds: number[]
|
showKinds: number[]
|
||||||
onShowKindsChange: (kinds: number[]) => void
|
onShowKindsChange: (kinds: number[]) => void
|
||||||
|
showSocialGraphFilter?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
const { showKinds: savedShowKinds } = useKindFilter()
|
const { showKinds: savedShowKinds } = useKindFilter()
|
||||||
|
const {
|
||||||
|
proximityLevel: savedProximity,
|
||||||
|
includeMode: savedIncludeMode,
|
||||||
|
updateProximityLevel,
|
||||||
|
updateIncludeMode
|
||||||
|
} = useSocialGraphFilter()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const { updateShowKinds } = useKindFilter()
|
const { updateShowKinds } = useKindFilter()
|
||||||
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
||||||
|
const [temporaryProximity, setTemporaryProximity] = useState<number | null>(savedProximity)
|
||||||
|
const [temporaryIncludeMode, setTemporaryIncludeMode] = useState(savedIncludeMode)
|
||||||
const [isPersistent, setIsPersistent] = useState(false)
|
const [isPersistent, setIsPersistent] = useState(false)
|
||||||
const isDifferentFromSaved = useMemo(
|
|
||||||
() => !isSameKindFilter(showKinds, savedShowKinds),
|
const isDifferentFromSaved = useMemo(() => {
|
||||||
[showKinds, savedShowKinds]
|
const kindsDifferent = !isSameKindFilter(showKinds, savedShowKinds)
|
||||||
)
|
const proximityDifferent = showSocialGraphFilter && savedProximity !== null
|
||||||
|
return kindsDifferent || proximityDifferent
|
||||||
|
}, [showKinds, savedShowKinds, savedProximity, showSocialGraphFilter])
|
||||||
|
|
||||||
const isTemporaryDifferentFromSaved = useMemo(
|
const isTemporaryDifferentFromSaved = useMemo(
|
||||||
() => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
|
() => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
|
||||||
[temporaryShowKinds, savedShowKinds]
|
[temporaryShowKinds, savedShowKinds]
|
||||||
@@ -57,8 +73,10 @@ export default function KindFilter({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTemporaryShowKinds(showKinds)
|
setTemporaryShowKinds(showKinds)
|
||||||
|
setTemporaryProximity(savedProximity)
|
||||||
|
setTemporaryIncludeMode(savedIncludeMode)
|
||||||
setIsPersistent(false)
|
setIsPersistent(false)
|
||||||
}, [open])
|
}, [open, savedProximity, savedIncludeMode])
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
if (temporaryShowKinds.length === 0) {
|
if (temporaryShowKinds.length === 0) {
|
||||||
@@ -71,6 +89,16 @@ export default function KindFilter({
|
|||||||
onShowKindsChange(newShowKinds)
|
onShowKindsChange(newShowKinds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply social graph filter changes
|
||||||
|
if (showSocialGraphFilter) {
|
||||||
|
if (temporaryProximity !== savedProximity) {
|
||||||
|
updateProximityLevel(temporaryProximity)
|
||||||
|
}
|
||||||
|
if (temporaryIncludeMode !== savedIncludeMode) {
|
||||||
|
updateIncludeMode(temporaryIncludeMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isPersistent) {
|
if (isPersistent) {
|
||||||
updateShowKinds(newShowKinds)
|
updateShowKinds(newShowKinds)
|
||||||
}
|
}
|
||||||
@@ -155,6 +183,18 @@ export default function KindFilter({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showSocialGraphFilter && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<SocialGraphFilter
|
||||||
|
temporaryProximity={temporaryProximity}
|
||||||
|
temporaryIncludeMode={temporaryIncludeMode}
|
||||||
|
onTemporaryProximityChange={setTemporaryProximity}
|
||||||
|
onTemporaryIncludeModeChange={setTemporaryIncludeMode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Label className="flex items-center gap-2 cursor-pointer mt-4">
|
<Label className="flex items-center gap-2 cursor-pointer mt-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="persistent-filter"
|
id="persistent-filter"
|
||||||
|
|||||||
847
src/components/NRCSettings/index.tsx
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
/**
|
||||||
|
* NRC Settings Component
|
||||||
|
*
|
||||||
|
* UI for managing Nostr Relay Connect (NRC) connections and listener settings.
|
||||||
|
* Includes both:
|
||||||
|
* - Listener mode: Allow other devices to connect to this one
|
||||||
|
* - Client mode: Connect to and sync from other devices
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNRC } from '@/providers/NRCProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Link2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
QrCode,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
RefreshCw,
|
||||||
|
Smartphone,
|
||||||
|
Download,
|
||||||
|
Camera,
|
||||||
|
Zap,
|
||||||
|
Coins
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { NRCConnection, RemoteConnection } from '@/services/nrc'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import { Html5Qrcode } from 'html5-qrcode'
|
||||||
|
|
||||||
|
export default function NRCSettings() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const {
|
||||||
|
// Listener state
|
||||||
|
isEnabled,
|
||||||
|
isConnected,
|
||||||
|
connections,
|
||||||
|
activeSessions,
|
||||||
|
rendezvousUrl,
|
||||||
|
relaySupportsCat,
|
||||||
|
enable,
|
||||||
|
disable,
|
||||||
|
addConnection,
|
||||||
|
removeConnection,
|
||||||
|
getConnectionURI,
|
||||||
|
setRendezvousUrl,
|
||||||
|
// Client state
|
||||||
|
remoteConnections,
|
||||||
|
isSyncing,
|
||||||
|
syncProgress,
|
||||||
|
addRemoteConnection,
|
||||||
|
removeRemoteConnection,
|
||||||
|
testRemoteConnection,
|
||||||
|
syncFromDevice,
|
||||||
|
syncAllRemotes
|
||||||
|
} = useNRC()
|
||||||
|
|
||||||
|
// Listener state
|
||||||
|
const [newConnectionLabel, setNewConnectionLabel] = useState('')
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||||
|
const [isQRDialogOpen, setIsQRDialogOpen] = useState(false)
|
||||||
|
const [currentQRConnection, setCurrentQRConnection] = useState<NRCConnection | null>(null)
|
||||||
|
const [currentQRUri, setCurrentQRUri] = useState('')
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState('')
|
||||||
|
const [copiedUri, setCopiedUri] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Client state
|
||||||
|
const [connectionUri, setConnectionUri] = useState('')
|
||||||
|
const [newRemoteLabel, setNewRemoteLabel] = useState('')
|
||||||
|
const [isConnectDialogOpen, setIsConnectDialogOpen] = useState(false)
|
||||||
|
const [isScannerOpen, setIsScannerOpen] = useState(false)
|
||||||
|
const [scannerError, setScannerError] = useState('')
|
||||||
|
const scannerRef = useRef<Html5Qrcode | null>(null)
|
||||||
|
const scannerContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Private config sync setting
|
||||||
|
const [nrcOnlyConfigSync, setNrcOnlyConfigSync] = useState(storage.getNrcOnlyConfigSync())
|
||||||
|
|
||||||
|
const handleToggleNrcOnlyConfig = useCallback((checked: boolean) => {
|
||||||
|
storage.setNrcOnlyConfigSync(checked)
|
||||||
|
setNrcOnlyConfigSync(checked)
|
||||||
|
dispatchSettingsChanged()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Generate QR code when URI changes
|
||||||
|
const generateQRCode = useCallback(async (uri: string) => {
|
||||||
|
try {
|
||||||
|
const dataUrl = await QRCode.toDataURL(uri, {
|
||||||
|
width: 256,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: '#000000', light: '#ffffff' }
|
||||||
|
})
|
||||||
|
setQrDataUrl(dataUrl)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate QR code:', error)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleToggleEnabled = useCallback(async () => {
|
||||||
|
if (isEnabled) {
|
||||||
|
disable()
|
||||||
|
} else {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
await enable()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to enable NRC:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isEnabled, enable, disable])
|
||||||
|
|
||||||
|
const handleAddConnection = useCallback(async () => {
|
||||||
|
if (!newConnectionLabel.trim()) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const { uri, connection } = await addConnection(newConnectionLabel.trim())
|
||||||
|
setIsAddDialogOpen(false)
|
||||||
|
setNewConnectionLabel('')
|
||||||
|
|
||||||
|
// Show QR code
|
||||||
|
setCurrentQRConnection(connection)
|
||||||
|
setCurrentQRUri(uri)
|
||||||
|
await generateQRCode(uri)
|
||||||
|
setIsQRDialogOpen(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add connection:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [newConnectionLabel, addConnection])
|
||||||
|
|
||||||
|
const handleShowQR = useCallback(
|
||||||
|
async (connection: NRCConnection) => {
|
||||||
|
try {
|
||||||
|
const uri = getConnectionURI(connection)
|
||||||
|
setCurrentQRConnection(connection)
|
||||||
|
setCurrentQRUri(uri)
|
||||||
|
await generateQRCode(uri)
|
||||||
|
setIsQRDialogOpen(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get connection URI:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getConnectionURI, generateQRCode]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCopyUri = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(currentQRUri)
|
||||||
|
setCopiedUri(true)
|
||||||
|
setTimeout(() => setCopiedUri(false), 2000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy URI:', error)
|
||||||
|
}
|
||||||
|
}, [currentQRUri])
|
||||||
|
|
||||||
|
const handleRemoveConnection = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await removeConnection(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove connection:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeConnection]
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== Client Handlers =====
|
||||||
|
const handleAddRemoteConnection = useCallback(async () => {
|
||||||
|
if (!connectionUri.trim() || !newRemoteLabel.trim()) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
await addRemoteConnection(connectionUri.trim(), newRemoteLabel.trim())
|
||||||
|
setIsConnectDialogOpen(false)
|
||||||
|
setConnectionUri('')
|
||||||
|
setNewRemoteLabel('')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add remote connection:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [connectionUri, newRemoteLabel, addRemoteConnection])
|
||||||
|
|
||||||
|
const handleRemoveRemoteConnection = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await removeRemoteConnection(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove remote connection:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeRemoteConnection]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSyncDevice = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await syncFromDevice(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync from device:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[syncFromDevice]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTestConnection = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await testRemoteConnection(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to test connection:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[testRemoteConnection]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSyncAll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await syncAllRemotes()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync all remotes:', error)
|
||||||
|
}
|
||||||
|
}, [syncAllRemotes])
|
||||||
|
|
||||||
|
const startScanner = useCallback(async () => {
|
||||||
|
if (!scannerContainerRef.current) return
|
||||||
|
|
||||||
|
setScannerError('')
|
||||||
|
try {
|
||||||
|
const scanner = new Html5Qrcode('qr-scanner-container')
|
||||||
|
scannerRef.current = scanner
|
||||||
|
|
||||||
|
await scanner.start(
|
||||||
|
{ facingMode: 'environment' },
|
||||||
|
{
|
||||||
|
fps: 10,
|
||||||
|
qrbox: { width: 250, height: 250 }
|
||||||
|
},
|
||||||
|
(decodedText) => {
|
||||||
|
// Found a QR code
|
||||||
|
if (decodedText.startsWith('nostr+relayconnect://')) {
|
||||||
|
setConnectionUri(decodedText)
|
||||||
|
stopScanner()
|
||||||
|
setIsScannerOpen(false)
|
||||||
|
setIsConnectDialogOpen(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Ignore errors while scanning
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start scanner:', error)
|
||||||
|
setScannerError(error instanceof Error ? error.message : 'Failed to start camera')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopScanner = useCallback(() => {
|
||||||
|
if (scannerRef.current) {
|
||||||
|
scannerRef.current.stop().catch(() => {
|
||||||
|
// Ignore errors when stopping
|
||||||
|
})
|
||||||
|
scannerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleOpenScanner = useCallback(() => {
|
||||||
|
setIsScannerOpen(true)
|
||||||
|
// Start scanner after dialog renders
|
||||||
|
setTimeout(startScanner, 100)
|
||||||
|
}, [startScanner])
|
||||||
|
|
||||||
|
const handleCloseScanner = useCallback(() => {
|
||||||
|
stopScanner()
|
||||||
|
setIsScannerOpen(false)
|
||||||
|
setScannerError('')
|
||||||
|
}, [stopScanner])
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t('Login required to use NRC')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Private Configuration Sync Toggle */}
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="nrc-only-config" className="text-base font-medium">
|
||||||
|
{t('Private Configuration Sync')}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('Only sync configurations between paired devices, not to public relays')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="nrc-only-config"
|
||||||
|
checked={nrcOnlyConfigSync}
|
||||||
|
onCheckedChange={handleToggleNrcOnlyConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="listener" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="listener" className="gap-2">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
{t('Share')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="client" className="gap-2">
|
||||||
|
<Smartphone className="w-4 h-4" />
|
||||||
|
{t('Connect')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ===== LISTENER TAB ===== */}
|
||||||
|
<TabsContent value="listener" className="space-y-6 mt-4">
|
||||||
|
{/* Enable/Disable Toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="nrc-enabled" className="text-base font-medium">
|
||||||
|
{t('Enable Relay Connect')}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('Allow other devices to sync with this client')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="nrc-enabled"
|
||||||
|
checked={isEnabled}
|
||||||
|
onCheckedChange={handleToggleEnabled}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Indicator */}
|
||||||
|
{isEnabled && (
|
||||||
|
<div className="flex items-center gap-4 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isConnected ? (
|
||||||
|
<Wifi className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="w-4 h-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm">
|
||||||
|
{isConnected ? t('Connected') : t('Connecting...')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{activeSessions > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{activeSessions} {t('active session(s)')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rendezvous Relay */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rendezvous-url" className="flex items-center gap-2">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
{t('Rendezvous Relay')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="rendezvous-url"
|
||||||
|
value={rendezvousUrl}
|
||||||
|
onChange={(e) => setRendezvousUrl(e.target.value)}
|
||||||
|
placeholder="wss://relay.example.com"
|
||||||
|
disabled={isEnabled}
|
||||||
|
/>
|
||||||
|
{isEnabled && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('Disable NRC to change the relay')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CAT (Cashu Access Token) Status */}
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Coins className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{t('CAT Authentication')}</span>
|
||||||
|
</div>
|
||||||
|
{relaySupportsCat ? (
|
||||||
|
<span className="px-2 py-1 bg-primary/10 text-primary rounded text-xs">
|
||||||
|
{t('Available')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 bg-muted text-muted-foreground rounded text-xs">
|
||||||
|
{t('Not Available')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connections List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Link2 className="w-4 h-4" />
|
||||||
|
{t('Authorized Devices')}
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsAddDialogOpen(true)}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t('Add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connections.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
|
||||||
|
{t('No devices connected yet')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{connections.map((connection) => (
|
||||||
|
<div
|
||||||
|
key={connection.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{connection.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{new Date(connection.createdAt).toLocaleDateString()}
|
||||||
|
{connection.useCat && (
|
||||||
|
<span className="ml-2 px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px]">
|
||||||
|
CAT
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleShowQR(connection)}
|
||||||
|
title={t('Show QR Code')}
|
||||||
|
>
|
||||||
|
<QrCode className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
title={t('Remove')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('Remove Device?')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('This will revoke access for "{{label}}". The device will no longer be able to sync.', {
|
||||||
|
label: connection.label
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleRemoveConnection(connection.id)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{t('Remove')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ===== CLIENT TAB ===== */}
|
||||||
|
<TabsContent value="client" className="space-y-6 mt-4">
|
||||||
|
{/* Sync Progress */}
|
||||||
|
{isSyncing && syncProgress && (
|
||||||
|
<div className="p-3 bg-muted/50 rounded-lg space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{syncProgress.phase === 'connecting' && t('Connecting...')}
|
||||||
|
{syncProgress.phase === 'requesting' && t('Requesting events...')}
|
||||||
|
{syncProgress.phase === 'receiving' && t('Receiving events...')}
|
||||||
|
{syncProgress.phase === 'complete' && t('Sync complete')}
|
||||||
|
{syncProgress.phase === 'error' && t('Error')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{syncProgress.eventsReceived > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('{{count}} events received', { count: syncProgress.eventsReceived })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{syncProgress.message && syncProgress.phase === 'error' && (
|
||||||
|
<div className="text-xs text-destructive">{syncProgress.message}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connect to Device */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
{t('Remote Devices')}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleOpenScanner}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Camera className="w-4 h-4" />
|
||||||
|
{t('Scan')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsConnectDialogOpen(true)}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t('Add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{remoteConnections.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
|
||||||
|
{t('No remote devices configured')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Sync All Button */}
|
||||||
|
{remoteConnections.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSyncAll}
|
||||||
|
disabled={isSyncing}
|
||||||
|
className="w-full gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||||
|
{t('Sync All Devices')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{remoteConnections.map((remote: RemoteConnection) => (
|
||||||
|
<div
|
||||||
|
key={remote.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{remote.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{remote.lastSync ? (
|
||||||
|
<>
|
||||||
|
{t('Last sync')}: {new Date(remote.lastSync).toLocaleString()}
|
||||||
|
{remote.eventCount !== undefined && (
|
||||||
|
<span className="ml-2">({remote.eventCount} {t('events')})</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('Never synced')
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Show Test button if never synced, Sync button otherwise */}
|
||||||
|
{!remote.lastSync ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleTestConnection(remote.id)}
|
||||||
|
disabled={isSyncing}
|
||||||
|
title={t('Test Connection')}
|
||||||
|
>
|
||||||
|
<Zap className={`w-4 h-4 ${isSyncing ? 'animate-pulse' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleSyncDevice(remote.id)}
|
||||||
|
disabled={isSyncing}
|
||||||
|
title={t('Sync')}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
title={t('Remove')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('Remove Remote Device?')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('This will remove "{{label}}" from your remote devices list.', {
|
||||||
|
label: remote.label
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleRemoveRemoteConnection(remote.id)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{t('Remove')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* ===== DIALOGS ===== */}
|
||||||
|
|
||||||
|
{/* Add Connection Dialog (Listener) */}
|
||||||
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Add Device')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('Create a connection URI to link another device')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="device-label">{t('Device Name')}</Label>
|
||||||
|
<Input
|
||||||
|
id="device-label"
|
||||||
|
value={newConnectionLabel}
|
||||||
|
onChange={(e) => setNewConnectionLabel(e.target.value)}
|
||||||
|
placeholder={t('e.g., Phone, Laptop')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleAddConnection()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddConnection}
|
||||||
|
disabled={!newConnectionLabel.trim() || isLoading}
|
||||||
|
>
|
||||||
|
{t('Create')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* QR Code Dialog */}
|
||||||
|
<Dialog open={isQRDialogOpen} onOpenChange={setIsQRDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Connection QR Code')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{currentQRConnection && (
|
||||||
|
<>
|
||||||
|
{t('Scan this code with "{{label}}" to connect', {
|
||||||
|
label: currentQRConnection.label
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4">
|
||||||
|
{qrDataUrl && (
|
||||||
|
<div className="p-4 bg-white rounded-lg">
|
||||||
|
<img src={qrDataUrl} alt="Connection QR Code" className="w-64 h-64" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={currentQRUri}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopyUri}
|
||||||
|
title={t('Copy')}
|
||||||
|
>
|
||||||
|
{copiedUri ? (
|
||||||
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setIsQRDialogOpen(false)}>{t('Done')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Connect to Remote Dialog (Client) */}
|
||||||
|
<Dialog open={isConnectDialogOpen} onOpenChange={setIsConnectDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Connect to Device')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('Enter a connection URI from another device to sync with it')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="connection-uri">{t('Connection URI')}</Label>
|
||||||
|
<Input
|
||||||
|
id="connection-uri"
|
||||||
|
value={connectionUri}
|
||||||
|
onChange={(e) => setConnectionUri(e.target.value)}
|
||||||
|
placeholder="nostr+relayconnect://..."
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="remote-label">{t('Device Name')}</Label>
|
||||||
|
<Input
|
||||||
|
id="remote-label"
|
||||||
|
value={newRemoteLabel}
|
||||||
|
onChange={(e) => setNewRemoteLabel(e.target.value)}
|
||||||
|
placeholder={t('e.g., Desktop, Main Phone')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleAddRemoteConnection()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsConnectDialogOpen(false)}>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddRemoteConnection}
|
||||||
|
disabled={!connectionUri.trim() || !newRemoteLabel.trim() || isLoading}
|
||||||
|
>
|
||||||
|
{t('Connect')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* QR Scanner Dialog */}
|
||||||
|
<Dialog open={isScannerOpen} onOpenChange={handleCloseScanner}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Scan QR Code')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('Point your camera at a connection QR code')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<div
|
||||||
|
id="qr-scanner-container"
|
||||||
|
ref={scannerContainerRef}
|
||||||
|
className="w-full aspect-square bg-muted rounded-lg overflow-hidden"
|
||||||
|
/>
|
||||||
|
{scannerError && (
|
||||||
|
<div className="mt-2 text-sm text-destructive">{scannerError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleCloseScanner}>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -57,10 +57,11 @@ export default function NewNotesButton({
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<span className="text-xs opacity-70">⇧↵</span>
|
||||||
|
<ArrowUp />
|
||||||
<div className="text-md font-medium">
|
<div className="text-md font-medium">
|
||||||
{t('Show n new notes', { n: newEvents.length > 99 ? '99+' : newEvents.length })}
|
{t('Show n new notes', { n: newEvents.length > 99 ? '99+' : newEvents.length })}
|
||||||
</div>
|
</div>
|
||||||
<ArrowUp />
|
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ export default function NormalFeed({
|
|||||||
isMainFeed = false,
|
isMainFeed = false,
|
||||||
showRelayCloseReason = false,
|
showRelayCloseReason = false,
|
||||||
disable24hMode = false,
|
disable24hMode = false,
|
||||||
|
enableSocialGraphFilter = false,
|
||||||
onRefresh
|
onRefresh
|
||||||
}: {
|
}: {
|
||||||
subRequests: TFeedSubRequest[]
|
subRequests: TFeedSubRequest[]
|
||||||
@@ -23,6 +24,7 @@ export default function NormalFeed({
|
|||||||
isMainFeed?: boolean
|
isMainFeed?: boolean
|
||||||
showRelayCloseReason?: boolean
|
showRelayCloseReason?: boolean
|
||||||
disable24hMode?: boolean
|
disable24hMode?: boolean
|
||||||
|
enableSocialGraphFilter?: boolean
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { hideUntrustedNotes } = useUserTrust()
|
const { hideUntrustedNotes } = useUserTrust()
|
||||||
@@ -87,6 +89,7 @@ export default function NormalFeed({
|
|||||||
<KindFilter
|
<KindFilter
|
||||||
showKinds={temporaryShowKinds}
|
showKinds={temporaryShowKinds}
|
||||||
onShowKindsChange={handleShowKindsChange}
|
onShowKindsChange={handleShowKindsChange}
|
||||||
|
showSocialGraphFilter={enableSocialGraphFilter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -110,6 +113,7 @@ export default function NormalFeed({
|
|||||||
hideUntrustedNotes={hideUntrustedNotes}
|
hideUntrustedNotes={hideUntrustedNotes}
|
||||||
areAlgoRelays={areAlgoRelays}
|
areAlgoRelays={areAlgoRelays}
|
||||||
showRelayCloseReason={showRelayCloseReason}
|
showRelayCloseReason={showRelayCloseReason}
|
||||||
|
applySocialGraphFilter={enableSocialGraphFilter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { useFetchEvent } from '@/hooks'
|
import { useFetchEvent } from '@/hooks'
|
||||||
import { createFakeEvent } from '@/lib/event'
|
import { createFakeEvent } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { isValidPubkey } from '@/lib/pubkey'
|
|
||||||
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
|
import { generateBech32IdFromATag, generateBech32IdFromETag } from '@/lib/tag'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
@@ -95,7 +95,7 @@ function HighlightSource({ event }: { event: Event }) {
|
|||||||
}
|
}
|
||||||
if (sourceTag && sourceTag[0] === 'a') {
|
if (sourceTag && sourceTag[0] === 'a') {
|
||||||
const [, pubkey] = sourceTag[1].split(':')
|
const [, pubkey] = sourceTag[1].split(':')
|
||||||
if (isValidPubkey(pubkey)) {
|
if (Pubkey.isValidHex(pubkey)) {
|
||||||
return pubkey
|
return pubkey
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,11 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
|
||||||
import { ExtendedKind, NSFW_DISPLAY_POLICY, SUPPORTED_KINDS } from '@/constants'
|
import { ExtendedKind, NSFW_DISPLAY_POLICY, SUPPORTED_KINDS } from '@/constants'
|
||||||
import { getParentStuff, isNsfwEvent } from '@/lib/event'
|
import { getParentStuff, isNsfwEvent } from '@/lib/event'
|
||||||
import { toExternalContent, toNote } from '@/lib/link'
|
import { toExternalContent, toNote } from '@/lib/link'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
@@ -18,6 +20,7 @@ import ParentNotePreview from '../ParentNotePreview'
|
|||||||
import TrustScoreBadge from '../TrustScoreBadge'
|
import TrustScoreBadge from '../TrustScoreBadge'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
import { Mail } from 'lucide-react'
|
||||||
import CommunityDefinition from './CommunityDefinition'
|
import CommunityDefinition from './CommunityDefinition'
|
||||||
import EmojiPack from './EmojiPack'
|
import EmojiPack from './EmojiPack'
|
||||||
import FollowPack from './FollowPack'
|
import FollowPack from './FollowPack'
|
||||||
@@ -50,7 +53,10 @@ export default function Note({
|
|||||||
showFull?: boolean
|
showFull?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
|
const { navigate } = usePrimaryPage()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const { startConversation } = useDM()
|
||||||
const { parentEventId, parentExternalContent } = useMemo(() => {
|
const { parentEventId, parentExternalContent } = useMemo(() => {
|
||||||
return getParentStuff(event)
|
return getParentStuff(event)
|
||||||
}, [event])
|
}, [event])
|
||||||
@@ -58,6 +64,12 @@ export default function Note({
|
|||||||
const [showNsfw, setShowNsfw] = useState(false)
|
const [showNsfw, setShowNsfw] = useState(false)
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const [showMuted, setShowMuted] = useState(false)
|
const [showMuted, setShowMuted] = useState(false)
|
||||||
|
|
||||||
|
const handleStartConversation = (e: React.MouseEvent) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
startConversation(event.pubkey)
|
||||||
|
navigate('inbox')
|
||||||
|
}
|
||||||
const isNsfw = useMemo(
|
const isNsfw = useMemo(
|
||||||
() => (nsfwDisplayPolicy === NSFW_DISPLAY_POLICY.SHOW ? false : isNsfwEvent(event)),
|
() => (nsfwDisplayPolicy === NSFW_DISPLAY_POLICY.SHOW ? false : isNsfwEvent(event)),
|
||||||
[event, nsfwDisplayPolicy]
|
[event, nsfwDisplayPolicy]
|
||||||
@@ -134,6 +146,15 @@ export default function Note({
|
|||||||
<FollowingBadge pubkey={event.pubkey} />
|
<FollowingBadge pubkey={event.pubkey} />
|
||||||
<TrustScoreBadge pubkey={event.pubkey} />
|
<TrustScoreBadge pubkey={event.pubkey} />
|
||||||
<ClientTag event={event} />
|
<ClientTag event={event} />
|
||||||
|
{pubkey && pubkey !== event.pubkey && (
|
||||||
|
<button
|
||||||
|
onClick={handleStartConversation}
|
||||||
|
className="p-1 rounded hover:bg-accent text-muted-foreground hover:text-foreground transition-colors"
|
||||||
|
title="Start conversation"
|
||||||
|
>
|
||||||
|
<Mail className="size-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
<div className="flex items-center gap-1 text-sm text-muted-foreground">
|
||||||
<Nip05 pubkey={event.pubkey} append="·" />
|
<Nip05 pubkey={event.pubkey} append="·" />
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import { Separator } from '@/components/ui/separator'
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { Event } from 'nostr-tools'
|
import { Event } from 'nostr-tools'
|
||||||
import Collapsible from '../Collapsible'
|
import Collapsible from '../Collapsible'
|
||||||
import Note from '../Note'
|
import Note from '../Note'
|
||||||
@@ -15,7 +17,9 @@ export default function MainNoteCard({
|
|||||||
reposters,
|
reposters,
|
||||||
embedded,
|
embedded,
|
||||||
originalNoteId,
|
originalNoteId,
|
||||||
pinned = false
|
pinned = false,
|
||||||
|
navColumn,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
className?: string
|
className?: string
|
||||||
@@ -23,12 +27,18 @@ export default function MainNoteCard({
|
|||||||
embedded?: boolean
|
embedded?: boolean
|
||||||
originalNoteId?: string
|
originalNoteId?: string
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
|
navColumn?: TNavigationColumn
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
|
const { ref, isSelected } = useKeyboardNavigable(navColumn ?? 1, navIndex ?? 0, {
|
||||||
|
meta: { type: 'note', event }
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={className}
|
ref={ref}
|
||||||
|
className={cn(className, 'scroll-mt-[6.5rem]', isSelected && 'ring-2 ring-primary ring-inset')}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation()
|
e.stopPropagation()
|
||||||
push(toNote(originalNoteId ?? event))
|
push(toNote(originalNoteId ?? event))
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import { isMentioningMutedUsers } from '@/lib/event'
|
import { isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
|
import { generateBech32IdFromATag, generateBech32IdFromETag, tagNameEquals } from '@/lib/tag'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
import { Event, kinds, verifyEvent } from 'nostr-tools'
|
||||||
@@ -12,13 +13,17 @@ export default function RepostNoteCard({
|
|||||||
className,
|
className,
|
||||||
filterMutedNotes = true,
|
filterMutedNotes = true,
|
||||||
pinned = false,
|
pinned = false,
|
||||||
reposters
|
reposters,
|
||||||
|
navColumn,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
className?: string
|
className?: string
|
||||||
filterMutedNotes?: boolean
|
filterMutedNotes?: boolean
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
reposters?: string[]
|
reposters?: string[]
|
||||||
|
navColumn?: TNavigationColumn
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
@@ -92,6 +97,8 @@ export default function RepostNoteCard({
|
|||||||
reposters={reposters?.includes(event.pubkey) ? reposters : [event.pubkey]}
|
reposters={reposters?.includes(event.pubkey) ? reposters : [event.pubkey]}
|
||||||
event={targetEvent}
|
event={targetEvent}
|
||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
|
navColumn={navColumn}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { NSFW_DISPLAY_POLICY } from '@/constants'
|
|||||||
import { isMentioningMutedUsers, isNsfwEvent } from '@/lib/event'
|
import { isMentioningMutedUsers, isNsfwEvent } from '@/lib/event'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, kinds } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
@@ -14,13 +15,17 @@ export default function NoteCard({
|
|||||||
className,
|
className,
|
||||||
filterMutedNotes = true,
|
filterMutedNotes = true,
|
||||||
pinned = false,
|
pinned = false,
|
||||||
reposters
|
reposters,
|
||||||
|
navColumn,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
className?: string
|
className?: string
|
||||||
filterMutedNotes?: boolean
|
filterMutedNotes?: boolean
|
||||||
pinned?: boolean
|
pinned?: boolean
|
||||||
reposters?: string[]
|
reposters?: string[]
|
||||||
|
navColumn?: TNavigationColumn
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers, nsfwDisplayPolicy } = useContentPolicy()
|
const { hideContentMentioningMutedUsers, nsfwDisplayPolicy } = useContentPolicy()
|
||||||
@@ -46,10 +51,21 @@ export default function NoteCard({
|
|||||||
filterMutedNotes={filterMutedNotes}
|
filterMutedNotes={filterMutedNotes}
|
||||||
pinned={pinned}
|
pinned={pinned}
|
||||||
reposters={reposters}
|
reposters={reposters}
|
||||||
|
navColumn={navColumn}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
return <MainNoteCard event={event} className={className} pinned={pinned} reposters={reposters} />
|
return (
|
||||||
|
<MainNoteCard
|
||||||
|
event={event}
|
||||||
|
className={className}
|
||||||
|
pinned={pinned}
|
||||||
|
reposters={reposters}
|
||||||
|
navColumn={navColumn}
|
||||||
|
navIndex={navIndex}
|
||||||
|
/>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function NoteCardLoadingSkeleton({ className }: { className?: string }) {
|
export function NoteCardLoadingSkeleton({ className }: { className?: string }) {
|
||||||
|
|||||||
@@ -10,12 +10,12 @@ import RepostList from '../RepostList'
|
|||||||
import ZapList from '../ZapList'
|
import ZapList from '../ZapList'
|
||||||
import { Tabs, TTabValue } from './Tabs'
|
import { Tabs, TTabValue } from './Tabs'
|
||||||
|
|
||||||
export default function NoteInteractions({ event }: { event: Event }) {
|
export default function NoteInteractions({ event, navIndexOffset = 0 }: { event: Event; navIndexOffset?: number }) {
|
||||||
const [type, setType] = useState<TTabValue>('replies')
|
const [type, setType] = useState<TTabValue>('replies')
|
||||||
let list
|
let list
|
||||||
switch (type) {
|
switch (type) {
|
||||||
case 'replies':
|
case 'replies':
|
||||||
list = <ReplyNoteList stuff={event} />
|
list = <ReplyNoteList stuff={event} navIndexOffset={navIndexOffset} />
|
||||||
break
|
break
|
||||||
case 'quotes':
|
case 'quotes':
|
||||||
list = <QuoteList stuff={event} />
|
list = <QuoteList stuff={event} />
|
||||||
|
|||||||
@@ -6,8 +6,10 @@ import { tagNameEquals } from '@/lib/tag'
|
|||||||
import { isTouchDevice } from '@/lib/utils'
|
import { isTouchDevice } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||||
|
import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import threadService from '@/services/thread.service'
|
import threadService from '@/services/thread.service'
|
||||||
@@ -53,6 +55,8 @@ const NoteList = forwardRef<
|
|||||||
pinnedEventIds?: string[]
|
pinnedEventIds?: string[]
|
||||||
filterFn?: (event: Event) => boolean
|
filterFn?: (event: Event) => boolean
|
||||||
showNewNotesDirectly?: boolean
|
showNewNotesDirectly?: boolean
|
||||||
|
navColumn?: TNavigationColumn
|
||||||
|
applySocialGraphFilter?: boolean
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
@@ -67,7 +71,9 @@ const NoteList = forwardRef<
|
|||||||
showRelayCloseReason = false,
|
showRelayCloseReason = false,
|
||||||
pinnedEventIds,
|
pinnedEventIds,
|
||||||
filterFn,
|
filterFn,
|
||||||
showNewNotesDirectly = false
|
showNewNotesDirectly = false,
|
||||||
|
navColumn = 1,
|
||||||
|
applySocialGraphFilter = false
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -77,6 +83,8 @@ const NoteList = forwardRef<
|
|||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
|
const { isPubkeyAllowed } = useSocialGraphFilter()
|
||||||
|
const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
const [initialLoading, setInitialLoading] = useState(false)
|
const [initialLoading, setInitialLoading] = useState(false)
|
||||||
@@ -118,10 +126,22 @@ const NoteList = forwardRef<
|
|||||||
if (filterFn && !filterFn(evt)) {
|
if (filterFn && !filterFn(evt)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// Social graph filter - only apply if enabled for this feed
|
||||||
|
if (applySocialGraphFilter && !isPubkeyAllowed(evt.pubkey)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
[hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn]
|
[
|
||||||
|
hideUntrustedNotes,
|
||||||
|
mutePubkeySet,
|
||||||
|
JSON.stringify(pinnedEventIds),
|
||||||
|
isEventDeleted,
|
||||||
|
filterFn,
|
||||||
|
applySocialGraphFilter,
|
||||||
|
isPubkeyAllowed
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -366,24 +386,47 @@ const NoteList = forwardRef<
|
|||||||
initialLoading
|
initialLoading
|
||||||
})
|
})
|
||||||
|
|
||||||
const showNewEvents = () => {
|
// Register load more callback for keyboard navigation
|
||||||
|
useEffect(() => {
|
||||||
|
registerLoadMore(navColumn, handleLoadMore)
|
||||||
|
return () => unregisterLoadMore(navColumn)
|
||||||
|
}, [navColumn, handleLoadMore, registerLoadMore, unregisterLoadMore])
|
||||||
|
|
||||||
|
const showNewEvents = useCallback(() => {
|
||||||
|
if (filteredNewEvents.length === 0) return
|
||||||
|
// Offset the selection by the number of new items being added at the top
|
||||||
|
offsetSelection(navColumn, filteredNewEvents.length)
|
||||||
setEvents((oldEvents) => [...newEvents, ...oldEvents])
|
setEvents((oldEvents) => [...newEvents, ...oldEvents])
|
||||||
setNewEvents([])
|
setNewEvents([])
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
scrollToTop('smooth')
|
scrollToTop('smooth')
|
||||||
}, 0)
|
}, 0)
|
||||||
|
}, [filteredNewEvents.length, navColumn, newEvents, offsetSelection])
|
||||||
|
|
||||||
|
// Shift+Enter to show new notes
|
||||||
|
useEffect(() => {
|
||||||
|
const handleKeyDown = (e: KeyboardEvent) => {
|
||||||
|
if (e.shiftKey && e.key === 'Enter' && filteredNewEvents.length > 0) {
|
||||||
|
e.preventDefault()
|
||||||
|
showNewEvents()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
window.addEventListener('keydown', handleKeyDown)
|
||||||
|
return () => window.removeEventListener('keydown', handleKeyDown)
|
||||||
|
}, [showNewEvents, filteredNewEvents.length])
|
||||||
|
|
||||||
const list = (
|
const list = (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen">
|
||||||
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
|
{pinnedEventIds?.map((id) => <PinnedNoteCard key={id} eventId={id} className="w-full" />)}
|
||||||
{visibleItems.map(({ key, event, reposters }) => (
|
{visibleItems.map(({ key, event, reposters }, index) => (
|
||||||
<NoteCard
|
<NoteCard
|
||||||
key={key}
|
key={key}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
event={event}
|
event={event}
|
||||||
filterMutedNotes={filterMutedNotes}
|
filterMutedNotes={filterMutedNotes}
|
||||||
reposters={reposters}
|
reposters={reposters}
|
||||||
|
navColumn={navColumn}
|
||||||
|
navIndex={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { getNoteBech32Id, isProtectedEvent } from '@/lib/event'
|
import { getNoteBech32Id, isProtectedEvent } from '@/lib/event'
|
||||||
import { toNjump } from '@/lib/link'
|
import { toNjump } from '@/lib/link'
|
||||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
|
||||||
import { simplifyUrl } from '@/lib/url'
|
import { simplifyUrl } from '@/lib/url'
|
||||||
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
|
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
|
||||||
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
|
||||||
@@ -174,7 +174,7 @@ export function useMenuActions({
|
|||||||
icon: Copy,
|
icon: Copy,
|
||||||
label: t('Copy user ID'),
|
label: t('Copy user ID'),
|
||||||
onClick: () => {
|
onClick: () => {
|
||||||
navigator.clipboard.writeText(pubkeyToNpub(event.pubkey) ?? '')
|
navigator.clipboard.writeText(Pubkey.tryFromString(event.pubkey)?.npub ?? '')
|
||||||
closeDrawer()
|
closeDrawer()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -5,10 +5,12 @@ import Notification from './Notification'
|
|||||||
|
|
||||||
export function HighlightNotification({
|
export function HighlightNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ export function HighlightNotification({
|
|||||||
targetEvent={notification}
|
targetEvent={notification}
|
||||||
description={t('highlighted your note')}
|
description={t('highlighted your note')}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,10 +13,12 @@ import Notification from './Notification'
|
|||||||
|
|
||||||
export function MentionNotification({
|
export function MentionNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
@@ -68,6 +70,7 @@ export function MentionNotification({
|
|||||||
}
|
}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
showStats
|
showStats
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import { Skeleton } from '@/components/ui/skeleton'
|
|||||||
import UserAvatar from '@/components/UserAvatar'
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
import Username from '@/components/Username'
|
import Username from '@/components/Username'
|
||||||
import { NOTIFICATION_LIST_STYLE } from '@/constants'
|
import { NOTIFICATION_LIST_STYLE } from '@/constants'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { toNote, toProfile } from '@/lib/link'
|
import { toNote, toProfile } from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
@@ -24,7 +25,8 @@ export default function Notification({
|
|||||||
middle = null,
|
middle = null,
|
||||||
targetEvent,
|
targetEvent,
|
||||||
isNew = false,
|
isNew = false,
|
||||||
showStats = false
|
showStats = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
icon: React.ReactNode
|
icon: React.ReactNode
|
||||||
notificationId: string
|
notificationId: string
|
||||||
@@ -35,6 +37,7 @@ export default function Notification({
|
|||||||
targetEvent?: NostrEvent
|
targetEvent?: NostrEvent
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
showStats?: boolean
|
showStats?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
@@ -46,6 +49,10 @@ export default function Notification({
|
|||||||
[isNew, isNotificationRead, notificationId]
|
[isNew, isNotificationRead, notificationId]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
const { ref: navRef, isSelected } = useKeyboardNavigable(1, navIndex ?? 0, {
|
||||||
|
meta: { type: 'note' }
|
||||||
|
})
|
||||||
|
|
||||||
const handleClick = () => {
|
const handleClick = () => {
|
||||||
markNotificationAsRead(notificationId)
|
markNotificationAsRead(notificationId)
|
||||||
if (targetEvent) {
|
if (targetEvent) {
|
||||||
@@ -58,7 +65,11 @@ export default function Notification({
|
|||||||
if (notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT) {
|
if (notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="flex items-center justify-between cursor-pointer py-2 px-4"
|
ref={navRef}
|
||||||
|
className={cn(
|
||||||
|
'flex items-center justify-between cursor-pointer py-2 px-4 scroll-mt-[6.5rem]',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-inset'
|
||||||
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2 items-center flex-1 w-0">
|
<div className="flex gap-2 items-center flex-1 w-0">
|
||||||
@@ -84,7 +95,11 @@ export default function Notification({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="clickable flex items-start gap-2 cursor-pointer py-2 px-4 border-b"
|
ref={navRef}
|
||||||
|
className={cn(
|
||||||
|
'clickable flex items-start gap-2 cursor-pointer py-2 px-4 border-b scroll-mt-[6.5rem]',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-inset'
|
||||||
|
)}
|
||||||
onClick={handleClick}
|
onClick={handleClick}
|
||||||
>
|
>
|
||||||
<div className="flex gap-2 items-center mt-1.5">
|
<div className="flex gap-2 items-center mt-1.5">
|
||||||
|
|||||||
@@ -8,10 +8,12 @@ import { useTranslation } from 'react-i18next'
|
|||||||
|
|
||||||
export function PollResponseNotification({
|
export function PollResponseNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const eventId = useMemo(() => {
|
const eventId = useMemo(() => {
|
||||||
@@ -33,6 +35,7 @@ export function PollResponseNotification({
|
|||||||
targetEvent={pollEvent}
|
targetEvent={pollEvent}
|
||||||
description={t('voted in your poll')}
|
description={t('voted in your poll')}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,10 +10,12 @@ import Notification from './Notification'
|
|||||||
|
|
||||||
export function ReactionNotification({
|
export function ReactionNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
@@ -66,6 +68,7 @@ export function ReactionNotification({
|
|||||||
targetEvent={event}
|
targetEvent={event}
|
||||||
description={t('reacted to your note')}
|
description={t('reacted to your note')}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,10 +7,12 @@ import Notification from './Notification'
|
|||||||
|
|
||||||
export function RepostNotification({
|
export function RepostNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const event = useMemo(() => {
|
const event = useMemo(() => {
|
||||||
@@ -35,6 +37,7 @@ export function RepostNotification({
|
|||||||
targetEvent={event}
|
targetEvent={event}
|
||||||
description={t('reposted your note')}
|
description={t('reposted your note')}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -9,10 +9,12 @@ import Notification from './Notification'
|
|||||||
|
|
||||||
export function ZapNotification({
|
export function ZapNotification({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { senderPubkey, eventId, amount, comment } = useMemo(
|
const { senderPubkey, eventId, amount, comment } = useMemo(
|
||||||
@@ -37,6 +39,7 @@ export function ZapNotification({
|
|||||||
}
|
}
|
||||||
description={event ? t('zapped your note') : t('zapped you')}
|
description={event ? t('zapped your note') : t('zapped you')}
|
||||||
isNew={isNew}
|
isNew={isNew}
|
||||||
|
navIndex={navIndex}
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,10 +15,12 @@ import { ZapNotification } from './ZapNotification'
|
|||||||
|
|
||||||
export function NotificationItem({
|
export function NotificationItem({
|
||||||
notification,
|
notification,
|
||||||
isNew = false
|
isNew = false,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
notification: Event
|
notification: Event
|
||||||
isNew?: boolean
|
isNew?: boolean
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { pubkey } = useNostr()
|
const { pubkey } = useNostr()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
@@ -42,7 +44,7 @@ export function NotificationItem({
|
|||||||
if (!canShow) return null
|
if (!canShow) return null
|
||||||
|
|
||||||
if (notification.kind === kinds.Reaction) {
|
if (notification.kind === kinds.Reaction) {
|
||||||
return <ReactionNotification notification={notification} isNew={isNew} />
|
return <ReactionNotification notification={notification} isNew={isNew} navIndex={navIndex} />
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
notification.kind === kinds.ShortTextNote ||
|
notification.kind === kinds.ShortTextNote ||
|
||||||
@@ -50,19 +52,19 @@ export function NotificationItem({
|
|||||||
notification.kind === ExtendedKind.VOICE_COMMENT ||
|
notification.kind === ExtendedKind.VOICE_COMMENT ||
|
||||||
notification.kind === ExtendedKind.POLL
|
notification.kind === ExtendedKind.POLL
|
||||||
) {
|
) {
|
||||||
return <MentionNotification notification={notification} isNew={isNew} />
|
return <MentionNotification notification={notification} isNew={isNew} navIndex={navIndex} />
|
||||||
}
|
}
|
||||||
if (notification.kind === kinds.Repost || notification.kind === kinds.GenericRepost) {
|
if (notification.kind === kinds.Repost || notification.kind === kinds.GenericRepost) {
|
||||||
return <RepostNotification notification={notification} isNew={isNew} />
|
return <RepostNotification notification={notification} isNew={isNew} navIndex={navIndex} />
|
||||||
}
|
}
|
||||||
if (notification.kind === kinds.Zap) {
|
if (notification.kind === kinds.Zap) {
|
||||||
return <ZapNotification notification={notification} isNew={isNew} />
|
return <ZapNotification notification={notification} isNew={isNew} navIndex={navIndex} />
|
||||||
}
|
}
|
||||||
if (notification.kind === ExtendedKind.POLL_RESPONSE) {
|
if (notification.kind === ExtendedKind.POLL_RESPONSE) {
|
||||||
return <PollResponseNotification notification={notification} isNew={isNew} />
|
return <PollResponseNotification notification={notification} isNew={isNew} navIndex={navIndex} />
|
||||||
}
|
}
|
||||||
if (notification.kind === kinds.Highlights) {
|
if (notification.kind === kinds.Highlights) {
|
||||||
return <HighlightNotification notification={notification} isNew={isNew} />
|
return <HighlightNotification notification={notification} isNew={isNew} navIndex={navIndex} />
|
||||||
}
|
}
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -254,11 +254,12 @@ const NotificationList = forwardRef((_, ref) => {
|
|||||||
|
|
||||||
const list = (
|
const list = (
|
||||||
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}>
|
<div className={notificationListStyle === NOTIFICATION_LIST_STYLE.COMPACT ? 'pt-2' : ''}>
|
||||||
{visibleNotifications.map((notification) => (
|
{visibleNotifications.map((notification, index) => (
|
||||||
<NotificationItem
|
<NotificationItem
|
||||||
key={notification.id}
|
key={notification.id}
|
||||||
notification={notification}
|
notification={notification}
|
||||||
isNew={notification.created_at > lastReadTime}
|
isNew={notification.created_at > lastReadTime}
|
||||||
|
navIndex={index}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
<div className="text-center text-sm text-muted-foreground">
|
<div className="text-center text-sm text-muted-foreground">
|
||||||
|
|||||||
@@ -3,22 +3,43 @@ import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
|||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { QrCodeIcon } from 'lucide-react'
|
import { QrCodeIcon } from 'lucide-react'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import Nip05 from '../Nip05'
|
import Nip05 from '../Nip05'
|
||||||
import PubkeyCopy from '../PubkeyCopy'
|
|
||||||
import QrCode from '../QrCode'
|
import QrCode from '../QrCode'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
|
||||||
export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
|
const [open, setOpen] = useState(false)
|
||||||
|
const npub = useMemo(() => {
|
||||||
|
// Validate pubkey is a 64-character hex string before encoding
|
||||||
|
if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return ''
|
||||||
|
try {
|
||||||
|
return nip19.npubEncode(pubkey)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}, [pubkey])
|
||||||
|
|
||||||
|
const handleQrClick = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(npub)
|
||||||
|
toast.success(t('Copied npub to clipboard'))
|
||||||
|
setOpen(false)
|
||||||
|
}, [npub, t])
|
||||||
|
|
||||||
if (!npub) return null
|
if (!npub) return null
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<div className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground">
|
<button
|
||||||
|
className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<QrCodeIcon size={14} />
|
<QrCodeIcon size={14} />
|
||||||
</div>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
@@ -26,29 +47,33 @@ export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
|||||||
<div className="flex items-center w-full gap-2 pointer-events-none px-1">
|
<div className="flex items-center w-full gap-2 pointer-events-none px-1">
|
||||||
<UserAvatar size="big" userId={pubkey} />
|
<UserAvatar size="big" userId={pubkey} />
|
||||||
<div className="flex-1 w-0">
|
<div className="flex-1 w-0">
|
||||||
<Username userId={pubkey} className="text-2xl font-semibold truncate" />
|
<Username userId={pubkey} className="text-2xl font-semibold truncate" showQrCode={false} />
|
||||||
<Nip05 pubkey={pubkey} />
|
<Nip05 pubkey={pubkey} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleQrClick}
|
||||||
|
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
title={t('Click to copy npub')}
|
||||||
|
>
|
||||||
<QrCode size={512} value={`nostr:${npub}`} />
|
<QrCode size={512} value={`nostr:${npub}`} />
|
||||||
<div className="flex flex-col items-center">
|
</button>
|
||||||
<PubkeyCopy pubkey={pubkey} />
|
<div className="text-sm text-muted-foreground">{t('Click QR code to copy npub')}</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
<Drawer>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerTrigger>{trigger}</DrawerTrigger>
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
<DrawerContent>{content}</DrawerContent>
|
<DrawerContent>{content}</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger>{trigger}</DialogTrigger>
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
<DialogContent className="w-80 p-0 m-0" onOpenAutoFocus={(e) => e.preventDefault()}>
|
<DialogContent className="w-80 p-0 m-0" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
{content}
|
{content}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -1,8 +1,8 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { Badge } from '@/components/ui/badge'
|
import { Badge } from '@/components/ui/badge'
|
||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
|
import { useFetchRelayInfo, useFetchRelayList } from '@/hooks'
|
||||||
import { toRelay } from '@/lib/link'
|
import { toRelay } from '@/lib/link'
|
||||||
import { userIdToPubkey } from '@/lib/pubkey'
|
|
||||||
import { TMailboxRelay } from '@/types'
|
import { TMailboxRelay } from '@/types'
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
@@ -10,7 +10,7 @@ import RelaySimpleInfo from '../RelaySimpleInfo'
|
|||||||
|
|
||||||
export default function OthersRelayList({ userId }: { userId: string }) {
|
export default function OthersRelayList({ userId }: { userId: string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const pubkey = useMemo(() => userIdToPubkey(userId), [userId])
|
const pubkey = useMemo(() => Pubkey.tryFromString(userId)?.hex ?? userId, [userId])
|
||||||
const { relayList, isFetching } = useFetchRelayList(pubkey)
|
const { relayList, isFetching } = useFetchRelayList(pubkey)
|
||||||
|
|
||||||
if (isFetching) {
|
if (isFetching) {
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import FollowingBadge from '@/components/FollowingBadge'
|
import FollowingBadge from '@/components/FollowingBadge'
|
||||||
import { ScrollArea } from '@/components/ui/scroll-area'
|
import { ScrollArea } from '@/components/ui/scroll-area'
|
||||||
import { formatNpub, userIdToPubkey } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
|
import { SuggestionKeyDownProps } from '@tiptap/suggestion'
|
||||||
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
import { forwardRef, useEffect, useImperativeHandle, useState } from 'react'
|
||||||
@@ -24,7 +24,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
|
|||||||
const item = props.items[index]
|
const item = props.items[index]
|
||||||
|
|
||||||
if (item) {
|
if (item) {
|
||||||
props.command({ id: item, label: formatNpub(item) })
|
props.command({ id: item, label: Pubkey.tryFromString(item)?.formatNpub(12) ?? item.slice(0, 12) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -92,7 +92,7 @@ const MentionList = forwardRef<MentionListHandle, MentionListProps>((props, ref)
|
|||||||
<SimpleUsername userId={item} className="font-semibold truncate" />
|
<SimpleUsername userId={item} className="font-semibold truncate" />
|
||||||
<FollowingBadge userId={item} />
|
<FollowingBadge userId={item} />
|
||||||
</div>
|
</div>
|
||||||
<Nip05 pubkey={userIdToPubkey(item)} />
|
<Nip05 pubkey={Pubkey.tryFromString(item)?.hex ?? item} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import TextWithEmojis from '@/components/TextWithEmojis'
|
import TextWithEmojis from '@/components/TextWithEmojis'
|
||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { useFetchProfile } from '@/hooks'
|
import { useFetchProfile } from '@/hooks'
|
||||||
import { formatUserId } from '@/lib/pubkey'
|
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
|
import { NodeViewRendererProps, NodeViewWrapper } from '@tiptap/react'
|
||||||
|
|
||||||
@@ -15,7 +15,7 @@ export default function MentionNode(props: NodeViewRendererProps & { selected: b
|
|||||||
{profile ? (
|
{profile ? (
|
||||||
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
|
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
|
||||||
) : (
|
) : (
|
||||||
formatUserId(props.node.attrs.id)
|
Pubkey.tryFromString(props.node.attrs.id)?.formatNpub(12) ?? props.node.attrs.id.slice(0, 12)
|
||||||
)}
|
)}
|
||||||
</NodeViewWrapper>
|
</NodeViewWrapper>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
import { formatNpub } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import TTMention from '@tiptap/extension-mention'
|
import TTMention from '@tiptap/extension-mention'
|
||||||
import { ReactNodeViewRenderer } from '@tiptap/react'
|
import { ReactNodeViewRenderer } from '@tiptap/react'
|
||||||
import MentionNode from './MentionNode'
|
import MentionNode from './MentionNode'
|
||||||
@@ -34,7 +34,7 @@ const Mention = TTMention.extend({
|
|||||||
type: 'mention',
|
type: 'mention',
|
||||||
attrs: {
|
attrs: {
|
||||||
id: npub,
|
id: npub,
|
||||||
label: formatNpub(npub)
|
label: Pubkey.tryFromString(npub)?.formatNpub(12) ?? npub.slice(0, 12)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
import UserAvatar from '@/components/UserAvatar'
|
import UserAvatar from '@/components/UserAvatar'
|
||||||
|
import { BIG_RELAY_URLS } from '@/constants'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
|
import graphQueryService from '@/services/graph-query.service'
|
||||||
import { useEffect, useState } from 'react'
|
import { useEffect, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
@@ -15,6 +17,55 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
|
|||||||
if (!pubkey || !accountPubkey) return
|
if (!pubkey || !accountPubkey) return
|
||||||
|
|
||||||
const init = async () => {
|
const init = async () => {
|
||||||
|
const limit = isSmallScreen ? 3 : 5
|
||||||
|
|
||||||
|
// Try graph query first for depth-2 follows
|
||||||
|
const graphResult = await graphQueryService.queryFollowGraph(
|
||||||
|
BIG_RELAY_URLS,
|
||||||
|
accountPubkey,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
|
||||||
|
if (graphResult?.pubkeys_by_depth && graphResult.pubkeys_by_depth.length >= 2) {
|
||||||
|
// Use graph query results - much more efficient
|
||||||
|
const directFollows = new Set(graphResult.pubkeys_by_depth[0] ?? [])
|
||||||
|
|
||||||
|
// Check which of user's follows also follow the target pubkey
|
||||||
|
const _followedBy: string[] = []
|
||||||
|
|
||||||
|
// We need to check if target pubkey is in each direct follow's follow list
|
||||||
|
// The graph query gives us all follows of follows at depth 2,
|
||||||
|
// but we need to know *which* direct follow has the target in their follows
|
||||||
|
// For now, we'll still need to do individual checks but can optimize with caching
|
||||||
|
|
||||||
|
// Alternative approach: Use followers query on the target
|
||||||
|
const followerResult = await graphQueryService.queryFollowerGraph(
|
||||||
|
BIG_RELAY_URLS,
|
||||||
|
pubkey,
|
||||||
|
1
|
||||||
|
)
|
||||||
|
|
||||||
|
if (followerResult?.pubkeys_by_depth?.[0]) {
|
||||||
|
// Followers of target pubkey
|
||||||
|
const targetFollowers = new Set(followerResult.pubkeys_by_depth[0])
|
||||||
|
|
||||||
|
// Find which of user's follows are followers of the target
|
||||||
|
for (const following of directFollows) {
|
||||||
|
if (following === pubkey) continue
|
||||||
|
if (targetFollowers.has(following)) {
|
||||||
|
_followedBy.push(following)
|
||||||
|
if (_followedBy.length >= limit) break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_followedBy.length > 0) {
|
||||||
|
setFollowedBy(_followedBy)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback to traditional method
|
||||||
const followings = (await client.fetchFollowings(accountPubkey)).reverse()
|
const followings = (await client.fetchFollowings(accountPubkey)).reverse()
|
||||||
const followingsOfFollowings = await Promise.all(
|
const followingsOfFollowings = await Promise.all(
|
||||||
followings.map(async (following) => {
|
followings.map(async (following) => {
|
||||||
@@ -22,7 +73,6 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
const _followedBy: string[] = []
|
const _followedBy: string[] = []
|
||||||
const limit = isSmallScreen ? 3 : 5
|
|
||||||
for (const [index, following] of followings.entries()) {
|
for (const [index, following] of followings.entries()) {
|
||||||
if (following === pubkey) continue
|
if (following === pubkey) continue
|
||||||
if (followingsOfFollowings[index].includes(pubkey)) {
|
if (followingsOfFollowings[index].includes(pubkey)) {
|
||||||
@@ -35,7 +85,7 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
|
|||||||
setFollowedBy(_followedBy)
|
setFollowedBy(_followedBy)
|
||||||
}
|
}
|
||||||
init()
|
init()
|
||||||
}, [pubkey, accountPubkey])
|
}, [pubkey, accountPubkey, isSmallScreen])
|
||||||
|
|
||||||
if (followedBy.length === 0) return null
|
if (followedBy.length === 0) return null
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
|
import { Pubkey } from '@/domain'
|
||||||
import { useFetchProfile } from '@/hooks'
|
import { useFetchProfile } from '@/hooks'
|
||||||
import { userIdToPubkey } from '@/lib/pubkey'
|
|
||||||
import { useMemo } from 'react'
|
import { useMemo } from 'react'
|
||||||
import FollowButton from '../FollowButton'
|
import FollowButton from '../FollowButton'
|
||||||
import Nip05 from '../Nip05'
|
import Nip05 from '../Nip05'
|
||||||
@@ -9,7 +9,7 @@ import TrustScoreBadge from '../TrustScoreBadge'
|
|||||||
import { SimpleUserAvatar } from '../UserAvatar'
|
import { SimpleUserAvatar } from '../UserAvatar'
|
||||||
|
|
||||||
export default function ProfileCard({ userId }: { userId: string }) {
|
export default function ProfileCard({ userId }: { userId: string }) {
|
||||||
const pubkey = useMemo(() => userIdToPubkey(userId), [userId])
|
const pubkey = useMemo(() => Pubkey.tryFromString(userId)?.hex ?? userId, [userId])
|
||||||
const { profile } = useFetchProfile(userId)
|
const { profile } = useFetchProfile(userId)
|
||||||
const { username, about, emojis } = profile || {}
|
const { username, about, emojis } = profile || {}
|
||||||
|
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
DropdownMenuTrigger
|
DropdownMenuTrigger
|
||||||
} from '@/components/ui/dropdown-menu'
|
} from '@/components/ui/dropdown-menu'
|
||||||
import { pubkeyToNpub } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
@@ -50,7 +50,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
|
|||||||
<Button
|
<Button
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
setIsDrawerOpen(false)
|
setIsDrawerOpen(false)
|
||||||
navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')
|
navigator.clipboard.writeText(Pubkey.tryFromString(pubkey)?.npub ?? '')
|
||||||
}}
|
}}
|
||||||
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
className="w-full p-6 justify-start text-lg gap-4 [&_svg]:size-5"
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
@@ -109,7 +109,7 @@ export default function ProfileOptions({ pubkey }: { pubkey: string }) {
|
|||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild>{trigger}</DropdownMenuTrigger>
|
||||||
<DropdownMenuContent>
|
<DropdownMenuContent>
|
||||||
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(pubkeyToNpub(pubkey) ?? '')}>
|
<DropdownMenuItem onClick={() => navigator.clipboard.writeText(Pubkey.tryFromString(pubkey)?.npub ?? '')}>
|
||||||
<Copy />
|
<Copy />
|
||||||
{t('Copy user ID')}
|
{t('Copy user ID')}
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
|||||||
@@ -1,10 +1,10 @@
|
|||||||
import { formatNpub } from '@/lib/pubkey'
|
import { Pubkey } from '@/domain'
|
||||||
import { Check, Copy } from 'lucide-react'
|
import { Check, Copy } from 'lucide-react'
|
||||||
import { nip19 } from 'nostr-tools'
|
|
||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
|
|
||||||
export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
|
export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
|
||||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
|
const pk = useMemo(() => Pubkey.tryFromString(pubkey), [pubkey])
|
||||||
|
const npub = pk?.npub ?? ''
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
|
||||||
const copyNpub = () => {
|
const copyNpub = () => {
|
||||||
@@ -20,7 +20,7 @@ export default function PubkeyCopy({ pubkey }: { pubkey: string }) {
|
|||||||
className="flex gap-2 text-sm text-muted-foreground items-center bg-muted w-fit px-2 rounded-full clickable"
|
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>{pk?.formatNpub(24) ?? npub}</div>
|
||||||
{copied ? <Check size={14} /> : <Copy size={14} />}
|
{copied ? <Check size={14} /> : <Copy size={14} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
71
src/components/QrScannerModal/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { X } from 'lucide-react'
|
||||||
|
import QrScanner from 'qr-scanner'
|
||||||
|
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function QrScannerModal({
|
||||||
|
onScan,
|
||||||
|
onClose
|
||||||
|
}: {
|
||||||
|
onScan: (result: string) => void
|
||||||
|
onClose: () => void
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const videoRef = useRef<HTMLVideoElement>(null)
|
||||||
|
const scannerRef = useRef<QrScanner | null>(null)
|
||||||
|
const [error, setError] = useState<string | null>(null)
|
||||||
|
|
||||||
|
const handleScan = useCallback(
|
||||||
|
(result: QrScanner.ScanResult) => {
|
||||||
|
onScan(result.data)
|
||||||
|
onClose()
|
||||||
|
},
|
||||||
|
[onScan, onClose]
|
||||||
|
)
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!videoRef.current) return
|
||||||
|
|
||||||
|
const scanner = new QrScanner(videoRef.current, handleScan, {
|
||||||
|
preferredCamera: 'environment',
|
||||||
|
highlightScanRegion: true,
|
||||||
|
highlightCodeOutline: true
|
||||||
|
})
|
||||||
|
|
||||||
|
scannerRef.current = scanner
|
||||||
|
|
||||||
|
scanner.start().catch(() => {
|
||||||
|
setError(t('Failed to access camera'))
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
scanner.destroy()
|
||||||
|
}
|
||||||
|
}, [handleScan, t])
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
|
||||||
|
<div className="relative w-full max-w-sm mx-4">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="absolute -top-12 right-0 text-white hover:bg-white/20"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<X className="h-6 w-6" />
|
||||||
|
</Button>
|
||||||
|
<div className="rounded-lg overflow-hidden bg-black">
|
||||||
|
{error ? (
|
||||||
|
<div className="p-8 text-center text-destructive">{error}</div>
|
||||||
|
) : (
|
||||||
|
<video ref={videoRef} className="w-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="text-center text-white/70 text-sm mt-4">
|
||||||
|
{t('Point camera at QR code')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,11 +1,13 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Skeleton } from '@/components/ui/skeleton'
|
import { Skeleton } from '@/components/ui/skeleton'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { useThread } from '@/hooks/useThread'
|
import { useThread } from '@/hooks/useThread'
|
||||||
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
import { getEventKey, isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
|
||||||
|
import { TNavigationColumn } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
@@ -29,13 +31,17 @@ export default function ReplyNote({
|
|||||||
parentEventId,
|
parentEventId,
|
||||||
onClickParent = () => {},
|
onClickParent = () => {},
|
||||||
highlight = false,
|
highlight = false,
|
||||||
className = ''
|
className = '',
|
||||||
|
navColumn,
|
||||||
|
navIndex
|
||||||
}: {
|
}: {
|
||||||
event: Event
|
event: Event
|
||||||
parentEventId?: string
|
parentEventId?: string
|
||||||
onClickParent?: () => void
|
onClickParent?: () => void
|
||||||
highlight?: boolean
|
highlight?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
|
navColumn?: TNavigationColumn
|
||||||
|
navIndex?: number
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
@@ -46,6 +52,13 @@ export default function ReplyNote({
|
|||||||
const eventKey = useMemo(() => getEventKey(event), [event])
|
const eventKey = useMemo(() => getEventKey(event), [event])
|
||||||
const replies = useThread(eventKey)
|
const replies = useThread(eventKey)
|
||||||
const [showMuted, setShowMuted] = useState(false)
|
const [showMuted, setShowMuted] = useState(false)
|
||||||
|
|
||||||
|
// Keyboard navigation
|
||||||
|
const { ref: navRef, isSelected } = useKeyboardNavigable(
|
||||||
|
navColumn ?? 2,
|
||||||
|
navIndex ?? 0,
|
||||||
|
{ meta: { type: 'note', event } }
|
||||||
|
)
|
||||||
const show = useMemo(() => {
|
const show = useMemo(() => {
|
||||||
if (showMuted) {
|
if (showMuted) {
|
||||||
return true
|
return true
|
||||||
@@ -79,9 +92,11 @@ export default function ReplyNote({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
|
ref={navRef}
|
||||||
className={cn(
|
className={cn(
|
||||||
'relative pb-3 transition-colors duration-500 clickable',
|
'relative pb-3 transition-colors duration-500 clickable scroll-mt-[6.5rem]',
|
||||||
highlight ? 'bg-primary/40' : '',
|
highlight ? 'bg-primary/40' : '',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-inset',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
onClick={() => push(toNote(event))}
|
onClick={() => push(toNote(event))}
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import { useSecondaryPage } from '@/PageManager'
|
import { useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { useAllDescendantThreads } from '@/hooks/useThread'
|
import { useAllDescendantThreads } from '@/hooks/useThread'
|
||||||
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
|
import { getEventKey, getKeyFromTag, getParentTag, isMentioningMutedUsers } from '@/lib/event'
|
||||||
import { toNote } from '@/lib/link'
|
import { toNote } from '@/lib/link'
|
||||||
@@ -13,8 +14,15 @@ import { useCallback, useMemo, useRef, useState } from 'react'
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import ReplyNote from '../ReplyNote'
|
import ReplyNote from '../ReplyNote'
|
||||||
|
|
||||||
export default function SubReplies({ parentKey }: { parentKey: string }) {
|
export default function SubReplies({
|
||||||
const { t } = useTranslation()
|
parentKey,
|
||||||
|
revealerNavIndex,
|
||||||
|
subReplyNavIndexStart
|
||||||
|
}: {
|
||||||
|
parentKey: string
|
||||||
|
revealerNavIndex?: number
|
||||||
|
subReplyNavIndexStart?: number
|
||||||
|
}) {
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const allThreads = useAllDescendantThreads(parentKey)
|
const allThreads = useAllDescendantThreads(parentKey)
|
||||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
@@ -86,37 +94,12 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{replies.length > 1 && (
|
{replies.length > 1 && (
|
||||||
<button
|
<Revealer
|
||||||
onClick={(e) => {
|
isExpanded={isExpanded}
|
||||||
e.stopPropagation()
|
onToggle={() => setIsExpanded((prev) => !prev)}
|
||||||
setIsExpanded(!isExpanded)
|
replyCount={replies.length}
|
||||||
}}
|
navIndex={revealerNavIndex}
|
||||||
className="relative w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable"
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={cn('absolute left-[34px] top-0 bottom-0 w-px text-border z-20')}
|
|
||||||
style={{
|
|
||||||
background: isExpanded
|
|
||||||
? 'currentColor'
|
|
||||||
: 'repeating-linear-gradient(to bottom, currentColor 0 3px, transparent 3px 7px)'
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
{isExpanded ? (
|
|
||||||
<>
|
|
||||||
<ChevronUp className="size-3.5" />
|
|
||||||
<span>
|
|
||||||
{t('Hide replies')} ({replies.length})
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<ChevronDown className="size-3.5" />
|
|
||||||
<span>
|
|
||||||
{t('Show replies')} ({replies.length})
|
|
||||||
</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
)}
|
||||||
{(isExpanded || replies.length === 1) && (
|
{(isExpanded || replies.length === 1) && (
|
||||||
<div>
|
<div>
|
||||||
@@ -139,6 +122,8 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
|||||||
<ReplyNote
|
<ReplyNote
|
||||||
className="flex-1 w-0 pl-10"
|
className="flex-1 w-0 pl-10"
|
||||||
event={reply}
|
event={reply}
|
||||||
|
navColumn={2}
|
||||||
|
navIndex={subReplyNavIndexStart !== undefined ? subReplyNavIndexStart + index : undefined}
|
||||||
parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
|
parentEventId={_parentKey !== parentKey ? _parentEventId : undefined}
|
||||||
onClickParent={() => {
|
onClickParent={() => {
|
||||||
if (!_parentKey) return
|
if (!_parentKey) return
|
||||||
@@ -154,3 +139,60 @@ export default function SubReplies({ parentKey }: { parentKey: string }) {
|
|||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Revealer({
|
||||||
|
isExpanded,
|
||||||
|
onToggle,
|
||||||
|
replyCount,
|
||||||
|
navIndex
|
||||||
|
}: {
|
||||||
|
isExpanded: boolean
|
||||||
|
onToggle: () => void
|
||||||
|
replyCount: number
|
||||||
|
navIndex?: number
|
||||||
|
}) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
|
||||||
|
const { ref: revealerRef, isSelected } = useKeyboardNavigable(2, navIndex ?? 0, {
|
||||||
|
meta: { type: 'note', onActivate: onToggle }
|
||||||
|
})
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div ref={revealerRef} className="scroll-mt-[6.5rem]">
|
||||||
|
<button
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation()
|
||||||
|
onToggle()
|
||||||
|
}}
|
||||||
|
className={cn(
|
||||||
|
'relative w-full flex items-center gap-1.5 pl-14 py-2 text-sm text-muted-foreground hover:text-foreground transition-colors clickable',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-inset'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={cn('absolute left-[34px] top-0 bottom-0 w-px text-border z-20')}
|
||||||
|
style={{
|
||||||
|
background: isExpanded
|
||||||
|
? 'currentColor'
|
||||||
|
: 'repeating-linear-gradient(to bottom, currentColor 0 3px, transparent 3px 7px)'
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
{isExpanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="size-3.5" />
|
||||||
|
<span>
|
||||||
|
{t('Hide replies')} ({replyCount})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="size-3.5" />
|
||||||
|
<span>
|
||||||
|
{t('Show replies')} ({replyCount})
|
||||||
|
</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ import SubReplies from './SubReplies'
|
|||||||
const LIMIT = 100
|
const LIMIT = 100
|
||||||
const SHOW_COUNT = 10
|
const SHOW_COUNT = 10
|
||||||
|
|
||||||
export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
|
export default function ReplyNoteList({ stuff, navIndexOffset = 0 }: { stuff: NEvent | string; navIndexOffset?: number }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
const { hideUntrustedInteractions, isUserTrusted } = useUserTrust()
|
||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
@@ -90,8 +90,8 @@ export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
|
|||||||
<div className="min-h-[80vh]">
|
<div className="min-h-[80vh]">
|
||||||
{(loading || initialLoading) && <LoadingBar />}
|
{(loading || initialLoading) && <LoadingBar />}
|
||||||
<div>
|
<div>
|
||||||
{visibleItems.map((reply) => (
|
{visibleItems.map((reply, index) => (
|
||||||
<Item key={reply.id} reply={reply} />
|
<Item key={reply.id} reply={reply} navIndex={navIndexOffset + index} />
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div ref={bottomRef} />
|
<div ref={bottomRef} />
|
||||||
@@ -106,13 +106,17 @@ export default function ReplyNoteList({ stuff }: { stuff: NEvent | string }) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Item({ reply }: { reply: NEvent }) {
|
// Use larger gaps between items to leave room for sub-replies
|
||||||
|
const NAV_INDEX_MULTIPLIER = 100
|
||||||
|
|
||||||
|
function Item({ reply, navIndex }: { reply: NEvent; navIndex: number }) {
|
||||||
const key = useMemo(() => getEventKey(reply), [reply])
|
const key = useMemo(() => getEventKey(reply), [reply])
|
||||||
|
const baseNavIndex = navIndex * NAV_INDEX_MULTIPLIER
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative border-b">
|
<div className="relative border-b">
|
||||||
<ReplyNote event={reply} />
|
<ReplyNote event={reply} navColumn={2} navIndex={baseNavIndex} />
|
||||||
<SubReplies parentKey={key} />
|
<SubReplies parentKey={key} revealerNavIndex={baseNavIndex + 1} subReplyNavIndexStart={baseNavIndex + 2} />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -342,6 +342,27 @@ const SearchBar = forwardRef<
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setSearching(true)}
|
onFocus={() => setSearching(true)}
|
||||||
onBlur={() => setSearching(false)}
|
onBlur={() => setSearching(false)}
|
||||||
|
onQrScan={(value) => {
|
||||||
|
setInput(value)
|
||||||
|
// Automatically search after scanning
|
||||||
|
let id = value
|
||||||
|
if (id.startsWith('nostr:')) {
|
||||||
|
id = id.slice(6)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { type } = nip19.decode(id)
|
||||||
|
if (['nprofile', 'npub'].includes(type)) {
|
||||||
|
updateSearch({ type: 'profile', search: id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['nevent', 'naddr', 'note'].includes(type)) {
|
||||||
|
updateSearch({ type: 'note', search: id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a valid nip19 identifier, just set input
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { SearchIcon, X } from 'lucide-react'
|
import { QrCodeIcon, SearchIcon, X } from 'lucide-react'
|
||||||
import { ComponentProps, forwardRef, useEffect, useState } from 'react'
|
import { ComponentProps, forwardRef, useEffect, useState } from 'react'
|
||||||
|
import QrScannerModal from '../QrScannerModal'
|
||||||
|
|
||||||
const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
type SearchInputProps = ComponentProps<'input'> & {
|
||||||
({ value, onChange, className, ...props }, ref) => {
|
onQrScan?: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||||
|
({ value, onChange, className, onQrScan, ...props }, ref) => {
|
||||||
const [displayClear, setDisplayClear] = useState(false)
|
const [displayClear, setDisplayClear] = useState(false)
|
||||||
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
|
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
|
||||||
|
const [showQrScanner, setShowQrScanner] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayClear(!!value)
|
setDisplayClear(!!value)
|
||||||
@@ -20,7 +26,14 @@ const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleQrScan = (result: string) => {
|
||||||
|
// Strip nostr: prefix if present
|
||||||
|
const value = result.startsWith('nostr:') ? result.slice(6) : result
|
||||||
|
onQrScan?.(value)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -37,6 +50,16 @@ const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
{onQrScan && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors size-5 shrink-0 flex items-center justify-center mr-1"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setShowQrScanner(true)}
|
||||||
|
>
|
||||||
|
<QrCodeIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{displayClear && (
|
{displayClear && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -48,6 +71,10 @@ const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showQrScanner && (
|
||||||
|
<QrScannerModal onScan={handleQrScan} onClose={() => setShowQrScanner(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
||||||
|
import QrScannerModal from '@/components/QrScannerModal'
|
||||||
import Donation from '@/components/Donation'
|
import Donation from '@/components/Donation'
|
||||||
import Emoji from '@/components/Emoji'
|
import Emoji from '@/components/Emoji'
|
||||||
import EmojiPackList from '@/components/EmojiPackList'
|
import EmojiPackList from '@/components/EmojiPackList'
|
||||||
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
|
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
|
||||||
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
|
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
|
||||||
import MailboxSetting from '@/components/MailboxSetting'
|
import MailboxSetting from '@/components/MailboxSetting'
|
||||||
|
import NRCSettings from '@/components/NRCSettings'
|
||||||
import NoteList from '@/components/NoteList'
|
import NoteList from '@/components/NoteList'
|
||||||
import Tabs from '@/components/Tabs'
|
import Tabs from '@/components/Tabs'
|
||||||
import {
|
import {
|
||||||
@@ -54,7 +56,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
|
|||||||
import { useZap } from '@/providers/ZapProvider'
|
import { useZap } from '@/providers/ZapProvider'
|
||||||
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
||||||
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
|
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
|
||||||
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
|
import { connectNWC, disconnect, launchModal } from '@getalby/bitcoin-connect-react'
|
||||||
import {
|
import {
|
||||||
Check,
|
Check,
|
||||||
Cog,
|
Cog,
|
||||||
@@ -64,12 +66,15 @@ import {
|
|||||||
KeyRound,
|
KeyRound,
|
||||||
LayoutList,
|
LayoutList,
|
||||||
List,
|
List,
|
||||||
|
MessageSquare,
|
||||||
Monitor,
|
Monitor,
|
||||||
Moon,
|
Moon,
|
||||||
Palette,
|
Palette,
|
||||||
PanelLeft,
|
PanelLeft,
|
||||||
PencilLine,
|
PencilLine,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
|
ScanLine,
|
||||||
|
RefreshCw,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
Smile,
|
Smile,
|
||||||
@@ -77,8 +82,10 @@ import {
|
|||||||
Wallet
|
Wallet
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { kinds } from 'nostr-tools'
|
import { kinds } from 'nostr-tools'
|
||||||
import { forwardRef, HTMLProps, useCallback, useState } from 'react'
|
import { forwardRef, HTMLProps, useCallback, useEffect, useRef, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useKeyboardNavigation, useNavigationRegion, NavigationIntent } from '@/providers/KeyboardNavigationProvider'
|
||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
|
||||||
type TEmojiTab = 'my-packs' | 'explore'
|
type TEmojiTab = 'my-packs' | 'explore'
|
||||||
|
|
||||||
@@ -99,6 +106,9 @@ const NOTIFICATION_STYLES = [
|
|||||||
{ key: 'compact', label: 'Compact', icon: <List className="size-5" /> }
|
{ key: 'compact', label: 'Compact', icon: <List className="size-5" /> }
|
||||||
] as const
|
] as const
|
||||||
|
|
||||||
|
// Accordion item values for keyboard navigation
|
||||||
|
const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'sync', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system']
|
||||||
|
|
||||||
export default function Settings() {
|
export default function Settings() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
const { pubkey, nsec, ncryptsec } = useNostr()
|
const { pubkey, nsec, ncryptsec } = useNostr()
|
||||||
@@ -106,6 +116,98 @@ export default function Settings() {
|
|||||||
const [copiedNsec, setCopiedNsec] = useState(false)
|
const [copiedNsec, setCopiedNsec] = useState(false)
|
||||||
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
|
const [copiedNcryptsec, setCopiedNcryptsec] = useState(false)
|
||||||
const [openSection, setOpenSection] = useState<string>('')
|
const [openSection, setOpenSection] = useState<string>('')
|
||||||
|
const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1)
|
||||||
|
const accordionRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||||
|
|
||||||
|
const { activeColumn, scrollToCenter } = useKeyboardNavigation()
|
||||||
|
const { current: currentPage } = usePrimaryPage()
|
||||||
|
|
||||||
|
// Get the visible accordion items based on pubkey availability
|
||||||
|
const visibleAccordionItems = pubkey
|
||||||
|
? ACCORDION_ITEMS
|
||||||
|
: ACCORDION_ITEMS.filter((item) => !['sync', 'wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
|
||||||
|
|
||||||
|
// Register as a navigation region - Settings decides what "up/down" means
|
||||||
|
const handleSettingsIntent = useCallback(
|
||||||
|
(intent: NavigationIntent): boolean => {
|
||||||
|
switch (intent) {
|
||||||
|
case 'up':
|
||||||
|
setSelectedAccordionIndex((prev) => {
|
||||||
|
const newIndex = prev <= 0 ? 0 : prev - 1
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = accordionRefs.current[newIndex]
|
||||||
|
if (el) scrollToCenter(el)
|
||||||
|
}, 0)
|
||||||
|
return newIndex
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'down':
|
||||||
|
setSelectedAccordionIndex((prev) => {
|
||||||
|
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1)
|
||||||
|
setTimeout(() => {
|
||||||
|
const el = accordionRefs.current[newIndex]
|
||||||
|
if (el) scrollToCenter(el)
|
||||||
|
}, 0)
|
||||||
|
return newIndex
|
||||||
|
})
|
||||||
|
return true
|
||||||
|
|
||||||
|
case 'activate':
|
||||||
|
if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) {
|
||||||
|
const value = visibleAccordionItems[selectedAccordionIndex]
|
||||||
|
setOpenSection((prev) => (prev === value ? '' : value))
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
|
||||||
|
case 'cancel':
|
||||||
|
if (openSection) {
|
||||||
|
setOpenSection('')
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
|
||||||
|
default:
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[selectedAccordionIndex, openSection, visibleAccordionItems, scrollToCenter]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Register this component as a navigation region when it's active
|
||||||
|
useNavigationRegion(
|
||||||
|
'settings-accordion',
|
||||||
|
100, // High priority - handle intents before default handlers
|
||||||
|
() => activeColumn === 1 && currentPage === 'settings', // Only active when settings is displayed
|
||||||
|
handleSettingsIntent,
|
||||||
|
[handleSettingsIntent, activeColumn, currentPage]
|
||||||
|
)
|
||||||
|
|
||||||
|
// Reset selection when column changes
|
||||||
|
useEffect(() => {
|
||||||
|
if (activeColumn !== 1) {
|
||||||
|
setSelectedAccordionIndex(-1)
|
||||||
|
}
|
||||||
|
}, [activeColumn])
|
||||||
|
|
||||||
|
// Helper to get accordion index and check selection
|
||||||
|
const getAccordionIndex = useCallback(
|
||||||
|
(value: string) => visibleAccordionItems.indexOf(value),
|
||||||
|
[visibleAccordionItems]
|
||||||
|
)
|
||||||
|
|
||||||
|
const isAccordionSelected = useCallback(
|
||||||
|
(value: string) => selectedAccordionIndex === getAccordionIndex(value),
|
||||||
|
[selectedAccordionIndex, getAccordionIndex]
|
||||||
|
)
|
||||||
|
|
||||||
|
const setAccordionRef = useCallback((value: string) => (el: HTMLDivElement | null) => {
|
||||||
|
const idx = visibleAccordionItems.indexOf(value)
|
||||||
|
if (idx !== -1) {
|
||||||
|
accordionRefs.current[idx] = el
|
||||||
|
}
|
||||||
|
}, [visibleAccordionItems])
|
||||||
|
|
||||||
// General settings
|
// General settings
|
||||||
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
const [language, setLanguage] = useState<TLanguage>(i18n.language as TLanguage)
|
||||||
@@ -154,6 +256,20 @@ export default function Settings() {
|
|||||||
|
|
||||||
// System settings
|
// System settings
|
||||||
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
|
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
|
||||||
|
const [graphQueriesEnabled, setGraphQueriesEnabled] = useState(storage.getGraphQueriesEnabled())
|
||||||
|
|
||||||
|
// Messaging settings
|
||||||
|
const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
|
||||||
|
|
||||||
|
// Wallet QR scanner
|
||||||
|
const [showWalletScanner, setShowWalletScanner] = useState(false)
|
||||||
|
|
||||||
|
const handleWalletScan = useCallback((result: string) => {
|
||||||
|
// Check if it's a valid NWC URI
|
||||||
|
if (result.startsWith('nostr+walletconnect://')) {
|
||||||
|
connectNWC(result)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
const handleLanguageChange = (value: TLanguage) => {
|
const handleLanguageChange = (value: TLanguage) => {
|
||||||
i18n.changeLanguage(value)
|
i18n.changeLanguage(value)
|
||||||
@@ -179,6 +295,7 @@ export default function Settings() {
|
|||||||
className="w-full"
|
className="w-full"
|
||||||
>
|
>
|
||||||
{/* General */}
|
{/* General */}
|
||||||
|
<NavigableAccordionItem ref={setAccordionRef('general')} isSelected={isAccordionSelected('general')}>
|
||||||
<AccordionItem value="general">
|
<AccordionItem value="general">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -328,8 +445,10 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
|
|
||||||
{/* Appearance */}
|
{/* Appearance */}
|
||||||
|
<NavigableAccordionItem ref={setAccordionRef('appearance')} isSelected={isAccordionSelected('appearance')}>
|
||||||
<AccordionItem value="appearance">
|
<AccordionItem value="appearance">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -403,8 +522,10 @@ export default function Settings() {
|
|||||||
</div>
|
</div>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
|
|
||||||
{/* Relays */}
|
{/* Relays */}
|
||||||
|
<NavigableAccordionItem ref={setAccordionRef('relays')} isSelected={isAccordionSelected('relays')}>
|
||||||
<AccordionItem value="relays">
|
<AccordionItem value="relays">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -427,9 +548,28 @@ export default function Settings() {
|
|||||||
</RadixTabs>
|
</RadixTabs>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
|
|
||||||
|
{/* Sync (NRC) */}
|
||||||
|
{!!pubkey && (
|
||||||
|
<NavigableAccordionItem ref={setAccordionRef('sync')} isSelected={isAccordionSelected('sync')}>
|
||||||
|
<AccordionItem value="sync">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
<span>{t('Device Sync')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4">
|
||||||
|
<NRCSettings />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Wallet */}
|
{/* Wallet */}
|
||||||
{!!pubkey && (
|
{!!pubkey && (
|
||||||
|
<NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
|
||||||
<AccordionItem value="wallet">
|
<AccordionItem value="wallet">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -472,18 +612,36 @@ export default function Settings() {
|
|||||||
<LightningAddressInput />
|
<LightningAddressInput />
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
|
<>
|
||||||
|
{showWalletScanner && (
|
||||||
|
<QrScannerModal
|
||||||
|
onScan={handleWalletScan}
|
||||||
|
onClose={() => setShowWalletScanner(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
|
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
|
||||||
{t('Connect Wallet')}
|
{t('Connect Wallet')}
|
||||||
</Button>
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowWalletScanner(true)}
|
||||||
|
title={t('Scan NWC QR code')}
|
||||||
|
>
|
||||||
|
<ScanLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Post Settings */}
|
{/* Post Settings */}
|
||||||
{!!pubkey && (
|
{!!pubkey && (
|
||||||
|
<NavigableAccordionItem ref={setAccordionRef('posts')} isSelected={isAccordionSelected('posts')}>
|
||||||
<AccordionItem value="posts">
|
<AccordionItem value="posts">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -495,10 +653,12 @@ export default function Settings() {
|
|||||||
<MediaUploadServiceSetting />
|
<MediaUploadServiceSetting />
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Emoji Packs */}
|
{/* Emoji Packs */}
|
||||||
{!!pubkey && (
|
{!!pubkey && (
|
||||||
|
<NavigableAccordionItem ref={setAccordionRef('emoji-packs')} isSelected={isAccordionSelected('emoji-packs')}>
|
||||||
<AccordionItem value="emoji-packs">
|
<AccordionItem value="emoji-packs">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -526,9 +686,44 @@ export default function Settings() {
|
|||||||
)}
|
)}
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Messaging */}
|
||||||
|
{!!pubkey && (
|
||||||
|
<NavigableAccordionItem ref={setAccordionRef('messaging')} isSelected={isAccordionSelected('messaging')}>
|
||||||
|
<AccordionItem value="messaging">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<MessageSquare className="size-4" />
|
||||||
|
<span>{t('Messaging')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4 space-y-4">
|
||||||
|
<SettingItem>
|
||||||
|
<Label htmlFor="prefer-nip44" className="text-base font-normal">
|
||||||
|
<div>{t('Prefer NIP-44 encryption')}</div>
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t('Use modern encryption for new conversations')}
|
||||||
|
</div>
|
||||||
|
</Label>
|
||||||
|
<Switch
|
||||||
|
id="prefer-nip44"
|
||||||
|
checked={preferNip44}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
storage.setPreferNip44(checked)
|
||||||
|
setPreferNip44(checked)
|
||||||
|
dispatchSettingsChanged()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* System */}
|
{/* System */}
|
||||||
|
<NavigableAccordionItem ref={setAccordionRef('system')} isSelected={isAccordionSelected('system')}>
|
||||||
<AccordionItem value="system">
|
<AccordionItem value="system">
|
||||||
<AccordionTrigger className="px-4 hover:no-underline">
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
<div className="flex items-center gap-4">
|
<div className="flex items-center gap-4">
|
||||||
@@ -563,8 +758,28 @@ export default function Settings() {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</SettingItem>
|
</SettingItem>
|
||||||
|
<SettingItem>
|
||||||
|
<div>
|
||||||
|
<Label htmlFor="graph-queries-enabled" className="text-base font-normal">
|
||||||
|
{t('Graph query optimization')}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('Use graph queries for faster follow/thread loading on supported relays')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="graph-queries-enabled"
|
||||||
|
checked={graphQueriesEnabled}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
storage.setGraphQueriesEnabled(checked)
|
||||||
|
setGraphQueriesEnabled(checked)
|
||||||
|
dispatchSettingsChanged()
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</SettingItem>
|
||||||
</AccordionContent>
|
</AccordionContent>
|
||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
</Accordion>
|
</Accordion>
|
||||||
|
|
||||||
{/* Non-accordion items */}
|
{/* Non-accordion items */}
|
||||||
@@ -662,3 +877,25 @@ const OptionButton = ({
|
|||||||
</button>
|
</button>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Wrapper for keyboard-navigable accordion items
|
||||||
|
const NavigableAccordionItem = forwardRef<
|
||||||
|
HTMLDivElement,
|
||||||
|
{
|
||||||
|
isSelected: boolean
|
||||||
|
children: React.ReactNode
|
||||||
|
}
|
||||||
|
>(({ isSelected, children }, ref) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'rounded-lg transition-all',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-2 ring-offset-background'
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
NavigableAccordionItem.displayName = 'NavigableAccordionItem'
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { cn } from '@/lib/utils'
|
|||||||
import { useSecondaryPage, useSidebarDrawer } from '@/PageManager'
|
import { useSecondaryPage, useSidebarDrawer } from '@/PageManager'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { LogIn, LogOut, Plus, Wallet } from 'lucide-react'
|
import { LogIn, LogOut, Plus, RefreshCw, Wallet } from 'lucide-react'
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import LoginDialog from '../LoginDialog'
|
import LoginDialog from '../LoginDialog'
|
||||||
@@ -139,6 +139,13 @@ function ProfileButton({ collapse }: { collapse: boolean }) {
|
|||||||
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate"
|
className="text-muted-foreground border border-muted-foreground px-1 rounded-md text-xs truncate"
|
||||||
/>
|
/>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
<DropdownMenuItem
|
||||||
|
onClick={() => window.location.reload()}
|
||||||
|
>
|
||||||
|
<RefreshCw />
|
||||||
|
{t('Force Reload')}
|
||||||
|
</DropdownMenuItem>
|
||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
<LoginDialog open={loginDialogOpen} setOpen={setLoginDialogOpen} />
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { Bookmark } from 'lucide-react'
|
import { Bookmark } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
export default function BookmarkButton({ collapse }: { collapse: boolean }) {
|
export default function BookmarkButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
|
||||||
const { navigate, current, display } = usePrimaryPage()
|
const { navigate, current, display } = usePrimaryPage()
|
||||||
const { checkLogin } = useNostr()
|
const { checkLogin } = useNostr()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
checkLogin(() => {
|
||||||
|
navigate('bookmark')
|
||||||
|
clearColumn(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Bookmarks"
|
title="Bookmarks"
|
||||||
onClick={() => checkLogin(() => navigate('bookmark'))}
|
onClick={handleClick}
|
||||||
active={display && current === 'bookmark'}
|
active={display && current === 'bookmark'}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<Bookmark />
|
<Bookmark />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
34
src/components/Sidebar/HelpButton.tsx
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
import { toHelp } from '@/lib/link'
|
||||||
|
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
|
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||||
|
import { HelpCircle } from 'lucide-react'
|
||||||
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
|
export default function HelpButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
|
||||||
|
const { current, navigate, display } = usePrimaryPage()
|
||||||
|
const { push } = useSecondaryPage()
|
||||||
|
const { enableSingleColumnLayout } = useUserPreferences()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (enableSingleColumnLayout) {
|
||||||
|
navigate('help')
|
||||||
|
clearColumn(1)
|
||||||
|
} else {
|
||||||
|
push(toHelp())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem
|
||||||
|
title="Help"
|
||||||
|
onClick={handleClick}
|
||||||
|
collapse={collapse}
|
||||||
|
active={display && current === 'help'}
|
||||||
|
navIndex={navIndex}
|
||||||
|
>
|
||||||
|
<HelpCircle />
|
||||||
|
</SidebarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { Home } from 'lucide-react'
|
import { Home } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
export default function HomeButton({ collapse }: { collapse: boolean }) {
|
export default function HomeButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
|
||||||
const { navigate, current, display } = usePrimaryPage()
|
const { navigate, current, display } = usePrimaryPage()
|
||||||
|
const { resetPrimarySelection, clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
navigate('home')
|
||||||
|
clearColumn(1)
|
||||||
|
resetPrimarySelection()
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Home"
|
title="Home"
|
||||||
onClick={() => navigate('home')}
|
onClick={handleClick}
|
||||||
active={display && current === 'home'}
|
active={display && current === 'home'}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<Home />
|
<Home />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
33
src/components/Sidebar/InboxButton.tsx
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useDM } from '@/providers/DMProvider'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
|
import { MessageSquare } from 'lucide-react'
|
||||||
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
|
export default function InboxButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
|
||||||
|
const { navigate, current, display } = usePrimaryPage()
|
||||||
|
const { hasNewMessages } = useDM()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
navigate('inbox')
|
||||||
|
clearColumn(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<SidebarItem
|
||||||
|
title="Inbox"
|
||||||
|
onClick={handleClick}
|
||||||
|
active={display && current === 'inbox'}
|
||||||
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
<MessageSquare />
|
||||||
|
{hasNewMessages && (
|
||||||
|
<div className="absolute -top-1 right-0 w-2 h-2 ring-2 ring-background bg-primary rounded-full" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</SidebarItem>
|
||||||
|
)
|
||||||
|
}
|
||||||
36
src/components/Sidebar/KeyboardModeButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
|
import { Keyboard } from 'lucide-react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
|
export default function KeyboardModeButton({ collapse }: { collapse: boolean }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { isEnabled, toggleKeyboardMode } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button
|
||||||
|
className={cn(
|
||||||
|
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-2 text-sm font-semibold',
|
||||||
|
collapse
|
||||||
|
? 'w-12 h-12 p-3 [&_svg]:size-full'
|
||||||
|
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
|
||||||
|
isEnabled && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10'
|
||||||
|
)}
|
||||||
|
variant="ghost"
|
||||||
|
title={t('Toggle keyboard navigation (⇧K)')}
|
||||||
|
onClick={toggleKeyboardMode}
|
||||||
|
>
|
||||||
|
<Keyboard />
|
||||||
|
{!collapse && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span>{t('Keyboard')}</span>
|
||||||
|
<kbd className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground border">⇧K</kbd>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{collapse && (
|
||||||
|
<span className="sr-only">{t('Toggle keyboard navigation')}</span>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,20 +1,30 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { useNotification } from '@/providers/NotificationProvider'
|
import { useNotification } from '@/providers/NotificationProvider'
|
||||||
import { Bell } from 'lucide-react'
|
import { Bell } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
export default function NotificationsButton({ collapse }: { collapse: boolean }) {
|
export default function NotificationsButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
|
||||||
const { checkLogin } = useNostr()
|
const { checkLogin } = useNostr()
|
||||||
const { navigate, current, display } = usePrimaryPage()
|
const { navigate, current, display } = usePrimaryPage()
|
||||||
const { hasNewNotification } = useNotification()
|
const { hasNewNotification } = useNotification()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
checkLogin(() => {
|
||||||
|
navigate('notifications')
|
||||||
|
clearColumn(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Notifications"
|
title="Notifications"
|
||||||
onClick={() => checkLogin(() => navigate('notifications'))}
|
onClick={handleClick}
|
||||||
active={display && current === 'notifications'}
|
active={display && current === 'notifications'}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<div className="relative">
|
<div className="relative">
|
||||||
<Bell />
|
<Bell />
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ import { PencilLine } from 'lucide-react'
|
|||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
export default function PostButton({ collapse }: { collapse: boolean }) {
|
export default function PostButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
|
||||||
const { checkLogin } = useNostr()
|
const { checkLogin } = useNostr()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
|
|
||||||
@@ -23,6 +23,7 @@ export default function PostButton({ collapse }: { collapse: boolean }) {
|
|||||||
variant="default"
|
variant="default"
|
||||||
className={cn('bg-primary gap-2', !collapse && 'justify-center')}
|
className={cn('bg-primary gap-2', !collapse && 'justify-center')}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<PencilLine />
|
<PencilLine />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
@@ -1,18 +1,28 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { UserRound } from 'lucide-react'
|
import { UserRound } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
export default function ProfileButton({ collapse }: { collapse: boolean }) {
|
export default function ProfileButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
|
||||||
const { navigate, current, display } = usePrimaryPage()
|
const { navigate, current, display } = usePrimaryPage()
|
||||||
const { checkLogin } = useNostr()
|
const { checkLogin } = useNostr()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
checkLogin(() => {
|
||||||
|
navigate('profile')
|
||||||
|
clearColumn(1)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Profile"
|
title="Profile"
|
||||||
onClick={() => checkLogin(() => navigate('profile'))}
|
onClick={handleClick}
|
||||||
active={display && current === 'profile'}
|
active={display && current === 'profile'}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<UserRound />
|
<UserRound />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
import { usePrimaryPage } from '@/PageManager'
|
import { usePrimaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { Search } from 'lucide-react'
|
import { Search } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
export default function SearchButton({ collapse }: { collapse: boolean }) {
|
export default function SearchButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
|
||||||
const { navigate, current, display } = usePrimaryPage()
|
const { navigate, current, display } = usePrimaryPage()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
navigate('search')
|
||||||
|
clearColumn(1)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Search"
|
title="Search"
|
||||||
onClick={() => navigate('search')}
|
onClick={handleClick}
|
||||||
active={current === 'search' && display}
|
active={current === 'search' && display}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<Search />
|
<Search />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
@@ -1,20 +1,32 @@
|
|||||||
import { toSettings } from '@/lib/link'
|
import { toSettings } from '@/lib/link'
|
||||||
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
|
import { usePrimaryPage, useSecondaryPage } from '@/PageManager'
|
||||||
|
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
||||||
import { Settings } from 'lucide-react'
|
import { Settings } from 'lucide-react'
|
||||||
import SidebarItem from './SidebarItem'
|
import SidebarItem from './SidebarItem'
|
||||||
|
|
||||||
export default function SettingsButton({ collapse }: { collapse: boolean }) {
|
export default function SettingsButton({ collapse, navIndex }: { collapse: boolean; navIndex?: number }) {
|
||||||
const { current, navigate, display } = usePrimaryPage()
|
const { current, navigate, display } = usePrimaryPage()
|
||||||
const { push } = useSecondaryPage()
|
const { push } = useSecondaryPage()
|
||||||
const { enableSingleColumnLayout } = useUserPreferences()
|
const { enableSingleColumnLayout } = useUserPreferences()
|
||||||
|
const { clearColumn } = useKeyboardNavigation()
|
||||||
|
|
||||||
|
const handleClick = () => {
|
||||||
|
if (enableSingleColumnLayout) {
|
||||||
|
navigate('settings')
|
||||||
|
clearColumn(1)
|
||||||
|
} else {
|
||||||
|
push(toSettings())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SidebarItem
|
<SidebarItem
|
||||||
title="Settings"
|
title="Settings"
|
||||||
onClick={() => (enableSingleColumnLayout ? navigate('settings') : push(toSettings()))}
|
onClick={handleClick}
|
||||||
collapse={collapse}
|
collapse={collapse}
|
||||||
active={display && current === 'settings'}
|
active={display && current === 'settings'}
|
||||||
|
navIndex={navIndex}
|
||||||
>
|
>
|
||||||
<Settings />
|
<Settings />
|
||||||
</SidebarItem>
|
</SidebarItem>
|
||||||
|
|||||||
@@ -1,15 +1,32 @@
|
|||||||
import { Button, ButtonProps } from '@/components/ui/button'
|
import { Button, ButtonProps } from '@/components/ui/button'
|
||||||
|
import { useKeyboardNavigable } from '@/hooks/useKeyboardNavigable'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { forwardRef } from 'react'
|
import { forwardRef, useCallback, useRef } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
|
|
||||||
const SidebarItem = forwardRef<
|
const SidebarItem = forwardRef<
|
||||||
HTMLButtonElement,
|
HTMLButtonElement,
|
||||||
ButtonProps & { title: string; collapse: boolean; description?: string; active?: boolean }
|
ButtonProps & {
|
||||||
>(({ children, title, description, className, active, collapse, ...props }, ref) => {
|
title: string
|
||||||
|
collapse: boolean
|
||||||
|
description?: string
|
||||||
|
active?: boolean
|
||||||
|
navIndex?: number
|
||||||
|
}
|
||||||
|
>(({ children, title, description, className, active, collapse, navIndex, onClick, ...props }, _ref) => {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>(null)
|
||||||
|
|
||||||
|
const handleActivate = useCallback(() => {
|
||||||
|
buttonRef.current?.click()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const { ref: navRef, isSelected } = useKeyboardNavigable(0, navIndex ?? 0, {
|
||||||
|
meta: { type: 'sidebar', onActivate: handleActivate }
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<div ref={navRef}>
|
||||||
<Button
|
<Button
|
||||||
className={cn(
|
className={cn(
|
||||||
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-4 text-lg font-semibold',
|
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-4 text-lg font-semibold',
|
||||||
@@ -17,16 +34,19 @@ const SidebarItem = forwardRef<
|
|||||||
? 'w-12 h-12 p-3 [&_svg]:size-full'
|
? 'w-12 h-12 p-3 [&_svg]:size-full'
|
||||||
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
|
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
|
||||||
active && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10',
|
active && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10',
|
||||||
|
isSelected && 'ring-2 ring-primary ring-offset-2 ring-offset-background',
|
||||||
className
|
className
|
||||||
)}
|
)}
|
||||||
variant="ghost"
|
variant="ghost"
|
||||||
title={t(title)}
|
title={t(title)}
|
||||||
ref={ref}
|
ref={buttonRef}
|
||||||
|
onClick={onClick}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
{!collapse && <div>{t(description ?? title)}</div>}
|
{!collapse && <div>{t(description ?? title)}</div>}
|
||||||
</Button>
|
</Button>
|
||||||
|
</div>
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
SidebarItem.displayName = 'SidebarItem'
|
SidebarItem.displayName = 'SidebarItem'
|
||||||
|
|||||||
@@ -9,7 +9,10 @@ import { useUserPreferences } from '@/providers/UserPreferencesProvider'
|
|||||||
import { ChevronsLeft, ChevronsRight } from 'lucide-react'
|
import { ChevronsLeft, ChevronsRight } from 'lucide-react'
|
||||||
import AccountButton from './AccountButton'
|
import AccountButton from './AccountButton'
|
||||||
import BookmarkButton from './BookmarkButton'
|
import BookmarkButton from './BookmarkButton'
|
||||||
|
import HelpButton from './HelpButton'
|
||||||
import HomeButton from './HomeButton'
|
import HomeButton from './HomeButton'
|
||||||
|
import InboxButton from './InboxButton'
|
||||||
|
import KeyboardModeButton from './KeyboardModeButton'
|
||||||
import LayoutSwitcher from './LayoutSwitcher'
|
import LayoutSwitcher from './LayoutSwitcher'
|
||||||
import NotificationsButton from './NotificationButton'
|
import NotificationsButton from './NotificationButton'
|
||||||
import PostButton from './PostButton'
|
import PostButton from './PostButton'
|
||||||
@@ -54,15 +57,18 @@ export default function PrimaryPageSidebar() {
|
|||||||
<Logo />
|
<Logo />
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
<HomeButton collapse={isCollapsed} />
|
<HomeButton collapse={isCollapsed} navIndex={0} />
|
||||||
<NotificationsButton collapse={isCollapsed} />
|
<NotificationsButton collapse={isCollapsed} navIndex={1} />
|
||||||
<SearchButton collapse={isCollapsed} />
|
<SearchButton collapse={isCollapsed} navIndex={2} />
|
||||||
<ProfileButton collapse={isCollapsed} />
|
{pubkey && <InboxButton collapse={isCollapsed} navIndex={3} />}
|
||||||
{pubkey && <BookmarkButton collapse={isCollapsed} />}
|
<ProfileButton collapse={isCollapsed} navIndex={pubkey ? 4 : 3} />
|
||||||
<SettingsButton collapse={isCollapsed} />
|
{pubkey && <BookmarkButton collapse={isCollapsed} navIndex={5} />}
|
||||||
<PostButton collapse={isCollapsed} />
|
<SettingsButton collapse={isCollapsed} navIndex={pubkey ? 6 : 4} />
|
||||||
|
<PostButton collapse={isCollapsed} navIndex={pubkey ? 7 : 5} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
<HelpButton collapse={isCollapsed} navIndex={pubkey ? 8 : 6} />
|
||||||
|
<KeyboardModeButton collapse={isCollapsed} />
|
||||||
<LayoutSwitcher collapse={isCollapsed} />
|
<LayoutSwitcher collapse={isCollapsed} />
|
||||||
<AccountButton collapse={isCollapsed} />
|
<AccountButton collapse={isCollapsed} />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||