Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5b23ea04d0 | ||
|
|
ecd7c36400 | ||
|
|
08f75a902d |
7
.claude/settings.local.json
Normal file
7
.claude/settings.local.json
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"permissions": {
|
||||||
|
"additionalDirectories": [
|
||||||
|
"/home/mleku/src/git.mleku.dev/mleku/coracle"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
11
package-lock.json
generated
11
package-lock.json
generated
@@ -1,12 +1,12 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"lockfileVersion": 3,
|
"lockfileVersion": 3,
|
||||||
"requires": true,
|
"requires": true,
|
||||||
"packages": {
|
"packages": {
|
||||||
"": {
|
"": {
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.3.0",
|
"version": "0.4.1",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@dnd-kit/core": "^6.3.1",
|
"@dnd-kit/core": "^6.3.1",
|
||||||
@@ -56,6 +56,7 @@
|
|||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"franc-min": "^6.2.0",
|
"franc-min": "^6.2.0",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"jotai": "^2.15.0",
|
"jotai": "^2.15.0",
|
||||||
@@ -8932,6 +8933,12 @@
|
|||||||
"url": "https://opencollective.com/unified"
|
"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": {
|
"node_modules/i18next": {
|
||||||
"version": "24.2.0",
|
"version": "24.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/i18next/-/i18next-24.2.0.tgz",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "smesh",
|
"name": "smesh",
|
||||||
"version": "0.4.0",
|
"version": "0.4.1",
|
||||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||||
"private": true,
|
"private": true,
|
||||||
"type": "module",
|
"type": "module",
|
||||||
@@ -70,6 +70,7 @@
|
|||||||
"emoji-picker-react": "^4.12.2",
|
"emoji-picker-react": "^4.12.2",
|
||||||
"flexsearch": "^0.7.43",
|
"flexsearch": "^0.7.43",
|
||||||
"franc-min": "^6.2.0",
|
"franc-min": "^6.2.0",
|
||||||
|
"html5-qrcode": "^2.3.8",
|
||||||
"i18next": "^24.2.0",
|
"i18next": "^24.2.0",
|
||||||
"i18next-browser-languagedetector": "^8.0.4",
|
"i18next-browser-languagedetector": "^8.0.4",
|
||||||
"jotai": "^2.15.0",
|
"jotai": "^2.15.0",
|
||||||
|
|||||||
@@ -12,9 +12,11 @@ import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
|
|||||||
import { FeedProvider } from '@/providers/FeedProvider'
|
import { FeedProvider } from '@/providers/FeedProvider'
|
||||||
import { FollowListProvider } from '@/providers/FollowListProvider'
|
import { FollowListProvider } from '@/providers/FollowListProvider'
|
||||||
import { KindFilterProvider } from '@/providers/KindFilterProvider'
|
import { KindFilterProvider } from '@/providers/KindFilterProvider'
|
||||||
|
import { SocialGraphFilterProvider } from '@/providers/SocialGraphFilterProvider'
|
||||||
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
||||||
import { MuteListProvider } from '@/providers/MuteListProvider'
|
import { MuteListProvider } from '@/providers/MuteListProvider'
|
||||||
import { NostrProvider } from '@/providers/NostrProvider'
|
import { NostrProvider } from '@/providers/NostrProvider'
|
||||||
|
import { NRCProvider } from '@/providers/NRCProvider'
|
||||||
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
|
import { PasswordPromptProvider } from '@/providers/PasswordPromptProvider'
|
||||||
import { PinListProvider } from '@/providers/PinListProvider'
|
import { PinListProvider } from '@/providers/PinListProvider'
|
||||||
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
import { PinnedUsersProvider } from '@/providers/PinnedUsersProvider'
|
||||||
@@ -37,6 +39,7 @@ export default function App(): JSX.Element {
|
|||||||
<DeletedEventProvider>
|
<DeletedEventProvider>
|
||||||
<PasswordPromptProvider>
|
<PasswordPromptProvider>
|
||||||
<NostrProvider>
|
<NostrProvider>
|
||||||
|
<NRCProvider>
|
||||||
<RepositoryProvider>
|
<RepositoryProvider>
|
||||||
<SettingsSyncProvider>
|
<SettingsSyncProvider>
|
||||||
<ZapProvider>
|
<ZapProvider>
|
||||||
@@ -51,10 +54,12 @@ export default function App(): JSX.Element {
|
|||||||
<PinnedUsersProvider>
|
<PinnedUsersProvider>
|
||||||
<FeedProvider>
|
<FeedProvider>
|
||||||
<MediaUploadServiceProvider>
|
<MediaUploadServiceProvider>
|
||||||
|
<SocialGraphFilterProvider>
|
||||||
<KindFilterProvider>
|
<KindFilterProvider>
|
||||||
<PageManager />
|
<PageManager />
|
||||||
<Toaster />
|
<Toaster />
|
||||||
</KindFilterProvider>
|
</KindFilterProvider>
|
||||||
|
</SocialGraphFilterProvider>
|
||||||
</MediaUploadServiceProvider>
|
</MediaUploadServiceProvider>
|
||||||
</FeedProvider>
|
</FeedProvider>
|
||||||
</PinnedUsersProvider>
|
</PinnedUsersProvider>
|
||||||
@@ -69,6 +74,7 @@ export default function App(): JSX.Element {
|
|||||||
</ZapProvider>
|
</ZapProvider>
|
||||||
</SettingsSyncProvider>
|
</SettingsSyncProvider>
|
||||||
</RepositoryProvider>
|
</RepositoryProvider>
|
||||||
|
</NRCProvider>
|
||||||
</NostrProvider>
|
</NostrProvider>
|
||||||
</PasswordPromptProvider>
|
</PasswordPromptProvider>
|
||||||
</DeletedEventProvider>
|
</DeletedEventProvider>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
import QrScannerModal from '@/components/QrScannerModal'
|
||||||
import { Button } from '@/components/ui/button'
|
import { Button } from '@/components/ui/button'
|
||||||
import { Input } from '@/components/ui/input'
|
import { Input } from '@/components/ui/input'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
import { BunkerSigner } from '@/providers/NostrProvider/bunker.signer'
|
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 { useState, useEffect } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
@@ -28,6 +29,7 @@ export default function BunkerLogin({
|
|||||||
const [connectUrl, setConnectUrl] = useState<string | null>(null)
|
const [connectUrl, setConnectUrl] = useState<string | null>(null)
|
||||||
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null)
|
||||||
const [copied, setCopied] = useState(false)
|
const [copied, setCopied] = useState(false)
|
||||||
|
const [showScanner, setShowScanner] = useState(false)
|
||||||
|
|
||||||
// Generate QR code when in scan mode
|
// Generate QR code when in scan mode
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -88,6 +90,11 @@ export default function BunkerLogin({
|
|||||||
}
|
}
|
||||||
}, [mode, relayUrl, bunkerLoginWithSigner, onLoginSuccess])
|
}, [mode, relayUrl, bunkerLoginWithSigner, onLoginSuccess])
|
||||||
|
|
||||||
|
const handleScan = (result: string) => {
|
||||||
|
setBunkerUrl(result)
|
||||||
|
setError(null)
|
||||||
|
}
|
||||||
|
|
||||||
const handlePasteSubmit = async (e: React.FormEvent) => {
|
const handlePasteSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
if (!bunkerUrl.trim()) {
|
if (!bunkerUrl.trim()) {
|
||||||
@@ -263,6 +270,10 @@ export default function BunkerLogin({
|
|||||||
|
|
||||||
// Paste mode
|
// Paste mode
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
|
{showScanner && (
|
||||||
|
<QrScannerModal onScan={handleScan} onClose={() => setShowScanner(false)} />
|
||||||
|
)}
|
||||||
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
<div className="flex flex-col gap-4" onClick={(e) => e.stopPropagation()}>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
|
<Button size="icon" variant="ghost" className="rounded-full" onClick={() => setMode('choose')}>
|
||||||
@@ -277,6 +288,7 @@ export default function BunkerLogin({
|
|||||||
<form onSubmit={handlePasteSubmit} className="space-y-4">
|
<form onSubmit={handlePasteSubmit} className="space-y-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
|
<Label htmlFor="bunkerUrl">{t('Bunker URL')}</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
<Input
|
<Input
|
||||||
id="bunkerUrl"
|
id="bunkerUrl"
|
||||||
type="text"
|
type="text"
|
||||||
@@ -286,6 +298,17 @@ export default function BunkerLogin({
|
|||||||
disabled={loading}
|
disabled={loading}
|
||||||
className="font-mono text-sm"
|
className="font-mono text-sm"
|
||||||
/>
|
/>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => setShowScanner(true)}
|
||||||
|
disabled={loading}
|
||||||
|
title={t('Scan QR code')}
|
||||||
|
>
|
||||||
|
<ScanLine className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
{t(
|
{t(
|
||||||
'Enter the bunker connection URL. This is typically provided by your signing device or service.'
|
'Enter the bunker connection URL. This is typically provided by your signing device or service.'
|
||||||
@@ -307,5 +330,6 @@ export default function BunkerLogin({
|
|||||||
</Button>
|
</Button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,10 +3,13 @@ import { Checkbox } from '@/components/ui/checkbox'
|
|||||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
|
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
|
||||||
import { Label } from '@/components/ui/label'
|
import { Label } from '@/components/ui/label'
|
||||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||||
|
import { Separator } from '@/components/ui/separator'
|
||||||
|
import SocialGraphFilter from '@/components/SocialGraphFilter'
|
||||||
import { ExtendedKind } from '@/constants'
|
import { ExtendedKind } from '@/constants'
|
||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { useKindFilter } from '@/providers/KindFilterProvider'
|
import { useKindFilter } from '@/providers/KindFilterProvider'
|
||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
|
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
|
||||||
import { ListFilter } from 'lucide-react'
|
import { ListFilter } from 'lucide-react'
|
||||||
import { kinds } from 'nostr-tools'
|
import { kinds } from 'nostr-tools'
|
||||||
import { useEffect, useMemo, useState } from 'react'
|
import { useEffect, useMemo, useState } from 'react'
|
||||||
@@ -34,22 +37,35 @@ const ALL_KINDS = KIND_FILTER_OPTIONS.flatMap(({ kindGroup }) => kindGroup)
|
|||||||
|
|
||||||
export default function KindFilter({
|
export default function KindFilter({
|
||||||
showKinds,
|
showKinds,
|
||||||
onShowKindsChange
|
onShowKindsChange,
|
||||||
|
showSocialGraphFilter = false
|
||||||
}: {
|
}: {
|
||||||
showKinds: number[]
|
showKinds: number[]
|
||||||
onShowKindsChange: (kinds: number[]) => void
|
onShowKindsChange: (kinds: number[]) => void
|
||||||
|
showSocialGraphFilter?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
const { showKinds: savedShowKinds } = useKindFilter()
|
const { showKinds: savedShowKinds } = useKindFilter()
|
||||||
|
const {
|
||||||
|
proximityLevel: savedProximity,
|
||||||
|
includeMode: savedIncludeMode,
|
||||||
|
updateProximityLevel,
|
||||||
|
updateIncludeMode
|
||||||
|
} = useSocialGraphFilter()
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const { updateShowKinds } = useKindFilter()
|
const { updateShowKinds } = useKindFilter()
|
||||||
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
||||||
|
const [temporaryProximity, setTemporaryProximity] = useState<number | null>(savedProximity)
|
||||||
|
const [temporaryIncludeMode, setTemporaryIncludeMode] = useState(savedIncludeMode)
|
||||||
const [isPersistent, setIsPersistent] = useState(false)
|
const [isPersistent, setIsPersistent] = useState(false)
|
||||||
const isDifferentFromSaved = useMemo(
|
|
||||||
() => !isSameKindFilter(showKinds, savedShowKinds),
|
const isDifferentFromSaved = useMemo(() => {
|
||||||
[showKinds, savedShowKinds]
|
const kindsDifferent = !isSameKindFilter(showKinds, savedShowKinds)
|
||||||
)
|
const proximityDifferent = showSocialGraphFilter && savedProximity !== null
|
||||||
|
return kindsDifferent || proximityDifferent
|
||||||
|
}, [showKinds, savedShowKinds, savedProximity, showSocialGraphFilter])
|
||||||
|
|
||||||
const isTemporaryDifferentFromSaved = useMemo(
|
const isTemporaryDifferentFromSaved = useMemo(
|
||||||
() => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
|
() => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
|
||||||
[temporaryShowKinds, savedShowKinds]
|
[temporaryShowKinds, savedShowKinds]
|
||||||
@@ -57,8 +73,10 @@ export default function KindFilter({
|
|||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setTemporaryShowKinds(showKinds)
|
setTemporaryShowKinds(showKinds)
|
||||||
|
setTemporaryProximity(savedProximity)
|
||||||
|
setTemporaryIncludeMode(savedIncludeMode)
|
||||||
setIsPersistent(false)
|
setIsPersistent(false)
|
||||||
}, [open])
|
}, [open, savedProximity, savedIncludeMode])
|
||||||
|
|
||||||
const handleApply = () => {
|
const handleApply = () => {
|
||||||
if (temporaryShowKinds.length === 0) {
|
if (temporaryShowKinds.length === 0) {
|
||||||
@@ -71,6 +89,16 @@ export default function KindFilter({
|
|||||||
onShowKindsChange(newShowKinds)
|
onShowKindsChange(newShowKinds)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply social graph filter changes
|
||||||
|
if (showSocialGraphFilter) {
|
||||||
|
if (temporaryProximity !== savedProximity) {
|
||||||
|
updateProximityLevel(temporaryProximity)
|
||||||
|
}
|
||||||
|
if (temporaryIncludeMode !== savedIncludeMode) {
|
||||||
|
updateIncludeMode(temporaryIncludeMode)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isPersistent) {
|
if (isPersistent) {
|
||||||
updateShowKinds(newShowKinds)
|
updateShowKinds(newShowKinds)
|
||||||
}
|
}
|
||||||
@@ -155,6 +183,18 @@ export default function KindFilter({
|
|||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{showSocialGraphFilter && (
|
||||||
|
<>
|
||||||
|
<Separator className="my-4" />
|
||||||
|
<SocialGraphFilter
|
||||||
|
temporaryProximity={temporaryProximity}
|
||||||
|
temporaryIncludeMode={temporaryIncludeMode}
|
||||||
|
onTemporaryProximityChange={setTemporaryProximity}
|
||||||
|
onTemporaryIncludeModeChange={setTemporaryIncludeMode}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
<Label className="flex items-center gap-2 cursor-pointer mt-4">
|
<Label className="flex items-center gap-2 cursor-pointer mt-4">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
id="persistent-filter"
|
id="persistent-filter"
|
||||||
|
|||||||
847
src/components/NRCSettings/index.tsx
Normal file
847
src/components/NRCSettings/index.tsx
Normal file
@@ -0,0 +1,847 @@
|
|||||||
|
/**
|
||||||
|
* NRC Settings Component
|
||||||
|
*
|
||||||
|
* UI for managing Nostr Relay Connect (NRC) connections and listener settings.
|
||||||
|
* Includes both:
|
||||||
|
* - Listener mode: Allow other devices to connect to this one
|
||||||
|
* - Client mode: Connect to and sync from other devices
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useCallback, useRef } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { useNRC } from '@/providers/NRCProvider'
|
||||||
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
||||||
|
import { Button } from '@/components/ui/button'
|
||||||
|
import { Input } from '@/components/ui/input'
|
||||||
|
import { Label } from '@/components/ui/label'
|
||||||
|
import { Switch } from '@/components/ui/switch'
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle
|
||||||
|
} from '@/components/ui/dialog'
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger
|
||||||
|
} from '@/components/ui/alert-dialog'
|
||||||
|
import {
|
||||||
|
Link2,
|
||||||
|
Plus,
|
||||||
|
Trash2,
|
||||||
|
Copy,
|
||||||
|
Check,
|
||||||
|
QrCode,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
Users,
|
||||||
|
Server,
|
||||||
|
RefreshCw,
|
||||||
|
Smartphone,
|
||||||
|
Download,
|
||||||
|
Camera,
|
||||||
|
Zap,
|
||||||
|
Coins
|
||||||
|
} from 'lucide-react'
|
||||||
|
import { NRCConnection, RemoteConnection } from '@/services/nrc'
|
||||||
|
import QRCode from 'qrcode'
|
||||||
|
import { Html5Qrcode } from 'html5-qrcode'
|
||||||
|
|
||||||
|
export default function NRCSettings() {
|
||||||
|
const { t } = useTranslation()
|
||||||
|
const { pubkey } = useNostr()
|
||||||
|
const {
|
||||||
|
// Listener state
|
||||||
|
isEnabled,
|
||||||
|
isConnected,
|
||||||
|
connections,
|
||||||
|
activeSessions,
|
||||||
|
rendezvousUrl,
|
||||||
|
relaySupportsCat,
|
||||||
|
enable,
|
||||||
|
disable,
|
||||||
|
addConnection,
|
||||||
|
removeConnection,
|
||||||
|
getConnectionURI,
|
||||||
|
setRendezvousUrl,
|
||||||
|
// Client state
|
||||||
|
remoteConnections,
|
||||||
|
isSyncing,
|
||||||
|
syncProgress,
|
||||||
|
addRemoteConnection,
|
||||||
|
removeRemoteConnection,
|
||||||
|
testRemoteConnection,
|
||||||
|
syncFromDevice,
|
||||||
|
syncAllRemotes
|
||||||
|
} = useNRC()
|
||||||
|
|
||||||
|
// Listener state
|
||||||
|
const [newConnectionLabel, setNewConnectionLabel] = useState('')
|
||||||
|
const [isAddDialogOpen, setIsAddDialogOpen] = useState(false)
|
||||||
|
const [isQRDialogOpen, setIsQRDialogOpen] = useState(false)
|
||||||
|
const [currentQRConnection, setCurrentQRConnection] = useState<NRCConnection | null>(null)
|
||||||
|
const [currentQRUri, setCurrentQRUri] = useState('')
|
||||||
|
const [qrDataUrl, setQrDataUrl] = useState('')
|
||||||
|
const [copiedUri, setCopiedUri] = useState(false)
|
||||||
|
const [isLoading, setIsLoading] = useState(false)
|
||||||
|
|
||||||
|
// Client state
|
||||||
|
const [connectionUri, setConnectionUri] = useState('')
|
||||||
|
const [newRemoteLabel, setNewRemoteLabel] = useState('')
|
||||||
|
const [isConnectDialogOpen, setIsConnectDialogOpen] = useState(false)
|
||||||
|
const [isScannerOpen, setIsScannerOpen] = useState(false)
|
||||||
|
const [scannerError, setScannerError] = useState('')
|
||||||
|
const scannerRef = useRef<Html5Qrcode | null>(null)
|
||||||
|
const scannerContainerRef = useRef<HTMLDivElement>(null)
|
||||||
|
|
||||||
|
// Private config sync setting
|
||||||
|
const [nrcOnlyConfigSync, setNrcOnlyConfigSync] = useState(storage.getNrcOnlyConfigSync())
|
||||||
|
|
||||||
|
const handleToggleNrcOnlyConfig = useCallback((checked: boolean) => {
|
||||||
|
storage.setNrcOnlyConfigSync(checked)
|
||||||
|
setNrcOnlyConfigSync(checked)
|
||||||
|
dispatchSettingsChanged()
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
// Generate QR code when URI changes
|
||||||
|
const generateQRCode = useCallback(async (uri: string) => {
|
||||||
|
try {
|
||||||
|
const dataUrl = await QRCode.toDataURL(uri, {
|
||||||
|
width: 256,
|
||||||
|
margin: 2,
|
||||||
|
color: { dark: '#000000', light: '#ffffff' }
|
||||||
|
})
|
||||||
|
setQrDataUrl(dataUrl)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to generate QR code:', error)
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleToggleEnabled = useCallback(async () => {
|
||||||
|
if (isEnabled) {
|
||||||
|
disable()
|
||||||
|
} else {
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
await enable()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to enable NRC:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [isEnabled, enable, disable])
|
||||||
|
|
||||||
|
const handleAddConnection = useCallback(async () => {
|
||||||
|
if (!newConnectionLabel.trim()) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
const { uri, connection } = await addConnection(newConnectionLabel.trim())
|
||||||
|
setIsAddDialogOpen(false)
|
||||||
|
setNewConnectionLabel('')
|
||||||
|
|
||||||
|
// Show QR code
|
||||||
|
setCurrentQRConnection(connection)
|
||||||
|
setCurrentQRUri(uri)
|
||||||
|
await generateQRCode(uri)
|
||||||
|
setIsQRDialogOpen(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add connection:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [newConnectionLabel, addConnection])
|
||||||
|
|
||||||
|
const handleShowQR = useCallback(
|
||||||
|
async (connection: NRCConnection) => {
|
||||||
|
try {
|
||||||
|
const uri = getConnectionURI(connection)
|
||||||
|
setCurrentQRConnection(connection)
|
||||||
|
setCurrentQRUri(uri)
|
||||||
|
await generateQRCode(uri)
|
||||||
|
setIsQRDialogOpen(true)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to get connection URI:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[getConnectionURI, generateQRCode]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleCopyUri = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(currentQRUri)
|
||||||
|
setCopiedUri(true)
|
||||||
|
setTimeout(() => setCopiedUri(false), 2000)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to copy URI:', error)
|
||||||
|
}
|
||||||
|
}, [currentQRUri])
|
||||||
|
|
||||||
|
const handleRemoveConnection = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await removeConnection(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove connection:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeConnection]
|
||||||
|
)
|
||||||
|
|
||||||
|
// ===== Client Handlers =====
|
||||||
|
const handleAddRemoteConnection = useCallback(async () => {
|
||||||
|
if (!connectionUri.trim() || !newRemoteLabel.trim()) return
|
||||||
|
|
||||||
|
setIsLoading(true)
|
||||||
|
try {
|
||||||
|
await addRemoteConnection(connectionUri.trim(), newRemoteLabel.trim())
|
||||||
|
setIsConnectDialogOpen(false)
|
||||||
|
setConnectionUri('')
|
||||||
|
setNewRemoteLabel('')
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to add remote connection:', error)
|
||||||
|
} finally {
|
||||||
|
setIsLoading(false)
|
||||||
|
}
|
||||||
|
}, [connectionUri, newRemoteLabel, addRemoteConnection])
|
||||||
|
|
||||||
|
const handleRemoveRemoteConnection = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await removeRemoteConnection(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to remove remote connection:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[removeRemoteConnection]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSyncDevice = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await syncFromDevice(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync from device:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[syncFromDevice]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleTestConnection = useCallback(
|
||||||
|
async (id: string) => {
|
||||||
|
try {
|
||||||
|
await testRemoteConnection(id)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to test connection:', error)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[testRemoteConnection]
|
||||||
|
)
|
||||||
|
|
||||||
|
const handleSyncAll = useCallback(async () => {
|
||||||
|
try {
|
||||||
|
await syncAllRemotes()
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to sync all remotes:', error)
|
||||||
|
}
|
||||||
|
}, [syncAllRemotes])
|
||||||
|
|
||||||
|
const startScanner = useCallback(async () => {
|
||||||
|
if (!scannerContainerRef.current) return
|
||||||
|
|
||||||
|
setScannerError('')
|
||||||
|
try {
|
||||||
|
const scanner = new Html5Qrcode('qr-scanner-container')
|
||||||
|
scannerRef.current = scanner
|
||||||
|
|
||||||
|
await scanner.start(
|
||||||
|
{ facingMode: 'environment' },
|
||||||
|
{
|
||||||
|
fps: 10,
|
||||||
|
qrbox: { width: 250, height: 250 }
|
||||||
|
},
|
||||||
|
(decodedText) => {
|
||||||
|
// Found a QR code
|
||||||
|
if (decodedText.startsWith('nostr+relayconnect://')) {
|
||||||
|
setConnectionUri(decodedText)
|
||||||
|
stopScanner()
|
||||||
|
setIsScannerOpen(false)
|
||||||
|
setIsConnectDialogOpen(true)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
() => {
|
||||||
|
// Ignore errors while scanning
|
||||||
|
}
|
||||||
|
)
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to start scanner:', error)
|
||||||
|
setScannerError(error instanceof Error ? error.message : 'Failed to start camera')
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const stopScanner = useCallback(() => {
|
||||||
|
if (scannerRef.current) {
|
||||||
|
scannerRef.current.stop().catch(() => {
|
||||||
|
// Ignore errors when stopping
|
||||||
|
})
|
||||||
|
scannerRef.current = null
|
||||||
|
}
|
||||||
|
}, [])
|
||||||
|
|
||||||
|
const handleOpenScanner = useCallback(() => {
|
||||||
|
setIsScannerOpen(true)
|
||||||
|
// Start scanner after dialog renders
|
||||||
|
setTimeout(startScanner, 100)
|
||||||
|
}, [startScanner])
|
||||||
|
|
||||||
|
const handleCloseScanner = useCallback(() => {
|
||||||
|
stopScanner()
|
||||||
|
setIsScannerOpen(false)
|
||||||
|
setScannerError('')
|
||||||
|
}, [stopScanner])
|
||||||
|
|
||||||
|
if (!pubkey) {
|
||||||
|
return (
|
||||||
|
<div className="text-muted-foreground text-sm">
|
||||||
|
{t('Login required to use NRC')}
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
{/* Private Configuration Sync Toggle */}
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="nrc-only-config" className="text-base font-medium">
|
||||||
|
{t('Private Configuration Sync')}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('Only sync configurations between paired devices, not to public relays')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="nrc-only-config"
|
||||||
|
checked={nrcOnlyConfigSync}
|
||||||
|
onCheckedChange={handleToggleNrcOnlyConfig}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Tabs defaultValue="listener" className="w-full">
|
||||||
|
<TabsList className="grid w-full grid-cols-2">
|
||||||
|
<TabsTrigger value="listener" className="gap-2">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
{t('Share')}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="client" className="gap-2">
|
||||||
|
<Smartphone className="w-4 h-4" />
|
||||||
|
{t('Connect')}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
{/* ===== LISTENER TAB ===== */}
|
||||||
|
<TabsContent value="listener" className="space-y-6 mt-4">
|
||||||
|
{/* Enable/Disable Toggle */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-1">
|
||||||
|
<Label htmlFor="nrc-enabled" className="text-base font-medium">
|
||||||
|
{t('Enable Relay Connect')}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t('Allow other devices to sync with this client')}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="nrc-enabled"
|
||||||
|
checked={isEnabled}
|
||||||
|
onCheckedChange={handleToggleEnabled}
|
||||||
|
disabled={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Status Indicator */}
|
||||||
|
{isEnabled && (
|
||||||
|
<div className="flex items-center gap-4 p-3 bg-muted/50 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{isConnected ? (
|
||||||
|
<Wifi className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<WifiOff className="w-4 h-4 text-yellow-500" />
|
||||||
|
)}
|
||||||
|
<span className="text-sm">
|
||||||
|
{isConnected ? t('Connected') : t('Connecting...')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{activeSessions > 0 && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Users className="w-4 h-4" />
|
||||||
|
<span className="text-sm">
|
||||||
|
{activeSessions} {t('active session(s)')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Rendezvous Relay */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="rendezvous-url" className="flex items-center gap-2">
|
||||||
|
<Server className="w-4 h-4" />
|
||||||
|
{t('Rendezvous Relay')}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="rendezvous-url"
|
||||||
|
value={rendezvousUrl}
|
||||||
|
onChange={(e) => setRendezvousUrl(e.target.value)}
|
||||||
|
placeholder="wss://relay.example.com"
|
||||||
|
disabled={isEnabled}
|
||||||
|
/>
|
||||||
|
{isEnabled && (
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t('Disable NRC to change the relay')}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* CAT (Cashu Access Token) Status */}
|
||||||
|
<div className="flex items-center justify-between p-3 bg-muted/30 rounded-lg">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Coins className="w-4 h-4" />
|
||||||
|
<span className="text-sm">{t('CAT Authentication')}</span>
|
||||||
|
</div>
|
||||||
|
{relaySupportsCat ? (
|
||||||
|
<span className="px-2 py-1 bg-primary/10 text-primary rounded text-xs">
|
||||||
|
{t('Available')}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span className="px-2 py-1 bg-muted text-muted-foreground rounded text-xs">
|
||||||
|
{t('Not Available')}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Connections List */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Link2 className="w-4 h-4" />
|
||||||
|
{t('Authorized Devices')}
|
||||||
|
</Label>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsAddDialogOpen(true)}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t('Add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{connections.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
|
||||||
|
{t('No devices connected yet')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{connections.map((connection) => (
|
||||||
|
<div
|
||||||
|
key={connection.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{connection.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{new Date(connection.createdAt).toLocaleDateString()}
|
||||||
|
{connection.useCat && (
|
||||||
|
<span className="ml-2 px-1.5 py-0.5 bg-primary/10 text-primary rounded text-[10px]">
|
||||||
|
CAT
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleShowQR(connection)}
|
||||||
|
title={t('Show QR Code')}
|
||||||
|
>
|
||||||
|
<QrCode className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
title={t('Remove')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('Remove Device?')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('This will revoke access for "{{label}}". The device will no longer be able to sync.', {
|
||||||
|
label: connection.label
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleRemoveConnection(connection.id)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{t('Remove')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* ===== CLIENT TAB ===== */}
|
||||||
|
<TabsContent value="client" className="space-y-6 mt-4">
|
||||||
|
{/* Sync Progress */}
|
||||||
|
{isSyncing && syncProgress && (
|
||||||
|
<div className="p-3 bg-muted/50 rounded-lg space-y-2">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<RefreshCw className="w-4 h-4 animate-spin" />
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{syncProgress.phase === 'connecting' && t('Connecting...')}
|
||||||
|
{syncProgress.phase === 'requesting' && t('Requesting events...')}
|
||||||
|
{syncProgress.phase === 'receiving' && t('Receiving events...')}
|
||||||
|
{syncProgress.phase === 'complete' && t('Sync complete')}
|
||||||
|
{syncProgress.phase === 'error' && t('Error')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{syncProgress.eventsReceived > 0 && (
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t('{{count}} events received', { count: syncProgress.eventsReceived })}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{syncProgress.message && syncProgress.phase === 'error' && (
|
||||||
|
<div className="text-xs text-destructive">{syncProgress.message}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Connect to Device */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label className="flex items-center gap-2">
|
||||||
|
<Download className="w-4 h-4" />
|
||||||
|
{t('Remote Devices')}
|
||||||
|
</Label>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleOpenScanner}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Camera className="w-4 h-4" />
|
||||||
|
{t('Scan')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setIsConnectDialogOpen(true)}
|
||||||
|
className="gap-1"
|
||||||
|
>
|
||||||
|
<Plus className="w-4 h-4" />
|
||||||
|
{t('Add')}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{remoteConnections.length === 0 ? (
|
||||||
|
<div className="text-sm text-muted-foreground p-4 text-center border border-dashed rounded-lg">
|
||||||
|
{t('No remote devices configured')}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-2">
|
||||||
|
{/* Sync All Button */}
|
||||||
|
{remoteConnections.length > 1 && (
|
||||||
|
<Button
|
||||||
|
variant="secondary"
|
||||||
|
size="sm"
|
||||||
|
onClick={handleSyncAll}
|
||||||
|
disabled={isSyncing}
|
||||||
|
className="w-full gap-2"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||||
|
{t('Sync All Devices')}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{remoteConnections.map((remote: RemoteConnection) => (
|
||||||
|
<div
|
||||||
|
key={remote.id}
|
||||||
|
className="flex items-center justify-between p-3 bg-muted/30 rounded-lg"
|
||||||
|
>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="font-medium truncate">{remote.label}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{remote.lastSync ? (
|
||||||
|
<>
|
||||||
|
{t('Last sync')}: {new Date(remote.lastSync).toLocaleString()}
|
||||||
|
{remote.eventCount !== undefined && (
|
||||||
|
<span className="ml-2">({remote.eventCount} {t('events')})</span>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
t('Never synced')
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
{/* Show Test button if never synced, Sync button otherwise */}
|
||||||
|
{!remote.lastSync ? (
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleTestConnection(remote.id)}
|
||||||
|
disabled={isSyncing}
|
||||||
|
title={t('Test Connection')}
|
||||||
|
>
|
||||||
|
<Zap className={`w-4 h-4 ${isSyncing ? 'animate-pulse' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
) : null}
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => handleSyncDevice(remote.id)}
|
||||||
|
disabled={isSyncing}
|
||||||
|
title={t('Sync')}
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${isSyncing ? 'animate-spin' : ''}`} />
|
||||||
|
</Button>
|
||||||
|
<AlertDialog>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="text-destructive hover:text-destructive"
|
||||||
|
title={t('Remove')}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{t('Remove Remote Device?')}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t('This will remove "{{label}}" from your remote devices list.', {
|
||||||
|
label: remote.label
|
||||||
|
})}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>{t('Cancel')}</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={() => handleRemoveRemoteConnection(remote.id)}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{t('Remove')}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
|
||||||
|
{/* ===== DIALOGS ===== */}
|
||||||
|
|
||||||
|
{/* Add Connection Dialog (Listener) */}
|
||||||
|
<Dialog open={isAddDialogOpen} onOpenChange={setIsAddDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Add Device')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('Create a connection URI to link another device')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="device-label">{t('Device Name')}</Label>
|
||||||
|
<Input
|
||||||
|
id="device-label"
|
||||||
|
value={newConnectionLabel}
|
||||||
|
onChange={(e) => setNewConnectionLabel(e.target.value)}
|
||||||
|
placeholder={t('e.g., Phone, Laptop')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleAddConnection()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsAddDialogOpen(false)}>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddConnection}
|
||||||
|
disabled={!newConnectionLabel.trim() || isLoading}
|
||||||
|
>
|
||||||
|
{t('Create')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* QR Code Dialog */}
|
||||||
|
<Dialog open={isQRDialogOpen} onOpenChange={setIsQRDialogOpen}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Connection QR Code')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{currentQRConnection && (
|
||||||
|
<>
|
||||||
|
{t('Scan this code with "{{label}}" to connect', {
|
||||||
|
label: currentQRConnection.label
|
||||||
|
})}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="flex flex-col items-center gap-4 py-4">
|
||||||
|
{qrDataUrl && (
|
||||||
|
<div className="p-4 bg-white rounded-lg">
|
||||||
|
<img src={qrDataUrl} alt="Connection QR Code" className="w-64 h-64" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="w-full">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Input
|
||||||
|
value={currentQRUri}
|
||||||
|
readOnly
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleCopyUri}
|
||||||
|
title={t('Copy')}
|
||||||
|
>
|
||||||
|
{copiedUri ? (
|
||||||
|
<Check className="w-4 h-4 text-green-500" />
|
||||||
|
) : (
|
||||||
|
<Copy className="w-4 h-4" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button onClick={() => setIsQRDialogOpen(false)}>{t('Done')}</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* Connect to Remote Dialog (Client) */}
|
||||||
|
<Dialog open={isConnectDialogOpen} onOpenChange={setIsConnectDialogOpen}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Connect to Device')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('Enter a connection URI from another device to sync with it')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="connection-uri">{t('Connection URI')}</Label>
|
||||||
|
<Input
|
||||||
|
id="connection-uri"
|
||||||
|
value={connectionUri}
|
||||||
|
onChange={(e) => setConnectionUri(e.target.value)}
|
||||||
|
placeholder="nostr+relayconnect://..."
|
||||||
|
className="font-mono text-xs"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="remote-label">{t('Device Name')}</Label>
|
||||||
|
<Input
|
||||||
|
id="remote-label"
|
||||||
|
value={newRemoteLabel}
|
||||||
|
onChange={(e) => setNewRemoteLabel(e.target.value)}
|
||||||
|
placeholder={t('e.g., Desktop, Main Phone')}
|
||||||
|
onKeyDown={(e) => {
|
||||||
|
if (e.key === 'Enter') {
|
||||||
|
handleAddRemoteConnection()
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setIsConnectDialogOpen(false)}>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleAddRemoteConnection}
|
||||||
|
disabled={!connectionUri.trim() || !newRemoteLabel.trim() || isLoading}
|
||||||
|
>
|
||||||
|
{t('Connect')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* QR Scanner Dialog */}
|
||||||
|
<Dialog open={isScannerOpen} onOpenChange={handleCloseScanner}>
|
||||||
|
<DialogContent className="sm:max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t('Scan QR Code')}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t('Point your camera at a connection QR code')}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="py-4">
|
||||||
|
<div
|
||||||
|
id="qr-scanner-container"
|
||||||
|
ref={scannerContainerRef}
|
||||||
|
className="w-full aspect-square bg-muted rounded-lg overflow-hidden"
|
||||||
|
/>
|
||||||
|
{scannerError && (
|
||||||
|
<div className="mt-2 text-sm text-destructive">{scannerError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={handleCloseScanner}>
|
||||||
|
{t('Cancel')}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -16,6 +16,7 @@ export default function NormalFeed({
|
|||||||
isMainFeed = false,
|
isMainFeed = false,
|
||||||
showRelayCloseReason = false,
|
showRelayCloseReason = false,
|
||||||
disable24hMode = false,
|
disable24hMode = false,
|
||||||
|
enableSocialGraphFilter = false,
|
||||||
onRefresh
|
onRefresh
|
||||||
}: {
|
}: {
|
||||||
subRequests: TFeedSubRequest[]
|
subRequests: TFeedSubRequest[]
|
||||||
@@ -23,6 +24,7 @@ export default function NormalFeed({
|
|||||||
isMainFeed?: boolean
|
isMainFeed?: boolean
|
||||||
showRelayCloseReason?: boolean
|
showRelayCloseReason?: boolean
|
||||||
disable24hMode?: boolean
|
disable24hMode?: boolean
|
||||||
|
enableSocialGraphFilter?: boolean
|
||||||
onRefresh?: () => void
|
onRefresh?: () => void
|
||||||
}) {
|
}) {
|
||||||
const { hideUntrustedNotes } = useUserTrust()
|
const { hideUntrustedNotes } = useUserTrust()
|
||||||
@@ -87,6 +89,7 @@ export default function NormalFeed({
|
|||||||
<KindFilter
|
<KindFilter
|
||||||
showKinds={temporaryShowKinds}
|
showKinds={temporaryShowKinds}
|
||||||
onShowKindsChange={handleShowKindsChange}
|
onShowKindsChange={handleShowKindsChange}
|
||||||
|
showSocialGraphFilter={enableSocialGraphFilter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
@@ -110,6 +113,7 @@ export default function NormalFeed({
|
|||||||
hideUntrustedNotes={hideUntrustedNotes}
|
hideUntrustedNotes={hideUntrustedNotes}
|
||||||
areAlgoRelays={areAlgoRelays}
|
areAlgoRelays={areAlgoRelays}
|
||||||
showRelayCloseReason={showRelayCloseReason}
|
showRelayCloseReason={showRelayCloseReason}
|
||||||
|
applySocialGraphFilter={enableSocialGraphFilter}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
|||||||
import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||||
import { useMuteList } from '@/providers/MuteListProvider'
|
import { useMuteList } from '@/providers/MuteListProvider'
|
||||||
import { useNostr } from '@/providers/NostrProvider'
|
import { useNostr } from '@/providers/NostrProvider'
|
||||||
|
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
|
||||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||||
import client from '@/services/client.service'
|
import client from '@/services/client.service'
|
||||||
import threadService from '@/services/thread.service'
|
import threadService from '@/services/thread.service'
|
||||||
@@ -55,6 +56,7 @@ const NoteList = forwardRef<
|
|||||||
filterFn?: (event: Event) => boolean
|
filterFn?: (event: Event) => boolean
|
||||||
showNewNotesDirectly?: boolean
|
showNewNotesDirectly?: boolean
|
||||||
navColumn?: TNavigationColumn
|
navColumn?: TNavigationColumn
|
||||||
|
applySocialGraphFilter?: boolean
|
||||||
}
|
}
|
||||||
>(
|
>(
|
||||||
(
|
(
|
||||||
@@ -70,7 +72,8 @@ const NoteList = forwardRef<
|
|||||||
pinnedEventIds,
|
pinnedEventIds,
|
||||||
filterFn,
|
filterFn,
|
||||||
showNewNotesDirectly = false,
|
showNewNotesDirectly = false,
|
||||||
navColumn = 1
|
navColumn = 1,
|
||||||
|
applySocialGraphFilter = false
|
||||||
},
|
},
|
||||||
ref
|
ref
|
||||||
) => {
|
) => {
|
||||||
@@ -80,6 +83,7 @@ const NoteList = forwardRef<
|
|||||||
const { mutePubkeySet } = useMuteList()
|
const { mutePubkeySet } = useMuteList()
|
||||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||||
const { isEventDeleted } = useDeletedEvent()
|
const { isEventDeleted } = useDeletedEvent()
|
||||||
|
const { isPubkeyAllowed } = useSocialGraphFilter()
|
||||||
const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
|
const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
|
||||||
const [events, setEvents] = useState<Event[]>([])
|
const [events, setEvents] = useState<Event[]>([])
|
||||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||||
@@ -122,10 +126,22 @@ const NoteList = forwardRef<
|
|||||||
if (filterFn && !filterFn(evt)) {
|
if (filterFn && !filterFn(evt)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
// Social graph filter - only apply if enabled for this feed
|
||||||
|
if (applySocialGraphFilter && !isPubkeyAllowed(evt.pubkey)) {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
return false
|
return false
|
||||||
},
|
},
|
||||||
[hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn]
|
[
|
||||||
|
hideUntrustedNotes,
|
||||||
|
mutePubkeySet,
|
||||||
|
JSON.stringify(pinnedEventIds),
|
||||||
|
isEventDeleted,
|
||||||
|
filterFn,
|
||||||
|
applySocialGraphFilter,
|
||||||
|
isPubkeyAllowed
|
||||||
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -3,22 +3,43 @@ import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
|||||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||||
import { QrCodeIcon } from 'lucide-react'
|
import { QrCodeIcon } from 'lucide-react'
|
||||||
import { nip19 } from 'nostr-tools'
|
import { nip19 } from 'nostr-tools'
|
||||||
import { useMemo } from 'react'
|
import { useCallback, useMemo, useState } from 'react'
|
||||||
|
import { useTranslation } from 'react-i18next'
|
||||||
|
import { toast } from 'sonner'
|
||||||
import Nip05 from '../Nip05'
|
import Nip05 from '../Nip05'
|
||||||
import PubkeyCopy from '../PubkeyCopy'
|
|
||||||
import QrCode from '../QrCode'
|
import QrCode from '../QrCode'
|
||||||
import UserAvatar from '../UserAvatar'
|
import UserAvatar from '../UserAvatar'
|
||||||
import Username from '../Username'
|
import Username from '../Username'
|
||||||
|
|
||||||
export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
||||||
|
const { t } = useTranslation()
|
||||||
const { isSmallScreen } = useScreenSize()
|
const { isSmallScreen } = useScreenSize()
|
||||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
|
const [open, setOpen] = useState(false)
|
||||||
|
const npub = useMemo(() => {
|
||||||
|
// Validate pubkey is a 64-character hex string before encoding
|
||||||
|
if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return ''
|
||||||
|
try {
|
||||||
|
return nip19.npubEncode(pubkey)
|
||||||
|
} catch {
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
}, [pubkey])
|
||||||
|
|
||||||
|
const handleQrClick = useCallback(() => {
|
||||||
|
navigator.clipboard.writeText(npub)
|
||||||
|
toast.success(t('Copied npub to clipboard'))
|
||||||
|
setOpen(false)
|
||||||
|
}, [npub, t])
|
||||||
|
|
||||||
if (!npub) return null
|
if (!npub) return null
|
||||||
|
|
||||||
const trigger = (
|
const trigger = (
|
||||||
<div className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground">
|
<button
|
||||||
|
className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground"
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
>
|
||||||
<QrCodeIcon size={14} />
|
<QrCodeIcon size={14} />
|
||||||
</div>
|
</button>
|
||||||
)
|
)
|
||||||
|
|
||||||
const content = (
|
const content = (
|
||||||
@@ -26,29 +47,33 @@ export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
|||||||
<div className="flex items-center w-full gap-2 pointer-events-none px-1">
|
<div className="flex items-center w-full gap-2 pointer-events-none px-1">
|
||||||
<UserAvatar size="big" userId={pubkey} />
|
<UserAvatar size="big" userId={pubkey} />
|
||||||
<div className="flex-1 w-0">
|
<div className="flex-1 w-0">
|
||||||
<Username userId={pubkey} className="text-2xl font-semibold truncate" />
|
<Username userId={pubkey} className="text-2xl font-semibold truncate" showQrCode={false} />
|
||||||
<Nip05 pubkey={pubkey} />
|
<Nip05 pubkey={pubkey} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={handleQrClick}
|
||||||
|
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||||
|
title={t('Click to copy npub')}
|
||||||
|
>
|
||||||
<QrCode size={512} value={`nostr:${npub}`} />
|
<QrCode size={512} value={`nostr:${npub}`} />
|
||||||
<div className="flex flex-col items-center">
|
</button>
|
||||||
<PubkeyCopy pubkey={pubkey} />
|
<div className="text-sm text-muted-foreground">{t('Click QR code to copy npub')}</div>
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
if (isSmallScreen) {
|
if (isSmallScreen) {
|
||||||
return (
|
return (
|
||||||
<Drawer>
|
<Drawer open={open} onOpenChange={setOpen}>
|
||||||
<DrawerTrigger>{trigger}</DrawerTrigger>
|
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||||
<DrawerContent>{content}</DrawerContent>
|
<DrawerContent>{content}</DrawerContent>
|
||||||
</Drawer>
|
</Drawer>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Dialog>
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
<DialogTrigger>{trigger}</DialogTrigger>
|
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||||
<DialogContent className="w-80 p-0 m-0" onOpenAutoFocus={(e) => e.preventDefault()}>
|
<DialogContent className="w-80 p-0 m-0" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||||
{content}
|
{content}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
|
|||||||
@@ -342,6 +342,27 @@ const SearchBar = forwardRef<
|
|||||||
onKeyDown={handleKeyDown}
|
onKeyDown={handleKeyDown}
|
||||||
onFocus={() => setSearching(true)}
|
onFocus={() => setSearching(true)}
|
||||||
onBlur={() => setSearching(false)}
|
onBlur={() => setSearching(false)}
|
||||||
|
onQrScan={(value) => {
|
||||||
|
setInput(value)
|
||||||
|
// Automatically search after scanning
|
||||||
|
let id = value
|
||||||
|
if (id.startsWith('nostr:')) {
|
||||||
|
id = id.slice(6)
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const { type } = nip19.decode(id)
|
||||||
|
if (['nprofile', 'npub'].includes(type)) {
|
||||||
|
updateSearch({ type: 'profile', search: id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (['nevent', 'naddr', 'note'].includes(type)) {
|
||||||
|
updateSearch({ type: 'note', search: id })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Not a valid nip19 identifier, just set input
|
||||||
|
}
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -1,11 +1,17 @@
|
|||||||
import { cn } from '@/lib/utils'
|
import { cn } from '@/lib/utils'
|
||||||
import { SearchIcon, X } from 'lucide-react'
|
import { QrCodeIcon, SearchIcon, X } from 'lucide-react'
|
||||||
import { ComponentProps, forwardRef, useEffect, useState } from 'react'
|
import { ComponentProps, forwardRef, useEffect, useState } from 'react'
|
||||||
|
import QrScannerModal from '../QrScannerModal'
|
||||||
|
|
||||||
const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
type SearchInputProps = ComponentProps<'input'> & {
|
||||||
({ value, onChange, className, ...props }, ref) => {
|
onQrScan?: (value: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||||
|
({ value, onChange, className, onQrScan, ...props }, ref) => {
|
||||||
const [displayClear, setDisplayClear] = useState(false)
|
const [displayClear, setDisplayClear] = useState(false)
|
||||||
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
|
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
|
||||||
|
const [showQrScanner, setShowQrScanner] = useState(false)
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setDisplayClear(!!value)
|
setDisplayClear(!!value)
|
||||||
@@ -20,7 +26,14 @@ const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const handleQrScan = (result: string) => {
|
||||||
|
// Strip nostr: prefix if present
|
||||||
|
const value = result.startsWith('nostr:') ? result.slice(6) : result
|
||||||
|
onQrScan?.(value)
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<div
|
<div
|
||||||
tabIndex={0}
|
tabIndex={0}
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -37,6 +50,16 @@ const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
|||||||
onChange={onChange}
|
onChange={onChange}
|
||||||
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
||||||
/>
|
/>
|
||||||
|
{onQrScan && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="text-muted-foreground hover:text-foreground transition-colors size-5 shrink-0 flex items-center justify-center mr-1"
|
||||||
|
onMouseDown={(e) => e.preventDefault()}
|
||||||
|
onClick={() => setShowQrScanner(true)}
|
||||||
|
>
|
||||||
|
<QrCodeIcon className="size-4" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
{displayClear && (
|
{displayClear && (
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -48,6 +71,10 @@ const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
|||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
{showQrScanner && (
|
||||||
|
<QrScannerModal onScan={handleQrScan} onClose={() => setShowQrScanner(false)} />
|
||||||
|
)}
|
||||||
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import EmojiPackList from '@/components/EmojiPackList'
|
|||||||
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
|
import EmojiPickerDialog from '@/components/EmojiPickerDialog'
|
||||||
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
|
import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting'
|
||||||
import MailboxSetting from '@/components/MailboxSetting'
|
import MailboxSetting from '@/components/MailboxSetting'
|
||||||
|
import NRCSettings from '@/components/NRCSettings'
|
||||||
import NoteList from '@/components/NoteList'
|
import NoteList from '@/components/NoteList'
|
||||||
import Tabs from '@/components/Tabs'
|
import Tabs from '@/components/Tabs'
|
||||||
import {
|
import {
|
||||||
@@ -73,6 +74,7 @@ import {
|
|||||||
PencilLine,
|
PencilLine,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
ScanLine,
|
ScanLine,
|
||||||
|
RefreshCw,
|
||||||
Server,
|
Server,
|
||||||
Settings2,
|
Settings2,
|
||||||
Smile,
|
Smile,
|
||||||
@@ -105,7 +107,7 @@ const NOTIFICATION_STYLES = [
|
|||||||
] as const
|
] as const
|
||||||
|
|
||||||
// Accordion item values for keyboard navigation
|
// 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() {
|
export default function Settings() {
|
||||||
const { t, i18n } = useTranslation()
|
const { t, i18n } = useTranslation()
|
||||||
@@ -123,7 +125,7 @@ export default function Settings() {
|
|||||||
// Get the visible accordion items based on pubkey availability
|
// Get the visible accordion items based on pubkey availability
|
||||||
const visibleAccordionItems = pubkey
|
const visibleAccordionItems = pubkey
|
||||||
? ACCORDION_ITEMS
|
? 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 as a navigation region - Settings decides what "up/down" means
|
// Register as a navigation region - Settings decides what "up/down" means
|
||||||
const handleSettingsIntent = useCallback(
|
const handleSettingsIntent = useCallback(
|
||||||
@@ -548,6 +550,23 @@ export default function Settings() {
|
|||||||
</AccordionItem>
|
</AccordionItem>
|
||||||
</NavigableAccordionItem>
|
</NavigableAccordionItem>
|
||||||
|
|
||||||
|
{/* Sync (NRC) */}
|
||||||
|
{!!pubkey && (
|
||||||
|
<NavigableAccordionItem ref={setAccordionRef('sync')} isSelected={isAccordionSelected('sync')}>
|
||||||
|
<AccordionItem value="sync">
|
||||||
|
<AccordionTrigger className="px-4 hover:no-underline">
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
|
<RefreshCw className="size-4" />
|
||||||
|
<span>{t('Device Sync')}</span>
|
||||||
|
</div>
|
||||||
|
</AccordionTrigger>
|
||||||
|
<AccordionContent className="px-4">
|
||||||
|
<NRCSettings />
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
</NavigableAccordionItem>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Wallet */}
|
{/* Wallet */}
|
||||||
{!!pubkey && (
|
{!!pubkey && (
|
||||||
<NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
|
<NavigableAccordionItem ref={setAccordionRef('wallet')} isSelected={isAccordionSelected('wallet')}>
|
||||||
|
|||||||
127
src/components/SocialGraphFilter/index.tsx
Normal file
127
src/components/SocialGraphFilter/index.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
13
src/components/StuffStats/KeyboardShortcut.tsx
Normal file
13
src/components/StuffStats/KeyboardShortcut.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -21,6 +21,7 @@ import { useTranslation } from 'react-i18next'
|
|||||||
import Emoji from '../Emoji'
|
import Emoji from '../Emoji'
|
||||||
import EmojiPicker from '../EmojiPicker'
|
import EmojiPicker from '../EmojiPicker'
|
||||||
import SuggestedEmojis from '../SuggestedEmojis'
|
import SuggestedEmojis from '../SuggestedEmojis'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
|
|
||||||
export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
||||||
@@ -128,7 +129,7 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
|||||||
<>
|
<>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
|
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
|
||||||
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">R</kbd>
|
<KeyboardShortcut shortcut="R" />
|
||||||
</span>
|
</span>
|
||||||
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
||||||
</>
|
</>
|
||||||
@@ -136,7 +137,7 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
|||||||
<>
|
<>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<SmilePlus />
|
<SmilePlus />
|
||||||
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">R</kbd>
|
<KeyboardShortcut shortcut="R" />
|
||||||
</span>
|
</span>
|
||||||
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
||||||
</>
|
</>
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ import { Event } from 'nostr-tools'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import PostEditor from '../PostEditor'
|
import PostEditor from '../PostEditor'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
|
|
||||||
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||||
@@ -70,7 +71,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
|||||||
>
|
>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
<MessageCircle />
|
<MessageCircle />
|
||||||
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">r</kbd>
|
<KeyboardShortcut shortcut="r" />
|
||||||
</span>
|
</span>
|
||||||
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ import { Event } from 'nostr-tools'
|
|||||||
import { useMemo, useState } from 'react'
|
import { useMemo, useState } from 'react'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import PostEditor from '../PostEditor'
|
import PostEditor from '../PostEditor'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut'
|
||||||
import { formatCount } from './utils'
|
import { formatCount } from './utils'
|
||||||
|
|
||||||
export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||||
@@ -93,7 +94,7 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
|||||||
>
|
>
|
||||||
<span className="relative">
|
<span className="relative">
|
||||||
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
||||||
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">p</kbd>
|
<KeyboardShortcut shortcut="p" />
|
||||||
</span>
|
</span>
|
||||||
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 're
|
|||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { toast } from 'sonner'
|
import { toast } from 'sonner'
|
||||||
import ZapDialog from '../ZapDialog'
|
import ZapDialog from '../ZapDialog'
|
||||||
|
import KeyboardShortcut from './KeyboardShortcut'
|
||||||
|
|
||||||
export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
@@ -153,7 +154,7 @@ export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
|||||||
) : (
|
) : (
|
||||||
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
|
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
|
||||||
)}
|
)}
|
||||||
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">z</kbd>
|
<KeyboardShortcut shortcut="z" />
|
||||||
</span>
|
</span>
|
||||||
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
||||||
</button>
|
</button>
|
||||||
|
|||||||
@@ -4,22 +4,25 @@ import { useFetchProfile } from '@/hooks'
|
|||||||
import { toProfile } from '@/lib/link'
|
import { toProfile } from '@/lib/link'
|
||||||
import { cn, isTouchDevice } from '@/lib/utils'
|
import { cn, isTouchDevice } from '@/lib/utils'
|
||||||
import { SecondaryPageLink } from '@/PageManager'
|
import { SecondaryPageLink } from '@/PageManager'
|
||||||
|
import { useMemo } from 'react'
|
||||||
|
import NpubQrCode from '../NpubQrCode'
|
||||||
import ProfileCard from '../ProfileCard'
|
import ProfileCard from '../ProfileCard'
|
||||||
import TextWithEmojis from '../TextWithEmojis'
|
import TextWithEmojis from '../TextWithEmojis'
|
||||||
import { useMemo } from 'react'
|
|
||||||
|
|
||||||
export default function Username({
|
export default function Username({
|
||||||
userId,
|
userId,
|
||||||
showAt = false,
|
showAt = false,
|
||||||
className,
|
className,
|
||||||
skeletonClassName,
|
skeletonClassName,
|
||||||
withoutSkeleton = false
|
withoutSkeleton = false,
|
||||||
|
showQrCode = true
|
||||||
}: {
|
}: {
|
||||||
userId: string
|
userId: string
|
||||||
showAt?: boolean
|
showAt?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
skeletonClassName?: string
|
skeletonClassName?: string
|
||||||
withoutSkeleton?: boolean
|
withoutSkeleton?: boolean
|
||||||
|
showQrCode?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { profile, isFetching } = useFetchProfile(userId)
|
const { profile, isFetching } = useFetchProfile(userId)
|
||||||
const supportTouch = useMemo(() => isTouchDevice(), [])
|
const supportTouch = useMemo(() => isTouchDevice(), [])
|
||||||
@@ -32,8 +35,7 @@ export default function Username({
|
|||||||
}
|
}
|
||||||
if (!profile) return null
|
if (!profile) return null
|
||||||
|
|
||||||
const trigger = (
|
const usernameLink = (
|
||||||
<div className={className}>
|
|
||||||
<SecondaryPageLink
|
<SecondaryPageLink
|
||||||
to={toProfile(userId)}
|
to={toProfile(userId)}
|
||||||
className="truncate hover:underline"
|
className="truncate hover:underline"
|
||||||
@@ -42,6 +44,12 @@ export default function Username({
|
|||||||
{showAt && '@'}
|
{showAt && '@'}
|
||||||
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
|
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
|
||||||
</SecondaryPageLink>
|
</SecondaryPageLink>
|
||||||
|
)
|
||||||
|
|
||||||
|
const trigger = (
|
||||||
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
|
{usernameLink}
|
||||||
|
{showQrCode && <NpubQrCode pubkey={userId} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -64,13 +72,15 @@ export function SimpleUsername({
|
|||||||
showAt = false,
|
showAt = false,
|
||||||
className,
|
className,
|
||||||
skeletonClassName,
|
skeletonClassName,
|
||||||
withoutSkeleton = false
|
withoutSkeleton = false,
|
||||||
|
showQrCode = true
|
||||||
}: {
|
}: {
|
||||||
userId: string
|
userId: string
|
||||||
showAt?: boolean
|
showAt?: boolean
|
||||||
className?: string
|
className?: string
|
||||||
skeletonClassName?: string
|
skeletonClassName?: string
|
||||||
withoutSkeleton?: boolean
|
withoutSkeleton?: boolean
|
||||||
|
showQrCode?: boolean
|
||||||
}) {
|
}) {
|
||||||
const { profile, isFetching } = useFetchProfile(userId)
|
const { profile, isFetching } = useFetchProfile(userId)
|
||||||
if (!profile && isFetching && !withoutSkeleton) {
|
if (!profile && isFetching && !withoutSkeleton) {
|
||||||
@@ -85,9 +95,12 @@ export function SimpleUsername({
|
|||||||
const { username, emojis } = profile
|
const { username, emojis } = profile
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={className}>
|
<div className={cn('flex items-center gap-1', className)}>
|
||||||
|
<span className="truncate">
|
||||||
{showAt && '@'}
|
{showAt && '@'}
|
||||||
<TextWithEmojis text={username} emojis={emojis} emojiClassName="mb-1" />
|
<TextWithEmojis text={username} emojis={emojis} emojiClassName="mb-1" />
|
||||||
|
</span>
|
||||||
|
{showQrCode && <NpubQrCode pubkey={userId} />}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -48,6 +48,9 @@ export const StorageKey = {
|
|||||||
DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
|
DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
|
||||||
DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
|
DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
|
||||||
GRAPH_QUERIES_ENABLED: 'graphQueriesEnabled',
|
GRAPH_QUERIES_ENABLED: 'graphQueriesEnabled',
|
||||||
|
SOCIAL_GRAPH_PROXIMITY: 'socialGraphProximity',
|
||||||
|
SOCIAL_GRAPH_INCLUDE_MODE: 'socialGraphIncludeMode',
|
||||||
|
NRC_ONLY_CONFIG_SYNC: 'nrcOnlyConfigSync',
|
||||||
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
|
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
|
||||||
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
|
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
|
||||||
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
|
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ const MePage = forwardRef<TPageRef>((_, ref) => {
|
|||||||
className="text-xl font-semibold text-wrap"
|
className="text-xl font-semibold text-wrap"
|
||||||
userId={pubkey}
|
userId={pubkey}
|
||||||
skeletonClassName="h-6 w-32"
|
skeletonClassName="h-6 w-32"
|
||||||
|
showQrCode={false}
|
||||||
/>
|
/>
|
||||||
<div className="flex gap-1 mt-1">
|
<div className="flex gap-1 mt-1">
|
||||||
<PubkeyCopy pubkey={pubkey} />
|
<PubkeyCopy pubkey={pubkey} />
|
||||||
|
|||||||
@@ -28,5 +28,5 @@ export default function PinnedFeed() {
|
|||||||
init()
|
init()
|
||||||
}, [pubkey, pinnedPubkeySet])
|
}, [pubkey, pinnedPubkeySet])
|
||||||
|
|
||||||
return <NormalFeed subRequests={subRequests} isMainFeed />
|
return <NormalFeed subRequests={subRequests} isMainFeed enableSocialGraphFilter />
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -28,6 +28,7 @@ export default function RelaysFeed() {
|
|||||||
areAlgoRelays={areAlgoRelays}
|
areAlgoRelays={areAlgoRelays}
|
||||||
isMainFeed
|
isMainFeed
|
||||||
showRelayCloseReason
|
showRelayCloseReason
|
||||||
|
enableSocialGraphFilter
|
||||||
/>
|
/>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,6 +33,7 @@ type TDMContext = {
|
|||||||
setPreferNip44: (prefer: boolean) => void
|
setPreferNip44: (prefer: boolean) => void
|
||||||
isNewConversation: boolean
|
isNewConversation: boolean
|
||||||
clearNewConversationFlag: () => void
|
clearNewConversationFlag: () => void
|
||||||
|
dismissProvisionalConversation: () => void
|
||||||
// Unread tracking
|
// Unread tracking
|
||||||
totalUnreadCount: number
|
totalUnreadCount: number
|
||||||
hasNewMessages: boolean
|
hasNewMessages: boolean
|
||||||
@@ -85,6 +86,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
|
const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
|
||||||
const [hasMoreConversations, setHasMoreConversations] = useState(false)
|
const [hasMoreConversations, setHasMoreConversations] = useState(false)
|
||||||
const [isNewConversation, setIsNewConversation] = useState(false)
|
const [isNewConversation, setIsNewConversation] = useState(false)
|
||||||
|
const [provisionalPubkey, setProvisionalPubkey] = useState<string | null>(null)
|
||||||
const [deletedState, setDeletedState] = useState<TDMDeletedState | null>(null)
|
const [deletedState, setDeletedState] = useState<TDMDeletedState | null>(null)
|
||||||
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
|
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
|
||||||
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
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)
|
// 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(
|
const startConversation = useCallback(
|
||||||
(partnerPubkey: string) => {
|
(partnerPubkey: string) => {
|
||||||
// Check if this is a new conversation (not in existing list)
|
// Check if this is a new conversation (not in existing list)
|
||||||
@@ -585,6 +588,18 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
)
|
)
|
||||||
if (!existingConversation) {
|
if (!existingConversation) {
|
||||||
setIsNewConversation(true)
|
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
|
// Clear messages and select the conversation
|
||||||
setMessages([])
|
setMessages([])
|
||||||
@@ -597,6 +612,25 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setIsNewConversation(false)
|
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
|
// Reload the current conversation by clearing its cached state
|
||||||
const reloadConversation = useCallback(() => {
|
const reloadConversation = useCallback(() => {
|
||||||
if (!currentConversation) return
|
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) => {
|
const setPreferNip44 = useCallback((prefer: boolean) => {
|
||||||
@@ -927,6 +967,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
|||||||
setPreferNip44,
|
setPreferNip44,
|
||||||
isNewConversation,
|
isNewConversation,
|
||||||
clearNewConversationFlag,
|
clearNewConversationFlag,
|
||||||
|
dismissProvisionalConversation,
|
||||||
// Unread tracking
|
// Unread tracking
|
||||||
totalUnreadCount,
|
totalUnreadCount,
|
||||||
hasNewMessages,
|
hasNewMessages,
|
||||||
|
|||||||
859
src/providers/NRCProvider.tsx
Normal file
859
src/providers/NRCProvider.tsx
Normal file
@@ -0,0 +1,859 @@
|
|||||||
|
/**
|
||||||
|
* 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 * as utils from '@noble/curves/abstract/utils'
|
||||||
|
import { useNostr } from './NostrProvider'
|
||||||
|
import client from '@/services/client.service'
|
||||||
|
import indexedDb from '@/services/indexed-db.service'
|
||||||
|
import cashuTokenService, { TokenScope } from '@/services/cashu-token.service'
|
||||||
|
import {
|
||||||
|
NRCConnection,
|
||||||
|
CATConfig,
|
||||||
|
NRCListenerConfig,
|
||||||
|
generateConnectionURI,
|
||||||
|
generateCATConnectionURI,
|
||||||
|
getNRCListenerService,
|
||||||
|
syncFromRemote,
|
||||||
|
testConnection,
|
||||||
|
parseConnectionURI,
|
||||||
|
relaySupportsCat,
|
||||||
|
deriveMintUrlFromRelay,
|
||||||
|
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
|
||||||
|
relaySupportsCat: boolean // Auto-detected CAT support
|
||||||
|
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, useCat?: boolean) => 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
|
||||||
|
})
|
||||||
|
|
||||||
|
// Auto-detected CAT support for the rendezvous relay
|
||||||
|
const [relaySupportsCatState, setRelaySupportsCatState] = useState(false)
|
||||||
|
|
||||||
|
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])
|
||||||
|
|
||||||
|
// Auto-detect CAT support when rendezvous URL changes
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false
|
||||||
|
|
||||||
|
const checkCatSupport = async () => {
|
||||||
|
try {
|
||||||
|
const supported = await relaySupportsCat(rendezvousUrl)
|
||||||
|
if (!cancelled) {
|
||||||
|
setRelaySupportsCatState(supported)
|
||||||
|
console.log(`[NRC] Relay ${rendezvousUrl} CAT support:`, supported)
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
if (!cancelled) {
|
||||||
|
setRelaySupportsCatState(false)
|
||||||
|
console.log(`[NRC] Failed to check CAT support for ${rendezvousUrl}:`, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
checkCatSupport()
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true
|
||||||
|
}
|
||||||
|
}, [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 {
|
||||||
|
// Build CAT config if relay supports it
|
||||||
|
const catConfig: CATConfig | undefined = relaySupportsCatState
|
||||||
|
? { mintUrl: deriveMintUrlFromRelay(rendezvousUrl), scope: 'nrc' }
|
||||||
|
: undefined
|
||||||
|
|
||||||
|
const config: NRCListenerConfig = {
|
||||||
|
rendezvousUrl,
|
||||||
|
signer: client.signer!,
|
||||||
|
authorizedSecrets: buildAuthorizedSecrets(),
|
||||||
|
catConfig
|
||||||
|
}
|
||||||
|
|
||||||
|
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, relaySupportsCatState])
|
||||||
|
|
||||||
|
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 a CAT token for authentication
|
||||||
|
*/
|
||||||
|
const getCATToken = async (mintUrl: string, userPubkey: string): Promise<string | undefined> => {
|
||||||
|
if (!client.signer) return undefined
|
||||||
|
|
||||||
|
try {
|
||||||
|
cashuTokenService.setMint(mintUrl)
|
||||||
|
await cashuTokenService.fetchMintInfo()
|
||||||
|
|
||||||
|
const userPubkeyBytes = utils.hexToBytes(userPubkey)
|
||||||
|
const token = await cashuTokenService.requestToken(
|
||||||
|
TokenScope.NRC,
|
||||||
|
userPubkeyBytes,
|
||||||
|
async (url: string, method: string) => {
|
||||||
|
const authEvent = await client.signer!.signEvent({
|
||||||
|
kind: 27235,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['u', url],
|
||||||
|
['method', method]
|
||||||
|
],
|
||||||
|
content: ''
|
||||||
|
})
|
||||||
|
return `Nostr ${btoa(JSON.stringify(authEvent))}`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return cashuTokenService.encodeToken(token)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NRC] Failed to get CAT token:', err)
|
||||||
|
return undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 })
|
||||||
|
|
||||||
|
// Get CAT token if needed
|
||||||
|
let catToken: string | undefined
|
||||||
|
if (remote.authMode === 'cat' && remote.mintUrl) {
|
||||||
|
catToken = await getCATToken(remote.mintUrl, pubkey)
|
||||||
|
if (!catToken) {
|
||||||
|
console.error(`[NRC] Failed to get CAT token for ${remote.label}, skipping`)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const signer = remote.authMode === 'cat' ? client.signer : undefined
|
||||||
|
|
||||||
|
// 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 }],
|
||||||
|
undefined,
|
||||||
|
signer,
|
||||||
|
catToken
|
||||||
|
)
|
||||||
|
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 (need new CAT token for new connection)
|
||||||
|
if (toSend.length > 0) {
|
||||||
|
setSyncProgress({ phase: 'sending', eventsReceived: 0, eventsSent: 0, message: `Sending ${toSend.length} events...` })
|
||||||
|
|
||||||
|
// Get fresh CAT token for sending
|
||||||
|
let sendCatToken = catToken
|
||||||
|
if (remote.authMode === 'cat' && remote.mintUrl) {
|
||||||
|
sendCatToken = await getCATToken(remote.mintUrl, pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
eventsSent = await sendEventsToRemote(
|
||||||
|
remote.uri,
|
||||||
|
toSend,
|
||||||
|
(progress) => setSyncProgress({ ...progress, message: `Sending events... (${progress.eventsSent || 0}/${toSend.length})` }),
|
||||||
|
signer,
|
||||||
|
sendCatToken
|
||||||
|
)
|
||||||
|
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...` })
|
||||||
|
|
||||||
|
// Get fresh CAT token for fetching
|
||||||
|
let fetchCatToken = catToken
|
||||||
|
if (remote.authMode === 'cat' && remote.mintUrl) {
|
||||||
|
fetchCatToken = await getCATToken(remote.mintUrl, pubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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})`
|
||||||
|
}),
|
||||||
|
signer,
|
||||||
|
fetchCatToken
|
||||||
|
)
|
||||||
|
fetchedEvents.push(...events)
|
||||||
|
|
||||||
|
// Get new CAT token for next batch if needed
|
||||||
|
if (remote.authMode === 'cat' && remote.mintUrl && i + BATCH_SIZE < toFetch.length) {
|
||||||
|
fetchCatToken = await getCATToken(remote.mintUrl, pubkey)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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, useCat = false): Promise<{ uri: string; connection: NRCConnection }> => {
|
||||||
|
if (!pubkey) {
|
||||||
|
throw new Error('Not logged in')
|
||||||
|
}
|
||||||
|
|
||||||
|
const id = crypto.randomUUID()
|
||||||
|
const createdAt = Date.now()
|
||||||
|
|
||||||
|
let connection: NRCConnection
|
||||||
|
let uri: string
|
||||||
|
|
||||||
|
// Use CAT if requested AND relay supports it, otherwise fall back to secret-based
|
||||||
|
if (useCat && relaySupportsCatState) {
|
||||||
|
uri = generateCATConnectionURI(pubkey, rendezvousUrl)
|
||||||
|
connection = {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
useCat: true,
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
const result = generateConnectionURI(pubkey, rendezvousUrl, undefined, label)
|
||||||
|
uri = result.uri
|
||||||
|
connection = {
|
||||||
|
id,
|
||||||
|
label,
|
||||||
|
secret: result.secret,
|
||||||
|
clientPubkey: result.clientPubkey,
|
||||||
|
useCat: false,
|
||||||
|
createdAt
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
setConnections((prev) => [...prev, connection])
|
||||||
|
|
||||||
|
return { uri, connection }
|
||||||
|
},
|
||||||
|
[pubkey, rendezvousUrl, relaySupportsCatState]
|
||||||
|
)
|
||||||
|
|
||||||
|
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.useCat && relaySupportsCatState) {
|
||||||
|
return generateCATConnectionURI(pubkey, rendezvousUrl)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (connection.secret) {
|
||||||
|
const result = generateConnectionURI(
|
||||||
|
pubkey,
|
||||||
|
rendezvousUrl,
|
||||||
|
connection.secret,
|
||||||
|
connection.label
|
||||||
|
)
|
||||||
|
return result.uri
|
||||||
|
}
|
||||||
|
|
||||||
|
throw new Error('Connection has no secret and relay does not support CAT')
|
||||||
|
},
|
||||||
|
[pubkey, rendezvousUrl, relaySupportsCatState]
|
||||||
|
)
|
||||||
|
|
||||||
|
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,
|
||||||
|
authMode: parsed.authMode,
|
||||||
|
mintUrl: parsed.mintUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
]
|
||||||
|
|
||||||
|
let catToken: string | undefined
|
||||||
|
|
||||||
|
// For CAT mode, obtain a token from the mint
|
||||||
|
if (remote.authMode === 'cat' && remote.mintUrl && client.signer && pubkey) {
|
||||||
|
console.log('[NRC] CAT mode: obtaining token from mint', remote.mintUrl)
|
||||||
|
try {
|
||||||
|
cashuTokenService.setMint(remote.mintUrl)
|
||||||
|
await cashuTokenService.fetchMintInfo()
|
||||||
|
|
||||||
|
const userPubkeyBytes = utils.hexToBytes(pubkey)
|
||||||
|
const token = await cashuTokenService.requestToken(
|
||||||
|
TokenScope.NRC,
|
||||||
|
userPubkeyBytes,
|
||||||
|
async (url: string, method: string) => {
|
||||||
|
// NIP-98 HTTP auth signing
|
||||||
|
const authEvent = await client.signer!.signEvent({
|
||||||
|
kind: 27235,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['u', url],
|
||||||
|
['method', method]
|
||||||
|
],
|
||||||
|
content: ''
|
||||||
|
})
|
||||||
|
return `Nostr ${btoa(JSON.stringify(authEvent))}`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
catToken = cashuTokenService.encodeToken(token)
|
||||||
|
console.log('[NRC] CAT token obtained successfully')
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NRC] Failed to obtain CAT token:', err)
|
||||||
|
throw new Error(`Failed to obtain CAT token: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const events = await syncFromRemote(
|
||||||
|
remote.uri,
|
||||||
|
syncFilters,
|
||||||
|
(progress) => setSyncProgress(progress),
|
||||||
|
remote.authMode === 'cat' ? client.signer : undefined,
|
||||||
|
catToken
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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, pubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
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 {
|
||||||
|
let catToken: string | undefined
|
||||||
|
|
||||||
|
// For CAT mode, obtain a token from the mint
|
||||||
|
if (remote.authMode === 'cat' && remote.mintUrl && client.signer && pubkey) {
|
||||||
|
console.log('[NRC] CAT mode: obtaining token for test from mint', remote.mintUrl)
|
||||||
|
try {
|
||||||
|
cashuTokenService.setMint(remote.mintUrl)
|
||||||
|
await cashuTokenService.fetchMintInfo()
|
||||||
|
|
||||||
|
const userPubkeyBytes = utils.hexToBytes(pubkey)
|
||||||
|
const token = await cashuTokenService.requestToken(
|
||||||
|
TokenScope.NRC,
|
||||||
|
userPubkeyBytes,
|
||||||
|
async (url: string, method: string) => {
|
||||||
|
const authEvent = await client.signer!.signEvent({
|
||||||
|
kind: 27235,
|
||||||
|
created_at: Math.floor(Date.now() / 1000),
|
||||||
|
tags: [
|
||||||
|
['u', url],
|
||||||
|
['method', method]
|
||||||
|
],
|
||||||
|
content: ''
|
||||||
|
})
|
||||||
|
return `Nostr ${btoa(JSON.stringify(authEvent))}`
|
||||||
|
}
|
||||||
|
)
|
||||||
|
catToken = cashuTokenService.encodeToken(token)
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NRC] Failed to obtain CAT token for test:', err)
|
||||||
|
throw new Error(`Failed to obtain CAT token: ${err instanceof Error ? err.message : 'Unknown error'}`)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await testConnection(
|
||||||
|
remote.uri,
|
||||||
|
(progress) => setSyncProgress(progress),
|
||||||
|
remote.authMode === 'cat' ? client.signer : undefined,
|
||||||
|
catToken
|
||||||
|
)
|
||||||
|
|
||||||
|
// 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, pubkey]
|
||||||
|
)
|
||||||
|
|
||||||
|
const value: NRCContextType = {
|
||||||
|
// Listener
|
||||||
|
isEnabled,
|
||||||
|
isListening,
|
||||||
|
isConnected,
|
||||||
|
connections,
|
||||||
|
activeSessions,
|
||||||
|
relaySupportsCat: relaySupportsCatState,
|
||||||
|
rendezvousUrl,
|
||||||
|
enable,
|
||||||
|
disable,
|
||||||
|
addConnection,
|
||||||
|
removeConnection,
|
||||||
|
getConnectionURI,
|
||||||
|
setRendezvousUrl,
|
||||||
|
// Client
|
||||||
|
remoteConnections,
|
||||||
|
isSyncing,
|
||||||
|
syncProgress,
|
||||||
|
addRemoteConnection,
|
||||||
|
removeRemoteConnection,
|
||||||
|
testRemoteConnection,
|
||||||
|
syncFromDevice,
|
||||||
|
syncAllRemotes
|
||||||
|
}
|
||||||
|
|
||||||
|
return <NRCContext.Provider value={value}>{children}</NRCContext.Provider>
|
||||||
|
}
|
||||||
@@ -13,6 +13,7 @@
|
|||||||
|
|
||||||
import { ISigner, TDraftEvent } from '@/types'
|
import { ISigner, TDraftEvent } from '@/types'
|
||||||
import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
|
import cashuTokenService, { TCashuToken, TokenScope } from '@/services/cashu-token.service'
|
||||||
|
import { relaySupportsCat, deriveMintUrlFromRelay } from '@/services/nrc'
|
||||||
import * as utils from '@noble/curves/abstract/utils'
|
import * as utils from '@noble/curves/abstract/utils'
|
||||||
import { secp256k1 } from '@noble/curves/secp256k1'
|
import { secp256k1 } from '@noble/curves/secp256k1'
|
||||||
import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools'
|
import { Event, VerifiedEvent, getPublicKey as nGetPublicKey, nip04, finalizeEvent } from 'nostr-tools'
|
||||||
@@ -397,35 +398,28 @@ export class BunkerSigner implements ISigner {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Check if relay requires Cashu token and acquire one if needed.
|
* Check if relay supports Cashu tokens and acquire one if so.
|
||||||
|
* Falls back gracefully to regular connection if CAT is not supported.
|
||||||
*/
|
*/
|
||||||
private async acquireTokenIfNeeded(relayUrl: string): Promise<void> {
|
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 {
|
try {
|
||||||
// Check if relay has Cashu mint endpoints
|
// First check if relay supports CAT using the NRC helper
|
||||||
const infoResponse = await fetch(`${mintUrl}/cashu/info`)
|
const supportsCat = await relaySupportsCat(relayUrl)
|
||||||
if (!infoResponse.ok) {
|
if (!supportsCat) {
|
||||||
console.log(`Relay ${relayUrl} does not support Cashu tokens`)
|
console.log(`[Bunker] Relay ${relayUrl} does not support CAT, using regular connection`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
await infoResponse.json() // Validate JSON response
|
console.log(`[Bunker] Relay ${relayUrl} supports CAT, acquiring token...`)
|
||||||
console.log(`Relay ${relayUrl} requires Cashu token, acquiring...`)
|
|
||||||
|
|
||||||
// Configure the mint
|
// Derive mint URL from relay URL
|
||||||
|
const mintUrl = deriveMintUrlFromRelay(relayUrl)
|
||||||
this.mintUrl = mintUrl
|
this.mintUrl = mintUrl
|
||||||
cashuTokenService.setMint(mintUrl)
|
cashuTokenService.setMint(mintUrl)
|
||||||
|
|
||||||
|
// Fetch mint info to initialize the service
|
||||||
|
await cashuTokenService.fetchMintInfo()
|
||||||
|
|
||||||
// Create NIP-98 auth signer using our local ephemeral key
|
// Create NIP-98 auth signer using our local ephemeral key
|
||||||
const signHttpAuth = async (url: string, method: string): Promise<string> => {
|
const signHttpAuth = async (url: string, method: string): Promise<string> => {
|
||||||
const authEvent: TDraftEvent = {
|
const authEvent: TDraftEvent = {
|
||||||
@@ -457,10 +451,10 @@ export class BunkerSigner implements ISigner {
|
|||||||
|
|
||||||
this.token = token
|
this.token = token
|
||||||
cashuTokenService.storeTokens(this.bunkerPubkey, token)
|
cashuTokenService.storeTokens(this.bunkerPubkey, token)
|
||||||
console.log(`Acquired Cashu token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`)
|
console.log(`[Bunker] Acquired CAT token for ${relayUrl}, expires: ${new Date(token.expiry * 1000).toISOString()}`)
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
// Relay doesn't support Cashu or request failed - continue without token
|
// Token acquisition failed - continue without token
|
||||||
console.warn(`Could not acquire Cashu token for ${relayUrl}:`, err)
|
console.log(`[Bunker] CAT token acquisition failed for ${relayUrl}, using regular connection:`, err instanceof Error ? err.message : err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,7 +45,8 @@ function getCurrentSettings(): TSyncSettings {
|
|||||||
filterOutOnionRelays: storage.getFilterOutOnionRelays(),
|
filterOutOnionRelays: storage.getFilterOutOnionRelays(),
|
||||||
quickReaction: storage.getQuickReaction(),
|
quickReaction: storage.getQuickReaction(),
|
||||||
quickReactionEmoji: storage.getQuickReactionEmoji(),
|
quickReactionEmoji: storage.getQuickReactionEmoji(),
|
||||||
noteListMode: storage.getNoteListMode()
|
noteListMode: storage.getNoteListMode(),
|
||||||
|
nrcOnlyConfigSync: storage.getNrcOnlyConfigSync()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -113,6 +114,9 @@ function applySettings(settings: TSyncSettings) {
|
|||||||
if (settings.noteListMode !== undefined) {
|
if (settings.noteListMode !== undefined) {
|
||||||
storage.setNoteListMode(settings.noteListMode)
|
storage.setNoteListMode(settings.noteListMode)
|
||||||
}
|
}
|
||||||
|
if (settings.nrcOnlyConfigSync !== undefined) {
|
||||||
|
storage.setNrcOnlyConfigSync(settings.nrcOnlyConfigSync)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SettingsSyncProvider({ children }: { children: React.ReactNode }) {
|
export function SettingsSyncProvider({ children }: { children: React.ReactNode }) {
|
||||||
@@ -155,6 +159,9 @@ export function SettingsSyncProvider({ children }: { children: React.ReactNode }
|
|||||||
const syncSettings = useCallback(async () => {
|
const syncSettings = useCallback(async () => {
|
||||||
if (!pubkey || !account) return
|
if (!pubkey || !account) return
|
||||||
|
|
||||||
|
// Skip relay-based settings sync if NRC-only config sync is enabled
|
||||||
|
if (storage.getNrcOnlyConfigSync()) return
|
||||||
|
|
||||||
const currentSettings = getCurrentSettings()
|
const currentSettings = getCurrentSettings()
|
||||||
const settingsJson = JSON.stringify(currentSettings)
|
const settingsJson = JSON.stringify(currentSettings)
|
||||||
|
|
||||||
@@ -192,6 +199,13 @@ export function SettingsSyncProvider({ children }: { children: React.ReactNode }
|
|||||||
return
|
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 () => {
|
const loadRemoteSettings = async () => {
|
||||||
setIsLoading(true)
|
setIsLoading(true)
|
||||||
try {
|
try {
|
||||||
|
|||||||
114
src/providers/SocialGraphFilterProvider.tsx
Normal file
114
src/providers/SocialGraphFilterProvider.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -22,7 +22,8 @@ export const TokenScope = {
|
|||||||
RELAY: 'relay',
|
RELAY: 'relay',
|
||||||
NIP46: 'nip46',
|
NIP46: 'nip46',
|
||||||
BLOSSOM: 'blossom',
|
BLOSSOM: 'blossom',
|
||||||
API: 'api'
|
API: 'api',
|
||||||
|
NRC: 'nrc'
|
||||||
} as const
|
} as const
|
||||||
|
|
||||||
export type TTokenScope = (typeof TokenScope)[keyof typeof TokenScope]
|
export type TTokenScope = (typeof TokenScope)[keyof typeof TokenScope]
|
||||||
|
|||||||
@@ -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>()
|
const relaySet = new Set<string>()
|
||||||
if (specifiedRelayUrls?.length) {
|
if (specifiedRelayUrls?.length) {
|
||||||
specifiedRelayUrls.forEach((url) => relaySet.add(url))
|
specifiedRelayUrls.forEach((url) => relaySet.add(url))
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { ExtendedKind } from '@/constants'
|
import { ExtendedKind } from '@/constants'
|
||||||
import { tagNameEquals } from '@/lib/tag'
|
import { tagNameEquals } from '@/lib/tag'
|
||||||
import { TDMDeletedState, TRelayInfo } from '@/types'
|
import { TDMDeletedState, TRelayInfo } from '@/types'
|
||||||
import { Event, kinds } from 'nostr-tools'
|
import { Event, Filter, kinds, matchFilters } from 'nostr-tools'
|
||||||
|
|
||||||
type TValue<T = any> = {
|
type TValue<T = any> = {
|
||||||
key: string
|
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() {
|
private async cleanUp() {
|
||||||
await this.initPromise
|
await this.initPromise
|
||||||
if (!this.db) {
|
if (!this.db) {
|
||||||
|
|||||||
@@ -61,6 +61,9 @@ class LocalStorageService {
|
|||||||
private preferNip44: boolean = false
|
private preferNip44: boolean = false
|
||||||
private dmConversationFilter: 'all' | 'follows' = 'all'
|
private dmConversationFilter: 'all' | 'follows' = 'all'
|
||||||
private graphQueriesEnabled: boolean = true
|
private graphQueriesEnabled: boolean = true
|
||||||
|
private socialGraphProximity: number | null = null
|
||||||
|
private socialGraphIncludeMode: boolean = true // true = include only, false = exclude
|
||||||
|
private nrcOnlyConfigSync: boolean = false
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
if (!LocalStorageService.instance) {
|
if (!LocalStorageService.instance) {
|
||||||
@@ -251,6 +254,20 @@ class LocalStorageService {
|
|||||||
this.graphQueriesEnabled =
|
this.graphQueriesEnabled =
|
||||||
window.localStorage.getItem(StorageKey.GRAPH_QUERIES_ENABLED) !== 'false'
|
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
|
// Clean up deprecated data
|
||||||
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
|
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
|
||||||
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
window.localStorage.removeItem(StorageKey.ACCOUNT_PROFILE_EVENT_MAP)
|
||||||
@@ -651,6 +668,37 @@ class LocalStorageService {
|
|||||||
this.graphQueriesEnabled = enabled
|
this.graphQueriesEnabled = enabled
|
||||||
window.localStorage.setItem(StorageKey.GRAPH_QUERIES_ENABLED, enabled.toString())
|
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()
|
const instance = new LocalStorageService()
|
||||||
|
|||||||
6
src/services/nrc/index.ts
Normal file
6
src/services/nrc/index.ts
Normal 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'
|
||||||
877
src/services/nrc/nrc-client.service.ts
Normal file
877
src/services/nrc/nrc-client.service.ts
Normal file
@@ -0,0 +1,877 @@
|
|||||||
|
/**
|
||||||
|
* 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 { ISigner } from '@/types'
|
||||||
|
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
|
||||||
|
authMode: 'secret' | 'cat'
|
||||||
|
mintUrl?: string // For CAT mode
|
||||||
|
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
|
||||||
|
// CAT mode fields
|
||||||
|
private signer?: ISigner
|
||||||
|
private catToken?: string
|
||||||
|
private clientPubkey?: string
|
||||||
|
|
||||||
|
constructor(connectionUri: string, signer?: ISigner, catToken?: string) {
|
||||||
|
this.uri = parseConnectionURI(connectionUri)
|
||||||
|
this.sessionId = generateSessionId()
|
||||||
|
this.signer = signer
|
||||||
|
this.catToken = catToken
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
|
|
||||||
|
// For CAT mode, get our pubkey from the signer
|
||||||
|
if (this.uri.authMode === 'cat' && this.signer) {
|
||||||
|
this.clientPubkey = await this.signer.getPublicKey()
|
||||||
|
console.log(`[NRC Client] CAT mode, our pubkey: ${this.clientPubkey?.slice(0, 8)}...`)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
|
||||||
|
// For CAT mode, get our pubkey from the signer
|
||||||
|
if (this.uri.authMode === 'cat' && this.signer) {
|
||||||
|
this.clientPubkey = await this.signer.getPublicKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
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]
|
||||||
|
|
||||||
|
// For CAT mode, get our pubkey from the signer
|
||||||
|
if (this.uri.authMode === 'cat' && this.signer) {
|
||||||
|
this.clientPubkey = await this.signer.getPublicKey()
|
||||||
|
}
|
||||||
|
|
||||||
|
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()
|
||||||
|
// Use CAT-mode pubkey if available, otherwise use secret-derived pubkey
|
||||||
|
const clientPubkey = this.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')
|
||||||
|
}
|
||||||
|
|
||||||
|
const plaintext = JSON.stringify(request)
|
||||||
|
let encrypted: string
|
||||||
|
let signedEvent: Event
|
||||||
|
|
||||||
|
if (this.uri.authMode === 'cat' && this.signer && this.clientPubkey) {
|
||||||
|
// CAT mode: use signer for encryption and signing
|
||||||
|
if (!this.signer.nip44Encrypt) {
|
||||||
|
throw new Error('Signer does not support NIP-44 encryption')
|
||||||
|
}
|
||||||
|
|
||||||
|
encrypted = await this.signer.nip44Encrypt(this.uri.relayPubkey, plaintext)
|
||||||
|
|
||||||
|
// Build the request event with CAT token
|
||||||
|
const tags: string[][] = [
|
||||||
|
['p', this.uri.relayPubkey],
|
||||||
|
['encryption', 'nip44_v2'],
|
||||||
|
['session', this.sessionId]
|
||||||
|
]
|
||||||
|
|
||||||
|
// Add CAT token if available
|
||||||
|
if (this.catToken) {
|
||||||
|
tags.push(['cashu', this.catToken])
|
||||||
|
}
|
||||||
|
|
||||||
|
const unsignedEvent = {
|
||||||
|
kind: KIND_NRC_REQUEST,
|
||||||
|
content: encrypted,
|
||||||
|
tags,
|
||||||
|
created_at: Math.floor(Date.now() / 1000)
|
||||||
|
}
|
||||||
|
|
||||||
|
signedEvent = await this.signer.signEvent(unsignedEvent)
|
||||||
|
console.log(`[NRC Client] CAT mode: Sent encrypted REQ with CAT token`)
|
||||||
|
} else {
|
||||||
|
// Secret mode: use derived keys
|
||||||
|
if (!this.uri.clientPrivkey || !this.uri.clientPubkey) {
|
||||||
|
throw new Error('Missing keys for secret mode')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Derive conversation key
|
||||||
|
const conversationKey = deriveConversationKey(
|
||||||
|
this.uri.clientPrivkey,
|
||||||
|
this.uri.relayPubkey
|
||||||
|
)
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
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> {
|
||||||
|
let plaintext: string
|
||||||
|
|
||||||
|
if (this.uri.authMode === 'cat' && this.signer) {
|
||||||
|
// CAT mode: use signer for decryption
|
||||||
|
if (!this.signer.nip44Decrypt) {
|
||||||
|
throw new Error('Signer does not support NIP-44 decryption')
|
||||||
|
}
|
||||||
|
plaintext = await this.signer.nip44Decrypt(event.pubkey, event.content)
|
||||||
|
} else {
|
||||||
|
// Secret mode: use derived key
|
||||||
|
if (!this.uri.clientPrivkey) {
|
||||||
|
throw new Error('Missing private key for decryption')
|
||||||
|
}
|
||||||
|
|
||||||
|
const conversationKey = deriveConversationKey(
|
||||||
|
this.uri.clientPrivkey,
|
||||||
|
this.uri.relayPubkey
|
||||||
|
)
|
||||||
|
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
|
||||||
|
* @param signer - Optional signer for CAT mode
|
||||||
|
* @param catToken - Optional CAT token for CAT mode
|
||||||
|
* @returns Array of synced events
|
||||||
|
*/
|
||||||
|
export async function syncFromRemote(
|
||||||
|
connectionUri: string,
|
||||||
|
filters: Filter[],
|
||||||
|
onProgress?: (progress: SyncProgress) => void,
|
||||||
|
signer?: ISigner,
|
||||||
|
catToken?: string
|
||||||
|
): Promise<Event[]> {
|
||||||
|
const client = new NRCClient(connectionUri, signer, catToken)
|
||||||
|
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
|
||||||
|
* @param signer - Optional signer for CAT mode
|
||||||
|
* @param catToken - Optional CAT token for CAT mode
|
||||||
|
* @returns true if connection successful
|
||||||
|
*/
|
||||||
|
export async function testConnection(
|
||||||
|
connectionUri: string,
|
||||||
|
onProgress?: (progress: SyncProgress) => void,
|
||||||
|
signer?: ISigner,
|
||||||
|
catToken?: string
|
||||||
|
): Promise<boolean> {
|
||||||
|
const client = new NRCClient(connectionUri, signer, catToken)
|
||||||
|
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
|
||||||
|
* @param signer - Optional signer for CAT mode
|
||||||
|
* @param catToken - Optional CAT token for CAT mode
|
||||||
|
* @returns Array of event manifest entries (id, kind, created_at, d)
|
||||||
|
*/
|
||||||
|
export async function requestRemoteIDs(
|
||||||
|
connectionUri: string,
|
||||||
|
filters: Filter[],
|
||||||
|
onProgress?: (progress: SyncProgress) => void,
|
||||||
|
signer?: ISigner,
|
||||||
|
catToken?: string
|
||||||
|
): Promise<EventManifestEntry[]> {
|
||||||
|
const client = new NRCClient(connectionUri, signer, catToken)
|
||||||
|
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
|
||||||
|
* @param signer - Optional signer for CAT mode
|
||||||
|
* @param catToken - Optional CAT token for CAT mode
|
||||||
|
* @returns Number of events successfully stored
|
||||||
|
*/
|
||||||
|
export async function sendEventsToRemote(
|
||||||
|
connectionUri: string,
|
||||||
|
events: Event[],
|
||||||
|
onProgress?: (progress: SyncProgress) => void,
|
||||||
|
signer?: ISigner,
|
||||||
|
catToken?: string
|
||||||
|
): Promise<number> {
|
||||||
|
const client = new NRCClient(connectionUri, signer, catToken)
|
||||||
|
return client.sendEvents(events, onProgress)
|
||||||
|
}
|
||||||
758
src/services/nrc/nrc-listener.service.ts
Normal file
758
src/services/nrc/nrc-listener.service.ts
Normal file
@@ -0,0 +1,758 @@
|
|||||||
|
/**
|
||||||
|
* 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 cashuTokenService, { decodeToken, TCashuToken } from '@/services/cashu-token.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.mode,
|
||||||
|
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')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for CAT token in cashu tag
|
||||||
|
const cashuTag = event.tags.find((t) => t[0] === 'cashu')
|
||||||
|
if (cashuTag && cashuTag[1] && this.config.catConfig) {
|
||||||
|
const catResult = await this.verifyCATToken(cashuTag[1], event.pubkey)
|
||||||
|
if (catResult) {
|
||||||
|
return catResult
|
||||||
|
}
|
||||||
|
// CAT verification failed, fall through to check other auth methods
|
||||||
|
console.log('[NRC] CAT verification failed, checking other auth methods')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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 {
|
||||||
|
mode: 'secret',
|
||||||
|
deviceName
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Verify a CAT (Cashu Access Token) for NRC authentication
|
||||||
|
*/
|
||||||
|
private async verifyCATToken(encodedToken: string, clientPubkey: string): Promise<AuthResult | null> {
|
||||||
|
if (!this.config?.catConfig) {
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Decode the token
|
||||||
|
const token: TCashuToken = decodeToken(encodedToken)
|
||||||
|
console.log('[NRC] Verifying CAT token, scope:', token.scope, 'expiry:', new Date(token.expiry * 1000))
|
||||||
|
|
||||||
|
// Check expiry
|
||||||
|
const now = Math.floor(Date.now() / 1000)
|
||||||
|
if (token.expiry < now) {
|
||||||
|
console.log('[NRC] CAT token expired')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check scope - must be 'nrc' or 'relay' for NRC auth
|
||||||
|
if (token.scope !== 'nrc' && token.scope !== 'relay') {
|
||||||
|
console.log('[NRC] CAT token has wrong scope:', token.scope)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Verify the token pubkey matches the event pubkey
|
||||||
|
const tokenPubkeyHex = utils.bytesToHex(token.pubkey)
|
||||||
|
if (tokenPubkeyHex !== clientPubkey) {
|
||||||
|
console.log('[NRC] CAT token pubkey mismatch:', tokenPubkeyHex, '!=', clientPubkey)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize the cashu service with the mint URL if not already done
|
||||||
|
cashuTokenService.setMint(this.config.catConfig.mintUrl)
|
||||||
|
|
||||||
|
// Verify token signature with mint
|
||||||
|
// Note: This requires the mint info to be fetched first
|
||||||
|
try {
|
||||||
|
await cashuTokenService.fetchMintInfo()
|
||||||
|
} catch (err) {
|
||||||
|
console.warn('[NRC] Could not fetch mint info for CAT verification:', err)
|
||||||
|
// Continue anyway - we've done basic validation
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cashuTokenService.verifyToken(token)) {
|
||||||
|
console.log('[NRC] CAT token signature verification failed')
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('[NRC] CAT token verified successfully')
|
||||||
|
return {
|
||||||
|
mode: 'cat',
|
||||||
|
deviceName: `cat:${clientPubkey.slice(0, 8)}`
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('[NRC] CAT token verification error:', err)
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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()
|
||||||
240
src/services/nrc/nrc-session.ts
Normal file
240
src/services/nrc/nrc-session.ts
Normal file
@@ -0,0 +1,240 @@
|
|||||||
|
import { Filter } from 'nostr-tools'
|
||||||
|
import { NRCSession, NRCSubscription, AuthMode } 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,
|
||||||
|
authMode: AuthMode,
|
||||||
|
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,
|
||||||
|
authMode,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
135
src/services/nrc/nrc-types.ts
Normal file
135
src/services/nrc/nrc-types.ts
Normal file
@@ -0,0 +1,135 @@
|
|||||||
|
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
|
||||||
|
|
||||||
|
// Authentication modes
|
||||||
|
export type AuthMode = 'secret' | 'cat'
|
||||||
|
|
||||||
|
// Session types
|
||||||
|
export interface NRCSession {
|
||||||
|
id: string
|
||||||
|
clientPubkey: string
|
||||||
|
conversationKey?: Uint8Array // Optional - only set when using direct key access
|
||||||
|
deviceName?: string
|
||||||
|
authMode: AuthMode
|
||||||
|
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
|
||||||
|
useCat: boolean // Whether to use CAT auth
|
||||||
|
createdAt: number
|
||||||
|
lastUsed?: number
|
||||||
|
}
|
||||||
|
|
||||||
|
// CAT (Cashu Access Token) configuration
|
||||||
|
export interface CATConfig {
|
||||||
|
mintUrl: string // Cashu mint URL
|
||||||
|
scope: string // Token scope (e.g., "nrc")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listener configuration
|
||||||
|
export interface NRCListenerConfig {
|
||||||
|
rendezvousUrl: string
|
||||||
|
signer: ISigner
|
||||||
|
authorizedSecrets: Map<string, string> // clientPubkey → deviceName
|
||||||
|
catConfig?: CATConfig // For CAT verification
|
||||||
|
sessionTimeout?: number // Session inactivity timeout in ms (default 30 min)
|
||||||
|
maxSubscriptionsPerSession?: number // Max subscriptions per session (default 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Authorization result
|
||||||
|
export interface AuthResult {
|
||||||
|
mode: AuthMode
|
||||||
|
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
|
||||||
|
authMode: AuthMode
|
||||||
|
// For secret-based auth
|
||||||
|
secret?: string // 32-byte hex secret
|
||||||
|
clientPubkey?: string // Derived pubkey from secret
|
||||||
|
clientPrivkey?: Uint8Array // Derived private key from secret
|
||||||
|
// For CAT auth
|
||||||
|
mintUrl?: string
|
||||||
|
// Optional
|
||||||
|
deviceName?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
// Listener state for React context
|
||||||
|
export interface NRCListenerState {
|
||||||
|
isEnabled: boolean
|
||||||
|
isListening: boolean
|
||||||
|
connections: NRCConnection[]
|
||||||
|
activeSessions: number
|
||||||
|
catConfig: CATConfig | null
|
||||||
|
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
|
||||||
|
}
|
||||||
241
src/services/nrc/nrc-uri.ts
Normal file
241
src/services/nrc/nrc-uri.ts
Normal file
@@ -0,0 +1,241 @@
|
|||||||
|
import * as utils from '@noble/curves/abstract/utils'
|
||||||
|
import { getPublicKey } from 'nostr-tools'
|
||||||
|
import * as nip44 from 'nostr-tools/nip44'
|
||||||
|
import { ParsedConnectionURI, AuthMode } from './nrc-types'
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Derive the Cashu mint URL from a relay URL.
|
||||||
|
* The mint is always at {relay-root}/cashu
|
||||||
|
*
|
||||||
|
* @param relayUrl - WebSocket relay URL (ws:// or wss://)
|
||||||
|
* @returns HTTP(S) mint URL
|
||||||
|
*/
|
||||||
|
export function deriveMintUrlFromRelay(relayUrl: string): string {
|
||||||
|
let mintUrl = relayUrl
|
||||||
|
|
||||||
|
// Convert WebSocket URL to HTTP URL
|
||||||
|
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
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove trailing slash and append /cashu
|
||||||
|
mintUrl = mintUrl.replace(/\/$/, '')
|
||||||
|
|
||||||
|
return mintUrl
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate a CAT-based NRC connection URI
|
||||||
|
*
|
||||||
|
* The mint URL is derived automatically from the rendezvous relay URL
|
||||||
|
* (mint is always at {relay-root}/cashu)
|
||||||
|
*
|
||||||
|
* @param relayPubkey - The public key of the listening client/relay
|
||||||
|
* @param rendezvousUrl - The URL of the rendezvous relay
|
||||||
|
* @returns The connection URI
|
||||||
|
*/
|
||||||
|
export function generateCATConnectionURI(
|
||||||
|
relayPubkey: string,
|
||||||
|
rendezvousUrl: string
|
||||||
|
): string {
|
||||||
|
const params = new URLSearchParams()
|
||||||
|
params.set('relay', rendezvousUrl)
|
||||||
|
params.set('auth', 'cat')
|
||||||
|
// Note: mint URL is derived from relay URL, not stored in URI
|
||||||
|
|
||||||
|
return `nostr+relayconnect://${relayPubkey}?${params.toString()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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')
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine auth mode
|
||||||
|
const authParam = url.searchParams.get('auth')
|
||||||
|
const authMode: AuthMode = authParam === 'cat' ? 'cat' : 'secret'
|
||||||
|
|
||||||
|
// Extract device name (optional)
|
||||||
|
const deviceName = url.searchParams.get('name') || undefined
|
||||||
|
|
||||||
|
if (authMode === 'cat') {
|
||||||
|
// CAT-based auth - mint URL is derived from relay URL
|
||||||
|
// (mint is always at {relay-root}/cashu)
|
||||||
|
const mintUrl = deriveMintUrlFromRelay(rendezvousUrl)
|
||||||
|
|
||||||
|
return {
|
||||||
|
relayPubkey,
|
||||||
|
rendezvousUrl,
|
||||||
|
authMode,
|
||||||
|
mintUrl,
|
||||||
|
deviceName
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Secret-based auth
|
||||||
|
const secret = url.searchParams.get('secret')
|
||||||
|
if (!secret) {
|
||||||
|
throw new Error('Secret auth requires secret parameter')
|
||||||
|
}
|
||||||
|
|
||||||
|
// 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,
|
||||||
|
authMode,
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if a relay supports CAT (Cashu Access Tokens)
|
||||||
|
* by probing the /cashu/info endpoint
|
||||||
|
*
|
||||||
|
* @param relayUrl - WebSocket relay URL
|
||||||
|
* @returns true if the relay has a Cashu mint
|
||||||
|
*/
|
||||||
|
export async function relaySupportsCat(relayUrl: string): Promise<boolean> {
|
||||||
|
const mintUrl = deriveMintUrlFromRelay(relayUrl)
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${mintUrl}/cashu/info`, {
|
||||||
|
method: 'GET',
|
||||||
|
headers: { Accept: 'application/json' }
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!response.ok) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if response is valid mint info
|
||||||
|
const info = await response.json()
|
||||||
|
return info && typeof info === 'object' && 'name' in info
|
||||||
|
} catch {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
1
src/types/index.d.ts
vendored
1
src/types/index.d.ts
vendored
@@ -236,6 +236,7 @@ export type TSyncSettings = {
|
|||||||
quickReactionEmoji?: string | TEmoji
|
quickReactionEmoji?: string | TEmoji
|
||||||
noteListMode?: TNoteListMode
|
noteListMode?: TNoteListMode
|
||||||
preferNip44?: boolean
|
preferNip44?: boolean
|
||||||
|
nrcOnlyConfigSync?: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
// DM types
|
// DM types
|
||||||
|
|||||||
Reference in New Issue
Block a user