From e695529548db595ade3f6d0068c2c1e8f53d6ef3 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Fri, 31 Oct 2025 06:53:36 +0100 Subject: [PATCH] implemented cache relays --- src/components/CacheRelaysSetting/index.tsx | 216 ++++++++++++ .../MailboxSetting/DiscoveredRelays.tsx | 12 +- src/components/Tabs/index.tsx | 6 +- src/constants.ts | 1 + src/lib/draft-event.ts | 9 + .../secondary/RelaySettingsPage/index.tsx | 8 + src/providers/NostrProvider/index.tsx | 46 ++- src/services/client.service.ts | 312 ++++++++++++++---- src/services/indexed-db.service.ts | 35 +- 9 files changed, 571 insertions(+), 74 deletions(-) create mode 100644 src/components/CacheRelaysSetting/index.tsx diff --git a/src/components/CacheRelaysSetting/index.tsx b/src/components/CacheRelaysSetting/index.tsx new file mode 100644 index 0000000..4ab23ad --- /dev/null +++ b/src/components/CacheRelaysSetting/index.tsx @@ -0,0 +1,216 @@ +import { Button } from '@/components/ui/button' +import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' +import { useNostr } from '@/providers/NostrProvider' +import { TMailboxRelay, TMailboxRelayScope } from '@/types' +import { useEffect, useState } from 'react' +import { useTranslation } from 'react-i18next' +import { + DndContext, + closestCenter, + KeyboardSensor, + PointerSensor, + TouchSensor, + useSensor, + useSensors, + DragEndEvent +} from '@dnd-kit/core' +import { + arrayMove, + SortableContext, + sortableKeyboardCoordinates, + verticalListSortingStrategy +} from '@dnd-kit/sortable' +import { restrictToVerticalAxis, restrictToParentElement } from '@dnd-kit/modifiers' +import MailboxRelay from '../MailboxSetting/MailboxRelay' +import NewMailboxRelayInput from '../MailboxSetting/NewMailboxRelayInput' +import RelayCountWarning from '../MailboxSetting/RelayCountWarning' +import DiscoveredRelays from '../MailboxSetting/DiscoveredRelays' +import { createCacheRelaysDraftEvent } from '@/lib/draft-event' +import { getRelayListFromEvent } from '@/lib/event-metadata' +import { showPublishingFeedback, showSimplePublishSuccess, showPublishingError } from '@/lib/publishing-feedback' +import { CloudUpload, Loader } from 'lucide-react' + +export default function CacheRelaysSetting() { + const { t } = useTranslation() + const { pubkey, cacheRelayListEvent, checkLogin, publish, updateCacheRelayListEvent } = useNostr() + const [relays, setRelays] = useState([]) + const [hasChange, setHasChange] = useState(false) + const [pushing, setPushing] = useState(false) + + const sensors = useSensors( + useSensor(PointerSensor, { + activationConstraint: { + distance: 8 + } + }), + useSensor(TouchSensor, { + activationConstraint: { + delay: 200, + tolerance: 8 + } + }), + useSensor(KeyboardSensor, { + coordinateGetter: sortableKeyboardCoordinates + }) + ) + + function handleDragEnd(event: DragEndEvent) { + const { active, over } = event + + if (active.id !== over?.id) { + const oldIndex = relays.findIndex((relay) => relay.url === active.id) + const newIndex = relays.findIndex((relay) => relay.url === over?.id) + + if (oldIndex !== -1 && newIndex !== -1) { + setRelays((relays) => arrayMove(relays, oldIndex, newIndex)) + setHasChange(true) + } + } + } + + useEffect(() => { + if (!cacheRelayListEvent) { + setRelays([]) + setHasChange(false) + return + } + + const cacheRelayList = getRelayListFromEvent(cacheRelayListEvent) + setRelays(cacheRelayList.originalRelays) + setHasChange(false) + }, [cacheRelayListEvent]) + + if (!pubkey) { + return ( +
+ +
+ ) + } + + if (cacheRelayListEvent === undefined) { + return
{t('loading...')}
+ } + + const changeCacheRelayScope = (url: string, scope: TMailboxRelayScope) => { + setRelays((prev) => prev.map((r) => (r.url === url ? { ...r, scope } : r))) + setHasChange(true) + } + + const removeCacheRelay = (url: string) => { + setRelays((prev) => prev.filter((r) => r.url !== url)) + setHasChange(true) + } + + const saveNewCacheRelay = (url: string) => { + if (url === '') return null + const normalizedUrl = normalizeUrl(url) + if (!normalizedUrl) { + return t('Invalid relay URL') + } + // Cache relays must be local network URLs only + if (!isLocalNetworkUrl(normalizedUrl)) { + return t('Cache relays must be local network URLs only (e.g., ws://localhost:4869 or ws://127.0.0.1:4869)') + } + if (relays.some((r) => r.url === normalizedUrl)) { + return t('Relay already exists') + } + setRelays([...relays, { url: normalizedUrl, scope: 'both' }]) + setHasChange(true) + return null + } + + const handleAddDiscoveredRelays = (newRelays: TMailboxRelay[]) => { + // Filter to only local network URLs for cache relays + const localRelays = newRelays.filter(newRelay => isLocalNetworkUrl(newRelay.url)) + const relaysToAdd = localRelays.filter( + newRelay => !relays.some(r => r.url === newRelay.url) + ) + if (relaysToAdd.length > 0) { + setRelays([...relays, ...relaysToAdd]) + setHasChange(true) + } + } + + const save = async () => { + if (!pubkey) return + + setPushing(true) + try { + const event = createCacheRelaysDraftEvent(relays) + const result = await publish(event) + await updateCacheRelayListEvent(result) + setHasChange(false) + + // Show publishing feedback + if ((result as any).relayStatuses) { + showPublishingFeedback({ + success: true, + relayStatuses: (result as any).relayStatuses, + successCount: (result as any).relayStatuses.filter((s: any) => s.success).length, + totalCount: (result as any).relayStatuses.length + }, { + message: t('Cache relays saved'), + duration: 6000 + }) + } else { + showSimplePublishSuccess(t('Cache relays saved')) + } + } catch (error) { + console.error('Failed to save cache relays:', error) + // Show error feedback + if (error instanceof Error && (error as any).relayStatuses) { + showPublishingFeedback({ + success: false, + relayStatuses: (error as any).relayStatuses, + successCount: (error as any).relayStatuses.filter((s: any) => s.success).length, + totalCount: (error as any).relayStatuses.length + }, { + message: error.message || t('Failed to save cache relays'), + duration: 6000 + }) + } else { + showPublishingError(error instanceof Error ? error : new Error(t('Failed to save cache relays'))) + } + } finally { + setPushing(false) + } + } + + return ( +
+
+
{t('Cache relays are used to store and retrieve events locally. These relays are merged with your inbox and outbox relays.')}
+
+ + + + + r.url)} strategy={verticalListSortingStrategy}> +
+ {relays.map((relay) => ( + + ))} +
+
+
+ +
+ ) +} + diff --git a/src/components/MailboxSetting/DiscoveredRelays.tsx b/src/components/MailboxSetting/DiscoveredRelays.tsx index 90b6715..d5fc90c 100644 --- a/src/components/MailboxSetting/DiscoveredRelays.tsx +++ b/src/components/MailboxSetting/DiscoveredRelays.tsx @@ -1,6 +1,6 @@ import { Button } from '@/components/ui/button' import { Checkbox } from '@/components/ui/checkbox' -import { normalizeUrl } from '@/lib/url' +import { normalizeUrl, isLocalNetworkUrl } from '@/lib/url' import { getRelaysFromNip07Extension, verifyNip05 } from '@/lib/nip05' import { useNostr } from '@/providers/NostrProvider' import { TMailboxRelay } from '@/types' @@ -15,7 +15,7 @@ interface DiscoveredRelay { selected: boolean } -export default function DiscoveredRelays({ onAdd }: { onAdd: (relays: TMailboxRelay[]) => void }) { +export default function DiscoveredRelays({ onAdd, localOnly = false }: { onAdd: (relays: TMailboxRelay[]) => void; localOnly?: boolean }) { const { t } = useTranslation() const { profile, account } = useNostr() const [discoveredRelays, setDiscoveredRelays] = useState([]) @@ -79,7 +79,13 @@ export default function DiscoveredRelays({ onAdd }: { onAdd: (relays: TMailboxRe // Note: Bunker relays are from the bunker connection URL itself // We could add logic here to extract relays from the bunker URL if needed - setDiscoveredRelays(Array.from(discovered.values())) + // Filter to only local relays if localOnly is true + let discoveredArray = Array.from(discovered.values()) + if (localOnly) { + discoveredArray = discoveredArray.filter(relay => isLocalNetworkUrl(relay.url)) + } + + setDiscoveredRelays(discoveredArray) } catch (error) { console.error('Error discovering relays:', error) setErrorMsg(t('Failed to discover relays')) diff --git a/src/components/Tabs/index.tsx b/src/components/Tabs/index.tsx index 73c1c51..c5d5154 100644 --- a/src/components/Tabs/index.tsx +++ b/src/components/Tabs/index.tsx @@ -2,7 +2,6 @@ import { cn } from '@/lib/utils' import { useDeepBrowsing } from '@/providers/DeepBrowsingProvider' import { ReactNode, useEffect, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' -import { ScrollArea, ScrollBar } from '../ui/scroll-area' type TabDefinition = { value: string @@ -91,7 +90,7 @@ export default function Tabs({ deepBrowsing && lastScrollTop > threshold ? '-translate-y-[calc(100%+12rem)]' : '' )} > - +
{tabs.map((tab, index) => (
- - +
{options &&
{options}
}
) diff --git a/src/constants.ts b/src/constants.ts index ddfd8a7..d9d26f8 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -130,6 +130,7 @@ export const ExtendedKind = { FAVORITE_RELAYS: 10012, BLOCKED_RELAYS: 10006, BLOSSOM_SERVER_LIST: 10063, + CACHE_RELAYS: 10432, RELAY_REVIEW: 31987, GROUP_METADATA: 39000, GROUP_LIST: 10009, // NIP-51 Group List diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index a9c848b..c6432bd 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -437,6 +437,15 @@ export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraf } } +export function createCacheRelaysDraftEvent(mailboxRelays: TMailboxRelay[]): TDraftEvent { + return { + kind: ExtendedKind.CACHE_RELAYS, + content: '', + tags: mailboxRelays.map(({ url, scope }) => buildRTag(url, scope)), + created_at: dayjs().unix() + } +} + export function createFollowListDraftEvent(tags: string[][], content?: string): TDraftEvent { return { kind: kinds.Contacts, diff --git a/src/pages/secondary/RelaySettingsPage/index.tsx b/src/pages/secondary/RelaySettingsPage/index.tsx index 6297c78..576f2ac 100644 --- a/src/pages/secondary/RelaySettingsPage/index.tsx +++ b/src/pages/secondary/RelaySettingsPage/index.tsx @@ -1,5 +1,6 @@ import MailboxSetting from '@/components/MailboxSetting' import FavoriteRelaysSetting from '@/components/FavoriteRelaysSetting' +import CacheRelaysSetting from '@/components/CacheRelaysSetting' import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs' import SecondaryPageLayout from '@/layouts/SecondaryPageLayout' import { forwardRef, useEffect, useState } from 'react' @@ -14,6 +15,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: case '#mailbox': setTabValue('mailbox') break + case '#cache-relays': + setTabValue('cache-relays') + break case '#favorite-relays': setTabValue('favorite-relays') break @@ -26,6 +30,7 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: {t('Favorite Relays')} {t('Read & Write Relays')} + {t('Cache Relays')} @@ -33,6 +38,9 @@ const RelaySettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: + + + ) diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index 6c2dc0f..d96cd74 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -51,6 +51,7 @@ type TNostrContext = { profile: TProfile | null profileEvent: Event | null relayList: TRelayList | null + cacheRelayListEvent: Event | null followListEvent: Event | null muteListEvent: Event | null bookmarkListEvent: Event | null @@ -83,6 +84,7 @@ type TNostrContext = { startLogin: () => void checkLogin: (cb?: () => T) => Promise updateRelayListEvent: (relayListEvent: Event) => Promise + updateCacheRelayListEvent: (cacheRelayListEvent: Event) => Promise updateProfileEvent: (profileEvent: Event) => Promise updateFollowListEvent: (followListEvent: Event) => Promise updateMuteListEvent: (muteListEvent: Event, privateTags: string[][]) => Promise @@ -156,6 +158,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { }, [signer]) const [profileEvent, setProfileEvent] = useState(null) const [relayList, setRelayList] = useState(null) + const [cacheRelayListEvent, setCacheRelayListEvent] = useState(null) const [followListEvent, setFollowListEvent] = useState(null) const [muteListEvent, setMuteListEvent] = useState(null) const [bookmarkListEvent, setBookmarkListEvent] = useState(null) @@ -228,6 +231,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const [ storedRelayListEvent, + storedCacheRelayListEvent, storedProfileEvent, storedFollowListEvent, storedMuteListEvent, @@ -237,6 +241,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { storedUserEmojiListEvent ] = await Promise.all([ indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList), + indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.CACHE_RELAYS), indexedDb.getReplaceableEvent(account.pubkey, kinds.Metadata), indexedDb.getReplaceableEvent(account.pubkey, kinds.Contacts), indexedDb.getReplaceableEvent(account.pubkey, kinds.Mutelist), @@ -283,17 +288,32 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { setUserEmojiListEvent(storedUserEmojiListEvent) } - const relayListEvents = await client.fetchEvents(BIG_RELAY_URLS, { - kinds: [kinds.RelayList], - authors: [account.pubkey] - }) + const [relayListEvents, cacheRelayListEvents] = await Promise.all([ + client.fetchEvents(BIG_RELAY_URLS, { + kinds: [kinds.RelayList], + authors: [account.pubkey] + }), + client.fetchEvents(BIG_RELAY_URLS, { + kinds: [ExtendedKind.CACHE_RELAYS], + authors: [account.pubkey] + }) + ]) const relayListEvent = getLatestEvent(relayListEvents) ?? storedRelayListEvent + const cacheRelayListEvent = getLatestEvent(cacheRelayListEvents) ?? storedCacheRelayListEvent const relayList = getRelayListFromEvent(relayListEvent, blockedRelays) if (relayListEvent) { client.updateRelayListCache(relayListEvent) await indexedDb.putReplaceableEvent(relayListEvent) } - setRelayList(relayList) + if (cacheRelayListEvent) { + await indexedDb.putReplaceableEvent(cacheRelayListEvent) + setCacheRelayListEvent(cacheRelayListEvent) + } else { + setCacheRelayListEvent(null) + } + // Fetch updated relay list (which merges both 10002 and 10432) + const mergedRelayList = await client.fetchRelayList(account.pubkey) + setRelayList(mergedRelayList) // Note: Deletion event fetching is now handled locally by individual components // for better performance and accuracy @@ -872,8 +892,18 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } const updateRelayListEvent = async (relayListEvent: Event) => { - const newRelayList = await indexedDb.putReplaceableEvent(relayListEvent) - setRelayList(getRelayListFromEvent(newRelayList)) + await indexedDb.putReplaceableEvent(relayListEvent) + // Fetch updated relay list (which merges both 10002 and 10432) + const mergedRelayList = await client.fetchRelayList(account?.pubkey || '') + setRelayList(mergedRelayList) + } + + const updateCacheRelayListEvent = async (cacheRelayListEvent: Event) => { + const newCacheRelayList = await indexedDb.putReplaceableEvent(cacheRelayListEvent) + setCacheRelayListEvent(newCacheRelayList) + // Fetch updated relay list (which merges both 10002 and 10432) + const mergedRelayList = await client.fetchRelayList(account?.pubkey || '') + setRelayList(mergedRelayList) } const updateProfileEvent = async (profileEvent: Event) => { @@ -956,6 +986,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { profile, profileEvent, relayList, + cacheRelayListEvent, followListEvent, muteListEvent, bookmarkListEvent, @@ -985,6 +1016,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { checkLogin, signEvent, updateRelayListEvent, + updateCacheRelayListEvent, updateProfileEvent, updateFollowListEvent, updateMuteListEvent, diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 06fcee4..32b3764 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1,4 +1,4 @@ -import { BIG_RELAY_URLS, ExtendedKind } from '@/constants' +import { BIG_RELAY_URLS, ExtendedKind, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants' import { compareEvents, getReplaceableCoordinate, @@ -10,7 +10,7 @@ import { formatPubkey, isValidPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib import { getPubkeysFromPTags, getServersFromServerTags, tagNameEquals } from '@/lib/tag' import { isLocalNetworkUrl, isWebsocketUrl, normalizeUrl } from '@/lib/url' import { isSafari } from '@/lib/utils' -import { ISigner, TProfile, TPublishOptions, TRelayList, TSubRequestFilter } from '@/types' +import { ISigner, TProfile, TPublishOptions, TRelayList, TMailboxRelay, TSubRequestFilter } from '@/types' import { sha256 } from '@noble/hashes/sha2' import DataLoader from 'dataloader' import dayjs from 'dayjs' @@ -122,13 +122,14 @@ class ClientService extends EventTarget { if ( [ kinds.RelayList, + ExtendedKind.CACHE_RELAYS, kinds.Contacts, ExtendedKind.FAVORITE_RELAYS, ExtendedKind.BLOSSOM_SERVER_LIST, ExtendedKind.RELAY_REVIEW ].includes(event.kind) ) { - _additionalRelayUrls.push(...BIG_RELAY_URLS) + _additionalRelayUrls.push(...BIG_RELAY_URLS, ...PROFILE_RELAY_URLS) } const relayList = await this.fetchRelayList(event.pubkey) @@ -153,57 +154,105 @@ class ClientService extends EventTarget { let finishedCount = 0 const errors: { url: string; error: any }[] = [] + // Add a global timeout to prevent hanging for more than 2 minutes + const globalTimeout = setTimeout(() => { + // Mark any unfinished relays as failed + uniqueRelayUrls.forEach(url => { + const alreadyFinished = relayStatuses.some(rs => rs.url === url) + if (!alreadyFinished) { + relayStatuses.push({ url, success: false, error: 'Timeout: Operation took too long' }) + finishedCount++ + } + }) + + // Ensure we resolve even if not all relays finished + if (finishedCount < uniqueRelayUrls.length) { + finishedCount = uniqueRelayUrls.length + resolve({ + success: successCount >= uniqueRelayUrls.length / 3, + relayStatuses, + successCount, + totalCount: uniqueRelayUrls.length + }) + } + }, 120_000) // 2 minutes global timeout + Promise.allSettled( uniqueRelayUrls.map(async (url) => { // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this - const relay = await this.pool.ensureRelay(url) - relay.publishTimeout = 10_000 // 10s - return relay - .publish(event) - .then(() => { - this.trackEventSeenOn(event.id, relay) - successCount++ - relayStatuses.push({ url, success: true }) - }) - .catch((error) => { - if ( - error instanceof Error && - error.message.startsWith('auth-required') && - !!that.signer - ) { - return relay - .auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt)) - .then(() => relay.publish(event)) - .then(() => { - this.trackEventSeenOn(event.id, relay) - successCount++ - relayStatuses.push({ url, success: true }) - }) - .catch((authError) => { - errors.push({ url, error: authError }) - relayStatuses.push({ url, success: false, error: authError.message }) - }) - } else { - errors.push({ url, error }) - relayStatuses.push({ url, success: false, error: error.message }) - } - }) - .finally(() => { - // If one third of the relays have accepted the event, consider it a success - const isSuccess = successCount >= uniqueRelayUrls.length / 3 - if (isSuccess) { - this.emitNewEvent(event) - } - if (++finishedCount >= uniqueRelayUrls.length) { - resolve({ - success: successCount >= uniqueRelayUrls.length / 3, - relayStatuses, - successCount, - totalCount: uniqueRelayUrls.length - }) - } + const isLocal = isLocalNetworkUrl(url) + const timeout = isLocal ? 5_000 : 10_000 // 5s for local, 10s for remote + + try { + // For local relays, add a connection timeout + let relay: Relay + if (isLocal) { + relay = await Promise.race([ + this.pool.ensureRelay(url), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Local relay connection timeout')), timeout) + ) + ]) + } else { + relay = await this.pool.ensureRelay(url) + } + + relay.publishTimeout = timeout + + await relay + .publish(event) + .then(() => { + this.trackEventSeenOn(event.id, relay) + successCount++ + relayStatuses.push({ url, success: true }) + }) + .catch((error) => { + if ( + error instanceof Error && + error.message.startsWith('auth-required') && + !!that.signer + ) { + return relay + .auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt)) + .then(() => relay.publish(event)) + .then(() => { + this.trackEventSeenOn(event.id, relay) + successCount++ + relayStatuses.push({ url, success: true }) + }) + .catch((authError) => { + errors.push({ url, error: authError }) + relayStatuses.push({ url, success: false, error: authError.message }) + }) + } else { + errors.push({ url, error }) + relayStatuses.push({ url, success: false, error: error.message }) + } + }) + } catch (error) { + errors.push({ url, error }) + relayStatuses.push({ + url, + success: false, + error: error instanceof Error ? error.message : 'Connection failed' }) + } finally { + // If one third of the relays have accepted the event, consider it a success + const isSuccess = successCount >= uniqueRelayUrls.length / 3 + if (isSuccess) { + this.emitNewEvent(event) + } + if (++finishedCount >= uniqueRelayUrls.length) { + clearTimeout(globalTimeout) + resolve({ + success: successCount >= uniqueRelayUrls.length / 3, + relayStatuses, + successCount, + totalCount: uniqueRelayUrls.length + }) + } + } }) ) }) @@ -1120,17 +1169,83 @@ class ClientService extends EventTarget { } async fetchRelayLists(pubkeys: string[]): Promise { + // First check IndexedDB for offline/quick access (prioritizes cache relays for offline use) + const storedRelayEvents = await Promise.all( + pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)) + ) + const storedCacheRelayEvents = await Promise.all( + pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)) + ) + + // Then fetch from relays (will update cache if newer) const relayEvents = await this.fetchReplaceableEventsFromBigRelays(pubkeys, kinds.RelayList) + + // Fetch cache relays from multiple sources: BIG_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, and user's inboxes/outboxes + const cacheRelayEvents = await this.fetchCacheRelayEventsFromMultipleSources(pubkeys, relayEvents, storedRelayEvents) - return relayEvents.map((event) => { - if (event) { - return getRelayListFromEvent(event) - } - return { - write: BIG_RELAY_URLS, - read: BIG_RELAY_URLS, + return relayEvents.map((event, index) => { + // Use stored cache relay event if available (for offline), otherwise use fetched one + const storedCacheEvent = storedCacheRelayEvents[index] + const cacheEvent = cacheRelayEvents[index] || storedCacheEvent + + // Use stored relay event if no network event (for offline), otherwise use fetched one + const storedRelayEvent = storedRelayEvents[index] + const relayEvent = event || storedRelayEvent + + const relayList = relayEvent ? getRelayListFromEvent(relayEvent) : { + write: [], + read: [], originalRelays: [] } + + // Merge cache relays (kind 10432) into the relay list + // Prioritize cache relays by placing them first in the list (for offline functionality) + if (cacheEvent) { + const cacheRelayList = getRelayListFromEvent(cacheEvent) + + // Merge read relays - cache relays first, then others (for offline priority) + const mergedRead = [...cacheRelayList.read, ...relayList.read] + const mergedWrite = [...cacheRelayList.write, ...relayList.write] + const mergedOriginalRelays = new Map() + + // Add cache relay original relays first (prioritized) + cacheRelayList.originalRelays.forEach(relay => { + mergedOriginalRelays.set(relay.url, relay) + }) + // Then add regular relay original relays + relayList.originalRelays.forEach(relay => { + if (!mergedOriginalRelays.has(relay.url)) { + mergedOriginalRelays.set(relay.url, relay) + } + }) + + // Deduplicate while preserving order (cache relays first) + return { + write: Array.from(new Set(mergedWrite)), + read: Array.from(new Set(mergedRead)), + originalRelays: Array.from(mergedOriginalRelays.values()) + } + } + + // If no cache event, return original relay list or default (with cache as fallback) + if (!relayEvent) { + // Check if we have a stored cache relay event as fallback + if (storedCacheEvent) { + const cacheRelayList = getRelayListFromEvent(storedCacheEvent) + return { + write: cacheRelayList.write.length > 0 ? cacheRelayList.write : BIG_RELAY_URLS, + read: cacheRelayList.read.length > 0 ? cacheRelayList.read : BIG_RELAY_URLS, + originalRelays: cacheRelayList.originalRelays + } + } + return { + write: BIG_RELAY_URLS, + read: BIG_RELAY_URLS, + originalRelays: [] + } + } + + return relayList }) } @@ -1138,6 +1253,91 @@ class ClientService extends EventTarget { await this.replaceableEventBatchLoadFn([{ pubkey, kind: kinds.RelayList }]) } + /** + * Fetch cache relay events (kind 10432) from multiple sources: + * - BIG_RELAY_URLS + * - PROFILE_FETCH_RELAY_URLS + * - User's inboxes (read relays from kind 10002) + * - User's outboxes (write relays from kind 10002) + */ + private async fetchCacheRelayEventsFromMultipleSources( + pubkeys: string[], + relayEvents: (NEvent | null | undefined)[], + storedRelayEvents: (NEvent | null | undefined)[] + ): Promise<(NEvent | null | undefined)[]> { + // Start with events from IndexedDB + const storedCacheRelayEvents = await Promise.all( + pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)) + ) + + // Determine which pubkeys need fetching (don't have stored events) + const pubkeysToFetch = pubkeys.filter((_, index) => !storedCacheRelayEvents[index]) + if (pubkeysToFetch.length === 0) { + return storedCacheRelayEvents + } + + // Build list of relays to query from + const relayUrls = new Set([...BIG_RELAY_URLS, ...PROFILE_FETCH_RELAY_URLS]) + + // Add user's inboxes and outboxes from their relay list (kind 10002) + pubkeys.forEach((_pubkey, index) => { + const relayEvent = relayEvents[index] || storedRelayEvents[index] + if (relayEvent) { + const relayList = getRelayListFromEvent(relayEvent) + // Add read relays (inboxes) + relayList.read.forEach(url => relayUrls.add(url)) + // Add write relays (outboxes) + relayList.write.forEach(url => relayUrls.add(url)) + } + }) + + // Fetch cache relay events from all sources + const cacheRelayEvents: (NEvent | null | undefined)[] = new Array(pubkeys.length).fill(undefined) + + // Initialize with stored events + storedCacheRelayEvents.forEach((event, index) => { + if (event) { + cacheRelayEvents[index] = event + } + }) + + // Fetch missing cache relay events + if (pubkeysToFetch.length > 0) { + try { + const events = await this.query(Array.from(relayUrls), pubkeysToFetch.map(pubkey => ({ + authors: [pubkey], + kinds: [ExtendedKind.CACHE_RELAYS] + }))) + + // Map fetched events back to original pubkey order + const eventMap = new Map() + events.forEach(event => { + const key = event.pubkey + const existing = eventMap.get(key) + if (!existing || existing.created_at < event.created_at) { + eventMap.set(key, event) + } + }) + + pubkeysToFetch.forEach((pubkey) => { + const pubkeyIndex = pubkeys.indexOf(pubkey) + if (pubkeyIndex !== -1) { + const event = eventMap.get(pubkey) + if (event) { + cacheRelayEvents[pubkeyIndex] = event + // Cache the event + indexedDb.putReplaceableEvent(event) + } + } + }) + } catch (error) { + console.warn('[ClientService] Error fetching cache relay events:', error) + } + } + + return cacheRelayEvents + } + async updateRelayListCache(event: NEvent) { await this.updateReplaceableEventFromBigRelaysCache(event) } diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 8173663..688f3c7 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -23,6 +23,7 @@ const StoreNames = { EMOJI_SET_EVENTS: 'emojiSetEvents', FAVORITE_RELAYS: 'favoriteRelays', BLOCKED_RELAYS_EVENTS: 'blockedRelaysEvents', + CACHE_RELAYS_EVENTS: 'cacheRelaysEvents', RELAY_SETS: 'relaySets', FOLLOWING_FAVORITE_RELAYS: 'followingFavoriteRelays', RELAY_INFOS: 'relayInfos', @@ -46,7 +47,7 @@ class IndexedDbService { init(): Promise { if (!this.initPromise) { this.initPromise = new Promise((resolve, reject) => { - const request = window.indexedDB.open('jumble', 12) + const request = window.indexedDB.open('jumble', 13) request.onerror = (event) => { reject(event) @@ -57,8 +58,8 @@ class IndexedDbService { resolve() } - request.onupgradeneeded = () => { - const db = request.result + request.onupgradeneeded = (event) => { + const db = (event.target as IDBOpenDBRequest).result if (!db.objectStoreNames.contains(StoreNames.PROFILE_EVENTS)) { db.createObjectStore(StoreNames.PROFILE_EVENTS, { keyPath: 'key' }) } @@ -113,7 +114,9 @@ class IndexedDbService { if (!db.objectStoreNames.contains(StoreNames.PUBLICATION_EVENTS)) { db.createObjectStore(StoreNames.PUBLICATION_EVENTS, { keyPath: 'key' }) } - this.db = db + if (!db.objectStoreNames.contains(StoreNames.CACHE_RELAYS_EVENTS)) { + db.createObjectStore(StoreNames.CACHE_RELAYS_EVENTS, { keyPath: 'key' }) + } } }) setTimeout(() => this.cleanUp(), 1000 * 60) // 1 minute @@ -167,10 +170,27 @@ class IndexedDbService { return Promise.reject('store name not found') } await this.initPromise + + // Wait a bit for database upgrade to complete if store doesn't exist + if (this.db && !this.db.objectStoreNames.contains(storeName)) { + // Wait up to 2 seconds for store to be created (database upgrade) + let retries = 20 + while (retries > 0 && this.db && !this.db.objectStoreNames.contains(storeName)) { + await new Promise(resolve => setTimeout(resolve, 100)) + retries-- + } + } + return new Promise((resolve, reject) => { if (!this.db) { return reject('database not initialized') } + // Check if the store exists before trying to access it + if (!this.db.objectStoreNames.contains(storeName)) { + console.warn(`Store ${storeName} not found in database. Cannot save event.`) + // Return the event anyway (don't reject) - caching is optional + return resolve(event) + } const transaction = this.db.transaction(storeName, 'readwrite') const store = transaction.objectStore(storeName) @@ -215,6 +235,11 @@ class IndexedDbService { if (!this.db) { return reject('database not initialized') } + // Check if the store exists before trying to access it + if (!this.db.objectStoreNames.contains(storeName)) { + console.warn(`Store ${storeName} not found in database. Returning null.`) + return resolve(null) + } const transaction = this.db.transaction(storeName, 'readonly') const store = transaction.objectStore(storeName) const key = this.getReplaceableEventKey(pubkey, d) @@ -477,6 +502,8 @@ class IndexedDbService { return StoreNames.FAVORITE_RELAYS case ExtendedKind.BLOCKED_RELAYS: return StoreNames.BLOCKED_RELAYS_EVENTS + case ExtendedKind.CACHE_RELAYS: + return StoreNames.CACHE_RELAYS_EVENTS case kinds.UserEmojiList: return StoreNames.USER_EMOJI_LIST_EVENTS case kinds.Emojisets: