7 Commits

Author SHA1 Message Date
woikos
d9343a76bb v0.5.1: Remove CAT token support from NRC and NIP-46 bunker
- Delete cashu-token.service.ts and TokenDisplay component
- Simplify NRC to use only secret-based authentication
- Remove CAT token handling from bunker signer
- Clean up related types and UI elements

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-19 06:03:17 +01:00
woikos
28b8720dbf v0.5.0: CAT token service improvements
- Improved Cashu Access Token handling
- Version bump to v0.5.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 21:54:18 +01:00
woikos
5b23ea04d0 Add QR scanner to bunker login and enhance NRC functionality
- Add QR scanner button to bunker URL paste input for easier mobile login
- Enhance NRC (Nostr Relay Connect) with improved connection handling
- Update NRC settings UI with better status display
- Improve bunker signer reliability

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-15 10:41:00 +01:00
woikos
ecd7c36400 Add NRC (Nostr Relay Connect) for cross-device sync
Implements NRC listener that allows other user clients to connect
and sync events through a rendezvous relay. Features:
- REQ-only (read) sync for security
- Secret-based and CAT token authentication
- NIP-44 encrypted tunneling
- Device-specific event filtering via d-tag prefix
- Session management with timeouts
- Settings UI with QR code connection flow

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-11 09:16:03 +01:00
woikos
08f75a902d Release v0.4.1
Auto-search after QR scan in search bar.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-05 20:38:28 +01:00
woikos
8a9795a53a Add graph query optimization for faster social graph operations
- Add GraphQueryService for NIP-XX graph queries
- Add GraphCacheService for IndexedDB caching of results
- Optimize FollowedBy component with graph queries
- Add graph query support to ThreadService
- Add useFetchFollowGraph hook
- Add graph query toggle in Settings > System
- Bump version to v0.4.0

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
2026-01-05 14:37:36 +01:00
woikos
d1ec24b85a Add keyboard mode toggle and QR scanner improvements
- Add keyboard mode toggle button (⇧K) in sidebar
- Triple-Escape to quickly exit keyboard mode
- Extract QrScannerModal to shared component
- Add QR scanner for NWC wallet connection in Settings
- Update Help page with keyboard toggle documentation
- Fix keyboard navigation getting stuck on inbox
- Improve feed loading after login (loads immediately)
- DM conversation page layout improvements

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-04 11:08:06 +01:00
57 changed files with 6536 additions and 1545 deletions

View File

@@ -0,0 +1,7 @@
{
"permissions": {
"additionalDirectories": [
"/home/mleku/src/git.mleku.dev/mleku/coracle"
]
}
}

11
package-lock.json generated
View File

