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))