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() {
- +

{t('Actions:')}

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 = {