From c4288cd03993f0e64ec006627450a60d602cd45c Mon Sep 17 00:00:00 2001 From: Silberengel Date: Thu, 7 May 2026 13:57:04 +0200 Subject: [PATCH] bug-fixes --- .../Sidebar/SidebarCalendarWeekWidget.tsx | 43 +++--- src/lib/calendar-event.ts | 31 +++++ src/pages/primary/CalendarPrimaryPage.tsx | 53 ++++--- .../primary/SpellsPage/useSpellsPageFeed.ts | 131 ++++++++++++++---- .../FavoriteRelaysActivityProvider.tsx | 72 ++++++++-- .../client-replaceable-events.service.ts | 92 ++++++++---- src/services/client.service.ts | 6 +- vite.config.ts | 12 +- 8 files changed, 338 insertions(+), 102 deletions(-) diff --git a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx index 6df76764..5e05477d 100644 --- a/src/components/Sidebar/SidebarCalendarWeekWidget.tsx +++ b/src/components/Sidebar/SidebarCalendarWeekWidget.tsx @@ -1,5 +1,6 @@ import { calendarOccurrenceOverlapsRange, + dedupeCalendarEventsPreferringOccurrenceRange, formatCalendarSidebarRow, formatSidebarWeekLabel, getCalendarEventMeta, @@ -38,16 +39,6 @@ const SIDEBAR_CALENDAR_MAX_RELAYS = 24 /** Merge session cache so events already loaded in feeds (but missed by this REQ) still appear. */ const SESSION_CALENDAR_MERGE_CAP = 5000 -function dedupeCalendarEvents(events: Event[]): Event[] { - const map = new Map() - for (const e of events) { - const k = replaceableEventDedupeKey(e) - const prev = map.get(k) - if (!prev || e.created_at > prev.created_at) map.set(k, e) - } - return [...map.values()] -} - export default function SidebarCalendarWeekWidget() { const { t } = useTranslation() const { relayList, pubkey } = useNostr() @@ -117,13 +108,21 @@ export default function SidebarCalendarWeekWidget() { indexedDb.getArchivedCalendarEventsOverlappingWindow(weekStartMs, weekEndExclusiveMs, 25_000, 400) ]) - const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive]) + const localBaseline = dedupeCalendarEventsPreferringOccurrenceRange( + [...fromIdb, ...fromArchive], + weekStartMs, + weekEndExclusiveMs + ) const sessionSnap = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - const mergedLocal = dedupeCalendarEvents([...localBaseline, ...sessionSnap]) + const mergedLocal = dedupeCalendarEventsPreferringOccurrenceRange( + [...localBaseline, ...sessionSnap], + weekStartMs, + weekEndExclusiveMs + ) /** Always paint IDB + session first; a superseded effect must not skip this (relayKey churn would leave the list blank). */ if (!cancelled) { setRawEvents(mergedLocal) @@ -136,12 +135,15 @@ export default function SidebarCalendarWeekWidget() { lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return + const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) const later = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...localBaseline])) + setRawEvents((prev) => + dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], ws, we) + ) }, 2500) return } @@ -207,18 +209,25 @@ export default function SidebarCalendarWeekWidget() { ) if (!cancelled) { setRawEvents( - dedupeCalendarEvents([...localBaseline, ...fromSessionAfterNet, ...batch, ...fromFollowing]) + dedupeCalendarEventsPreferringOccurrenceRange( + [...localBaseline, ...fromSessionAfterNet, ...batch, ...fromFollowing], + weekStartMs, + weekEndExclusiveMs + ) ) } lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return + const { weekStartMs: ws, weekEndExclusiveMs: we } = getLocalMondayWeekBounds(weekOffset) const later = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...localBaseline])) + setRawEvents((prev) => + dedupeCalendarEventsPreferringOccurrenceRange([...prev, ...later, ...localBaseline], ws, we) + ) }, 2500) } catch { if (!cancelled) { @@ -228,13 +237,13 @@ export default function SidebarCalendarWeekWidget() { indexedDb.getCalendarEventsForOccurrenceWindow(ws, we), indexedDb.getArchivedCalendarEventsOverlappingWindow(ws, we, 25_000, 400) ]) - const salvage = dedupeCalendarEvents([...idb, ...arc]) + const salvage = dedupeCalendarEventsPreferringOccurrenceRange([...idb, ...arc], ws, we) const fromSession = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents(dedupeCalendarEvents([...salvage, ...fromSession])) + setRawEvents(dedupeCalendarEventsPreferringOccurrenceRange([...salvage, ...fromSession], ws, we)) } catch { setRawEvents([]) } diff --git a/src/lib/calendar-event.ts b/src/lib/calendar-event.ts index 32b75613..72cc4f28 100644 --- a/src/lib/calendar-event.ts +++ b/src/lib/calendar-event.ts @@ -1,4 +1,5 @@ import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' +import { replaceableEventDedupeKey } from '@/lib/event' import { generateBech32IdFromATag, tagNameEquals } from '@/lib/tag' import { Event } from 'nostr-tools' @@ -443,6 +444,36 @@ export function calendarOccurrenceOverlapsRange( return w.startMs < rangeEndExclusiveMs && w.endExclusiveMs > rangeStartMs } +/** + * Deduplicate by replaceable coordinate; when several revisions exist, prefer one whose occurrence **overlaps** + * `[rangeStartMs, rangeEndExclusiveMs)` with a parseable window, then newest `created_at`. Avoids global calendar + * REQs replacing a good local row with a newer revision that does not apply to the visible range. + */ +export function dedupeCalendarEventsPreferringOccurrenceRange( + events: Event[], + rangeStartMs: number, + rangeEndExclusiveMs: number +): Event[] { + const byKey = new Map() + for (const e of events) { + const k = replaceableEventDedupeKey(e) + const list = byKey.get(k) + if (list) list.push(e) + else byKey.set(k, [e]) + } + const out: Event[] = [] + for (const variants of byKey.values()) { + const inRange = variants.filter( + (e) => + getCalendarOccurrenceWindowMs(e) != null && + calendarOccurrenceOverlapsRange(e, rangeStartMs, rangeEndExclusiveMs) + ) + const pool = inRange.length > 0 ? inRange : variants + out.push(pool.reduce((best, e) => (e.created_at > best.created_at ? e : best))) + } + return out +} + /** Monday 00:00 local through the following Monday 00:00 (exclusive), shifted by `weekOffset` weeks from the anchor week. */ /** Local midnight on the 1st through midnight on the 1st of the following month (exclusive). */ export function getLocalMonthRangeMs( diff --git a/src/pages/primary/CalendarPrimaryPage.tsx b/src/pages/primary/CalendarPrimaryPage.tsx index 19b127c8..d28db4fb 100644 --- a/src/pages/primary/CalendarPrimaryPage.tsx +++ b/src/pages/primary/CalendarPrimaryPage.tsx @@ -1,6 +1,7 @@ import PrimaryPageLayout, { type TPrimaryPageLayoutRef } from '@/layouts/PrimaryPageLayout' import { calendarOccurrenceOverlapsRange, + dedupeCalendarEventsPreferringOccurrenceRange, getCalendarEventMeta, getLocalMondayWeekBounds, getLocalMonthRangeMs @@ -52,16 +53,6 @@ export type CalendarPrimaryPageProps = { weekOffset?: number } -function dedupeCalendarEvents(events: NostrEvent[]): NostrEvent[] { - const map = new Map() - for (const e of events) { - const k = replaceableEventDedupeKey(e) - const prev = map.get(k) - if (!prev || e.created_at > prev.created_at) map.set(k, e) - } - return [...map.values()] -} - function mondayFirstOffsetFromMonthStart(year: number, monthIndex: number): number { const first = new Date(year, monthIndex, 1, 0, 0, 0, 0) const dow = first.getDay() @@ -173,7 +164,13 @@ const CalendarPrimaryPage = forwardRef(funct SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...mergeWithIdb])) + setRawEvents((prev) => + dedupeCalendarEventsPreferringOccurrenceRange( + [...prev, ...later, ...mergeWithIdb], + paddedMonthRange.rangeStartMs, + paddedMonthRange.rangeEndExclusiveMs + ) + ) }, 2500) } @@ -194,14 +191,24 @@ const CalendarPrimaryPage = forwardRef(funct ]) if (cancelled) return - const localBaseline = dedupeCalendarEvents([...fromIdb, ...fromArchive]) + const localBaseline = dedupeCalendarEventsPreferringOccurrenceRange( + [...fromIdb, ...fromArchive], + rangeStartMs, + rangeEndExclusiveMs + ) const fromSessionNow = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents(dedupeCalendarEvents([...localBaseline, ...fromSessionNow])) + setRawEvents( + dedupeCalendarEventsPreferringOccurrenceRange( + [...localBaseline, ...fromSessionNow], + rangeStartMs, + rangeEndExclusiveMs + ) + ) setLoading(false) if (!relayUrls.length) { @@ -268,7 +275,13 @@ const CalendarPrimaryPage = forwardRef(funct SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents(dedupeCalendarEvents([...batch, ...fromFollowing, ...fromSession, ...localBaseline])) + setRawEvents( + dedupeCalendarEventsPreferringOccurrenceRange( + [...batch, ...fromFollowing, ...fromSession, ...localBaseline], + rangeStartMs, + rangeEndExclusiveMs + ) + ) lateMergeTimer = window.setTimeout(() => { lateMergeTimer = null if (cancelled) return @@ -277,7 +290,13 @@ const CalendarPrimaryPage = forwardRef(funct SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents((prev) => dedupeCalendarEvents([...prev, ...later, ...localBaseline])) + setRawEvents((prev) => + dedupeCalendarEventsPreferringOccurrenceRange( + [...prev, ...later, ...localBaseline], + rangeStartMs, + rangeEndExclusiveMs + ) + ) }, 2500) } catch { if (!cancelled) { @@ -287,13 +306,13 @@ const CalendarPrimaryPage = forwardRef(funct indexedDb.getCalendarEventsForOccurrenceWindow(rs, re, MONTH_IDB_MAX_SCAN), indexedDb.getArchivedCalendarEventsOverlappingWindow(rs, re, 55_000, 2500) ]) - const salvage = dedupeCalendarEvents([...idb, ...arc]) + const salvage = dedupeCalendarEventsPreferringOccurrenceRange([...idb, ...arc], rs, re) const fromSession = client.getSessionEventsMatchingSearch( '', SESSION_CALENDAR_MERGE_CAP, [...CALENDAR_EVENT_KINDS] ) - setRawEvents(dedupeCalendarEvents([...salvage, ...fromSession])) + setRawEvents(dedupeCalendarEventsPreferringOccurrenceRange([...salvage, ...fromSession], rs, re)) } catch { setRawEvents([]) } diff --git a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts index 6fc2a99f..813271da 100644 --- a/src/pages/primary/SpellsPage/useSpellsPageFeed.ts +++ b/src/pages/primary/SpellsPage/useSpellsPageFeed.ts @@ -40,6 +40,55 @@ import type { TFeedSubRequest } from '@/types' import { isFollowFeedFauxSpellId } from './fauxSpellConfig' import storage from '@/services/local-storage.service' +/** `fetchReplaceableEvent(kind 3)` / relay-list hydration can hang; never block the Following spell on it. */ +const FOLLOWING_FETCH_FOLLOWINGS_TIMEOUT_MS = 10_000 +/** Per-shard relay-list batch has a UI budget; still cap so a wedged promise cannot blank the feed forever. */ +const FOLLOWING_GENERATE_SUBREQ_TIMEOUT_MS = 16_000 +const FOLLOWING_INBOX_SHARD_AUTHOR_CAP = 512 + +function racePromiseWithTimeout(promise: Promise, ms: number, onTimeout: () => T): Promise { + return new Promise((resolve) => { + const t = window.setTimeout(() => resolve(onTimeout()), ms) + promise + .then((v) => { + window.clearTimeout(t) + resolve(v) + }) + .catch(() => { + window.clearTimeout(t) + resolve(onTimeout()) + }) + }) +} + +function buildInboxShardFollowingSubRequests(args: { + authors: string[] + favoriteRelays: string[] + blockedRelays: string[] + relayList: { read: string[]; write: string[] } | null | undefined + augment: (raw: TFeedSubRequest[]) => TFeedSubRequest[] +}): TFeedSubRequest[] { + const { authors, favoriteRelays, blockedRelays, relayList, augment } = args + const feedUrls = getRelayUrlsWithFavoritesFastReadAndInbox( + favoriteRelays, + blockedRelays, + userReadRelaysWithHttp(relayList), + { userWriteRelays: relayList?.write ?? [] } + ) + if (!feedUrls.length) return [] + const capped = authors.slice(0, FOLLOWING_INBOX_SHARD_AUTHOR_CAP) + return augment([ + { + urls: feedUrls, + filter: { + authors: capped, + kinds: [...DEFAULT_FEED_SHOW_KINDS], + limit: FAUX_SPELL_EVENT_LIMIT + } + } + ]) +} + function useNoteListHideReplies() { const [hideReplies, setHideReplies] = useState(() => storage.getNoteListMode() === 'posts') @@ -166,41 +215,55 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { if (selectedFauxSpell === 'following') { const fromTags = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] const provisionalAuthors = [...new Set([pubkey, ...fromTags])] - let provisionalOk = false - try { - const rawProv = await client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey) - if (!cancelled) { - setFollowingSubRequests(augment(rawProv)) - provisionalOk = true - } - } catch { - /* refined wave may still succeed */ - } - let followings = fromTags - try { - followings = await client.fetchFollowings(pubkey) - } catch { - followings = followListEvent ? getPubkeysFromPTags(followListEvent.tags) : [] + const inboxFallbackArgs = { + favoriteRelays, + blockedRelays, + relayList, + augment } + + const [rawProv, followings] = await Promise.all([ + racePromiseWithTimeout( + client.generateSubRequestsForPubkeys(provisionalAuthors, pubkey) as Promise, + FOLLOWING_GENERATE_SUBREQ_TIMEOUT_MS, + () => [] + ), + racePromiseWithTimeout( + client.fetchFollowings(pubkey).catch(() => fromTags), + FOLLOWING_FETCH_FOLLOWINGS_TIMEOUT_MS, + () => fromTags + ) + ]) + + const provisionalNext = + rawProv.length > 0 + ? augment(rawProv) + : buildInboxShardFollowingSubRequests({ + authors: provisionalAuthors, + ...inboxFallbackArgs + }) + if (!cancelled) setFollowingSubRequests(provisionalNext) + const fullAuthors = [...new Set([pubkey, ...followings])] const sameSet = fullAuthors.length === provisionalAuthors.length && fullAuthors.every((p) => provisionalAuthors.includes(p)) && provisionalAuthors.every((p) => fullAuthors.includes(p)) if (sameSet) { - if (!provisionalOk && !cancelled) { - try { - const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey) - if (!cancelled) setFollowingSubRequests(augment(req)) - } catch { - if (!cancelled) setFollowingSubRequests([]) - } - } return } - const req = await client.generateSubRequestsForPubkeys(fullAuthors, pubkey) - if (!cancelled) setFollowingSubRequests(augment(req)) + + const rawFull = await racePromiseWithTimeout( + client.generateSubRequestsForPubkeys(fullAuthors, pubkey) as Promise, + FOLLOWING_GENERATE_SUBREQ_TIMEOUT_MS, + () => [] + ) + const fullNext = + rawFull.length > 0 + ? augment(rawFull) + : buildInboxShardFollowingSubRequests({ authors: fullAuthors, ...inboxFallbackArgs }) + if (!cancelled) setFollowingSubRequests(fullNext) } else if (followSetD) { const ev = followSetListEvents.find((e) => getFollowSetDTag(e) === followSetD) if (!ev) { @@ -209,8 +272,22 @@ export function useSpellsPageFeed(a: UseSpellsPageFeedArgs) { } const listed = pubkeysFromFollowSetEvent(ev) const authorPubkeys = [pubkey, ...listed] - const req = await client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) - if (!cancelled) setFollowingSubRequests(augment(req)) + const rawFs = await racePromiseWithTimeout( + client.generateSubRequestsForPubkeys(authorPubkeys, pubkey) as Promise, + FOLLOWING_GENERATE_SUBREQ_TIMEOUT_MS, + () => [] + ) + const req = + rawFs.length > 0 + ? augment(rawFs) + : buildInboxShardFollowingSubRequests({ + authors: authorPubkeys, + favoriteRelays, + blockedRelays, + relayList, + augment + }) + if (!cancelled) setFollowingSubRequests(req) } else { if (!cancelled) setFollowingSubRequests([]) } diff --git a/src/providers/FavoriteRelaysActivityProvider.tsx b/src/providers/FavoriteRelaysActivityProvider.tsx index bd54476b..a4f88065 100644 --- a/src/providers/FavoriteRelaysActivityProvider.tsx +++ b/src/providers/FavoriteRelaysActivityProvider.tsx @@ -21,11 +21,14 @@ import { } from './favorite-relays-activity-context' const ACTIVE_WINDOW_SEC = 3600 +/** Recent slice (seconds): newest notes dominate global REQ limits; a shorter window improves author diversity. */ +const PULSE_RECENT_TAIL_SEC = 1200 +/** Per-REQ event cap; two time slices run in parallel and merge (see {@link fetchRelayPulseNoteEvents}). */ +const PULSE_REQ_LIMIT_RECENT = 900 +const PULSE_REQ_LIMIT_EARLIER = 1400 const FETCH_RETRY_DELAY_MS = 2500 /** Wall-clock cadence while the tab is visible */ const POLL_INTERVAL_MS = 60 * 60 * 1000 -/** Event cap for relay pulse query. This is event-count (not author-count): keep high enough for >120 active npubs. */ -const REQ_LIMIT = 500 /** Keep relay pulse focused on note-like activity to avoid expensive all-kind signature verification bursts. */ const ACTIVE_PULSE_KINDS = [ kinds.ShortTextNote, @@ -39,6 +42,59 @@ const ACTIVE_PULSE_KINDS = [ ExtendedKind.GENERIC_REPOST ] as number[] +const PULSE_QUERY_OPTS = { + firstRelayResultGraceMs: false as const, + eoseTimeout: 1800, + globalTimeout: 14_000 +} + +function mergeRelayPulseEventsById(events: { id: string; pubkey: string; created_at: number }[]) { + const byId = new Map() + for (const e of events) { + const id = e.id?.trim().toLowerCase() + if (!id || !/^[0-9a-f]{64}$/i.test(id)) continue + const prev = byId.get(id) + if (!prev || e.created_at > prev.created_at) byId.set(id, e) + } + return [...byId.values()] +} + +/** + * One REQ with a high `limit` over a full hour mostly returns the newest notes, so a few threads can + * exhaust the cap and hide many active npubs. Two slices (recent tail + earlier in the same hour) + * merge by id, then we dedupe by pubkey for the widget. + */ +async function fetchRelayPulseNoteEvents( + urls: string[], + anchorSec: number +): Promise<{ pubkey: string; created_at: number; id: string }[]> { + const sinceFull = anchorSec - ACTIVE_WINDOW_SEC + const recentSince = anchorSec - PULSE_RECENT_TAIL_SEC + const kinds = [...ACTIVE_PULSE_KINDS] + const settled = await Promise.allSettled([ + queryService.fetchEvents( + urls, + { since: recentSince, limit: PULSE_REQ_LIMIT_RECENT, kinds }, + PULSE_QUERY_OPTS + ), + queryService.fetchEvents( + urls, + { + since: sinceFull, + until: recentSince, + limit: PULSE_REQ_LIMIT_EARLIER, + kinds + }, + PULSE_QUERY_OPTS + ) + ]) + const merged: { id: string; pubkey: string; created_at: number }[] = [] + for (const r of settled) { + if (r.status === 'fulfilled') merged.push(...r.value) + } + return mergeRelayPulseEventsById(merged) +} + function aggregatePubkeysByRecency(events: { pubkey: string; created_at: number }[]): string[] { const lastByPk = new Map() for (const e of events) { @@ -129,17 +185,9 @@ export function FavoriteRelaysActivityProvider({ children }: { children: React.R return } setLoading(true) - const since = Math.floor(Date.now() / 1000) - ACTIVE_WINDOW_SEC + const anchorSec = Math.floor(Date.now() / 1000) try { - const events = await queryService.fetchEvents( - urls, - { since, limit: REQ_LIMIT, kinds: [...ACTIVE_PULSE_KINDS] }, - { - firstRelayResultGraceMs: false, - eoseTimeout: 1800, - globalTimeout: 14_000 - } - ) + const events = await fetchRelayPulseNoteEvents(urls, anchorSec) const now = Date.now() const nextPubkeys = aggregatePubkeysByRecency(events) const prev = orderedPubkeysRef.current diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index 4960c194..64eacf64 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -13,8 +13,8 @@ import { import { kinds, nip19 } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools' import DataLoader from 'dataloader' -import { normalizeHttpUrl, normalizeUrl } from '@/lib/url' -import { getProfileFromEvent } from '@/lib/event-metadata' +import { isWebsocketUrl, normalizeAnyRelayUrl, normalizeHttpUrl, normalizeUrl } from '@/lib/url' +import { getProfileFromEvent, getRelayListFromEvent } from '@/lib/event-metadata' import { formatPubkey, pubkeyToNpub, userIdToPubkey } from '@/lib/pubkey' import { getPubkeysFromPTags, getServersFromServerTags } from '@/lib/tag' import { TProfile } from '@/types' @@ -1167,6 +1167,9 @@ export class ReplaceableEventService { * When relayUrls are provided (e.g. user write + search relays), queries those directly. * Otherwise uses the default relay set (FAST_WRITE + PROFILE_FETCH + FAST_READ). */ + /** Hard cap: {@link fetchReplaceableEvent} can otherwise wedge the DataLoader chain when relays never answer. */ + private static readonly FETCH_FOLLOW_LIST_REPLACEABLE_TIMEOUT_MS = 14_000 + async fetchFollowListEvent(pubkey: string, relayUrls?: string[]): Promise { if (relayUrls && relayUrls.length > 0) { const normalized = Array.from( @@ -1181,7 +1184,19 @@ export class ReplaceableEventService { const latest = events.sort((a, b) => b.created_at - a.created_at)[0] return latest } - return await this.fetchReplaceableEvent(pubkey, kinds.Contacts) + const fromNetwork = await Promise.race([ + this.fetchReplaceableEvent(pubkey, kinds.Contacts), + new Promise((resolve) => + setTimeout(() => resolve(undefined), ReplaceableEventService.FETCH_FOLLOW_LIST_REPLACEABLE_TIMEOUT_MS) + ) + ]) + if (fromNetwork) return fromNetwork + try { + const fromIdb = await indexedDb.getReplaceableEvent(pubkey, kinds.Contacts) + return fromIdb ?? undefined + } catch { + return undefined + } } /** @@ -1278,10 +1293,16 @@ export class ReplaceableEventService { if (!skipCache) { const cached = this.followingFavoriteRelaysCache.get(pubkey) if (cached) { - return cached + return cached.catch((err: unknown) => { + this.followingFavoriteRelaysCache.delete(pubkey) + throw err + }) } } - const promise = this._fetchFollowingFavoriteRelays(pubkey) + const promise = this._fetchFollowingFavoriteRelays(pubkey).catch((err: unknown) => { + this.followingFavoriteRelaysCache.delete(pubkey) + throw err + }) this.followingFavoriteRelaysCache.set(pubkey, promise) return promise } @@ -1289,46 +1310,63 @@ export class ReplaceableEventService { private async _fetchFollowingFavoriteRelays(pubkey: string): Promise<[string, string[]][]> { const followings = await this.fetchFollowings(pubkey) const followingsToProcess = followings.slice(0, 100) - const favoriteRelaysEvents = await this.fetchReplaceableEventsFromProfileFetchRelays( - followingsToProcess, - ExtendedKind.FAVORITE_RELAYS - ) + const [favoriteRelaysEvents, relayListEvents] = await Promise.all([ + this.fetchReplaceableEventsFromProfileFetchRelays( + followingsToProcess, + ExtendedKind.FAVORITE_RELAYS + ), + this.fetchReplaceableEventsFromProfileFetchRelays(followingsToProcess, kinds.RelayList) + ]) // Group by relay URL: Map> const relayToUsers = new Map>() - - // favoriteRelaysEvents[i] corresponds to followingsToProcess[i] - for (let i = 0; i < followingsToProcess.length && i < favoriteRelaysEvents.length; i++) { - const event = favoriteRelaysEvents[i] + + const addFollowingRelay = (followingPk: string, rawUrl: string) => { + const normalizedUrl = + (normalizeUrl(rawUrl) || normalizeAnyRelayUrl(rawUrl) || '').trim() || null + if (!normalizedUrl || !isWebsocketUrl(normalizedUrl)) return + if (!relayToUsers.has(normalizedUrl)) relayToUsers.set(normalizedUrl, new Set()) + relayToUsers.get(normalizedUrl)!.add(followingPk) + } + + for (let i = 0; i < followingsToProcess.length; i++) { const followingPubkey = followingsToProcess[i] - if (event && followingPubkey) { - event.tags.forEach(([tagName, tagValue]) => { - if (tagName === 'relay' && tagValue) { - const normalizedUrl = normalizeUrl(tagValue) - if (normalizedUrl) { - if (!relayToUsers.has(normalizedUrl)) { - relayToUsers.set(normalizedUrl, new Set()) - } - relayToUsers.get(normalizedUrl)!.add(followingPubkey) - } + if (!followingPubkey) continue + + const favEv = favoriteRelaysEvents[i] + if (favEv) { + favEv.tags.forEach(([tagName, tagValue]) => { + if (!tagValue) return + if (tagName === 'relay' || tagName === 'r') { + addFollowingRelay(followingPubkey, tagValue) } }) } + + /** NIP-65 kind 10002 — most clients only publish this, not kind 10012 “favorite relays”. */ + const nip65 = relayListEvents[i] + if (nip65) { + const rl = getRelayListFromEvent(nip65) + for (const { url } of rl.originalRelays) { + addFollowingRelay(followingPubkey, url) + } + } } - + // Convert to array format: [relayUrl, pubkeys[]] const result: [string, string[]][] = [] for (const [relayUrl, pubkeys] of relayToUsers.entries()) { result.push([relayUrl, Array.from(pubkeys)]) } - + logger.debug('[ReplaceableEventService] fetchFollowingFavoriteRelays completed', { followingsCount: followings.length, processedCount: followingsToProcess.length, - eventsFound: favoriteRelaysEvents.filter(e => e !== undefined).length, + favoriteRelaysEventsFound: favoriteRelaysEvents.filter((e) => e !== undefined).length, + relayListEventsFound: relayListEvents.filter((e) => e !== undefined).length, uniqueRelays: result.length, totalUsers: result.reduce((sum, [, users]) => sum + users.length, 0) }) - + return result } } diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 80fd3ec0..68762a04 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -3102,7 +3102,11 @@ class ClientService extends EventTarget { relays = relaysAfterSocialKindBlockedStrip(wsOriginal, stripped) } relays = this.relayUrlsAfterStrikesOrRecover(relays) - const queryRelays = dedupeNormalizeRelayUrlsOrdered([...relays, ...httpRelayBases]) + let queryRelays = dedupeNormalizeRelayUrlsOrdered([...relays, ...httpRelayBases]) + /** If every candidate was session-striked / filtered away, still hit public read mirrors so REQ does not no-op. */ + if (queryRelays.length === 0) { + queryRelays = dedupeNormalizeRelayUrlsOrdered([...FAST_READ_RELAY_URLS]) + } const events = await this.queryService.query(queryRelays, filter, onevent, { eoseTimeout, globalTimeout, diff --git a/vite.config.ts b/vite.config.ts index 89a57e51..fc2a0ab5 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -152,7 +152,17 @@ export default defineConfig(({ mode }) => { }, '/sites': { target: 'http://127.0.0.1:8090', - changeOrigin: true + changeOrigin: true, + /** Without OG proxy on :8090, Node was returning 500 HTML; return JSON so callers fail softly in dev. */ + configure(proxy) { + proxy.on('error', (_err, _req, res) => { + const r = res as { writeHead?: (c: number, h: Record) => void; end?: (b: string) => void } + if (typeof r?.writeHead === 'function' && typeof r?.end === 'function') { + r.writeHead(502, { 'Content-Type': 'application/json' }) + r.end(JSON.stringify({ ok: false, error: 'og_proxy_unreachable', hint: 'Start OG scraper on :8090 (see PROXY_SETUP.md)' })) + } + }) + } }, // Loopback HTTP index relay: `import.meta.env.DEV` rewrites kind 10243 URLs through this path. '/dev-index-relay': {