@@ -1,12 +1,12 @@
{
"name": "smesh",
"version": "0.3.0",
"version": "0.4.1",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "smesh",
"version": "0.3.0",
"version": "0.4.1",
"license": "MIT",
"dependencies": {
"@dnd-kit/core": "^6.3.1",
@@ -56,6 +56,7 @@
"emoji-picker-react": "^4.12.2",
"flexsearch": "^0.7.43",
"franc-min": "^6.2.0",
"html5-qrcode": "^2.3.8",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"jotai": "^2.15.0",
@@ -8932,6 +8933,12 @@
"url": "https://opencollective.com/unified"
}
},
"node_modules/html5-qrcode": {
"version": "2.3.8",
"resolved": "https://registry.npmjs.org/html5-qrcode/-/html5-qrcode-2.3.8.tgz",
"integrity": "sha512-jsr4vafJhwoLVEDW3n1KvPnCCXWaQfRng0/EEYk1vNcQGcG/htAdhJX0be8YyqMoSz7+hZvOZSTAepsabiuhiQ==",
"license": "Apache-2.0"
},
"node_modules/i18next": {
"version": "24.2.0",
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",

View File

@@ -1,6 +1,6 @@
{
"name": "smesh",
"version": "0.3.1",
"version": "0.5.1",
"description": "A user-friendly Nostr client for exploring relay feeds",
"private": true,
"type": "module",
@@ -70,6 +70,7 @@
"emoji-picker-react": "^4.12.2",
"flexsearch": "^0.7.43",
"franc-min": "^6.2.0",
"html5-qrcode": "^2.3.8",
"i18next": "^24.2.0",
"i18next-browser-languagedetector": "^8.0.4",
"jotai": "^2.15.0",

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 10 KiB

View File

@@ -12,9 +12,11 @@ import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
import { FeedProvider } from '@/providers/FeedProvider'
import { FollowListProvider } from '@/providers/FollowListProvider'
import { KindFilterProvider } from '@/providers/KindFilterProvider'
import { SocialGraphFilterProvider } from '@/providers/SocialGraphFilterProvider'
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
import { MuteListProvider } from '@/providers/MuteListProvider'
import { NostrProvider } from '@/providers/NostrProvider'
import { NRCProvider } from '@/providers/NRCProvider'
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
import { PinListProvider } from '@/providers/PinListProvider'
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
@@ -37,6 +39,7 @@ export default function App(): JSX.Element {
<DeletedEventProvider>
<PasswordPromptProvider>
<NostrProvider>
<NRCProvider>
<RepositoryProvider>
<SettingsSyncProvider>
<ZapProvider>
@@ -51,10 +54,12 @@ export default function App(): JSX.Element {
<PinnedUsersProvider>
<FeedProvider>
<MediaUploadServiceProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
<SocialGraphFilterProvider>
<KindFilterProvider>
<PageManager />
<Toaster />
</KindFilterProvider>
</SocialGraphFilterProvider>
</MediaUploadServiceProvider>
</FeedProvider>
</PinnedUsersProvider>
@@ -69,6 +74,7 @@ export default function App(): JSX.Element {
</ZapProvider>
</SettingsSyncProvider>
</RepositoryProvider>
</NRCProvider>
</NostrProvider>
</PasswordPromptProvider>
</DeletedEventProvider>

View File

@@ -1,9 +1,10 @@
import QrScannerModal from '@/components/QrScannerModal'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { useNostr } from '@/providers/NostrProvider'
import { BunkerSigner } from '@/providers/NostrProvider/bunker.signer'
import { ArrowLeft, Loader2, QrCode, Server, Copy, Check } from 'lucide-react'
import { ArrowLeft, Loader2, QrCode, Server, Copy, Check, ScanLine } from 'lucide-react'
import { useState, useEffect } from 'react'
import { useTranslation } from 'react-i18next'
import QRCode from 'qrcode'
@@ -28,6 +29,7 @@ export default function BunkerLogin({
const [connectUrl, setConnectUrl] = useState<string | null>(null)
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
const [copied, setCopied] = useState(false)
const [showScanner, setShowScanner] = useState(false)
// Generate QR code when in scan mode
useEffect(() => {
@@ -88,6 +90,11 @@ export default function BunkerLogin({
}
}, [mode, relayUrl, bunkerLoginWithSigner, onLoginSuccess])
const handleScan = (result: string) => {
setBunkerUrl(result)
setError(null)
}
const handlePasteSubmit = async (e: React.FormEvent) => {
e.preventDefault()
if (!bunkerUrl.trim()) {
@@ -263,49 +270,66 @@ export default function BunkerLogin({
// Paste mode
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>
<>
{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">
<Server className="size-5" />
<span className="font-semibold">{t('Paste Bunker URL')}</span>
<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>
</div>
<form onSubmit={handlePasteSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
<Input
id="bunkerUrl"
type="text"
placeholder="bunker://pubkey?relay=wss://..."
value={bunkerUrl}
onChange={(e) => setBunkerUrl(e.target.value)}
disabled={loading}
className="font-mono text-sm"
/>
<p className="text-xs text-muted-foreground">
{t(
'Enter the bunker connection URL. This is typically provided by your signing device or service.'
<form onSubmit={handlePasteSubmit} className="space-y-4">
<div className="space-y-2">
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
<div className="flex gap-2">
<Input
id="bunkerUrl"
type="text"
placeholder="bunker://pubkey?relay=wss://..."
value={bunkerUrl}
onChange={(e) => setBunkerUrl(e.target.value)}
disabled={loading}
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">
{t(
'Enter the bunker connection URL. This is typically provided by your signing device or service.'
)}
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button type="submit" className="w-full" disabled={loading || !bunkerUrl.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('Connecting...')}
</>
) : (
t('Connect to Bunker')
)}
</p>
</div>
{error && <div className="text-sm text-destructive">{error}</div>}
<Button type="submit" className="w-full" disabled={loading || !bunkerUrl.trim()}>
{loading ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
{t('Connecting...')}
</>
) : (
t('Connect to Bunker')
)}
</Button>
</form>
</div>
</Button>
</form>
</div>
</>
)
}

View File

@@ -1,78 +1,12 @@
import QrScannerModal from '@/components/QrScannerModal'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useNostr } from '@/providers/NostrProvider'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ScanLine, X } 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>
)
}
import { ScanLine } from 'lucide-react'
export default function PrivateKeyLogin({
back,

View File

@@ -23,15 +23,33 @@ export default function Help() {
<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={['Arrow Up', 'Arrow Down']} description={t('Move between items in a list')} />
<KeyBinding keys={['Arrow Left', 'Arrow Right']} description={t('Switch between columns (sidebar, feed, detail)')} />
<KeyBinding keys={['Enter']} description={t('Open or activate the selected item')} />
<KeyBinding keys={['Escape']} description={t('Close current view or go to sidebar')} />
<KeyBinding keys={['Backspace']} description={t('Go back to previous view')} />
<KeyBinding keys={['Page Up', 'Page Down']} description={t('Jump to top or bottom of list')} />
<KeyBinding keys={['Tab']} description={t('Cycle through note actions (reply, repost, quote, react, zap)')} />
<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>
@@ -156,18 +174,33 @@ export default function Help() {
)
}
function KeyBinding({ keys, description }: { keys: string[]; description: string }) {
function KeyBinding({
keys,
altKeys,
description
}: {
keys: string[]
altKeys?: string[]
description: string
}) {
return (
<div className="flex items-center gap-3">
<div className="flex gap-1">
<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"
>
<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>

View File

@@ -26,9 +26,10 @@ import { useFollowList } from '@/providers/FollowListProvider'
interface MessageViewProps {
onBack?: () => void
hideHeader?: boolean
}
export default function MessageView({ onBack }: MessageViewProps) {
export default function MessageView({ onBack, hideHeader }: MessageViewProps) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const {
@@ -184,6 +185,20 @@ export default function MessageView({ onBack }: MessageViewProps) {
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
}
@@ -192,114 +207,116 @@ export default function MessageView({ onBack }: MessageViewProps) {
return (
<div className="flex flex-col h-full">
{/* Header */}
<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>
{/* 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>
{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}
title={t('Reload messages')}
onClick={reloadConversation}
disabled={isLoadingConversation}
>
<X className="size-4" />
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
</Button>
)}
</>
)}
</div>
<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">

View File

@@ -3,10 +3,13 @@ import { Checkbox } from '@/components/ui/checkbox'
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
import { Label } from '@/components/ui/label'
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
import { Separator } from '@/components/ui/separator'
import SocialGraphFilter from '@/components/SocialGraphFilter'
import { ExtendedKind } from '@/constants'
import { cn } from '@/lib/utils'
import { useKindFilter } from '@/providers/KindFilterProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
import { ListFilter } from 'lucide-react'
import { kinds } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
@@ -34,22 +37,35 @@ const ALL_KINDS = KIND_FILTER_OPTIONS.flatMap(({ kindGroup }) => kindGroup)
export default function KindFilter({
showKinds,
onShowKindsChange
onShowKindsChange,
showSocialGraphFilter = false
}: {
showKinds: number[]
onShowKindsChange: (kinds: number[]) => void
showSocialGraphFilter?: boolean
}) {
const { t } = useTranslation()
const { isSmallScreen } = useScreenSize()
const { showKinds: savedShowKinds } = useKindFilter()
const {
proximityLevel: savedProximity,
includeMode: savedIncludeMode,
updateProximityLevel,
updateIncludeMode
} = useSocialGraphFilter()
const [open, setOpen] = useState(false)
const { updateShowKinds } = useKindFilter()
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
const [temporaryProximity, setTemporaryProximity] = useState<number | null>(savedProximity)
const [temporaryIncludeMode, setTemporaryIncludeMode] = useState(savedIncludeMode)
const [isPersistent, setIsPersistent] = useState(false)
const isDifferentFromSaved = useMemo(
() => !isSameKindFilter(showKinds, savedShowKinds),
[showKinds, savedShowKinds]
)
const isDifferentFromSaved = useMemo(() => {
const kindsDifferent = !isSameKindFilter(showKinds, savedShowKinds)
const proximityDifferent = showSocialGraphFilter && savedProximity !== null
return kindsDifferent || proximityDifferent
}, [showKinds, savedShowKinds, savedProximity, showSocialGraphFilter])
const isTemporaryDifferentFromSaved = useMemo(
() => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
[temporaryShowKinds, savedShowKinds]
@@ -57,8 +73,10 @@ export default function KindFilter({
useEffect(() => {
setTemporaryShowKinds(showKinds)
setTemporaryProximity(savedProximity)
setTemporaryIncludeMode(savedIncludeMode)
setIsPersistent(false)
}, [open])
}, [open, savedProximity, savedIncludeMode])
const handleApply = () => {
if (temporaryShowKinds.length === 0) {
@@ -71,6 +89,16 @@ export default function KindFilter({
onShowKindsChange(newShowKinds)
}
// Apply social graph filter changes
if (showSocialGraphFilter) {
if (temporaryProximity !== savedProximity) {
updateProximityLevel(temporaryProximity)
}
if (temporaryIncludeMode !== savedIncludeMode) {
updateIncludeMode(temporaryIncludeMode)
}
}
if (isPersistent) {
updateShowKinds(newShowKinds)
}
@@ -155,6 +183,18 @@ export default function KindFilter({
</Button>
</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">
<Checkbox
id="persistent-filter"

View File

@@ -0,0 +1,823 @@
/**
* 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
} 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,
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>
{/* 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()}
</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>
)
}

View File

@@ -16,6 +16,7 @@ export default function NormalFeed({
isMainFeed = false,
showRelayCloseReason = false,
disable24hMode = false,
enableSocialGraphFilter = false,
onRefresh
}: {
subRequests: TFeedSubRequest[]
@@ -23,6 +24,7 @@ export default function NormalFeed({
isMainFeed?: boolean
showRelayCloseReason?: boolean
disable24hMode?: boolean
enableSocialGraphFilter?: boolean
onRefresh?: () => void
}) {
const { hideUntrustedNotes } = useUserTrust()
@@ -87,6 +89,7 @@ export default function NormalFeed({
<KindFilter
showKinds={temporaryShowKinds}
onShowKindsChange={handleShowKindsChange}
showSocialGraphFilter={enableSocialGraphFilter}
/>
)}
</>
@@ -110,6 +113,7 @@ export default function NormalFeed({
hideUntrustedNotes={hideUntrustedNotes}
areAlgoRelays={areAlgoRelays}
showRelayCloseReason={showRelayCloseReason}
applySocialGraphFilter={enableSocialGraphFilter}
/>
)}
</>

View File

@@ -9,6 +9,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { useMuteList } from '@/providers/MuteListProvider'
import { useNostr } from '@/providers/NostrProvider'
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
import { useUserTrust } from '@/providers/UserTrustProvider'
import client from '@/services/client.service'
import threadService from '@/services/thread.service'
@@ -55,6 +56,7 @@ const NoteList = forwardRef<
filterFn?: (event: Event) => boolean
showNewNotesDirectly?: boolean
navColumn?: TNavigationColumn
applySocialGraphFilter?: boolean
}
>(
(
@@ -70,7 +72,8 @@ const NoteList = forwardRef<
pinnedEventIds,
filterFn,
showNewNotesDirectly = false,
navColumn = 1
navColumn = 1,
applySocialGraphFilter = false
},
ref
) => {
@@ -80,7 +83,8 @@ const NoteList = forwardRef<
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const { offsetSelection } = useKeyboardNavigation()
const { isPubkeyAllowed } = useSocialGraphFilter()
const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const [initialLoading, setInitialLoading] = useState(false)
@@ -122,10 +126,22 @@ const NoteList = forwardRef<
if (filterFn && !filterFn(evt)) {
return true
}
// Social graph filter - only apply if enabled for this feed
if (applySocialGraphFilter && !isPubkeyAllowed(evt.pubkey)) {
return true
}
return false
},
[hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn]
[
hideUntrustedNotes,
mutePubkeySet,
JSON.stringify(pinnedEventIds),
isEventDeleted,
filterFn,
applySocialGraphFilter,
isPubkeyAllowed
]
)
useEffect(() => {
@@ -370,6 +386,12 @@ const NoteList = forwardRef<
initialLoading
})
// 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

View File

@@ -3,22 +3,43 @@ import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { QrCodeIcon } from 'lucide-react'
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 PubkeyCopy from '../PubkeyCopy'
import QrCode from '../QrCode'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
export default function NpubQrCode({ pubkey }: { pubkey: string }) {
const { t } = useTranslation()
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
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} />
</div>
</button>
)
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">
<UserAvatar size="big" userId={pubkey} />
<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} />
</div>
</div>
<QrCode size={512} value={`nostr:${npub}`} />
<div className="flex flex-col items-center">
<PubkeyCopy pubkey={pubkey} />
</div>
<button
onClick={handleQrClick}
className="cursor-pointer hover:opacity-90 transition-opacity"
title={t('Click to copy npub')}
>
<QrCode size={512} value={`nostr:${npub}`} />
</button>
<div className="text-sm text-muted-foreground">{t('Click QR code to copy npub')}</div>
</div>
)
if (isSmallScreen) {
return (
<Drawer>
<DrawerTrigger>{trigger}</DrawerTrigger>
<Drawer open={open} onOpenChange={setOpen}>
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
<DrawerContent>{content}</DrawerContent>
</Drawer>
)
}
return (
<Dialog>
<DialogTrigger>{trigger}</DialogTrigger>
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>{trigger}</DialogTrigger>
<DialogContent className="w-80 p-0 m-0" onOpenAutoFocus={(e) => e.preventDefault()}>
{content}
</DialogContent>

View File

@@ -1,7 +1,9 @@
import UserAvatar from '@/components/UserAvatar'
import { BIG_RELAY_URLS } from '@/constants'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import graphQueryService from '@/services/graph-query.service'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -15,6 +17,55 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
if (!pubkey || !accountPubkey) return
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 followingsOfFollowings = await Promise.all(
followings.map(async (following) => {
@@ -22,7 +73,6 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
})
)
const _followedBy: string[] = []
const limit = isSmallScreen ? 3 : 5
for (const [index, following] of followings.entries()) {
if (following === pubkey) continue
if (followingsOfFollowings[index].includes(pubkey)) {
@@ -35,7 +85,7 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
setFollowedBy(_followedBy)
}
init()
}, [pubkey, accountPubkey])
}, [pubkey, accountPubkey, isSmallScreen])
if (followedBy.length === 0) return null

View 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>
)
}

View File

@@ -342,6 +342,27 @@ const SearchBar = forwardRef<
onKeyDown={handleKeyDown}
onFocus={() => setSearching(true)}
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>
)

View File

@@ -1,11 +1,17 @@
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 QrScannerModal from '../QrScannerModal'
const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
({ value, onChange, className, ...props }, ref) => {
type SearchInputProps = ComponentProps<'input'> & {
onQrScan?: (value: string) => void
}
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
({ value, onChange, className, onQrScan, ...props }, ref) => {
const [displayClear, setDisplayClear] = useState(false)
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
const [showQrScanner, setShowQrScanner] = useState(false)
useEffect(() => {
setDisplayClear(!!value)
@@ -20,34 +26,55 @@ 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 (
<div
tabIndex={0}
className={cn(
'flex h-9 w-full items-center rounded-xl border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-all duration-200 md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-2 [&:has(:focus-visible)]:outline-none hover:border-ring/50',
className
<>
<div
tabIndex={0}
className={cn(
'flex h-9 w-full items-center rounded-xl border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-all duration-200 md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-2 [&:has(:focus-visible)]:outline-none hover:border-ring/50',
className
)}
>
<SearchIcon className="size-4 shrink-0 opacity-50" onClick={() => inputRef?.focus()} />
<input
{...props}
name="search-input"
ref={setRefs}
value={value}
onChange={onChange}
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 && (
<button
type="button"
className="rounded-full bg-foreground/40 hover:bg-foreground transition-opacity size-5 shrink-0 flex flex-col items-center justify-center"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChange?.({ target: { value: '' } } as any)}
>
<X className="!size-3 shrink-0 text-background" strokeWidth={4} />
</button>
)}
</div>
{showQrScanner && (
<QrScannerModal onScan={handleQrScan} onClose={() => setShowQrScanner(false)} />
)}
>
<SearchIcon className="size-4 shrink-0 opacity-50" onClick={() => inputRef?.focus()} />
<input
{...props}
name="search-input"
ref={setRefs}
value={value}
onChange={onChange}
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
/>
{displayClear && (
<button
type="button"
className="rounded-full bg-foreground/40 hover:bg-foreground transition-opacity size-5 shrink-0 flex flex-col items-center justify-center"
onMouseDown={(e) => e.preventDefault()}
onClick={() => onChange?.({ target: { value: '' } } as any)}
>
<X className="!size-3 shrink-0 text-background" strokeWidth={4} />
</button>
)}
</div>
</>
)
}
)

View File

@@ -1,10 +1,12 @@
import AboutInfoDialog from '@/components/AboutInfoDialog'
import QrScannerModal from '@/components/QrScannerModal'
import Donation from '@/components/Donation'
import Emoji from '@/components/Emoji'
import EmojiPackList from '@/components/EmojiPackList'
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
import MailboxSetting from '@/components/MailboxSetting'
import NRCSettings from '@/components/NRCSettings'
import NoteList from '@/components/NoteList'
import Tabs from '@/components/Tabs'
import {
@@ -54,7 +56,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
import { useZap } from '@/providers/ZapProvider'
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
import { connectNWC, disconnect, launchModal } from '@getalby/bitcoin-connect-react'
import {
Check,
Cog,
@@ -71,6 +73,8 @@ import {
PanelLeft,
PencilLine,
RotateCcw,
ScanLine,
RefreshCw,
Server,
Settings2,
Smile,
@@ -80,7 +84,8 @@ import {
import { kinds } from 'nostr-tools'
import { forwardRef, HTMLProps, useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { useKeyboardNavigation, useNavigationRegion, NavigationIntent } from '@/providers/KeyboardNavigationProvider'
import { usePrimaryPage } from '@/PageManager'
type TEmojiTab = 'my-packs' | 'explore'
@@ -102,7 +107,7 @@ const NOTIFICATION_STYLES = [
] as const
// Accordion item values for keyboard navigation
const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system']
const ACCORDION_ITEMS = ['general', 'appearance', 'relays', 'sync', 'wallet', 'posts', 'emoji-packs', 'messaging', 'system']
export default function Settings() {
const { t, i18n } = useTranslation()
@@ -114,57 +119,77 @@ export default function Settings() {
const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1)
const accordionRefs = useRef<(HTMLDivElement | null)[]>([])
const { activeColumn, registerSettingsHandlers, unregisterSettingsHandlers } = useKeyboardNavigation()
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) => !['wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
: ACCORDION_ITEMS.filter((item) => !['sync', 'wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
// Register keyboard handlers for settings page navigation
// 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)
return
}
const handlers = {
onUp: () => {
setSelectedAccordionIndex((prev) => {
const newIndex = prev <= 0 ? 0 : prev - 1
setTimeout(() => {
accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 0)
return newIndex
})
},
onDown: () => {
setSelectedAccordionIndex((prev) => {
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1)
setTimeout(() => {
accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
}, 0)
return newIndex
})
},
onEnter: () => {
if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) {
const value = visibleAccordionItems[selectedAccordionIndex]
setOpenSection((prev) => (prev === value ? '' : value))
}
},
onEscape: () => {
if (openSection) {
setOpenSection('')
return true
}
return false
}
}
registerSettingsHandlers(handlers)
return () => unregisterSettingsHandlers()
}, [activeColumn, selectedAccordionIndex, openSection, visibleAccordionItems])
}, [activeColumn])
// Helper to get accordion index and check selection
const getAccordionIndex = useCallback(
@@ -231,10 +256,21 @@ export default function Settings() {
// System settings
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) => {
i18n.changeLanguage(value)
setLanguage(value)
@@ -514,6 +550,23 @@ export default function Settings() {
</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 */}
{!!pubkey && (
<NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
@@ -559,11 +612,27 @@ export default function Settings() {
<LightningAddressInput />
</>
) : (
<div className="flex items-center gap-2">
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
{t('Connect Wallet')}
</Button>
</div>
<>
{showWalletScanner && (
<QrScannerModal
onScan={handleWalletScan}
onClose={() => setShowWalletScanner(false)}
/>
)}
<div className="flex items-center gap-2">
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
{t('Connect Wallet')}
</Button>
<Button
variant="outline"
size="icon"
onClick={() => setShowWalletScanner(true)}
title={t('Scan NWC QR code')}
>
<ScanLine className="h-4 w-4" />
</Button>
</div>
</>
)}
</AccordionContent>
</AccordionItem>
@@ -689,6 +758,25 @@ export default function Settings() {
}}
/>
</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>
</AccordionItem>
</NavigableAccordionItem>

View 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>
)
}

View File

@@ -12,6 +12,7 @@ import BookmarkButton from './BookmarkButton'
import HelpButton from './HelpButton'
import HomeButton from './HomeButton'
import InboxButton from './InboxButton'
import KeyboardModeButton from './KeyboardModeButton'
import LayoutSwitcher from './LayoutSwitcher'
import NotificationsButton from './NotificationButton'
import PostButton from './PostButton'
@@ -67,6 +68,7 @@ export default function PrimaryPageSidebar() {
</div>
<div className="space-y-4">
<HelpButton collapse={isCollapsed} navIndex={pubkey ? 8 : 6} />
<KeyboardModeButton collapse={isCollapsed} />
<LayoutSwitcher collapse={isCollapsed} />
<AccountButton collapse={isCollapsed} />
</div>

View File

@@ -0,0 +1,127 @@
import { Button } from '@/components/ui/button'
import { Label } from '@/components/ui/label'
import { Switch } from '@/components/ui/switch'
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
import { Loader2, Minus, Plus } from 'lucide-react'
import { useTranslation } from 'react-i18next'
const DEPTH_LABELS: Record<number, string> = {
1: 'Direct follows',
2: 'Follows of follows'
}
interface SocialGraphFilterProps {
temporaryProximity: number | null
temporaryIncludeMode: boolean
onTemporaryProximityChange: (level: number | null) => void
onTemporaryIncludeModeChange: (include: boolean) => void
}
export default function SocialGraphFilter({
temporaryProximity,
temporaryIncludeMode,
onTemporaryProximityChange,
onTemporaryIncludeModeChange
}: SocialGraphFilterProps) {
const { t } = useTranslation()
const { graphPubkeyCount, isLoading } = useSocialGraphFilter()
const isEnabled = temporaryProximity !== null
const depth = temporaryProximity ?? 1
const handleToggle = (enabled: boolean) => {
onTemporaryProximityChange(enabled ? 1 : null)
}
const handleIncrease = () => {
if (depth < 2) {
onTemporaryProximityChange(depth + 1)
}
}
const handleDecrease = () => {
if (depth > 1) {
onTemporaryProximityChange(depth - 1)
}
}
return (
<div className="space-y-3">
<div className="flex items-center justify-between">
<Label htmlFor="social-graph-filter" className="font-medium">
{t('Social graph filter')}
</Label>
<Switch id="social-graph-filter" checked={isEnabled} onCheckedChange={handleToggle} />
</div>
{isEnabled && (
<>
{/* Include/Exclude toggle */}
<div className="flex items-center gap-2">
<Button
variant={temporaryIncludeMode ? 'default' : 'outline'}
size="sm"
className="flex-1"
onClick={() => onTemporaryIncludeModeChange(true)}
>
{t('Include')}
</Button>
<Button
variant={!temporaryIncludeMode ? 'default' : 'outline'}
size="sm"
className="flex-1"
onClick={() => onTemporaryIncludeModeChange(false)}
>
{t('Exclude')}
</Button>
</div>
{/* Depth stepper */}
<div className="flex items-center justify-between rounded-lg border px-3 py-2">
<div className="flex-1">
<p className="text-sm font-medium">{t(DEPTH_LABELS[depth])}</p>
<p className="text-xs text-muted-foreground">
{isLoading ? (
<span className="flex items-center gap-1">
<Loader2 className="h-3 w-3 animate-spin" />
{t('Loading...')}
</span>
) : (
t('{{count}} users', { count: graphPubkeyCount })
)}
</p>
</div>
<div className="flex items-center gap-1">
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleDecrease}
disabled={depth <= 1}
>
<Minus className="h-4 w-4" />
</Button>
<span className="w-6 text-center text-sm font-medium">{depth}</span>
<Button
variant="outline"
size="icon"
className="h-8 w-8"
onClick={handleIncrease}
disabled={depth >= 2}
>
<Plus className="h-4 w-4" />
</Button>
</div>
</div>
{/* Mode description */}
<p className="text-xs text-muted-foreground">
{temporaryIncludeMode
? t('Only show notes from users in your social graph')
: t('Hide notes from users in your social graph')}
</p>
</>
)}
</div>
)
}

View File

@@ -0,0 +1,13 @@
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
export default function KeyboardShortcut({ shortcut }: { shortcut: string }) {
const { isEnabled } = useKeyboardNavigation()
if (!isEnabled) return null
return (
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">
{shortcut}
</kbd>
)
}

View File

@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
import Emoji from '../Emoji'
import EmojiPicker from '../EmojiPicker'
import SuggestedEmojis from '../SuggestedEmojis'
import KeyboardShortcut from './KeyboardShortcut'
import { formatCount } from './utils'
export default function LikeButton({ stuff }: { stuff: Event | string }) {
@@ -111,8 +112,8 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
const trigger = (
<button
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground"
title={t('Like')}
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground group"
title={t('React (Shift+R)')}
disabled={liking}
data-action="react"
onClick={handleClick}
@@ -126,12 +127,18 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
<Loader className="animate-spin" />
) : myLastEmoji ? (
<>
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
<span className="relative">
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
<KeyboardShortcut shortcut="R" />
</span>
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</>
) : (
<>
<SmilePlus />
<span className="relative">
<SmilePlus />
<KeyboardShortcut shortcut="R" />
</span>
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</>
)}

View File

@@ -11,6 +11,7 @@ import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import KeyboardShortcut from './KeyboardShortcut'
import { formatCount } from './utils'
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
@@ -56,7 +57,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
<>
<button
className={cn(
'flex gap-1 items-center enabled:hover:text-blue-400 pr-3 h-full',
'flex gap-1 items-center enabled:hover:text-blue-400 pr-3 h-full group',
hasReplied ? 'text-blue-400' : 'text-muted-foreground'
)}
onClick={(e) => {
@@ -65,10 +66,13 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
setOpen(true)
})
}}
title={t('Reply')}
title={t('Reply (r)')}
data-action="reply"
>
<MessageCircle />
<span className="relative">
<MessageCircle />
<KeyboardShortcut shortcut="r" />
</span>
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
</button>
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />

View File

@@ -20,6 +20,7 @@ import { Event } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import PostEditor from '../PostEditor'
import KeyboardShortcut from './KeyboardShortcut'
import { formatCount } from './utils'
export default function RepostButton({ stuff }: { stuff: Event | string }) {
@@ -77,11 +78,11 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
const trigger = (
<button
className={cn(
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40 group',
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
)}
disabled={!event}
title={t('Repost')}
title={t('Repost (p) / Quote (q)')}
data-action="repost"
onClick={() => {
if (!event) return
@@ -91,7 +92,10 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
}
}}
>
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
<span className="relative">
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
<KeyboardShortcut shortcut="p" />
</span>
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
</button>
)
@@ -108,10 +112,25 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
/>
)
// Hidden button for keyboard shortcut (q for quote)
const quoteButton = (
<button
className="hidden"
data-action="quote"
onClick={(e) => {
e.stopPropagation()
checkLogin(() => {
setIsPostDialogOpen(true)
})
}}
/>
)
if (isSmallScreen) {
return (
<>
{trigger}
{quoteButton}
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
<DrawerContent hideOverlay>
@@ -170,12 +189,12 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
setIsPostDialogOpen(true)
})
}}
data-action="quote"
>
<PencilLine /> {t('Quote')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{quoteButton}
{postEditor}
</>
)

View File

@@ -14,6 +14,7 @@ import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 're
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import ZapDialog from '../ZapDialog'
import KeyboardShortcut from './KeyboardShortcut'
export default function ZapButton({ stuff }: { stuff: Event | string }) {
const { t } = useTranslation()
@@ -135,10 +136,10 @@ export default function ZapButton({ stuff }: { stuff: Event | string }) {
<>
<button
className={cn(
'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default',
'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default group',
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
)}
title={t('Zap')}
title={t('Zap (z)')}
disabled={disable || zapping}
data-action="zap"
onMouseDown={handleClickStart}
@@ -147,11 +148,14 @@ export default function ZapButton({ stuff }: { stuff: Event | string }) {
onTouchStart={handleClickStart}
onTouchEnd={handleClickEnd}
>
{zapping ? (
<Loader className="animate-spin" />
) : (
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
)}
<span className="relative">
{zapping ? (
<Loader className="animate-spin" />
) : (
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
)}
<KeyboardShortcut shortcut="z" />
</span>
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
</button>
{event && (

View File

@@ -1,280 +0,0 @@
import { Button } from '@/components/ui/button'
import {
Card,
CardContent,
CardDescription,
CardFooter,
CardHeader,
CardTitle
} from '@/components/ui/card'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
import { useNostr } from '@/providers/NostrProvider'
import { Clock, Copy, Key, RefreshCw, Shield } from 'lucide-react'
import { useCallback, useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import QrCode from '../QrCode'
import dayjs from 'dayjs'
import relativeTime from 'dayjs/plugin/relativeTime'
import * as utils from '@noble/curves/abstract/utils'
dayjs.extend(relativeTime)
interface TokenDisplayProps {
bunkerPubkey: string
mintUrl: string
}
export default function TokenDisplay({ bunkerPubkey, mintUrl }: TokenDisplayProps) {
const { t } = useTranslation()
const { signHttpAuth, pubkey } = useNostr()
const [currentToken, setCurrentToken] = useState<TCashuToken | null>(null)
const [nextToken, setNextToken] = useState<TCashuToken | null>(null)
const [loading, setLoading] = useState(false)
const [refreshing, setRefreshing] = useState(false)
// Load tokens on mount
useEffect(() => {
const stored = cashuTokenService.loadTokens(bunkerPubkey)
if (stored) {
setCurrentToken(stored.current || null)
setNextToken(stored.next || null)
}
}, [bunkerPubkey])
// Request a new token
const requestToken = useCallback(async () => {
if (!pubkey) {
toast.error(t('You must be logged in to request a token'))
return
}
setLoading(true)
try {
cashuTokenService.setMint(mintUrl)
await cashuTokenService.fetchMintInfo()
const userPubkey = utils.hexToBytes(pubkey)
const token = await cashuTokenService.requestToken(
TokenScope.NIP46,
userPubkey,
signHttpAuth,
[24133] // NIP-46 kind
)
// Store the token
if (currentToken && cashuTokenService.verifyToken(currentToken)) {
// Current still valid, store new as next
cashuTokenService.storeTokens(bunkerPubkey, currentToken, token)
setNextToken(token)
} else {
// Current expired or missing, use new as current
cashuTokenService.storeTokens(bunkerPubkey, token)
setCurrentToken(token)
setNextToken(null)
}
toast.success(t('Token obtained successfully'))
} catch (err) {
toast.error(t('Failed to get token') + ': ' + (err as Error).message)
} finally {
setLoading(false)
}
}, [bunkerPubkey, mintUrl, pubkey, signHttpAuth, currentToken, t])
// Refresh tokens (promote next to current if needed)
const refreshTokens = useCallback(async () => {
if (!pubkey) return
setRefreshing(true)
try {
// Check if current needs refresh
if (currentToken && cashuTokenService.needsRefresh(currentToken)) {
// Request a new token as next
if (!nextToken) {
await requestToken()
}
}
// Promote next to current if current expired
const now = Date.now() / 1000
if (currentToken && currentToken.expiry <= now && nextToken) {
cashuTokenService.storeTokens(bunkerPubkey, nextToken)
setCurrentToken(nextToken)
setNextToken(null)
toast.info(t('Token rotated'))
}
} finally {
setRefreshing(false)
}
}, [bunkerPubkey, currentToken, nextToken, pubkey, requestToken, t])
// Copy token to clipboard
const copyToken = useCallback(
(token: TCashuToken) => {
const encoded = cashuTokenService.encodeToken(token)
navigator.clipboard.writeText(encoded)
toast.success(t('Token copied to clipboard'))
},
[t]
)
// Format expiry time
const formatExpiry = (expiry: number) => {
const date = dayjs.unix(expiry)
const now = dayjs()
if (date.isBefore(now)) {
return t('Expired')
}
return date.fromNow()
}
// Check if token is expired
const isExpired = (token: TCashuToken) => {
return token.expiry < Date.now() / 1000
}
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Shield className="h-5 w-5" />
{t('Access Tokens')}
</CardTitle>
<CardDescription>
{t('Cashu tokens for authenticated bunker access')}
</CardDescription>
</CardHeader>
<CardContent>
{!currentToken && !nextToken ? (
<div className="text-center py-8">
<Key className="h-12 w-12 mx-auto text-muted-foreground mb-4" />
<p className="text-muted-foreground mb-4">
{t('No tokens available. Request one to enable bunker access.')}
</p>
<Button onClick={requestToken} disabled={loading}>
{loading ? t('Requesting...') : t('Request Token')}
</Button>
</div>
) : (
<Tabs defaultValue="current" className="w-full">
<TabsList className="grid w-full grid-cols-2">
<TabsTrigger value="current" className="relative">
{t('Current')}
{currentToken && isExpired(currentToken) && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-destructive" />
)}
</TabsTrigger>
<TabsTrigger value="next">
{t('Next')}
{nextToken && (
<span className="absolute top-1 right-1 h-2 w-2 rounded-full bg-green-500" />
)}
</TabsTrigger>
</TabsList>
<TabsContent value="current" className="space-y-4">
{currentToken ? (
<TokenCard
token={currentToken}
formatExpiry={formatExpiry}
isExpired={isExpired(currentToken)}
onCopy={() => copyToken(currentToken)}
/>
) : (
<div className="text-center py-4 text-muted-foreground">
{t('No current token')}
</div>
)}
</TabsContent>
<TabsContent value="next" className="space-y-4">
{nextToken ? (
<TokenCard
token={nextToken}
formatExpiry={formatExpiry}
isExpired={isExpired(nextToken)}
onCopy={() => copyToken(nextToken)}
/>
) : (
<div className="text-center py-4 text-muted-foreground">
{t('No pending token. One will be requested before current expires.')}
</div>
)}
</TabsContent>
</Tabs>
)}
</CardContent>
<CardFooter className="flex gap-2">
<Button variant="outline" onClick={refreshTokens} disabled={refreshing} className="flex-1">
<RefreshCw className={`h-4 w-4 mr-2 ${refreshing ? 'animate-spin' : ''}`} />
{t('Refresh')}
</Button>
<Button onClick={requestToken} disabled={loading} className="flex-1">
{loading ? t('Requesting...') : t('Request New Token')}
</Button>
</CardFooter>
</Card>
)
}
// Individual token display card
function TokenCard({
token,
formatExpiry,
isExpired,
onCopy
}: {
token: TCashuToken
formatExpiry: (expiry: number) => string
isExpired: boolean
onCopy: () => void
}) {
const { t } = useTranslation()
const encoded = cashuTokenService.encodeToken(token)
return (
<div className="space-y-4">
<div className="flex justify-center">
<QrCode value={encoded} size={200} />
</div>
<div className="space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('Scope')}</span>
<span className="font-mono">{token.scope}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('Keyset')}</span>
<span className="font-mono text-xs">{token.keysetId}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground flex items-center gap-1">
<Clock className="h-3 w-3" />
{t('Expires')}
</span>
<span className={isExpired ? 'text-destructive' : 'text-green-600'}>
{formatExpiry(token.expiry)}
</span>
</div>
{token.kinds && token.kinds.length > 0 && (
<div className="flex items-center justify-between">
<span className="text-muted-foreground">{t('Kinds')}</span>
<span className="font-mono text-xs">{token.kinds.join(', ')}</span>
</div>
)}
</div>
<Button variant="outline" onClick={onCopy} className="w-full">
<Copy className="h-4 w-4 mr-2" />
{t('Copy Token')}
</Button>
</div>
)
}

View File

@@ -4,22 +4,25 @@ import { useFetchProfile } from '@/hooks'
import { toProfile } from '@/lib/link'
import { cn, isTouchDevice } from '@/lib/utils'
import { SecondaryPageLink } from '@/PageManager'
import { useMemo } from 'react'
import NpubQrCode from '../NpubQrCode'
import ProfileCard from '../ProfileCard'
import TextWithEmojis from '../TextWithEmojis'
import { useMemo } from 'react'
export default function Username({
userId,
showAt = false,
className,
skeletonClassName,
withoutSkeleton = false
withoutSkeleton = false,
showQrCode = true
}: {
userId: string
showAt?: boolean
className?: string
skeletonClassName?: string
withoutSkeleton?: boolean
showQrCode?: boolean
}) {
const { profile, isFetching } = useFetchProfile(userId)
const supportTouch = useMemo(() => isTouchDevice(), [])
@@ -32,16 +35,21 @@ export default function Username({
}
if (!profile) return null
const usernameLink = (
<SecondaryPageLink
to={toProfile(userId)}
className="truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{showAt && '@'}
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
</SecondaryPageLink>
)
const trigger = (
<div className={className}>
<SecondaryPageLink
to={toProfile(userId)}
className="truncate hover:underline"
onClick={(e) => e.stopPropagation()}
>
{showAt && '@'}
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
</SecondaryPageLink>
<div className={cn('flex items-center gap-1', className)}>
{usernameLink}
{showQrCode && <NpubQrCode pubkey={userId} />}
</div>
)
@@ -64,13 +72,15 @@ export function SimpleUsername({
showAt = false,
className,
skeletonClassName,
withoutSkeleton = false
withoutSkeleton = false,
showQrCode = true
}: {
userId: string
showAt?: boolean
className?: string
skeletonClassName?: string
withoutSkeleton?: boolean
showQrCode?: boolean
}) {
const { profile, isFetching } = useFetchProfile(userId)
if (!profile && isFetching && !withoutSkeleton) {
@@ -85,9 +95,12 @@ export function SimpleUsername({
const { username, emojis } = profile
return (
<div className={className}>
{showAt && '@'}
<TextWithEmojis text={username} emojis={emojis} emojiClassName="mb-1" />
<div className={cn('flex items-center gap-1', className)}>
<span className="truncate">
{showAt && '@'}
<TextWithEmojis text={username} emojis={emojis} emojiClassName="mb-1" />
</span>
{showQrCode && <NpubQrCode pubkey={userId} />}
</div>
)
}

View File

@@ -47,6 +47,10 @@ export const StorageKey = {
DM_CONVERSATION_FILTER: 'dmConversationFilter',
DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
GRAPH_QUERIES_ENABLED: 'graphQueriesEnabled',
SOCIAL_GRAPH_PROXIMITY: 'socialGraphProximity',
SOCIAL_GRAPH_INCLUDE_MODE: 'socialGraphIncludeMode',
NRC_ONLY_CONFIG_SYNC: 'nrcOnlyConfigSync',
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated

View File

@@ -0,0 +1,144 @@
import { BIG_RELAY_URLS } from '@/constants'
import client from '@/services/client.service'
import graphQueryService from '@/services/graph-query.service'
import { useEffect, useState } from 'react'
interface FollowGraphResult {
/** Pubkeys by depth (index 0 = direct follows, index 1 = follows of follows, etc.) */
pubkeysByDepth: string[][]
/** Whether graph query was used (vs traditional method) */
usedGraphQuery: boolean
/** Loading state */
isLoading: boolean
/** Error if any */
error: Error | null
}
/**
* Hook for fetching follow graph with automatic graph query optimization.
* Falls back to traditional method when graph queries are not available.
*
* @param pubkey - The seed pubkey to fetch follows for
* @param depth - How many levels deep to fetch (1 = direct follows, 2 = + follows of follows)
* @param relayUrls - Optional relay URLs to try for graph queries
*/
export function useFetchFollowGraph(
pubkey: string | null,
depth: number = 1,
relayUrls?: string[]
): FollowGraphResult {
const [pubkeysByDepth, setPubkeysByDepth] = useState<string[][]>([])
const [usedGraphQuery, setUsedGraphQuery] = useState(false)
const [isLoading, setIsLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
if (!pubkey) {
setPubkeysByDepth([])
setIsLoading(false)
return
}
const fetchFollowGraph = async () => {
setIsLoading(true)
setError(null)
setUsedGraphQuery(false)
const urls = relayUrls ?? BIG_RELAY_URLS
try {
// Try graph query first
const graphResult = await graphQueryService.queryFollowGraph(urls, pubkey, depth)
if (graphResult?.pubkeys_by_depth?.length) {
setPubkeysByDepth(graphResult.pubkeys_by_depth)
setUsedGraphQuery(true)
setIsLoading(false)
return
}
// Fallback to traditional method
const result = await fetchTraditionalFollowGraph(pubkey, depth)
setPubkeysByDepth(result)
} catch (e) {
console.error('Failed to fetch follow graph:', e)
setError(e instanceof Error ? e : new Error('Unknown error'))
} finally {
setIsLoading(false)
}
}
fetchFollowGraph()
}, [pubkey, depth, relayUrls?.join(',')])
return { pubkeysByDepth, usedGraphQuery, isLoading, error }
}
/**
* Traditional method for fetching follow graph (used as fallback)
*/
async function fetchTraditionalFollowGraph(pubkey: string, depth: number): Promise<string[][]> {
const result: string[][] = []
// Depth 1: Direct follows
const directFollows = await client.fetchFollowings(pubkey)
result.push(directFollows)
if (depth < 2 || directFollows.length === 0) {
return result
}
// Depth 2: Follows of follows
// Note: This is expensive - N queries for N follows
const followsOfFollows = new Set<string>()
const directFollowsSet = new Set(directFollows)
directFollowsSet.add(pubkey) // Exclude self
// Fetch in batches to avoid overwhelming relays
const batchSize = 10
for (let i = 0; i < directFollows.length; i += batchSize) {
const batch = directFollows.slice(i, i + batchSize)
const batchResults = await Promise.all(batch.map((pk) => client.fetchFollowings(pk)))
for (const follows of batchResults) {
for (const pk of follows) {
if (!directFollowsSet.has(pk)) {
followsOfFollows.add(pk)
}
}
}
}
result.push(Array.from(followsOfFollows))
return result
}
/**
* Get all pubkeys from a follow graph result as a flat array
*/
export function flattenFollowGraph(pubkeysByDepth: string[][]): string[] {
return pubkeysByDepth.flat()
}
/**
* Check if a pubkey is in the follow graph at a specific depth
*/
export function isPubkeyAtDepth(
pubkeysByDepth: string[][],
pubkey: string,
depth: number
): boolean {
const depthIndex = depth - 1
if (depthIndex < 0 || depthIndex >= pubkeysByDepth.length) {
return false
}
return pubkeysByDepth[depthIndex].includes(pubkey)
}
/**
* Check if a pubkey is anywhere in the follow graph
*/
export function isPubkeyInGraph(pubkeysByDepth: string[][], pubkey: string): boolean {
return pubkeysByDepth.some((depth) => depth.includes(pubkey))
}

View File

@@ -62,6 +62,7 @@ const MePage = forwardRef<TPageRef>((_, ref) => {
className="text-xl font-semibold text-wrap"
userId={pubkey}
skeletonClassName="h-6 w-32"
showQrCode={false}
/>
<div className="flex gap-1 mt-1">
<PubkeyCopy pubkey={pubkey} />

View File

@@ -28,5 +28,5 @@ export default function PinnedFeed() {
init()
}, [pubkey, pinnedPubkeySet])
return <NormalFeed subRequests={subRequests} isMainFeed />
return <NormalFeed subRequests={subRequests} isMainFeed enableSocialGraphFilter />
}

View File

@@ -28,6 +28,7 @@ export default function RelaysFeed() {
areAlgoRelays={areAlgoRelays}
isMainFeed
showRelayCloseReason
enableSocialGraphFilter
/>
)
}

View File

@@ -1,22 +1,51 @@
import MessageView from '@/components/Inbox/MessageView'
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
import UserAvatar from '@/components/UserAvatar'
import { Button } from '@/components/ui/button'
import { Titlebar } from '@/components/Titlebar'
import { useSecondaryPage } from '@/PageManager'
import { useDM } from '@/providers/DMProvider'
import { TPageRef } from '@/types'
import { useFollowList } from '@/providers/FollowListProvider'
import client from '@/services/client.service'
import { TPageRef, TProfile } from '@/types'
import { ChevronLeft, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
import { nip19 } from 'nostr-tools'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { cn } from '@/lib/utils'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger
} from '@/components/ui/dropdown-menu'
import ConversationSettingsModal from '@/components/Inbox/ConversationSettingsModal'
import indexedDb from '@/services/indexed-db.service'
import { useNostr } from '@/providers/NostrProvider'
interface DMConversationPageProps {
pubkey?: string
index?: number
}
const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubkey, index }, ref) => {
const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubkey }, ref) => {
const { t } = useTranslation()
const layoutRef = useRef<TPageRef>(null)
const { selectConversation, currentConversation } = useDM()
const { pubkey: userPubkey } = useNostr()
const {
selectConversation,
currentConversation,
isLoadingConversation,
isNewConversation,
clearNewConversationFlag,
reloadConversation,
deleteAllInConversation,
undeleteAllInConversation
} = useDM()
const { pop } = useSecondaryPage()
const { followingSet } = useFollowList()
const [profile, setProfile] = useState<TProfile | null>(null)
const [settingsOpen, setSettingsOpen] = useState(false)
const [selectedRelays, setSelectedRelays] = useState<string[]>([])
const [showPulse, setShowPulse] = useState(false)
// Decode npub to hex if needed
const hexPubkey = useMemo(() => {
@@ -32,6 +61,8 @@ const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubk
return pubkey
}, [pubkey])
const isFollowing = hexPubkey ? followingSet.has(hexPubkey) : false
useImperativeHandle(ref, () => layoutRef.current as TPageRef)
// Select the conversation when this page mounts
@@ -48,17 +79,161 @@ const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubk
}
}, [])
// Fetch profile
useEffect(() => {
if (!hexPubkey) return
const fetchProfileData = async () => {
try {
const profileData = await client.fetchProfile(hexPubkey)
if (profileData) {
setProfile(profileData)
}
} catch (error) {
console.error('Failed to fetch profile:', error)
}
}
fetchProfileData()
}, [hexPubkey])
// Handle pulsing animation for new conversations
useEffect(() => {
if (isNewConversation) {
setShowPulse(true)
const timer = setTimeout(() => {
setShowPulse(false)
clearNewConversationFlag()
}, 10000)
return () => clearTimeout(timer)
}
}, [isNewConversation, clearNewConversationFlag])
// Load saved relay settings when conversation changes
useEffect(() => {
if (!hexPubkey || !userPubkey) return
const loadRelaySettings = async () => {
const saved = await indexedDb.getConversationRelaySettings(userPubkey, hexPubkey)
setSelectedRelays(saved || [])
}
loadRelaySettings()
}, [hexPubkey, userPubkey])
// Save relay settings when they change
const handleRelaysChange = async (relays: string[]) => {
setSelectedRelays(relays)
if (userPubkey && hexPubkey) {
await indexedDb.putConversationRelaySettings(userPubkey, hexPubkey, relays)
}
}
const handleBack = () => {
selectConversation(null)
pop()
}
const displayName = profile?.username || (hexPubkey ? hexPubkey.slice(0, 8) + '...' : '')
// Custom titlebar with user info
const titlebar = (
<div className="flex items-center gap-2 w-full px-1">
<Button
className="flex gap-1 items-center justify-start pl-2 pr-1"
variant="ghost"
size="titlebar-icon"
title={t('back')}
onClick={handleBack}
>
<ChevronLeft />
</Button>
{hexPubkey && (
<>
<UserAvatar userId={hexPubkey} className="size-7" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-semibold 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 block">{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>
<Button
variant="ghost"
size="icon"
className="size-8"
title={t('Close conversation')}
onClick={handleBack}
>
<X className="size-4" />
</Button>
</>
)}
</div>
)
return (
<SecondaryPageLayout ref={layoutRef} index={index} title={t('Conversation')}>
<div className="h-full">
<MessageView onBack={handleBack} />
<>
<Titlebar className="p-1" hideBottomBorder={false}>
{titlebar}
</Titlebar>
<div className="h-[calc(100%-3rem)]">
<MessageView hideHeader />
</div>
</SecondaryPageLayout>
{hexPubkey && (
<ConversationSettingsModal
partnerPubkey={hexPubkey}
open={settingsOpen}
onOpenChange={setSettingsOpen}
selectedRelays={selectedRelays}
onSelectedRelaysChange={handleRelaysChange}
/>
)}
</>
)
})

View File

@@ -33,6 +33,7 @@ type TDMContext = {
setPreferNip44: (prefer: boolean) => void
isNewConversation: boolean
clearNewConversationFlag: () => void
dismissProvisionalConversation: () => void
// Unread tracking
totalUnreadCount: number
hasNewMessages: boolean
@@ -85,6 +86,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
const [hasMoreConversations, setHasMoreConversations] = useState(false)
const [isNewConversation, setIsNewConversation] = useState(false)
const [provisionalPubkey, setProvisionalPubkey] = useState<string | null>(null)
const [deletedState, setDeletedState] = useState<TDMDeletedState | null>(null)
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
const [isSelectionMode, setIsSelectionMode] = useState(false)
@@ -577,6 +579,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
)
// Start a new conversation - marks it as new for UI effects (pulsing settings button)
// Creates a provisional conversation that appears in the list immediately
const startConversation = useCallback(
(partnerPubkey: string) => {
// Check if this is a new conversation (not in existing list)
@@ -585,6 +588,18 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
)
if (!existingConversation) {
setIsNewConversation(true)
setProvisionalPubkey(partnerPubkey)
// Add a provisional conversation to the list so it appears immediately
const provisionalConversation: TConversation = {
partnerPubkey,
lastMessageAt: Math.floor(Date.now() / 1000),
lastMessagePreview: '',
unreadCount: 0,
preferredEncryption: null
}
// Add to front of both lists
setAllConversations((prev) => [provisionalConversation, ...prev])
setConversations((prev) => [provisionalConversation, ...prev])
}
// Clear messages and select the conversation
setMessages([])
@@ -597,6 +612,25 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
setIsNewConversation(false)
}, [])
// Dismiss a provisional conversation (remove from list without sending any messages)
const dismissProvisionalConversation = useCallback(() => {
if (!provisionalPubkey) return
// Remove from conversation lists
setAllConversations((prev) => prev.filter((c) => c.partnerPubkey !== provisionalPubkey))
setConversations((prev) => prev.filter((c) => c.partnerPubkey !== provisionalPubkey))
// Clear provisional state
setProvisionalPubkey(null)
setIsNewConversation(false)
// Deselect if this was the current conversation
if (currentConversation === provisionalPubkey) {
setCurrentConversation(null)
setMessages([])
}
}, [provisionalPubkey, currentConversation])
// Reload the current conversation by clearing its cached state
const reloadConversation = useCallback(() => {
if (!currentConversation) return
@@ -708,8 +742,14 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
]
}
})
// Clear provisional state - conversation is now permanent
if (provisionalPubkey === currentConversation) {
setProvisionalPubkey(null)
setIsNewConversation(false)
}
},
[pubkey, encryption, currentConversation, relayList, conversations, preferNip44]
[pubkey, encryption, currentConversation, relayList, conversations, preferNip44, provisionalPubkey]
)
const setPreferNip44 = useCallback((prefer: boolean) => {
@@ -927,6 +967,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
setPreferNip44,
isNewConversation,
clearNewConversationFlag,
dismissProvisionalConversation,
// Unread tracking
totalUnreadCount,
hasNewMessages,

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,653 @@
/**
* NRC (Nostr Relay Connect) Provider
*
* Manages NRC state for both:
* - Listener mode: Accept connections from other devices
* - Client mode: Connect to and sync from other devices
*/
import { createContext, useContext, useState, useEffect, useCallback, ReactNode } from 'react'
import { Filter, Event } from 'nostr-tools'
import { useNostr } from './NostrProvider'
import client from '@/services/client.service'
import indexedDb from '@/services/indexed-db.service'
import {
NRCConnection,
NRCListenerConfig,
generateConnectionURI,
getNRCListenerService,
syncFromRemote,
testConnection,
parseConnectionURI,
requestRemoteIDs,
sendEventsToRemote,
EventManifestEntry
} from '@/services/nrc'
import type { SyncProgress, RemoteConnection } from '@/services/nrc'
// Kinds to sync bidirectionally
const SYNC_KINDS = [0, 3, 10000, 10001, 10002, 10003, 10012, 30002]
// Storage keys
const STORAGE_KEY_ENABLED = 'nrc:enabled'
const STORAGE_KEY_CONNECTIONS = 'nrc:connections'
const STORAGE_KEY_REMOTE_CONNECTIONS = 'nrc:remoteConnections'
const STORAGE_KEY_RENDEZVOUS_URL = 'nrc:rendezvousUrl'
// Default rendezvous relay
const DEFAULT_RENDEZVOUS_URL = 'wss://relay.damus.io'
interface NRCContextType {
// Listener State (this device accepts connections)
isEnabled: boolean
isListening: boolean
isConnected: boolean
connections: NRCConnection[] // Devices authorized to connect to us
activeSessions: number
rendezvousUrl: string
// Client State (this device connects to others)
remoteConnections: RemoteConnection[] // Devices we connect to
isSyncing: boolean
syncProgress: SyncProgress | null
// Listener Actions
enable: () => Promise<void>
disable: () => void
addConnection: (label: string) => Promise<{ uri: string; connection: NRCConnection }>
removeConnection: (id: string) => Promise<void>
getConnectionURI: (connection: NRCConnection) => string
setRendezvousUrl: (url: string) => void
// Client Actions
addRemoteConnection: (uri: string, label: string) => Promise<RemoteConnection>
removeRemoteConnection: (id: string) => Promise<void>
testRemoteConnection: (id: string) => Promise<boolean>
syncFromDevice: (id: string, filters?: Filter[]) => Promise<Event[]>
syncAllRemotes: (filters?: Filter[]) => Promise<Event[]>
}
const NRCContext = createContext<NRCContextType | undefined>(undefined)
export const useNRC = () => {
const context = useContext(NRCContext)
if (!context) {
throw new Error('useNRC must be used within an NRCProvider')
}
return context
}
interface NRCProviderProps {
children: ReactNode
}
export function NRCProvider({ children }: NRCProviderProps) {
const { pubkey } = useNostr()
// ===== Listener State =====
const [isEnabled, setIsEnabled] = useState<boolean>(() => {
const stored = localStorage.getItem(STORAGE_KEY_ENABLED)
return stored === 'true'
})
const [connections, setConnections] = useState<NRCConnection[]>(() => {
const stored = localStorage.getItem(STORAGE_KEY_CONNECTIONS)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return []
}
}
return []
})
const [rendezvousUrl, setRendezvousUrlState] = useState<string>(() => {
return localStorage.getItem(STORAGE_KEY_RENDEZVOUS_URL) || DEFAULT_RENDEZVOUS_URL
})
const [isListening, setIsListening] = useState(false)
const [isConnected, setIsConnected] = useState(false)
const [activeSessions, setActiveSessions] = useState(0)
// ===== Client State =====
const [remoteConnections, setRemoteConnections] = useState<RemoteConnection[]>(() => {
const stored = localStorage.getItem(STORAGE_KEY_REMOTE_CONNECTIONS)
if (stored) {
try {
return JSON.parse(stored)
} catch {
return []
}
}
return []
})
const [isSyncing, setIsSyncing] = useState(false)
const [syncProgress, setSyncProgress] = useState<SyncProgress | null>(null)
const listenerService = getNRCListenerService()
// ===== Persist State =====
useEffect(() => {
localStorage.setItem(STORAGE_KEY_ENABLED, String(isEnabled))
}, [isEnabled])
useEffect(() => {
localStorage.setItem(STORAGE_KEY_CONNECTIONS, JSON.stringify(connections))
}, [connections])
useEffect(() => {
localStorage.setItem(STORAGE_KEY_REMOTE_CONNECTIONS, JSON.stringify(remoteConnections))
}, [remoteConnections])
useEffect(() => {
localStorage.setItem(STORAGE_KEY_RENDEZVOUS_URL, rendezvousUrl)
}, [rendezvousUrl])
// ===== Listener Logic =====
const buildAuthorizedSecrets = useCallback((): Map<string, string> => {
const map = new Map<string, string>()
for (const conn of connections) {
if (conn.secret && conn.clientPubkey) {
map.set(conn.clientPubkey, conn.label)
}
}
return map
}, [connections])
useEffect(() => {
if (!isEnabled || !client.signer || !pubkey) {
if (listenerService.isRunning()) {
listenerService.stop()
setIsListening(false)
setIsConnected(false)
setActiveSessions(0)
}
return
}
// Stop existing listener before starting with new config
if (listenerService.isRunning()) {
listenerService.stop()
}
let statusInterval: ReturnType<typeof setInterval> | null = null
const startListener = async () => {
try {
const config: NRCListenerConfig = {
rendezvousUrl,
signer: client.signer!,
authorizedSecrets: buildAuthorizedSecrets()
}
console.log('[NRC] Starting listener with', config.authorizedSecrets.size, 'authorized clients')
listenerService.setOnSessionChange((count) => {
setActiveSessions(count)
})
await listenerService.start(config)
setIsListening(true)
setIsConnected(listenerService.isConnected())
statusInterval = setInterval(() => {
setIsConnected(listenerService.isConnected())
setActiveSessions(listenerService.getActiveSessionCount())
}, 5000)
} catch (error) {
console.error('[NRC] Failed to start listener:', error)
setIsListening(false)
setIsConnected(false)
}
}
startListener()
return () => {
if (statusInterval) {
clearInterval(statusInterval)
}
listenerService.stop()
setIsListening(false)
setIsConnected(false)
setActiveSessions(0)
}
}, [isEnabled, pubkey, rendezvousUrl, buildAuthorizedSecrets])
useEffect(() => {
if (!isEnabled || !client.signer || !pubkey) return
}, [connections, isEnabled, pubkey])
// ===== Auto-sync remote connections (bidirectional) =====
// Sync interval: 15 minutes
const AUTO_SYNC_INTERVAL = 15 * 60 * 1000
// Minimum time between syncs for the same connection: 5 minutes
const MIN_SYNC_INTERVAL = 5 * 60 * 1000
/**
* Get local events for sync kinds and build manifest
*/
const getLocalEventsAndManifest = async (): Promise<{
events: Event[]
manifest: EventManifestEntry[]
}> => {
const events = await indexedDb.queryEventsForNRC([{ kinds: SYNC_KINDS, limit: 1000 }])
const manifest: EventManifestEntry[] = events.map((e) => ({
kind: e.kind,
id: e.id,
created_at: e.created_at,
d: e.tags.find((t) => t[0] === 'd')?.[1]
}))
return { events, manifest }
}
/**
* Diff manifests to find what each side needs
* For replaceable events: compare by (kind, pubkey, d) and use newer created_at
*/
const diffManifests = (
local: EventManifestEntry[],
remote: EventManifestEntry[],
localEvents: Event[]
): { toSend: Event[]; toFetch: string[] } => {
// Build maps keyed by (kind, d) for replaceable events
const localMap = new Map<string, EventManifestEntry>()
const localEventsMap = new Map<string, Event>()
for (let i = 0; i < local.length; i++) {
const entry = local[i]
const key = `${entry.kind}:${entry.d || ''}`
const existing = localMap.get(key)
// Keep the newer one
if (!existing || entry.created_at > existing.created_at) {
localMap.set(key, entry)
localEventsMap.set(entry.id, localEvents[i])
}
}
const remoteMap = new Map<string, EventManifestEntry>()
for (const entry of remote) {
const key = `${entry.kind}:${entry.d || ''}`
const existing = remoteMap.get(key)
if (!existing || entry.created_at > existing.created_at) {
remoteMap.set(key, entry)
}
}
const toSend: Event[] = []
const toFetch: string[] = []
// Find events we have that are newer than remote's (or remote doesn't have)
for (const [key, localEntry] of localMap) {
const remoteEntry = remoteMap.get(key)
if (!remoteEntry || localEntry.created_at > remoteEntry.created_at) {
const event = localEventsMap.get(localEntry.id)
if (event) {
toSend.push(event)
}
}
}
// Find events remote has that are newer than ours (or we don't have)
for (const [key, remoteEntry] of remoteMap) {
const localEntry = localMap.get(key)
if (!localEntry || remoteEntry.created_at > localEntry.created_at) {
toFetch.push(remoteEntry.id)
}
}
return { toSend, toFetch }
}
useEffect(() => {
// Only auto-sync if we have remote connections and a signer
if (remoteConnections.length === 0 || !client.signer || !pubkey) {
return
}
// Don't auto-sync if already syncing
if (isSyncing) {
return
}
const bidirectionalSync = async () => {
const now = Date.now()
// Find connections that need syncing
const needsSync = remoteConnections.filter(
(c) => !c.lastSync || (now - c.lastSync) > MIN_SYNC_INTERVAL
)
if (needsSync.length === 0) {
return
}
console.log(`[NRC] Bidirectional sync: ${needsSync.length} connection(s) need syncing`)
for (const remote of needsSync) {
if (isSyncing) break
try {
console.log(`[NRC] Bidirectional sync with ${remote.label}...`)
setIsSyncing(true)
setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
// Step 1: Get remote's event IDs
setSyncProgress({ phase: 'requesting', eventsReceived: 0, message: 'Getting remote event list...' })
const remoteManifest = await requestRemoteIDs(
remote.uri,
[{ kinds: SYNC_KINDS, limit: 1000 }]
)
console.log(`[NRC] Remote has ${remoteManifest.length} events`)
// Step 2: Get our local events and manifest
const { events: localEvents, manifest: localManifest } = await getLocalEventsAndManifest()
console.log(`[NRC] Local has ${localManifest.length} events`)
// Step 3: Diff to find what each side needs
const { toSend, toFetch } = diffManifests(localManifest, remoteManifest, localEvents)
console.log(`[NRC] Diff: sending ${toSend.length}, fetching ${toFetch.length}`)
let eventsSent = 0
let eventsReceived = 0
// Step 4: Send events remote needs
if (toSend.length > 0) {
setSyncProgress({ phase: 'sending', eventsReceived: 0, eventsSent: 0, message: `Sending ${toSend.length} events...` })
eventsSent = await sendEventsToRemote(
remote.uri,
toSend,
(progress) => setSyncProgress({ ...progress, message: `Sending events... (${progress.eventsSent || 0}/${toSend.length})` })
)
console.log(`[NRC] Sent ${eventsSent} events to ${remote.label}`)
}
// Step 5: Fetch events we need using regular filter queries
if (toFetch.length > 0) {
setSyncProgress({ phase: 'receiving', eventsReceived: 0, eventsSent, message: `Fetching ${toFetch.length} events...` })
// Fetch by ID in batches (relay may limit number of IDs per filter)
const BATCH_SIZE = 50
const fetchedEvents: Event[] = []
for (let i = 0; i < toFetch.length; i += BATCH_SIZE) {
const batch = toFetch.slice(i, i + BATCH_SIZE)
const events = await syncFromRemote(
remote.uri,
[{ ids: batch }],
(progress) => setSyncProgress({
...progress,
eventsSent,
message: `Fetching events... (${fetchedEvents.length + progress.eventsReceived}/${toFetch.length})`
})
)
fetchedEvents.push(...events)
}
// Store fetched events
for (const event of fetchedEvents) {
try {
await indexedDb.putReplaceableEvent(event)
} catch {
// Ignore storage errors
}
}
eventsReceived = fetchedEvents.length
console.log(`[NRC] Received ${eventsReceived} events from ${remote.label}`)
}
// Update last sync time
setRemoteConnections((prev) =>
prev.map((c) =>
c.id === remote.id
? { ...c, lastSync: Date.now(), eventCount: eventsReceived }
: c
)
)
console.log(`[NRC] Bidirectional sync complete with ${remote.label}: sent ${eventsSent}, received ${eventsReceived}`)
} catch (err) {
console.error(`[NRC] Bidirectional sync failed for ${remote.label}:`, err)
} finally {
setIsSyncing(false)
setSyncProgress(null)
}
}
}
// Run initial sync after a short delay
const initialTimer = setTimeout(bidirectionalSync, 3000)
// Set up periodic sync
const intervalTimer = setInterval(bidirectionalSync, AUTO_SYNC_INTERVAL)
return () => {
clearTimeout(initialTimer)
clearInterval(intervalTimer)
}
}, [remoteConnections.length, pubkey, isSyncing])
// ===== Listener Actions =====
const enable = useCallback(async () => {
if (!client.signer) {
throw new Error('Signer required to enable NRC')
}
setIsEnabled(true)
}, [])
const disable = useCallback(() => {
setIsEnabled(false)
listenerService.stop()
setIsListening(false)
setIsConnected(false)
setActiveSessions(0)
}, [])
const addConnection = useCallback(
async (label: string): Promise<{ uri: string; connection: NRCConnection }> => {
if (!pubkey) {
throw new Error('Not logged in')
}
const id = crypto.randomUUID()
const createdAt = Date.now()
const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label)
const uri = result.uri
const connection: NRCConnection = {
id,
label,
secret: result.secret,
clientPubkey: result.clientPubkey,
createdAt
}
setConnections((prev) => [...prev, connection])
return { uri, connection }
},
[pubkey, rendezvousUrl]
)
const removeConnection = useCallback(async (id: string) => {
setConnections((prev) => prev.filter((c) => c.id !== id))
}, [])
const getConnectionURI = useCallback(
(connection: NRCConnection): string => {
if (!pubkey) {
throw new Error('Not logged in')
}
if (!connection.secret) {
throw new Error('Connection has no secret')
}
const result = generateConnectionURI(
pubkey,
rendezvousUrl,
connection.secret,
connection.label
)
return result.uri
},
[pubkey, rendezvousUrl]
)
const setRendezvousUrl = useCallback((url: string) => {
setRendezvousUrlState(url)
}, [])
// ===== Client Actions =====
const addRemoteConnection = useCallback(
async (uri: string, label: string): Promise<RemoteConnection> => {
// Validate and parse the URI
const parsed = parseConnectionURI(uri)
const remoteConnection: RemoteConnection = {
id: crypto.randomUUID(),
uri,
label,
relayPubkey: parsed.relayPubkey,
rendezvousUrl: parsed.rendezvousUrl
}
setRemoteConnections((prev) => [...prev, remoteConnection])
return remoteConnection
},
[]
)
const removeRemoteConnection = useCallback(async (id: string) => {
setRemoteConnections((prev) => prev.filter((c) => c.id !== id))
}, [])
const syncFromDevice = useCallback(
async (id: string, filters?: Filter[]): Promise<Event[]> => {
const remote = remoteConnections.find((c) => c.id === id)
if (!remote) {
throw new Error('Remote connection not found')
}
setIsSyncing(true)
setSyncProgress({ phase: 'connecting', eventsReceived: 0 })
try {
// Default filters: sync everything
const syncFilters = filters || [
{ kinds: [0, 3, 10000, 10001, 10002, 10003, 10012, 30002], limit: 1000 }
]
const events = await syncFromRemote(
remote.uri,
syncFilters,
(progress) => setSyncProgress(progress)
)
// Store synced events in IndexedDB
for (const event of events) {
try {
await indexedDb.putReplaceableEvent(event)
} catch (err) {
console.warn('[NRC] Failed to store event:', err)
}
}
// Update last sync time
setRemoteConnections((prev) =>
prev.map((c) =>
c.id === id ? { ...c, lastSync: Date.now(), eventCount: events.length } : c
)
)
return events
} finally {
setIsSyncing(false)
setSyncProgress(null)
}
},
[remoteConnections]
)
const syncAllRemotes = useCallback(
async (filters?: Filter[]): Promise<Event[]> => {
const allEvents: Event[] = []
for (const remote of remoteConnections) {
try {
const events = await syncFromDevice(remote.id, filters)
allEvents.push(...events)
} catch (error) {
console.error(`[NRC] Failed to sync from ${remote.label}:`, error)
}
}
return allEvents
},
[remoteConnections, syncFromDevice]
)
const testRemoteConnection = useCallback(
async (id: string): Promise<boolean> => {
const remote = remoteConnections.find((c) => c.id === id)
if (!remote) {
throw new Error('Remote connection not found')
}
setIsSyncing(true)
setSyncProgress({ phase: 'connecting', eventsReceived: 0, message: 'Testing connection...' })
try {
const result = await testConnection(
remote.uri,
(progress) => setSyncProgress(progress)
)
// Update connection to mark it as tested
setRemoteConnections((prev) =>
prev.map((c) =>
c.id === id ? { ...c, lastSync: Date.now(), eventCount: 0 } : c
)
)
return result
} finally {
setIsSyncing(false)
setSyncProgress(null)
}
},
[remoteConnections]
)
const value: NRCContextType = {
// Listener
isEnabled,
isListening,
isConnected,
connections,
activeSessions,
rendezvousUrl,
enable,
disable,
addConnection,
removeConnection,
getConnectionURI,
setRendezvousUrl,
// Client
remoteConnections,
isSyncing,
syncProgress,
addRemoteConnection,
removeRemoteConnection,
testRemoteConnection,
syncFromDevice,
syncAllRemotes
}
return <NRCContext.Provider value={value}>{children}</NRCContext.Provider>
}

View File

@@ -1,18 +1,12 @@
/**
* NIP-46 Bunker Signer with Cashu Token Authentication
* NIP-46 Bunker Signer
*
* Implements remote signing via NIP-46 protocol with Cashu access tokens
* for authorization. The signer connects to a bunker WebSocket and
* Implements remote signing via NIP-46 protocol.
* The signer connects to a bunker WebSocket and
* requests signing operations.
*
* Token flow:
* 1. Connect to bunker with X-Cashu-Token header
* 2. Send NIP-46 requests encrypted with NIP-04
* 3. Receive signed events from bunker
*/
import { ISigner, TDraftEvent } from '@/types'
import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
import * as utils from '@noble/curves/abstract/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools'
@@ -61,13 +55,12 @@ function generateRequestId(): string {
}
/**
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>&cat=<token>).
* Parse a bunker URL (bunker://<pubkey>?relay=<url>&secret=<secret>).
*/
export function parseBunkerUrl(url: string): {
pubkey: string
relays: string[]
secret?: string
catToken?: string
} {
if (!url.startsWith('bunker://')) {
throw new Error('Invalid bunker URL: must start with bunker://')
@@ -83,7 +76,6 @@ export function parseBunkerUrl(url: string): {
const params = new URLSearchParams(queryPart || '')
const relays = params.getAll('relay')
const secret = params.get('secret') || undefined
const catToken = params.get('cat') || undefined
if (relays.length === 0) {
throw new Error('Invalid bunker URL: no relay specified')
@@ -92,8 +84,7 @@ export function parseBunkerUrl(url: string): {
return {
pubkey: pubkeyPart,
relays,
secret,
catToken
secret
}
}
@@ -173,8 +164,6 @@ export class BunkerSigner implements ISigner {
private ws: WebSocket | null = null
private pendingRequests = new Map<string, PendingRequest>()
private connected = false
private token: TCashuToken | null = null
private mintUrl: string | null = null
private requestTimeout = 30000 // 30 seconds
// Whether we're waiting for signer to connect (reverse flow)
@@ -186,22 +175,12 @@ export class BunkerSigner implements ISigner {
* @param bunkerPubkey - The bunker's public key (hex)
* @param relayUrls - Relay URLs to connect to
* @param connectionSecret - Optional connection secret for initial handshake
* @param catToken - Optional CAT token (encoded string) for authorization
*/
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string, catToken?: string) {
constructor(bunkerPubkey: string, relayUrls: string[], connectionSecret?: string) {
this.bunkerPubkey = bunkerPubkey
this.relayUrls = relayUrls
this.connectionSecret = connectionSecret
// Decode CAT token if provided
if (catToken) {
try {
this.token = cashuTokenService.decodeToken(catToken)
} catch (err) {
console.warn('Failed to decode CAT token from URL:', err)
}
}
// Generate local ephemeral keypair for NIP-46 communication
this.localPrivkey = secp256k1.utils.randomPrivateKey()
this.localPubkey = nGetPublicKey(this.localPrivkey)
@@ -263,7 +242,6 @@ export class BunkerSigner implements ISigner {
* Connect to relay and wait for signer to initiate connection.
*/
private async connectAndWait(relayUrl: string): Promise<void> {
await this.acquireTokenIfNeeded(relayUrl)
await this.connectToRelayAndListen(relayUrl)
}
@@ -281,14 +259,6 @@ export class BunkerSigner implements ISigner {
wsUrl = 'wss://' + relayUrl
}
// Add token if available
if (this.token) {
const tokenEncoded = cashuTokenService.encodeToken(this.token)
const url = new URL(wsUrl)
url.searchParams.set('token', tokenEncoded)
wsUrl = url.toString()
}
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
@@ -341,43 +311,10 @@ export class BunkerSigner implements ISigner {
return this.localPubkey
}
/**
* Set the Cashu token for authentication.
*/
setToken(token: TCashuToken) {
this.token = token
}
/**
* Set the mint URL for token refresh.
*/
setMintUrl(url: string) {
this.mintUrl = url
cashuTokenService.setMint(url)
}
/**
* Initialize connection to the bunker.
*/
async init(): Promise<void> {
// Check for stored token
const stored = cashuTokenService.loadTokens(this.bunkerPubkey)
if (stored?.current && !cashuTokenService.needsRefresh(stored.current)) {
this.token = stored.current
}
// Try to acquire token for each relay if we don't have one
if (!this.token) {
for (const relayUrl of this.relayUrls) {
try {
await this.acquireTokenIfNeeded(relayUrl)
if (this.token) break
} catch (err) {
console.warn(`Failed to acquire token for ${relayUrl}:`, err)
}
}
}
// Connect to first available relay
for (const relayUrl of this.relayUrls) {
try {
@@ -396,74 +333,6 @@ export class BunkerSigner implements ISigner {
await this.connect()
}
/**
* Check if relay requires Cashu token and acquire one if needed.
*/
private async acquireTokenIfNeeded(relayUrl: string): Promise<void> {
// Convert to HTTP URL for mint endpoints
let mintUrl = relayUrl
if (relayUrl.startsWith('ws://')) {
mintUrl = 'http://' + relayUrl.slice(5)
} else if (relayUrl.startsWith('wss://')) {
mintUrl = 'https://' + relayUrl.slice(6)
} else if (!relayUrl.startsWith('http://') && !relayUrl.startsWith('https://')) {
mintUrl = 'https://' + relayUrl
}
mintUrl = mintUrl.replace(/\/$/, '')
try {
// Check if relay has Cashu mint endpoints
const infoResponse = await fetch(`${mintUrl}/cashu/info`)
if (!infoResponse.ok) {
console.log(`Relay ${relayUrl} does not support Cashu tokens`)
return
}
await infoResponse.json() // Validate JSON response
console.log(`Relay ${relayUrl} requires Cashu token, acquiring...`)
// Configure the mint
this.mintUrl = mintUrl
cashuTokenService.setMint(mintUrl)
// Create NIP-98 auth signer using our local ephemeral key
const signHttpAuth = async (url: string, method: string): Promise<string> => {
const authEvent: TDraftEvent = {
kind: 27235,
created_at: Math.floor(Date.now() / 1000),
content: '',
tags: [
['u', url],
['method', method]
]
}
const signedAuth = finalizeEvent(authEvent, this.localPrivkey)
// Encode as base64url for NIP-98 header
const eventJson = JSON.stringify(signedAuth)
const base64 = btoa(eventJson)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return `Nostr ${base64}`
}
// Request token with NIP-46 scope
const token = await cashuTokenService.requestToken(
TokenScope.NIP46,
utils.hexToBytes(this.localPubkey),
signHttpAuth,
[24133] // NIP-46 kind
)
this.token = token
cashuTokenService.storeTokens(this.bunkerPubkey, token)
console.log(`Acquired Cashu token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`)
} catch (err) {
// Relay doesn't support Cashu or request failed - continue without token
console.warn(`Could not acquire Cashu token for ${relayUrl}:`, err)
}
}
/**
* Connect to a relay WebSocket.
*/
@@ -479,16 +348,6 @@ export class BunkerSigner implements ISigner {
wsUrl = 'wss://' + relayUrl
}
// Add Cashu token header if available
// Note: WebSocket API doesn't support custom headers directly,
// so we'll need to pass token as a subprotocol or query param
if (this.token) {
const tokenEncoded = cashuTokenService.encodeToken(this.token)
const url = new URL(wsUrl)
url.searchParams.set('token', tokenEncoded)
wsUrl = url.toString()
}
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
@@ -642,16 +501,12 @@ export class BunkerSigner implements ISigner {
// Encrypt with NIP-04 to the bunker's pubkey
const encrypted = await nip04.encrypt(this.localPrivkey, this.bunkerPubkey, JSON.stringify(request))
// Create NIP-46 request event with optional CAT tag
const tags: string[][] = [['p', this.bunkerPubkey]]
if (this.token) {
tags.push(['cat', cashuTokenService.encodeToken(this.token)])
}
// Create NIP-46 request event
const draftEvent: TDraftEvent = {
kind: 24133,
created_at: Math.floor(Date.now() / 1000),
content: encrypted,
tags
tags: [['p', this.bunkerPubkey]]
}
const signedEvent = finalizeEvent(draftEvent, this.localPrivkey)
@@ -748,47 +603,6 @@ export class BunkerSigner implements ISigner {
return this.connected
}
/**
* Get the current token.
*/
getToken(): TCashuToken | null {
return this.token
}
/**
* Request a new token from the mint.
* Requires a signing function for NIP-98 auth.
*/
async refreshToken(
signHttpAuth: (url: string, method: string) => Promise<string>,
userPubkey: Uint8Array
): Promise<TCashuToken> {
if (!this.mintUrl) {
throw new Error('Mint URL not configured')
}
const token = await cashuTokenService.requestToken(
TokenScope.NIP46,
userPubkey,
signHttpAuth,
[24133] // NIP-46 kind
)
this.token = token
// Store the new token
const existing = cashuTokenService.loadTokens(this.bunkerPubkey)
if (existing?.current && cashuTokenService.verifyToken(existing.current)) {
// Current still valid, store new as next
cashuTokenService.storeTokens(this.bunkerPubkey, existing.current, token)
} else {
// Current expired or invalid, use new as current
cashuTokenService.storeTokens(this.bunkerPubkey, token)
}
return token
}
/**
* Disconnect from the bunker.
*/

View File

@@ -127,18 +127,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const init = async () => {
if (hasNostrLoginHash()) {
return await loginByNostrLoginHash()
await loginByNostrLoginHash()
setIsInitialized(true)
return
}
const accounts = storage.getAccounts()
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
if (!act) return
if (!act) {
setIsInitialized(true)
return
}
// Set account immediately so feed can load based on pubkey
// while signer initializes in the background
setAccount({ pubkey: act.pubkey, signerType: act.signerType })
setIsInitialized(true)
// Initialize signer in background - feed doesn't need it to load
await loginWithAccountPointer(act)
}
init().then(() => {
setIsInitialized(true)
})
init()
const handleHashChange = () => {
if (hasNostrLoginHash()) {
@@ -482,8 +491,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const bunkerLogin = async (bunkerUrl: string) => {
try {
const { pubkey: bunkerPubkey, relays, secret, catToken } = parseBunkerUrl(bunkerUrl)
const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret, catToken)
const { pubkey: bunkerPubkey, relays, secret } = parseBunkerUrl(bunkerUrl)
const bunkerSigner = new BunkerSigner(bunkerPubkey, relays, secret)
await bunkerSigner.init()
const pubkey = await bunkerSigner.getPublicKey()
return login(bunkerSigner, {
@@ -491,8 +500,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
signerType: 'bunker',
bunkerPubkey,
bunkerRelays: relays,
bunkerSecret: secret,
bunkerCatToken: catToken
bunkerSecret: secret
})
} catch (err) {
toast.error(t('Bunker login failed') + ': ' + (err as Error).message)
@@ -569,8 +577,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const bunkerSigner = new BunkerSigner(
account.bunkerPubkey,
account.bunkerRelays,
account.bunkerSecret,
account.bunkerCatToken
account.bunkerSecret
)
await bunkerSigner.init()
return login(bunkerSigner, account)

View File

@@ -45,7 +45,8 @@ function getCurrentSettings(): TSyncSettings {
filterOutOnionRelays: storage.getFilterOutOnionRelays(),
quickReaction: storage.getQuickReaction(),
quickReactionEmoji: storage.getQuickReactionEmoji(),
noteListMode: storage.getNoteListMode()
noteListMode: storage.getNoteListMode(),
nrcOnlyConfigSync: storage.getNrcOnlyConfigSync()
}
}
@@ -113,6 +114,9 @@ function applySettings(settings: TSyncSettings) {
if (settings.noteListMode !== undefined) {
storage.setNoteListMode(settings.noteListMode)
}
if (settings.nrcOnlyConfigSync !== undefined) {
storage.setNrcOnlyConfigSync(settings.nrcOnlyConfigSync)
}
}
export function SettingsSyncProvider({ children }: { children: React.ReactNode }) {
@@ -155,6 +159,9 @@ export function SettingsSyncProvider({ children }: { children: React.ReactNode }
const syncSettings = useCallback(async () => {
if (!pubkey || !account) return
// Skip relay-based settings sync if NRC-only config sync is enabled
if (storage.getNrcOnlyConfigSync()) return
const currentSettings = getCurrentSettings()
const settingsJson = JSON.stringify(currentSettings)
@@ -192,6 +199,13 @@ export function SettingsSyncProvider({ children }: { children: React.ReactNode }
return
}
// Skip relay-based settings sync if NRC-only config sync is enabled
// (settings will sync via NRC instead)
if (storage.getNrcOnlyConfigSync()) {
lastSyncedSettingsRef.current = JSON.stringify(getCurrentSettings())
return
}
const loadRemoteSettings = async () => {
setIsLoading(true)
try {

View File

@@ -0,0 +1,114 @@
import { useFetchFollowGraph } from '@/hooks/useFetchFollowGraph'
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
import { createContext, useCallback, useContext, useMemo, useState } from 'react'
import { useNostr } from './NostrProvider'
type TSocialGraphFilterContext = {
// Settings
proximityLevel: number | null // null = disabled, 1 = direct follows, 2 = follows of follows
includeMode: boolean // true = include only graph members, false = exclude graph members
updateProximityLevel: (level: number | null) => void
updateIncludeMode: (include: boolean) => void
// Cached data
graphPubkeys: Set<string> // Pre-computed Set for O(1) lookup
graphPubkeyCount: number
isLoading: boolean
// Filter function for use in feeds
isPubkeyAllowed: (pubkey: string) => boolean
}
const SocialGraphFilterContext = createContext<TSocialGraphFilterContext | undefined>(undefined)
export const useSocialGraphFilter = () => {
const context = useContext(SocialGraphFilterContext)
if (!context) {
throw new Error('useSocialGraphFilter must be used within a SocialGraphFilterProvider')
}
return context
}
export function SocialGraphFilterProvider({ children }: { children: React.ReactNode }) {
const { pubkey } = useNostr()
const [proximityLevel, setProximityLevel] = useState<number | null>(
storage.getSocialGraphProximity()
)
const [includeMode, setIncludeMode] = useState<boolean>(storage.getSocialGraphIncludeMode())
// Fetch the follow graph when proximity is enabled
const { pubkeysByDepth, isLoading } = useFetchFollowGraph(
proximityLevel !== null ? pubkey : null,
proximityLevel ?? 1
)
// Build the Set of graph pubkeys (always includes self)
const graphPubkeys = useMemo(() => {
const set = new Set<string>()
// Always include self in the graph
if (pubkey) {
set.add(pubkey)
}
// Add pubkeys up to selected depth
if (proximityLevel && pubkeysByDepth.length) {
for (let depth = 0; depth < proximityLevel && depth < pubkeysByDepth.length; depth++) {
pubkeysByDepth[depth].forEach((pk) => set.add(pk))
}
}
return set
}, [pubkey, proximityLevel, pubkeysByDepth])
const graphPubkeyCount = graphPubkeys.size
const updateProximityLevel = useCallback((level: number | null) => {
storage.setSocialGraphProximity(level)
setProximityLevel(level)
dispatchSettingsChanged()
}, [])
const updateIncludeMode = useCallback((include: boolean) => {
storage.setSocialGraphIncludeMode(include)
setIncludeMode(include)
dispatchSettingsChanged()
}, [])
const isPubkeyAllowed = useCallback(
(targetPubkey: string): boolean => {
// If filter disabled, allow all
if (proximityLevel === null) return true
// If loading, allow all (graceful degradation)
if (isLoading) return true
// Always allow self
if (targetPubkey === pubkey) return true
const isInGraph = graphPubkeys.has(targetPubkey)
// Include mode: only allow if in graph
// Exclude mode: only allow if NOT in graph
return includeMode ? isInGraph : !isInGraph
},
[proximityLevel, isLoading, graphPubkeys, includeMode, pubkey]
)
return (
<SocialGraphFilterContext.Provider
value={{
proximityLevel,
includeMode,
updateProximityLevel,
updateIncludeMode,
graphPubkeys,
graphPubkeyCount,
isLoading,
isPubkeyAllowed
}}
>
{children}
</SocialGraphFilterContext.Provider>
)
}

View File

@@ -1,458 +0,0 @@
/**
* Cashu Token Service
*
* Manages Cashu access tokens for bunker authentication.
* Handles token issuance, storage, and two-token rotation (current + next).
*
* Token flow:
* 1. Generate random secret and blinding factor
* 2. Compute blinded message B_ = hash_to_curve(secret) + r*G
* 3. Submit B_ to mint with NIP-98 auth
* 4. Receive blinded signature C_
* 5. Unblind: C = C_ - r*K (where K is mint's pubkey)
* 6. Store token {secret, C, keysetId, expiry, ...}
*/
import * as utils from '@noble/curves/abstract/utils'
import { secp256k1 } from '@noble/curves/secp256k1'
import { sha256 } from '@noble/hashes/sha256'
// Token scopes
export const TokenScope = {
RELAY: 'relay',
NIP46: 'nip46',
BLOSSOM: 'blossom',
API: 'api'
} as const
export type TTokenScope = (typeof TokenScope)[keyof typeof TokenScope]
// Token format matching ORLY's token.Token
export type TCashuToken = {
keysetId: string // k - keyset identifier
secret: Uint8Array // s - 32-byte random secret
signature: Uint8Array // c - 33-byte signature point (compressed)
pubkey: Uint8Array // p - 32-byte user pubkey
expiry: number // e - Unix timestamp
scope: TTokenScope // sc - token scope
kinds?: number[] // kinds - permitted event kinds
kindRanges?: [number, number][] // kind_ranges - permitted ranges
}
// Keyset info from mint
export type TKeysetInfo = {
id: string
publicKey: string // hex-encoded mint public key
active: boolean
expiresAt?: number
}
// Mint info
export type TMintInfo = {
name: string
version: string
pubkey: string
keysets: TKeysetInfo[]
}
// Blinding result
type BlindResult = {
B_: Uint8Array // Blinded point (33 bytes compressed)
secret: Uint8Array // Original secret
r: Uint8Array // Blinding factor scalar
}
// Storage key prefix
const STORAGE_PREFIX = 'cashu_token_'
/**
* Hash a message to a point on secp256k1 using try-and-increment.
* Algorithm matches ORLY's Go implementation exactly:
* 1. msgHash = SHA256(domain_separator || message)
* 2. For counter in 0..65535:
* a. counterBytes = counter as 4-byte little-endian
* b. hash = SHA256(msgHash || counterBytes)
* c. compressed = 0x02 || hash
* d. If valid secp256k1 point, return compressed
*/
function hashToCurve(message: Uint8Array): Uint8Array {
const domainSeparator = new TextEncoder().encode('Secp256k1_HashToCurve_Cashu_')
const msgHash = sha256(new Uint8Array([...domainSeparator, ...message]))
// Try incrementing counter until we get a valid point
for (let counter = 0; counter < 65536; counter++) {
// 4-byte little-endian counter (matching ORLY's binary.LittleEndian.PutUint32)
const counterBytes = new Uint8Array(4)
new DataView(counterBytes.buffer).setUint32(0, counter, true) // true = little-endian
// msgHash THEN counterBytes (matching ORLY's append order)
const toHash = new Uint8Array([...msgHash, ...counterBytes])
const hash = sha256(toHash)
// Only try 0x02 prefix (even Y coordinate)
const compressed = new Uint8Array([0x02, ...hash])
try {
// Validate this is a valid point
const point = secp256k1.ProjectivePoint.fromHex(compressed)
if (!point.equals(secp256k1.ProjectivePoint.ZERO)) {
return compressed
}
} catch {
// Not a valid point, continue
}
}
throw new Error('Failed to hash to curve after 65536 attempts')
}
/**
* Create a blinded message from a secret.
* B_ = Y + r*G where Y = hash_to_curve(secret)
*/
function blind(secret: Uint8Array): BlindResult {
// Generate random blinding factor r
const r = secp256k1.utils.randomPrivateKey()
// Y = hash_to_curve(secret)
const Y = secp256k1.ProjectivePoint.fromHex(hashToCurve(secret))
// r*G
const rG = secp256k1.ProjectivePoint.BASE.multiply(utils.bytesToNumberBE(r))
// B_ = Y + r*G
const B_ = Y.add(rG)
return {
B_: B_.toRawBytes(true), // Compressed format
secret,
r
}
}
/**
* Unblind the signature to get the final signature.
* C = C_ - r*K where K is the mint's public key
*/
function unblind(C_: Uint8Array, r: Uint8Array, K: Uint8Array): Uint8Array {
const C_point = secp256k1.ProjectivePoint.fromHex(C_)
const K_point = secp256k1.ProjectivePoint.fromHex(K)
// r*K
const rK = K_point.multiply(utils.bytesToNumberBE(r))
// C = C_ - r*K
const C = C_point.subtract(rK)
return C.toRawBytes(true)
}
/**
* Verify a token signature locally.
* Checks that C = k*Y where Y = hash_to_curve(secret) and k is unknown.
* We verify using DLEQ proof or by checking C matches our expectations.
*/
function verifyToken(token: TCashuToken, _mintPubkey: Uint8Array): boolean {
try {
// Basic validation
if (token.expiry < Date.now() / 1000) {
return false
}
// Verify signature is a valid point
secp256k1.ProjectivePoint.fromHex(token.signature)
// Could implement full DLEQ verification here if needed
return true
} catch {
return false
}
}
/**
* Encode a token to the Cashu format (cashuA prefix + base64url).
*/
function encodeToken(token: TCashuToken): string {
const tokenData = {
k: token.keysetId,
s: utils.bytesToHex(token.secret),
c: utils.bytesToHex(token.signature),
p: utils.bytesToHex(token.pubkey),
e: token.expiry,
sc: token.scope,
kinds: token.kinds,
kind_ranges: token.kindRanges
}
const json = JSON.stringify(tokenData)
// Use base64url encoding
const base64 = btoa(json)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '')
return 'cashuA' + base64
}
/**
* Decode a token from the Cashu format.
*/
function decodeToken(encoded: string): TCashuToken {
if (!encoded.startsWith('cashuA')) {
throw new Error('Invalid token prefix, expected cashuA')
}
const base64url = encoded.slice(6)
// Convert base64url to base64
let base64 = base64url.replace(/-/g, '+').replace(/_/g, '/')
// Add padding if needed
while (base64.length % 4 !== 0) {
base64 += '='
}
const json = atob(base64)
const data = JSON.parse(json)
return {
keysetId: data.k,
secret: utils.hexToBytes(data.s),
signature: utils.hexToBytes(data.c),
pubkey: utils.hexToBytes(data.p),
expiry: data.e,
scope: data.sc,
kinds: data.kinds,
kindRanges: data.kind_ranges
}
}
/**
* Cashu Token Service - manages token lifecycle for bunker auth.
*/
class CashuTokenService {
private mintUrl: string | null = null
private mintPubkey: Uint8Array | null = null
private activeKeysetId: string | null = null
/**
* Configure the mint endpoint.
*/
setMint(url: string) {
this.mintUrl = url.replace(/\/$/, '')
}
/**
* Fetch mint info and keysets.
*/
async fetchMintInfo(): Promise<TMintInfo> {
if (!this.mintUrl) {
throw new Error('Mint URL not configured')
}
const response = await fetch(`${this.mintUrl}/cashu/info`)
if (!response.ok) {
throw new Error(`Failed to fetch mint info: ${response.statusText}`)
}
const info = await response.json()
this.mintPubkey = utils.hexToBytes(info.pubkey)
// Also fetch keysets
const keysetsResponse = await fetch(`${this.mintUrl}/cashu/keysets`)
if (keysetsResponse.ok) {
const keysetsData = await keysetsResponse.json()
info.keysets = keysetsData.keysets
// Find active keyset
const active = keysetsData.keysets.find((k: TKeysetInfo) => k.active)
if (active) {
this.activeKeysetId = active.id
}
}
return info
}
/**
* Request a new token from the mint.
* Requires NIP-98 auth via the signHttpAuth function.
*/
async requestToken(
scope: TTokenScope,
userPubkey: Uint8Array,
signHttpAuth: (url: string, method: string) => Promise<string>,
kinds?: number[],
kindRanges?: [number, number][]
): Promise<TCashuToken> {
if (!this.mintUrl) {
throw new Error('Mint URL not configured')
}
// Generate secret and blind it
const secret = crypto.getRandomValues(new Uint8Array(32))
const blindResult = blind(secret)
// Create request
const requestBody = {
blinded_message: utils.bytesToHex(blindResult.B_),
scope,
kinds,
kind_ranges: kindRanges
}
// Get NIP-98 auth header
const authUrl = `${this.mintUrl}/cashu/mint`
const authHeader = await signHttpAuth(authUrl, 'POST')
// Submit to mint
const response = await fetch(authUrl, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: authHeader
},
body: JSON.stringify(requestBody)
})
if (!response.ok) {
const error = await response.text()
throw new Error(`Mint request failed: ${error}`)
}
const result = await response.json()
// Unblind the signature
const C_ = utils.hexToBytes(result.blinded_signature)
const K = utils.hexToBytes(result.mint_pubkey)
const signature = unblind(C_, blindResult.r, K)
const token: TCashuToken = {
keysetId: result.keyset_id,
secret: blindResult.secret,
signature,
pubkey: userPubkey,
expiry: result.expiry,
scope,
kinds,
kindRanges
}
return token
}
/**
* Store tokens for a specific bunker.
* Maintains current and next token for rotation.
*/
storeTokens(bunkerPubkey: string, current: TCashuToken, next?: TCashuToken) {
const key = `${STORAGE_PREFIX}${bunkerPubkey}`
const data = {
current: encodeToken(current),
next: next ? encodeToken(next) : undefined,
storedAt: Date.now()
}
localStorage.setItem(key, JSON.stringify(data))
}
/**
* Load tokens for a specific bunker.
*/
loadTokens(bunkerPubkey: string): { current?: TCashuToken; next?: TCashuToken } | null {
const key = `${STORAGE_PREFIX}${bunkerPubkey}`
const stored = localStorage.getItem(key)
if (!stored) {
return null
}
try {
const data = JSON.parse(stored)
return {
current: data.current ? decodeToken(data.current) : undefined,
next: data.next ? decodeToken(data.next) : undefined
}
} catch {
return null
}
}
/**
* Get the active token for a bunker, handling rotation if needed.
*/
getActiveToken(bunkerPubkey: string): TCashuToken | null {
const tokens = this.loadTokens(bunkerPubkey)
if (!tokens) {
return null
}
const now = Date.now() / 1000
// If current is valid, use it
if (tokens.current && tokens.current.expiry > now) {
return tokens.current
}
// Current expired, try to promote next
if (tokens.next && tokens.next.expiry > now) {
// Promote next to current
this.storeTokens(bunkerPubkey, tokens.next)
return tokens.next
}
// Both expired
return null
}
/**
* Check if token needs refresh (< 1 day until expiry).
*/
needsRefresh(token: TCashuToken): boolean {
const now = Date.now() / 1000
const oneDaySeconds = 24 * 60 * 60
return token.expiry - now < oneDaySeconds
}
/**
* Clear tokens for a bunker.
*/
clearTokens(bunkerPubkey: string) {
const key = `${STORAGE_PREFIX}${bunkerPubkey}`
localStorage.removeItem(key)
}
/**
* Encode a token for transmission (e.g., in headers).
*/
encodeToken(token: TCashuToken): string {
return encodeToken(token)
}
/**
* Decode a token string.
*/
decodeToken(encoded: string): TCashuToken {
return decodeToken(encoded)
}
/**
* Verify a token is valid.
*/
verifyToken(token: TCashuToken): boolean {
if (!this.mintPubkey) {
// Can't verify without mint pubkey, assume valid if not expired
return token.expiry > Date.now() / 1000
}
return verifyToken(token, this.mintPubkey)
}
/**
* Get the active keyset ID.
*/
getActiveKeysetId(): string | null {
return this.activeKeysetId
}
}
// Export singleton instance
const cashuTokenService = new CashuTokenService()
export default cashuTokenService
// Export utilities
export { encodeToken, decodeToken, hashToCurve, blind, unblind }

View File

@@ -95,6 +95,19 @@ class ClientService extends EventTarget {
}
}
// NRC-only config sync: don't publish config events to relays, only sync via NRC
const CONFIG_KINDS = [
kinds.Contacts, // 3
kinds.Mutelist, // 10000
kinds.RelayList, // 10002
30002, // Relay sets
ExtendedKind.FAVORITE_RELAYS, // 10012
30078 // Application data (settings sync)
]
if (storage.getNrcOnlyConfigSync() && CONFIG_KINDS.includes(event.kind)) {
return [] // No relays - NRC will sync this event to paired devices
}
const relaySet = new Set<string>()
if (specifiedRelayUrls?.length) {
specifiedRelayUrls.forEach((url) => relaySet.add(url))

View File

@@ -0,0 +1,316 @@
import { GraphResponse } from '@/types/graph'
import { TGraphQueryCapability } from '@/types'
const DB_NAME = 'smesh-graph-cache'
const DB_VERSION = 1
// Store names
const STORES = {
FOLLOW_GRAPH: 'followGraphResults',
THREAD: 'threadResults',
RELAY_CAPABILITIES: 'relayCapabilities'
}
// Cache expiry times (in milliseconds)
const CACHE_EXPIRY = {
FOLLOW_GRAPH: 5 * 60 * 1000, // 5 minutes
THREAD: 10 * 60 * 1000, // 10 minutes
RELAY_CAPABILITY: 60 * 60 * 1000 // 1 hour
}
interface CachedEntry<T> {
data: T
timestamp: number
}
class GraphCacheService {
static instance: GraphCacheService
private db: IDBDatabase | null = null
private dbPromise: Promise<IDBDatabase> | null = null
public static getInstance(): GraphCacheService {
if (!GraphCacheService.instance) {
GraphCacheService.instance = new GraphCacheService()
}
return GraphCacheService.instance
}
private async getDB(): Promise<IDBDatabase> {
if (this.db) return this.db
if (this.dbPromise) return this.dbPromise
this.dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION)
request.onerror = () => {
console.error('Failed to open graph cache database:', request.error)
reject(request.error)
}
request.onsuccess = () => {
this.db = request.result
resolve(request.result)
}
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result
// Create stores if they don't exist
if (!db.objectStoreNames.contains(STORES.FOLLOW_GRAPH)) {
db.createObjectStore(STORES.FOLLOW_GRAPH)
}
if (!db.objectStoreNames.contains(STORES.THREAD)) {
db.createObjectStore(STORES.THREAD)
}
if (!db.objectStoreNames.contains(STORES.RELAY_CAPABILITIES)) {
db.createObjectStore(STORES.RELAY_CAPABILITIES)
}
}
})
return this.dbPromise
}
/**
* Cache a follow graph query result
*/
async cacheFollowGraph(
pubkey: string,
depth: number,
result: GraphResponse
): Promise<void> {
try {
const db = await this.getDB()
const key = `${pubkey}:${depth}`
const entry: CachedEntry<GraphResponse> = {
data: result,
timestamp: Date.now()
}
return new Promise((resolve, reject) => {
const tx = db.transaction(STORES.FOLLOW_GRAPH, 'readwrite')
const store = tx.objectStore(STORES.FOLLOW_GRAPH)
const request = store.put(entry, key)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
} catch (error) {
console.error('Failed to cache follow graph:', error)
}
}
/**
* Get cached follow graph result
*/
async getCachedFollowGraph(
pubkey: string,
depth: number
): Promise<GraphResponse | null> {
try {
const db = await this.getDB()
const key = `${pubkey}:${depth}`
return new Promise((resolve, reject) => {
const tx = db.transaction(STORES.FOLLOW_GRAPH, 'readonly')
const store = tx.objectStore(STORES.FOLLOW_GRAPH)
const request = store.get(key)
request.onsuccess = () => {
const entry = request.result as CachedEntry<GraphResponse> | undefined
if (!entry) {
resolve(null)
return
}
// Check if cache is expired
if (Date.now() - entry.timestamp > CACHE_EXPIRY.FOLLOW_GRAPH) {
resolve(null)
return
}
resolve(entry.data)
}
request.onerror = () => reject(request.error)
})
} catch (error) {
console.error('Failed to get cached follow graph:', error)
return null
}
}
/**
* Cache a thread query result
*/
async cacheThread(eventId: string, result: GraphResponse): Promise<void> {
try {
const db = await this.getDB()
const entry: CachedEntry<GraphResponse> = {
data: result,
timestamp: Date.now()
}
return new Promise((resolve, reject) => {
const tx = db.transaction(STORES.THREAD, 'readwrite')
const store = tx.objectStore(STORES.THREAD)
const request = store.put(entry, eventId)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
} catch (error) {
console.error('Failed to cache thread:', error)
}
}
/**
* Get cached thread result
*/
async getCachedThread(eventId: string): Promise<GraphResponse | null> {
try {
const db = await this.getDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORES.THREAD, 'readonly')
const store = tx.objectStore(STORES.THREAD)
const request = store.get(eventId)
request.onsuccess = () => {
const entry = request.result as CachedEntry<GraphResponse> | undefined
if (!entry) {
resolve(null)
return
}
if (Date.now() - entry.timestamp > CACHE_EXPIRY.THREAD) {
resolve(null)
return
}
resolve(entry.data)
}
request.onerror = () => reject(request.error)
})
} catch (error) {
console.error('Failed to get cached thread:', error)
return null
}
}
/**
* Cache relay graph capability
*/
async cacheRelayCapability(
url: string,
capability: TGraphQueryCapability | null
): Promise<void> {
try {
const db = await this.getDB()
const entry: CachedEntry<TGraphQueryCapability | null> = {
data: capability,
timestamp: Date.now()
}
return new Promise((resolve, reject) => {
const tx = db.transaction(STORES.RELAY_CAPABILITIES, 'readwrite')
const store = tx.objectStore(STORES.RELAY_CAPABILITIES)
const request = store.put(entry, url)
request.onsuccess = () => resolve()
request.onerror = () => reject(request.error)
})
} catch (error) {
console.error('Failed to cache relay capability:', error)
}
}
/**
* Get cached relay capability
*/
async getCachedRelayCapability(
url: string
): Promise<TGraphQueryCapability | null | undefined> {
try {
const db = await this.getDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORES.RELAY_CAPABILITIES, 'readonly')
const store = tx.objectStore(STORES.RELAY_CAPABILITIES)
const request = store.get(url)
request.onsuccess = () => {
const entry = request.result as
| CachedEntry<TGraphQueryCapability | null>
| undefined
if (!entry) {
resolve(undefined) // Not in cache
return
}
if (Date.now() - entry.timestamp > CACHE_EXPIRY.RELAY_CAPABILITY) {
resolve(undefined) // Expired
return
}
resolve(entry.data)
}
request.onerror = () => reject(request.error)
})
} catch (error) {
console.error('Failed to get cached relay capability:', error)
return undefined
}
}
/**
* Invalidate follow graph cache for a pubkey
*/
async invalidateFollowGraph(pubkey: string): Promise<void> {
try {
const db = await this.getDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(STORES.FOLLOW_GRAPH, 'readwrite')
const store = tx.objectStore(STORES.FOLLOW_GRAPH)
// Delete entries for all depths
for (let depth = 1; depth <= 16; depth++) {
store.delete(`${pubkey}:${depth}`)
}
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
} catch (error) {
console.error('Failed to invalidate follow graph cache:', error)
}
}
/**
* Clear all caches
*/
async clearAll(): Promise<void> {
try {
const db = await this.getDB()
return new Promise((resolve, reject) => {
const tx = db.transaction(
[STORES.FOLLOW_GRAPH, STORES.THREAD, STORES.RELAY_CAPABILITIES],
'readwrite'
)
tx.objectStore(STORES.FOLLOW_GRAPH).clear()
tx.objectStore(STORES.THREAD).clear()
tx.objectStore(STORES.RELAY_CAPABILITIES).clear()
tx.oncomplete = () => resolve()
tx.onerror = () => reject(tx.error)
})
} catch (error) {
console.error('Failed to clear graph cache:', error)
}
}
}
const instance = GraphCacheService.getInstance()
export default instance

View File

@@ -0,0 +1,326 @@
import { normalizeUrl } from '@/lib/url'
import { TGraphQueryCapability, TRelayInfo } from '@/types'
import { GraphQuery, GraphResponse } from '@/types/graph'
import { Event as NEvent, Filter, SimplePool, verifyEvent } from 'nostr-tools'
import relayInfoService from './relay-info.service'
import storage from './local-storage.service'
// Graph query response kinds (relay-signed)
const GRAPH_RESPONSE_KINDS = {
FOLLOWS: 39000,
MENTIONS: 39001,
THREAD: 39002
}
class GraphQueryService {
static instance: GraphQueryService
private pool: SimplePool
private capabilityCache = new Map<string, TGraphQueryCapability | null>()
private capabilityFetchPromises = new Map<string, Promise<TGraphQueryCapability | null>>()
constructor() {
this.pool = new SimplePool()
}
public static getInstance(): GraphQueryService {
if (!GraphQueryService.instance) {
GraphQueryService.instance = new GraphQueryService()
}
return GraphQueryService.instance
}
/**
* Check if graph queries are enabled in settings
*/
isEnabled(): boolean {
return storage.getGraphQueriesEnabled()
}
/**
* Get relay's graph query capability via NIP-11
*/
async getRelayCapability(url: string): Promise<TGraphQueryCapability | null> {
const normalizedUrl = normalizeUrl(url)
// Check memory cache first
if (this.capabilityCache.has(normalizedUrl)) {
return this.capabilityCache.get(normalizedUrl) ?? null
}
// Check if already fetching
const existingPromise = this.capabilityFetchPromises.get(normalizedUrl)
if (existingPromise) {
return existingPromise
}
// Fetch capability
const fetchPromise = this._fetchRelayCapability(normalizedUrl)
this.capabilityFetchPromises.set(normalizedUrl, fetchPromise)
try {
const capability = await fetchPromise
this.capabilityCache.set(normalizedUrl, capability)
return capability
} finally {
this.capabilityFetchPromises.delete(normalizedUrl)
}
}
private async _fetchRelayCapability(url: string): Promise<TGraphQueryCapability | null> {
try {
const relayInfo = (await relayInfoService.getRelayInfo(url)) as TRelayInfo | undefined
if (!relayInfo?.graph_query?.enabled) {
return null
}
return relayInfo.graph_query
} catch {
return null
}
}
/**
* Check if a relay supports a specific graph query method
*/
async supportsMethod(url: string, method: GraphQuery['method']): Promise<boolean> {
const capability = await this.getRelayCapability(url)
if (!capability?.enabled) return false
return capability.methods.includes(method)
}
/**
* Find a relay supporting graph queries from a list of URLs
*/
async findGraphCapableRelay(
urls: string[],
method?: GraphQuery['method']
): Promise<string | null> {
if (!this.isEnabled()) return null
// Check capabilities in parallel
const results = await Promise.all(
urls.map(async (url) => {
const capability = await this.getRelayCapability(url)
if (!capability?.enabled) return null
if (method && !capability.methods.includes(method)) return null
return url
})
)
return results.find((url) => url !== null) ?? null
}
/**
* Execute a graph query against a specific relay
*/
async executeQuery(relayUrl: string, query: GraphQuery): Promise<GraphResponse | null> {
if (!this.isEnabled()) return null
const capability = await this.getRelayCapability(relayUrl)
if (!capability?.enabled) {
console.warn(`Relay ${relayUrl} does not support graph queries`)
return null
}
// Validate method support
if (!capability.methods.includes(query.method)) {
console.warn(`Relay ${relayUrl} does not support method: ${query.method}`)
return null
}
// Validate depth
const depth = query.depth ?? 1
if (depth > capability.max_depth) {
console.warn(`Requested depth ${depth} exceeds relay max ${capability.max_depth}`)
query = { ...query, depth: capability.max_depth }
}
// Build the filter with graph extension
// The _graph field is a custom extension not in the standard Filter type
const filter = {
_graph: query
} as Filter
// Determine expected response kind
const expectedKind = this.getExpectedResponseKind(query.method)
return new Promise<GraphResponse | null>(async (resolve) => {
let resolved = false
const timeout = setTimeout(() => {
if (!resolved) {
resolved = true
resolve(null)
}
}, 30000) // 30s timeout for graph queries
try {
const relay = await this.pool.ensureRelay(relayUrl, { connectionTimeout: 5000 })
const sub = relay.subscribe([filter], {
onevent: (event: NEvent) => {
// Verify it's a relay-signed graph response
if (event.kind !== expectedKind) return
// Verify event signature
if (!verifyEvent(event)) {
console.warn('Invalid signature on graph response')
return
}
try {
const response = JSON.parse(event.content) as GraphResponse
if (!resolved) {
resolved = true
clearTimeout(timeout)
sub.close()
resolve(response)
}
} catch (e) {
console.error('Failed to parse graph response:', e)
}
},
oneose: () => {
// If we got EOSE without a response, the query may not be supported
if (!resolved) {
resolved = true
clearTimeout(timeout)
sub.close()
resolve(null)
}
}
})
} catch (error) {
console.error('Failed to connect to relay for graph query:', error)
if (!resolved) {
resolved = true
clearTimeout(timeout)
resolve(null)
}
}
})
}
private getExpectedResponseKind(method: GraphQuery['method']): number {
switch (method) {
case 'follows':
case 'followers':
return GRAPH_RESPONSE_KINDS.FOLLOWS
case 'mentions':
return GRAPH_RESPONSE_KINDS.MENTIONS
case 'thread':
return GRAPH_RESPONSE_KINDS.THREAD
default:
return GRAPH_RESPONSE_KINDS.FOLLOWS
}
}
/**
* High-level method: Query follow graph with fallback
*/
async queryFollowGraph(
relayUrls: string[],
seed: string,
depth: number = 1
): Promise<GraphResponse | null> {
const graphRelay = await this.findGraphCapableRelay(relayUrls, 'follows')
if (!graphRelay) return null
return this.executeQuery(graphRelay, {
method: 'follows',
seed,
depth
})
}
/**
* High-level method: Query follower graph
*/
async queryFollowerGraph(
relayUrls: string[],
seed: string,
depth: number = 1
): Promise<GraphResponse | null> {
const graphRelay = await this.findGraphCapableRelay(relayUrls, 'followers')
if (!graphRelay) return null
return this.executeQuery(graphRelay, {
method: 'followers',
seed,
depth
})
}
/**
* High-level method: Query thread with optional ref aggregation
*/
async queryThread(
relayUrls: string[],
eventId: string,
depth: number = 10,
options?: {
inboundRefKinds?: number[]
outboundRefKinds?: number[]
}
): Promise<GraphResponse | null> {
const graphRelay = await this.findGraphCapableRelay(relayUrls, 'thread')
if (!graphRelay) return null
const query: GraphQuery = {
method: 'thread',
seed: eventId,
depth
}
if (options?.inboundRefKinds?.length) {
query.inbound_refs = [{ kinds: options.inboundRefKinds, from_depth: 0 }]
}
if (options?.outboundRefKinds?.length) {
query.outbound_refs = [{ kinds: options.outboundRefKinds, from_depth: 0 }]
}
return this.executeQuery(graphRelay, query)
}
/**
* High-level method: Query mentions with aggregation
*/
async queryMentions(
relayUrls: string[],
pubkey: string,
options?: {
inboundRefKinds?: number[] // e.g., [7, 9735] for reactions and zaps
}
): Promise<GraphResponse | null> {
const graphRelay = await this.findGraphCapableRelay(relayUrls, 'mentions')
if (!graphRelay) return null
const query: GraphQuery = {
method: 'mentions',
seed: pubkey
}
if (options?.inboundRefKinds?.length) {
query.inbound_refs = [{ kinds: options.inboundRefKinds, from_depth: 0 }]
}
return this.executeQuery(graphRelay, query)
}
/**
* Clear capability cache for a relay (e.g., when relay info is updated)
*/
clearCapabilityCache(url?: string): void {
if (url) {
const normalizedUrl = normalizeUrl(url)
this.capabilityCache.delete(normalizedUrl)
} else {
this.capabilityCache.clear()
}
}
}
const instance = GraphQueryService.getInstance()
export default instance

View File

@@ -1,7 +1,7 @@
import { ExtendedKind } from '@/constants'
import { tagNameEquals } from '@/lib/tag'
import { TDMDeletedState, TRelayInfo } from '@/types'
import { Event, kinds } from 'nostr-tools'
import { Event, Filter, kinds, matchFilters } from 'nostr-tools'
type TValue<T = any> = {
key: string
@@ -1014,6 +1014,84 @@ class IndexedDbService {
}
}
/**
* Query all events across all stores for NRC sync.
* Returns events matching the provided filters.
*
* Note: This method queries all event-containing stores and filters
* client-side using matchFilters. Device-specific event filtering
* should be done by the caller.
*/
async queryEventsForNRC(filters: Filter[]): Promise<Event[]> {
await this.initPromise
if (!this.db) {
return []
}
// List of stores that contain Event objects
const eventStores = [
StoreNames.PROFILE_EVENTS,
StoreNames.RELAY_LIST_EVENTS,
StoreNames.FOLLOW_LIST_EVENTS,
StoreNames.MUTE_LIST_EVENTS,
StoreNames.BOOKMARK_LIST_EVENTS,
StoreNames.BLOSSOM_SERVER_LIST_EVENTS,
StoreNames.USER_EMOJI_LIST_EVENTS,
StoreNames.EMOJI_SET_EVENTS,
StoreNames.PIN_LIST_EVENTS,
StoreNames.PINNED_USERS_EVENTS,
StoreNames.FAVORITE_RELAYS,
StoreNames.RELAY_SETS,
StoreNames.DM_EVENTS
]
const allEvents: Event[] = []
// Query each store
const transaction = this.db.transaction(eventStores, 'readonly')
await Promise.all(
eventStores.map(
(storeName) =>
new Promise<void>((resolve) => {
const store = transaction.objectStore(storeName)
const request = store.openCursor()
request.onsuccess = (event) => {
const cursor = (event.target as IDBRequest).result
if (cursor) {
const value = cursor.value as TValue<Event | null>
if (value.value) {
// Check if event matches any of the filters
if (matchFilters(filters, value.value)) {
allEvents.push(value.value)
}
}
cursor.continue()
} else {
resolve()
}
}
request.onerror = () => {
resolve() // Continue even if one store fails
}
})
)
)
// Sort by created_at descending (newest first)
allEvents.sort((a, b) => b.created_at - a.created_at)
// Apply limit from filters if specified
const limit = Math.min(...filters.map((f) => f.limit ?? Infinity))
if (limit !== Infinity && limit > 0) {
return allEvents.slice(0, limit)
}
return allEvents
}
private async cleanUp() {
await this.initPromise
if (!this.db) {

View File

@@ -1,7 +1,6 @@
import {
ALLOWED_FILTER_KINDS,
DEFAULT_FAVICON_URL_TEMPLATE,
DEFAULT_NIP_96_SERVICE,
ExtendedKind,
MEDIA_AUTO_LOAD_POLICY,
NOTIFICATION_LIST_STYLE,
@@ -40,7 +39,6 @@ class LocalStorageService {
private defaultZapComment: string = 'Zap!'
private quickZap: boolean = false
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
private autoplay: boolean = true
private hideUntrustedInteractions: boolean = false
private hideUntrustedNotifications: boolean = false
@@ -62,6 +60,10 @@ class LocalStorageService {
private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
private preferNip44: boolean = false
private dmConversationFilter: 'all' | 'follows' = 'all'
private graphQueriesEnabled: boolean = true
private socialGraphProximity: number | null = null
private socialGraphIncludeMode: boolean = true // true = include only, false = exclude
private nrcOnlyConfigSync: boolean = false
constructor() {
if (!LocalStorageService.instance) {
@@ -124,10 +126,6 @@ class LocalStorageService {
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
// deprecated
this.mediaUploadService =
window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE
this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false'
const hideUntrustedEvents =
@@ -253,6 +251,22 @@ class LocalStorageService {
this.preferNip44 = window.localStorage.getItem(StorageKey.PREFER_NIP44) === 'true'
this.dmConversationFilter =
(window.localStorage.getItem(StorageKey.DM_CONVERSATION_FILTER) as 'all' | 'follows') || 'all'
this.graphQueriesEnabled =
window.localStorage.getItem(StorageKey.GRAPH_QUERIES_ENABLED) !== 'false'
const socialGraphProximityStr = window.localStorage.getItem(StorageKey.SOCIAL_GRAPH_PROXIMITY)
if (socialGraphProximityStr) {
const parsed = parseInt(socialGraphProximityStr)
if (!isNaN(parsed) && parsed >= 1 && parsed <= 2) {
this.socialGraphProximity = parsed
}
}
this.socialGraphIncludeMode =
window.localStorage.getItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE) !== 'false'
this.nrcOnlyConfigSync =
window.localStorage.getItem(StorageKey.NRC_ONLY_CONFIG_SYNC) === 'true'
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
@@ -439,7 +453,7 @@ class LocalStorageService {
}
getMediaUploadServiceConfig(pubkey?: string | null): TMediaUploadServiceConfig {
const defaultConfig = { type: 'nip96', service: this.mediaUploadService } as const
const defaultConfig = { type: 'blossom' } as const
if (!pubkey) {
return defaultConfig
}
@@ -645,6 +659,46 @@ class LocalStorageService {
map[pubkey] = timestamp
window.localStorage.setItem(StorageKey.DM_LAST_SEEN_TIMESTAMP, JSON.stringify(map))
}
getGraphQueriesEnabled() {
return this.graphQueriesEnabled
}
setGraphQueriesEnabled(enabled: boolean) {
this.graphQueriesEnabled = enabled
window.localStorage.setItem(StorageKey.GRAPH_QUERIES_ENABLED, enabled.toString())
}
getSocialGraphProximity(): number | null {
return this.socialGraphProximity
}
setSocialGraphProximity(depth: number | null) {
this.socialGraphProximity = depth
if (depth === null) {
window.localStorage.removeItem(StorageKey.SOCIAL_GRAPH_PROXIMITY)
} else {
window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_PROXIMITY, depth.toString())
}
}
getSocialGraphIncludeMode(): boolean {
return this.socialGraphIncludeMode
}
setSocialGraphIncludeMode(include: boolean) {
this.socialGraphIncludeMode = include
window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE, include.toString())
}
getNrcOnlyConfigSync() {
return this.nrcOnlyConfigSync
}
setNrcOnlyConfigSync(nrcOnly: boolean) {
this.nrcOnlyConfigSync = nrcOnly
window.localStorage.setItem(StorageKey.NRC_ONLY_CONFIG_SYNC, nrcOnly.toString())
}
}
const instance = new LocalStorageService()

View File

@@ -0,0 +1,6 @@
export * from './nrc-types'
export * from './nrc-uri'
export * from './nrc-session'
export { NRCListenerService, getNRCListenerService, default as nrcListenerService } from './nrc-listener.service'
export { NRCClient, syncFromRemote, testConnection, requestRemoteIDs, sendEventsToRemote } from './nrc-client.service'
export type { SyncProgress, RemoteConnection } from './nrc-client.service'

View File

@@ -0,0 +1,790 @@
/**
* NRC (Nostr Relay Connect) Client Service
*
* Connects to a remote NRC listener and syncs events.
* Uses the nostr+relayconnect:// URI scheme to establish encrypted
* communication through a rendezvous relay.
*/
import { Event, Filter } from 'nostr-tools'
import * as nip44 from 'nostr-tools/nip44'
import * as utils from '@noble/curves/abstract/utils'
import { finalizeEvent } from 'nostr-tools'
import {
KIND_NRC_REQUEST,
KIND_NRC_RESPONSE,
RequestMessage,
ResponseMessage,
ParsedConnectionURI,
EventManifestEntry
} from './nrc-types'
import { parseConnectionURI, deriveConversationKey } from './nrc-uri'
/**
* Generate a random subscription ID
*/
function generateSubId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(8))
return utils.bytesToHex(bytes)
}
/**
* Generate a random session ID
*/
function generateSessionId(): string {
return crypto.randomUUID()
}
/**
* Sync progress callback
*/
export interface SyncProgress {
phase: 'connecting' | 'requesting' | 'receiving' | 'sending' | 'complete' | 'error'
eventsReceived: number
eventsSent?: number
message?: string
}
/**
* Remote connection state
*/
export interface RemoteConnection {
id: string
uri: string
label: string
relayPubkey: string
rendezvousUrl: string
lastSync?: number
eventCount?: number
}
// Chunk buffer for reassembling large messages
interface ChunkBuffer {
chunks: Map<number, string>
total: number
receivedAt: number
}
// Default sync timeout: 60 seconds
const DEFAULT_SYNC_TIMEOUT = 60000
/**
* NRC Client for connecting to remote devices
*/
export class NRCClient {
private uri: ParsedConnectionURI
private ws: WebSocket | null = null
private sessionId: string
private connected = false
private subId: string | null = null
private pendingEvents: Event[] = []
private onProgress?: (progress: SyncProgress) => void
private resolveSync?: (events: Event[]) => void
private rejectSync?: (error: Error) => void
private chunkBuffers: Map<string, ChunkBuffer> = new Map()
private syncTimeout: ReturnType<typeof setTimeout> | null = null
private lastActivityTime: number = 0
constructor(connectionUri: string) {
this.uri = parseConnectionURI(connectionUri)
this.sessionId = generateSessionId()
}
/**
* Get the relay pubkey this client connects to
*/
getRelayPubkey(): string {
return this.uri.relayPubkey
}
/**
* Get the rendezvous URL
*/
getRendezvousUrl(): string {
return this.uri.rendezvousUrl
}
/**
* Connect to the rendezvous relay and sync events
*/
async sync(
filters: Filter[],
onProgress?: (progress: SyncProgress) => void,
timeout: number = DEFAULT_SYNC_TIMEOUT
): Promise<Event[]> {
this.onProgress = onProgress
this.pendingEvents = []
this.chunkBuffers.clear()
this.lastActivityTime = Date.now()
return new Promise<Event[]>((resolve, reject) => {
this.resolveSync = resolve
this.rejectSync = reject
// Set up sync timeout
this.syncTimeout = setTimeout(() => {
const timeSinceActivity = Date.now() - this.lastActivityTime
if (timeSinceActivity > 30000) {
// No activity for 30s, likely stalled
console.error('[NRC Client] Sync timeout - no activity for 30s')
this.disconnect()
reject(new Error('Sync timeout - connection stalled'))
} else {
// Still receiving data, extend timeout
console.log('[NRC Client] Sync still active, extending timeout')
this.syncTimeout = setTimeout(() => {
this.disconnect()
reject(new Error('Sync timeout'))
}, timeout)
}
}, timeout)
this.connect()
.then(() => {
this.sendREQ(filters)
})
.catch((err) => {
this.clearSyncTimeout()
reject(err)
})
})
}
// State for IDS request
private idsMode = false
private resolveIDs?: (manifest: EventManifestEntry[]) => void
private rejectIDs?: (error: Error) => void
// State for sending events
private sendingEvents = false
private eventsSentCount = 0
private eventsToSend: Event[] = []
private resolveSend?: (count: number) => void
/**
* Request event IDs from remote (for diffing)
*/
async requestIDs(
filters: Filter[],
onProgress?: (progress: SyncProgress) => void,
timeout: number = DEFAULT_SYNC_TIMEOUT
): Promise<EventManifestEntry[]> {
this.onProgress = onProgress
this.chunkBuffers.clear()
this.lastActivityTime = Date.now()
this.idsMode = true
return new Promise<EventManifestEntry[]>((resolve, reject) => {
this.resolveIDs = resolve
this.rejectIDs = reject
this.syncTimeout = setTimeout(() => {
this.disconnect()
reject(new Error('IDS request timeout'))
}, timeout)
this.connect()
.then(() => {
this.sendIDSRequest(filters)
})
.catch((err) => {
this.clearSyncTimeout()
reject(err)
})
})
}
/**
* Send IDS request
*/
private sendIDSRequest(filters: Filter[]): void {
if (!this.ws || !this.connected) {
this.rejectIDs?.(new Error('Not connected'))
return
}
this.onProgress?.({
phase: 'requesting',
eventsReceived: 0,
message: 'Requesting event IDs...'
})
this.subId = generateSubId()
const request: RequestMessage = {
type: 'IDS',
payload: ['IDS', this.subId, ...filters]
}
this.sendEncryptedRequest(request).catch((err) => {
console.error('[NRC Client] Failed to send IDS:', err)
this.rejectIDs?.(err)
})
}
/**
* Send events to remote device
*/
async sendEvents(
events: Event[],
onProgress?: (progress: SyncProgress) => void,
timeout: number = DEFAULT_SYNC_TIMEOUT
): Promise<number> {
if (events.length === 0) return 0
this.onProgress = onProgress
this.chunkBuffers.clear()
this.lastActivityTime = Date.now()
this.sendingEvents = true
this.eventsSentCount = 0
this.eventsToSend = [...events]
return new Promise<number>((resolve, reject) => {
this.resolveSend = resolve
this.syncTimeout = setTimeout(() => {
this.disconnect()
reject(new Error('Send events timeout'))
}, timeout)
this.connect()
.then(() => {
this.sendNextEvent()
})
.catch((err) => {
this.clearSyncTimeout()
reject(err)
})
})
}
/**
* Send the next event in the queue
*/
private sendNextEvent(): void {
if (this.eventsToSend.length === 0) {
// All done
this.clearSyncTimeout()
this.onProgress?.({
phase: 'complete',
eventsReceived: 0,
eventsSent: this.eventsSentCount,
message: `Sent ${this.eventsSentCount} events`
})
this.resolveSend?.(this.eventsSentCount)
this.disconnect()
return
}
const event = this.eventsToSend.shift()!
this.onProgress?.({
phase: 'sending',
eventsReceived: 0,
eventsSent: this.eventsSentCount,
message: `Sending event ${this.eventsSentCount + 1}...`
})
const request: RequestMessage = {
type: 'EVENT',
payload: ['EVENT', event]
}
this.sendEncryptedRequest(request).catch((err) => {
console.error('[NRC Client] Failed to send EVENT:', err)
// Continue with next event even if this one failed
this.sendNextEvent()
})
}
/**
* Clear the sync timeout
*/
private clearSyncTimeout(): void {
if (this.syncTimeout) {
clearTimeout(this.syncTimeout)
this.syncTimeout = null
}
}
/**
* Update last activity time (called when receiving data)
*/
private updateActivity(): void {
this.lastActivityTime = Date.now()
}
/**
* Connect to the rendezvous relay
*/
private async connect(): Promise<void> {
if (this.connected) return
this.onProgress?.({
phase: 'connecting',
eventsReceived: 0,
message: 'Connecting to rendezvous relay...'
})
const relayUrl = this.uri.rendezvousUrl
return new Promise<void>((resolve, reject) => {
// Normalize WebSocket URL
let wsUrl = relayUrl
if (relayUrl.startsWith('http://')) {
wsUrl = 'ws://' + relayUrl.slice(7)
} else if (relayUrl.startsWith('https://')) {
wsUrl = 'wss://' + relayUrl.slice(8)
} else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
wsUrl = 'wss://' + relayUrl
}
console.log(`[NRC Client] Connecting to: ${wsUrl}`)
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Connection timeout'))
}, 10000)
ws.onopen = () => {
clearTimeout(timeout)
this.ws = ws
this.connected = true
// Subscribe to responses for our client pubkey
const responseSubId = generateSubId()
const clientPubkey = this.uri.clientPubkey
if (!clientPubkey) {
reject(new Error('Client pubkey not available'))
return
}
ws.send(
JSON.stringify([
'REQ',
responseSubId,
{
kinds: [KIND_NRC_RESPONSE],
'#p': [clientPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
])
)
console.log(`[NRC Client] Connected, subscribed for responses to ${clientPubkey.slice(0, 8)}...`)
resolve()
}
ws.onerror = (error) => {
clearTimeout(timeout)
console.error('[NRC Client] WebSocket error:', error)
reject(new Error('WebSocket error'))
}
ws.onclose = () => {
this.connected = false
this.ws = null
console.log('[NRC Client] WebSocket closed')
}
ws.onmessage = (event) => {
this.handleMessage(event.data)
}
})
}
/**
* Send a REQ message to the remote listener
*/
private sendREQ(filters: Filter[]): void {
if (!this.ws || !this.connected) {
this.rejectSync?.(new Error('Not connected'))
return
}
console.log(`[NRC Client] Sending REQ to listener pubkey: ${this.uri.relayPubkey?.slice(0, 8)}...`)
console.log(`[NRC Client] Our client pubkey: ${this.uri.clientPubkey?.slice(0, 8)}...`)
console.log(`[NRC Client] Filters:`, JSON.stringify(filters))
this.onProgress?.({
phase: 'requesting',
eventsReceived: 0,
message: 'Requesting events...'
})
this.subId = generateSubId()
const request: RequestMessage = {
type: 'REQ',
payload: ['REQ', this.subId, ...filters]
}
this.sendEncryptedRequest(request).catch((err) => {
console.error('[NRC Client] Failed to send request:', err)
this.rejectSync?.(err)
})
}
/**
* Send an encrypted request to the remote listener
*/
private async sendEncryptedRequest(request: RequestMessage): Promise<void> {
if (!this.ws) {
throw new Error('Not connected')
}
if (!this.uri.clientPrivkey || !this.uri.clientPubkey) {
throw new Error('Missing keys')
}
const plaintext = JSON.stringify(request)
// Derive conversation key
const conversationKey = deriveConversationKey(
this.uri.clientPrivkey,
this.uri.relayPubkey
)
const encrypted = nip44.v2.encrypt(plaintext, conversationKey)
// Build the request event
const unsignedEvent = {
kind: KIND_NRC_REQUEST,
content: encrypted,
tags: [
['p', this.uri.relayPubkey],
['encryption', 'nip44_v2'],
['session', this.sessionId]
],
created_at: Math.floor(Date.now() / 1000),
pubkey: this.uri.clientPubkey
}
const signedEvent = finalizeEvent(unsignedEvent, this.uri.clientPrivkey)
// Send to rendezvous relay
this.ws.send(JSON.stringify(['EVENT', signedEvent]))
console.log(`[NRC Client] Sent encrypted REQ, event id: ${signedEvent.id?.slice(0, 8)}..., p-tag: ${this.uri.relayPubkey?.slice(0, 8)}...`)
}
/**
* Handle incoming WebSocket messages
*/
private handleMessage(data: string): void {
try {
const msg = JSON.parse(data)
if (!Array.isArray(msg)) return
const [type, ...rest] = msg
if (type === 'EVENT') {
const [subId, event] = rest as [string, Event]
console.log(`[NRC Client] Received EVENT on sub ${subId}, kind ${event.kind}, from ${event.pubkey?.slice(0, 8)}...`)
if (event.kind === KIND_NRC_RESPONSE) {
// Check p-tag to see who it's addressed to
const pTag = event.tags.find(t => t[0] === 'p')?.[1]
console.log(`[NRC Client] Response p-tag: ${pTag?.slice(0, 8)}..., our pubkey: ${this.uri.clientPubkey?.slice(0, 8)}...`)
this.handleResponse(event)
} else {
console.log(`[NRC Client] Ignoring event kind ${event.kind}`)
}
} else if (type === 'EOSE') {
console.log('[NRC Client] Received EOSE from relay subscription')
} else if (type === 'OK') {
console.log('[NRC Client] Event published:', rest)
} else if (type === 'NOTICE') {
console.log('[NRC Client] Relay notice:', rest[0])
}
} catch (err) {
console.error('[NRC Client] Failed to parse message:', err)
}
}
/**
* Handle a response event from the remote listener
*/
private handleResponse(event: Event): void {
console.log(`[NRC Client] Attempting to decrypt response from ${event.pubkey?.slice(0, 8)}...`)
this.decryptAndProcessResponse(event).catch((err) => {
console.error('[NRC Client] Failed to handle response:', err)
})
}
/**
* Decrypt and process a response event
*/
private async decryptAndProcessResponse(event: Event): Promise<void> {
if (!this.uri.clientPrivkey) {
throw new Error('Missing private key for decryption')
}
const conversationKey = deriveConversationKey(
this.uri.clientPrivkey,
this.uri.relayPubkey
)
const plaintext = nip44.v2.decrypt(event.content, conversationKey)
const response: ResponseMessage = JSON.parse(plaintext)
console.log(`[NRC Client] Received response: ${response.type}`)
// Handle chunked messages
if (response.type === 'CHUNK') {
this.handleChunk(response)
return
}
this.processResponse(response)
}
/**
* Handle a chunk message and reassemble when complete
*/
private handleChunk(response: ResponseMessage): void {
const chunk = response.payload[0] as {
type: 'CHUNK'
messageId: string
index: number
total: number
data: string
}
if (!chunk || chunk.type !== 'CHUNK') {
console.error('[NRC Client] Invalid chunk message')
return
}
const { messageId, index, total, data } = chunk
// Get or create buffer for this message
let buffer = this.chunkBuffers.get(messageId)
if (!buffer) {
buffer = {
chunks: new Map(),
total,
receivedAt: Date.now()
}
this.chunkBuffers.set(messageId, buffer)
}
// Store the chunk
buffer.chunks.set(index, data)
this.updateActivity()
console.log(`[NRC Client] Received chunk ${index + 1}/${total} for message ${messageId.slice(0, 8)}`)
// Check if we have all chunks
if (buffer.chunks.size === buffer.total) {
// Reassemble the message
const parts: string[] = []
for (let i = 0; i < buffer.total; i++) {
const part = buffer.chunks.get(i)
if (!part) {
console.error(`[NRC Client] Missing chunk ${i} for message ${messageId}`)
this.chunkBuffers.delete(messageId)
return
}
parts.push(part)
}
// Decode from base64
const encoded = parts.join('')
try {
const plaintext = decodeURIComponent(escape(atob(encoded)))
const reassembled: ResponseMessage = JSON.parse(plaintext)
console.log(`[NRC Client] Reassembled chunked message: ${reassembled.type}`)
this.processResponse(reassembled)
} catch (err) {
console.error('[NRC Client] Failed to reassemble chunked message:', err)
}
// Clean up buffer
this.chunkBuffers.delete(messageId)
}
// Clean up old buffers (older than 60 seconds)
const now = Date.now()
for (const [id, buf] of this.chunkBuffers) {
if (now - buf.receivedAt > 60000) {
console.warn(`[NRC Client] Discarding stale chunk buffer: ${id}`)
this.chunkBuffers.delete(id)
}
}
}
/**
* Process a complete response message
*/
private processResponse(response: ResponseMessage): void {
this.updateActivity()
switch (response.type) {
case 'EVENT': {
// Extract the event from payload: ["EVENT", subId, eventObject]
const [, , syncedEvent] = response.payload as [string, string, Event]
if (syncedEvent) {
this.pendingEvents.push(syncedEvent)
this.onProgress?.({
phase: 'receiving',
eventsReceived: this.pendingEvents.length,
message: `Received ${this.pendingEvents.length} events...`
})
}
break
}
case 'EOSE': {
console.log(`[NRC Client] EOSE received, got ${this.pendingEvents.length} events`)
this.complete()
break
}
case 'NOTICE': {
const [, message] = response.payload as [string, string]
console.log(`[NRC Client] Notice: ${message}`)
this.onProgress?.({
phase: 'error',
eventsReceived: this.pendingEvents.length,
message: message
})
break
}
case 'OK': {
// Response to EVENT publish
if (this.sendingEvents) {
const [, eventId, success, message] = response.payload as [string, string, boolean, string]
if (success) {
this.eventsSentCount++
console.log(`[NRC Client] Event ${eventId?.slice(0, 8)} stored successfully`)
} else {
console.warn(`[NRC Client] Event ${eventId?.slice(0, 8)} failed: ${message}`)
}
// Send next event
this.sendNextEvent()
}
break
}
case 'IDS': {
// Response to IDS request - contains event manifest
if (this.idsMode) {
const [, , manifest] = response.payload as [string, string, EventManifestEntry[]]
console.log(`[NRC Client] Received IDS response with ${manifest?.length || 0} entries`)
this.clearSyncTimeout()
this.resolveIDs?.(manifest || [])
this.disconnect()
}
break
}
default:
console.log(`[NRC Client] Unknown response type: ${response.type}`)
}
}
/**
* Complete the sync operation
*/
private complete(): void {
this.clearSyncTimeout()
this.onProgress?.({
phase: 'complete',
eventsReceived: this.pendingEvents.length,
message: `Synced ${this.pendingEvents.length} events`
})
this.resolveSync?.(this.pendingEvents)
this.disconnect()
}
/**
* Disconnect from the rendezvous relay
*/
disconnect(): void {
this.clearSyncTimeout()
if (this.ws) {
this.ws.close()
this.ws = null
}
this.connected = false
}
}
/**
* Sync events from a remote device
*
* @param connectionUri - The nostr+relayconnect:// URI
* @param filters - Nostr filters for events to sync
* @param onProgress - Optional progress callback
* @returns Array of synced events
*/
export async function syncFromRemote(
connectionUri: string,
filters: Filter[],
onProgress?: (progress: SyncProgress) => void
): Promise<Event[]> {
const client = new NRCClient(connectionUri)
return client.sync(filters, onProgress)
}
/**
* Test connection to a remote device
* Performs a minimal sync (kind 0 with limit 1) to verify the connection works
*
* @param connectionUri - The nostr+relayconnect:// URI
* @param onProgress - Optional progress callback
* @returns true if connection successful
*/
export async function testConnection(
connectionUri: string,
onProgress?: (progress: SyncProgress) => void
): Promise<boolean> {
const client = new NRCClient(connectionUri)
try {
// Request just one profile event to test the full round-trip
const events = await client.sync(
[{ kinds: [0], limit: 1 }],
onProgress,
15000 // 15 second timeout for test
)
console.log(`[NRC] Test connection successful, received ${events.length} events`)
return true
} catch (err) {
console.error('[NRC] Test connection failed:', err)
throw err
}
}
/**
* Request event IDs from a remote device (for diffing)
*
* @param connectionUri - The nostr+relayconnect:// URI
* @param filters - Filters to match events
* @param onProgress - Optional progress callback
* @returns Array of event manifest entries (id, kind, created_at, d)
*/
export async function requestRemoteIDs(
connectionUri: string,
filters: Filter[],
onProgress?: (progress: SyncProgress) => void
): Promise<EventManifestEntry[]> {
const client = new NRCClient(connectionUri)
return client.requestIDs(filters, onProgress)
}
/**
* Send events to a remote device
*
* @param connectionUri - The nostr+relayconnect:// URI
* @param events - Events to send
* @param onProgress - Optional progress callback
* @returns Number of events successfully stored
*/
export async function sendEventsToRemote(
connectionUri: string,
events: Event[],
onProgress?: (progress: SyncProgress) => void
): Promise<number> {
const client = new NRCClient(connectionUri)
return client.sendEvents(events, onProgress)
}

View File

@@ -0,0 +1,683 @@
/**
* NRC (Nostr Relay Connect) Listener Service
*
* Listens for NRC requests (kind 24891) on a rendezvous relay and responds
* with events from the local IndexedDB. This allows other user clients to
* sync their data through this client.
*
* Protocol:
* - Client sends kind 24891 request with encrypted REQ/CLOSE message
* - This listener decrypts, queries local storage, and responds with kind 24892
* - All content is NIP-44 encrypted end-to-end
*/
import { Event, Filter } from 'nostr-tools'
import * as utils from '@noble/curves/abstract/utils'
import indexedDb from '@/services/indexed-db.service'
import {
KIND_NRC_REQUEST,
KIND_NRC_RESPONSE,
NRCListenerConfig,
RequestMessage,
ResponseMessage,
AuthResult,
NRCSession,
isDeviceSpecificEvent,
EventManifestEntry
} from './nrc-types'
import { NRCSessionManager } from './nrc-session'
/**
* Generate a random subscription ID
*/
function generateSubId(): string {
const bytes = crypto.getRandomValues(new Uint8Array(8))
return utils.bytesToHex(bytes)
}
/**
* NRC Listener Service
*
* Listens for incoming NRC requests and responds with local events.
*/
export class NRCListenerService {
private config: NRCListenerConfig | null = null
private sessions: NRCSessionManager
private ws: WebSocket | null = null
private subId: string | null = null
private connected = false
private running = false
private reconnectTimeout: ReturnType<typeof setTimeout> | null = null
private reconnectDelay = 1000 // Start with 1 second
private maxReconnectDelay = 30000 // Max 30 seconds
private listenerPubkey: string | null = null
// Event callbacks
private onSessionChange?: (count: number) => void
constructor() {
this.sessions = new NRCSessionManager()
}
/**
* Set callback for session count changes
*/
setOnSessionChange(callback: (count: number) => void): void {
this.onSessionChange = callback
}
/**
* Start listening for NRC requests
*/
async start(config: NRCListenerConfig): Promise<void> {
if (this.running) {
console.warn('[NRC] Listener already running')
return
}
this.config = config
this.running = true
// Get our public key
this.listenerPubkey = await config.signer.getPublicKey()
// Start session cleanup
this.sessions.start()
// Connect to rendezvous relay
await this.connectToRelay()
}
/**
* Stop listening
*/
stop(): void {
this.running = false
if (this.reconnectTimeout) {
clearTimeout(this.reconnectTimeout)
this.reconnectTimeout = null
}
if (this.ws) {
// Unsubscribe
if (this.subId) {
try {
this.ws.send(JSON.stringify(['CLOSE', this.subId]))
} catch {
// Ignore errors when closing
}
}
this.ws.close()
this.ws = null
}
this.sessions.stop()
this.connected = false
this.subId = null
console.log('[NRC] Listener stopped')
}
/**
* Check if listener is running
*/
isRunning(): boolean {
return this.running
}
/**
* Check if connected to rendezvous relay
*/
isConnected(): boolean {
return this.connected
}
/**
* Get active session count
*/
getActiveSessionCount(): number {
return this.sessions.getActiveSessionCount()
}
/**
* Connect to the rendezvous relay
*/
private async connectToRelay(): Promise<void> {
if (!this.config || !this.running) return Promise.resolve()
const relayUrl = this.config.rendezvousUrl
return new Promise<void>((resolve, reject) => {
// Normalize WebSocket URL
let wsUrl = relayUrl
if (relayUrl.startsWith('http://')) {
wsUrl = 'ws://' + relayUrl.slice(7)
} else if (relayUrl.startsWith('https://')) {
wsUrl = 'wss://' + relayUrl.slice(8)
} else if (!relayUrl.startsWith('ws://') && !relayUrl.startsWith('wss://')) {
wsUrl = 'wss://' + relayUrl
}
console.log(`[NRC] Connecting to rendezvous relay: ${wsUrl}`)
const ws = new WebSocket(wsUrl)
const timeout = setTimeout(() => {
ws.close()
reject(new Error('Connection timeout'))
}, 10000)
ws.onopen = () => {
clearTimeout(timeout)
this.ws = ws
this.connected = true
this.reconnectDelay = 1000 // Reset reconnect delay on success
// Subscribe to NRC requests for our pubkey
this.subId = generateSubId()
ws.send(
JSON.stringify([
'REQ',
this.subId,
{
kinds: [KIND_NRC_REQUEST],
'#p': [this.listenerPubkey],
since: Math.floor(Date.now() / 1000) - 60
}
])
)
console.log(`[NRC] Connected and subscribed with subId: ${this.subId}, listening for pubkey: ${this.listenerPubkey}`)
resolve()
}
ws.onerror = (error) => {
clearTimeout(timeout)
console.error('[NRC] WebSocket error:', error)
reject(new Error('WebSocket error'))
}
ws.onclose = () => {
this.connected = false
this.ws = null
this.subId = null
console.log('[NRC] WebSocket closed')
// Attempt reconnection if still running
if (this.running) {
this.scheduleReconnect()
}
}
ws.onmessage = (event) => {
this.handleMessage(event.data)
}
}).catch((error) => {
console.error('[NRC] Failed to connect:', error)
if (this.running) {
this.scheduleReconnect()
}
})
}
/**
* Schedule reconnection with exponential backoff
*/
private scheduleReconnect(): void {
if (this.reconnectTimeout || !this.running) return
console.log(`[NRC] Scheduling reconnect in ${this.reconnectDelay}ms`)
this.reconnectTimeout = setTimeout(() => {
this.reconnectTimeout = null
this.connectToRelay()
}, this.reconnectDelay)
// Exponential backoff
this.reconnectDelay = Math.min(this.reconnectDelay * 2, this.maxReconnectDelay)
}
/**
* Handle incoming WebSocket message
*/
private handleMessage(data: string): void {
try {
const msg = JSON.parse(data)
if (!Array.isArray(msg)) return
const [type, ...rest] = msg
if (type === 'EVENT') {
const [, event] = rest as [string, Event]
if (event.kind === KIND_NRC_REQUEST) {
console.log('[NRC] Received NRC request from pubkey:', event.pubkey)
this.handleRequest(event).catch((err) => {
console.error('[NRC] Error handling request:', err)
})
}
} else if (type === 'EOSE') {
// End of stored events, listener is now live
console.log('[NRC] Received EOSE, now listening for live events')
} else if (type === 'NOTICE') {
console.log('[NRC] Relay notice:', rest[0])
} else if (type === 'OK') {
// Event published successfully
} else if (type === 'CLOSED') {
console.log('[NRC] Subscription closed:', rest)
}
} catch (err) {
console.error('[NRC] Failed to parse message:', err)
}
}
/**
* Handle an NRC request event
*/
private async handleRequest(event: Event): Promise<void> {
if (!this.config) return
// Extract session ID from tags (used for correlation but we use pubkey-based sessions)
const sessionTag = event.tags.find((t) => t[0] === 'session')
const _sessionId = sessionTag?.[1]
void _sessionId // Suppress unused variable warning
try {
// Authorize the request
const authResult = await this.authorize(event)
// Get or create session
const session = this.sessions.getOrCreateSession(
event.pubkey,
undefined, // We use signer's nip44 methods instead of conversationKey
authResult.deviceName
)
// Notify session change
this.onSessionChange?.(this.sessions.getActiveSessionCount())
// Decrypt the content using signer
const plaintext = await this.decrypt(event.pubkey, event.content)
const request: RequestMessage = JSON.parse(plaintext)
console.log('[NRC] Received request:', request.type)
// Handle the request based on type
switch (request.type) {
case 'REQ':
await this.handleREQ(event, session, request.payload)
break
case 'CLOSE':
await this.handleCLOSE(session, request.payload)
break
case 'EVENT':
await this.handleEVENT(event, session, request.payload)
break
case 'IDS':
// Return just event IDs matching filters (for diffing)
await this.handleIDS(event, session, request.payload)
break
case 'COUNT':
// Not implemented
await this.sendError(event, session, 'COUNT not supported')
break
default:
await this.sendError(event, session, `Unknown message type: ${request.type}`)
}
} catch (err) {
console.error('[NRC] Request handling failed:', err)
// Try to send error response (best effort)
try {
await this.sendErrorBestEffort(event, `Request failed: ${err instanceof Error ? err.message : 'Unknown error'}`)
} catch {
// Ignore errors when sending error response
}
}
}
/**
* Authorize an incoming request
*/
private async authorize(event: Event): Promise<AuthResult> {
if (!this.config) {
throw new Error('Listener not configured')
}
// Secret-based auth: check if pubkey is authorized
const deviceName = this.config.authorizedSecrets.get(event.pubkey)
if (!deviceName) {
console.log('[NRC] Unauthorized pubkey:', event.pubkey)
console.log('[NRC] Authorized pubkeys:', Array.from(this.config.authorizedSecrets.keys()))
console.log('[NRC] Authorized pubkeys (full):', JSON.stringify(Array.from(this.config.authorizedSecrets.entries())))
throw new Error('Unauthorized: unknown client pubkey')
}
return {
deviceName
}
}
/**
* Decrypt content using the signer's NIP-44 implementation
*/
private async decrypt(clientPubkey: string, ciphertext: string): Promise<string> {
if (!this.config) {
throw new Error('Listener not configured')
}
if (!this.config.signer.nip44Decrypt) {
throw new Error('Signer does not support NIP-44 decryption')
}
return this.config.signer.nip44Decrypt(clientPubkey, ciphertext)
}
/**
* Encrypt content using the signer's NIP-44 implementation
*/
private async encrypt(clientPubkey: string, plaintext: string): Promise<string> {
if (!this.config) {
throw new Error('Listener not configured')
}
if (!this.config.signer.nip44Encrypt) {
throw new Error('Signer does not support NIP-44 encryption')
}
return this.config.signer.nip44Encrypt(clientPubkey, plaintext)
}
// Max chunk size (accounting for encryption overhead and event wrapper)
// NIP-44 adds ~100 bytes overhead, plus base64 encoding increases size by ~33%
private static readonly MAX_CHUNK_SIZE = 40000 // ~40KB chunks to stay safely under 65KB limit
/**
* Handle REQ message - query local storage and respond
*/
private async handleREQ(
reqEvent: Event,
session: NRCSession,
payload: unknown[]
): Promise<void> {
// Parse REQ: ["REQ", subId, filter1, filter2, ...]
if (payload.length < 2) {
await this.sendError(reqEvent, session, 'Invalid REQ: missing subscription ID or filters')
return
}
const [, subId, ...filterObjs] = payload as [string, string, ...Filter[]]
// Add subscription to session
const subscription = this.sessions.addSubscription(session.id, subId, filterObjs)
if (!subscription) {
await this.sendError(reqEvent, session, 'Too many subscriptions')
return
}
// Query local events matching the filters
const events = await this.queryLocalEvents(filterObjs)
console.log(`[NRC] Found ${events.length} events matching filters`)
// Send each matching event
for (const evt of events) {
const response: ResponseMessage = {
type: 'EVENT',
payload: ['EVENT', subId, evt]
}
try {
await this.sendResponseChunked(reqEvent, session, response)
this.sessions.incrementEventCount(session.id, subId)
} catch (err) {
console.error(`[NRC] Failed to send event ${evt.id?.slice(0, 8)}:`, err)
}
}
// Send EOSE
const eoseResponse: ResponseMessage = {
type: 'EOSE',
payload: ['EOSE', subId]
}
await this.sendResponse(reqEvent, session, eoseResponse)
this.sessions.markEOSE(session.id, subId)
console.log(`[NRC] Sent EOSE for subscription ${subId}`)
}
/**
* Handle CLOSE message
*/
private async handleCLOSE(session: NRCSession, payload: unknown[]): Promise<void> {
// Parse CLOSE: ["CLOSE", subId]
const [, subId] = payload as [string, string]
if (subId) {
this.sessions.removeSubscription(session.id, subId)
}
}
/**
* Handle EVENT message - store an event from the remote device
*/
private async handleEVENT(
reqEvent: Event,
session: NRCSession,
payload: unknown[]
): Promise<void> {
// Parse EVENT: ["EVENT", eventObject]
const [, eventToStore] = payload as [string, Event]
if (!eventToStore || !eventToStore.id || !eventToStore.sig) {
await this.sendError(reqEvent, session, 'Invalid EVENT: missing event data')
return
}
try {
// Store the event in IndexedDB
await indexedDb.putReplaceableEvent(eventToStore)
console.log(`[NRC] Stored event ${eventToStore.id.slice(0, 8)} kind ${eventToStore.kind} from ${session.deviceName}`)
// Send OK response
const response: ResponseMessage = {
type: 'OK',
payload: ['OK', eventToStore.id, true, '']
}
await this.sendResponse(reqEvent, session, response)
} catch (err) {
console.error('[NRC] Failed to store event:', err)
const response: ResponseMessage = {
type: 'OK',
payload: ['OK', eventToStore.id, false, `Failed to store: ${err instanceof Error ? err.message : 'Unknown error'}`]
}
await this.sendResponse(reqEvent, session, response)
}
}
/**
* Handle IDS message - return event IDs matching filters (for diffing)
* Similar to REQ but returns only IDs, not full events
*/
private async handleIDS(
reqEvent: Event,
session: NRCSession,
payload: unknown[]
): Promise<void> {
// Parse IDS: ["IDS", subId, filter1, filter2, ...]
if (payload.length < 2) {
await this.sendError(reqEvent, session, 'Invalid IDS: missing subscription ID or filters')
return
}
const [, subId, ...filterObjs] = payload as [string, string, ...Filter[]]
// Query local events matching the filters
const events = await this.queryLocalEvents(filterObjs)
console.log(`[NRC] Found ${events.length} events for IDS request`)
// Build manifest of event IDs with metadata for diffing
const manifest: EventManifestEntry[] = events.map((evt) => ({
kind: evt.kind,
id: evt.id,
created_at: evt.created_at,
d: evt.tags.find((t) => t[0] === 'd')?.[1]
}))
// Send IDS response with the manifest
const response: ResponseMessage = {
type: 'IDS',
payload: ['IDS', subId, manifest]
}
await this.sendResponseChunked(reqEvent, session, response)
console.log(`[NRC] Sent IDS response with ${manifest.length} entries`)
}
/**
* Query local IndexedDB for events matching filters
*/
private async queryLocalEvents(filters: Filter[]): Promise<Event[]> {
// Get all events from IndexedDB and filter
const allEvents = await indexedDb.queryEventsForNRC(filters)
// Filter out device-specific events
return allEvents.filter((evt) => !isDeviceSpecificEvent(evt))
}
/**
* Send an encrypted response
*/
private async sendResponse(
reqEvent: Event,
session: NRCSession,
response: ResponseMessage
): Promise<void> {
if (!this.ws || !this.config || !this.listenerPubkey) {
throw new Error('Not connected')
}
// Encrypt the response using signer
const plaintext = JSON.stringify(response)
const encrypted = await this.encrypt(session.clientPubkey, plaintext)
// Build the response event
const unsignedEvent = {
kind: KIND_NRC_RESPONSE,
content: encrypted,
tags: [
['p', reqEvent.pubkey],
['encryption', 'nip44_v2'],
['session', session.id],
['e', reqEvent.id]
],
created_at: Math.floor(Date.now() / 1000)
}
// Sign with our signer
const signedEvent = await this.config.signer.signEvent(unsignedEvent)
// Publish to rendezvous relay
this.ws.send(JSON.stringify(['EVENT', signedEvent]))
}
/**
* Send a response, chunking if necessary for large payloads
*/
private async sendResponseChunked(
reqEvent: Event,
session: NRCSession,
response: ResponseMessage
): Promise<void> {
const plaintext = JSON.stringify(response)
// If small enough, send directly
if (plaintext.length <= NRCListenerService.MAX_CHUNK_SIZE) {
await this.sendResponse(reqEvent, session, response)
return
}
// Need to chunk - convert to base64 for safe transmission
const encoded = btoa(unescape(encodeURIComponent(plaintext)))
const chunks: string[] = []
// Split into chunks
for (let i = 0; i < encoded.length; i += NRCListenerService.MAX_CHUNK_SIZE) {
chunks.push(encoded.slice(i, i + NRCListenerService.MAX_CHUNK_SIZE))
}
const messageId = crypto.randomUUID()
console.log(`[NRC] Chunking large message (${plaintext.length} bytes) into ${chunks.length} chunks`)
// Send each chunk
for (let i = 0; i < chunks.length; i++) {
const chunkResponse: ResponseMessage = {
type: 'CHUNK',
payload: [{
type: 'CHUNK',
messageId,
index: i,
total: chunks.length,
data: chunks[i]
}]
}
await this.sendResponse(reqEvent, session, chunkResponse)
}
}
/**
* Send an error response
*/
private async sendError(
reqEvent: Event,
session: NRCSession,
message: string
): Promise<void> {
const response: ResponseMessage = {
type: 'NOTICE',
payload: ['NOTICE', message]
}
await this.sendResponse(reqEvent, session, response)
}
/**
* Send error response with best-effort encryption
*/
private async sendErrorBestEffort(reqEvent: Event, message: string): Promise<void> {
if (!this.ws || !this.config || !this.listenerPubkey) {
return
}
try {
const response: ResponseMessage = {
type: 'NOTICE',
payload: ['NOTICE', message]
}
const plaintext = JSON.stringify(response)
const encrypted = await this.encrypt(reqEvent.pubkey, plaintext)
const unsignedEvent = {
kind: KIND_NRC_RESPONSE,
content: encrypted,
tags: [
['p', reqEvent.pubkey],
['encryption', 'nip44_v2'],
['e', reqEvent.id]
],
created_at: Math.floor(Date.now() / 1000)
}
const signedEvent = await this.config.signer.signEvent(unsignedEvent)
this.ws.send(JSON.stringify(['EVENT', signedEvent]))
} catch {
// Best effort - ignore errors
}
}
}
// Singleton instance
let instance: NRCListenerService | null = null
export function getNRCListenerService(): NRCListenerService {
if (!instance) {
instance = new NRCListenerService()
}
return instance
}
export default getNRCListenerService()

View File

@@ -0,0 +1,238 @@
import { Filter } from 'nostr-tools'
import { NRCSession, NRCSubscription } from './nrc-types'
// Default session timeout: 30 minutes
const DEFAULT_SESSION_TIMEOUT = 30 * 60 * 1000
// Default max subscriptions per session
const DEFAULT_MAX_SUBSCRIPTIONS = 100
/**
* Generate a unique session ID
*/
function generateSessionId(): string {
return crypto.randomUUID()
}
/**
* Session manager for tracking NRC client sessions
*/
export class NRCSessionManager {
private sessions: Map<string, NRCSession> = new Map()
private sessionTimeout: number
private maxSubscriptions: number
private cleanupInterval: ReturnType<typeof setInterval> | null = null
constructor(
sessionTimeout: number = DEFAULT_SESSION_TIMEOUT,
maxSubscriptions: number = DEFAULT_MAX_SUBSCRIPTIONS
) {
this.sessionTimeout = sessionTimeout
this.maxSubscriptions = maxSubscriptions
}
/**
* Start the cleanup interval for expired sessions
*/
start(): void {
if (this.cleanupInterval) return
// Run cleanup every 5 minutes
this.cleanupInterval = setInterval(() => {
this.cleanupExpiredSessions()
}, 5 * 60 * 1000)
}
/**
* Stop the cleanup interval
*/
stop(): void {
if (this.cleanupInterval) {
clearInterval(this.cleanupInterval)
this.cleanupInterval = null
}
this.sessions.clear()
}
/**
* Get or create a session for a client
*/
getOrCreateSession(
clientPubkey: string,
conversationKey: Uint8Array | undefined,
deviceName?: string
): NRCSession {
// Check if session exists for this client
for (const session of this.sessions.values()) {
if (session.clientPubkey === clientPubkey) {
// Update last activity and return existing session
session.lastActivity = Date.now()
return session
}
}
// Create new session
const session: NRCSession = {
id: generateSessionId(),
clientPubkey,
conversationKey,
deviceName,
createdAt: Date.now(),
lastActivity: Date.now(),
subscriptions: new Map()
}
this.sessions.set(session.id, session)
return session
}
/**
* Get a session by ID
*/
getSession(sessionId: string): NRCSession | undefined {
return this.sessions.get(sessionId)
}
/**
* Get a session by client pubkey
*/
getSessionByClientPubkey(clientPubkey: string): NRCSession | undefined {
for (const session of this.sessions.values()) {
if (session.clientPubkey === clientPubkey) {
return session
}
}
return undefined
}
/**
* Touch a session to update last activity
*/
touchSession(sessionId: string): void {
const session = this.sessions.get(sessionId)
if (session) {
session.lastActivity = Date.now()
}
}
/**
* Add a subscription to a session
*/
addSubscription(
sessionId: string,
subId: string,
filters: Filter[]
): NRCSubscription | null {
const session = this.sessions.get(sessionId)
if (!session) return null
// Check subscription limit
if (session.subscriptions.size >= this.maxSubscriptions) {
return null
}
const subscription: NRCSubscription = {
id: subId,
filters,
createdAt: Date.now(),
eventCount: 0,
eoseSent: false
}
session.subscriptions.set(subId, subscription)
session.lastActivity = Date.now()
return subscription
}
/**
* Get a subscription from a session
*/
getSubscription(sessionId: string, subId: string): NRCSubscription | undefined {
const session = this.sessions.get(sessionId)
return session?.subscriptions.get(subId)
}
/**
* Remove a subscription from a session
*/
removeSubscription(sessionId: string, subId: string): boolean {
const session = this.sessions.get(sessionId)
if (!session) return false
const deleted = session.subscriptions.delete(subId)
if (deleted) {
session.lastActivity = Date.now()
}
return deleted
}
/**
* Mark EOSE sent for a subscription
*/
markEOSE(sessionId: string, subId: string): void {
const subscription = this.getSubscription(sessionId, subId)
if (subscription) {
subscription.eoseSent = true
}
}
/**
* Increment event count for a subscription
*/
incrementEventCount(sessionId: string, subId: string): void {
const subscription = this.getSubscription(sessionId, subId)
if (subscription) {
subscription.eventCount++
}
}
/**
* Remove a session
*/
removeSession(sessionId: string): boolean {
return this.sessions.delete(sessionId)
}
/**
* Get the count of active sessions
*/
getActiveSessionCount(): number {
return this.sessions.size
}
/**
* Get all active sessions
*/
getAllSessions(): NRCSession[] {
return Array.from(this.sessions.values())
}
/**
* Clean up expired sessions
*/
private cleanupExpiredSessions(): void {
const now = Date.now()
const expiredSessionIds: string[] = []
for (const [sessionId, session] of this.sessions) {
if (now - session.lastActivity > this.sessionTimeout) {
expiredSessionIds.push(sessionId)
}
}
for (const sessionId of expiredSessionIds) {
this.sessions.delete(sessionId)
console.log(`[NRC] Cleaned up expired session: ${sessionId}`)
}
}
/**
* Check if a session is expired
*/
isSessionExpired(sessionId: string): boolean {
const session = this.sessions.get(sessionId)
if (!session) return true
return Date.now() - session.lastActivity > this.sessionTimeout
}
}

View File

@@ -0,0 +1,118 @@
import { Filter, Event } from 'nostr-tools'
import { ISigner } from '@/types'
// NRC Event Kinds
export const KIND_NRC_REQUEST = 24891
export const KIND_NRC_RESPONSE = 24892
// Session types
export interface NRCSession {
id: string
clientPubkey: string
conversationKey?: Uint8Array // Optional - only set when using direct key access
deviceName?: string
createdAt: number
lastActivity: number
subscriptions: Map<string, NRCSubscription>
}
export interface NRCSubscription {
id: string
filters: Filter[]
createdAt: number
eventCount: number
eoseSent: boolean
}
// Message types (encrypted content)
export interface RequestMessage {
type: 'REQ' | 'CLOSE' | 'EVENT' | 'COUNT' | 'IDS'
payload: unknown[]
}
export interface ResponseMessage {
type: 'EVENT' | 'EOSE' | 'OK' | 'NOTICE' | 'CLOSED' | 'COUNT' | 'CHUNK' | 'IDS'
payload: unknown[]
}
// ===== Sync Types =====
/**
* Event manifest entry - describes an event we have
* Used by IDS request/response for diffing
*/
export interface EventManifestEntry {
kind: number
id: string
created_at: number
d?: string // For parameterized replaceable events (kinds 30000-39999)
}
// Chunked message for large payloads
export interface ChunkMessage {
type: 'CHUNK'
messageId: string // Unique ID for this chunked message
index: number // 0-based chunk index
total: number // Total number of chunks
data: string // Base64 encoded chunk data
}
// Helper to check if a message is a chunk
export function isChunkMessage(msg: ResponseMessage): msg is ResponseMessage & { payload: [ChunkMessage] } {
return msg.type === 'CHUNK'
}
// Connection management
export interface NRCConnection {
id: string
label: string
secret?: string // For secret-based auth
clientPubkey?: string // Derived from secret
createdAt: number
lastUsed?: number
}
// Listener configuration
export interface NRCListenerConfig {
rendezvousUrl: string
signer: ISigner
authorizedSecrets: Map<string, string> // clientPubkey → deviceName
sessionTimeout?: number // Session inactivity timeout in ms (default 30 min)
maxSubscriptionsPerSession?: number // Max subscriptions per session (default 100)
}
// Authorization result
export interface AuthResult {
conversationKey?: Uint8Array // Optional - only set when using direct key access
deviceName: string
}
// Parsed connection URI
export interface ParsedConnectionURI {
relayPubkey: string // Hex pubkey of the listening relay/client
rendezvousUrl: string // URL of the rendezvous relay
// For secret-based auth
secret?: string // 32-byte hex secret
clientPubkey?: string // Derived pubkey from secret
clientPrivkey?: Uint8Array // Derived private key from secret
// Optional
deviceName?: string
}
// Listener state for React context
export interface NRCListenerState {
isEnabled: boolean
isListening: boolean
connections: NRCConnection[]
activeSessions: number
rendezvousUrl: string
}
// Event with simplified typing for storage queries
export type StoredEvent = Event
// Device-specific event check
export function isDeviceSpecificEvent(event: Event): boolean {
const dTag = event.tags.find((t) => t[0] === 'd')?.[1]
return dTag?.startsWith('device:') ?? false
}

147
src/services/nrc/nrc-uri.ts Normal file
View File

@@ -0,0 +1,147 @@
import * as utils from '@noble/curves/abstract/utils'
import { getPublicKey } from 'nostr-tools'
import * as nip44 from 'nostr-tools/nip44'
import { ParsedConnectionURI } from './nrc-types'
/**
* Generate a random 32-byte secret as hex string
*/
export function generateSecret(): string {
const bytes = new Uint8Array(32)
crypto.getRandomValues(bytes)
return utils.bytesToHex(bytes)
}
/**
* Derive a keypair from a 32-byte secret
* Returns the private key bytes and public key hex
*/
export function deriveKeypairFromSecret(secretHex: string): {
privkey: Uint8Array
pubkey: string
} {
const privkey = utils.hexToBytes(secretHex)
const pubkey = getPublicKey(privkey)
return { privkey, pubkey }
}
/**
* Derive conversation key for NIP-44 encryption
*/
export function deriveConversationKey(
ourPrivkey: Uint8Array,
theirPubkey: string
): Uint8Array {
return nip44.v2.utils.getConversationKey(ourPrivkey, theirPubkey)
}
/**
* Generate a secret-based NRC connection URI
*
* @param relayPubkey - The public key of the listening client/relay
* @param rendezvousUrl - The URL of the rendezvous relay
* @param secret - Optional 32-byte hex secret (generated if not provided)
* @param deviceName - Optional device name for identification
* @returns The connection URI and the secret used
*/
export function generateConnectionURI(
relayPubkey: string,
rendezvousUrl: string,
secret?: string,
deviceName?: string
): { uri: string; secret: string; clientPubkey: string } {
const secretHex = secret || generateSecret()
const { pubkey: clientPubkey } = deriveKeypairFromSecret(secretHex)
// Build URI
const params = new URLSearchParams()
params.set('relay', rendezvousUrl)
params.set('secret', secretHex)
if (deviceName) {
params.set('name', deviceName)
}
const uri = `nostr+relayconnect://${relayPubkey}?${params.toString()}`
return { uri, secret: secretHex, clientPubkey }
}
/**
* Parse an NRC connection URI
*
* @param uri - The nostr+relayconnect:// URI to parse
* @returns Parsed connection parameters
* @throws Error if URI is invalid
*/
export function parseConnectionURI(uri: string): ParsedConnectionURI {
// Parse as URL
let url: URL
try {
url = new URL(uri)
} catch {
throw new Error('Invalid URI format')
}
// Validate scheme
if (url.protocol !== 'nostr+relayconnect:') {
throw new Error('Invalid URI scheme, expected nostr+relayconnect://')
}
// Extract relay pubkey from host (should be 64 hex chars)
const relayPubkey = url.hostname
if (!/^[0-9a-fA-F]{64}$/.test(relayPubkey)) {
throw new Error('Invalid relay pubkey in URI')
}
// Extract rendezvous relay URL
const rendezvousUrl = url.searchParams.get('relay')
if (!rendezvousUrl) {
throw new Error('Missing relay parameter in URI')
}
// Validate rendezvous URL
try {
new URL(rendezvousUrl)
} catch {
throw new Error('Invalid rendezvous relay URL')
}
// Extract device name (optional)
const deviceName = url.searchParams.get('name') || undefined
// Secret-based auth
const secret = url.searchParams.get('secret')
if (!secret) {
throw new Error('Missing secret parameter in URI')
}
// Validate secret format (64 hex chars = 32 bytes)
if (!/^[0-9a-fA-F]{64}$/.test(secret)) {
throw new Error('Invalid secret format, expected 64 hex characters')
}
// Derive keypair from secret
const { privkey, pubkey } = deriveKeypairFromSecret(secret)
return {
relayPubkey,
rendezvousUrl,
secret,
clientPubkey: pubkey,
clientPrivkey: privkey,
deviceName
}
}
/**
* Validate a connection URI without fully parsing it
* Returns true if the URI appears valid, false otherwise
*/
export function isValidConnectionURI(uri: string): boolean {
try {
parseConnectionURI(uri)
return true
} catch {
return false
}
}

View File

@@ -11,6 +11,7 @@ import {
} from '@/lib/event'
import { generateBech32IdFromETag } from '@/lib/tag'
import client from '@/services/client.service'
import graphQueryService from '@/services/graph-query.service'
import dayjs from 'dayjs'
import { Filter, kinds, NostrEvent } from 'nostr-tools'
@@ -62,6 +63,20 @@ class ThreadService {
return
}
// Try graph query first for E-tag threads (event ID based)
if (rootInfo.type === 'E') {
const graphResult = await this.tryGraphQueryThread(rootInfo.id)
if (graphResult) {
// Graph query succeeded, no need to subscribe
this.subscriptions.set(rootInfo.id, {
promise: Promise.resolve({ closer: () => {}, timelineKey: '' }),
count: 1,
until: undefined // Graph queries return complete results
})
return
}
}
const _subscribe = async () => {
let relayUrls: string[] = []
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
@@ -366,6 +381,50 @@ class ThreadService {
return promise
}
/**
* Try to fetch thread events using graph query (NIP-XX).
* Returns true if successful, false otherwise.
*/
private async tryGraphQueryThread(eventId: string): Promise<boolean> {
try {
const graphResult = await graphQueryService.queryThread(
BIG_RELAY_URLS,
eventId,
10, // Max depth for threads
{
inboundRefKinds: [7, 9735] // Reactions and zaps
}
)
if (!graphResult?.events_by_depth?.length) {
return false
}
// Graph query returns event IDs by depth
// We need to fetch the actual events and add them to the thread
const allEventIds = graphResult.events_by_depth.flat()
if (allEventIds.length === 0) {
return false
}
// Fetch actual events for the IDs returned by graph query
const events = await client.fetchEvents(BIG_RELAY_URLS, {
ids: allEventIds.slice(0, 500), // Limit to prevent huge queries
limit: allEventIds.length
})
if (events.length > 0) {
this.addRepliesToThread(events)
return true
}
return false
} catch (error) {
console.error('Graph query for thread failed:', error)
return false
}
}
private resolveStuff(stuff: NostrEvent | string) {
return typeof stuff === 'string'
? { event: undefined, externalContent: stuff, stuffKey: stuff }

38
src/types/graph.d.ts vendored Normal file
View File

@@ -0,0 +1,38 @@
// Re-export TGraphQueryCapability from index.d.ts
export type { TGraphQueryCapability } from './index'
// Graph query request structure (NIP-XX extension)
export interface GraphQuery {
method: 'follows' | 'followers' | 'mentions' | 'thread'
seed: string // 64-char hex pubkey or event ID
depth?: number // 1-16, default 1
inbound_refs?: RefSpec[]
outbound_refs?: RefSpec[]
}
export interface RefSpec {
kinds: number[]
from_depth?: number
}
// Graph query response (from relay-signed event content)
export interface GraphResponse {
pubkeys_by_depth?: string[][]
events_by_depth?: string[][]
total_pubkeys?: number
total_events?: number
inbound_refs?: RefSummary[]
outbound_refs?: RefSummary[]
}
export interface RefSummary {
kind: number
target: string
count: number
refs?: string[]
}
// Graph query filter extension for nostr-tools
export interface GraphFilter {
_graph: GraphQuery
}

10
src/types/index.d.ts vendored
View File

@@ -40,6 +40,13 @@ export type TRelayList = {
originalRelays: TMailboxRelay[]
}
export type TGraphQueryCapability = {
enabled: boolean
max_depth: number
max_results: number
methods: string[]
}
export type TRelayInfo = {
url: string
shortUrl: string
@@ -57,6 +64,7 @@ export type TRelayInfo = {
auth_required?: boolean
payment_required?: boolean
}
graph_query?: TGraphQueryCapability
}
export type TWebMetadata = {
@@ -115,7 +123,6 @@ export type TAccount = {
bunkerPubkey?: string
bunkerRelays?: string[]
bunkerSecret?: string
bunkerCatToken?: string
}
export type TAccountPointer = Pick<TAccount, 'pubkey' | 'signerType'>
@@ -228,6 +235,7 @@ export type TSyncSettings = {
quickReactionEmoji?: string | TEmoji
noteListMode?: TNoteListMode
preferNip44?: boolean
nrcOnlyConfigSync?: boolean
}
// DM types