Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
08f75a902d | ||
|
|
8a9795a53a | ||
|
|
d1ec24b85a |
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"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "smesh",
|
||||
"version": "0.3.1",
|
||||
"version": "0.4.1",
|
||||
"description": "A user-friendly Nostr client for exploring relay feeds",
|
||||
"private": true,
|
||||
"type": "module",
|
||||
|
||||
11
src/App.tsx
11
src/App.tsx
@@ -12,6 +12,7 @@ import { FavoriteRelaysProvider } from '@/providers/FavoriteRelaysProvider'
|
||||
import { FeedProvider } from '@/providers/FeedProvider'
|
||||
import { FollowListProvider } from '@/providers/FollowListProvider'
|
||||
import { KindFilterProvider } from '@/providers/KindFilterProvider'
|
||||
import { SocialGraphFilterProvider } from '@/providers/SocialGraphFilterProvider'
|
||||
import { MediaUploadServiceProvider } from '@/providers/MediaUploadServiceProvider'
|
||||
import { MuteListProvider } from '@/providers/MuteListProvider'
|
||||
import { NostrProvider } from '@/providers/NostrProvider'
|
||||
@@ -51,10 +52,12 @@ export default function App(): JSX.Element {
|
||||
<PinnedUsersProvider>
|
||||
<FeedProvider>
|
||||
<MediaUploadServiceProvider>
|
||||
<KindFilterProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</KindFilterProvider>
|
||||
<SocialGraphFilterProvider>
|
||||
<KindFilterProvider>
|
||||
<PageManager />
|
||||
<Toaster />
|
||||
</KindFilterProvider>
|
||||
</SocialGraphFilterProvider>
|
||||
</MediaUploadServiceProvider>
|
||||
</FeedProvider>
|
||||
</PinnedUsersProvider>
|
||||
|
||||
@@ -1,78 +1,12 @@
|
||||
import QrScannerModal from '@/components/QrScannerModal'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { ScanLine, X } from 'lucide-react'
|
||||
import QrScanner from 'qr-scanner'
|
||||
|
||||
function QrScannerModal({
|
||||
onScan,
|
||||
onClose
|
||||
}: {
|
||||
onScan: (result: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const scannerRef = useRef<QrScanner | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleScan = useCallback(
|
||||
(result: QrScanner.ScanResult) => {
|
||||
onScan(result.data)
|
||||
onClose()
|
||||
},
|
||||
[onScan, onClose]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
|
||||
const scanner = new QrScanner(videoRef.current, handleScan, {
|
||||
preferredCamera: 'environment',
|
||||
highlightScanRegion: true,
|
||||
highlightCodeOutline: true
|
||||
})
|
||||
|
||||
scannerRef.current = scanner
|
||||
|
||||
scanner.start().catch(() => {
|
||||
setError(t('Failed to access camera'))
|
||||
})
|
||||
|
||||
return () => {
|
||||
scanner.destroy()
|
||||
}
|
||||
}, [handleScan, t])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
|
||||
<div className="relative w-full max-w-sm mx-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute -top-12 right-0 text-white hover:bg-white/20"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
<div className="rounded-lg overflow-hidden bg-black">
|
||||
{error ? (
|
||||
<div className="p-8 text-center text-destructive">{error}</div>
|
||||
) : (
|
||||
<video ref={videoRef} className="w-full" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-white/70 text-sm mt-4">
|
||||
{t('Point camera at QR code')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
import { ScanLine } from 'lucide-react'
|
||||
|
||||
export default function PrivateKeyLogin({
|
||||
back,
|
||||
|
||||
@@ -23,15 +23,33 @@ export default function Help() {
|
||||
<AccordionContent className="pb-4">
|
||||
<div className="space-y-4 text-sm text-muted-foreground">
|
||||
<p>{t('Navigate the app entirely with your keyboard:')}</p>
|
||||
<p className="font-medium">{t('Toggle Keyboard Mode:')}</p>
|
||||
<div className="space-y-2">
|
||||
<KeyBinding keys={['Arrow Up', 'Arrow Down']} description={t('Move between items in a list')} />
|
||||
<KeyBinding keys={['Arrow Left', 'Arrow Right']} description={t('Switch between columns (sidebar, feed, detail)')} />
|
||||
<KeyBinding keys={['Enter']} description={t('Open or activate the selected item')} />
|
||||
<KeyBinding keys={['Escape']} description={t('Close current view or go to sidebar')} />
|
||||
<KeyBinding keys={['Backspace']} description={t('Go back to previous view')} />
|
||||
<KeyBinding keys={['Page Up', 'Page Down']} description={t('Jump to top or bottom of list')} />
|
||||
<KeyBinding keys={['Tab']} description={t('Cycle through note actions (reply, repost, quote, react, zap)')} />
|
||||
<KeyBinding keys={['⇧K']} description={t('Toggle keyboard navigation on/off')} />
|
||||
<KeyBinding keys={['Esc', 'Esc', 'Esc']} description={t('Triple-Escape to quickly exit keyboard mode')} />
|
||||
</div>
|
||||
<p className="text-xs opacity-70">{t('You can also click the keyboard button in the sidebar to toggle.')}</p>
|
||||
<p className="font-medium mt-4">{t('Movement:')}</p>
|
||||
<div className="space-y-2">
|
||||
<KeyBinding keys={['↑', '↓']} altKeys={['k', 'j']} description={t('Move between items in a list')} />
|
||||
<KeyBinding keys={['Tab']} description={t('Switch to next column (Shift+Tab for previous)')} />
|
||||
<KeyBinding keys={['Page Up']} description={t('Jump to top and focus first item')} />
|
||||
</div>
|
||||
<p className="font-medium mt-4">{t('Actions:')}</p>
|
||||
<div className="space-y-2">
|
||||
<KeyBinding keys={['→', 'Enter']} altKeys={['l']} description={t('Activate the selected item')} />
|
||||
<KeyBinding keys={['←']} altKeys={['h']} description={t('Go back (close panel or move to sidebar)')} />
|
||||
<KeyBinding keys={['Escape']} description={t('Close current view or cancel')} />
|
||||
</div>
|
||||
<p className="font-medium mt-4">{t('Note Actions (when a note is selected):')}</p>
|
||||
<div className="space-y-2">
|
||||
<KeyBinding keys={['r']} description={t('Reply')} />
|
||||
<KeyBinding keys={['p']} description={t('Repost')} />
|
||||
<KeyBinding keys={['q']} description={t('Quote')} />
|
||||
<KeyBinding keys={['R']} description={t('React with emoji')} />
|
||||
<KeyBinding keys={['z']} description={t('Zap (send sats)')} />
|
||||
</div>
|
||||
<p className="text-xs opacity-70 pt-2">{t('Selected items are centered on screen for easy viewing.')}</p>
|
||||
</div>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -156,18 +174,33 @@ export default function Help() {
|
||||
)
|
||||
}
|
||||
|
||||
function KeyBinding({ keys, description }: { keys: string[]; description: string }) {
|
||||
function KeyBinding({
|
||||
keys,
|
||||
altKeys,
|
||||
description
|
||||
}: {
|
||||
keys: string[]
|
||||
altKeys?: string[]
|
||||
description: string
|
||||
}) {
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex gap-1">
|
||||
<div className="flex items-center gap-1">
|
||||
{keys.map((key) => (
|
||||
<kbd
|
||||
key={key}
|
||||
className="px-2 py-1 text-xs font-mono bg-muted border rounded"
|
||||
>
|
||||
<kbd key={key} className="px-2 py-1 text-xs font-mono bg-muted border rounded">
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
{altKeys && (
|
||||
<>
|
||||
<span className="text-xs text-muted-foreground mx-1">/</span>
|
||||
{altKeys.map((key) => (
|
||||
<kbd key={key} className="px-2 py-1 text-xs font-mono bg-muted border rounded">
|
||||
{key}
|
||||
</kbd>
|
||||
))}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<span>{description}</span>
|
||||
</div>
|
||||
|
||||
@@ -26,9 +26,10 @@ import { useFollowList } from '@/providers/FollowListProvider'
|
||||
|
||||
interface MessageViewProps {
|
||||
onBack?: () => void
|
||||
hideHeader?: boolean
|
||||
}
|
||||
|
||||
export default function MessageView({ onBack }: MessageViewProps) {
|
||||
export default function MessageView({ onBack, hideHeader }: MessageViewProps) {
|
||||
const { t } = useTranslation()
|
||||
const { pubkey } = useNostr()
|
||||
const {
|
||||
@@ -184,6 +185,20 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
||||
lastMessageCountRef.current = 0
|
||||
}, [currentConversation])
|
||||
|
||||
// Scroll to bottom when conversation opens and messages are loaded
|
||||
const hasMessages = messages.length > 0
|
||||
useEffect(() => {
|
||||
if (currentConversation && hasMessages && scrollRef.current) {
|
||||
// Use requestAnimationFrame to ensure DOM is ready
|
||||
requestAnimationFrame(() => {
|
||||
if (scrollRef.current) {
|
||||
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
|
||||
lastMessageCountRef.current = messages.length
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [currentConversation, hasMessages])
|
||||
|
||||
if (!currentConversation || !pubkey) {
|
||||
return null
|
||||
}
|
||||
@@ -192,114 +207,116 @@ export default function MessageView({ onBack }: MessageViewProps) {
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center gap-3 p-3 border-b">
|
||||
{isSelectionMode ? (
|
||||
// Selection mode header
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={clearSelection}
|
||||
className="size-8"
|
||||
title={t('Cancel')}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
<span className="font-medium text-sm">{t('Delete')}</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deleteSelectedMessages}
|
||||
disabled={selectedMessages.size === 0}
|
||||
className="text-xs"
|
||||
>
|
||||
{t('Selected')} ({selectedMessages.size})
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={deleteAllInConversation}
|
||||
className="text-xs"
|
||||
>
|
||||
{t('All')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
// Normal header
|
||||
<>
|
||||
<UserAvatar userId={currentConversation} className="size-8" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-sm truncate">{displayName}</span>
|
||||
{isFollowing && (
|
||||
<span title="Following">
|
||||
<Users className="size-3 text-primary" />
|
||||
</span>
|
||||
{/* Header - show when not hidden, or when in selection mode */}
|
||||
{(!hideHeader || isSelectionMode) && (
|
||||
<div className="flex items-center gap-3 p-3 border-b">
|
||||
{isSelectionMode ? (
|
||||
// Selection mode header
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={clearSelection}
|
||||
className="size-8"
|
||||
title={t('Cancel')}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Trash2 className="size-4 text-destructive" />
|
||||
<span className="font-medium text-sm">{t('Delete')}</span>
|
||||
</div>
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={deleteSelectedMessages}
|
||||
disabled={selectedMessages.size === 0}
|
||||
className="text-xs"
|
||||
>
|
||||
{t('Selected')} ({selectedMessages.size})
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={deleteAllInConversation}
|
||||
className="text-xs"
|
||||
>
|
||||
{t('All')}
|
||||
</Button>
|
||||
</>
|
||||
) : (
|
||||
// Normal header
|
||||
<>
|
||||
<UserAvatar userId={currentConversation} className="size-8" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-medium text-sm truncate">{displayName}</span>
|
||||
{isFollowing && (
|
||||
<span title="Following">
|
||||
<Users className="size-3 text-primary" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{profile?.nip05 && (
|
||||
<span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
|
||||
)}
|
||||
</div>
|
||||
{profile?.nip05 && (
|
||||
<span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
title={t('Reload messages')}
|
||||
onClick={reloadConversation}
|
||||
disabled={isLoadingConversation}
|
||||
>
|
||||
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
|
||||
title={t('Conversation settings')}
|
||||
onClick={() => {
|
||||
setShowPulse(false)
|
||||
clearNewConversationFlag()
|
||||
setSettingsOpen(true)
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
{t('Delete All')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={undeleteAllInConversation}>
|
||||
<Undo2 className="size-4 mr-2" />
|
||||
{t('Undelete All')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
title={t('Close conversation')}
|
||||
onClick={onBack}
|
||||
title={t('Reload messages')}
|
||||
onClick={reloadConversation}
|
||||
disabled={isLoadingConversation}
|
||||
>
|
||||
<X className="size-4" />
|
||||
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
|
||||
title={t('Conversation settings')}
|
||||
onClick={() => {
|
||||
setShowPulse(false)
|
||||
clearNewConversationFlag()
|
||||
setSettingsOpen(true)
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
{t('Delete All')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={undeleteAllInConversation}>
|
||||
<Undo2 className="size-4 mr-2" />
|
||||
{t('Undelete All')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{onBack && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
title={t('Close conversation')}
|
||||
onClick={onBack}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Messages */}
|
||||
<div className="flex-1 relative overflow-hidden">
|
||||
|
||||
@@ -3,10 +3,13 @@ import { Checkbox } from '@/components/ui/checkbox'
|
||||
import { Drawer, DrawerContent, DrawerHeader, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import SocialGraphFilter from '@/components/SocialGraphFilter'
|
||||
import { ExtendedKind } from '@/constants'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useKindFilter } from '@/providers/KindFilterProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
|
||||
import { ListFilter } from 'lucide-react'
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { useEffect, useMemo, useState } from 'react'
|
||||
@@ -34,22 +37,35 @@ const ALL_KINDS = KIND_FILTER_OPTIONS.flatMap(({ kindGroup }) => kindGroup)
|
||||
|
||||
export default function KindFilter({
|
||||
showKinds,
|
||||
onShowKindsChange
|
||||
onShowKindsChange,
|
||||
showSocialGraphFilter = false
|
||||
}: {
|
||||
showKinds: number[]
|
||||
onShowKindsChange: (kinds: number[]) => void
|
||||
showSocialGraphFilter?: boolean
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const { showKinds: savedShowKinds } = useKindFilter()
|
||||
const {
|
||||
proximityLevel: savedProximity,
|
||||
includeMode: savedIncludeMode,
|
||||
updateProximityLevel,
|
||||
updateIncludeMode
|
||||
} = useSocialGraphFilter()
|
||||
const [open, setOpen] = useState(false)
|
||||
const { updateShowKinds } = useKindFilter()
|
||||
const [temporaryShowKinds, setTemporaryShowKinds] = useState(showKinds)
|
||||
const [temporaryProximity, setTemporaryProximity] = useState<number | null>(savedProximity)
|
||||
const [temporaryIncludeMode, setTemporaryIncludeMode] = useState(savedIncludeMode)
|
||||
const [isPersistent, setIsPersistent] = useState(false)
|
||||
const isDifferentFromSaved = useMemo(
|
||||
() => !isSameKindFilter(showKinds, savedShowKinds),
|
||||
[showKinds, savedShowKinds]
|
||||
)
|
||||
|
||||
const isDifferentFromSaved = useMemo(() => {
|
||||
const kindsDifferent = !isSameKindFilter(showKinds, savedShowKinds)
|
||||
const proximityDifferent = showSocialGraphFilter && savedProximity !== null
|
||||
return kindsDifferent || proximityDifferent
|
||||
}, [showKinds, savedShowKinds, savedProximity, showSocialGraphFilter])
|
||||
|
||||
const isTemporaryDifferentFromSaved = useMemo(
|
||||
() => !isSameKindFilter(temporaryShowKinds, savedShowKinds),
|
||||
[temporaryShowKinds, savedShowKinds]
|
||||
@@ -57,8 +73,10 @@ export default function KindFilter({
|
||||
|
||||
useEffect(() => {
|
||||
setTemporaryShowKinds(showKinds)
|
||||
setTemporaryProximity(savedProximity)
|
||||
setTemporaryIncludeMode(savedIncludeMode)
|
||||
setIsPersistent(false)
|
||||
}, [open])
|
||||
}, [open, savedProximity, savedIncludeMode])
|
||||
|
||||
const handleApply = () => {
|
||||
if (temporaryShowKinds.length === 0) {
|
||||
@@ -71,6 +89,16 @@ export default function KindFilter({
|
||||
onShowKindsChange(newShowKinds)
|
||||
}
|
||||
|
||||
// Apply social graph filter changes
|
||||
if (showSocialGraphFilter) {
|
||||
if (temporaryProximity !== savedProximity) {
|
||||
updateProximityLevel(temporaryProximity)
|
||||
}
|
||||
if (temporaryIncludeMode !== savedIncludeMode) {
|
||||
updateIncludeMode(temporaryIncludeMode)
|
||||
}
|
||||
}
|
||||
|
||||
if (isPersistent) {
|
||||
updateShowKinds(newShowKinds)
|
||||
}
|
||||
@@ -155,6 +183,18 @@ export default function KindFilter({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{showSocialGraphFilter && (
|
||||
<>
|
||||
<Separator className="my-4" />
|
||||
<SocialGraphFilter
|
||||
temporaryProximity={temporaryProximity}
|
||||
temporaryIncludeMode={temporaryIncludeMode}
|
||||
onTemporaryProximityChange={setTemporaryProximity}
|
||||
onTemporaryIncludeModeChange={setTemporaryIncludeMode}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
<Label className="flex items-center gap-2 cursor-pointer mt-4">
|
||||
<Checkbox
|
||||
id="persistent-filter"
|
||||
|
||||
@@ -16,6 +16,7 @@ export default function NormalFeed({
|
||||
isMainFeed = false,
|
||||
showRelayCloseReason = false,
|
||||
disable24hMode = false,
|
||||
enableSocialGraphFilter = false,
|
||||
onRefresh
|
||||
}: {
|
||||
subRequests: TFeedSubRequest[]
|
||||
@@ -23,6 +24,7 @@ export default function NormalFeed({
|
||||
isMainFeed?: boolean
|
||||
showRelayCloseReason?: boolean
|
||||
disable24hMode?: boolean
|
||||
enableSocialGraphFilter?: boolean
|
||||
onRefresh?: () => void
|
||||
}) {
|
||||
const { hideUntrustedNotes } = useUserTrust()
|
||||
@@ -87,6 +89,7 @@ export default function NormalFeed({
|
||||
<KindFilter
|
||||
showKinds={temporaryShowKinds}
|
||||
onShowKindsChange={handleShowKindsChange}
|
||||
showSocialGraphFilter={enableSocialGraphFilter}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
@@ -110,6 +113,7 @@ export default function NormalFeed({
|
||||
hideUntrustedNotes={hideUntrustedNotes}
|
||||
areAlgoRelays={areAlgoRelays}
|
||||
showRelayCloseReason={showRelayCloseReason}
|
||||
applySocialGraphFilter={enableSocialGraphFilter}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
|
||||
@@ -9,6 +9,7 @@ import { useDeletedEvent } from '@/providers/DeletedEventProvider'
|
||||
import { TNavigationColumn, useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||
import { useMuteList } from '@/providers/MuteListProvider'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useSocialGraphFilter } from '@/providers/SocialGraphFilterProvider'
|
||||
import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import client from '@/services/client.service'
|
||||
import threadService from '@/services/thread.service'
|
||||
@@ -55,6 +56,7 @@ const NoteList = forwardRef<
|
||||
filterFn?: (event: Event) => boolean
|
||||
showNewNotesDirectly?: boolean
|
||||
navColumn?: TNavigationColumn
|
||||
applySocialGraphFilter?: boolean
|
||||
}
|
||||
>(
|
||||
(
|
||||
@@ -70,7 +72,8 @@ const NoteList = forwardRef<
|
||||
pinnedEventIds,
|
||||
filterFn,
|
||||
showNewNotesDirectly = false,
|
||||
navColumn = 1
|
||||
navColumn = 1,
|
||||
applySocialGraphFilter = false
|
||||
},
|
||||
ref
|
||||
) => {
|
||||
@@ -80,7 +83,8 @@ const NoteList = forwardRef<
|
||||
const { mutePubkeySet } = useMuteList()
|
||||
const { hideContentMentioningMutedUsers } = useContentPolicy()
|
||||
const { isEventDeleted } = useDeletedEvent()
|
||||
const { offsetSelection } = useKeyboardNavigation()
|
||||
const { isPubkeyAllowed } = useSocialGraphFilter()
|
||||
const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
|
||||
const [events, setEvents] = useState<Event[]>([])
|
||||
const [newEvents, setNewEvents] = useState<Event[]>([])
|
||||
const [initialLoading, setInitialLoading] = useState(false)
|
||||
@@ -122,10 +126,22 @@ const NoteList = forwardRef<
|
||||
if (filterFn && !filterFn(evt)) {
|
||||
return true
|
||||
}
|
||||
// Social graph filter - only apply if enabled for this feed
|
||||
if (applySocialGraphFilter && !isPubkeyAllowed(evt.pubkey)) {
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
},
|
||||
[hideUntrustedNotes, mutePubkeySet, JSON.stringify(pinnedEventIds), isEventDeleted, filterFn]
|
||||
[
|
||||
hideUntrustedNotes,
|
||||
mutePubkeySet,
|
||||
JSON.stringify(pinnedEventIds),
|
||||
isEventDeleted,
|
||||
filterFn,
|
||||
applySocialGraphFilter,
|
||||
isPubkeyAllowed
|
||||
]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
@@ -370,6 +386,12 @@ const NoteList = forwardRef<
|
||||
initialLoading
|
||||
})
|
||||
|
||||
// Register load more callback for keyboard navigation
|
||||
useEffect(() => {
|
||||
registerLoadMore(navColumn, handleLoadMore)
|
||||
return () => unregisterLoadMore(navColumn)
|
||||
}, [navColumn, handleLoadMore, registerLoadMore, unregisterLoadMore])
|
||||
|
||||
const showNewEvents = useCallback(() => {
|
||||
if (filteredNewEvents.length === 0) return
|
||||
// Offset the selection by the number of new items being added at the top
|
||||
|
||||
@@ -3,22 +3,43 @@ import { Drawer, DrawerContent, DrawerTrigger } from '@/components/ui/drawer'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import { QrCodeIcon } from 'lucide-react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { useMemo } from 'react'
|
||||
import { useCallback, useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import Nip05 from '../Nip05'
|
||||
import PubkeyCopy from '../PubkeyCopy'
|
||||
import QrCode from '../QrCode'
|
||||
import UserAvatar from '../UserAvatar'
|
||||
import Username from '../Username'
|
||||
|
||||
export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
||||
const { t } = useTranslation()
|
||||
const { isSmallScreen } = useScreenSize()
|
||||
const npub = useMemo(() => (pubkey ? nip19.npubEncode(pubkey) : ''), [pubkey])
|
||||
const [open, setOpen] = useState(false)
|
||||
const npub = useMemo(() => {
|
||||
// Validate pubkey is a 64-character hex string before encoding
|
||||
if (!pubkey || !/^[0-9a-f]{64}$/i.test(pubkey)) return ''
|
||||
try {
|
||||
return nip19.npubEncode(pubkey)
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
}, [pubkey])
|
||||
|
||||
const handleQrClick = useCallback(() => {
|
||||
navigator.clipboard.writeText(npub)
|
||||
toast.success(t('Copied npub to clipboard'))
|
||||
setOpen(false)
|
||||
}, [npub, t])
|
||||
|
||||
if (!npub) return null
|
||||
|
||||
const trigger = (
|
||||
<div className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground">
|
||||
<button
|
||||
className="bg-muted rounded-full h-5 w-5 flex flex-col items-center justify-center text-muted-foreground hover:text-foreground"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<QrCodeIcon size={14} />
|
||||
</div>
|
||||
</button>
|
||||
)
|
||||
|
||||
const content = (
|
||||
@@ -26,29 +47,33 @@ export default function NpubQrCode({ pubkey }: { pubkey: string }) {
|
||||
<div className="flex items-center w-full gap-2 pointer-events-none px-1">
|
||||
<UserAvatar size="big" userId={pubkey} />
|
||||
<div className="flex-1 w-0">
|
||||
<Username userId={pubkey} className="text-2xl font-semibold truncate" />
|
||||
<Username userId={pubkey} className="text-2xl font-semibold truncate" showQrCode={false} />
|
||||
<Nip05 pubkey={pubkey} />
|
||||
</div>
|
||||
</div>
|
||||
<QrCode size={512} value={`nostr:${npub}`} />
|
||||
<div className="flex flex-col items-center">
|
||||
<PubkeyCopy pubkey={pubkey} />
|
||||
</div>
|
||||
<button
|
||||
onClick={handleQrClick}
|
||||
className="cursor-pointer hover:opacity-90 transition-opacity"
|
||||
title={t('Click to copy npub')}
|
||||
>
|
||||
<QrCode size={512} value={`nostr:${npub}`} />
|
||||
</button>
|
||||
<div className="text-sm text-muted-foreground">{t('Click QR code to copy npub')}</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<Drawer>
|
||||
<DrawerTrigger>{trigger}</DrawerTrigger>
|
||||
<Drawer open={open} onOpenChange={setOpen}>
|
||||
<DrawerTrigger asChild>{trigger}</DrawerTrigger>
|
||||
<DrawerContent>{content}</DrawerContent>
|
||||
</Drawer>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogTrigger>{trigger}</DialogTrigger>
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="w-80 p-0 m-0" onOpenAutoFocus={(e) => e.preventDefault()}>
|
||||
{content}
|
||||
</DialogContent>
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
import { useScreenSize } from '@/providers/ScreenSizeProvider'
|
||||
import client from '@/services/client.service'
|
||||
import graphQueryService from '@/services/graph-query.service'
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
@@ -15,6 +17,55 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
|
||||
if (!pubkey || !accountPubkey) return
|
||||
|
||||
const init = async () => {
|
||||
const limit = isSmallScreen ? 3 : 5
|
||||
|
||||
// Try graph query first for depth-2 follows
|
||||
const graphResult = await graphQueryService.queryFollowGraph(
|
||||
BIG_RELAY_URLS,
|
||||
accountPubkey,
|
||||
2
|
||||
)
|
||||
|
||||
if (graphResult?.pubkeys_by_depth && graphResult.pubkeys_by_depth.length >= 2) {
|
||||
// Use graph query results - much more efficient
|
||||
const directFollows = new Set(graphResult.pubkeys_by_depth[0] ?? [])
|
||||
|
||||
// Check which of user's follows also follow the target pubkey
|
||||
const _followedBy: string[] = []
|
||||
|
||||
// We need to check if target pubkey is in each direct follow's follow list
|
||||
// The graph query gives us all follows of follows at depth 2,
|
||||
// but we need to know *which* direct follow has the target in their follows
|
||||
// For now, we'll still need to do individual checks but can optimize with caching
|
||||
|
||||
// Alternative approach: Use followers query on the target
|
||||
const followerResult = await graphQueryService.queryFollowerGraph(
|
||||
BIG_RELAY_URLS,
|
||||
pubkey,
|
||||
1
|
||||
)
|
||||
|
||||
if (followerResult?.pubkeys_by_depth?.[0]) {
|
||||
// Followers of target pubkey
|
||||
const targetFollowers = new Set(followerResult.pubkeys_by_depth[0])
|
||||
|
||||
// Find which of user's follows are followers of the target
|
||||
for (const following of directFollows) {
|
||||
if (following === pubkey) continue
|
||||
if (targetFollowers.has(following)) {
|
||||
_followedBy.push(following)
|
||||
if (_followedBy.length >= limit) break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_followedBy.length > 0) {
|
||||
setFollowedBy(_followedBy)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback to traditional method
|
||||
const followings = (await client.fetchFollowings(accountPubkey)).reverse()
|
||||
const followingsOfFollowings = await Promise.all(
|
||||
followings.map(async (following) => {
|
||||
@@ -22,7 +73,6 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
|
||||
})
|
||||
)
|
||||
const _followedBy: string[] = []
|
||||
const limit = isSmallScreen ? 3 : 5
|
||||
for (const [index, following] of followings.entries()) {
|
||||
if (following === pubkey) continue
|
||||
if (followingsOfFollowings[index].includes(pubkey)) {
|
||||
@@ -35,7 +85,7 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
|
||||
setFollowedBy(_followedBy)
|
||||
}
|
||||
init()
|
||||
}, [pubkey, accountPubkey])
|
||||
}, [pubkey, accountPubkey, isSmallScreen])
|
||||
|
||||
if (followedBy.length === 0) return null
|
||||
|
||||
|
||||
71
src/components/QrScannerModal/index.tsx
Normal file
71
src/components/QrScannerModal/index.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { X } from 'lucide-react'
|
||||
import QrScanner from 'qr-scanner'
|
||||
import { useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function QrScannerModal({
|
||||
onScan,
|
||||
onClose
|
||||
}: {
|
||||
onScan: (result: string) => void
|
||||
onClose: () => void
|
||||
}) {
|
||||
const { t } = useTranslation()
|
||||
const videoRef = useRef<HTMLVideoElement>(null)
|
||||
const scannerRef = useRef<QrScanner | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const handleScan = useCallback(
|
||||
(result: QrScanner.ScanResult) => {
|
||||
onScan(result.data)
|
||||
onClose()
|
||||
},
|
||||
[onScan, onClose]
|
||||
)
|
||||
|
||||
useEffect(() => {
|
||||
if (!videoRef.current) return
|
||||
|
||||
const scanner = new QrScanner(videoRef.current, handleScan, {
|
||||
preferredCamera: 'environment',
|
||||
highlightScanRegion: true,
|
||||
highlightCodeOutline: true
|
||||
})
|
||||
|
||||
scannerRef.current = scanner
|
||||
|
||||
scanner.start().catch(() => {
|
||||
setError(t('Failed to access camera'))
|
||||
})
|
||||
|
||||
return () => {
|
||||
scanner.destroy()
|
||||
}
|
||||
}, [handleScan, t])
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
|
||||
<div className="relative w-full max-w-sm mx-4">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute -top-12 right-0 text-white hover:bg-white/20"
|
||||
onClick={onClose}
|
||||
>
|
||||
<X className="h-6 w-6" />
|
||||
</Button>
|
||||
<div className="rounded-lg overflow-hidden bg-black">
|
||||
{error ? (
|
||||
<div className="p-8 text-center text-destructive">{error}</div>
|
||||
) : (
|
||||
<video ref={videoRef} className="w-full" />
|
||||
)}
|
||||
</div>
|
||||
<p className="text-center text-white/70 text-sm mt-4">
|
||||
{t('Point camera at QR code')}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -342,6 +342,27 @@ const SearchBar = forwardRef<
|
||||
onKeyDown={handleKeyDown}
|
||||
onFocus={() => setSearching(true)}
|
||||
onBlur={() => setSearching(false)}
|
||||
onQrScan={(value) => {
|
||||
setInput(value)
|
||||
// Automatically search after scanning
|
||||
let id = value
|
||||
if (id.startsWith('nostr:')) {
|
||||
id = id.slice(6)
|
||||
}
|
||||
try {
|
||||
const { type } = nip19.decode(id)
|
||||
if (['nprofile', 'npub'].includes(type)) {
|
||||
updateSearch({ type: 'profile', search: id })
|
||||
return
|
||||
}
|
||||
if (['nevent', 'naddr', 'note'].includes(type)) {
|
||||
updateSearch({ type: 'note', search: id })
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Not a valid nip19 identifier, just set input
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -1,11 +1,17 @@
|
||||
import { cn } from '@/lib/utils'
|
||||
import { SearchIcon, X } from 'lucide-react'
|
||||
import { QrCodeIcon, SearchIcon, X } from 'lucide-react'
|
||||
import { ComponentProps, forwardRef, useEffect, useState } from 'react'
|
||||
import QrScannerModal from '../QrScannerModal'
|
||||
|
||||
const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
||||
({ value, onChange, className, ...props }, ref) => {
|
||||
type SearchInputProps = ComponentProps<'input'> & {
|
||||
onQrScan?: (value: string) => void
|
||||
}
|
||||
|
||||
const SearchInput = forwardRef<HTMLInputElement, SearchInputProps>(
|
||||
({ value, onChange, className, onQrScan, ...props }, ref) => {
|
||||
const [displayClear, setDisplayClear] = useState(false)
|
||||
const [inputRef, setInputRef] = useState<HTMLInputElement | null>(null)
|
||||
const [showQrScanner, setShowQrScanner] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
setDisplayClear(!!value)
|
||||
@@ -20,34 +26,55 @@ const SearchInput = forwardRef<HTMLInputElement, ComponentProps<'input'>>(
|
||||
}
|
||||
}
|
||||
|
||||
const handleQrScan = (result: string) => {
|
||||
// Strip nostr: prefix if present
|
||||
const value = result.startsWith('nostr:') ? result.slice(6) : result
|
||||
onQrScan?.(value)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center rounded-xl border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-all duration-200 md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-2 [&:has(:focus-visible)]:outline-none hover:border-ring/50',
|
||||
className
|
||||
<>
|
||||
<div
|
||||
tabIndex={0}
|
||||
className={cn(
|
||||
'flex h-9 w-full items-center rounded-xl border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-all duration-200 md:text-sm [&:has(:focus-visible)]:ring-ring [&:has(:focus-visible)]:ring-2 [&:has(:focus-visible)]:outline-none hover:border-ring/50',
|
||||
className
|
||||
)}
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" onClick={() => inputRef?.focus()} />
|
||||
<input
|
||||
{...props}
|
||||
name="search-input"
|
||||
ref={setRefs}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{onQrScan && (
|
||||
<button
|
||||
type="button"
|
||||
className="text-muted-foreground hover:text-foreground transition-colors size-5 shrink-0 flex items-center justify-center mr-1"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => setShowQrScanner(true)}
|
||||
>
|
||||
<QrCodeIcon className="size-4" />
|
||||
</button>
|
||||
)}
|
||||
{displayClear && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-foreground/40 hover:bg-foreground transition-opacity size-5 shrink-0 flex flex-col items-center justify-center"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onChange?.({ target: { value: '' } } as any)}
|
||||
>
|
||||
<X className="!size-3 shrink-0 text-background" strokeWidth={4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showQrScanner && (
|
||||
<QrScannerModal onScan={handleQrScan} onClose={() => setShowQrScanner(false)} />
|
||||
)}
|
||||
>
|
||||
<SearchIcon className="size-4 shrink-0 opacity-50" onClick={() => inputRef?.focus()} />
|
||||
<input
|
||||
{...props}
|
||||
name="search-input"
|
||||
ref={setRefs}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
className="size-full mx-2 border-none bg-transparent focus:outline-none placeholder:text-muted-foreground"
|
||||
/>
|
||||
{displayClear && (
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-full bg-foreground/40 hover:bg-foreground transition-opacity size-5 shrink-0 flex flex-col items-center justify-center"
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
onClick={() => onChange?.({ target: { value: '' } } as any)}
|
||||
>
|
||||
<X className="!size-3 shrink-0 text-background" strokeWidth={4} />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import AboutInfoDialog from '@/components/AboutInfoDialog'
|
||||
import QrScannerModal from '@/components/QrScannerModal'
|
||||
import Donation from '@/components/Donation'
|
||||
import Emoji from '@/components/Emoji'
|
||||
import EmojiPackList from '@/components/EmojiPackList'
|
||||
@@ -54,7 +55,7 @@ import { useUserTrust } from '@/providers/UserTrustProvider'
|
||||
import { useZap } from '@/providers/ZapProvider'
|
||||
import storage, { dispatchSettingsChanged } from '@/services/local-storage.service'
|
||||
import { TMediaAutoLoadPolicy, TNsfwDisplayPolicy } from '@/types'
|
||||
import { disconnect, launchModal } from '@getalby/bitcoin-connect-react'
|
||||
import { connectNWC, disconnect, launchModal } from '@getalby/bitcoin-connect-react'
|
||||
import {
|
||||
Check,
|
||||
Cog,
|
||||
@@ -71,6 +72,7 @@ import {
|
||||
PanelLeft,
|
||||
PencilLine,
|
||||
RotateCcw,
|
||||
ScanLine,
|
||||
Server,
|
||||
Settings2,
|
||||
Smile,
|
||||
@@ -80,7 +82,8 @@ import {
|
||||
import { kinds } from 'nostr-tools'
|
||||
import { forwardRef, HTMLProps, useCallback, useEffect, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||
import { useKeyboardNavigation, useNavigationRegion, NavigationIntent } from '@/providers/KeyboardNavigationProvider'
|
||||
import { usePrimaryPage } from '@/PageManager'
|
||||
|
||||
type TEmojiTab = 'my-packs' | 'explore'
|
||||
|
||||
@@ -114,57 +117,77 @@ export default function Settings() {
|
||||
const [selectedAccordionIndex, setSelectedAccordionIndex] = useState(-1)
|
||||
const accordionRefs = useRef<(HTMLDivElement | null)[]>([])
|
||||
|
||||
const { activeColumn, registerSettingsHandlers, unregisterSettingsHandlers } = useKeyboardNavigation()
|
||||
const { activeColumn, scrollToCenter } = useKeyboardNavigation()
|
||||
const { current: currentPage } = usePrimaryPage()
|
||||
|
||||
// Get the visible accordion items based on pubkey availability
|
||||
const visibleAccordionItems = pubkey
|
||||
? ACCORDION_ITEMS
|
||||
: ACCORDION_ITEMS.filter((item) => !['wallet', 'posts', 'emoji-packs', 'messaging'].includes(item))
|
||||
|
||||
// Register keyboard handlers for settings page navigation
|
||||
// Register as a navigation region - Settings decides what "up/down" means
|
||||
const handleSettingsIntent = useCallback(
|
||||
(intent: NavigationIntent): boolean => {
|
||||
switch (intent) {
|
||||
case 'up':
|
||||
setSelectedAccordionIndex((prev) => {
|
||||
const newIndex = prev <= 0 ? 0 : prev - 1
|
||||
setTimeout(() => {
|
||||
const el = accordionRefs.current[newIndex]
|
||||
if (el) scrollToCenter(el)
|
||||
}, 0)
|
||||
return newIndex
|
||||
})
|
||||
return true
|
||||
|
||||
case 'down':
|
||||
setSelectedAccordionIndex((prev) => {
|
||||
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1)
|
||||
setTimeout(() => {
|
||||
const el = accordionRefs.current[newIndex]
|
||||
if (el) scrollToCenter(el)
|
||||
}, 0)
|
||||
return newIndex
|
||||
})
|
||||
return true
|
||||
|
||||
case 'activate':
|
||||
if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) {
|
||||
const value = visibleAccordionItems[selectedAccordionIndex]
|
||||
setOpenSection((prev) => (prev === value ? '' : value))
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
case 'cancel':
|
||||
if (openSection) {
|
||||
setOpenSection('')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
|
||||
default:
|
||||
return false
|
||||
}
|
||||
},
|
||||
[selectedAccordionIndex, openSection, visibleAccordionItems, scrollToCenter]
|
||||
)
|
||||
|
||||
// Register this component as a navigation region when it's active
|
||||
useNavigationRegion(
|
||||
'settings-accordion',
|
||||
100, // High priority - handle intents before default handlers
|
||||
() => activeColumn === 1 && currentPage === 'settings', // Only active when settings is displayed
|
||||
handleSettingsIntent,
|
||||
[handleSettingsIntent, activeColumn, currentPage]
|
||||
)
|
||||
|
||||
// Reset selection when column changes
|
||||
useEffect(() => {
|
||||
if (activeColumn !== 1) {
|
||||
setSelectedAccordionIndex(-1)
|
||||
return
|
||||
}
|
||||
|
||||
const handlers = {
|
||||
onUp: () => {
|
||||
setSelectedAccordionIndex((prev) => {
|
||||
const newIndex = prev <= 0 ? 0 : prev - 1
|
||||
setTimeout(() => {
|
||||
accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, 0)
|
||||
return newIndex
|
||||
})
|
||||
},
|
||||
onDown: () => {
|
||||
setSelectedAccordionIndex((prev) => {
|
||||
const newIndex = prev < 0 ? 0 : Math.min(prev + 1, visibleAccordionItems.length - 1)
|
||||
setTimeout(() => {
|
||||
accordionRefs.current[newIndex]?.scrollIntoView({ behavior: 'smooth', block: 'nearest' })
|
||||
}, 0)
|
||||
return newIndex
|
||||
})
|
||||
},
|
||||
onEnter: () => {
|
||||
if (selectedAccordionIndex >= 0 && selectedAccordionIndex < visibleAccordionItems.length) {
|
||||
const value = visibleAccordionItems[selectedAccordionIndex]
|
||||
setOpenSection((prev) => (prev === value ? '' : value))
|
||||
}
|
||||
},
|
||||
onEscape: () => {
|
||||
if (openSection) {
|
||||
setOpenSection('')
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
registerSettingsHandlers(handlers)
|
||||
return () => unregisterSettingsHandlers()
|
||||
}, [activeColumn, selectedAccordionIndex, openSection, visibleAccordionItems])
|
||||
}, [activeColumn])
|
||||
|
||||
// Helper to get accordion index and check selection
|
||||
const getAccordionIndex = useCallback(
|
||||
@@ -231,10 +254,21 @@ export default function Settings() {
|
||||
|
||||
// System settings
|
||||
const [filterOutOnionRelays, setFilterOutOnionRelays] = useState(storage.getFilterOutOnionRelays())
|
||||
const [graphQueriesEnabled, setGraphQueriesEnabled] = useState(storage.getGraphQueriesEnabled())
|
||||
|
||||
// Messaging settings
|
||||
const [preferNip44, setPreferNip44] = useState(storage.getPreferNip44())
|
||||
|
||||
// Wallet QR scanner
|
||||
const [showWalletScanner, setShowWalletScanner] = useState(false)
|
||||
|
||||
const handleWalletScan = useCallback((result: string) => {
|
||||
// Check if it's a valid NWC URI
|
||||
if (result.startsWith('nostr+walletconnect://')) {
|
||||
connectNWC(result)
|
||||
}
|
||||
}, [])
|
||||
|
||||
const handleLanguageChange = (value: TLanguage) => {
|
||||
i18n.changeLanguage(value)
|
||||
setLanguage(value)
|
||||
@@ -559,11 +593,27 @@ export default function Settings() {
|
||||
<LightningAddressInput />
|
||||
</>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
|
||||
{t('Connect Wallet')}
|
||||
</Button>
|
||||
</div>
|
||||
<>
|
||||
{showWalletScanner && (
|
||||
<QrScannerModal
|
||||
onScan={handleWalletScan}
|
||||
onClose={() => setShowWalletScanner(false)}
|
||||
/>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
<Button className="bg-foreground hover:bg-foreground/90" onClick={() => launchModal()}>
|
||||
{t('Connect Wallet')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="icon"
|
||||
onClick={() => setShowWalletScanner(true)}
|
||||
title={t('Scan NWC QR code')}
|
||||
>
|
||||
<ScanLine className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
@@ -689,6 +739,25 @@ export default function Settings() {
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
<SettingItem>
|
||||
<div>
|
||||
<Label htmlFor="graph-queries-enabled" className="text-base font-normal">
|
||||
{t('Graph query optimization')}
|
||||
</Label>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{t('Use graph queries for faster follow/thread loading on supported relays')}
|
||||
</p>
|
||||
</div>
|
||||
<Switch
|
||||
id="graph-queries-enabled"
|
||||
checked={graphQueriesEnabled}
|
||||
onCheckedChange={(checked) => {
|
||||
storage.setGraphQueriesEnabled(checked)
|
||||
setGraphQueriesEnabled(checked)
|
||||
dispatchSettingsChanged()
|
||||
}}
|
||||
/>
|
||||
</SettingItem>
|
||||
</AccordionContent>
|
||||
</AccordionItem>
|
||||
</NavigableAccordionItem>
|
||||
|
||||
36
src/components/Sidebar/KeyboardModeButton.tsx
Normal file
36
src/components/Sidebar/KeyboardModeButton.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
|
||||
import { Keyboard } from 'lucide-react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
|
||||
export default function KeyboardModeButton({ collapse }: { collapse: boolean }) {
|
||||
const { t } = useTranslation()
|
||||
const { isEnabled, toggleKeyboardMode } = useKeyboardNavigation()
|
||||
|
||||
return (
|
||||
<Button
|
||||
className={cn(
|
||||
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-2 text-sm font-semibold',
|
||||
collapse
|
||||
? 'w-12 h-12 p-3 [&_svg]:size-full'
|
||||
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
|
||||
isEnabled && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10'
|
||||
)}
|
||||
variant="ghost"
|
||||
title={t('Toggle keyboard navigation (⇧K)')}
|
||||
onClick={toggleKeyboardMode}
|
||||
>
|
||||
<Keyboard />
|
||||
{!collapse && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{t('Keyboard')}</span>
|
||||
<kbd className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground border">⇧K</kbd>
|
||||
</div>
|
||||
)}
|
||||
{collapse && (
|
||||
<span className="sr-only">{t('Toggle keyboard navigation')}</span>
|
||||
)}
|
||||
</Button>
|
||||
)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import BookmarkButton from './BookmarkButton'
|
||||
import HelpButton from './HelpButton'
|
||||
import HomeButton from './HomeButton'
|
||||
import InboxButton from './InboxButton'
|
||||
import KeyboardModeButton from './KeyboardModeButton'
|
||||
import LayoutSwitcher from './LayoutSwitcher'
|
||||
import NotificationsButton from './NotificationButton'
|
||||
import PostButton from './PostButton'
|
||||
@@ -67,6 +68,7 @@ export default function PrimaryPageSidebar() {
|
||||
</div>
|
||||
<div className="space-y-4">
|
||||
<HelpButton collapse={isCollapsed} navIndex={pubkey ? 8 : 6} />
|
||||
<KeyboardModeButton collapse={isCollapsed} />
|
||||
<LayoutSwitcher collapse={isCollapsed} />
|
||||
<AccountButton collapse={isCollapsed} />
|
||||
</div>
|
||||
|
||||
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 EmojiPicker from '../EmojiPicker'
|
||||
import SuggestedEmojis from '../SuggestedEmojis'
|
||||
import KeyboardShortcut from './KeyboardShortcut'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
||||
@@ -111,8 +112,8 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
||||
|
||||
const trigger = (
|
||||
<button
|
||||
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground"
|
||||
title={t('Like')}
|
||||
className="flex items-center enabled:hover:text-primary gap-1 px-3 h-full text-muted-foreground group"
|
||||
title={t('React (Shift+R)')}
|
||||
disabled={liking}
|
||||
data-action="react"
|
||||
onClick={handleClick}
|
||||
@@ -126,12 +127,18 @@ export default function LikeButton({ stuff }: { stuff: Event | string }) {
|
||||
<Loader className="animate-spin" />
|
||||
) : myLastEmoji ? (
|
||||
<>
|
||||
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
|
||||
<span className="relative">
|
||||
<Emoji emoji={myLastEmoji} classNames={{ img: 'size-4' }} />
|
||||
<KeyboardShortcut shortcut="R" />
|
||||
</span>
|
||||
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<SmilePlus />
|
||||
<span className="relative">
|
||||
<SmilePlus />
|
||||
<KeyboardShortcut shortcut="R" />
|
||||
</span>
|
||||
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -11,6 +11,7 @@ import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PostEditor from '../PostEditor'
|
||||
import KeyboardShortcut from './KeyboardShortcut'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||
@@ -56,7 +57,7 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||
<>
|
||||
<button
|
||||
className={cn(
|
||||
'flex gap-1 items-center enabled:hover:text-blue-400 pr-3 h-full',
|
||||
'flex gap-1 items-center enabled:hover:text-blue-400 pr-3 h-full group',
|
||||
hasReplied ? 'text-blue-400' : 'text-muted-foreground'
|
||||
)}
|
||||
onClick={(e) => {
|
||||
@@ -65,10 +66,13 @@ export default function ReplyButton({ stuff }: { stuff: Event | string }) {
|
||||
setOpen(true)
|
||||
})
|
||||
}}
|
||||
title={t('Reply')}
|
||||
title={t('Reply (r)')}
|
||||
data-action="reply"
|
||||
>
|
||||
<MessageCircle />
|
||||
<span className="relative">
|
||||
<MessageCircle />
|
||||
<KeyboardShortcut shortcut="r" />
|
||||
</span>
|
||||
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
|
||||
</button>
|
||||
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />
|
||||
|
||||
@@ -20,6 +20,7 @@ import { Event } from 'nostr-tools'
|
||||
import { useMemo, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import PostEditor from '../PostEditor'
|
||||
import KeyboardShortcut from './KeyboardShortcut'
|
||||
import { formatCount } from './utils'
|
||||
|
||||
export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||
@@ -77,11 +78,11 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||
const trigger = (
|
||||
<button
|
||||
className={cn(
|
||||
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40',
|
||||
'flex gap-1 items-center px-3 h-full enabled:hover:text-lime-500 disabled:text-muted-foreground/40 group',
|
||||
hasReposted ? 'text-lime-500' : 'text-muted-foreground'
|
||||
)}
|
||||
disabled={!event}
|
||||
title={t('Repost')}
|
||||
title={t('Repost (p) / Quote (q)')}
|
||||
data-action="repost"
|
||||
onClick={() => {
|
||||
if (!event) return
|
||||
@@ -91,7 +92,10 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||
}
|
||||
}}
|
||||
>
|
||||
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
||||
<span className="relative">
|
||||
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
|
||||
<KeyboardShortcut shortcut="p" />
|
||||
</span>
|
||||
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
|
||||
</button>
|
||||
)
|
||||
@@ -108,10 +112,25 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||
/>
|
||||
)
|
||||
|
||||
// Hidden button for keyboard shortcut (q for quote)
|
||||
const quoteButton = (
|
||||
<button
|
||||
className="hidden"
|
||||
data-action="quote"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation()
|
||||
checkLogin(() => {
|
||||
setIsPostDialogOpen(true)
|
||||
})
|
||||
}}
|
||||
/>
|
||||
)
|
||||
|
||||
if (isSmallScreen) {
|
||||
return (
|
||||
<>
|
||||
{trigger}
|
||||
{quoteButton}
|
||||
<Drawer open={isDrawerOpen} onOpenChange={setIsDrawerOpen}>
|
||||
<DrawerOverlay onClick={() => setIsDrawerOpen(false)} />
|
||||
<DrawerContent hideOverlay>
|
||||
@@ -170,12 +189,12 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
|
||||
setIsPostDialogOpen(true)
|
||||
})
|
||||
}}
|
||||
data-action="quote"
|
||||
>
|
||||
<PencilLine /> {t('Quote')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{quoteButton}
|
||||
{postEditor}
|
||||
</>
|
||||
)
|
||||
|
||||
@@ -14,6 +14,7 @@ import { MouseEvent, TouchEvent, useEffect, useMemo, useRef, useState } from 're
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { toast } from 'sonner'
|
||||
import ZapDialog from '../ZapDialog'
|
||||
import KeyboardShortcut from './KeyboardShortcut'
|
||||
|
||||
export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
||||
const { t } = useTranslation()
|
||||
@@ -135,10 +136,10 @@ export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
||||
<>
|
||||
<button
|
||||
className={cn(
|
||||
'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default',
|
||||
'flex items-center gap-1 select-none px-3 h-full cursor-pointer enabled:hover:text-yellow-400 disabled:text-muted-foreground/40 disabled:cursor-default group',
|
||||
hasZapped ? 'text-yellow-400' : 'text-muted-foreground'
|
||||
)}
|
||||
title={t('Zap')}
|
||||
title={t('Zap (z)')}
|
||||
disabled={disable || zapping}
|
||||
data-action="zap"
|
||||
onMouseDown={handleClickStart}
|
||||
@@ -147,11 +148,14 @@ export default function ZapButton({ stuff }: { stuff: Event | string }) {
|
||||
onTouchStart={handleClickStart}
|
||||
onTouchEnd={handleClickEnd}
|
||||
>
|
||||
{zapping ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : (
|
||||
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
|
||||
)}
|
||||
<span className="relative">
|
||||
{zapping ? (
|
||||
<Loader className="animate-spin" />
|
||||
) : (
|
||||
<Zap className={hasZapped ? 'fill-yellow-400' : ''} />
|
||||
)}
|
||||
<KeyboardShortcut shortcut="z" />
|
||||
</span>
|
||||
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
|
||||
</button>
|
||||
{event && (
|
||||
|
||||
@@ -4,22 +4,25 @@ import { useFetchProfile } from '@/hooks'
|
||||
import { toProfile } from '@/lib/link'
|
||||
import { cn, isTouchDevice } from '@/lib/utils'
|
||||
import { SecondaryPageLink } from '@/PageManager'
|
||||
import { useMemo } from 'react'
|
||||
import NpubQrCode from '../NpubQrCode'
|
||||
import ProfileCard from '../ProfileCard'
|
||||
import TextWithEmojis from '../TextWithEmojis'
|
||||
import { useMemo } from 'react'
|
||||
|
||||
export default function Username({
|
||||
userId,
|
||||
showAt = false,
|
||||
className,
|
||||
skeletonClassName,
|
||||
withoutSkeleton = false
|
||||
withoutSkeleton = false,
|
||||
showQrCode = true
|
||||
}: {
|
||||
userId: string
|
||||
showAt?: boolean
|
||||
className?: string
|
||||
skeletonClassName?: string
|
||||
withoutSkeleton?: boolean
|
||||
showQrCode?: boolean
|
||||
}) {
|
||||
const { profile, isFetching } = useFetchProfile(userId)
|
||||
const supportTouch = useMemo(() => isTouchDevice(), [])
|
||||
@@ -32,16 +35,21 @@ export default function Username({
|
||||
}
|
||||
if (!profile) return null
|
||||
|
||||
const usernameLink = (
|
||||
<SecondaryPageLink
|
||||
to={toProfile(userId)}
|
||||
className="truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showAt && '@'}
|
||||
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
|
||||
</SecondaryPageLink>
|
||||
)
|
||||
|
||||
const trigger = (
|
||||
<div className={className}>
|
||||
<SecondaryPageLink
|
||||
to={toProfile(userId)}
|
||||
className="truncate hover:underline"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
{showAt && '@'}
|
||||
<TextWithEmojis text={profile.username} emojis={profile.emojis} emojiClassName="mb-1" />
|
||||
</SecondaryPageLink>
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
{usernameLink}
|
||||
{showQrCode && <NpubQrCode pubkey={userId} />}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -64,13 +72,15 @@ export function SimpleUsername({
|
||||
showAt = false,
|
||||
className,
|
||||
skeletonClassName,
|
||||
withoutSkeleton = false
|
||||
withoutSkeleton = false,
|
||||
showQrCode = true
|
||||
}: {
|
||||
userId: string
|
||||
showAt?: boolean
|
||||
className?: string
|
||||
skeletonClassName?: string
|
||||
withoutSkeleton?: boolean
|
||||
showQrCode?: boolean
|
||||
}) {
|
||||
const { profile, isFetching } = useFetchProfile(userId)
|
||||
if (!profile && isFetching && !withoutSkeleton) {
|
||||
@@ -85,9 +95,12 @@ export function SimpleUsername({
|
||||
const { username, emojis } = profile
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
{showAt && '@'}
|
||||
<TextWithEmojis text={username} emojis={emojis} emojiClassName="mb-1" />
|
||||
<div className={cn('flex items-center gap-1', className)}>
|
||||
<span className="truncate">
|
||||
{showAt && '@'}
|
||||
<TextWithEmojis text={username} emojis={emojis} emojiClassName="mb-1" />
|
||||
</span>
|
||||
{showQrCode && <NpubQrCode pubkey={userId} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -47,6 +47,9 @@ export const StorageKey = {
|
||||
DM_CONVERSATION_FILTER: 'dmConversationFilter',
|
||||
DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
|
||||
DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
|
||||
GRAPH_QUERIES_ENABLED: 'graphQueriesEnabled',
|
||||
SOCIAL_GRAPH_PROXIMITY: 'socialGraphProximity',
|
||||
SOCIAL_GRAPH_INCLUDE_MODE: 'socialGraphIncludeMode',
|
||||
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
|
||||
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
|
||||
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated
|
||||
|
||||
144
src/hooks/useFetchFollowGraph.tsx
Normal file
144
src/hooks/useFetchFollowGraph.tsx
Normal file
@@ -0,0 +1,144 @@
|
||||
import { BIG_RELAY_URLS } from '@/constants'
|
||||
import client from '@/services/client.service'
|
||||
import graphQueryService from '@/services/graph-query.service'
|
||||
import { useEffect, useState } from 'react'
|
||||
|
||||
interface FollowGraphResult {
|
||||
/** Pubkeys by depth (index 0 = direct follows, index 1 = follows of follows, etc.) */
|
||||
pubkeysByDepth: string[][]
|
||||
/** Whether graph query was used (vs traditional method) */
|
||||
usedGraphQuery: boolean
|
||||
/** Loading state */
|
||||
isLoading: boolean
|
||||
/** Error if any */
|
||||
error: Error | null
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for fetching follow graph with automatic graph query optimization.
|
||||
* Falls back to traditional method when graph queries are not available.
|
||||
*
|
||||
* @param pubkey - The seed pubkey to fetch follows for
|
||||
* @param depth - How many levels deep to fetch (1 = direct follows, 2 = + follows of follows)
|
||||
* @param relayUrls - Optional relay URLs to try for graph queries
|
||||
*/
|
||||
export function useFetchFollowGraph(
|
||||
pubkey: string | null,
|
||||
depth: number = 1,
|
||||
relayUrls?: string[]
|
||||
): FollowGraphResult {
|
||||
const [pubkeysByDepth, setPubkeysByDepth] = useState<string[][]>([])
|
||||
const [usedGraphQuery, setUsedGraphQuery] = useState(false)
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [error, setError] = useState<Error | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey) {
|
||||
setPubkeysByDepth([])
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
const fetchFollowGraph = async () => {
|
||||
setIsLoading(true)
|
||||
setError(null)
|
||||
setUsedGraphQuery(false)
|
||||
|
||||
const urls = relayUrls ?? BIG_RELAY_URLS
|
||||
|
||||
try {
|
||||
// Try graph query first
|
||||
const graphResult = await graphQueryService.queryFollowGraph(urls, pubkey, depth)
|
||||
|
||||
if (graphResult?.pubkeys_by_depth?.length) {
|
||||
setPubkeysByDepth(graphResult.pubkeys_by_depth)
|
||||
setUsedGraphQuery(true)
|
||||
setIsLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
// Fallback to traditional method
|
||||
const result = await fetchTraditionalFollowGraph(pubkey, depth)
|
||||
setPubkeysByDepth(result)
|
||||
} catch (e) {
|
||||
console.error('Failed to fetch follow graph:', e)
|
||||
setError(e instanceof Error ? e : new Error('Unknown error'))
|
||||
} finally {
|
||||
setIsLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
fetchFollowGraph()
|
||||
}, [pubkey, depth, relayUrls?.join(',')])
|
||||
|
||||
return { pubkeysByDepth, usedGraphQuery, isLoading, error }
|
||||
}
|
||||
|
||||
/**
|
||||
* Traditional method for fetching follow graph (used as fallback)
|
||||
*/
|
||||
async function fetchTraditionalFollowGraph(pubkey: string, depth: number): Promise<string[][]> {
|
||||
const result: string[][] = []
|
||||
|
||||
// Depth 1: Direct follows
|
||||
const directFollows = await client.fetchFollowings(pubkey)
|
||||
result.push(directFollows)
|
||||
|
||||
if (depth < 2 || directFollows.length === 0) {
|
||||
return result
|
||||
}
|
||||
|
||||
// Depth 2: Follows of follows
|
||||
// Note: This is expensive - N queries for N follows
|
||||
const followsOfFollows = new Set<string>()
|
||||
const directFollowsSet = new Set(directFollows)
|
||||
directFollowsSet.add(pubkey) // Exclude self
|
||||
|
||||
// Fetch in batches to avoid overwhelming relays
|
||||
const batchSize = 10
|
||||
for (let i = 0; i < directFollows.length; i += batchSize) {
|
||||
const batch = directFollows.slice(i, i + batchSize)
|
||||
const batchResults = await Promise.all(batch.map((pk) => client.fetchFollowings(pk)))
|
||||
|
||||
for (const follows of batchResults) {
|
||||
for (const pk of follows) {
|
||||
if (!directFollowsSet.has(pk)) {
|
||||
followsOfFollows.add(pk)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
result.push(Array.from(followsOfFollows))
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pubkeys from a follow graph result as a flat array
|
||||
*/
|
||||
export function flattenFollowGraph(pubkeysByDepth: string[][]): string[] {
|
||||
return pubkeysByDepth.flat()
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pubkey is in the follow graph at a specific depth
|
||||
*/
|
||||
export function isPubkeyAtDepth(
|
||||
pubkeysByDepth: string[][],
|
||||
pubkey: string,
|
||||
depth: number
|
||||
): boolean {
|
||||
const depthIndex = depth - 1
|
||||
if (depthIndex < 0 || depthIndex >= pubkeysByDepth.length) {
|
||||
return false
|
||||
}
|
||||
return pubkeysByDepth[depthIndex].includes(pubkey)
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a pubkey is anywhere in the follow graph
|
||||
*/
|
||||
export function isPubkeyInGraph(pubkeysByDepth: string[][], pubkey: string): boolean {
|
||||
return pubkeysByDepth.some((depth) => depth.includes(pubkey))
|
||||
}
|
||||
@@ -62,6 +62,7 @@ const MePage = forwardRef<TPageRef>((_, ref) => {
|
||||
className="text-xl font-semibold text-wrap"
|
||||
userId={pubkey}
|
||||
skeletonClassName="h-6 w-32"
|
||||
showQrCode={false}
|
||||
/>
|
||||
<div className="flex gap-1 mt-1">
|
||||
<PubkeyCopy pubkey={pubkey} />
|
||||
|
||||
@@ -28,5 +28,5 @@ export default function PinnedFeed() {
|
||||
init()
|
||||
}, [pubkey, pinnedPubkeySet])
|
||||
|
||||
return <NormalFeed subRequests={subRequests} isMainFeed />
|
||||
return <NormalFeed subRequests={subRequests} isMainFeed enableSocialGraphFilter />
|
||||
}
|
||||
|
||||
@@ -28,6 +28,7 @@ export default function RelaysFeed() {
|
||||
areAlgoRelays={areAlgoRelays}
|
||||
isMainFeed
|
||||
showRelayCloseReason
|
||||
enableSocialGraphFilter
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,22 +1,51 @@
|
||||
import MessageView from '@/components/Inbox/MessageView'
|
||||
import SecondaryPageLayout from '@/layouts/SecondaryPageLayout'
|
||||
import UserAvatar from '@/components/UserAvatar'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Titlebar } from '@/components/Titlebar'
|
||||
import { useSecondaryPage } from '@/PageManager'
|
||||
import { useDM } from '@/providers/DMProvider'
|
||||
import { TPageRef } from '@/types'
|
||||
import { useFollowList } from '@/providers/FollowListProvider'
|
||||
import client from '@/services/client.service'
|
||||
import { TPageRef, TProfile } from '@/types'
|
||||
import { ChevronLeft, MoreVertical, RefreshCw, Settings, Trash2, Undo2, Users, X } from 'lucide-react'
|
||||
import { nip19 } from 'nostr-tools'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef } from 'react'
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from 'react'
|
||||
import { useTranslation } from 'react-i18next'
|
||||
import { cn } from '@/lib/utils'
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger
|
||||
} from '@/components/ui/dropdown-menu'
|
||||
import ConversationSettingsModal from '@/components/Inbox/ConversationSettingsModal'
|
||||
import indexedDb from '@/services/indexed-db.service'
|
||||
import { useNostr } from '@/providers/NostrProvider'
|
||||
|
||||
interface DMConversationPageProps {
|
||||
pubkey?: string
|
||||
index?: number
|
||||
}
|
||||
|
||||
const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubkey, index }, ref) => {
|
||||
const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubkey }, ref) => {
|
||||
const { t } = useTranslation()
|
||||
const layoutRef = useRef<TPageRef>(null)
|
||||
const { selectConversation, currentConversation } = useDM()
|
||||
const { pubkey: userPubkey } = useNostr()
|
||||
const {
|
||||
selectConversation,
|
||||
currentConversation,
|
||||
isLoadingConversation,
|
||||
isNewConversation,
|
||||
clearNewConversationFlag,
|
||||
reloadConversation,
|
||||
deleteAllInConversation,
|
||||
undeleteAllInConversation
|
||||
} = useDM()
|
||||
const { pop } = useSecondaryPage()
|
||||
const { followingSet } = useFollowList()
|
||||
const [profile, setProfile] = useState<TProfile | null>(null)
|
||||
const [settingsOpen, setSettingsOpen] = useState(false)
|
||||
const [selectedRelays, setSelectedRelays] = useState<string[]>([])
|
||||
const [showPulse, setShowPulse] = useState(false)
|
||||
|
||||
// Decode npub to hex if needed
|
||||
const hexPubkey = useMemo(() => {
|
||||
@@ -32,6 +61,8 @@ const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubk
|
||||
return pubkey
|
||||
}, [pubkey])
|
||||
|
||||
const isFollowing = hexPubkey ? followingSet.has(hexPubkey) : false
|
||||
|
||||
useImperativeHandle(ref, () => layoutRef.current as TPageRef)
|
||||
|
||||
// Select the conversation when this page mounts
|
||||
@@ -48,17 +79,161 @@ const DMConversationPage = forwardRef<TPageRef, DMConversationPageProps>(({ pubk
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Fetch profile
|
||||
useEffect(() => {
|
||||
if (!hexPubkey) return
|
||||
|
||||
const fetchProfileData = async () => {
|
||||
try {
|
||||
const profileData = await client.fetchProfile(hexPubkey)
|
||||
if (profileData) {
|
||||
setProfile(profileData)
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch profile:', error)
|
||||
}
|
||||
}
|
||||
fetchProfileData()
|
||||
}, [hexPubkey])
|
||||
|
||||
// Handle pulsing animation for new conversations
|
||||
useEffect(() => {
|
||||
if (isNewConversation) {
|
||||
setShowPulse(true)
|
||||
const timer = setTimeout(() => {
|
||||
setShowPulse(false)
|
||||
clearNewConversationFlag()
|
||||
}, 10000)
|
||||
return () => clearTimeout(timer)
|
||||
}
|
||||
}, [isNewConversation, clearNewConversationFlag])
|
||||
|
||||
// Load saved relay settings when conversation changes
|
||||
useEffect(() => {
|
||||
if (!hexPubkey || !userPubkey) return
|
||||
|
||||
const loadRelaySettings = async () => {
|
||||
const saved = await indexedDb.getConversationRelaySettings(userPubkey, hexPubkey)
|
||||
setSelectedRelays(saved || [])
|
||||
}
|
||||
loadRelaySettings()
|
||||
}, [hexPubkey, userPubkey])
|
||||
|
||||
// Save relay settings when they change
|
||||
const handleRelaysChange = async (relays: string[]) => {
|
||||
setSelectedRelays(relays)
|
||||
if (userPubkey && hexPubkey) {
|
||||
await indexedDb.putConversationRelaySettings(userPubkey, hexPubkey, relays)
|
||||
}
|
||||
}
|
||||
|
||||
const handleBack = () => {
|
||||
selectConversation(null)
|
||||
pop()
|
||||
}
|
||||
|
||||
const displayName = profile?.username || (hexPubkey ? hexPubkey.slice(0, 8) + '...' : '')
|
||||
|
||||
// Custom titlebar with user info
|
||||
const titlebar = (
|
||||
<div className="flex items-center gap-2 w-full px-1">
|
||||
<Button
|
||||
className="flex gap-1 items-center justify-start pl-2 pr-1"
|
||||
variant="ghost"
|
||||
size="titlebar-icon"
|
||||
title={t('back')}
|
||||
onClick={handleBack}
|
||||
>
|
||||
<ChevronLeft />
|
||||
</Button>
|
||||
{hexPubkey && (
|
||||
<>
|
||||
<UserAvatar userId={hexPubkey} className="size-7" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="font-semibold text-sm truncate">{displayName}</span>
|
||||
{isFollowing && (
|
||||
<span title="Following">
|
||||
<Users className="size-3 text-primary" />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{profile?.nip05 && (
|
||||
<span className="text-xs text-muted-foreground truncate block">{profile.nip05}</span>
|
||||
)}
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
title={t('Reload messages')}
|
||||
onClick={reloadConversation}
|
||||
disabled={isLoadingConversation}
|
||||
>
|
||||
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
|
||||
title={t('Conversation settings')}
|
||||
onClick={() => {
|
||||
setShowPulse(false)
|
||||
clearNewConversationFlag()
|
||||
setSettingsOpen(true)
|
||||
}}
|
||||
>
|
||||
<Settings className="size-4" />
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="size-8">
|
||||
<MoreVertical className="size-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
|
||||
<Trash2 className="size-4 mr-2" />
|
||||
{t('Delete All')}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem onClick={undeleteAllInConversation}>
|
||||
<Undo2 className="size-4 mr-2" />
|
||||
{t('Undelete All')}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-8"
|
||||
title={t('Close conversation')}
|
||||
onClick={handleBack}
|
||||
>
|
||||
<X className="size-4" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<SecondaryPageLayout ref={layoutRef} index={index} title={t('Conversation')}>
|
||||
<div className="h-full">
|
||||
<MessageView onBack={handleBack} />
|
||||
<>
|
||||
<Titlebar className="p-1" hideBottomBorder={false}>
|
||||
{titlebar}
|
||||
</Titlebar>
|
||||
<div className="h-[calc(100%-3rem)]">
|
||||
<MessageView hideHeader />
|
||||
</div>
|
||||
</SecondaryPageLayout>
|
||||
{hexPubkey && (
|
||||
<ConversationSettingsModal
|
||||
partnerPubkey={hexPubkey}
|
||||
open={settingsOpen}
|
||||
onOpenChange={setSettingsOpen}
|
||||
selectedRelays={selectedRelays}
|
||||
onSelectedRelaysChange={handleRelaysChange}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
||||
|
||||
@@ -33,6 +33,7 @@ type TDMContext = {
|
||||
setPreferNip44: (prefer: boolean) => void
|
||||
isNewConversation: boolean
|
||||
clearNewConversationFlag: () => void
|
||||
dismissProvisionalConversation: () => void
|
||||
// Unread tracking
|
||||
totalUnreadCount: number
|
||||
hasNewMessages: boolean
|
||||
@@ -85,6 +86,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
||||
const [preferNip44, setPreferNip44State] = useState(() => storage.getPreferNip44())
|
||||
const [hasMoreConversations, setHasMoreConversations] = useState(false)
|
||||
const [isNewConversation, setIsNewConversation] = useState(false)
|
||||
const [provisionalPubkey, setProvisionalPubkey] = useState<string | null>(null)
|
||||
const [deletedState, setDeletedState] = useState<TDMDeletedState | null>(null)
|
||||
const [selectedMessages, setSelectedMessages] = useState<Set<string>>(new Set())
|
||||
const [isSelectionMode, setIsSelectionMode] = useState(false)
|
||||
@@ -577,6 +579,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
|
||||
// Start a new conversation - marks it as new for UI effects (pulsing settings button)
|
||||
// Creates a provisional conversation that appears in the list immediately
|
||||
const startConversation = useCallback(
|
||||
(partnerPubkey: string) => {
|
||||
// Check if this is a new conversation (not in existing list)
|
||||
@@ -585,6 +588,18 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
||||
)
|
||||
if (!existingConversation) {
|
||||
setIsNewConversation(true)
|
||||
setProvisionalPubkey(partnerPubkey)
|
||||
// Add a provisional conversation to the list so it appears immediately
|
||||
const provisionalConversation: TConversation = {
|
||||
partnerPubkey,
|
||||
lastMessageAt: Math.floor(Date.now() / 1000),
|
||||
lastMessagePreview: '',
|
||||
unreadCount: 0,
|
||||
preferredEncryption: null
|
||||
}
|
||||
// Add to front of both lists
|
||||
setAllConversations((prev) => [provisionalConversation, ...prev])
|
||||
setConversations((prev) => [provisionalConversation, ...prev])
|
||||
}
|
||||
// Clear messages and select the conversation
|
||||
setMessages([])
|
||||
@@ -597,6 +612,25 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
||||
setIsNewConversation(false)
|
||||
}, [])
|
||||
|
||||
// Dismiss a provisional conversation (remove from list without sending any messages)
|
||||
const dismissProvisionalConversation = useCallback(() => {
|
||||
if (!provisionalPubkey) return
|
||||
|
||||
// Remove from conversation lists
|
||||
setAllConversations((prev) => prev.filter((c) => c.partnerPubkey !== provisionalPubkey))
|
||||
setConversations((prev) => prev.filter((c) => c.partnerPubkey !== provisionalPubkey))
|
||||
|
||||
// Clear provisional state
|
||||
setProvisionalPubkey(null)
|
||||
setIsNewConversation(false)
|
||||
|
||||
// Deselect if this was the current conversation
|
||||
if (currentConversation === provisionalPubkey) {
|
||||
setCurrentConversation(null)
|
||||
setMessages([])
|
||||
}
|
||||
}, [provisionalPubkey, currentConversation])
|
||||
|
||||
// Reload the current conversation by clearing its cached state
|
||||
const reloadConversation = useCallback(() => {
|
||||
if (!currentConversation) return
|
||||
@@ -708,8 +742,14 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
||||
]
|
||||
}
|
||||
})
|
||||
|
||||
// Clear provisional state - conversation is now permanent
|
||||
if (provisionalPubkey === currentConversation) {
|
||||
setProvisionalPubkey(null)
|
||||
setIsNewConversation(false)
|
||||
}
|
||||
},
|
||||
[pubkey, encryption, currentConversation, relayList, conversations, preferNip44]
|
||||
[pubkey, encryption, currentConversation, relayList, conversations, preferNip44, provisionalPubkey]
|
||||
)
|
||||
|
||||
const setPreferNip44 = useCallback((prefer: boolean) => {
|
||||
@@ -927,6 +967,7 @@ export function DMProvider({ children }: { children: React.ReactNode }) {
|
||||
setPreferNip44,
|
||||
isNewConversation,
|
||||
clearNewConversationFlag,
|
||||
dismissProvisionalConversation,
|
||||
// Unread tracking
|
||||
totalUnreadCount,
|
||||
hasNewMessages,
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -127,18 +127,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
|
||||
useEffect(() => {
|
||||
const init = async () => {
|
||||
if (hasNostrLoginHash()) {
|
||||
return await loginByNostrLoginHash()
|
||||
await loginByNostrLoginHash()
|
||||
setIsInitialized(true)
|
||||
return
|
||||
}
|
||||
|
||||
const accounts = storage.getAccounts()
|
||||
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
|
||||
if (!act) return
|
||||
if (!act) {
|
||||
setIsInitialized(true)
|
||||
return
|
||||
}
|
||||
|
||||
// Set account immediately so feed can load based on pubkey
|
||||
// while signer initializes in the background
|
||||
setAccount({ pubkey: act.pubkey, signerType: act.signerType })
|
||||
setIsInitialized(true)
|
||||
|
||||
// Initialize signer in background - feed doesn't need it to load
|
||||
await loginWithAccountPointer(act)
|
||||
}
|
||||
init().then(() => {
|
||||
setIsInitialized(true)
|
||||
})
|
||||
init()
|
||||
|
||||
const handleHashChange = () => {
|
||||
if (hasNostrLoginHash()) {
|
||||
|
||||
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>
|
||||
)
|
||||
}
|
||||
316
src/services/graph-cache.service.ts
Normal file
316
src/services/graph-cache.service.ts
Normal file
@@ -0,0 +1,316 @@
|
||||
import { GraphResponse } from '@/types/graph'
|
||||
import { TGraphQueryCapability } from '@/types'
|
||||
|
||||
const DB_NAME = 'smesh-graph-cache'
|
||||
const DB_VERSION = 1
|
||||
|
||||
// Store names
|
||||
const STORES = {
|
||||
FOLLOW_GRAPH: 'followGraphResults',
|
||||
THREAD: 'threadResults',
|
||||
RELAY_CAPABILITIES: 'relayCapabilities'
|
||||
}
|
||||
|
||||
// Cache expiry times (in milliseconds)
|
||||
const CACHE_EXPIRY = {
|
||||
FOLLOW_GRAPH: 5 * 60 * 1000, // 5 minutes
|
||||
THREAD: 10 * 60 * 1000, // 10 minutes
|
||||
RELAY_CAPABILITY: 60 * 60 * 1000 // 1 hour
|
||||
}
|
||||
|
||||
interface CachedEntry<T> {
|
||||
data: T
|
||||
timestamp: number
|
||||
}
|
||||
|
||||
class GraphCacheService {
|
||||
static instance: GraphCacheService
|
||||
private db: IDBDatabase | null = null
|
||||
private dbPromise: Promise<IDBDatabase> | null = null
|
||||
|
||||
public static getInstance(): GraphCacheService {
|
||||
if (!GraphCacheService.instance) {
|
||||
GraphCacheService.instance = new GraphCacheService()
|
||||
}
|
||||
return GraphCacheService.instance
|
||||
}
|
||||
|
||||
private async getDB(): Promise<IDBDatabase> {
|
||||
if (this.db) return this.db
|
||||
if (this.dbPromise) return this.dbPromise
|
||||
|
||||
this.dbPromise = new Promise((resolve, reject) => {
|
||||
const request = indexedDB.open(DB_NAME, DB_VERSION)
|
||||
|
||||
request.onerror = () => {
|
||||
console.error('Failed to open graph cache database:', request.error)
|
||||
reject(request.error)
|
||||
}
|
||||
|
||||
request.onsuccess = () => {
|
||||
this.db = request.result
|
||||
resolve(request.result)
|
||||
}
|
||||
|
||||
request.onupgradeneeded = (event) => {
|
||||
const db = (event.target as IDBOpenDBRequest).result
|
||||
|
||||
// Create stores if they don't exist
|
||||
if (!db.objectStoreNames.contains(STORES.FOLLOW_GRAPH)) {
|
||||
db.createObjectStore(STORES.FOLLOW_GRAPH)
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORES.THREAD)) {
|
||||
db.createObjectStore(STORES.THREAD)
|
||||
}
|
||||
if (!db.objectStoreNames.contains(STORES.RELAY_CAPABILITIES)) {
|
||||
db.createObjectStore(STORES.RELAY_CAPABILITIES)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
return this.dbPromise
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a follow graph query result
|
||||
*/
|
||||
async cacheFollowGraph(
|
||||
pubkey: string,
|
||||
depth: number,
|
||||
result: GraphResponse
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDB()
|
||||
const key = `${pubkey}:${depth}`
|
||||
const entry: CachedEntry<GraphResponse> = {
|
||||
data: result,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORES.FOLLOW_GRAPH, 'readwrite')
|
||||
const store = tx.objectStore(STORES.FOLLOW_GRAPH)
|
||||
const request = store.put(entry, key)
|
||||
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to cache follow graph:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached follow graph result
|
||||
*/
|
||||
async getCachedFollowGraph(
|
||||
pubkey: string,
|
||||
depth: number
|
||||
): Promise<GraphResponse | null> {
|
||||
try {
|
||||
const db = await this.getDB()
|
||||
const key = `${pubkey}:${depth}`
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORES.FOLLOW_GRAPH, 'readonly')
|
||||
const store = tx.objectStore(STORES.FOLLOW_GRAPH)
|
||||
const request = store.get(key)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const entry = request.result as CachedEntry<GraphResponse> | undefined
|
||||
if (!entry) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
// Check if cache is expired
|
||||
if (Date.now() - entry.timestamp > CACHE_EXPIRY.FOLLOW_GRAPH) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
resolve(entry.data)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached follow graph:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache a thread query result
|
||||
*/
|
||||
async cacheThread(eventId: string, result: GraphResponse): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDB()
|
||||
const entry: CachedEntry<GraphResponse> = {
|
||||
data: result,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORES.THREAD, 'readwrite')
|
||||
const store = tx.objectStore(STORES.THREAD)
|
||||
const request = store.put(entry, eventId)
|
||||
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to cache thread:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached thread result
|
||||
*/
|
||||
async getCachedThread(eventId: string): Promise<GraphResponse | null> {
|
||||
try {
|
||||
const db = await this.getDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORES.THREAD, 'readonly')
|
||||
const store = tx.objectStore(STORES.THREAD)
|
||||
const request = store.get(eventId)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const entry = request.result as CachedEntry<GraphResponse> | undefined
|
||||
if (!entry) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
if (Date.now() - entry.timestamp > CACHE_EXPIRY.THREAD) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
|
||||
resolve(entry.data)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached thread:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Cache relay graph capability
|
||||
*/
|
||||
async cacheRelayCapability(
|
||||
url: string,
|
||||
capability: TGraphQueryCapability | null
|
||||
): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDB()
|
||||
const entry: CachedEntry<TGraphQueryCapability | null> = {
|
||||
data: capability,
|
||||
timestamp: Date.now()
|
||||
}
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORES.RELAY_CAPABILITIES, 'readwrite')
|
||||
const store = tx.objectStore(STORES.RELAY_CAPABILITIES)
|
||||
const request = store.put(entry, url)
|
||||
|
||||
request.onsuccess = () => resolve()
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to cache relay capability:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get cached relay capability
|
||||
*/
|
||||
async getCachedRelayCapability(
|
||||
url: string
|
||||
): Promise<TGraphQueryCapability | null | undefined> {
|
||||
try {
|
||||
const db = await this.getDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORES.RELAY_CAPABILITIES, 'readonly')
|
||||
const store = tx.objectStore(STORES.RELAY_CAPABILITIES)
|
||||
const request = store.get(url)
|
||||
|
||||
request.onsuccess = () => {
|
||||
const entry = request.result as
|
||||
| CachedEntry<TGraphQueryCapability | null>
|
||||
| undefined
|
||||
if (!entry) {
|
||||
resolve(undefined) // Not in cache
|
||||
return
|
||||
}
|
||||
|
||||
if (Date.now() - entry.timestamp > CACHE_EXPIRY.RELAY_CAPABILITY) {
|
||||
resolve(undefined) // Expired
|
||||
return
|
||||
}
|
||||
|
||||
resolve(entry.data)
|
||||
}
|
||||
request.onerror = () => reject(request.error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to get cached relay capability:', error)
|
||||
return undefined
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Invalidate follow graph cache for a pubkey
|
||||
*/
|
||||
async invalidateFollowGraph(pubkey: string): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(STORES.FOLLOW_GRAPH, 'readwrite')
|
||||
const store = tx.objectStore(STORES.FOLLOW_GRAPH)
|
||||
|
||||
// Delete entries for all depths
|
||||
for (let depth = 1; depth <= 16; depth++) {
|
||||
store.delete(`${pubkey}:${depth}`)
|
||||
}
|
||||
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to invalidate follow graph cache:', error)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear all caches
|
||||
*/
|
||||
async clearAll(): Promise<void> {
|
||||
try {
|
||||
const db = await this.getDB()
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const tx = db.transaction(
|
||||
[STORES.FOLLOW_GRAPH, STORES.THREAD, STORES.RELAY_CAPABILITIES],
|
||||
'readwrite'
|
||||
)
|
||||
|
||||
tx.objectStore(STORES.FOLLOW_GRAPH).clear()
|
||||
tx.objectStore(STORES.THREAD).clear()
|
||||
tx.objectStore(STORES.RELAY_CAPABILITIES).clear()
|
||||
|
||||
tx.oncomplete = () => resolve()
|
||||
tx.onerror = () => reject(tx.error)
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to clear graph cache:', error)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instance = GraphCacheService.getInstance()
|
||||
export default instance
|
||||
326
src/services/graph-query.service.ts
Normal file
326
src/services/graph-query.service.ts
Normal file
@@ -0,0 +1,326 @@
|
||||
import { normalizeUrl } from '@/lib/url'
|
||||
import { TGraphQueryCapability, TRelayInfo } from '@/types'
|
||||
import { GraphQuery, GraphResponse } from '@/types/graph'
|
||||
import { Event as NEvent, Filter, SimplePool, verifyEvent } from 'nostr-tools'
|
||||
import relayInfoService from './relay-info.service'
|
||||
import storage from './local-storage.service'
|
||||
|
||||
// Graph query response kinds (relay-signed)
|
||||
const GRAPH_RESPONSE_KINDS = {
|
||||
FOLLOWS: 39000,
|
||||
MENTIONS: 39001,
|
||||
THREAD: 39002
|
||||
}
|
||||
|
||||
class GraphQueryService {
|
||||
static instance: GraphQueryService
|
||||
|
||||
private pool: SimplePool
|
||||
private capabilityCache = new Map<string, TGraphQueryCapability | null>()
|
||||
private capabilityFetchPromises = new Map<string, Promise<TGraphQueryCapability | null>>()
|
||||
|
||||
constructor() {
|
||||
this.pool = new SimplePool()
|
||||
}
|
||||
|
||||
public static getInstance(): GraphQueryService {
|
||||
if (!GraphQueryService.instance) {
|
||||
GraphQueryService.instance = new GraphQueryService()
|
||||
}
|
||||
return GraphQueryService.instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if graph queries are enabled in settings
|
||||
*/
|
||||
isEnabled(): boolean {
|
||||
return storage.getGraphQueriesEnabled()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get relay's graph query capability via NIP-11
|
||||
*/
|
||||
async getRelayCapability(url: string): Promise<TGraphQueryCapability | null> {
|
||||
const normalizedUrl = normalizeUrl(url)
|
||||
|
||||
// Check memory cache first
|
||||
if (this.capabilityCache.has(normalizedUrl)) {
|
||||
return this.capabilityCache.get(normalizedUrl) ?? null
|
||||
}
|
||||
|
||||
// Check if already fetching
|
||||
const existingPromise = this.capabilityFetchPromises.get(normalizedUrl)
|
||||
if (existingPromise) {
|
||||
return existingPromise
|
||||
}
|
||||
|
||||
// Fetch capability
|
||||
const fetchPromise = this._fetchRelayCapability(normalizedUrl)
|
||||
this.capabilityFetchPromises.set(normalizedUrl, fetchPromise)
|
||||
|
||||
try {
|
||||
const capability = await fetchPromise
|
||||
this.capabilityCache.set(normalizedUrl, capability)
|
||||
return capability
|
||||
} finally {
|
||||
this.capabilityFetchPromises.delete(normalizedUrl)
|
||||
}
|
||||
}
|
||||
|
||||
private async _fetchRelayCapability(url: string): Promise<TGraphQueryCapability | null> {
|
||||
try {
|
||||
const relayInfo = (await relayInfoService.getRelayInfo(url)) as TRelayInfo | undefined
|
||||
|
||||
if (!relayInfo?.graph_query?.enabled) {
|
||||
return null
|
||||
}
|
||||
|
||||
return relayInfo.graph_query
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a relay supports a specific graph query method
|
||||
*/
|
||||
async supportsMethod(url: string, method: GraphQuery['method']): Promise<boolean> {
|
||||
const capability = await this.getRelayCapability(url)
|
||||
if (!capability?.enabled) return false
|
||||
return capability.methods.includes(method)
|
||||
}
|
||||
|
||||
/**
|
||||
* Find a relay supporting graph queries from a list of URLs
|
||||
*/
|
||||
async findGraphCapableRelay(
|
||||
urls: string[],
|
||||
method?: GraphQuery['method']
|
||||
): Promise<string | null> {
|
||||
if (!this.isEnabled()) return null
|
||||
|
||||
// Check capabilities in parallel
|
||||
const results = await Promise.all(
|
||||
urls.map(async (url) => {
|
||||
const capability = await this.getRelayCapability(url)
|
||||
if (!capability?.enabled) return null
|
||||
if (method && !capability.methods.includes(method)) return null
|
||||
return url
|
||||
})
|
||||
)
|
||||
|
||||
return results.find((url) => url !== null) ?? null
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a graph query against a specific relay
|
||||
*/
|
||||
async executeQuery(relayUrl: string, query: GraphQuery): Promise<GraphResponse | null> {
|
||||
if (!this.isEnabled()) return null
|
||||
|
||||
const capability = await this.getRelayCapability(relayUrl)
|
||||
if (!capability?.enabled) {
|
||||
console.warn(`Relay ${relayUrl} does not support graph queries`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate method support
|
||||
if (!capability.methods.includes(query.method)) {
|
||||
console.warn(`Relay ${relayUrl} does not support method: ${query.method}`)
|
||||
return null
|
||||
}
|
||||
|
||||
// Validate depth
|
||||
const depth = query.depth ?? 1
|
||||
if (depth > capability.max_depth) {
|
||||
console.warn(`Requested depth ${depth} exceeds relay max ${capability.max_depth}`)
|
||||
query = { ...query, depth: capability.max_depth }
|
||||
}
|
||||
|
||||
// Build the filter with graph extension
|
||||
// The _graph field is a custom extension not in the standard Filter type
|
||||
const filter = {
|
||||
_graph: query
|
||||
} as Filter
|
||||
|
||||
// Determine expected response kind
|
||||
const expectedKind = this.getExpectedResponseKind(query.method)
|
||||
|
||||
return new Promise<GraphResponse | null>(async (resolve) => {
|
||||
let resolved = false
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
resolve(null)
|
||||
}
|
||||
}, 30000) // 30s timeout for graph queries
|
||||
|
||||
try {
|
||||
const relay = await this.pool.ensureRelay(relayUrl, { connectionTimeout: 5000 })
|
||||
|
||||
const sub = relay.subscribe([filter], {
|
||||
onevent: (event: NEvent) => {
|
||||
// Verify it's a relay-signed graph response
|
||||
if (event.kind !== expectedKind) return
|
||||
|
||||
// Verify event signature
|
||||
if (!verifyEvent(event)) {
|
||||
console.warn('Invalid signature on graph response')
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
const response = JSON.parse(event.content) as GraphResponse
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
sub.close()
|
||||
resolve(response)
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse graph response:', e)
|
||||
}
|
||||
},
|
||||
oneose: () => {
|
||||
// If we got EOSE without a response, the query may not be supported
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
sub.close()
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to connect to relay for graph query:', error)
|
||||
if (!resolved) {
|
||||
resolved = true
|
||||
clearTimeout(timeout)
|
||||
resolve(null)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
private getExpectedResponseKind(method: GraphQuery['method']): number {
|
||||
switch (method) {
|
||||
case 'follows':
|
||||
case 'followers':
|
||||
return GRAPH_RESPONSE_KINDS.FOLLOWS
|
||||
case 'mentions':
|
||||
return GRAPH_RESPONSE_KINDS.MENTIONS
|
||||
case 'thread':
|
||||
return GRAPH_RESPONSE_KINDS.THREAD
|
||||
default:
|
||||
return GRAPH_RESPONSE_KINDS.FOLLOWS
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level method: Query follow graph with fallback
|
||||
*/
|
||||
async queryFollowGraph(
|
||||
relayUrls: string[],
|
||||
seed: string,
|
||||
depth: number = 1
|
||||
): Promise<GraphResponse | null> {
|
||||
const graphRelay = await this.findGraphCapableRelay(relayUrls, 'follows')
|
||||
if (!graphRelay) return null
|
||||
|
||||
return this.executeQuery(graphRelay, {
|
||||
method: 'follows',
|
||||
seed,
|
||||
depth
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level method: Query follower graph
|
||||
*/
|
||||
async queryFollowerGraph(
|
||||
relayUrls: string[],
|
||||
seed: string,
|
||||
depth: number = 1
|
||||
): Promise<GraphResponse | null> {
|
||||
const graphRelay = await this.findGraphCapableRelay(relayUrls, 'followers')
|
||||
if (!graphRelay) return null
|
||||
|
||||
return this.executeQuery(graphRelay, {
|
||||
method: 'followers',
|
||||
seed,
|
||||
depth
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level method: Query thread with optional ref aggregation
|
||||
*/
|
||||
async queryThread(
|
||||
relayUrls: string[],
|
||||
eventId: string,
|
||||
depth: number = 10,
|
||||
options?: {
|
||||
inboundRefKinds?: number[]
|
||||
outboundRefKinds?: number[]
|
||||
}
|
||||
): Promise<GraphResponse | null> {
|
||||
const graphRelay = await this.findGraphCapableRelay(relayUrls, 'thread')
|
||||
if (!graphRelay) return null
|
||||
|
||||
const query: GraphQuery = {
|
||||
method: 'thread',
|
||||
seed: eventId,
|
||||
depth
|
||||
}
|
||||
|
||||
if (options?.inboundRefKinds?.length) {
|
||||
query.inbound_refs = [{ kinds: options.inboundRefKinds, from_depth: 0 }]
|
||||
}
|
||||
|
||||
if (options?.outboundRefKinds?.length) {
|
||||
query.outbound_refs = [{ kinds: options.outboundRefKinds, from_depth: 0 }]
|
||||
}
|
||||
|
||||
return this.executeQuery(graphRelay, query)
|
||||
}
|
||||
|
||||
/**
|
||||
* High-level method: Query mentions with aggregation
|
||||
*/
|
||||
async queryMentions(
|
||||
relayUrls: string[],
|
||||
pubkey: string,
|
||||
options?: {
|
||||
inboundRefKinds?: number[] // e.g., [7, 9735] for reactions and zaps
|
||||
}
|
||||
): Promise<GraphResponse | null> {
|
||||
const graphRelay = await this.findGraphCapableRelay(relayUrls, 'mentions')
|
||||
if (!graphRelay) return null
|
||||
|
||||
const query: GraphQuery = {
|
||||
method: 'mentions',
|
||||
seed: pubkey
|
||||
}
|
||||
|
||||
if (options?.inboundRefKinds?.length) {
|
||||
query.inbound_refs = [{ kinds: options.inboundRefKinds, from_depth: 0 }]
|
||||
}
|
||||
|
||||
return this.executeQuery(graphRelay, query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear capability cache for a relay (e.g., when relay info is updated)
|
||||
*/
|
||||
clearCapabilityCache(url?: string): void {
|
||||
if (url) {
|
||||
const normalizedUrl = normalizeUrl(url)
|
||||
this.capabilityCache.delete(normalizedUrl)
|
||||
} else {
|
||||
this.capabilityCache.clear()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const instance = GraphQueryService.getInstance()
|
||||
export default instance
|
||||
@@ -1,7 +1,6 @@
|
||||
import {
|
||||
ALLOWED_FILTER_KINDS,
|
||||
DEFAULT_FAVICON_URL_TEMPLATE,
|
||||
DEFAULT_NIP_96_SERVICE,
|
||||
ExtendedKind,
|
||||
MEDIA_AUTO_LOAD_POLICY,
|
||||
NOTIFICATION_LIST_STYLE,
|
||||
@@ -40,7 +39,6 @@ class LocalStorageService {
|
||||
private defaultZapComment: string = 'Zap!'
|
||||
private quickZap: boolean = false
|
||||
private accountFeedInfoMap: Record<string, TFeedInfo | undefined> = {}
|
||||
private mediaUploadService: string = DEFAULT_NIP_96_SERVICE
|
||||
private autoplay: boolean = true
|
||||
private hideUntrustedInteractions: boolean = false
|
||||
private hideUntrustedNotifications: boolean = false
|
||||
@@ -62,6 +60,9 @@ class LocalStorageService {
|
||||
private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
|
||||
private preferNip44: boolean = false
|
||||
private dmConversationFilter: 'all' | 'follows' = 'all'
|
||||
private graphQueriesEnabled: boolean = true
|
||||
private socialGraphProximity: number | null = null
|
||||
private socialGraphIncludeMode: boolean = true // true = include only, false = exclude
|
||||
|
||||
constructor() {
|
||||
if (!LocalStorageService.instance) {
|
||||
@@ -124,10 +125,6 @@ class LocalStorageService {
|
||||
window.localStorage.getItem(StorageKey.ACCOUNT_FEED_INFO_MAP) ?? '{}'
|
||||
this.accountFeedInfoMap = JSON.parse(accountFeedInfoMapStr)
|
||||
|
||||
// deprecated
|
||||
this.mediaUploadService =
|
||||
window.localStorage.getItem(StorageKey.MEDIA_UPLOAD_SERVICE) ?? DEFAULT_NIP_96_SERVICE
|
||||
|
||||
this.autoplay = window.localStorage.getItem(StorageKey.AUTOPLAY) !== 'false'
|
||||
|
||||
const hideUntrustedEvents =
|
||||
@@ -253,6 +250,19 @@ class LocalStorageService {
|
||||
this.preferNip44 = window.localStorage.getItem(StorageKey.PREFER_NIP44) === 'true'
|
||||
this.dmConversationFilter =
|
||||
(window.localStorage.getItem(StorageKey.DM_CONVERSATION_FILTER) as 'all' | 'follows') || 'all'
|
||||
this.graphQueriesEnabled =
|
||||
window.localStorage.getItem(StorageKey.GRAPH_QUERIES_ENABLED) !== 'false'
|
||||
|
||||
const socialGraphProximityStr = window.localStorage.getItem(StorageKey.SOCIAL_GRAPH_PROXIMITY)
|
||||
if (socialGraphProximityStr) {
|
||||
const parsed = parseInt(socialGraphProximityStr)
|
||||
if (!isNaN(parsed) && parsed >= 1 && parsed <= 2) {
|
||||
this.socialGraphProximity = parsed
|
||||
}
|
||||
}
|
||||
|
||||
this.socialGraphIncludeMode =
|
||||
window.localStorage.getItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE) !== 'false'
|
||||
|
||||
// Clean up deprecated data
|
||||
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
|
||||
@@ -439,7 +449,7 @@ class LocalStorageService {
|
||||
}
|
||||
|
||||
getMediaUploadServiceConfig(pubkey?: string | null): TMediaUploadServiceConfig {
|
||||
const defaultConfig = { type: 'nip96', service: this.mediaUploadService } as const
|
||||
const defaultConfig = { type: 'blossom' } as const
|
||||
if (!pubkey) {
|
||||
return defaultConfig
|
||||
}
|
||||
@@ -645,6 +655,37 @@ class LocalStorageService {
|
||||
map[pubkey] = timestamp
|
||||
window.localStorage.setItem(StorageKey.DM_LAST_SEEN_TIMESTAMP, JSON.stringify(map))
|
||||
}
|
||||
|
||||
getGraphQueriesEnabled() {
|
||||
return this.graphQueriesEnabled
|
||||
}
|
||||
|
||||
setGraphQueriesEnabled(enabled: boolean) {
|
||||
this.graphQueriesEnabled = enabled
|
||||
window.localStorage.setItem(StorageKey.GRAPH_QUERIES_ENABLED, enabled.toString())
|
||||
}
|
||||
|
||||
getSocialGraphProximity(): number | null {
|
||||
return this.socialGraphProximity
|
||||
}
|
||||
|
||||
setSocialGraphProximity(depth: number | null) {
|
||||
this.socialGraphProximity = depth
|
||||
if (depth === null) {
|
||||
window.localStorage.removeItem(StorageKey.SOCIAL_GRAPH_PROXIMITY)
|
||||
} else {
|
||||
window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_PROXIMITY, depth.toString())
|
||||
}
|
||||
}
|
||||
|
||||
getSocialGraphIncludeMode(): boolean {
|
||||
return this.socialGraphIncludeMode
|
||||
}
|
||||
|
||||
setSocialGraphIncludeMode(include: boolean) {
|
||||
this.socialGraphIncludeMode = include
|
||||
window.localStorage.setItem(StorageKey.SOCIAL_GRAPH_INCLUDE_MODE, include.toString())
|
||||
}
|
||||
}
|
||||
|
||||
const instance = new LocalStorageService()
|
||||
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
} from '@/lib/event'
|
||||
import { generateBech32IdFromETag } from '@/lib/tag'
|
||||
import client from '@/services/client.service'
|
||||
import graphQueryService from '@/services/graph-query.service'
|
||||
import dayjs from 'dayjs'
|
||||
import { Filter, kinds, NostrEvent } from 'nostr-tools'
|
||||
|
||||
@@ -62,6 +63,20 @@ class ThreadService {
|
||||
return
|
||||
}
|
||||
|
||||
// Try graph query first for E-tag threads (event ID based)
|
||||
if (rootInfo.type === 'E') {
|
||||
const graphResult = await this.tryGraphQueryThread(rootInfo.id)
|
||||
if (graphResult) {
|
||||
// Graph query succeeded, no need to subscribe
|
||||
this.subscriptions.set(rootInfo.id, {
|
||||
promise: Promise.resolve({ closer: () => {}, timelineKey: '' }),
|
||||
count: 1,
|
||||
until: undefined // Graph queries return complete results
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
const _subscribe = async () => {
|
||||
let relayUrls: string[] = []
|
||||
const rootPubkey = (rootInfo as { pubkey?: string }).pubkey ?? event?.pubkey
|
||||
@@ -366,6 +381,50 @@ class ThreadService {
|
||||
return promise
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to fetch thread events using graph query (NIP-XX).
|
||||
* Returns true if successful, false otherwise.
|
||||
*/
|
||||
private async tryGraphQueryThread(eventId: string): Promise<boolean> {
|
||||
try {
|
||||
const graphResult = await graphQueryService.queryThread(
|
||||
BIG_RELAY_URLS,
|
||||
eventId,
|
||||
10, // Max depth for threads
|
||||
{
|
||||
inboundRefKinds: [7, 9735] // Reactions and zaps
|
||||
}
|
||||
)
|
||||
|
||||
if (!graphResult?.events_by_depth?.length) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Graph query returns event IDs by depth
|
||||
// We need to fetch the actual events and add them to the thread
|
||||
const allEventIds = graphResult.events_by_depth.flat()
|
||||
if (allEventIds.length === 0) {
|
||||
return false
|
||||
}
|
||||
|
||||
// Fetch actual events for the IDs returned by graph query
|
||||
const events = await client.fetchEvents(BIG_RELAY_URLS, {
|
||||
ids: allEventIds.slice(0, 500), // Limit to prevent huge queries
|
||||
limit: allEventIds.length
|
||||
})
|
||||
|
||||
if (events.length > 0) {
|
||||
this.addRepliesToThread(events)
|
||||
return true
|
||||
}
|
||||
|
||||
return false
|
||||
} catch (error) {
|
||||
console.error('Graph query for thread failed:', error)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private resolveStuff(stuff: NostrEvent | string) {
|
||||
return typeof stuff === 'string'
|
||||
? { event: undefined, externalContent: stuff, stuffKey: stuff }
|
||||
|
||||
38
src/types/graph.d.ts
vendored
Normal file
38
src/types/graph.d.ts
vendored
Normal file
@@ -0,0 +1,38 @@
|
||||
// Re-export TGraphQueryCapability from index.d.ts
|
||||
export type { TGraphQueryCapability } from './index'
|
||||
|
||||
// Graph query request structure (NIP-XX extension)
|
||||
export interface GraphQuery {
|
||||
method: 'follows' | 'followers' | 'mentions' | 'thread'
|
||||
seed: string // 64-char hex pubkey or event ID
|
||||
depth?: number // 1-16, default 1
|
||||
inbound_refs?: RefSpec[]
|
||||
outbound_refs?: RefSpec[]
|
||||
}
|
||||
|
||||
export interface RefSpec {
|
||||
kinds: number[]
|
||||
from_depth?: number
|
||||
}
|
||||
|
||||
// Graph query response (from relay-signed event content)
|
||||
export interface GraphResponse {
|
||||
pubkeys_by_depth?: string[][]
|
||||
events_by_depth?: string[][]
|
||||
total_pubkeys?: number
|
||||
total_events?: number
|
||||
inbound_refs?: RefSummary[]
|
||||
outbound_refs?: RefSummary[]
|
||||
}
|
||||
|
||||
export interface RefSummary {
|
||||
kind: number
|
||||
target: string
|
||||
count: number
|
||||
refs?: string[]
|
||||
}
|
||||
|
||||
// Graph query filter extension for nostr-tools
|
||||
export interface GraphFilter {
|
||||
_graph: GraphQuery
|
||||
}
|
||||
8
src/types/index.d.ts
vendored
8
src/types/index.d.ts
vendored
@@ -40,6 +40,13 @@ export type TRelayList = {
|
||||
originalRelays: TMailboxRelay[]
|
||||
}
|
||||
|
||||
export type TGraphQueryCapability = {
|
||||
enabled: boolean
|
||||
max_depth: number
|
||||
max_results: number
|
||||
methods: string[]
|
||||
}
|
||||
|
||||
export type TRelayInfo = {
|
||||
url: string
|
||||
shortUrl: string
|
||||
@@ -57,6 +64,7 @@ export type TRelayInfo = {
|
||||
auth_required?: boolean
|
||||
payment_required?: boolean
|
||||
}
|
||||
graph_query?: TGraphQueryCapability
|
||||
}
|
||||
|
||||
export type TWebMetadata = {
|
||||
|
||||
Reference in New Issue
Block a user