Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
64564c0237
  1. 30
      src/components/NoteStats/LikeButton.tsx
  2. 48
      src/components/VideoPlayer/index.tsx
  3. 7
      src/constants.ts
  4. 2
      src/i18n/locales/de.ts
  5. 2
      src/i18n/locales/en.ts
  6. 359
      src/services/client.service.ts

30
src/components/NoteStats/LikeButton.tsx

@ -35,7 +35,13 @@ import { useTranslation } from 'react-i18next'
import Emoji from '../Emoji' import Emoji from '../Emoji'
import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker' import EmojiPicker, { EMOJI_PICKER_REACTIONS } from '../EmojiPicker'
import { formatCount } from './utils' import { formatCount } from './utils'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import {
type RelayStatus,
showPublishingError,
showPublishingFeedback,
showSimplePublishSuccess
} from '@/lib/publishing-feedback'
import { LoginRequiredError } from '@/lib/nostr-errors'
import { WEB_EXTERNAL_REACTION_PUBLISHED_EVENT } from '@/lib/rss-web-feed' import { WEB_EXTERNAL_REACTION_PUBLISHED_EVENT } from '@/lib/rss-web-feed'
export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) { export default function LikeButton({ event, hideCount = false }: { event: Event; hideCount?: boolean }) {
@ -177,7 +183,29 @@ export default function LikeButton({ event, hideCount = false }: { event: Event;
} }
} }
} catch (error) { } catch (error) {
if (error instanceof LoginRequiredError) {
return
}
logger.error('Like failed', { error, eventId: event.id }) logger.error('Like failed', { error, eventId: event.id })
if (error instanceof AggregateError && (error as AggregateError & { relayStatuses?: RelayStatus[] }).relayStatuses) {
const relayStatuses = (error as AggregateError & { relayStatuses: RelayStatus[] }).relayStatuses
const successCount = relayStatuses.filter((s) => s.success).length
showPublishingFeedback(
{
success: successCount > 0,
relayStatuses,
successCount,
totalCount: relayStatuses.length
},
{
message:
successCount > 0 ? t('Reaction published to some relays') : t('Failed to publish reaction'),
duration: 6000
}
)
} else {
showPublishingError(error instanceof Error ? error.message : t('Failed to publish reaction'))
}
} finally { } finally {
setLiking(false) setLiking(false)
clearTimeout(timer) clearTimeout(timer)

48
src/components/VideoPlayer/index.tsx

@ -1,6 +1,6 @@
import { isImwaldElectron } from '@/lib/client-platform' import { isImwaldElectron } from '@/lib/client-platform'
import { isHlsPlaylistUrl } from '@/lib/url' import { isHlsPlaylistUrl } from '@/lib/url'
import { cn, isInViewport } from '@/lib/utils' import { cn } from '@/lib/utils'
import { useContentPolicy } from '@/providers/ContentPolicyProvider' import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import mediaManager from '@/services/media-manager.service' import mediaManager from '@/services/media-manager.service'
import Hls from 'hls.js' import Hls from 'hls.js'
@ -97,24 +97,52 @@ export default function VideoPlayer({
if (!video || !container) return if (!video || !container) return
/**
* Mobile: `threshold: 1` + a second `isInViewport` (full element inside innerHeight) caused
* play/pause thrash as the toolbar/resizes and subpixel layout toggled visibility. That produced
* a buffering spinner loop (Loader2) and stutter. Use fractional visibility + debounced pause.
*/
const PLAY_AFTER_VISIBLE_RATIO = 0.35
const PAUSE_BELOW_RATIO = 0.12
const PLAY_DELAY_MS = 200
const PAUSE_DELAY_MS = 450
let playTimer: ReturnType<typeof setTimeout> | undefined
let pauseTimer: ReturnType<typeof setTimeout> | undefined
const observer = new IntersectionObserver( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (entry.isIntersecting) { const ratio = entry.intersectionRatio
setTimeout(() => { if (ratio >= PLAY_AFTER_VISIBLE_RATIO) {
if (isInViewport(container)) { if (pauseTimer !== undefined) {
mediaManager.autoPlay(video) clearTimeout(pauseTimer)
} pauseTimer = undefined
}, 200) }
} else { if (playTimer !== undefined) return
mediaManager.pause(video) playTimer = setTimeout(() => {
playTimer = undefined
mediaManager.autoPlay(video)
}, PLAY_DELAY_MS)
} else if (ratio <= PAUSE_BELOW_RATIO) {
if (playTimer !== undefined) {
clearTimeout(playTimer)
playTimer = undefined
}
if (pauseTimer !== undefined) return
pauseTimer = setTimeout(() => {
pauseTimer = undefined
mediaManager.pause(video)
}, PAUSE_DELAY_MS)
} }
}, },
{ threshold: 1 } { threshold: [0, 0.1, 0.2, 0.35, 0.5, 0.75, 1] }
) )
observer.observe(container) observer.observe(container)
return () => { return () => {
if (playTimer !== undefined) clearTimeout(playTimer)
if (pauseTimer !== undefined) clearTimeout(pauseTimer)
observer.unobserve(container) observer.unobserve(container)
} }
}, [autoplay, src, hlsMode]) }, [autoplay, src, hlsMode])

