diff --git a/src/components/Note/PublicationIndex/PublicationIndex.tsx b/src/components/Note/PublicationIndex/PublicationIndex.tsx index bb8aac5c..bff7f235 100644 --- a/src/components/Note/PublicationIndex/PublicationIndex.tsx +++ b/src/components/Note/PublicationIndex/PublicationIndex.tsx @@ -21,6 +21,10 @@ import { upsertRenderedPublicationEvents } from '@/lib/publication-rendered-even interface PublicationReference { coordinate?: string + /** + * Optional historical snapshot id (`a` tag field 4) or direct `e` tag id. + * For `a` references this is metadata only and MUST NOT drive section fetches. + */ eventId?: string event?: Event kind?: number @@ -138,6 +142,9 @@ export default function PublicationIndex({ refs.push({ type: 'a', coordinate: parsed.coordinate, + // `a[3]` is historization metadata for this coordinate revision only. + // Keep it for diagnostics/UI context; fetches resolve by coordinate, not by this id. + eventId: tag[3], kind: parsed.kind, pubkey: parsed.pubkey, identifier: parsed.identifier, diff --git a/src/constants.ts b/src/constants.ts index 7ecadbb6..f1809f09 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -477,10 +477,23 @@ export function isSocialKindBlockedKind(kind: number): boolean { return SOCIAL_KIND_BLOCKED_KIND_SET.has(kind) } -/** True when the filter is unrestricted by kind or includes any {@link SOCIAL_KIND_BLOCKED_KINDS}. */ +/** + * True when a filter should avoid relays that do not carry social-note surface. + * + * Important: kindless lookup filters (e.g. `ids`, `authors + #d`) are often used for + * publication / replaceable resolution and must keep relays like thecitadel in scope. + */ export function relayFilterIncludesSocialKindBlockedKind(filter: Filter): boolean { const k = filter.kinds - if (k === undefined) return true + if (k === undefined) { + const ids = Array.isArray(filter.ids) ? filter.ids.length : 0 + const dTags = Array.isArray((filter as Record)['#d']) + ? ((filter as Record)['#d'] as unknown[]).length + : 0 + // Scoped lookups are not "broad social feed" queries. + if (ids > 0 || dTags > 0) return false + return true + } const arr = Array.isArray(k) ? k : [k] return arr.some((kind) => SOCIAL_KIND_BLOCKED_KIND_SET.has(kind)) } diff --git a/src/hooks/usePublicationSectionLoader.ts b/src/hooks/usePublicationSectionLoader.ts index 6edcb049..cb48e031 100644 --- a/src/hooks/usePublicationSectionLoader.ts +++ b/src/hooks/usePublicationSectionLoader.ts @@ -48,6 +48,18 @@ function signatureOfRefs(refs: PublicationSectionRef[]): string { return refs.map((r) => publicationRefKey(r)).join('|') } +function dedupeRelayUrls(urls: string[]): string[] { + const out: string[] = [] + const seen = new Set() + for (const url of urls) { + const u = (url || '').trim() + if (!u || seen.has(u)) continue + seen.add(u) + out.push(u) + } + return out +} + export function usePublicationSectionLoader(indexEvent: Event, refs: PublicationSectionRef[]) { const indexId = indexEvent.id const refsSignature = useMemo(() => signatureOfRefs(refs), [refs]) @@ -168,7 +180,8 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication if (row.type === 'e' && row.eventId) { const hex = resolvePublicationEventIdToHex(row.eventId) if (hex) ev = await indexedDb.getEventFromPublicationStore(hex) - } else if (row.coordinate) { + } + if (!ev && row.coordinate) { ev = await indexedDb.getPublicationEvent(row.coordinate) } if (ev) byDb.set(row.key, ev) @@ -227,6 +240,7 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication await Promise.all( unresolved.map(async (row) => { try { + // Only `e` refs are fetched by event id; `a` refs resolve by coordinate. if (row.type === 'e' && row.eventId) { const ev = await withTimeout( eventService.fetchEvent(row.eventId), @@ -238,7 +252,13 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication if (row.coordinate) { const parsed = parsePublicationATagCoordinate(row.coordinate) if (!parsed) return - const relaysToTry = row.relay ? [row.relay] : relayUrls + // Relay hints in `a` tags are often stale. Keep the hint first, but also try + // current section relay sets so one dead hinted relay cannot force a false miss. + const relaysToTry = dedupeRelayUrls( + row.relay + ? [row.relay, ...relayUrls, ...fallbackRelayUrls] + : [...relayUrls, ...fallbackRelayUrls] + ) const ev = await withTimeout( queryService .fetchEvents( @@ -323,14 +343,13 @@ export function usePublicationSectionLoader(indexEvent: Event, refs: Publication if (relayUrls.length === 0) return const sig = `${indexId}:${refsSignature}` if (autoLoadedSignatureRef.current === sig) return - autoLoadedSignatureRef.current = sig const idleKeys = rows.filter((r) => r.status === 'idle').map((r) => r.key) - if (idleKeys.length > 0) { - if (import.meta.env.DEV) { - logger.info('[PublicationSection] flush_start', { keys: idleKeys, relayCount: relayUrls.length }) - } - requestKeys(idleKeys) + if (idleKeys.length === 0) return + autoLoadedSignatureRef.current = sig + if (import.meta.env.DEV) { + logger.info('[PublicationSection] flush_start', { keys: idleKeys, relayCount: relayUrls.length }) } + requestKeys(idleKeys) }, [indexId, refsSignature, relayUrls, rows, requestKeys]) const referencesWithEvents = useMemo( diff --git a/src/hooks/useQuoteEvents.tsx b/src/hooks/useQuoteEvents.tsx index d5fa0de2..e2ed484d 100644 --- a/src/hooks/useQuoteEvents.tsx +++ b/src/hooks/useQuoteEvents.tsx @@ -91,6 +91,7 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) { : ev.id const qeIdForTagFilter = /^[0-9a-f]{64}$/i.test(filterQeId) ? filterQeId.toLowerCase() : filterQeId + const qeIdIsHexEventId = /^[0-9a-f]{64}$/i.test(qeIdForTagFilter) const eventCoordinate = isReplaceableEvent(ev.kind) ? getReplaceableCoordinateFromEvent(ev) : `${ev.kind}:${ev.pubkey}:${ev.id}` @@ -98,16 +99,35 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) { const highlightKinds = [kinds.Highlights] as const const otherBacklinkKinds = [...THREAD_BACKLINK_STREAM_KINDS_WITHOUT_HIGHLIGHT] - const { closer, timelineKey } = await client.subscribeTimeline( - [ - { - urls: finalRelayUrls, - filter: { '#q': [qeIdForTagFilter], kinds: [kinds.ShortTextNote], limit: LIMIT } - }, - { - urls: finalRelayUrls, - filter: { '#q': [qeIdForTagFilter], kinds: [...highlightKinds], limit: LIMIT } - }, + const subRequests: { urls: string[]; filter: Filter }[] = [ + { + urls: finalRelayUrls, + filter: { '#q': [qeIdForTagFilter], kinds: [kinds.ShortTextNote], limit: LIMIT } + }, + { + urls: finalRelayUrls, + filter: { '#q': [qeIdForTagFilter], kinds: [...highlightKinds], limit: LIMIT } + }, + { + urls: finalRelayUrls, + filter: { + '#a': [eventCoordinate], + kinds: [...highlightKinds], + limit: LIMIT + } + }, + { + urls: finalRelayUrls, + filter: { + '#a': [eventCoordinate], + kinds: otherBacklinkKinds, + limit: LIMIT + } + } + ] + // `#e` tag filters must use 64-hex event ids. For replaceable roots we use `#a`/`#q` only. + if (qeIdIsHexEventId) { + subRequests.push( { urls: finalRelayUrls, filter: { @@ -123,24 +143,12 @@ export function useQuoteEvents(event: Event | null, enabled: boolean) { kinds: otherBacklinkKinds, limit: LIMIT } - }, - { - urls: finalRelayUrls, - filter: { - '#a': [eventCoordinate], - kinds: [...highlightKinds], - limit: LIMIT - } - }, - { - urls: finalRelayUrls, - filter: { - '#a': [eventCoordinate], - kinds: otherBacklinkKinds, - limit: LIMIT - } } - ], + ) + } + + const { closer, timelineKey } = await client.subscribeTimeline( + subRequests, { onEvents: (batch, eosed) => { if (cancelled) return diff --git a/src/lib/publication-section-fetch.ts b/src/lib/publication-section-fetch.ts index b48236e9..11e9d8cc 100644 --- a/src/lib/publication-section-fetch.ts +++ b/src/lib/publication-section-fetch.ts @@ -110,6 +110,7 @@ const IDS_CHUNK = 44 const D_CHUNK = 28 const ANY_KIND_LIMIT_PER_D = 12 const AUTHOR_KIND_SCAN_LIMIT = 200 +const HINT_RELAY_AUTHOR_KIND_SCAN_LIMIT = 1200 function dTagOf(ev: Event): string | undefined { const d = ev.tags.find((t) => (t[0] || '').trim().toLowerCase() === 'd')?.[1] @@ -134,6 +135,7 @@ export async function batchFetchPublicationSectionEvents( const aRefs = refs.filter((r) => r.type === 'a' && r.coordinate && r.pubkey && typeof r.kind === 'number') for (const ref of refs) { + // Only explicit `e` refs are resolved by id. For `a` refs, tag[3] is historization metadata only. if (ref.type !== 'e' || !ref.eventId) continue const key = publicationRefKey(ref) const hex = resolvePublicationEventIdToHex(ref.eventId) @@ -334,6 +336,89 @@ export async function batchFetchPublicationSectionEvents( } } + // Secondary hint pass: some relays do not index `#d` reliably for 30040/30041. + // For unresolved refs with an explicit relay hint, scan that same relay by author+kind + // and resolve `d` client-side before doing broader multi-relay fallbacks. + const unresolvedAfterHintPass = aRefs.filter((r) => !out.has(publicationRefKey(r))) + const byHintRelayForScan = new Map() + for (const ref of unresolvedAfterHintPass) { + const relay = normalizeUrl(ref.relay || '') || ref.relay?.trim() + if (!relay) continue + const list = byHintRelayForScan.get(relay) + if (list) list.push(ref) + else byHintRelayForScan.set(relay, [ref]) + } + + for (const [relay, relayRefs] of byHintRelayForScan) { + const groups = new Map() + for (const ref of relayRefs) { + const key = `${ref.pubkey!.toLowerCase()}:${ref.kind!}` + if (!groups.has(key)) { + groups.set(key, { pubkey: ref.pubkey!.toLowerCase(), kind: ref.kind! }) + } + } + const scanFilters: Filter[] = [] + for (const g of groups.values()) { + scanFilters.push({ + authors: [g.pubkey], + kinds: [g.kind], + limit: HINT_RELAY_AUTHOR_KIND_SCAN_LIMIT + }) + } + if (scanFilters.length === 0) continue + if (import.meta.env.DEV) { + logger.info('[PublicationSection] relay_hint_author_kind_scan_start', { + relay, + refCount: relayRefs.length, + filterCount: scanFilters.length + }) + } + try { + const scanEvents = await queryService.fetchEvents([relay], scanFilters, { + globalTimeout: 10_000, + eoseTimeout: 1_500, + firstRelayResultGraceMs: false + }) + const scanByCoord = new Map() + for (const ev of scanEvents) { + const coord = coordinateOfEvent(ev) + if (!coord) continue + for (const k of publicationCoordinateLookupKeys(coord)) { + const prev = scanByCoord.get(k) + if (!prev || ev.created_at > prev.created_at) scanByCoord.set(k, ev) + } + } + for (const ref of relayRefs) { + const key = publicationRefKey(ref) + if (out.has(key)) continue + const coord = ref.coordinate! + let ev: Event | undefined + for (const lk of publicationCoordinateLookupKeys(coord)) { + ev = scanByCoord.get(lk) + if (ev) break + } + if (ev) out.set(key, ev) + } + if (import.meta.env.DEV) { + logger.info('[PublicationSection] relay_hint_author_kind_scan_done', { + relay, + eventsReturned: scanEvents.length, + unresolvedAfterScan: relayRefs + .map((r) => publicationRefKey(r)) + .filter((k) => !out.has(k)) + .slice(0, 8) + }) + } + } catch { + if (import.meta.env.DEV) { + logger.warn('[PublicationSection] relay_hint_author_kind_scan_error', { + relay, + filterCount: scanFilters.length + }) + } + } + } + // Last fallback: author + #d across any kind. const unresolvedAfterHint = aRefs.filter((r) => !out.has(publicationRefKey(r))) if (unresolvedAfterHint.length > 0) { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 84534a20..bed23c0f 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -26,6 +26,47 @@ function filterForRelay(f: Filter, relaySupportsSearch: boolean): Filter { return rest as Filter } +const HEX_EVENT_ID_RE = /^[0-9a-f]{64}$/i + +function decodeEventRefForETagFilter(raw: string): string | null { + const trimmed = raw.trim() + if (!trimmed) return null + const withoutPrefix = trimmed.toLowerCase().startsWith('nostr:') ? trimmed.slice(6).trim() : trimmed + if (HEX_EVENT_ID_RE.test(withoutPrefix)) return withoutPrefix.toLowerCase() + try { + const decoded = nip19.decode(withoutPrefix) + if (decoded.type === 'note') return decoded.data + if (decoded.type === 'nevent') return decoded.data.id + } catch { + // ignore malformed refs + } + return null +} + +function sanitizeETagFilterForSubscribe(filter: Filter): Filter | null { + const f = { ...filter } as Filter & { '#e'?: string[]; '#E'?: string[] } + const rawLower = Array.isArray(f['#e']) ? f['#e'] : [] + const rawUpper = Array.isArray(f['#E']) ? f['#E'] : [] + if (rawLower.length === 0 && rawUpper.length === 0) return f + const rawAll = [...rawLower, ...rawUpper] + const decoded = [ + ...new Set( + rawAll + .map((v) => decodeEventRefForETagFilter(String(v))) + .filter((v): v is string => !!v) + ) + ] + if (decoded.length === 0) return null + f['#e'] = decoded + delete f['#E'] + return f +} + +function sanitizeSubscribeFiltersBeforeReq(filter: Filter | Filter[]): Filter[] { + const asArray = Array.isArray(filter) ? filter : [filter] + return asArray.map(sanitizeETagFilterForSubscribe).filter((f): f is Filter => !!f) +} + /** Single key for `pool.seenOn` / query seen-on maps (hex ids are case-insensitive). */ function canonicalSeenOnEventId(eventId: string): string { const t = eventId.trim() @@ -82,6 +123,7 @@ import { Filter, kinds, matchFilters, + nip19, Event as NEvent, Relay, SimplePool, @@ -1823,7 +1865,20 @@ class ClientService extends EventTarget { ) { const originalDedupedRelays = Array.from(new Set(urls)) let relays = originalDedupedRelays - const filters = Array.isArray(filter) ? filter : [filter] + const filters = sanitizeSubscribeFiltersBeforeReq(filter) + if (filters.length === 0) { + logger.debug('[relay-req] batch_skip', { + reason: 'no_filters_after_sanitize', + filterSummary: summarizeFiltersForRelayLog(Array.isArray(filter) ? filter : [filter]) + }) + queueMicrotask(() => { + oneose?.(true) + relayReqLog?.onBatchEnd?.([]) + }) + return { + close: () => {} + } + } const stripSocialBlockedRelays = SOCIAL_KIND_BLOCKED_RELAY_URLS.length > 0 &&