Browse Source

change to a normal replaceable kind

imwald
Silberengel 4 months ago
parent
commit
68c744bd87
  1. 68
      src/components/RssFeedList/index.tsx
  2. 2
      src/constants.ts
  3. 12
      src/lib/draft-event.ts
  4. 216
      src/pages/secondary/RssFeedSettingsPage/index.tsx
  5. 81
      src/providers/NostrProvider/index.tsx
  6. 145
      src/services/client.service.ts
  7. 91
      src/services/indexed-db.service.ts

68
src/components/RssFeedList/index.tsx

@ -2,15 +2,14 @@ import { useEffect, useState } from 'react' @@ -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<TRssFeedItem[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
@ -24,24 +23,44 @@ export default function RssFeedList() { @@ -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() { @@ -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) {

2
src/constants.ts

@ -150,7 +150,7 @@ export const ExtendedKind = { @@ -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

12
src/lib/draft-event.ts

@ -439,10 +439,18 @@ export function createRelayListDraftEvent(mailboxRelays: TMailboxRelay[]): TDraf @@ -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()
}
}

216
src/pages/secondary/RssFeedSettingsPage/index.tsx

@ -16,7 +16,7 @@ import indexedDb from '@/services/indexed-db.service' @@ -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<string[]>([])
const [newFeedUrl, setNewFeedUrl] = useState('')
const [showRssFeed, setShowRssFeed] = useState(true)
@ -24,38 +24,54 @@ const RssFeedSettingsPage = forwardRef(({ index, hideTitlebar = false }: { index @@ -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 () => {
// 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 @@ -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 @@ -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

81
src/providers/NostrProvider/index.tsx

@ -1,5 +1,5 @@ @@ -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 = { @@ -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 }) { @@ -167,6 +168,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
const [favoriteRelaysEvent, setFavoriteRelaysEvent] = useState<Event | null>(null)
const [blockedRelaysEvent, setBlockedRelaysEvent] = useState<Event | null>(null)
const [userEmojiListEvent, setUserEmojiListEvent] = useState<Event | null>(null)
const [rssFeedListEvent, setRssFeedListEvent] = useState<Event | null>(null)
const [notificationsSeenAt, setNotificationsSeenAt] = useState(-1)
const [isInitialized, setIsInitialized] = useState(false)
@ -209,6 +211,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -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 }) { @@ -1058,6 +1132,7 @@ export function NostrProvider({ children }: { children: React.ReactNode }) {
favoriteRelaysEvent,
blockedRelaysEvent,
userEmojiListEvent,
rssFeedListEvent,
notificationsSeenAt,
account,
accounts,

145
src/services/client.service.ts

@ -1,4 +1,4 @@ @@ -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 { @@ -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 { @@ -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 { @@ -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 { @@ -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([
console.log(`🔵 [PublishEvent] Ensuring relay connection`, { url, isLocal, connectionTimeout })
const connectionPromise = isLocal
? Promise.race([
this.pool.ensureRelay(url),
new Promise<Relay>((_, reject) =>
setTimeout(() => reject(new Error('Local relay connection timeout')), timeout)
setTimeout(() => reject(new Error('Local relay connection timeout')), connectionTimeout)
)
])
} else {
relay = await this.pool.ensureRelay(url)
}
: Promise.race([
this.pool.ensureRelay(url),
new Promise<Relay>((_, reject) =>
setTimeout(() => reject(new Error('Remote relay connection timeout')), connectionTimeout)
)
])
relay = await connectionPromise
console.log(`✅ [PublishEvent] Relay connected`, { url })
relay.publishTimeout = publishTimeout
relay.publishTimeout = timeout
console.log(`🔵 [PublishEvent] Publishing to relay`, { url })
await relay
// 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<void>((_, 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 { @@ -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 { @@ -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
}
}
})
)

91
src/services/indexed-db.service.ts

@ -51,7 +51,7 @@ class IndexedDbService { @@ -51,7 +51,7 @@ class IndexedDbService {
init(): Promise<void> {
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 { @@ -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 { @@ -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<Event> | 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 { @@ -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)

Loading…
Cancel
Save