Browse Source

bug-fix

imwald
Silberengel 3 weeks ago
parent
commit
dec8de6068
  1. 2
      src/components/Note/Superchat.tsx
  2. 3
      src/components/Note/Zap.tsx
  3. 13
      src/components/ReplyNoteList/index.tsx
  4. 10
      src/components/TurnIntoSuperchatButton/index.tsx
  5. 10
      src/hooks/usePaymentAttestationStatus.test.ts
  6. 85
      src/hooks/usePaymentAttestationStatus.tsx
  7. 34
      src/lib/payment-attestation-cache.ts

2
src/components/Note/Superchat.tsx

@ -31,7 +31,6 @@ export default function Superchat({
variant?: SuperchatLayoutVariant variant?: SuperchatLayoutVariant
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { attested } = usePaymentAttestationStatus(event)
const info = useMemo(() => getPaymentNotificationInfo(event), [event]) const info = useMemo(() => getPaymentNotificationInfo(event), [event])
const paytoType = useMemo( const paytoType = useMemo(
() => (info?.payto ? parsePaytoTagType(info.payto) : 'unknown'), () => (info?.payto ? parsePaytoTagType(info.payto) : 'unknown'),
@ -62,6 +61,7 @@ export default function Superchat({
} }
const { senderPubkey, recipientPubkey, comment } = info const { senderPubkey, recipientPubkey, comment } = info
const { attested } = usePaymentAttestationStatus(event, recipientPubkey)
const hasThreadTarget = Boolean(targetEvent || referencedFetchId) const hasThreadTarget = Boolean(targetEvent || referencedFetchId)
const isNotification = variant === 'notification' const isNotification = variant === 'notification'
const isProfileWall = variant === 'profileWall' const isProfileWall = variant === 'profileWall'

3
src/components/Note/Zap.tsx

@ -54,7 +54,6 @@ export default function Zap({
}, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey]) }, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey])
const paytoType = useMemo(() => getSuperchatPaytoType(event), [event]) const paytoType = useMemo(() => getSuperchatPaytoType(event), [event])
const { attested } = usePaymentAttestationStatus(event)
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
const secondaryPage = useSecondaryPageOptional() const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
@ -77,6 +76,8 @@ export default function Zap({
const attestationRecipientPubkey = actualRecipientPubkey ?? recipientPubkey ?? null const attestationRecipientPubkey = actualRecipientPubkey ?? recipientPubkey ?? null
const { attested } = usePaymentAttestationStatus(event, attestationRecipientPubkey)
const openZapTarget = (e: MouseEvent<HTMLButtonElement>) => { const openZapTarget = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation() e.stopPropagation()
if (isEventZap && zapInfo?.eventId) { if (isEventZap && zapInfo?.eventId) {

13
src/components/ReplyNoteList/index.tsx

@ -51,7 +51,6 @@ import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-li
import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal' import { sanitizeRelayUrlsForFetch } from '@/lib/read-only-relay-personal'
import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req' import { buildThreadInteractionFilters, buildThreadSuperchatPriorityFilters } from '@/lib/thread-interaction-req'
import { import {
readKnownAttestedPaymentTargetsSync,
resolveAttestedPaymentIdSet resolveAttestedPaymentIdSet
} from '@/lib/payment-attestation-cache' } from '@/lib/payment-attestation-cache'
import { feedRelayPolicyUrls } from '@/features/feed/relay-policy' import { feedRelayPolicyUrls } from '@/features/feed/relay-policy'
@ -443,18 +442,6 @@ function ReplyNoteList({
return out.length ? out : undefined return out.length ? out : undefined
}, [duplicateWebPreviewCleanedUrlHints, rootInfo]) }, [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(() => { useEffect(() => {
const pk = event.pubkey const pk = event.pubkey
if (!pk) return if (!pk) return

10
src/components/TurnIntoSuperchatButton/index.tsx

@ -11,7 +11,6 @@ import {
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { requestProfileWallRefresh } from '@/hooks/useProfileWall' import { requestProfileWallRefresh } from '@/hooks/useProfileWall'
import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus' import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus'
import { markLocalAttestationTarget } from '@/lib/payment-attestation-cache'
import { useNostr } from '@/providers/NostrProvider' import { useNostr } from '@/providers/NostrProvider'
import { Sparkles } from 'lucide-react' import { Sparkles } from 'lucide-react'
import { Event } from 'nostr-tools' import { Event } from 'nostr-tools'
@ -96,12 +95,11 @@ function TurnIntoSuperchatButtonInner({
try { try {
const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true }) const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true })
const published = await publish(draft, { disableFallbacks: true }) const published = await publish(draft, { disableFallbacks: true })
markLocalAttestationTarget(attestationRecipientPubkey, event.id) if (!published) {
if (published) { toast.error(t('Failed to attest superchat'))
markAttested(published) return
} else {
markAttested({ ...draft, id: event.id, pubkey: attestationRecipientPubkey, sig: '' } as Event)
} }
markAttested(published)
requestProfileWallRefresh(attestationRecipientPubkey) requestProfileWallRefresh(attestationRecipientPubkey)
showSimplePublishSuccess(t('Superchat attested')) showSimplePublishSuccess(t('Superchat attested'))
} catch (error) { } catch (error) {

10
src/hooks/usePaymentAttestationStatus.test.ts

@ -1,8 +1,5 @@
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { import { rememberPaymentAttestation } from '@/lib/payment-attestation-cache'
markLocalAttestationTarget,
rememberPaymentAttestation
} from '@/lib/payment-attestation-cache'
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { isPaymentAttestationForTarget, readAttestedFromLocalSources } from './usePaymentAttestationStatus' import { isPaymentAttestationForTarget, readAttestedFromLocalSources } from './usePaymentAttestationStatus'
import type { Event } from 'nostr-tools' import type { Event } from 'nostr-tools'
@ -32,11 +29,10 @@ describe('readAttestedFromLocalSources', () => {
expect(result.attestationEvent?.id).toBe(attestation.id) expect(result.attestationEvent?.id).toBe(attestation.id)
}) })
it('returns attested when durable local storage marks the target', () => { it('does not treat durable local storage alone as verified attestation', () => {
const otherTargetId = 'd'.repeat(64) const otherTargetId = 'd'.repeat(64)
markLocalAttestationTarget(recipient, otherTargetId)
const result = readAttestedFromLocalSources(otherTargetId, recipient) const result = readAttestedFromLocalSources(otherTargetId, recipient)
expect(result.attested).toBe(true) expect(result.attested).toBe(false)
expect(result.attestationEvent).toBeNull() expect(result.attestationEvent).toBeNull()
}) })
}) })

85
src/hooks/usePaymentAttestationStatus.tsx

@ -6,13 +6,10 @@ import {
import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey' import { hexPubkeysEqual, normalizeHexPubkey } from '@/lib/pubkey'
import { import {
hydrateAttestationsForAuthor, hydrateAttestationsForAuthor,
isLocallyMarkedAttested,
loadPaymentAttestationLocal, loadPaymentAttestationLocal,
peekCachedPaymentAttestation, peekCachedPaymentAttestation,
readKnownAttestedPaymentTargetsSync,
refreshPaymentAttestationFromRelays, refreshPaymentAttestationFromRelays,
rememberPaymentAttestationFromPublish, rememberPaymentAttestationFromPublish
resolveAttestedPaymentIdSet
} from '@/lib/payment-attestation-cache' } from '@/lib/payment-attestation-cache'
import client from '@/services/client.service' import client from '@/services/client.service'
import { Event as NostrEvent } from 'nostr-tools' import { Event as NostrEvent } from 'nostr-tools'
@ -38,6 +35,7 @@ export function isPaymentAttestationForTarget(
return Boolean(attestedId && attestedId.toLowerCase() === targetEventId.trim().toLowerCase()) return Boolean(attestedId && attestedId.toLowerCase() === targetEventId.trim().toLowerCase())
} }
/** Sync read: only true when a verified attestation is already in the in-memory cache. */
export function readAttestedFromLocalSources( export function readAttestedFromLocalSources(
targetEventId: string | undefined, targetEventId: string | undefined,
recipientPubkey: string | null recipientPubkey: string | null
@ -45,25 +43,11 @@ export function readAttestedFromLocalSources(
if (!targetEventId || !recipientPubkey) { if (!targetEventId || !recipientPubkey) {
return { attested: false, attestationEvent: null } return { attested: false, attestationEvent: null }
} }
const targetLower = targetEventId.trim().toLowerCase()
const hit = peekCachedPaymentAttestation(targetEventId, recipientPubkey) const hit = peekCachedPaymentAttestation(targetEventId, recipientPubkey)
if (hit) { if (!hit || !isPaymentAttestationForTarget(hit, targetEventId, recipientPubkey)) {
return { attested: true, attestationEvent: hit }
}
if (
isLocallyMarkedAttested(recipientPubkey, targetEventId) ||
readKnownAttestedPaymentTargetsSync(recipientPubkey).has(targetLower)
) {
return { attested: true, attestationEvent: null }
}
return { attested: false, attestationEvent: null } return { attested: false, attestationEvent: null }
} }
return { attested: true, attestationEvent: hit }
function isTargetInAttestedSet(
attestedIds: ReadonlySet<string>,
targetEventId: string
): boolean {
return attestedIds.has(targetEventId.trim().toLowerCase())
} }
export function usePaymentAttestationStatus( export function usePaymentAttestationStatus(
@ -100,10 +84,11 @@ export function usePaymentAttestationStatus(
const applyMatch = useCallback( const applyMatch = useCallback(
(match: NostrEvent | undefined) => { (match: NostrEvent | undefined) => {
if (!match || !targetEvent?.id || !recipientPubkey) return if (!match || !targetEvent?.id || !recipientPubkey) return false
if (!isPaymentAttestationForTarget(match, targetEvent.id, recipientPubkey)) return if (!isPaymentAttestationForTarget(match, targetEvent.id, recipientPubkey)) return false
setAttestationEvent(match) setAttestationEvent(match)
setAttested(true) setAttested(true)
return true
}, },
[recipientPubkey, targetEvent?.id] [recipientPubkey, targetEvent?.id]
) )
@ -129,22 +114,6 @@ export function usePaymentAttestationStatus(
setAttested(next.attested) setAttested(next.attested)
}, [recipientPubkey, targetEvent?.id, targetId]) }, [recipientPubkey, targetEvent?.id, targetId])
const applyResolvedAttestation = useCallback(
(attestedIds: ReadonlySet<string>) => {
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(() => { useEffect(() => {
if (!recipientPubkey || !targetEvent?.id) return if (!recipientPubkey || !targetEvent?.id) return
@ -152,14 +121,8 @@ export function usePaymentAttestationStatus(
void (async () => { void (async () => {
await hydrateAttestationsForAuthor(recipientPubkey) await hydrateAttestationsForAuthor(recipientPubkey)
if (cancelled) return if (cancelled) return
const next = readAttestedFromLocalSources(targetEvent.id, recipientPubkey) const cached = peekCachedPaymentAttestation(targetEvent.id, recipientPubkey)
if (!next.attested) return if (cached && applyMatch(cached)) return
if (next.attestationEvent) {
applyMatch(next.attestationEvent)
} else {
setAttestationEvent(null)
setAttested(true)
}
})() })()
return () => { return () => {
@ -177,14 +140,7 @@ export function usePaymentAttestationStatus(
try { try {
const local = await loadPaymentAttestationLocal(targetEvent.id, recipientPubkey, filter) const local = await loadPaymentAttestationLocal(targetEvent.id, recipientPubkey, filter)
if (cancelled) return if (cancelled) return
if (local) { if (local && applyMatch(local)) return
applyMatch(local)
return
}
const attestedIds = await resolveAttestedPaymentIdSet(recipientPubkey)
if (cancelled) return
if (applyResolvedAttestation(attestedIds)) return
const relay = await refreshPaymentAttestationFromRelays( const relay = await refreshPaymentAttestationFromRelays(
targetEvent.id, targetEvent.id,
@ -192,16 +148,9 @@ export function usePaymentAttestationStatus(
filter filter
) )
if (cancelled) return if (cancelled) return
if (relay) { if (relay && applyMatch(relay)) return
applyMatch(relay)
return
}
const afterRelay = await resolveAttestedPaymentIdSet(recipientPubkey)
if (cancelled) return
if (!applyResolvedAttestation(afterRelay)) {
clearAttested() clearAttested()
}
} catch { } catch {
/* optional */ /* optional */
} finally { } finally {
@ -212,15 +161,7 @@ export function usePaymentAttestationStatus(
return () => { return () => {
cancelled = true cancelled = true
} }
}, [ }, [applyMatch, clearAttested, filter, recipientPubkey, targetEvent?.id, targetId])
applyMatch,
applyResolvedAttestation,
clearAttested,
filter,
recipientPubkey,
targetEvent?.id,
targetId
])
useEffect(() => { useEffect(() => {
if (!targetEvent?.id || !recipientPubkey) return if (!targetEvent?.id || !recipientPubkey) return

34
src/lib/payment-attestation-cache.ts

@ -58,6 +58,27 @@ export function readKnownAttestedPaymentTargetsSync(recipientPubkey: string): Se
return new Set(readLocalAttestedIds(recipientPubkey)) return new Set(readLocalAttestedIds(recipientPubkey))
} }
/** Drop durable local marks that are not backed by a cached kind 9741 attestation. */
export function pruneUnverifiedLocalAttestationMarks(recipientPubkey: string): void {
const pk = normalizeHexPubkey(recipientPubkey)
if (!/^[0-9a-f]{64}$/.test(pk)) return
const local = readLocalAttestedIds(pk)
if (local.size === 0) return
const verified: string[] = []
for (const id of local) {
const cached = peekCachedPaymentAttestation(id, pk)
if (cached?.kind === ExtendedKind.PAYMENT_ATTESTATION) {
verified.push(id)
}
}
if (verified.length === local.size) return
try {
localStorage.setItem(`${LOCAL_ATTESTED_KEY_PREFIX}${pk}`, JSON.stringify(verified))
} catch {
/* quota */
}
}
/** Attested payment target ids from local storage, IndexedDB, session, and optional relay batch. */ /** Attested payment target ids from local storage, IndexedDB, session, and optional relay batch. */
export async function resolveAttestedPaymentIdSet( export async function resolveAttestedPaymentIdSet(
recipientPubkey: string, recipientPubkey: string,
@ -66,7 +87,7 @@ export async function resolveAttestedPaymentIdSet(
const pk = normalizeHexPubkey(recipientPubkey) const pk = normalizeHexPubkey(recipientPubkey)
if (!/^[0-9a-f]{64}$/.test(pk)) return new Set() if (!/^[0-9a-f]{64}$/.test(pk)) return new Set()
const out = new Set(readLocalAttestedIds(pk)) const out = new Set<string>()
await hydrateAttestationsForAuthor(pk) await hydrateAttestationsForAuthor(pk)
const attestations: NostrEvent[] = [] const attestations: NostrEvent[] = []
@ -93,6 +114,16 @@ export async function resolveAttestedPaymentIdSet(
for (const id of buildAttestedPaymentIdSet(attestations, pk)) { for (const id of buildAttestedPaymentIdSet(attestations, pk)) {
out.add(id) out.add(id)
} }
// Keep durable local marks only when they match a verified attestation target.
for (const id of readLocalAttestedIds(pk)) {
if (out.has(id)) continue
const cached = peekCachedPaymentAttestation(id, pk)
if (cached && cached.kind === ExtendedKind.PAYMENT_ATTESTATION) {
out.add(id)
}
}
return out return out
} }
@ -147,6 +178,7 @@ export async function hydrateAttestationsForAuthor(authorPubkey: string): Promis
for (const attestation of sessionHits) { for (const attestation of sessionHits) {
rememberPaymentAttestationFromPublish(attestation) rememberPaymentAttestationFromPublish(attestation)
} }
pruneUnverifiedLocalAttestationMarks(pk)
})().finally(() => { })().finally(() => {
if (authorHydrateByPubkey.get(pk) === inflight) { if (authorHydrateByPubkey.get(pk) === inflight) {
authorHydrateByPubkey.delete(pk) authorHydrateByPubkey.delete(pk)

Loading…
Cancel
Save