From 68c744bd87b3814001736e08a6d490525addc29f Mon Sep 17 00:00:00 2001 From: Silberengel Date: Tue, 11 Nov 2025 20:13:17 +0100 Subject: [PATCH] change to a normal replaceable kind --- src/components/RssFeedList/index.tsx | 70 ++++-- src/constants.ts | 2 +- src/lib/draft-event.ts | 12 +- .../secondary/RssFeedSettingsPage/index.tsx | 228 +++++++++++++++--- src/providers/NostrProvider/index.tsx | 81 ++++++- src/services/client.service.ts | 153 ++++++++++-- src/services/indexed-db.service.ts | 91 ++++++- 7 files changed, 563 insertions(+), 74 deletions(-) diff --git a/src/components/RssFeedList/index.tsx b/src/components/RssFeedList/index.tsx index e6dc26d..a60000c 100644 --- a/src/components/RssFeedList/index.tsx +++ b/src/components/RssFeedList/index.tsx @@ -2,15 +2,14 @@ import { useEffect, useState } from 'react' import { useTranslation } from 'react-i18next' import { useNostr } from '@/providers/NostrProvider' import rssFeedService, { RssFeedItem as TRssFeedItem } from '@/services/rss-feed.service' -import { ExtendedKind, DEFAULT_RSS_FEEDS } from '@/constants' -import indexedDb from '@/services/indexed-db.service' +import { DEFAULT_RSS_FEEDS } from '@/constants' import RssFeedItem from '../RssFeedItem' import { Loader, AlertCircle } from 'lucide-react' import logger from '@/lib/logger' export default function RssFeedList() { const { t } = useTranslation() - const { pubkey } = useNostr() + const { pubkey, rssFeedListEvent } = useNostr() const [items, setItems] = useState([]) const [loading, setLoading] = useState(true) const [error, setError] = useState(null) @@ -24,24 +23,44 @@ export default function RssFeedList() { // Get feed URLs from event or use default let feedUrls: string[] = DEFAULT_RSS_FEEDS - if (pubkey) { + if (pubkey && rssFeedListEvent) { try { - const event = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.RSS_FEED_LIST) - if (event && event.content) { - try { - const urls = JSON.parse(event.content) as string[] - if (Array.isArray(urls) && urls.length > 0) { - feedUrls = urls + // Extract URLs from "u" tags + const urls = rssFeedListEvent.tags + .filter(tag => tag[0] === 'u' && tag[1]) + .map(tag => tag[1] as string) + .filter((url): url is string => { + if (typeof url !== 'string') { + logger.warn('[RssFeedList] Invalid RSS feed URL (not a string)', { url, type: typeof url }) + return false } - } catch (e) { - logger.error('[RssFeedList] Failed to parse RSS feed list', { error: e }) - // Use default feeds on parse error - } + const trimmed = url.trim() + if (trimmed.length === 0) { + logger.warn('[RssFeedList] Empty RSS feed URL found') + return false + } + return true + }) + + if (urls.length > 0) { + feedUrls = urls + logger.info('[RssFeedList] Loaded RSS feed list from context', { + feedCount: urls.length, + eventId: rssFeedListEvent.id, + urls + }) + } else { + logger.info('[RssFeedList] RSS feed list is empty or contains no valid URLs, using default feeds') } } catch (e) { - logger.error('[RssFeedList] Failed to load RSS feed list event', { error: e }) - // Use default feeds on error + logger.error('[RssFeedList] Failed to parse RSS feed list from tags', { + error: e, + tags: rssFeedListEvent.tags + }) + // Use default feeds on parse error } + } else if (pubkey) { + logger.info('[RssFeedList] No RSS feed list event in context, using default feeds') } // Fetch and merge feeds (this handles errors gracefully and returns partial results) @@ -70,6 +89,25 @@ export default function RssFeedList() { } loadRssFeeds() + + // Listen for RSS feed list updates + const handleRssFeedListUpdate = (event: CustomEvent) => { + const detail = event.detail as { pubkey: string; feedUrls: string[]; eventId: string } + // Only refresh if it's for the current user + if (detail.pubkey === pubkey) { + logger.info('[RssFeedList] Received RSS feed list update event, refreshing...', { + eventId: detail.eventId, + feedCount: detail.feedUrls.length + }) + loadRssFeeds() + } + } + + window.addEventListener('rssFeedListUpdated', handleRssFeedListUpdate as EventListener) + + return () => { + window.removeEventListener('rssFeedListUpdated', handleRssFeedListUpdate as EventListener) + } }, [pubkey, t]) if (loading) { diff --git a/src/constants.ts b/src/constants.ts index 7ee1175..588448a 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -150,7 +150,7 @@ export const ExtendedKind = { WIKI_ARTICLE: 30818, WIKI_ARTICLE_MARKDOWN: 30817, PUBLICATION_CONTENT: 30041, - RSS_FEED_LIST: 30895, + RSS_FEED_LIST: 10895, // NIP-89 Application Handlers APPLICATION_HANDLER_RECOMMENDATION: 31989, APPLICATION_HANDLER_INFO: 31990 diff --git a/src/lib/draft-event.ts b/src/lib/draft-event.ts index 9d419f0..22b05b8 100644 --- a/src/lib/draft-event.ts +++ b/src/lib/draft-event.ts @@ -439,10 +439,18 @@ export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraf } export function createRssFeedListDraftEvent(feedUrls: string[]): TDraftEvent { + // Validate and sanitize feed URLs + const validUrls = feedUrls + .map(url => typeof url === 'string' ? url.trim() : '') + .filter(url => url.length > 0) + + // Create tags with "u" prefix for each feed URL + const tags = validUrls.map(url => ['u', url] as [string, string]) + return { kind: ExtendedKind.RSS_FEED_LIST, - content: JSON.stringify(feedUrls), - tags: [], + content: '', // Empty content, URLs are in tags + tags, created_at: dayjs().unix() } } diff --git a/src/pages/secondary/RssFeedSettingsPage/index.tsx b/src/pages/secondary/RssFeedSettingsPage/index.tsx index aed69b2..17ec7ca 100644 --- a/src/pages/secondary/RssFeedSettingsPage/index.tsx +++ b/src/pages/secondary/RssFeedSettingsPage/index.tsx @@ -16,7 +16,7 @@ import indexedDb from '@/services/indexed-db.service' const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index?: number; hideTitlebar?: boolean }, ref) => { const { t } = useTranslation() - const { pubkey, publish, checkLogin } = useNostr() + const { pubkey, publish, checkLogin, rssFeedListEvent } = useNostr() const [feedUrls, setFeedUrls] = useState([]) const [newFeedUrl, setNewFeedUrl] = useState('') const [showRssFeed, setShowRssFeed] = useState(true) @@ -24,38 +24,54 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index const [pushing, setPushing] = useState(false) const [loading, setLoading] = useState(true) + // Load RSS feed list from context (which is loaded from cache first, then relays if stale) useEffect(() => { // Load show RSS feed setting setShowRssFeed(storage.getShowRssFeed()) - // Load RSS feed list from event - const loadRssFeedList = async () => { - if (!pubkey) { - setLoading(false) - return - } + // Load RSS feed list from context event (which comes from cache) + if (!pubkey) { + setLoading(false) + return + } + if (rssFeedListEvent) { try { - const event = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.RSS_FEED_LIST) - if (event && event.content) { - try { - const urls = JSON.parse(event.content) as string[] - if (Array.isArray(urls)) { - setFeedUrls(urls) + // Extract URLs from "u" tags + const urls = rssFeedListEvent.tags + .filter(tag => tag[0] === 'u' && tag[1]) + .map(tag => tag[1] as string) + .filter((url): url is string => { + if (typeof url !== 'string') { + logger.warn('[RssFeedSettingsPage] Invalid RSS feed URL (not a string)', { url, type: typeof url }) + return false } - } catch (e) { - logger.error('[RssFeedSettingsPage] Failed to parse RSS feed list', { error: e }) - } + const trimmed = url.trim() + if (trimmed.length === 0) { + logger.warn('[RssFeedSettingsPage] Empty RSS feed URL found') + return false + } + return true + }) + + if (urls.length > 0) { + setFeedUrls(urls) + logger.info('[RssFeedSettingsPage] Loaded RSS feed list from context', { count: urls.length, urls }) + } else { + logger.info('[RssFeedSettingsPage] RSS feed list is empty or contains no valid URLs') } - } catch (error) { - logger.error('[RssFeedSettingsPage] Failed to load RSS feed list', { error }) - } finally { - setLoading(false) + } catch (e) { + logger.error('[RssFeedSettingsPage] Failed to parse RSS feed list from tags', { + error: e, + tags: rssFeedListEvent.tags + }) } + } else { + logger.info('[RssFeedSettingsPage] No RSS feed list event in context (user may not have created one yet)') } - - loadRssFeedList() - }, [pubkey]) + + setLoading(false) + }, [pubkey, rssFeedListEvent]) const handleShowRssFeedChange = (checked: boolean) => { setShowRssFeed(checked) @@ -91,23 +107,173 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index } const handleSave = async () => { - if (!pubkey) return + if (!pubkey) { + logger.error('[RssFeedSettingsPage] Cannot save: no pubkey') + return + } setPushing(true) try { + logger.info('[RssFeedSettingsPage] Creating RSS feed list event', { + pubkey: pubkey.substring(0, 8), + feedCount: feedUrls.length, + feedUrls + }) + const event = createRssFeedListDraftEvent(feedUrls) - const result = await publish(event) + + // Validate the event structure before publishing + logger.info('[RssFeedSettingsPage] Draft event created', { + kind: event.kind, + tagCount: event.tags.length, + tags: event.tags, + created_at: event.created_at + }) + console.log('✅ [RSS] Event created with tags', { + kind: event.kind, + tagCount: event.tags.length, + tags: event.tags + }) + + console.log('🔵 [RSS] About to call publish()') + let result + try { + result = await publish(event) + console.log('✅ [RSS] Event published successfully!', { + id: result.id, + kind: result.kind, + pubkey: result.pubkey?.substring(0, 8), + content: result.content + }) + } catch (publishError) { + console.error('❌ [RSS] Publish failed!', publishError) + throw publishError + } + + logger.info('[RssFeedSettingsPage] Event published', { + eventId: result.id, + kind: result.kind, + pubkey: result.pubkey, + created_at: result.created_at, + content: result.content + }) // Cache the event in IndexedDB for immediate access + console.log('🔵 [RSS] About to cache event in IndexedDB', { + eventId: result.id, + kind: result.kind, + pubkey: result.pubkey?.substring(0, 8) + }) + try { - await indexedDb.putReplaceableEvent(result) + logger.info('[RssFeedSettingsPage] Attempting to cache event in IndexedDB', { + eventId: result.id, + kind: result.kind, + pubkey: result.pubkey + }) + + console.log('🔵 [RSS] Calling indexedDb.putReplaceableEvent()...') + const savedEvent = await indexedDb.putReplaceableEvent(result) + console.log('✅ [RSS] Successfully cached to IndexedDB!', { + eventId: savedEvent.id, + kind: savedEvent.kind, + pubkey: savedEvent.pubkey?.substring(0, 8), + content: savedEvent.content + }) + logger.info('[RssFeedSettingsPage] Successfully cached RSS feed list event to IndexedDB', { + eventId: savedEvent.id, + kind: savedEvent.kind, + pubkey: savedEvent.pubkey, + feedCount: feedUrls.length + }) } catch (cacheError) { - logger.warn('[RssFeedSettingsPage] Failed to cache RSS feed list event', { error: cacheError }) - // Don't fail the save if caching fails + console.error('❌ [RSS] Failed to cache to IndexedDB!', { + error: cacheError, + errorMessage: cacheError instanceof Error ? cacheError.message : String(cacheError), + errorStack: cacheError instanceof Error ? cacheError.stack : undefined, + eventId: result.id, + kind: result.kind + }) + logger.error('[RssFeedSettingsPage] Failed to cache RSS feed list event', { + error: cacheError, + eventId: result.id, + kind: result.kind + }) + // Don't fail the save if caching fails, but log the error } + // Verify the event was saved by reading it back + console.log('🔵 [RSS] Verifying event was saved...') + try { + logger.info('[RssFeedSettingsPage] Verifying event was saved to IndexedDB', { + pubkey: pubkey.substring(0, 8), + kind: ExtendedKind.RSS_FEED_LIST + }) + + const savedEvent = await indexedDb.getReplaceableEvent(pubkey, ExtendedKind.RSS_FEED_LIST) + if (savedEvent) { + console.log('✅ [RSS] Event found in IndexedDB!', { + eventId: savedEvent.id, + expectedId: result.id, + match: savedEvent.id === result.id, + content: savedEvent.content + }) + logger.info('[RssFeedSettingsPage] Event found in IndexedDB', { + eventId: savedEvent.id, + expectedId: result.id, + match: savedEvent.id === result.id, + created_at: savedEvent.created_at, + content: savedEvent.content + }) + + if (savedEvent.id === result.id) { + console.log('✅ [RSS] Event IDs match! Verification successful!') + logger.info('[RssFeedSettingsPage] Verified RSS feed list event in IndexedDB', { eventId: savedEvent.id }) + } else { + console.warn('⚠️ [RSS] Event ID mismatch!', { + expectedId: result.id, + foundId: savedEvent.id + }) + logger.warn('[RssFeedSettingsPage] RSS feed list event ID mismatch', { + expectedId: result.id, + foundId: savedEvent.id, + expectedCreatedAt: result.created_at, + foundCreatedAt: savedEvent.created_at + }) + } + } else { + console.error('❌ [RSS] Event NOT found in IndexedDB after save!', { + expectedId: result.id, + pubkey: pubkey.substring(0, 8), + kind: ExtendedKind.RSS_FEED_LIST + }) + logger.error('[RssFeedSettingsPage] RSS feed list event not found in IndexedDB after save', { + expectedId: result.id, + pubkey: pubkey.substring(0, 8), + kind: ExtendedKind.RSS_FEED_LIST + }) + } + } catch (verifyError) { + console.error('❌ [RSS] Error verifying event in IndexedDB!', verifyError) + logger.error('[RssFeedSettingsPage] Failed to verify RSS feed list event in IndexedDB', { + error: verifyError, + pubkey: pubkey.substring(0, 8), + kind: ExtendedKind.RSS_FEED_LIST + }) + } + + // Dispatch custom event to notify other components (like RssFeedList) to refresh + window.dispatchEvent(new CustomEvent('rssFeedListUpdated', { + detail: { pubkey, feedUrls, eventId: result.id } + })) + // Read relayStatuses immediately before it might be deleted const relayStatuses = (result as any).relayStatuses + logger.info('[RssFeedSettingsPage] Publishing complete', { + eventId: result.id, + relayStatusCount: relayStatuses?.length || 0, + successCount: relayStatuses?.filter((s: any) => s.success).length || 0 + }) setHasChange(false) @@ -126,7 +292,11 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index showSimplePublishSuccess(t('RSS feeds saved')) } } catch (error) { - logger.error('[RssFeedSettingsPage] Failed to save RSS feed list', { error }) + logger.error('[RssFeedSettingsPage] Failed to save RSS feed list', { + error, + errorMessage: error instanceof Error ? error.message : String(error), + errorStack: error instanceof Error ? error.stack : undefined + }) // Show error feedback with relay statuses if available if (error instanceof Error && (error as any).relayStatuses) { const errorRelayStatuses = (error as any).relayStatuses diff --git a/src/providers/NostrProvider/index.tsx b/src/providers/NostrProvider/index.tsx index f306963..128954d 100644 --- a/src/providers/NostrProvider/index.tsx +++ b/src/providers/NostrProvider/index.tsx @@ -1,5 +1,5 @@ import LoginDialog from '@/components/LoginDialog' -import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, PROFILE_FETCH_RELAY_URLS } from '@/constants' +import { ApplicationDataKey, BIG_RELAY_URLS, ExtendedKind, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS } from '@/constants' import { createDeletionRequestDraftEvent, createFollowListDraftEvent, @@ -60,6 +60,7 @@ type TNostrContext = { favoriteRelaysEvent: Event | null blockedRelaysEvent: Event | null userEmojiListEvent: Event | null + rssFeedListEvent: Event | null notificationsSeenAt: number account: TAccountPointer | null accounts: TAccountPointer[] @@ -167,6 +168,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState(null) const [blockedRelaysEvent, setBlockedRelaysEvent] = useState(null) const [userEmojiListEvent, setUserEmojiListEvent] = useState(null) + const [rssFeedListEvent, setRssFeedListEvent] = useState(null) const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1) const [isInitialized, setIsInitialized] = useState(false) @@ -209,6 +211,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { setFollowListEvent(null) setMuteListEvent(null) setBookmarkListEvent(null) + setRssFeedListEvent(null) setNotificationsSeenAt(-1) if (!account) { return @@ -239,7 +242,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { storedBookmarkListEvent, storedFavoriteRelaysEvent, storedBlockedRelaysEvent, - storedUserEmojiListEvent + storedUserEmojiListEvent, + storedRssFeedListEvent ] = await Promise.all([ indexedDb.getReplaceableEvent(account.pubkey, kinds.RelayList), indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.CACHE_RELAYS), @@ -249,7 +253,8 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { indexedDb.getReplaceableEvent(account.pubkey, kinds.BookmarkList), indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.FAVORITE_RELAYS), indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.BLOCKED_RELAYS), - indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList) + indexedDb.getReplaceableEvent(account.pubkey, kinds.UserEmojiList), + indexedDb.getReplaceableEvent(account.pubkey, ExtendedKind.RSS_FEED_LIST) ]) // Extract blocked relays from event @@ -321,6 +326,61 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { if (storedUserEmojiListEvent) { setUserEmojiListEvent(storedUserEmojiListEvent) } + if (storedRssFeedListEvent) { + setRssFeedListEvent(storedRssFeedListEvent) + logger.info('[NostrProvider] Loaded RSS feed list event from cache', { + eventId: storedRssFeedListEvent.id, + created_at: storedRssFeedListEvent.created_at + }) + } + + // Fetch RSS feed list from relays if cache is missing or stale (older than 1 hour) + const rssFeedListStale = !storedRssFeedListEvent || + (dayjs().unix() - storedRssFeedListEvent.created_at > 3600) // 1 hour + + if (rssFeedListStale) { + logger.info('[NostrProvider] RSS feed list cache is missing or stale, fetching from relays', { + hasCache: !!storedRssFeedListEvent, + cacheAge: storedRssFeedListEvent ? dayjs().unix() - storedRssFeedListEvent.created_at : 'N/A' + }) + + // Fetch in background - don't block initialization + client.fetchEvents(FAST_WRITE_RELAY_URLS.concat(PROFILE_RELAY_URLS), { + kinds: [ExtendedKind.RSS_FEED_LIST], + authors: [account.pubkey], + limit: 1 + }).then(events => { + const latestEvent = getLatestEvent(events) + if (latestEvent) { + // Only update if the fetched event is newer than cached + if (!storedRssFeedListEvent || latestEvent.created_at > storedRssFeedListEvent.created_at) { + logger.info('[NostrProvider] Found newer RSS feed list event from relays', { + eventId: latestEvent.id, + created_at: latestEvent.created_at, + wasCached: !!storedRssFeedListEvent + }) + indexedDb.putReplaceableEvent(latestEvent).then(() => { + setRssFeedListEvent(latestEvent) + logger.info('[NostrProvider] Updated RSS feed list event in cache and state') + }).catch(err => { + logger.error('[NostrProvider] Failed to cache RSS feed list event', { error: err }) + }) + } else { + logger.info('[NostrProvider] Cached RSS feed list event is up to date', { + cachedCreatedAt: storedRssFeedListEvent.created_at, + fetchedCreatedAt: latestEvent.created_at + }) + } + } else if (!storedRssFeedListEvent) { + logger.info('[NostrProvider] No RSS feed list event found on relays (user may not have created one yet)') + } + }).catch(err => { + logger.error('[NostrProvider] Failed to fetch RSS feed list from relays', { error: err }) + // Don't clear cache on fetch error - use cached value + }) + } else { + logger.info('[NostrProvider] RSS feed list cache is fresh, using cached value') + } const [relayListEvents, cacheRelayListEvents] = await Promise.all([ client.fetchEvents(BIG_RELAY_URLS, { @@ -825,10 +885,19 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { } } + console.log('🔵 [Publish] Determining target relays...', { kind: event.kind, pubkey: event.pubkey?.substring(0, 8) }) const relays = await client.determineTargetRelays(event, options) + console.log('✅ [Publish] Target relays determined', { relayCount: relays.length, relays: relays.slice(0, 5) }) try { + console.log('🔵 [Publish] Calling client.publishEvent()...', { relayCount: relays.length, eventId: event.id?.substring(0, 8) }) const publishResult = await client.publishEvent(relays, event) + console.log('✅ [Publish] publishEvent completed', { + success: publishResult.success, + successCount: publishResult.successCount, + totalCount: publishResult.totalCount, + relayStatuses: publishResult.relayStatuses + }) // Store relay status temporarily for display (but don't persist it on the event) // This metadata is only for logging/feedback, not part of the actual event @@ -836,6 +905,9 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { // If publishing failed completely, throw an error so the form doesn't close if (!publishResult.success) { + console.error('❌ [Publish] Publishing failed to all relays!', { + relayStatuses: publishResult.relayStatuses + }) const error = new AggregateError( publishResult.relayStatuses .filter(s => !s.success) @@ -846,6 +918,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { throw error } + console.log('✅ [Publish] Publishing successful, attaching relayStatuses to event') // Attach relayStatuses only temporarily for UI feedback, then remove it // This prevents it from being included in the event when serialized // Use a longer delay to ensure UI components can read it before deletion @@ -858,6 +931,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { }, 100) } + console.log('✅ [Publish] Returning event', { eventId: event.id?.substring(0, 8), hasRelayStatuses: !!relayStatuses }) return event } catch (error) { // Check for authentication-related errors @@ -1058,6 +1132,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { favoriteRelaysEvent, blockedRelaysEvent, userEmojiListEvent, + rssFeedListEvent, notificationsSeenAt, account, accounts, diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 53864b8..c2157d5 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -1,4 +1,4 @@ -import { BIG_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' +import { BIG_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, PROFILE_FETCH_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' import { compareEvents, getReplaceableCoordinate, @@ -132,6 +132,8 @@ class ClientService extends EventTarget { ].includes(event.kind) ) { _additionalRelayUrls.push(...BIG_RELAY_URLS, ...PROFILE_RELAY_URLS) + } else if (event.kind === ExtendedKind.RSS_FEED_LIST) { + _additionalRelayUrls.push(...FAST_WRITE_RELAY_URLS, ...PROFILE_RELAY_URLS) } const relayList = await this.fetchRelayList(event.pubkey) @@ -148,7 +150,15 @@ class ClientService extends EventTarget { } async publishEvent(relayUrls: string[], event: NEvent) { + console.log('🔵 [PublishEvent] Starting publishEvent', { + eventId: event.id?.substring(0, 8), + kind: event.kind, + relayCount: relayUrls.length + }) + const uniqueRelayUrls = Array.from(new Set(relayUrls)) + console.log('🔵 [PublishEvent] Unique relays', { count: uniqueRelayUrls.length, relays: uniqueRelayUrls.slice(0, 5) }) + const relayStatuses: { url: string; success: boolean; error?: string }[] = [] return new Promise<{ success: boolean; relayStatuses: typeof relayStatuses; successCount: number; totalCount: number }>((resolve) => { @@ -156,20 +166,42 @@ 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 + console.log('🔵 [PublishEvent] Setting up global timeout (30 seconds)') + let hasResolved = false + + // Add a global timeout to prevent hanging - use 30 seconds for faster feedback const globalTimeout = setTimeout(() => { + if (hasResolved) { + console.log('🔵 [PublishEvent] Already resolved, ignoring timeout') + return + } + + console.warn('⚠️ [PublishEvent] Global timeout reached!', { + finishedCount, + totalRelays: uniqueRelayUrls.length, + successCount, + relayStatusesCount: relayStatuses.length + }) + // Mark any unfinished relays as failed uniqueRelayUrls.forEach(url => { const alreadyFinished = relayStatuses.some(rs => rs.url === url) if (!alreadyFinished) { + console.warn('⚠️ [PublishEvent] Marking relay as timed out', { url }) 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 + if (!hasResolved) { + hasResolved = true + console.log('✅ [PublishEvent] Resolving due to timeout', { + success: successCount >= uniqueRelayUrls.length / 3, + successCount, + totalCount: uniqueRelayUrls.length, + relayStatuses: relayStatuses.length + }) resolve({ success: successCount >= uniqueRelayUrls.length / 3, relayStatuses, @@ -177,62 +209,100 @@ class ClientService extends EventTarget { totalCount: uniqueRelayUrls.length }) } - }, 120_000) // 2 minutes global timeout + }, 30_000) // 30 seconds global timeout (reduced from 2 minutes) + console.log('🔵 [PublishEvent] Starting Promise.allSettled for all relays') Promise.allSettled( - uniqueRelayUrls.map(async (url) => { + uniqueRelayUrls.map(async (url, index) => { + console.log(`🔵 [PublishEvent] Starting relay ${index + 1}/${uniqueRelayUrls.length}`, { url }) // eslint-disable-next-line @typescript-eslint/no-this-alias const that = this const isLocal = isLocalNetworkUrl(url) - const timeout = isLocal ? 5_000 : 10_000 // 5s for local, 10s for remote + const connectionTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote + const publishTimeout = isLocal ? 5_000 : 8_000 // 5s for local, 8s for remote + + // Set up a per-relay timeout to ensure we always reach the finally block + const relayTimeout = setTimeout(() => { + console.warn(`⚠️ [PublishEvent] Per-relay timeout for ${url}`, { connectionTimeout, publishTimeout }) + // This will be caught in the catch block if the promise is still pending + }, connectionTimeout + publishTimeout + 2_000) // Add 2s buffer 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) - } + console.log(`🔵 [PublishEvent] Ensuring relay connection`, { url, isLocal, connectionTimeout }) + + const connectionPromise = isLocal + ? Promise.race([ + this.pool.ensureRelay(url), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Local relay connection timeout')), connectionTimeout) + ) + ]) + : Promise.race([ + this.pool.ensureRelay(url), + new Promise((_, reject) => + setTimeout(() => reject(new Error('Remote relay connection timeout')), connectionTimeout) + ) + ]) + + relay = await connectionPromise + console.log(`✅ [PublishEvent] Relay connected`, { url }) - relay.publishTimeout = timeout + relay.publishTimeout = publishTimeout - await relay + console.log(`🔵 [PublishEvent] Publishing to relay`, { url }) + + // Wrap publish in a timeout promise + const publishPromise = relay .publish(event) .then(() => { + console.log(`✅ [PublishEvent] Successfully published to relay`, { url }) this.trackEventSeenOn(event.id, relay) successCount++ relayStatuses.push({ url, success: true }) }) .catch((error) => { + console.warn(`⚠️ [PublishEvent] Publish failed, checking if auth required`, { url, error: error.message }) if ( error instanceof Error && error.message.startsWith('auth-required') && !!that.signer ) { + console.log(`🔵 [PublishEvent] Auth required, attempting authentication`, { url }) return relay .auth((authEvt: EventTemplate) => that.signer!.signEvent(authEvt)) - .then(() => relay.publish(event)) .then(() => { + console.log(`✅ [PublishEvent] Auth successful, retrying publish`, { url }) + return relay.publish(event) + }) + .then(() => { + console.log(`✅ [PublishEvent] Successfully published after auth`, { url }) this.trackEventSeenOn(event.id, relay) successCount++ relayStatuses.push({ url, success: true }) }) .catch((authError) => { + console.error(`❌ [PublishEvent] Auth or publish failed`, { url, error: authError.message }) errors.push({ url, error: authError }) relayStatuses.push({ url, success: false, error: authError.message }) }) } else { + console.error(`❌ [PublishEvent] Publish failed`, { url, error: error.message }) errors.push({ url, error }) relayStatuses.push({ url, success: false, error: error.message }) } }) + + // Add a timeout wrapper for the entire publish operation + await Promise.race([ + publishPromise, + new Promise((_, reject) => + setTimeout(() => reject(new Error(`Publish timeout after ${publishTimeout}ms`)), publishTimeout) + ) + ]) } catch (error) { + console.error(`❌ [PublishEvent] Connection or setup failed`, { url, error: error instanceof Error ? error.message : String(error) }) errors.push({ url, error }) relayStatuses.push({ url, @@ -240,12 +310,28 @@ class ClientService extends EventTarget { error: error instanceof Error ? error.message : 'Connection failed' }) } finally { + clearTimeout(relayTimeout) + const currentFinished = ++finishedCount + console.log(`🔵 [PublishEvent] Relay finished`, { + url, + finishedCount: currentFinished, + totalRelays: uniqueRelayUrls.length, + successCount + }) + // 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) { + if (currentFinished >= uniqueRelayUrls.length && !hasResolved) { + hasResolved = true + console.log('✅ [PublishEvent] All relays finished, resolving', { + success: successCount >= uniqueRelayUrls.length / 3, + successCount, + totalCount: uniqueRelayUrls.length, + relayStatusesCount: relayStatuses.length + }) clearTimeout(globalTimeout) resolve({ success: successCount >= uniqueRelayUrls.length / 3, @@ -254,6 +340,31 @@ class ClientService extends EventTarget { totalCount: uniqueRelayUrls.length }) } + + // Also resolve early if we have enough successes (1/3 of relays) + // This prevents waiting for slow/failing relays + if (!hasResolved && successCount >= Math.max(1, Math.ceil(uniqueRelayUrls.length / 3)) && currentFinished >= Math.max(1, Math.ceil(uniqueRelayUrls.length / 3))) { + // Wait a bit more to see if more relays succeed quickly + setTimeout(() => { + if (!hasResolved) { + hasResolved = true + console.log('✅ [PublishEvent] Resolving early with enough successes', { + success: true, + successCount, + totalCount: uniqueRelayUrls.length, + finishedCount: currentFinished, + relayStatusesCount: relayStatuses.length + }) + clearTimeout(globalTimeout) + resolve({ + success: true, + relayStatuses, + successCount, + totalCount: uniqueRelayUrls.length + }) + } + }, 2000) // Wait 2 more seconds for quick responses + } } }) ) diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index f68b7b5..e14033a 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -51,7 +51,7 @@ class IndexedDbService { init(): Promise { if (!this.initPromise) { this.initPromise = new Promise((resolve, reject) => { - const request = window.indexedDB.open('jumble', 15) + const request = window.indexedDB.open('jumble', 16) request.onerror = (event) => { reject(event) @@ -178,12 +178,31 @@ class IndexedDbService { const storeName = this.getStoreNameByKind(cleanEvent.kind) if (!storeName) { + logger.error('[IndexedDB] Store name not found for kind', { kind: cleanEvent.kind }) return Promise.reject('store name not found') } + + console.log('🔵 [IndexedDB] Putting replaceable event', { + kind: cleanEvent.kind, + storeName, + eventId: cleanEvent.id?.substring(0, 8), + pubkey: cleanEvent.pubkey?.substring(0, 8), + created_at: cleanEvent.created_at, + fullEventId: cleanEvent.id + }) + logger.info('[IndexedDB] Putting replaceable event', { + kind: cleanEvent.kind, + storeName, + eventId: cleanEvent.id?.substring(0, 8), + pubkey: cleanEvent.pubkey?.substring(0, 8), + created_at: cleanEvent.created_at + }) + await this.initPromise // Wait a bit for database upgrade to complete if store doesn't exist if (this.db && !this.db.objectStoreNames.contains(storeName)) { + logger.warn('[IndexedDB] Store not found, waiting for database upgrade', { 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)) { @@ -194,38 +213,104 @@ class IndexedDbService { return new Promise((resolve, reject) => { if (!this.db) { + logger.error('[IndexedDB] Database not initialized', { storeName, kind: cleanEvent.kind }) return reject('database not initialized') } + // Check if the store exists before trying to access it if (!this.db.objectStoreNames.contains(storeName)) { - logger.warn(`Store ${storeName} not found in database. Cannot save event.`) + console.error('[IndexedDB] Store not found in database after waiting', { + storeName, + kind: cleanEvent.kind, + availableStores: Array.from(this.db.objectStoreNames), + dbVersion: this.db.version + }) + logger.error('[IndexedDB] Store not found in database after waiting', { + storeName, + kind: cleanEvent.kind, + availableStores: Array.from(this.db.objectStoreNames) + }) // Return the event anyway (don't reject) - caching is optional return resolve(cleanEvent) } + + console.log('✅ [IndexedDB] Store exists, proceeding with save', { + storeName, + kind: cleanEvent.kind, + eventId: cleanEvent.id?.substring(0, 8), + dbVersion: this.db.version, + allStores: Array.from(this.db.objectStoreNames) + }) + const transaction = this.db.transaction(storeName, 'readwrite') const store = transaction.objectStore(storeName) const key = this.getReplaceableEventKeyFromEvent(cleanEvent) + logger.info('[IndexedDB] Getting existing event', { storeName, key, eventId: cleanEvent.id?.substring(0, 8) }) + const getRequest = store.get(key) getRequest.onsuccess = () => { const oldValue = getRequest.result as TValue | undefined + if (oldValue?.value) { + logger.info('[IndexedDB] Found existing event', { + storeName, + key, + oldEventId: oldValue.value.id?.substring(0, 8), + oldCreatedAt: oldValue.value.created_at, + newCreatedAt: cleanEvent.created_at, + willUpdate: cleanEvent.created_at > oldValue.value.created_at + }) + } else { + logger.info('[IndexedDB] No existing event found', { storeName, key }) + } + if (oldValue?.value && oldValue.value.created_at >= cleanEvent.created_at) { + logger.info('[IndexedDB] Keeping existing event (newer or same timestamp)', { + storeName, + key, + existingEventId: oldValue.value.id?.substring(0, 8) + }) transaction.commit() return resolve(oldValue.value) } + + console.log('🔵 [IndexedDB] Putting new event', { + storeName, + key, + eventId: cleanEvent.id?.substring(0, 8), + fullEventId: cleanEvent.id, + content: cleanEvent.content?.substring(0, 50) + }) + logger.info('[IndexedDB] Putting new event', { storeName, key, eventId: cleanEvent.id?.substring(0, 8) }) const putRequest = store.put(this.formatValue(key, cleanEvent)) putRequest.onsuccess = () => { + console.log('✅ [IndexedDB] Successfully put event!', { + storeName, + key, + eventId: cleanEvent.id?.substring(0, 8), + content: cleanEvent.content?.substring(0, 50) + }) + logger.info('[IndexedDB] Successfully put event', { storeName, key, eventId: cleanEvent.id?.substring(0, 8) }) transaction.commit() resolve(cleanEvent) } putRequest.onerror = (event) => { + console.error('❌ [IndexedDB] Error putting event!', { + storeName, + key, + error: event, + target: (event.target as any)?.error, + errorMessage: (event.target as any)?.error?.message + }) + logger.error('[IndexedDB] Error putting event', { storeName, key, error: event }) transaction.commit() reject(event) } } getRequest.onerror = (event) => { + logger.error('[IndexedDB] Error getting existing event', { storeName, key, error: event }) transaction.commit() reject(event) } @@ -474,6 +559,8 @@ class IndexedDbService { } private getReplaceableEventKeyFromEvent(event: Event): string { + // Events that are replaceable by pubkey only (no d-tag) + // RSS_FEED_LIST (10895) is in the 10000-20000 range, so it's automatically handled if ( [kinds.Metadata, kinds.Contacts].includes(event.kind) || (event.kind >= 10000 && event.kind < 20000 && event.kind !== ExtendedKind.PUBLICATION && event.kind !== ExtendedKind.PUBLICATION_CONTENT && event.kind !== ExtendedKind.WIKI_ARTICLE && event.kind !== ExtendedKind.WIKI_ARTICLE_MARKDOWN && event.kind !== kinds.LongFormArticle)