2 Commits

Author SHA1 Message Date
woikos
8a9795a53a Add graph query optimization for faster social graph operations
- Add GraphQueryService for NIP-XX graph queries
- Add GraphCacheService for IndexedDB caching of results
- Optimize FollowedBy component with graph queries
- Add graph query support to ThreadService
- Add useFetchFollowGraph hook
- Add graph query toggle in Settings > System
- Bump version to v0.4.0

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

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

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

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

View File

@@ -1,6 +1,6 @@
{
"name": "smesh",
"version": "0.3.1",
"version": "0.4.0",
"description": "A user-friendly Nostr client for exploring relay feeds",
"private": true,
"type": "module",

View File

@@ -1,78 +1,12 @@
import QrScannerModal from '@/components/QrScannerModal'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { useNostr } from '@/providers/NostrProvider'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useState } from 'react'
import { useTranslation } from 'react-i18next'
import { ScanLine, X } from 'lucide-react'
import QrScanner from 'qr-scanner'
function QrScannerModal({
onScan,
onClose
}: {
onScan: (result: string) => void
onClose: () => void
}) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement>(null)
const scannerRef = useRef<QrScanner | null>(null)
const [error, setError] = useState<string | null>(null)
const handleScan = useCallback(
(result: QrScanner.ScanResult) => {
onScan(result.data)
onClose()
},
[onScan, onClose]
)
useEffect(() => {
if (!videoRef.current) return
const scanner = new QrScanner(videoRef.current, handleScan, {
preferredCamera: 'environment',
highlightScanRegion: true,
highlightCodeOutline: true
})
scannerRef.current = scanner
scanner.start().catch(() => {
setError(t('Failed to access camera'))
})
return () => {
scanner.destroy()
}
}, [handleScan, t])
return (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
<div className="relative w-full max-w-sm mx-4">
<Button
variant="ghost"
size="icon"
className="absolute -top-12 right-0 text-white hover:bg-white/20"
onClick={onClose}
>
<X className="h-6 w-6" />
</Button>
<div className="rounded-lg overflow-hidden bg-black">
{error ? (
<div className="p-8 text-center text-destructive">{error}</div>
) : (
<video ref={videoRef} className="w-full" />
)}
</div>
<p className="text-center text-white/70 text-sm mt-4">
{t('Point camera at QR code')}
</p>
</div>
</div>
)
}
import { ScanLine } from 'lucide-react'
export default function PrivateKeyLogin({
back,

View File

@@ -23,15 +23,33 @@ export default function Help() {
<AccordionContent className="pb-4">
<div className="space-y-4 text-sm text-muted-foreground">
<p>{t('Navigate the app entirely with your keyboard:')}</p>
<p className="font-medium">{t('Toggle Keyboard Mode:')}</p>
<div className="space-y-2">
<KeyBinding keys={['Arrow Up', 'Arrow Down']} description={t('Move between items in a list')} />
<KeyBinding keys={['Arrow Left', 'Arrow Right']} description={t('Switch between columns (sidebar, feed, detail)')} />
<KeyBinding keys={['Enter']} description={t('Open or activate the selected item')} />
<KeyBinding keys={['Escape']} description={t('Close current view or go to sidebar')} />
<KeyBinding keys={['Backspace']} description={t('Go back to previous view')} />
<KeyBinding keys={['Page Up', 'Page Down']} description={t('Jump to top or bottom of list')} />
<KeyBinding keys={['Tab']} description={t('Cycle through note actions (reply, repost, quote, react, zap)')} />
<KeyBinding keys={['⇧K']} description={t('Toggle keyboard navigation on/off')} />
<KeyBinding keys={['Esc', 'Esc', 'Esc']} description={t('Triple-Escape to quickly exit keyboard mode')} />
</div>
<p className="text-xs opacity-70">{t('You can also click the keyboard button in the sidebar to toggle.')}</p>
<p className="font-medium mt-4">{t('Movement:')}</p>
<div className="space-y-2">
<KeyBinding keys={['↑', '↓']} altKeys={['k', 'j']} description={t('Move between items in a list')} />
<KeyBinding keys={['Tab']} description={t('Switch to next column (Shift+Tab for previous)')} />
<KeyBinding keys={['Page Up']} description={t('Jump to top and focus first item')} />
</div>
<p className="font-medium mt-4">{t('Actions:')}</p>
<div className="space-y-2">
<KeyBinding keys={['→', 'Enter']} altKeys={['l']} description={t('Activate the selected item')} />
<KeyBinding keys={['←']} altKeys={['h']} description={t('Go back (close panel or move to sidebar)')} />
<KeyBinding keys={['Escape']} description={t('Close current view or cancel')} />
</div>
<p className="font-medium mt-4">{t('Note Actions (when a note is selected):')}</p>
<div className="space-y-2">
<KeyBinding keys={['r']} description={t('Reply')} />
<KeyBinding keys={['p']} description={t('Repost')} />
<KeyBinding keys={['q']} description={t('Quote')} />
<KeyBinding keys={['R']} description={t('React with emoji')} />
<KeyBinding keys={['z']} description={t('Zap (send sats)')} />
</div>
<p className="text-xs opacity-70 pt-2">{t('Selected items are centered on screen for easy viewing.')}</p>
</div>
</AccordionContent>
</AccordionItem>
@@ -156,18 +174,33 @@ export default function Help() {
)
}
function KeyBinding({ keys, description }: { keys: string[]; description: string }) {
function KeyBinding({
keys,
altKeys,
description
}: {
keys: string[]
altKeys?: string[]
description: string
}) {
return (
<div className="flex items-center gap-3">
<div className="flex gap-1">
<div className="flex items-center gap-1">
{keys.map((key) => (
<kbd
key={key}
className="px-2 py-1 text-xs font-mono bg-muted border rounded"
>
<kbd key={key} className="px-2 py-1 text-xs font-mono bg-muted border rounded">
{key}
</kbd>
))}
{altKeys && (
<>
<span className="text-xs text-muted-foreground mx-1">/</span>
{altKeys.map((key) => (
<kbd key={key} className="px-2 py-1 text-xs font-mono bg-muted border rounded">
{key}
</kbd>
))}
</>
)}
</div>
<span>{description}</span>
</div>

View File

@@ -26,9 +26,10 @@ import { useFollowList } from '@/providers/FollowListProvider'
interface MessageViewProps {
onBack?: () => void
hideHeader?: boolean
}
export default function MessageView({ onBack }: MessageViewProps) {
export default function MessageView({ onBack, hideHeader }: MessageViewProps) {
const { t } = useTranslation()
const { pubkey } = useNostr()
const {
@@ -184,6 +185,20 @@ export default function MessageView({ onBack }: MessageViewProps) {
lastMessageCountRef.current = 0
}, [currentConversation])
// Scroll to bottom when conversation opens and messages are loaded
const hasMessages = messages.length > 0
useEffect(() => {
if (currentConversation && hasMessages && scrollRef.current) {
// Use requestAnimationFrame to ensure DOM is ready
requestAnimationFrame(() => {
if (scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight
lastMessageCountRef.current = messages.length
}
})
}
}, [currentConversation, hasMessages])
if (!currentConversation || !pubkey) {
return null
}
@@ -192,114 +207,116 @@ export default function MessageView({ onBack }: MessageViewProps) {
return (
<div className="flex flex-col h-full">
{/* Header */}
<div className="flex items-center gap-3 p-3 border-b">
{isSelectionMode ? (
// Selection mode header
<>
<Button
variant="ghost"
size="icon"
onClick={clearSelection}
className="size-8"
title={t('Cancel')}
>
<X className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Trash2 className="size-4 text-destructive" />
<span className="font-medium text-sm">{t('Delete')}</span>
</div>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={deleteSelectedMessages}
disabled={selectedMessages.size === 0}
className="text-xs"
>
{t('Selected')} ({selectedMessages.size})
</Button>
<Button
variant="destructive"
size="sm"
onClick={deleteAllInConversation}
className="text-xs"
>
{t('All')}
</Button>
</>
) : (
// Normal header
<>
<UserAvatar userId={currentConversation} className="size-8" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm truncate">{displayName}</span>
{isFollowing && (
<span title="Following">
<Users className="size-3 text-primary" />
</span>
{/* Header - show when not hidden, or when in selection mode */}
{(!hideHeader || isSelectionMode) && (
<div className="flex items-center gap-3 p-3 border-b">
{isSelectionMode ? (
// Selection mode header
<>
<Button
variant="ghost"
size="icon"
onClick={clearSelection}
className="size-8"
title={t('Cancel')}
>
<X className="size-4" />
</Button>
<div className="flex items-center gap-2">
<Trash2 className="size-4 text-destructive" />
<span className="font-medium text-sm">{t('Delete')}</span>
</div>
<div className="flex-1" />
<Button
variant="outline"
size="sm"
onClick={deleteSelectedMessages}
disabled={selectedMessages.size === 0}
className="text-xs"
>
{t('Selected')} ({selectedMessages.size})
</Button>
<Button
variant="destructive"
size="sm"
onClick={deleteAllInConversation}
className="text-xs"
>
{t('All')}
</Button>
</>
) : (
// Normal header
<>
<UserAvatar userId={currentConversation} className="size-8" />
<div className="flex-1 min-w-0">
<div className="flex items-center gap-1.5">
<span className="font-medium text-sm truncate">{displayName}</span>
{isFollowing && (
<span title="Following">
<Users className="size-3 text-primary" />
</span>
)}
</div>
{profile?.nip05 && (
<span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
)}
</div>
{profile?.nip05 && (
<span className="text-xs text-muted-foreground truncate">{profile.nip05}</span>
)}
</div>
<Button
variant="ghost"
size="icon"
className="size-8"
title={t('Reload messages')}
onClick={reloadConversation}
disabled={isLoadingConversation}
>
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
</Button>
<Button
variant="ghost"
size="icon"
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
title={t('Conversation settings')}
onClick={() => {
setShowPulse(false)
clearNewConversationFlag()
setSettingsOpen(true)
}}
>
<Settings className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
<Trash2 className="size-4 mr-2" />
{t('Delete All')}
</DropdownMenuItem>
<DropdownMenuItem onClick={undeleteAllInConversation}>
<Undo2 className="size-4 mr-2" />
{t('Undelete All')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onBack && (
<Button
variant="ghost"
size="icon"
className="size-8"
title={t('Close conversation')}
onClick={onBack}
title={t('Reload messages')}
onClick={reloadConversation}
disabled={isLoadingConversation}
>
<X className="size-4" />
<RefreshCw className={cn('size-4', isLoadingConversation && 'animate-spin')} />
</Button>
)}
</>
)}
</div>
<Button
variant="ghost"
size="icon"
className={cn('size-8', showPulse && 'animate-pulse ring-2 ring-primary ring-offset-2')}
title={t('Conversation settings')}
onClick={() => {
setShowPulse(false)
clearNewConversationFlag()
setSettingsOpen(true)
}}
>
<Settings className="size-4" />
</Button>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" className="size-8">
<MoreVertical className="size-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem onClick={deleteAllInConversation} className="text-destructive focus:text-destructive">
<Trash2 className="size-4 mr-2" />
{t('Delete All')}
</DropdownMenuItem>
<DropdownMenuItem onClick={undeleteAllInConversation}>
<Undo2 className="size-4 mr-2" />
{t('Undelete All')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{onBack && (
<Button
variant="ghost"
size="icon"
className="size-8"
title={t('Close conversation')}
onClick={onBack}
>
<X className="size-4" />
</Button>
)}
</>
)}
</div>
)}
{/* Messages */}
<div className="flex-1 relative overflow-hidden">

View File

@@ -80,7 +80,7 @@ const NoteList = forwardRef<
const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy()
const { isEventDeleted } = useDeletedEvent()
const { offsetSelection } = useKeyboardNavigation()
const { offsetSelection, registerLoadMore, unregisterLoadMore } = useKeyboardNavigation()
const [events, setEvents] = useState<Event[]>([])
const [newEvents, setNewEvents] = useState<Event[]>([])
const [initialLoading, setInitialLoading] = useState(false)
@@ -370,6 +370,12 @@ const NoteList = forwardRef<
initialLoading
})
// Register load more callback for keyboard navigation
useEffect(() => {
registerLoadMore(navColumn, handleLoadMore)
return () => unregisterLoadMore(navColumn)
}, [navColumn, handleLoadMore, registerLoadMore, unregisterLoadMore])
const showNewEvents = useCallback(() => {
if (filteredNewEvents.length === 0) return
// Offset the selection by the number of new items being added at the top

View File

@@ -1,7 +1,9 @@
import UserAvatar from '@/components/UserAvatar'
import { BIG_RELAY_URLS } from '@/constants'
import { useNostr } from '@/providers/NostrProvider'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import client from '@/services/client.service'
import graphQueryService from '@/services/graph-query.service'
import { useEffect, useState } from 'react'
import { useTranslation } from 'react-i18next'
@@ -15,6 +17,55 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
if (!pubkey || !accountPubkey) return
const init = async () => {
const limit = isSmallScreen ? 3 : 5
// Try graph query first for depth-2 follows
const graphResult = await graphQueryService.queryFollowGraph(
BIG_RELAY_URLS,
accountPubkey,
2
)
if (graphResult?.pubkeys_by_depth && graphResult.pubkeys_by_depth.length >= 2) {
// Use graph query results - much more efficient
const directFollows = new Set(graphResult.pubkeys_by_depth[0] ?? [])
// Check which of user's follows also follow the target pubkey
const _followedBy: string[] = []
// We need to check if target pubkey is in each direct follow's follow list
// The graph query gives us all follows of follows at depth 2,
// but we need to know *which* direct follow has the target in their follows
// For now, we'll still need to do individual checks but can optimize with caching
// Alternative approach: Use followers query on the target
const followerResult = await graphQueryService.queryFollowerGraph(
BIG_RELAY_URLS,
pubkey,
1
)
if (followerResult?.pubkeys_by_depth?.[0]) {
// Followers of target pubkey
const targetFollowers = new Set(followerResult.pubkeys_by_depth[0])
// Find which of user's follows are followers of the target
for (const following of directFollows) {
if (following === pubkey) continue
if (targetFollowers.has(following)) {
_followedBy.push(following)
if (_followedBy.length >= limit) break
}
}
}
if (_followedBy.length > 0) {
setFollowedBy(_followedBy)
return
}
}
// Fallback to traditional method
const followings = (await client.fetchFollowings(accountPubkey)).reverse()
const followingsOfFollowings = await Promise.all(
followings.map(async (following) => {
@@ -22,7 +73,6 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
})
)
const _followedBy: string[] = []
const limit = isSmallScreen ? 3 : 5
for (const [index, following] of followings.entries()) {
if (following === pubkey) continue
if (followingsOfFollowings[index].includes(pubkey)) {
@@ -35,7 +85,7 @@ export default function FollowedBy({ pubkey }: { pubkey: string }) {
setFollowedBy(_followedBy)
}
init()
}, [pubkey, accountPubkey])
}, [pubkey, accountPubkey, isSmallScreen])
if (followedBy.length === 0) return null

View File

@@ -0,0 +1,71 @@
import { Button } from '@/components/ui/button'
import { X } from 'lucide-react'
import QrScanner from 'qr-scanner'
import { useCallback, useEffect, useRef, useState } from 'react'
import { useTranslation } from 'react-i18next'
export default function QrScannerModal({
onScan,
onClose
}: {
onScan: (result: string) => void
onClose: () => void
}) {
const { t } = useTranslation()
const videoRef = useRef<HTMLVideoElement>(null)
const scannerRef = useRef<QrScanner | null>(null)
const [error, setError] = useState<string | null>(null)
const handleScan = useCallback(
(result: QrScanner.ScanResult) => {
onScan(result.data)
onClose()
},
[onScan, onClose]
)
useEffect(() => {
if (!videoRef.current) return
const scanner = new QrScanner(videoRef.current, handleScan, {
preferredCamera: 'environment',
highlightScanRegion: true,
highlightCodeOutline: true
})
scannerRef.current = scanner
scanner.start().catch(() => {
setError(t('Failed to access camera'))
})
return () => {
scanner.destroy()
}
}, [handleScan, t])
return (
<div className="fixed inset-0 z-50 bg-black/80 flex items-center justify-center">
<div className="relative w-full max-w-sm mx-4">
<Button
variant="ghost"
size="icon"
className="absolute -top-12 right-0 text-white hover:bg-white/20"
onClick={onClose}
>
<X className="h-6 w-6" />
</Button>
<div className="rounded-lg overflow-hidden bg-black">
{error ? (
<div className="p-8 text-center text-destructive">{error}</div>
) : (
<video ref={videoRef} className="w-full" />
)}
</div>
<p className="text-center text-white/70 text-sm mt-4">
{t('Point camera at QR code')}
</p>
</div>
</div>
)
}

View File

@@ -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>

View File

@@ -0,0 +1,36 @@
import { Button } from '@/components/ui/button'
import { cn } from '@/lib/utils'
import { useKeyboardNavigation } from '@/providers/KeyboardNavigationProvider'
import { Keyboard } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export default function KeyboardModeButton({ collapse }: { collapse: boolean }) {
const { t } = useTranslation()
const { isEnabled, toggleKeyboardMode } = useKeyboardNavigation()
return (
<Button
className={cn(
'flex shadow-none items-center transition-colors duration-500 bg-transparent m-0 rounded-lg gap-2 text-sm font-semibold',
collapse
? 'w-12 h-12 p-3 [&_svg]:size-full'
: 'justify-start w-full h-auto py-2 px-3 [&_svg]:size-5',
isEnabled && 'text-primary hover:text-primary bg-primary/10 hover:bg-primary/10'
)}
variant="ghost"
title={t('Toggle keyboard navigation (⇧K)')}
onClick={toggleKeyboardMode}
>
<Keyboard />
{!collapse && (
<div className="flex items-center gap-2">
<span>{t('Keyboard')}</span>
<kbd className="text-xs px-1.5 py-0.5 rounded bg-muted text-muted-foreground border">K</kbd>
</div>
)}
{collapse && (
<span className="sr-only">{t('Toggle keyboard navigation')}</span>
)}
</Button>
)
}

View File

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

View File

@@ -111,8 +111,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 +126,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' }} />
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">R</kbd>
</span>
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</>
) : (
<>
<SmilePlus />
<span className="relative">
<SmilePlus />
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">R</kbd>
</span>
{!!likeCount && <div className="text-sm">{formatCount(likeCount)}</div>}
</>
)}

View File

@@ -56,7 +56,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 +65,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 />
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">r</kbd>
</span>
{!!replyCount && <div className="text-sm">{formatCount(replyCount)}</div>}
</button>
<PostEditor parentStuff={stuff} open={open} setOpen={setOpen} />

View File

@@ -77,11 +77,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 +91,10 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
}
}}
>
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
<span className="relative">
{reposting ? <Loader className="animate-spin" /> : <Repeat />}
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">p</kbd>
</span>
{!!repostCount && <div className="text-sm">{formatCount(repostCount)}</div>}
</button>
)
@@ -108,10 +111,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 +188,12 @@ export default function RepostButton({ stuff }: { stuff: Event | string }) {
setIsPostDialogOpen(true)
})
}}
data-action="quote"
>
<PencilLine /> {t('Quote')}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
{quoteButton}
{postEditor}
</>
)

View File

@@ -135,10 +135,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 +147,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' : ''} />
)}
<kbd className="absolute -top-1.5 -right-2 text-[9px] font-mono opacity-50 group-hover:opacity-100">z</kbd>
</span>
{!!zapAmount && <div className="text-sm">{formatAmount(zapAmount)}</div>}
</button>
{event && (

View File

@@ -47,6 +47,7 @@ export const StorageKey = {
DM_CONVERSATION_FILTER: 'dmConversationFilter',
DM_ENCRYPTION_PREFERENCES: 'dmEncryptionPreferences',
DM_LAST_SEEN_TIMESTAMP: 'dmLastSeenTimestamp',
GRAPH_QUERIES_ENABLED: 'graphQueriesEnabled',
DEFAULT_SHOW_NSFW: 'defaultShowNsfw', // deprecated
PINNED_PUBKEYS: 'pinnedPubkeys', // deprecated
MEDIA_UPLOAD_SERVICE: 'mediaUploadService', // deprecated

View File

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

View File

@@ -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}
/>
)}
</>
)
})

