diff --git a/package.json b/package.json
index 4bc07945..2dce8881 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/src/components/Help/index.tsx b/src/components/Help/index.tsx
index f22aab49..7e42ae33 100644
--- a/src/components/Help/index.tsx
+++ b/src/components/Help/index.tsx
@@ -33,7 +33,7 @@ export default function Help() {
diff --git a/src/components/Profile/FollowedBy.tsx b/src/components/Profile/FollowedBy.tsx
index 4e1994d2..c6ac612f 100644
--- a/src/components/Profile/FollowedBy.tsx
+++ b/src/components/Profile/FollowedBy.tsx
@@ -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
diff --git a/src/components/Settings/index.tsx b/src/components/Settings/index.tsx
index ab2f5fe4..bdb51066 100644
--- a/src/components/Settings/index.tsx
+++ b/src/components/Settings/index.tsx
@@ -254,6 +254,7 @@ 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())
@@ -738,6 +739,25 @@ export default function Settings() {
}}
/>
+
+
+
+
+ {t('Use graph queries for faster follow/thread loading on supported relays')}
+
+
+ {
+ storage.setGraphQueriesEnabled(checked)
+ setGraphQueriesEnabled(checked)
+ dispatchSettingsChanged()
+ }}
+ />
+
diff --git a/src/constants.ts b/src/constants.ts
index aa501923..b02f77c3 100644
--- a/src/constants.ts
+++ b/src/constants.ts
@@ -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
diff --git a/src/hooks/useFetchFollowGraph.tsx b/src/hooks/useFetchFollowGraph.tsx
new file mode 100644
index 00000000..b038e81f
--- /dev/null
+++ b/src/hooks/useFetchFollowGraph.tsx
@@ -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
([])
+ const [usedGraphQuery, setUsedGraphQuery] = useState(false)
+ const [isLoading, setIsLoading] = useState(true)
+ const [error, setError] = useState(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 {
+ 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()
+ 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))
+}
diff --git a/src/providers/KeyboardNavigationProvider.tsx b/src/providers/KeyboardNavigationProvider.tsx
index aef4591a..94aae487 100644
--- a/src/providers/KeyboardNavigationProvider.tsx
+++ b/src/providers/KeyboardNavigationProvider.tsx
@@ -32,7 +32,6 @@ export type NavigationIntent =
| 'back'
| 'cancel'
| 'pageUp'
- | 'pageDown'
| 'nextAction'
| 'prevAction'
@@ -298,16 +297,25 @@ export function KeyboardNavigationProvider({
const scrollToCenter = useCallback((element: HTMLElement) => {
// Find the scrollable container (look for overflow-y: auto/scroll)
+ // Stop at document.body - if we reach it, use window scrolling instead
let scrollContainer: HTMLElement | null = element.parentElement
- while (scrollContainer) {
+ while (scrollContainer && scrollContainer !== document.body && scrollContainer !== document.documentElement) {
const style = window.getComputedStyle(scrollContainer)
const overflowY = style.overflowY
if (overflowY === 'auto' || overflowY === 'scroll') {
- break
+ // Verify this container is actually scrollable (has scrollable content)
+ if (scrollContainer.scrollHeight > scrollContainer.clientHeight) {
+ break
+ }
}
scrollContainer = scrollContainer.parentElement
}
+ // If we reached body/documentElement, use window scrolling
+ if (!scrollContainer || scrollContainer === document.body || scrollContainer === document.documentElement) {
+ scrollContainer = null
+ }
+
const headerOffset = 100 // Account for sticky headers
if (scrollContainer) {
@@ -349,21 +357,35 @@ export function KeyboardNavigationProvider({
})
}
} else {
- // Fallback to window scrolling
+ // Window scrolling (mobile, single column mode)
const rect = element.getBoundingClientRect()
const viewportHeight = window.innerHeight
const visibleHeight = viewportHeight - headerOffset
// If element is taller than visible area, scroll to show its top
if (rect.height > visibleHeight) {
- element.scrollIntoView({ behavior: 'smooth', block: 'start' })
+ const targetScrollTop = window.scrollY + rect.top - headerOffset
+ window.scrollTo({
+ top: Math.max(0, targetScrollTop),
+ behavior: 'smooth'
+ })
return
}
+ // Check if already visible
const isVisible = rect.top >= headerOffset && rect.bottom <= viewportHeight - 50
if (!isVisible) {
- element.scrollIntoView({ behavior: 'smooth', block: 'center' })
+ // Calculate target scroll to center the element
+ const elementMiddle = rect.top + rect.height / 2
+ const viewportMiddle = viewportHeight / 2
+ const scrollAdjustment = elementMiddle - viewportMiddle
+ const newScrollTop = window.scrollY + scrollAdjustment
+
+ window.scrollTo({
+ top: Math.max(0, newScrollTop),
+ behavior: 'smooth'
+ })
}
}
}, [])
@@ -439,11 +461,18 @@ export function KeyboardNavigationProvider({
// ============================================================================
const getAvailableColumns = useCallback((): TNavigationColumn[] => {
- if (isSmallScreen || enableSingleColumnLayout) {
+ if (isSmallScreen) {
+ // Mobile: sidebar is in a drawer, only one column visible at a time
if (sidebarDrawerOpen) return [0]
if (secondaryStackLength > 0) return [2]
return [1]
}
+ if (enableSingleColumnLayout) {
+ // Single column desktop: sidebar is always visible alongside main content
+ if (secondaryStackLength > 0) return [0, 2]
+ return [0, 1]
+ }
+ // Two column desktop
if (secondaryStackLength > 0) return [0, 1, 2]
return [0, 1]
}, [isSmallScreen, enableSingleColumnLayout, sidebarDrawerOpen, secondaryStackLength])
@@ -544,31 +573,52 @@ export function KeyboardNavigationProvider({
[setSelectedIndex, scrollToCenter]
)
- const jumpToEdge = useCallback(
- (edge: 'top' | 'bottom') => {
- const currentColumn = activeColumnRef.current
- const items = itemsRef.current[currentColumn]
+ const jumpToTop = useCallback(() => {
+ // For primary feed, use column 1; for secondary, use column 2
+ // Fall back to current column if it has items
+ let targetColumn = activeColumnRef.current
+
+ // Check if current column has items, if not try column 1 (primary)
+ if (itemsRef.current[targetColumn].size === 0) {
+ if (itemsRef.current[1].size > 0) {
+ targetColumn = 1
+ } else if (itemsRef.current[2].size > 0) {
+ targetColumn = 2
+ }
+ }
+
+ const items = itemsRef.current[targetColumn]
if (items.size === 0) return
const indices = Array.from(items.keys()).sort((a, b) => a - b)
if (indices.length === 0) return
- const newIdx = edge === 'top' ? 0 : indices.length - 1
- const newItemIndex = indices[newIdx]
+ const newItemIndex = indices[0]
if (newItemIndex === undefined) return
- setSelectedIndex(currentColumn, newItemIndex)
+ // Set active column and selection immediately
+ setActiveColumn(targetColumn)
+ setSelectedIndex(targetColumn, newItemIndex)
+ // Scroll to top of the page/container
+ window.scrollTo({ top: 0, behavior: 'smooth' })
+
+ // Also scroll any parent containers to top
const item = items.get(newItemIndex)
if (item?.ref.current) {
- // For edges, use start/end positioning
- item.ref.current.scrollIntoView({
- behavior: 'smooth',
- block: edge === 'top' ? 'start' : 'end'
- })
+ let scrollContainer: HTMLElement | null = item.ref.current.parentElement
+ while (scrollContainer && scrollContainer !== document.body) {
+ const style = window.getComputedStyle(scrollContainer)
+ if ((style.overflowY === 'auto' || style.overflowY === 'scroll') &&
+ scrollContainer.scrollHeight > scrollContainer.clientHeight) {
+ scrollContainer.scrollTo({ top: 0, behavior: 'smooth' })
+ break
+ }
+ scrollContainer = scrollContainer.parentElement
+ }
}
},
- [setSelectedIndex]
+ [setSelectedIndex, setActiveColumn]
)
// ============================================================================
@@ -759,10 +809,7 @@ export function KeyboardNavigationProvider({
moveColumn(1)
break
case 'pageUp':
- jumpToEdge('top')
- break
- case 'pageDown':
- jumpToEdge('bottom')
+ jumpToTop()
break
case 'activate':
handleEnter()
@@ -781,7 +828,7 @@ export function KeyboardNavigationProvider({
break
}
},
- [moveItem, moveColumn, jumpToEdge, handleEnter, handleBack, handleEscape, cycleAction]
+ [moveItem, moveColumn, jumpToTop, handleEnter, handleBack, handleEscape, cycleAction]
)
// Keep the ref updated with the latest handleDefaultIntent
@@ -841,9 +888,6 @@ export function KeyboardNavigationProvider({
case 'PageUp':
intent = 'pageUp'
break
- case 'PageDown':
- intent = 'pageDown'
- break
case 'Escape':
intent = 'cancel'
break
@@ -943,9 +987,16 @@ export function KeyboardNavigationProvider({
useEffect(() => {
const available = getAvailableColumns()
if (!available.includes(activeColumn)) {
- setActiveColumn(available[0])
+ // When current column becomes unavailable, find the best fallback:
+ // - If coming from column 2 (secondary), prefer column 1 (primary) over column 0 (sidebar)
+ // - Otherwise use the first available column
+ if (activeColumn === 2 && available.includes(1)) {
+ setActiveColumn(1)
+ } else {
+ setActiveColumn(available[0])
+ }
}
- }, [getAvailableColumns, activeColumn])
+ }, [getAvailableColumns, activeColumn, setActiveColumn])
// Track secondary panel changes to switch focus
const prevSecondaryStackLength = useRef(secondaryStackLength)
@@ -971,8 +1022,11 @@ export function KeyboardNavigationProvider({
}
}
} else if (secondaryStackLength < prevSecondaryStackLength.current && isEnabled) {
- // Secondary closed - return to primary
- setActiveColumn(1)
+ // Secondary closed - return to primary only if we were in the secondary column
+ // Don't move focus if user is already in sidebar or primary
+ if (activeColumnRef.current === 2) {
+ setActiveColumn(1)
+ }
}
prevSecondaryStackLength.current = secondaryStackLength
}, [secondaryStackLength, isEnabled, setSelectedIndex, scrollToCenter, setActiveColumn])
diff --git a/src/services/graph-cache.service.ts b/src/services/graph-cache.service.ts
new file mode 100644
index 00000000..428a8b37
--- /dev/null
+++ b/src/services/graph-cache.service.ts
@@ -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 {
+ data: T
+ timestamp: number
+}
+
+class GraphCacheService {
+ static instance: GraphCacheService
+ private db: IDBDatabase | null = null
+ private dbPromise: Promise | null = null
+
+ public static getInstance(): GraphCacheService {
+ if (!GraphCacheService.instance) {
+ GraphCacheService.instance = new GraphCacheService()
+ }
+ return GraphCacheService.instance
+ }
+
+ private async getDB(): Promise {
+ 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 {
+ try {
+ const db = await this.getDB()
+ const key = `${pubkey}:${depth}`
+ const entry: CachedEntry = {
+ 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 {
+ 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 | 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 {
+ try {
+ const db = await this.getDB()
+ const entry: CachedEntry = {
+ 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 {
+ 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 | 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 {
+ try {
+ const db = await this.getDB()
+ const entry: CachedEntry = {
+ 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 {
+ 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
+ | 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 {
+ 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 {
+ 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
diff --git a/src/services/graph-query.service.ts b/src/services/graph-query.service.ts
new file mode 100644
index 00000000..c4d31138
--- /dev/null
+++ b/src/services/graph-query.service.ts
@@ -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()
+ private capabilityFetchPromises = new Map>()
+
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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 {
+ 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(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 {
+ 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 {
+ 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 {
+ 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 {
+ 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
diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts
index 4f004290..1991523b 100644
--- a/src/services/local-storage.service.ts
+++ b/src/services/local-storage.service.ts
@@ -60,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) {
@@ -247,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)
@@ -639,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()
diff --git a/src/services/thread.service.ts b/src/services/thread.service.ts
index f4a4b7a4..4b2e08c1 100644
--- a/src/services/thread.service.ts
+++ b/src/services/thread.service.ts
@@ -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 {
+ 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 }
diff --git a/src/types/graph.d.ts b/src/types/graph.d.ts
new file mode 100644
index 00000000..6017529d
--- /dev/null
+++ b/src/types/graph.d.ts
@@ -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
+}
diff --git a/src/types/index.d.ts b/src/types/index.d.ts
index 86fd46e5..7e1484a1 100644
--- a/src/types/index.d.ts
+++ b/src/types/index.d.ts
@@ -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 = {