diff --git a/src/hooks/useFetchThreadContextEvent.tsx b/src/hooks/useFetchThreadContextEvent.tsx index c769ea57..605c102d 100644 --- a/src/hooks/useFetchThreadContextEvent.tsx +++ b/src/hooks/useFetchThreadContextEvent.tsx @@ -10,10 +10,37 @@ import { buildThreadContextFetchRelayUrls } from '@/lib/thread-context-relays' import client, { eventService } from '@/services/client.service' import { navigationEventStore } from '@/services/navigation-event-store' import { Event } from 'nostr-tools' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useMemo, useState } from 'react' export type ThreadContextRole = 'parent' | 'root' +/** First usable event from parallel fetches, or undefined after all settle or `timeoutMs`. */ +function raceThreadContextFetches( + tasks: Array<() => Promise>, + timeoutMs: number +): Promise { + if (tasks.length === 0) return Promise.resolve(undefined) + return new Promise((resolve) => { + const timer = window.setTimeout(() => resolve(undefined), timeoutMs) + let settled = 0 + const finish = (ev: Event | undefined) => { + settled++ + if (ev) { + window.clearTimeout(timer) + resolve(ev) + return + } + if (settled === tasks.length) { + window.clearTimeout(timer) + resolve(undefined) + } + } + for (const run of tasks) { + void run().then(finish).catch(() => finish(undefined)) + } + }) +} + export function useFetchThreadContextEvent( eventId: string | undefined, contextEvent: Event | undefined, @@ -28,10 +55,8 @@ export function useFetchThreadContextEvent( const [event, setEvent] = useState(initialEvent) const [isFetching, setIsFetching] = useState(!initialEvent) const [refetchToken, setRefetchToken] = useState(0) - const searchableAttemptedRef = useRef(false) const refetch = useCallback(() => { - searchableAttemptedRef.current = false setRefetchToken((n) => n + 1) }, []) @@ -45,10 +70,6 @@ export function useFetchThreadContextEvent( [blockedRelays] ) - useEffect(() => { - searchableAttemptedRef.current = false - }, [eventId]) - useEffect(() => { let cancelled = false @@ -111,35 +132,28 @@ export function useFetchThreadContextEvent( ? { relayHints: relayUrls, threadContext: true as const } : { threadContext: true as const } - const fetchParentOrRoot = async () => { + const fetchParentOrRoot = () => { if (skipShortcuts) { return eventService.fetchEventForceRetry(eventId, threadOpts) } return eventService.fetchEvent(eventId, threadOpts) } - let fetchedEvent = await Promise.race([ - fetchParentOrRoot(), - new Promise((resolve) => { - window.setTimeout(() => resolve(undefined), THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS) + const aggrAwareSearch = sanitizeRelayUrlsForFetch(getAggrAwareSearchRelayUrls()) + const tasks: Array<() => Promise> = [fetchParentOrRoot] + if (aggrAwareSearch.length > 0) { + tasks.push(async () => { + const ev = await client.fetchEventWithExternalRelays(eventId, aggrAwareSearch) + if (ev) client.addEventToCache(ev) + return ev }) - ]) - - const aggrAwareSearch = getAggrAwareSearchRelayUrls() - if (!fetchedEvent && !searchableAttemptedRef.current && aggrAwareSearch.length > 0) { - searchableAttemptedRef.current = true - const searchable = sanitizeRelayUrlsForFetch(aggrAwareSearch) - fetchedEvent = await Promise.race([ - client.fetchEventWithExternalRelays(eventId, searchable), - new Promise((resolve) => { - window.setTimeout(() => resolve(undefined), THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS) - }) - ]) - if (fetchedEvent) { - client.addEventToCache(fetchedEvent) - } } + const fetchedEvent = await raceThreadContextFetches( + tasks, + THREAD_CONTEXT_EVENT_FETCH_GLOBAL_TIMEOUT_MS + ) + if (cancelled) return if (fetchedEvent && !isEventDeleted(fetchedEvent)) { setEvent(fetchedEvent) diff --git a/src/lib/favorites-feed-relays.ts b/src/lib/favorites-feed-relays.ts index aee0813e..ff6bc6e7 100644 --- a/src/lib/favorites-feed-relays.ts +++ b/src/lib/favorites-feed-relays.ts @@ -19,7 +19,6 @@ import { import { feedRelayPolicyUrls, type FeedRelayLayer } from '@/features/feed/relay-policy' import { stripMailboxLocalUrlsForRemoteViewers } from '@/lib/relay-list-sanitize' import { relaySessionStrikes } from '@/lib/relay-strikes' -import { prependAggrNostrLandIfViewerEligible } from '@/lib/nostr-land-relay-eligibility' import { profileFetchRelayUrlsWithoutFastReadLayer } from '@/lib/viewer-relay-defaults' function isBlockedRelay(url: string, blockedRelays: string[]): boolean { @@ -40,7 +39,7 @@ export function userReadRelaysWithHttp( ): string[] { const http = relayList?.httpRead ?? [] const read = relayList?.read ?? [] - return prependAggrNostrLandIfViewerEligible(dedupeNormalizeRelayUrlsOrdered([...http, ...read])) + return dedupeNormalizeRelayUrlsOrdered([...http, ...read]) } export function getFavoritesFeedRelayUrls( diff --git a/src/lib/nostr-land-relay-eligibility.test.ts b/src/lib/nostr-land-relay-eligibility.test.ts index dd86e2b3..a222f800 100644 --- a/src/lib/nostr-land-relay-eligibility.test.ts +++ b/src/lib/nostr-land-relay-eligibility.test.ts @@ -4,6 +4,7 @@ import { filterAggrNostrLandUnlessViewerEligible, getAggrAwareSearchRelayUrls, getViewerNostrLandAggrSearchRelayUrls, + prependAggrForEventLookupRelayUrls, prependAggrNostrLandIfViewerEligible, relayUrlsIncludeCanonicalNostrLandRelay, syncViewerRelayStackNostrLandAggrEligible @@ -32,6 +33,13 @@ describe('nostr.land aggr eligibility', () => { ]) }) + it('prependAggrForEventLookupRelayUrls matches prependAggrNostrLandIfViewerEligible', () => { + syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/']) + expect(prependAggrForEventLookupRelayUrls(['wss://inbox.example/'])[0]).toMatch( + /^wss:\/\/aggr\.nostr\.land\/?$/ + ) + }) + it('getAggrAwareSearchRelayUrls prepends aggr when eligible', () => { syncViewerRelayStackNostrLandAggrEligible(['wss://nostr.land/']) const urls = getAggrAwareSearchRelayUrls() diff --git a/src/lib/nostr-land-relay-eligibility.ts b/src/lib/nostr-land-relay-eligibility.ts index fdcac1d2..e39c23fd 100644 --- a/src/lib/nostr-land-relay-eligibility.ts +++ b/src/lib/nostr-land-relay-eligibility.ts @@ -108,6 +108,14 @@ export function urlsForViewerNostrLandAggrEligibilitySync(options: { ] } +/** + * Prepend aggr for event-by-id lookups (threads, embeds, parent previews, comprehensive fetch). + * Home OP timelines must not use this — use {@link buildAllFavoritesFeedRelayUrls} / `nostrLandAggr: 'never'`. + */ +export function prependAggrForEventLookupRelayUrls(relayUrls: readonly string[]): string[] { + return prependAggrNostrLandIfViewerEligible(relayUrls) +} + /** Deduped prepend of aggr when the viewer opted into nostr.land relays (see sync…). */ export function prependAggrNostrLandIfViewerEligible(relayUrls: readonly string[]): string[] { if (!viewerStackMentionsNostrLand) return [...relayUrls] diff --git a/src/lib/relay-list-builder.ts b/src/lib/relay-list-builder.ts index c942ad75..4916d10e 100644 --- a/src/lib/relay-list-builder.ts +++ b/src/lib/relay-list-builder.ts @@ -13,6 +13,7 @@ import { FAST_READ_RELAY_URLS, PROFILE_RELAY_URLS, SEARCHABLE_RELAY_URLS } from import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { mergeRelayUrlLayers, userReadRelaysWithHttp } from '@/lib/favorites-feed-relays' import { isRelayBlockedByUser } from '@/lib/relay-blocked' +import { prependAggrForEventLookupRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { canonicalRelaySessionKey, @@ -385,7 +386,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio }), personalKeys ) - if (httpRelayUrls.length === 0) return ws + if (httpRelayUrls.length === 0) return prependAggrForEventLookupRelayUrls(ws) const seen = new Set(ws.map(relayKey)) const out = [...ws] for (const u of httpRelayUrls) { @@ -394,7 +395,7 @@ export async function buildComprehensiveRelayList(options: RelayListBuilderOptio seen.add(k) out.push(u) } - return out + return prependAggrForEventLookupRelayUrls(out) } /** @@ -649,7 +650,7 @@ export async function buildReplyReadRelayList( includeProfileFetchRelays: false, blockedRelays }) - return scoped + return prependAggrForEventLookupRelayUrls(scoped) } let useGlobal = true @@ -682,5 +683,7 @@ export async function buildReplyReadRelayList( includeProfileFetchRelays: useGlobal, blockedRelays }) - return mergeRelayUrlLayers([scoped, defaultFavoriteRelaysForViewer(useGlobal)], blockedRelays) + return prependAggrForEventLookupRelayUrls( + mergeRelayUrlLayers([scoped, defaultFavoriteRelaysForViewer(useGlobal)], blockedRelays) + ) } diff --git a/src/lib/thread-context-relays.ts b/src/lib/thread-context-relays.ts index 083b2d72..7d35cc0b 100644 --- a/src/lib/thread-context-relays.ts +++ b/src/lib/thread-context-relays.ts @@ -1,3 +1,4 @@ +import { prependAggrForEventLookupRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' @@ -41,5 +42,11 @@ export async function buildThreadContextFetchRelayUrls( ...new Set([...tagHints, ...relayHintsFromEventTags(contextEvent)]) ]) const opAuthorPubkey = pubkeyFromThreadETag(targetTag) - return buildReplyReadRelayList(opAuthorPubkey, viewerPubkey, blockedRelays, threadRelayHints) + const relays = await buildReplyReadRelayList( + opAuthorPubkey, + viewerPubkey, + blockedRelays, + threadRelayHints + ) + return prependAggrForEventLookupRelayUrls(relays) } diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index 1fb2eb49..dd24b33b 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -11,6 +11,7 @@ import { SINGLE_EVENT_BY_ID_QUERY_EOSE_TIMEOUT_MS, SINGLE_EVENT_BY_ID_QUERY_GLOBAL_TIMEOUT_MS } from '@/constants' +import { prependAggrForEventLookupRelayUrls } from '@/lib/nostr-land-relay-eligibility' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { urlIsNonLocalForRemoteViewer } from '@/lib/relay-list-sanitize' import logger from '@/lib/logger' @@ -1195,7 +1196,7 @@ export class EventService { [...new Set(urls.map((u) => normalizeUrl(u)).filter((u): u is string => Boolean(u)))] ) if (extraRelayHints?.length) { - relays = normalizeRelayList(extraRelayHints) + relays = normalizeRelayList(prependAggrForEventLookupRelayUrls(extraRelayHints)) } if (/^[0-9a-f]{64}$/i.test(id)) {