7
src/constants.ts

@ -107,9 +107,10 @@ export const MAX_PUBLISH_RELAYS = 20
export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000 export const OUTBOX_PUBLISH_RETRY_DELAY_MS = 5000
/** /**
* Cap how long we wait on NIP-65 / inbox relay-list fetches before publishing. * Cap how long we wait on NIP-65 / inbox relay-list resolution (including `fetchRelayLists` network phase
* Without this, a stuck `fetchRelayList` / `fetchRelayLists` can leave republish toasts loading forever * and kind-10432 fetch) before publishing or falling back to IndexedDB-only merge.
* (the 30s publish timeout only runs after targets are resolved). * Without this, a stuck `fetchReplaceableEventsFromProfileFetchRelays` can block the UI even when kind
* 10002 is already in IndexedDB (the 30s publish timeout only runs after targets are resolved).
*/ */
export const PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS = 12_000 export const PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS = 12_000

2
src/i18n/locales/de.ts

@ -1398,6 +1398,7 @@ export default {
'Failed to pin note': 'Failed to pin note', 'Failed to pin note': 'Failed to pin note',
'Failed to publish post': 'Failed to publish post', 'Failed to publish post': 'Failed to publish post',
'Failed to publish reply': 'Failed to publish reply', 'Failed to publish reply': 'Failed to publish reply',
'Failed to publish reaction': 'Reaktion konnte nicht veröffentlicht werden',
'Failed to publish thread': 'Failed to publish thread', 'Failed to publish thread': 'Failed to publish thread',
'Failed to publish to some relays. Please try again or use different relays.': 'Failed to publish to some relays. Please try again or use different relays.':
'Failed to publish to some relays. Please try again or use different relays.', 'Failed to publish to some relays. Please try again or use different relays.',
@ -1642,6 +1643,7 @@ export default {
'Rate limited. Please wait before trying again.': 'Rate limited. Please wait before trying again.':
'Rate limited. Please wait before trying again.', 'Rate limited. Please wait before trying again.',
'Reaction published': 'Reaction published', 'Reaction published': 'Reaction published',
'Reaction published to some relays': 'Reaktion auf einigen Relays veröffentlicht',
'Reaction removed': 'Reaction removed', 'Reaction removed': 'Reaction removed',
'Read full article': 'Read full article', 'Read full article': 'Read full article',
'Reading group entry': 'Reading group entry', 'Reading group entry': 'Reading group entry',

2
src/i18n/locales/en.ts

@ -1395,6 +1395,7 @@ export default {
'Failed to pin note': 'Failed to pin note', 'Failed to pin note': 'Failed to pin note',
'Failed to publish post': 'Failed to publish post', 'Failed to publish post': 'Failed to publish post',
'Failed to publish reply': 'Failed to publish reply', 'Failed to publish reply': 'Failed to publish reply',
'Failed to publish reaction': 'Failed to publish reaction',
'Failed to publish thread': 'Failed to publish thread', 'Failed to publish thread': 'Failed to publish thread',
'Failed to publish to some relays. Please try again or use different relays.': 'Failed to publish to some relays. Please try again or use different relays.':
'Failed to publish to some relays. Please try again or use different relays.', 'Failed to publish to some relays. Please try again or use different relays.',
@ -1702,6 +1703,7 @@ export default {
'Rate limited. Please wait before trying again.': 'Rate limited. Please wait before trying again.':
'Rate limited. Please wait before trying again.', 'Rate limited. Please wait before trying again.',
'Reaction published': 'Reaction published', 'Reaction published': 'Reaction published',
'Reaction published to some relays': 'Reaction published to some relays',
'Reaction removed': 'Reaction removed', 'Reaction removed': 'Reaction removed',
'Read full article': 'Read full article', 'Read full article': 'Read full article',
'Reading group entry': 'Reading group entry', 'Reading group entry': 'Reading group entry',

359
src/services/client.service.ts

@ -653,7 +653,12 @@ class ClientService extends EventTarget {
.slice(0, MAX_PUBLISH_RELAYS) .slice(0, MAX_PUBLISH_RELAYS)
} }
private async capPublishRelayUrlsForPublish( /**
* Same ordering as {@link prioritizePublishUrlList} but bounded so relay-list / inbox fetches
* cannot block publishing indefinitely. Used from {@link determineTargetRelays} (before
* {@link publishEvent}) and from {@link capPublishRelayUrlsForPublish}.
*/
private async prioritizePublishUrlListWithTimeout(
relayUrls: string[], relayUrls: string[],
event: NEvent, event: NEvent,
favoriteRelayUrls: string[] = [] favoriteRelayUrls: string[] = []
@ -675,6 +680,72 @@ class ClientService extends EventTarget {
]) ])
} }
private async capPublishRelayUrlsForPublish(
relayUrls: string[],
event: NEvent,
favoriteRelayUrls: string[] = []
): Promise<string[]> {
return this.prioritizePublishUrlListWithTimeout(relayUrls, event, favoriteRelayUrls)
}
private emptyRelayListForPublish(): TRelayList {
return {
write: [],
read: [],
originalRelays: [],
httpRead: [],
httpWrite: [],
httpOriginalRelays: []
}
}
/** Bounded wait so NIP-65 fetch cannot block publishing (reactions, replies without relay picker, etc.). */
private async fetchRelayListWithPublishTimeout(pubkey: string): Promise<TRelayList> {
const empty = this.emptyRelayListForPublish()
try {
return await Promise.race([
this.fetchRelayList(pubkey),
new Promise<TRelayList>((resolve) =>
setTimeout(() => {
logger.warn('[DetermineTargetRelays] fetchRelayList timed out; using empty outbox', {
pubkeySlice: pubkey.slice(0, 12)
})
resolve(empty)
}, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS)
)
])
} catch (err) {
logger.warn('[DetermineTargetRelays] fetchRelayList failed, using fallback relays', {
pubkeySlice: pubkey.slice(0, 12),
error: err instanceof Error ? err.message : String(err)
})
return empty
}
}
private async fetchRelayListsWithPublishTimeout(pubkeys: string[]): Promise<TRelayList[]> {
if (pubkeys.length === 0) return []
try {
return await Promise.race([
this.fetchRelayLists(pubkeys),
new Promise<TRelayList[]>((resolve) =>
setTimeout(() => {
logger.warn('[DetermineTargetRelays] fetchRelayLists timed out; skipping context inbox merge', {
pubkeyCount: pubkeys.length
})
resolve(pubkeys.map(() => this.emptyRelayListForPublish()))
}, PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS)
)
])
} catch (err) {
logger.warn('[DetermineTargetRelays] fetchRelayLists failed', {
pubkeyCount: pubkeys.length,
error: err instanceof Error ? err.message : String(err)
})
return pubkeys.map(() => this.emptyRelayListForPublish())
}
}
/** /**
* Determine which relays to publish an event to. * Determine which relays to publish an event to.
* Fallbacks (used when user relay list is empty or fetch fails): * Fallbacks (used when user relay list is empty or fetch fails):
@ -703,7 +774,7 @@ class ClientService extends EventTarget {
// For Report events, always include user's write relays first, then add seen relays if they're write-capable // For Report events, always include user's write relays first, then add seen relays if they're write-capable
if (event.kind === kinds.Report) { if (event.kind === kinds.Report) {
// Start with user's write relays (outboxes) - these are the primary targets for reports // Start with user's write relays (outboxes) - these are the primary targets for reports
const relayList = await this.fetchRelayList(event.pubkey) const relayList = await this.fetchRelayListWithPublishTimeout(event.pubkey)
const reportHttpWrites = (relayList?.httpWrite ?? []) const reportHttpWrites = (relayList?.httpWrite ?? [])
.map((url) => normalizeHttpRelayUrl(url) || url) .map((url) => normalizeHttpRelayUrl(url) || url)
.filter((u): u is string => !!u) .filter((u): u is string => !!u)
@ -757,7 +828,19 @@ class ClientService extends EventTarget {
event.kind === ExtendedKind.PUBLIC_MESSAGE || event.kind === ExtendedKind.PUBLIC_MESSAGE ||
event.kind === ExtendedKind.CALENDAR_EVENT_RSVP event.kind === ExtendedKind.CALENDAR_EVENT_RSVP
) { ) {
const authorRelayList = await this.fetchRelayList(event.pubkey).catch(() => ({ write: [] as string[], read: [] as string[], httpWrite: [] as string[], httpRead: [] as string[] })) const recipientPubkeys = Array.from(
new Set(
event.tags.filter((t) => t[0] === 'p' && t[1] && isValidPubkey(t[1])).map((t) => t[1] as string)
)
).filter((p) => p !== event.pubkey)
const recipientListsPromise =
recipientPubkeys.length > 0
? this.fetchRelayListsWithPublishTimeout(recipientPubkeys)
: Promise.resolve([] as TRelayList[])
const [authorRelayList, recipientRelayLists] = await Promise.all([
this.fetchRelayListWithPublishTimeout(event.pubkey),
recipientListsPromise
])
const authorHttpWrites = (authorRelayList?.httpWrite ?? []) const authorHttpWrites = (authorRelayList?.httpWrite ?? [])
.map((url) => normalizeHttpRelayUrl(url)) .map((url) => normalizeHttpRelayUrl(url))
.filter((url): url is string => !!url) .filter((url): url is string => !!url)
@ -768,20 +851,12 @@ class ClientService extends EventTarget {
if (authorWrite.length === 0) { if (authorWrite.length === 0) {
authorWrite = [...FAST_WRITE_RELAY_URLS] authorWrite = [...FAST_WRITE_RELAY_URLS]
} }
const recipientPubkeys = Array.from(
new Set(
event.tags.filter((t) => t[0] === 'p' && t[1] && isValidPubkey(t[1])).map((t) => t[1] as string)
)
).filter((p) => p !== event.pubkey)
let recipientRead: string[] = [] let recipientRead: string[] = []
if (recipientPubkeys.length > 0) { recipientRead = recipientRelayLists.flatMap((rl) => [
const recipientRelayLists = await this.fetchRelayLists(recipientPubkeys) ...(rl?.httpRead ?? []).map((url) => normalizeHttpRelayUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)),
recipientRead = recipientRelayLists.flatMap((rl) => [ ...(rl?.read ?? []).map((url) => normalizeUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u))
...(rl?.httpRead ?? []).map((url) => normalizeHttpRelayUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)), ])
...(rl?.read ?? []).map((url) => normalizeUrl(url)).filter((u): u is string => !!u && !isLocalNetworkUrl(u)) recipientRead = dedupeNormalizeRelayUrlsOrdered(recipientRead)
])
recipientRead = dedupeNormalizeRelayUrlsOrdered(recipientRead)
}
let pubRelays = mergeRelayPriorityLayers( let pubRelays = mergeRelayPriorityLayers(
[relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)], [relayUrlsLocalsFirst(authorWrite), dedupeNormalizeRelayUrlsOrdered(recipientRead)],
blockedRelayUrls, blockedRelayUrls,
@ -818,20 +893,13 @@ class ClientService extends EventTarget {
if (event.kind === ExtendedKind.SPELL) { if (event.kind === ExtendedKind.SPELL) {
let spellRelayList: TRelayList | undefined let spellRelayList: TRelayList | undefined
try { try {
spellRelayList = await this.fetchRelayList(event.pubkey) spellRelayList = await this.fetchRelayListWithPublishTimeout(event.pubkey)
} catch (err) { } catch (err) {
logger.warn('[DetermineTargetRelays] fetchRelayList failed for spell', { logger.warn('[DetermineTargetRelays] fetchRelayList failed for spell', {
pubkey: event.pubkey, pubkey: event.pubkey,
error: err instanceof Error ? err.message : String(err) error: err instanceof Error ? err.message : String(err)
}) })
spellRelayList = { spellRelayList = this.emptyRelayListForPublish()
write: [],
read: [],
originalRelays: [],
httpRead: [],
httpWrite: [],
httpOriginalRelays: []
}
} }
const spellHttpWrites = (spellRelayList?.httpWrite ?? []) const spellHttpWrites = (spellRelayList?.httpWrite ?? [])
.map((url) => normalizeHttpRelayUrl(url)) .map((url) => normalizeHttpRelayUrl(url))
@ -862,25 +930,26 @@ class ClientService extends EventTarget {
const bootstrapExtras: string[] = [...(additionalRelayUrls ?? [])] const bootstrapExtras: string[] = [...(additionalRelayUrls ?? [])]
let authorInboxFromContext: string[] = [] let authorInboxFromContext: string[] = []
if ( const shouldMergeContextInboxes =
!specifiedRelayUrls?.length && !specifiedRelayUrls?.length &&
![kinds.Contacts, kinds.Mutelist, ExtendedKind.FOLLOW_SET].includes(event.kind) ![kinds.Contacts, kinds.Mutelist, ExtendedKind.FOLLOW_SET].includes(event.kind)
) { const ctxPubkeys = shouldMergeContextInboxes ? this.collectReplyAndMentionPubkeys(event) : []
const ctxPubkeys = this.collectReplyAndMentionPubkeys(event) const relayListsPromise =
if (ctxPubkeys.length > 0) { ctxPubkeys.length > 0
const relayLists = await this.fetchRelayLists(ctxPubkeys) ? this.fetchRelayListsWithPublishTimeout(ctxPubkeys)
relayLists.forEach((relayList) => { : Promise.resolve([] as TRelayList[])
for (const u of relayList.httpRead ?? []) { const relayListPromise = this.fetchRelayListWithPublishTimeout(event.pubkey)
const n = normalizeHttpRelayUrl(u) || u const [relayLists, relayList] = await Promise.all([relayListsPromise, relayListPromise])
if (n) authorInboxFromContext.push(n) relayLists.forEach((rl) => {
} for (const u of rl.httpRead ?? []) {
for (const u of relayList.read ?? []) { const n = normalizeHttpRelayUrl(u) || u
const n = normalizeUrl(u) || u if (n) authorInboxFromContext.push(n)
if (n) authorInboxFromContext.push(n)
}
})
} }
} for (const u of rl.read ?? []) {
const n = normalizeUrl(u) || u
if (n) authorInboxFromContext.push(n)
}
})
if ( if (
[ [
kinds.RelayList, kinds.RelayList,
@ -918,34 +987,9 @@ class ClientService extends EventTarget {
event.kind === ExtendedKind.FAVORITE_RELAYS || event.kind === ExtendedKind.FAVORITE_RELAYS ||
event.kind === kinds.Relaysets event.kind === kinds.Relaysets
) { ) {
logger.debug('[DetermineTargetRelays] Fetching user relay list for event publication', { logger.debug('[DetermineTargetRelays] User relay list resolved for publication', {
pubkey: event.pubkey,
kind: event.kind
})
}
let relayList: TRelayList | undefined
try {
relayList = await this.fetchRelayList(event.pubkey)
} catch (err) {
logger.warn('[DetermineTargetRelays] fetchRelayList failed, using fallback relays', {
pubkey: event.pubkey, pubkey: event.pubkey,
error: err instanceof Error ? err.message : String(err) kind: event.kind,
})
relayList = {
write: [],
read: [],
originalRelays: [],
httpRead: [],
httpWrite: [],
httpOriginalRelays: []
}
}
if (
event.kind === kinds.RelayList ||
event.kind === ExtendedKind.FAVORITE_RELAYS ||
event.kind === kinds.Relaysets
) {
logger.debug('[DetermineTargetRelays] User relay list fetched', {
hasRelayList: !!relayList, hasRelayList: !!relayList,
writeRelayCount: relayList?.write?.length ?? 0, writeRelayCount: relayList?.write?.length ?? 0,
readRelayCount: relayList?.read?.length ?? 0, readRelayCount: relayList?.read?.length ?? 0,
@ -1000,7 +1044,7 @@ class ClientService extends EventTarget {
if (specifiedRelayUrls?.length) { if (specifiedRelayUrls?.length) {
const checkedCount = specifiedRelayUrls.length const checkedCount = specifiedRelayUrls.length
relays = await this.prioritizePublishUrlList(relays, event, favoriteRelayUrls ?? []) relays = await this.prioritizePublishUrlListWithTimeout(relays, event, favoriteRelayUrls ?? [])
if (checkedCount > relays.length) { if (checkedCount > relays.length) {
logger.info('[Publish] Relay picker: checked count exceeds per-publish cap (stage 1)', { logger.info('[Publish] Relay picker: checked count exceeds per-publish cap (stage 1)', {
checkedInRelayPicker: checkedCount, checkedInRelayPicker: checkedCount,
@ -3330,39 +3374,28 @@ class ClientService extends EventTarget {
return requestPromise return requestPromise
} }
async fetchRelayLists(pubkeys: string[]): Promise<TRelayList[]> { /**
// First check IndexedDB for offline/quick access (prioritizes cache relays for offline use) * Merge NIP-65 (10002), HTTP relay list (10243), and cache relays (10432) from network and/or IndexedDB.
const storedRelayEvents = await Promise.all( * Network arrays may be sparse/undefined per index; stored* always filled from IDB reads.
pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, kinds.RelayList)) */
) private mergeRelayListsBundle(
const storedCacheRelayEvents = await Promise.all( pubkeys: string[],
pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.CACHE_RELAYS)) relayEvents: (NEvent | null | undefined)[],
) httpRelayEvents: (NEvent | null | undefined)[],
const storedHttpRelayEvents = await Promise.all( cacheRelayEvents: (NEvent | null | undefined)[],
pubkeys.map(pubkey => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.HTTP_RELAY_LIST)) storedRelayEvents: (NEvent | null | undefined)[],
) storedHttpRelayEvents: (NEvent | null | undefined)[],
storedCacheRelayEvents: (NEvent | null | undefined)[]
// Then fetch from relays (will update cache if newer) ): TRelayList[] {
const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(pubkeys, kinds.RelayList)
const httpRelayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(
pubkeys,
ExtendedKind.HTTP_RELAY_LIST
)
// Fetch cache relays from multiple sources: FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, and user's inboxes/outboxes
const cacheRelayEvents = await this.fetchCacheRelayEventsFromMultipleSources(pubkeys, relayEvents, storedRelayEvents)
return pubkeys.map((targetPubkey, index) => { return pubkeys.map((targetPubkey, index) => {
const isOwnRelayList = const isOwnRelayList =
this.pubkey != null && hexPubkeysEqual(this.pubkey, userIdToPubkey(targetPubkey)) this.pubkey != null && hexPubkeysEqual(this.pubkey, userIdToPubkey(targetPubkey))
// Use stored cache relay event if available (for offline), otherwise use fetched one
const storedCacheEvent = storedCacheRelayEvents[index] const storedCacheEvent = storedCacheRelayEvents[index]
const cacheEvent = cacheRelayEvents[index] || storedCacheEvent const cacheEvent = cacheRelayEvents[index] || storedCacheEvent
const httpRelayEvent = httpRelayEvents[index] || storedHttpRelayEvents[index] const httpRelayEvent = httpRelayEvents[index] || storedHttpRelayEvents[index]
// Use stored relay event if no network event (for offline), otherwise use fetched one
const storedRelayEvent = storedRelayEvents[index] const storedRelayEvent = storedRelayEvents[index]
const relayEvent = relayEvents[index] || storedRelayEvent const relayEvent = relayEvents[index] || storedRelayEvent
@ -3388,27 +3421,22 @@ class ClientService extends EventTarget {
...emptyHttp ...emptyHttp
} }
// Merge kind 10432 (cache relays) only for the logged-in user — never use someone else's local relays.
if (isOwnRelayList && cacheEvent) { if (isOwnRelayList && cacheEvent) {
const cacheRelayList = getRelayListFromEvent(cacheEvent) const cacheRelayList = getRelayListFromEvent(cacheEvent)
// Merge read relays - cache relays first, then others (for offline priority)
const mergedRead = [...cacheRelayList.read, ...relayList.read] const mergedRead = [...cacheRelayList.read, ...relayList.read]
const mergedWrite = [...cacheRelayList.write, ...relayList.write] const mergedWrite = [...cacheRelayList.write, ...relayList.write]
const mergedOriginalRelays = new Map<string, TMailboxRelay>() const mergedOriginalRelays = new Map<string, TMailboxRelay>()
// Add cache relay original relays first (prioritized) cacheRelayList.originalRelays.forEach((relay) => {
cacheRelayList.originalRelays.forEach(relay => {
mergedOriginalRelays.set(relay.url, relay) mergedOriginalRelays.set(relay.url, relay)
}) })
// Then add regular relay original relays relayList.originalRelays.forEach((relay) => {
relayList.originalRelays.forEach(relay => {
if (!mergedOriginalRelays.has(relay.url)) { if (!mergedOriginalRelays.has(relay.url)) {
mergedOriginalRelays.set(relay.url, relay) mergedOriginalRelays.set(relay.url, relay)
} }
}) })
// Deduplicate while preserving order (cache relays first)
return mergeKind10243({ return mergeKind10243({
write: Array.from(new Set(mergedWrite)), write: Array.from(new Set(mergedWrite)),
read: Array.from(new Set(mergedRead)), read: Array.from(new Set(mergedRead)),
@ -3417,7 +3445,6 @@ class ClientService extends EventTarget {
}) })
} }
// If no merged cache path, return original relay list or default (with own cache as fallback only)
if (!relayEvent) { if (!relayEvent) {
if (isOwnRelayList && storedCacheEvent) { if (isOwnRelayList && storedCacheEvent) {
const cacheRelayList = getRelayListFromEvent(storedCacheEvent) const cacheRelayList = getRelayListFromEvent(storedCacheEvent)
@ -3444,6 +3471,119 @@ class ClientService extends EventTarget {
}) })
} }
/** Background refresh so UI/publish can use IDB immediately while relays catch up. */
private refreshRelayListsFromNetwork(
pubkeys: string[],
storedKind10002: (NEvent | null | undefined)[]
): void {
void (async () => {
try {
const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(
pubkeys,
kinds.RelayList
)
await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(
pubkeys,
ExtendedKind.HTTP_RELAY_LIST
)
await this.fetchCacheRelayEventsFromMultipleSources(pubkeys, relayEvents, storedKind10002)
} catch {
/* best-effort */
}
})()
}
async fetchRelayLists(pubkeys: string[]): Promise<TRelayList[]> {
if (pubkeys.length === 0) return []
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))
)
const storedHttpRelayEvents = await Promise.all(
pubkeys.map((pubkey) => indexedDb.getReplaceableEvent(pubkey, ExtendedKind.HTTP_RELAY_LIST))
)
const budgetMs = PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS
const allHaveKind10002 = pubkeys.every((_, i) => storedRelayEvents[i] != null)
const networkBundle = async (): Promise<{
relayEvents: (NEvent | null | undefined)[]
httpRelayEvents: (NEvent | null | undefined)[]
cacheRelayEvents: (NEvent | null | undefined)[]
}> => {
const relayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(
pubkeys,
kinds.RelayList
)
const httpRelayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(
pubkeys,
ExtendedKind.HTTP_RELAY_LIST
)
const cacheRelayEvents = await this.fetchCacheRelayEventsFromMultipleSources(
pubkeys,
relayEvents,
storedRelayEvents
)
return { relayEvents, httpRelayEvents, cacheRelayEvents }
}
if (allHaveKind10002) {
this.refreshRelayListsFromNetwork(pubkeys, storedRelayEvents)
logger.debug(
'[FetchRelayLists] Kind 10002 present in IndexedDB for all pubkeys; merging locally, network refresh in background',
{ count: pubkeys.length }
)
const cacheRelayEvents = await Promise.race([
this.fetchCacheRelayEventsFromMultipleSources(pubkeys, storedRelayEvents, storedRelayEvents),
new Promise<(NEvent | null | undefined)[]>((resolve) =>
setTimeout(() => resolve(storedCacheRelayEvents.map((e) => e ?? undefined)), budgetMs)
)
])
return this.mergeRelayListsBundle(
pubkeys,
storedRelayEvents.map((e) => e ?? undefined),
storedHttpRelayEvents.map((e) => e ?? undefined),
cacheRelayEvents,
storedRelayEvents,
storedHttpRelayEvents,
storedCacheRelayEvents
)
}
const raced = await Promise.race([
networkBundle(),
new Promise<null>((resolve) => setTimeout(() => resolve(null), budgetMs))
])
if (raced != null) {
return this.mergeRelayListsBundle(
pubkeys,
raced.relayEvents,
raced.httpRelayEvents,
raced.cacheRelayEvents,
storedRelayEvents,
storedHttpRelayEvents,
storedCacheRelayEvents
)
}
logger.warn('[FetchRelayLists] Network relay-list fetch exceeded budget; using IndexedDB / empty network layer only', {
pubkeyCount: pubkeys.length
})
const cacheRelayEvents = storedCacheRelayEvents.map((e) => e ?? undefined)
return this.mergeRelayListsBundle(
pubkeys,
pubkeys.map(() => undefined),
pubkeys.map(() => undefined),
cacheRelayEvents,
storedRelayEvents,
storedHttpRelayEvents,
storedCacheRelayEvents
)
}
async forceUpdateRelayListEvent(pubkey: string) { async forceUpdateRelayListEvent(pubkey: string) {
await this.replaceableEventService.fetchReplaceableEvent(pubkey, kinds.RelayList) await this.replaceableEventService.fetchReplaceableEvent(pubkey, kinds.RelayList)
} }
@ -3471,11 +3611,18 @@ class ClientService extends EventTarget {
return storedCacheRelayEvents return storedCacheRelayEvents
} }
// Fetch from PROFILE_FETCH_RELAY_URLS const cacheRelayEvents = await Promise.race([
const cacheRelayEvents = await this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays( this.replaceableEventService.fetchReplaceableEventsFromProfileFetchRelays(
pubkeysToFetch, pubkeysToFetch,
ExtendedKind.CACHE_RELAYS ExtendedKind.CACHE_RELAYS
) ),
new Promise<(NEvent | undefined)[]>((resolve) =>
setTimeout(
() => resolve(pubkeysToFetch.map(() => undefined)),
PUBLISH_RELAY_LIST_RESOLUTION_TIMEOUT_MS
)
)
])
// Map results back to original pubkey order // Map results back to original pubkey order
return pubkeys.map((pubkey, index) => { return pubkeys.map((pubkey, index) => {

Loading…
Cancel
Save