From fd177eb9113c703da0ef6c681e5fd616fec20e8e Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 23 May 2026 14:00:20 +0200 Subject: [PATCH] bug-fixes --- src/components/ReplyNoteList/index.tsx | 92 +++++++++--- .../TurnIntoSuperchatButton/index.tsx | 4 + src/hooks/usePaymentAttestationStatus.tsx | 72 ++++++--- src/lib/event.ts | 22 ++- src/lib/feed-local-event-match.test.ts | 8 + src/lib/feed-local-event-match.ts | 2 +- src/lib/op-reference-tags.ts | 12 ++ src/lib/payment-attestation-cache.ts | 137 +++++++++++++++++- src/lib/superchat.ts | 8 +- src/lib/thread-interaction-req.ts | 14 ++ src/services/client-events.service.ts | 46 +++++- .../client-replaceable-events.service.ts | 87 ++++++++--- src/services/indexed-db.service.ts | 6 + 13 files changed, 432 insertions(+), 78 deletions(-) diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index a3bd1990..27f392d5 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -18,7 +18,6 @@ import { } from '@/lib/event' import logger from '@/lib/logger' import { - buildAttestedPaymentIdSet, getPaymentAttestationTargetId, partitionAttestedSuperchats, replyFeedSuperchatsFirst @@ -50,7 +49,11 @@ import { formatPubkey, pubkeyToNpub } from '@/lib/pubkey' import { collectProfilePubkeysFromEvents } from '@/lib/profile-batch-coordinator' import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' -import { buildThreadInteractionFilters } from '@/lib/thread-interaction-req' +import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req' +import { + readKnownAttestedPaymentTargetsSync, + resolveAttestedPaymentIdSet +} from '@/lib/payment-attestation-cache' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { eventReferencesThreadTarget } from '@/lib/op-reference-tags' import { replyBelongsToNoteThread } from '@/lib/thread-reply-root-match' @@ -58,7 +61,7 @@ import { buildRssWebNostrQueryRelayUrls, isRssArticleUrlThreadInteraction } from import type { TProfile, TSubRequestFilter } from '@/types' import { Filter, Event as NEvent, kinds } from 'nostr-tools' import { useNoteStatsById } from '@/hooks/useNoteStatsById' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import type { TFunction } from 'i18next' import { useTranslation } from 'react-i18next' import { LoadingBar } from '../LoadingBar' @@ -440,9 +443,37 @@ function ReplyNoteList({ return out.length ? out : undefined }, [duplicateWebPreviewCleanedUrlHints, rootInfo]) + useLayoutEffect(() => { + const pk = event.pubkey + if (!pk) return + const syncIds = readKnownAttestedPaymentTargetsSync(pk) + if (syncIds.size === 0) return + setAttestedPaymentIds((prev) => { + const next = new Set(prev) + for (const id of syncIds) next.add(id) + return next.size === prev.size ? prev : next + }) + }, [event.pubkey, event.id]) + useEffect(() => { - setAttestedPaymentIds(new Set()) - }, [event.id]) + const pk = event.pubkey + if (!pk) return + let cancelled = false + void (async () => { + const ids = await resolveAttestedPaymentIdSet(pk) + if (cancelled) return + setAttestedPaymentIds(ids) + const relayHints = threadRelayUrlsRef.current.length + ? threadRelayUrlsRef.current + : browsingRelayUrls.map((u) => normalizeAnyRelayUrl(u) || u).filter(Boolean) + const targets = await hydrateAttestedSuperchatTargets(ids, relayHints) + if (cancelled) return + if (targets.length > 0) addReplies(targets) + })() + return () => { + cancelled = true + } + }, [event.pubkey, event.id, addReplies, browsingRelayUrls]) useEffect(() => { const handleAttestation = (data: Event) => { @@ -1184,6 +1215,8 @@ function ReplyNoteList({ allowThirdPartyLocalRelays: false }) ) + threadRelayUrlsRef.current = relayUrlsForThreadReq + const recipientPubkey = event.pubkey // Stream replies as relays return them (aggr is first in the list) instead of waiting for full EOSE. const streamThreadReply = (evt: NEvent) => { @@ -1197,13 +1230,41 @@ function ReplyNoteList({ if (!hasCache) setLoading(false) } - const allReplies = await queryService.fetchEvents(relayUrlsForThreadReq, filters, { - onevent: streamThreadReply, - foreground: true, - firstRelayResultGraceMs: 900, - globalTimeout: 12_000, - relayOpSource: 'ReplyNoteList.thread' + const superchatFilters = buildThreadSuperchatPriorityFilters({ + root: rootInfo, + opEventKind: event.kind, + limit: LIMIT }) + if (superchatFilters.length > 0) { + void queryService + .fetchEvents(relayUrlsForThreadReq, superchatFilters, { + onevent: streamThreadReply, + foreground: true, + firstRelayResultGraceMs: 400, + globalTimeout: 8000, + relayOpSource: 'ReplyNoteList.threadSuperchats' + }) + .catch(() => { + /* optional early wave */ + }) + } + + const attestationTask = recipientPubkey + ? fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, { + foreground: statsForeground + }) + : Promise.resolve([] as NEvent[]) + + const [allReplies, relayAttestations] = await Promise.all([ + queryService.fetchEvents(relayUrlsForThreadReq, filters, { + onevent: streamThreadReply, + foreground: true, + firstRelayResultGraceMs: 900, + globalTimeout: 12_000, + relayOpSource: 'ReplyNoteList.thread' + }), + attestationTask + ]) if (fetchGeneration !== replyFetchGenRef.current) return @@ -1242,15 +1303,10 @@ function ReplyNoteList({ const repliesForStatsPrime = mergedForUi addReplies(mergedForUi) - const recipientPubkey = event.pubkey - threadRelayUrlsRef.current = relayUrlsForThreadReq if (recipientPubkey) { - void fetchPaymentAttestationsForRecipient(recipientPubkey, relayUrlsForThreadReq, { - foreground: statsForeground - }) - .then(async (attestations) => { + void resolveAttestedPaymentIdSet(recipientPubkey, relayAttestations) + .then(async (attestedIds) => { if (fetchGeneration !== replyFetchGenRef.current) return - const attestedIds = buildAttestedPaymentIdSet(attestations, recipientPubkey) setAttestedPaymentIds(attestedIds) const targets = await hydrateAttestedSuperchatTargets( attestedIds, diff --git a/src/components/TurnIntoSuperchatButton/index.tsx b/src/components/TurnIntoSuperchatButton/index.tsx index ad4b597a..a1e821c8 100644 --- a/src/components/TurnIntoSuperchatButton/index.tsx +++ b/src/components/TurnIntoSuperchatButton/index.tsx @@ -11,6 +11,7 @@ import { import { cn } from '@/lib/utils' import { requestProfileWallRefresh } from '@/hooks/useProfileWall' import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus' +import { markLocalAttestationTarget } from '@/lib/payment-attestation-cache' import { useNostr } from '@/providers/NostrProvider' import { Sparkles } from 'lucide-react' import { Event } from 'nostr-tools' @@ -84,8 +85,11 @@ function TurnIntoSuperchatButtonInner({ try { const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true }) const published = await publish(draft, { disableFallbacks: true }) + markLocalAttestationTarget(recipientPubkey, event.id) if (published) { markAttested(published) + } else { + markAttested({ ...draft, id: event.id, pubkey: recipientPubkey, sig: '' } as Event) } requestProfileWallRefresh(recipientPubkey) showSimplePublishSuccess(t('Superchat attested')) diff --git a/src/hooks/usePaymentAttestationStatus.tsx b/src/hooks/usePaymentAttestationStatus.tsx index c3ee212c..572c77d4 100644 --- a/src/hooks/usePaymentAttestationStatus.tsx +++ b/src/hooks/usePaymentAttestationStatus.tsx @@ -3,8 +3,12 @@ import { getPaymentAttestationTargetId, getSuperchatPaymentRecipientPubkey } from '@/lib/superchat' +import { hexPubkeysEqual } from '@/lib/pubkey' import { + hydrateAttestationsForAuthor, + isLocallyMarkedAttested, loadPaymentAttestationLocal, + markLocalAttestationTarget, peekCachedPaymentAttestation, refreshPaymentAttestationFromRelays, rememberPaymentAttestationFromPublish @@ -17,11 +21,26 @@ function attestationFilter(recipientPubkey: string, targetEventId: string) { return { kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [recipientPubkey], - '#e': [targetEventId], + '#e': [targetEventId.trim().toLowerCase()], limit: 5 } } +function readAttestedFromLocalSources( + targetEventId: string | undefined, + recipientPubkey: string | null +): { attested: boolean; attestationEvent: NostrEvent | null } { + if (!targetEventId || !recipientPubkey) { + return { attested: false, attestationEvent: null } + } + const hit = peekCachedPaymentAttestation(targetEventId, recipientPubkey) + const locallyMarked = isLocallyMarkedAttested(recipientPubkey, targetEventId) + return { + attested: Boolean(hit) || locallyMarked, + attestationEvent: hit ?? null + } +} + export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) { const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null const targetId = targetEvent?.id?.toLowerCase() @@ -34,16 +53,15 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) [targetEvent?.id, recipientPubkey] ) - const cached = useMemo( - () => - targetEvent?.id && recipientPubkey - ? peekCachedPaymentAttestation(targetEvent.id, recipientPubkey) - : undefined, + const localSnapshot = useMemo( + () => readAttestedFromLocalSources(targetEvent?.id, recipientPubkey), [targetEvent?.id, recipientPubkey, targetId] ) - const [attested, setAttested] = useState(Boolean(cached)) - const [attestationEvent, setAttestationEvent] = useState(cached ?? null) + const [attested, setAttested] = useState(localSnapshot.attested) + const [attestationEvent, setAttestationEvent] = useState( + localSnapshot.attestationEvent + ) const [checking, setChecking] = useState(false) const applyMatch = useCallback((match: NostrEvent | undefined) => { @@ -55,10 +73,14 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) const markAttested = useCallback( (attestation: NostrEvent) => { if (!targetEvent?.id || !recipientPubkey) return - if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return - if (attestation.pubkey.toLowerCase() !== recipientPubkey.toLowerCase()) return + markLocalAttestationTarget(recipientPubkey, targetEvent.id) + if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) { + setAttested(true) + return + } + if (!hexPubkeysEqual(attestation.pubkey, recipientPubkey)) return const attestedId = getPaymentAttestationTargetId(attestation) - if (attestedId?.toLowerCase() !== targetEvent.id.toLowerCase()) return + if (!attestedId || attestedId.toLowerCase() !== targetEvent.id.toLowerCase()) return rememberPaymentAttestationFromPublish(attestation) applyMatch(attestation) }, @@ -66,21 +88,23 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) ) useLayoutEffect(() => { - if (!targetEvent?.id || !recipientPubkey) { - setAttested(false) - setAttestationEvent(null) - return - } - const hit = peekCachedPaymentAttestation(targetEvent.id, recipientPubkey) - if (hit) { - setAttestationEvent(hit) - setAttested(true) - } - }, [recipientPubkey, targetEvent?.id]) + const next = readAttestedFromLocalSources(targetEvent?.id, recipientPubkey) + setAttestationEvent(next.attestationEvent) + setAttested(next.attested) + }, [recipientPubkey, targetEvent?.id, targetId]) + + useEffect(() => { + if (!recipientPubkey) return + void hydrateAttestationsForAuthor(recipientPubkey) + }, [recipientPubkey]) useEffect(() => { if (!targetEvent?.id || !recipientPubkey || !filter) return + if (isLocallyMarkedAttested(recipientPubkey, targetEvent.id)) { + setAttested(true) + } + let cancelled = false setChecking(true) @@ -92,6 +116,10 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) applyMatch(local) return } + if (isLocallyMarkedAttested(recipientPubkey, targetEvent.id)) { + setAttested(true) + return + } const relay = await refreshPaymentAttestationFromRelays( targetEvent.id, recipientPubkey, diff --git a/src/lib/event.ts b/src/lib/event.ts index fc0baeb9..1864de0c 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -1,4 +1,5 @@ import { ExtendedKind, isNip52CalendarCardKind } from '@/constants' +import { getZapInfoFromEvent } from '@/lib/event-metadata' import { muteSetHas } from '@/lib/mute-set' import { EMBEDDED_EVENT_REGEX, EMBEDDED_MENTION_REGEX, NOSTR_EMBEDDED_NOTE_REGEX } from '@/lib/content-patterns' import { cleanUrl, normalizeUrl } from '@/lib/url' @@ -293,11 +294,22 @@ export function getRootETag(event?: Event) { // Kind 9735: thread root for note zaps is the zapped event id on `e` / `E` if (event.kind === kinds.Zap) { const firstHex = getFirstHexEventIdFromETags(event.tags) - if (!firstHex) return undefined - return ( - event.tags.find((t) => t[0] === 'e' && t[1] === firstHex) ?? - event.tags.find((t) => t[0] === 'E' && t[1] === firstHex) - ) + if (firstHex) { + return ( + event.tags.find((t) => t[0] === 'e' && t[1] === firstHex) ?? + event.tags.find((t) => t[0] === 'E' && t[1] === firstHex) + ) + } + const zapped = getZapInfoFromEvent(event)?.originalEventId + if (zapped && /^[0-9a-f]{64}$/i.test(zapped)) { + const hex = zapped.toLowerCase() + return ( + event.tags.find((t) => t[0] === 'e' && t[1]?.toLowerCase() === hex) ?? + event.tags.find((t) => t[0] === 'E' && t[1]?.toLowerCase() === hex) ?? + ['e', hex] + ) + } + return undefined } if (event.kind !== kinds.ShortTextNote) return undefined diff --git a/src/lib/feed-local-event-match.test.ts b/src/lib/feed-local-event-match.test.ts index 3d8da9bd..5915d5f0 100644 --- a/src/lib/feed-local-event-match.test.ts +++ b/src/lib/feed-local-event-match.test.ts @@ -43,6 +43,14 @@ describe('eventMatchesLocalFeedFilter', () => { ).toBe(true) }) + it('matches uppercase E tags when filter uses #e', () => { + expect( + eventMatchesLocalFeedFilter(event({ tags: [['E', 'e'.repeat(64)]] }), { + '#e': ['e'.repeat(64)] + }) + ).toBe(true) + }) + it('rejects events outside any filter constraint', () => { expect(eventMatchesLocalFeedFilter(event({ kind: 6 }), { kinds: [1] })).toBe(false) expect(eventMatchesLocalFeedFilter(event(), { since: 1001 })).toBe(false) diff --git a/src/lib/feed-local-event-match.ts b/src/lib/feed-local-event-match.ts index 500fd126..66cdac45 100644 --- a/src/lib/feed-local-event-match.ts +++ b/src/lib/feed-local-event-match.ts @@ -34,7 +34,7 @@ export function eventMatchesLocalFeedFilter(event: Event, filter: Filter): boole if (!Array.isArray(values) || values.length === 0) continue const tagName = key.slice(1) const eventValues = event.tags - .filter((tag) => tag[0] === tagName && typeof tag[1] === 'string') + .filter((tag) => tag[0]?.toLowerCase() === tagName.toLowerCase() && typeof tag[1] === 'string') .map((tag) => tag[1] as string) if (eventValues.length === 0) return false if (!valuesMatchTag(tagName, eventValues, values)) return false diff --git a/src/lib/op-reference-tags.ts b/src/lib/op-reference-tags.ts index b72bb210..bae9b3db 100644 --- a/src/lib/op-reference-tags.ts +++ b/src/lib/op-reference-tags.ts @@ -4,10 +4,12 @@ import { isReplaceableEvent, normalizeReplaceableCoordinateString } from '@/lib/event' +import { getZapInfoFromEvent } from '@/lib/event-metadata' import { isRssArticleUrlThreadInteraction } from '@/lib/rss-web-feed' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' import type { TThreadRootRef } from '@/lib/thread-reply-root-match' import type { Event } from 'nostr-tools' +import { kinds } from 'nostr-tools' const REF_TAG_NAMES = new Set(['e', 'E', 'a', 'A', 'q', 'Q']) @@ -33,6 +35,12 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b if (normalizeReplaceableCoordinateString(v) === coordNorm) return true } } + if (evt.kind === kinds.Zap) { + const zapped = getZapInfoFromEvent(evt)?.originalEventId + if (zapped && /^[0-9a-f]{64}$/i.test(zapped) && zapped.toLowerCase() === eventHex) return true + const coord = getZapInfoFromEvent(evt)?.eventId + if (coord && normalizeReplaceableCoordinateString(coord) === coordNorm) return true + } return false } const hex = root.id.trim().toLowerCase() @@ -44,6 +52,10 @@ export function eventReferencesThreadTarget(evt: Event, root: TThreadRootRef): b if (!v) continue if (/^[0-9a-f]{64}$/i.test(v) && v.toLowerCase() === hex) return true } + if (evt.kind === kinds.Zap) { + const zapped = getZapInfoFromEvent(evt)?.originalEventId + if (zapped && /^[0-9a-f]{64}$/i.test(zapped) && zapped.toLowerCase() === hex) return true + } return false } diff --git a/src/lib/payment-attestation-cache.ts b/src/lib/payment-attestation-cache.ts index 6614c7e2..b502c1c4 100644 --- a/src/lib/payment-attestation-cache.ts +++ b/src/lib/payment-attestation-cache.ts @@ -1,16 +1,101 @@ import { ExtendedKind } from '@/constants' -import { findPaymentAttestationForTarget } from '@/lib/superchat' +import { buildAttestedPaymentIdSet, findPaymentAttestationForTarget } from '@/lib/superchat' +import { normalizeHexPubkey } from '@/lib/pubkey' import client from '@/services/client.service' import indexedDb from '@/services/indexed-db.service' import type { Event as NostrEvent, Filter } from 'nostr-tools' const attestationByTargetKey = new Map() const relayFetchByTargetKey = new Map>() +const authorHydrateByPubkey = new Map>() + +const LOCAL_ATTESTED_KEY_PREFIX = 'jumble:attested-payment-ids:' export function paymentAttestationCacheKey(targetEventId: string, recipientPubkey: string): string { return `${targetEventId.trim().toLowerCase()}:${recipientPubkey.trim().toLowerCase()}` } +function readLocalAttestedIds(recipientPubkey: string): Set { + const pk = normalizeHexPubkey(recipientPubkey) + if (!/^[0-9a-f]{64}$/.test(pk)) return new Set() + try { + const raw = localStorage.getItem(`${LOCAL_ATTESTED_KEY_PREFIX}${pk}`) + if (!raw) return new Set() + const parsed = JSON.parse(raw) as unknown + if (!Array.isArray(parsed)) return new Set() + return new Set( + parsed + .filter((id): id is string => typeof id === 'string') + .map((id) => id.trim().toLowerCase()) + .filter((id) => /^[0-9a-f]{64}$/.test(id)) + ) + } catch { + return new Set() + } +} + +/** Durable local record that this payment was attested (survives reloads and relay failures). */ +export function markLocalAttestationTarget(recipientPubkey: string, targetEventId: string): void { + const pk = normalizeHexPubkey(recipientPubkey) + const targetId = targetEventId.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(pk) || !/^[0-9a-f]{64}$/.test(targetId)) return + const ids = readLocalAttestedIds(pk) + if (ids.has(targetId)) return + ids.add(targetId) + try { + localStorage.setItem(`${LOCAL_ATTESTED_KEY_PREFIX}${pk}`, JSON.stringify([...ids])) + } catch { + /* quota */ + } +} + +export function isLocallyMarkedAttested(recipientPubkey: string, targetEventId: string): boolean { + return readLocalAttestedIds(recipientPubkey).has(targetEventId.trim().toLowerCase()) +} + +/** Synchronous attested payment ids from durable local storage (no network). */ +export function readKnownAttestedPaymentTargetsSync(recipientPubkey: string): Set { + return new Set(readLocalAttestedIds(recipientPubkey)) +} + +/** Attested payment target ids from local storage, IndexedDB, session, and optional relay batch. */ +export async function resolveAttestedPaymentIdSet( + recipientPubkey: string, + relayAttestations: NostrEvent[] = [] +): Promise> { + const pk = normalizeHexPubkey(recipientPubkey) + if (!/^[0-9a-f]{64}$/.test(pk)) return new Set() + + const out = new Set(readLocalAttestedIds(pk)) + await hydrateAttestationsForAuthor(pk) + + const attestations: NostrEvent[] = [] + const seen = new Set() + const push = (ev: NostrEvent) => { + if (seen.has(ev.id)) return + seen.add(ev.id) + attestations.push(ev) + } + + for (const attestation of await indexedDb.getPaymentAttestationsForAuthor(pk, 1000)) { + push(attestation) + } + for (const attestation of client.eventService.getSessionEventsMatchingFilters( + [{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pk], limit: 500 }], + 500 + )) { + push(attestation) + } + for (const attestation of relayAttestations) { + push(attestation) + } + + for (const id of buildAttestedPaymentIdSet(attestations, pk)) { + out.add(id) + } + return out +} + export function peekCachedPaymentAttestation( targetEventId: string, recipientPubkey: string @@ -27,6 +112,7 @@ export function rememberPaymentAttestation( paymentAttestationCacheKey(targetEventId, recipientPubkey), attestation ) + markLocalAttestationTarget(recipientPubkey, targetEventId) } export function resolvePaymentAttestationFromEvents( @@ -41,6 +127,36 @@ export function resolvePaymentAttestationFromEvents( return match } +/** Load all known attestations by this author into the in-memory cache (once per session). */ +export async function hydrateAttestationsForAuthor(authorPubkey: string): Promise { + const pk = normalizeHexPubkey(authorPubkey) + if (!/^[0-9a-f]{64}$/.test(pk)) return + + let inflight = authorHydrateByPubkey.get(pk) + if (!inflight) { + inflight = (async () => { + const idbAttestations = await indexedDb.getPaymentAttestationsForAuthor(pk, 1000) + for (const attestation of idbAttestations) { + rememberPaymentAttestationFromPublish(attestation) + } + + const sessionHits = client.eventService.getSessionEventsMatchingFilters( + [{ kinds: [ExtendedKind.PAYMENT_ATTESTATION], authors: [pk], limit: 500 }], + 500 + ) + for (const attestation of sessionHits) { + rememberPaymentAttestationFromPublish(attestation) + } + })().finally(() => { + if (authorHydrateByPubkey.get(pk) === inflight) { + authorHydrateByPubkey.delete(pk) + } + }) + authorHydrateByPubkey.set(pk, inflight) + } + await inflight +} + export async function loadPaymentAttestationLocal( targetEventId: string, recipientPubkey: string, @@ -49,6 +165,16 @@ export async function loadPaymentAttestationLocal( const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey) if (cached) return cached + if (isLocallyMarkedAttested(recipientPubkey, targetEventId)) { + await hydrateAttestationsForAuthor(recipientPubkey) + const hydrated = peekCachedPaymentAttestation(targetEventId, recipientPubkey) + if (hydrated) return hydrated + } + + await hydrateAttestationsForAuthor(recipientPubkey) + const afterHydrate = peekCachedPaymentAttestation(targetEventId, recipientPubkey) + if (afterHydrate) return afterHydrate + const sessionHits = client.eventService.getSessionEventsMatchingFilters([filter], 5) const fromSession = resolvePaymentAttestationFromEvents(sessionHits, targetEventId, recipientPubkey) if (fromSession) return fromSession @@ -66,6 +192,10 @@ export async function refreshPaymentAttestationFromRelays( const cached = peekCachedPaymentAttestation(targetEventId, recipientPubkey) if (cached) return cached + if (isLocallyMarkedAttested(recipientPubkey, targetEventId)) { + return undefined + } + const key = paymentAttestationCacheKey(targetEventId, recipientPubkey) let inflight = relayFetchByTargetKey.get(key) if (!inflight) { @@ -89,7 +219,10 @@ export async function refreshPaymentAttestationFromRelays( export function rememberPaymentAttestationFromPublish(attestation: NostrEvent): void { if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return - const targetId = attestation.tags.find(([name]) => name === 'e' || name === 'E')?.[1]?.trim().toLowerCase() + const targetId = attestation.tags + .find(([name]) => name === 'e' || name === 'E')?.[1] + ?.trim() + .toLowerCase() if (!targetId || !/^[0-9a-f]{64}$/.test(targetId)) return rememberPaymentAttestation(targetId, attestation.pubkey, attestation) } diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts index 217a76c4..a207445a 100644 --- a/src/lib/superchat.ts +++ b/src/lib/superchat.ts @@ -69,13 +69,11 @@ export function findPaymentAttestationForTarget( recipientPubkey: string ): Event | undefined { const target = targetEventId.trim().toLowerCase() - const recipient = recipientPubkey.trim().toLowerCase() for (const attestation of attestations) { - if (attestation.pubkey.toLowerCase() !== recipient) continue + if (!hexPubkeysEqual(attestation.pubkey, recipientPubkey)) continue const attestedId = getPaymentAttestationTargetId(attestation) - const targetKind = getPaymentAttestationTargetKind(attestation) - if (!attestedId || !targetKind) continue - if (attestedId.toLowerCase() === target) return attestation + if (!attestedId || attestedId.toLowerCase() !== target) continue + return attestation } return undefined } diff --git a/src/lib/thread-interaction-req.ts b/src/lib/thread-interaction-req.ts index a67859fa..34748b92 100644 --- a/src/lib/thread-interaction-req.ts +++ b/src/lib/thread-interaction-req.ts @@ -92,3 +92,17 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte } return filters } + +/** Zap / payment filters only — run first so paid thread replies appear before regular replies. */ +export function buildThreadSuperchatPriorityFilters( + input: BuildThreadInteractionFiltersInput +): Filter[] { + const superchatKinds = new Set([kinds.Zap, ExtendedKind.PAYMENT_NOTIFICATION]) + const out: Filter[] = [] + for (const filter of buildThreadInteractionFilters(input)) { + const kindsList = filter.kinds?.filter((k) => superchatKinds.has(k)) + if (!kindsList?.length) continue + out.push({ ...filter, kinds: kindsList }) + } + return out +} diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index f2c65d39..16f72ba1 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -200,6 +200,14 @@ export class EventService { * Session cache is keyed by event `id` (hex). `fetchEvent("naddr1…")` has no hex until a REQ returns; * scan for a replaceable whose `kind`/`pubkey`/`d` matches the naddr (e.g. live 30311 already loaded from ticker/embed). */ + findSessionReplaceableByNaddr(data: { + pubkey: string + kind: number + identifier: string + }): NEvent | undefined { + return this.getSessionEventIfMatchingNaddr(data) + } + private getSessionEventIfMatchingNaddr(data: { pubkey: string kind: number @@ -376,12 +384,26 @@ export class EventService { pointerHasFetchHints = Boolean(data.author || data.relays?.length) break case 'naddr': { + const ident = data.identifier ?? '' const fromSession = this.getSessionEventIfMatchingNaddr({ pubkey: data.pubkey, kind: data.kind, - identifier: data.identifier ?? '' + identifier: ident }) if (fromSession) return fromSession + try { + const fromIdb = await indexedDb.getReplaceableEvent( + data.pubkey.toLowerCase(), + data.kind, + ident + ) + if (fromIdb && fromIdb.kind === data.kind && !shouldDropEventOnIngest(fromIdb)) { + this.addEventToCache(fromIdb) + return fromIdb + } + } catch { + /* optional */ + } break } } @@ -1286,6 +1308,28 @@ export class EventService { } } + if ( + filter.authors?.length === 1 && + filter.kinds?.length === 1 && + Array.isArray(filter['#d']) && + filter['#d'].length >= 1 + ) { + const pk = filter.authors[0]!.trim().toLowerCase() + const kind = filter.kinds[0]! + const dTag = String(filter['#d'][0] ?? '').trim() + if (pk && dTag) { + try { + const cached = await indexedDb.getReplaceableEvent(pk, kind, dTag) + if (cached && cached.kind === kind && !shouldDropEventOnIngest(cached, ingestOpts)) { + this.addEventToCache(cached, ingestOpts) + return cached + } + } catch { + /* optional */ + } + } + } + if (relays.length > 0) { const hintedEvents = await this.queryService.query(relays, filter, undefined, { immediateReturn: true, diff --git a/src/services/client-replaceable-events.service.ts b/src/services/client-replaceable-events.service.ts index e36f87a3..565bc82d 100644 --- a/src/services/client-replaceable-events.service.ts +++ b/src/services/client-replaceable-events.service.ts @@ -1,5 +1,6 @@ import { AUTHOR_PROFILE_VIEW_REPLACEABLE_KINDS, + DOCUMENT_RELAY_URLS, ExtendedKind, FAST_READ_RELAY_URLS, FEED_PROFILE_BATCH_FETCH_TIMEOUT_MS, @@ -9,7 +10,8 @@ import { METADATA_BATCH_QUERY_GLOBAL_TIMEOUT_MS, PROFILE_BATCH_NETWORK_LOAD_TIMEOUT_MS, PROFILE_RELAY_URLS, - RECOMMENDED_BLOSSOM_SERVERS + RECOMMENDED_BLOSSOM_SERVERS, + isDocumentRelayKind } from '@/constants' import { kinds, nip19 } from 'nostr-tools' import type { Event as NEvent, Filter } from 'nostr-tools' @@ -831,38 +833,74 @@ export class ReplaceableEventService { private async replaceableEventBatchLoadFn( params: readonly { pubkey: string; kind: number; d?: string }[] ): Promise<(NEvent | null)[]> { - const groups = new Map() - params.forEach(({ pubkey, kind, d }) => { - const key = `${kind}:${d ?? ''}` - if (!groups.has(key)) { - groups.set(key, []) - } - groups.get(key)!.push({ pubkey, kind, d }) - }) + const results: (NEvent | null)[] = new Array(params.length).fill(null) + const missing: { pubkey: string; kind: number; d: string; index: number }[] = [] + + await Promise.allSettled( + params.map(async ({ pubkey, kind, d }, index) => { + if (!d) { + results[index] = null + return + } + try { + const idb = await indexedDb.getReplaceableEvent(pubkey, kind, d) + if (idb && idb.kind === kind && !shouldDropEventOnIngest(idb)) { + results[index] = idb + return + } + } catch { + /* optional */ + } + const session = client.eventService.findSessionReplaceableByNaddr({ + pubkey, + kind, + identifier: d + }) + if (session && session.kind === kind && !shouldDropEventOnIngest(session)) { + results[index] = session + return + } + missing.push({ pubkey, kind, d, index }) + }) + ) + + if (missing.length === 0) { + return results + } const eventsMap = new Map() + const groups = new Map() + for (const item of missing) { + const key = `${item.kind}:${item.d}` + if (!groups.has(key)) groups.set(key, []) + groups.get(key)!.push(item) + } + await Promise.allSettled( - Array.from(groups.entries()).map(async ([, items]) => { + Array.from(groups.values()).map(async (items) => { const { kind, d } = items[0]! - const pubkeys = items.map(item => item.pubkey) - const relayUrls = FAST_READ_RELAY_URLS + const pubkeys = items.map((item) => item.pubkey) + const relayUrls = stripLocalNetworkRelaysForWssReq( + isDocumentRelayKind(kind) + ? [...new Set([...FAST_READ_RELAY_URLS, ...DOCUMENT_RELAY_URLS])] + : [...FAST_READ_RELAY_URLS] + ) const filter: Filter = { authors: pubkeys, - kinds: [kind] - } - if (d) { - filter['#d'] = [d] + kinds: [kind], + '#d': [d] } const events = await this.queryService.query(relayUrls, filter, undefined, { replaceableRace: true, - eoseTimeout: 100, // Reduced from 200ms for faster early returns - globalTimeout: 2000 // Reduced from 3000ms to prevent long waits when many relays are slow + eoseTimeout: isDocumentRelayKind(kind) ? 2500 : 100, + globalTimeout: isDocumentRelayKind(kind) ? 8000 : 2000 }) for (const event of events) { - const eventKey = `${event.pubkey}:${event.kind}:${d ?? ''}` + if (event.kind !== kind || shouldDropEventOnIngest(event)) continue + const eventKey = `${event.pubkey}:${event.kind}:${d}` const existing = eventsMap.get(eventKey) if (!existing || existing.created_at < event.created_at) { eventsMap.set(eventKey, event) @@ -871,15 +909,16 @@ export class ReplaceableEventService { }) ) - return params.map(({ pubkey, kind, d }) => { - const eventKey = `${pubkey}:${kind}:${d ?? ''}` + for (const { pubkey, kind, d, index } of missing) { + const eventKey = `${pubkey}:${kind}:${d}` const event = eventsMap.get(eventKey) if (event) { + results[index] = event void indexedDb.putReplaceableEvent(event) - return event } - return null - }) + } + + return results } /** Persist kind 10133 rows returned alongside a kind-0 REQ (same filter, separate cache slots). */ diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index efed79b1..955be363 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -668,6 +668,12 @@ class IndexedDbService { // This prevents the "no profile" state when cache exists but is just old } transaction.commit() + if (!row.value) { + return resolve(undefined) + } + if (row.value.kind !== kind) { + return resolve(undefined) + } resolve(row.value) }