diff --git a/src/components/Note/Superchat.tsx b/src/components/Note/Superchat.tsx index c82c8dc3..265ecca9 100644 --- a/src/components/Note/Superchat.tsx +++ b/src/components/Note/Superchat.tsx @@ -151,10 +151,10 @@ export default function Superchat({ {comment ? ( ) : null} - {!isProfileWall ? ( + {isNotification ? ( diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index cafe6e47..ad887de8 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -183,10 +183,10 @@ export default function Zap({ {comment ? ( ) : null} - {!isProfileWall ? ( + {isNotification ? ( diff --git a/src/components/NoteList/index.tsx b/src/components/NoteList/index.tsx index 08b3c8d8..897968d7 100644 --- a/src/components/NoteList/index.tsx +++ b/src/components/NoteList/index.tsx @@ -602,6 +602,21 @@ function tightestSinceFromSpellFilters(shardFilters: Filter[]): number | undefin return sinceCandidates.length > 0 ? Math.max(...sinceCandidates) : undefined } +/** `#p` mention filters (notifications spell) — used for fast IndexedDB payment-notification warm-up. */ +function recipientPubkeysFromSpellFilters(shardFilters: Filter[]): string[] { + const out = new Set() + for (const f of shardFilters) { + const pTags = f['#p'] + if (!Array.isArray(pTags)) continue + for (const pk of pTags) { + if (typeof pk !== 'string') continue + const norm = pk.trim().toLowerCase() + if (/^[0-9a-f]{64}$/.test(norm)) out.add(norm) + } + } + return [...out] +} + /** Union of `filter.kinds` across mapped REQ shards; empty if any shard omits kinds (caller should not use fallback). */ function filterEvsToMappedTimelineReqKinds( evs: Event[], @@ -2571,6 +2586,44 @@ const NoteList = forwardRef( urls: string[] filter: TSubRequestFilter }> + + const mergeSpellLocalDiskLayer = (incoming: Event[], variant: string) => { + if (!effectActive || timelineEffectStale()) return + const narrowed = narrowLiveBatch(incoming) + if (narrowed.length === 0) return + const merged = collapseDuplicateNip18RepostTimelineRows( + mergeEventBatchesById(spellLocalMergeBase, narrowed, eventCapEarly, areAlgoRelays) + ) + if (merged.length === 0) return + spellLocalMergeBase = merged + timelineMergeBootstrapRef.current = merged.slice() + setEvents(merged) + lastEventsForTimelinePrefetchRef.current = merged + setNewEvents([]) + setShowCount(revealBatchSize ?? SHOW_COUNT) + setLoading(false) + feedPaintRelayPendingRef.current = true + feedPaintRelayMetaRef.current = { variant, mergedCount: merged.length } + setFeedEmptyToastGateTick((n) => n + 1) + setFeedTimelineEmptyUiReady(true) + } + + const mentionRecipients = recipientPubkeysFromSpellFilters(shardFilters) + if (mentionRecipients.length === 1) { + try { + const paymentNotifications = await indexedDb.getPaymentNotificationsForRecipient( + mentionRecipients[0]!, + localLayerCap + ) + mergeSpellLocalDiskLayer( + paymentNotifications.filter(matchesSpellLocal), + 'spell_payment_notifications_idb' + ) + } catch { + /* best-effort */ + } + } + const [diskRaw, filterAwareLocalRaw, fromPub, fromArch] = await Promise.all([ client.getTimelineDiskSnapshotEvents(filterAwareDiskReq), client.getLocalFeedEvents(filterAwareDiskReq, { diff --git a/src/components/TurnIntoSuperchatButton/index.tsx b/src/components/TurnIntoSuperchatButton/index.tsx index 4ef18bed..218c4b6a 100644 --- a/src/components/TurnIntoSuperchatButton/index.tsx +++ b/src/components/TurnIntoSuperchatButton/index.tsx @@ -84,7 +84,7 @@ function TurnIntoSuperchatButtonInner({ className )} > - {t('Superchat attested')} + {t('Confirmed by recipient')}

) } diff --git a/src/hooks/usePaymentAttestationStatus.test.ts b/src/hooks/usePaymentAttestationStatus.test.ts index 49b8c089..1c4305d3 100644 --- a/src/hooks/usePaymentAttestationStatus.test.ts +++ b/src/hooks/usePaymentAttestationStatus.test.ts @@ -1,6 +1,10 @@ import { ExtendedKind } from '@/constants' +import { + markLocalAttestationTarget, + rememberPaymentAttestation +} from '@/lib/payment-attestation-cache' import { describe, expect, it } from 'vitest' -import { isPaymentAttestationForTarget } from './usePaymentAttestationStatus' +import { isPaymentAttestationForTarget, readAttestedFromLocalSources } from './usePaymentAttestationStatus' import type { Event } from 'nostr-tools' const recipient = 'a'.repeat(64) @@ -19,6 +23,24 @@ function attestationEvent(overrides: Partial = {}): Event { } } +describe('readAttestedFromLocalSources', () => { + it('returns attested when the in-memory cache has a match', () => { + const attestation = attestationEvent() + rememberPaymentAttestation(targetId, recipient, attestation) + const result = readAttestedFromLocalSources(targetId, recipient) + expect(result.attested).toBe(true) + expect(result.attestationEvent?.id).toBe(attestation.id) + }) + + it('returns attested when durable local storage marks the target', () => { + const otherTargetId = 'd'.repeat(64) + markLocalAttestationTarget(recipient, otherTargetId) + const result = readAttestedFromLocalSources(otherTargetId, recipient) + expect(result.attested).toBe(true) + expect(result.attestationEvent).toBeNull() + }) +}) + describe('isPaymentAttestationForTarget', () => { it('accepts a matching kind 9741 attestation', () => { expect(isPaymentAttestationForTarget(attestationEvent(), targetId, recipient)).toBe(true) diff --git a/src/hooks/usePaymentAttestationStatus.tsx b/src/hooks/usePaymentAttestationStatus.tsx index 727a521b..b45018a7 100644 --- a/src/hooks/usePaymentAttestationStatus.tsx +++ b/src/hooks/usePaymentAttestationStatus.tsx @@ -3,13 +3,16 @@ import { getPaymentAttestationTargetId, getSuperchatPaymentRecipientPubkey } from '@/lib/superchat' -import { hexPubkeysEqual } from '@/lib/pubkey' +import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hydrateAttestationsForAuthor, + isLocallyMarkedAttested, loadPaymentAttestationLocal, peekCachedPaymentAttestation, + readKnownAttestedPaymentTargetsSync, refreshPaymentAttestationFromRelays, - rememberPaymentAttestationFromPublish + rememberPaymentAttestationFromPublish, + resolveAttestedPaymentIdSet } from '@/lib/payment-attestation-cache' import client from '@/services/client.service' import { Event as NostrEvent } from 'nostr-tools' @@ -35,27 +38,45 @@ export function isPaymentAttestationForTarget( return Boolean(attestedId && attestedId.toLowerCase() === targetEventId.trim().toLowerCase()) } -function readAttestedFromLocalSources( +export function readAttestedFromLocalSources( targetEventId: string | undefined, recipientPubkey: string | null ): { attested: boolean; attestationEvent: NostrEvent | null } { if (!targetEventId || !recipientPubkey) { return { attested: false, attestationEvent: null } } + const targetLower = targetEventId.trim().toLowerCase() const hit = peekCachedPaymentAttestation(targetEventId, recipientPubkey) - return { - attested: Boolean(hit), - attestationEvent: hit ?? null + if (hit) { + return { attested: true, attestationEvent: hit } + } + if ( + isLocallyMarkedAttested(recipientPubkey, targetEventId) || + readKnownAttestedPaymentTargetsSync(recipientPubkey).has(targetLower) + ) { + return { attested: true, attestationEvent: null } } + return { attested: false, attestationEvent: null } +} + +function isTargetInAttestedSet( + attestedIds: ReadonlySet, + targetEventId: string +): boolean { + return attestedIds.has(targetEventId.trim().toLowerCase()) } export function usePaymentAttestationStatus( targetEvent: NostrEvent | undefined, recipientPubkeyOverride?: string | null ) { - const recipientPubkey = targetEvent - ? recipientPubkeyOverride ?? getSuperchatPaymentRecipientPubkey(targetEvent) - : null + const recipientPubkey = useMemo(() => { + if (!targetEvent) return null + const raw = recipientPubkeyOverride ?? getSuperchatPaymentRecipientPubkey(targetEvent) + if (!raw) return null + const normalized = normalizeHexPubkey(raw) + return /^[0-9a-f]{64}$/.test(normalized) ? normalized : null + }, [recipientPubkeyOverride, targetEvent]) const targetId = targetEvent?.id?.toLowerCase() const filter = useMemo( @@ -108,10 +129,43 @@ export function usePaymentAttestationStatus( setAttested(next.attested) }, [recipientPubkey, targetEvent?.id, targetId]) + const applyResolvedAttestation = useCallback( + (attestedIds: ReadonlySet) => { + if (!targetEvent?.id || !recipientPubkey) return false + if (!isTargetInAttestedSet(attestedIds, targetEvent.id)) return false + const cached = peekCachedPaymentAttestation(targetEvent.id, recipientPubkey) + if (cached) { + applyMatch(cached) + } else { + setAttestationEvent(null) + setAttested(true) + } + return true + }, + [applyMatch, recipientPubkey, targetEvent?.id] + ) + useEffect(() => { - if (!recipientPubkey) return - void hydrateAttestationsForAuthor(recipientPubkey) - }, [recipientPubkey]) + if (!recipientPubkey || !targetEvent?.id) return + + let cancelled = false + void (async () => { + await hydrateAttestationsForAuthor(recipientPubkey) + if (cancelled) return + const next = readAttestedFromLocalSources(targetEvent.id, recipientPubkey) + if (!next.attested) return + if (next.attestationEvent) { + applyMatch(next.attestationEvent) + } else { + setAttestationEvent(null) + setAttested(true) + } + })() + + return () => { + cancelled = true + } + }, [applyMatch, recipientPubkey, targetEvent?.id, targetId]) useEffect(() => { if (!targetEvent?.id || !recipientPubkey || !filter) return @@ -127,6 +181,11 @@ export function usePaymentAttestationStatus( applyMatch(local) return } + + const attestedIds = await resolveAttestedPaymentIdSet(recipientPubkey) + if (cancelled) return + if (applyResolvedAttestation(attestedIds)) return + const relay = await refreshPaymentAttestationFromRelays( targetEvent.id, recipientPubkey, @@ -135,7 +194,12 @@ export function usePaymentAttestationStatus( if (cancelled) return if (relay) { applyMatch(relay) - } else { + return + } + + const afterRelay = await resolveAttestedPaymentIdSet(recipientPubkey) + if (cancelled) return + if (!applyResolvedAttestation(afterRelay)) { clearAttested() } } catch { @@ -148,7 +212,15 @@ export function usePaymentAttestationStatus( return () => { cancelled = true } - }, [applyMatch, clearAttested, filter, recipientPubkey, targetEvent?.id, targetId]) + }, [ + applyMatch, + applyResolvedAttestation, + clearAttested, + filter, + recipientPubkey, + targetEvent?.id, + targetId + ]) useEffect(() => { if (!targetEvent?.id || !recipientPubkey) return diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 7f54f709..b78bccc0 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -221,6 +221,7 @@ export default { "Invalid superchat": "Invalid superchat", "Turn this into a superchat!": "Turn this into a superchat!", "Superchat attested": "Superchat attested", + "Confirmed by recipient": "Confirmed by recipient", "Failed to attest superchat": "Failed to attest superchat: {{error}}", "Superchat thread": "View thread", "Superchat profile": "View profile", diff --git a/src/lib/payment-attestation-cache.ts b/src/lib/payment-attestation-cache.ts index b502c1c4..412ea40a 100644 --- a/src/lib/payment-attestation-cache.ts +++ b/src/lib/payment-attestation-cache.ts @@ -192,10 +192,6 @@ 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) { diff --git a/src/services/client.service.ts b/src/services/client.service.ts index 9a22ca07..31ab78dd 100644 --- a/src/services/client.service.ts +++ b/src/services/client.service.ts @@ -2267,22 +2267,25 @@ class ClientService extends EventTarget { add(this.eventService.getSessionEventsMatchingFilters(filters, maxMatches)) - const [timelineRows, archiveRows, publicationRows, paymentSuperchatRows] = await Promise.all([ + const [timelineRows, paymentSuperchatRows] = await Promise.all([ this.getTimelineDiskSnapshotEvents(subRequests).catch(() => [] as NEvent[]), + indexedDb + .getPaymentSuperchatEventsMatchingFilters(filters, maxMatches) + .catch(() => [] as NEvent[]) + ]) + add(timelineRows) + add(paymentSuperchatRows) + + const [archiveRows, publicationRows] = await Promise.all([ indexedDb .scanEventArchiveByFilters(filters, { maxRowsScanned, maxMatches }) .catch(() => [] as NEvent[]), indexedDb .scanPublicationEventsByFilters(filters, { maxRowsScanned: Math.min(maxRowsScanned, 16_000), maxMatches }) - .catch(() => [] as NEvent[]), - indexedDb - .getPaymentSuperchatEventsMatchingFilters(filters, maxMatches) .catch(() => [] as NEvent[]) ]) - add(timelineRows) add(archiveRows) add(publicationRows) - add(paymentSuperchatRows) return [...byId.values()] .sort((a, b) => b.created_at - a.created_at || b.id.localeCompare(a.id))