File diff suppressed because it is too large Load Diff

View File

@@ -127,18 +127,27 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
useEffect(() => {
const init = async () => {
if (hasNostrLoginHash()) {
return await loginByNostrLoginHash()
await loginByNostrLoginHash()
setIsInitialized(true)
return
}
const accounts = storage.getAccounts()
const act = storage.getCurrentAccount() ?? accounts[0] // auto login the first account
if (!act) return
if (!act) {
setIsInitialized(true)
return
}
// Set account immediately so feed can load based on pubkey
// while signer initializes in the background
setAccount({ pubkey: act.pubkey, signerType: act.signerType })
setIsInitialized(true)
// Initialize signer in background - feed doesn't need it to load
await loginWithAccountPointer(act)
}
init().then(() => {
setIsInitialized(true)
})
init()
const handleHashChange = () => {
if (hasNostrLoginHash()) {

View File

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

View File

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

View File

@@ -1,7 +1,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,7 @@ class LocalStorageService {
private nsfwDisplayPolicy: TNsfwDisplayPolicy = NSFW_DISPLAY_POLICY.HIDE_CONTENT
private preferNip44: boolean = false
private dmConversationFilter: 'all' | 'follows' = 'all'
private graphQueriesEnabled: boolean = true
constructor() {
if (!LocalStorageService.instance) {
@@ -124,10 +123,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 +248,8 @@ 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'
// Clean up deprecated data
window.localStorage.removeItem(StorageKey.PINNED_PUBKEYS)
@@ -439,7 +436,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 +642,15 @@ 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())
}
}
const instance = new LocalStorageService()

View File

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

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

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

View File

@@ -40,6 +40,13 @@ export type TRelayList = {
originalRelays: TMailboxRelay[]
}
export type TGraphQueryCapability = {
enabled: boolean
max_depth: number
max_results: number
methods: string[]
}
export type TRelayInfo = {
url: string
shortUrl: string
@@ -57,6 +64,7 @@ export type TRelayInfo = {
auth_required?: boolean
payment_required?: boolean
}
graph_query?: TGraphQueryCapability
}
export type TWebMetadata = {