From dc33d85cfd0218d3bbe484590d12b121dccfce29 Mon Sep 17 00:00:00 2001 From: Silberengel Date: Sat, 23 May 2026 15:07:16 +0200 Subject: [PATCH] more bug-fixes --- src/components/Note/Superchat.tsx | 32 +++++++--- src/components/Note/Zap.tsx | 46 +++++++++----- src/components/ReplyNote/index.tsx | 2 +- src/hooks/usePaymentAttestationStatus.test.ts | 56 +++++++++++++++++ src/hooks/usePaymentAttestationStatus.tsx | 63 +++++++++++-------- src/lib/event-metadata.ts | 8 ++- src/lib/superchat.ts | 6 +- 7 files changed, 157 insertions(+), 56 deletions(-) create mode 100644 src/hooks/usePaymentAttestationStatus.test.ts diff --git a/src/components/Note/Superchat.tsx b/src/components/Note/Superchat.tsx index 253a5079..a4cb6e16 100644 --- a/src/components/Note/Superchat.tsx +++ b/src/components/Note/Superchat.tsx @@ -1,4 +1,5 @@ import { useFetchEvent } from '@/hooks' +import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus' import { openNoteFromFetchOrCache } from '@/lib/navigation-related-events' import { parsePaytoTagType } from '@/lib/payto' import { relayHintsFromEventTags } from '@/lib/relay-list-builder' @@ -20,17 +21,17 @@ export type SuperchatLayoutVariant = 'notification' | 'profileWall' | 'thread' export default function Superchat({ event, className, - showAttestationAction = false, variant = 'thread' }: { event: Event className?: string - /** Notifications feed only — attest incoming payments. */ + /** @deprecated Attestation button is shown automatically for payment recipients. */ showAttestationAction?: boolean /** `notification`: recipient + view links; `profileWall`: sender row; `thread`: body only. */ variant?: SuperchatLayoutVariant }) { const { t } = useTranslation() + const { attested } = usePaymentAttestationStatus(event) const info = useMemo(() => getPaymentNotificationInfo(event), [event]) const paytoType = useMemo( () => (info?.payto ? parsePaytoTagType(info.payto) : 'unknown'), @@ -64,6 +65,7 @@ export default function Superchat({ const hasThreadTarget = Boolean(targetEvent || referencedFetchId) const isNotification = variant === 'notification' const isProfileWall = variant === 'profileWall' + const showAsSuperchat = isProfileWall || attested const hasTarget = isNotification && (hasThreadTarget || Boolean(recipientPubkey)) const hasMetaLine = isProfileWall || @@ -128,19 +130,29 @@ export default function Superchat({ hasMetaLine && 'mt-1' )} > - - {t('Superchat')} + {showAsSuperchat ? ( + <> + + {t('Superchat')} + + ) : ( + + )} ) : null} {comment ? ( ) : null} - {showAttestationAction ? ( - + {!isProfileWall ? ( + ) : null} ) diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index 53acce6c..16872808 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -1,4 +1,5 @@ import { useFetchEvent } from '@/hooks' +import { usePaymentAttestationStatus } from '@/hooks/usePaymentAttestationStatus' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { shouldHideInteractions } from '@/lib/event-filtering' import { formatAmount } from '@/lib/lightning' @@ -8,6 +9,7 @@ import { getSuperchatPaytoType } from '@/lib/superchat' import { toProfile } from '@/lib/link' import { cn } from '@/lib/utils' import { Event } from 'nostr-tools' +import { Zap as ZapIcon } from 'lucide-react' import { useMemo, type MouseEvent } from 'react' import { useTranslation } from 'react-i18next' import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager' @@ -21,11 +23,11 @@ import type { SuperchatLayoutVariant } from './Superchat' export default function Zap({ event, className, - showAttestationAction = false, variant = 'thread' }: { event: Event className?: string + /** @deprecated Attestation button is shown automatically for payment recipients. */ showAttestationAction?: boolean variant?: SuperchatLayoutVariant }) { @@ -52,6 +54,7 @@ export default function Zap({ }, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey]) const paytoType = useMemo(() => getSuperchatPaytoType(event), [event]) + const { attested } = usePaymentAttestationStatus(event) const { navigateToNote } = useSmartNoteNavigationOptional() const secondaryPage = useSecondaryPageOptional() const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url }) @@ -83,6 +86,7 @@ export default function Zap({ const isNotification = variant === 'notification' const isProfileWall = variant === 'profileWall' + const showAsSuperchat = isProfileWall || attested const hasMetaLine = isProfileWall || (isNotification && @@ -147,24 +151,38 @@ export default function Zap({ hasMetaLine && 'mt-1' )} > - - {t('Superchat')} - {amount != null ? ( - - {formatAmount(amount)} {t('sats')} - - ) : null} + {showAsSuperchat ? ( + <> + + {t('Superchat')} + {amount != null ? ( + + {formatAmount(amount)} {t('sats')} + + ) : null} + + ) : ( + <> + + {t('Zap')} + {amount != null ? ( + + {formatAmount(amount)} {t('sats')} + + ) : null} + + )} ) : null} {comment ? ( ) : null} - {showAttestationAction ? ( - + {!isProfileWall ? ( + ) : null} ) diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index e7dda355..45cc9c10 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -203,7 +203,7 @@ export default function ReplyNote({ {t(notificationReactionSummaryKey(reactionDisplay))} )} - ) : event.kind === kinds.Zap ? ( + ) : event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT ? ( ) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? ( diff --git a/src/hooks/usePaymentAttestationStatus.test.ts b/src/hooks/usePaymentAttestationStatus.test.ts new file mode 100644 index 00000000..49b8c089 --- /dev/null +++ b/src/hooks/usePaymentAttestationStatus.test.ts @@ -0,0 +1,56 @@ +import { ExtendedKind } from '@/constants' +import { describe, expect, it } from 'vitest' +import { isPaymentAttestationForTarget } from './usePaymentAttestationStatus' +import type { Event } from 'nostr-tools' + +const recipient = 'a'.repeat(64) +const targetId = 'b'.repeat(64) + +function attestationEvent(overrides: Partial = {}): Event { + return { + id: 'c'.repeat(64), + kind: ExtendedKind.PAYMENT_ATTESTATION, + pubkey: recipient, + created_at: 1, + tags: [['e', targetId], ['k', '9735']], + content: '', + sig: 'sig', + ...overrides + } +} + +describe('isPaymentAttestationForTarget', () => { + it('accepts a matching kind 9741 attestation', () => { + expect(isPaymentAttestationForTarget(attestationEvent(), targetId, recipient)).toBe(true) + }) + + it('rejects zap receipts and other kinds', () => { + expect( + isPaymentAttestationForTarget( + attestationEvent({ kind: ExtendedKind.ZAP_RECEIPT, tags: [['e', targetId]] }), + targetId, + recipient + ) + ).toBe(false) + }) + + it('rejects attestations for a different payment target', () => { + expect( + isPaymentAttestationForTarget( + attestationEvent({ tags: [['e', 'd'.repeat(64)], ['k', '9735']] }), + targetId, + recipient + ) + ).toBe(false) + }) + + it('rejects attestations from a different author', () => { + expect( + isPaymentAttestationForTarget( + attestationEvent({ pubkey: 'e'.repeat(64) }), + targetId, + recipient + ) + ).toBe(false) + }) +}) diff --git a/src/hooks/usePaymentAttestationStatus.tsx b/src/hooks/usePaymentAttestationStatus.tsx index 572c77d4..797976ce 100644 --- a/src/hooks/usePaymentAttestationStatus.tsx +++ b/src/hooks/usePaymentAttestationStatus.tsx @@ -6,9 +6,7 @@ import { import { hexPubkeysEqual } from '@/lib/pubkey' import { hydrateAttestationsForAuthor, - isLocallyMarkedAttested, loadPaymentAttestationLocal, - markLocalAttestationTarget, peekCachedPaymentAttestation, refreshPaymentAttestationFromRelays, rememberPaymentAttestationFromPublish @@ -26,6 +24,17 @@ function attestationFilter(recipientPubkey: string, targetEventId: string) { } } +export function isPaymentAttestationForTarget( + attestation: NostrEvent, + targetEventId: string, + recipientPubkey: string +): boolean { + if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return false + if (!hexPubkeysEqual(attestation.pubkey, recipientPubkey)) return false + const attestedId = getPaymentAttestationTargetId(attestation) + return Boolean(attestedId && attestedId.toLowerCase() === targetEventId.trim().toLowerCase()) +} + function readAttestedFromLocalSources( targetEventId: string | undefined, recipientPubkey: string | null @@ -34,9 +43,8 @@ function readAttestedFromLocalSources( return { attested: false, attestationEvent: null } } const hit = peekCachedPaymentAttestation(targetEventId, recipientPubkey) - const locallyMarked = isLocallyMarkedAttested(recipientPubkey, targetEventId) return { - attested: Boolean(hit) || locallyMarked, + attested: Boolean(hit), attestationEvent: hit ?? null } } @@ -64,23 +72,25 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) ) const [checking, setChecking] = useState(false) - const applyMatch = useCallback((match: NostrEvent | undefined) => { - if (!match) return - setAttestationEvent(match) - setAttested(true) + const applyMatch = useCallback( + (match: NostrEvent | undefined) => { + if (!match || !targetEvent?.id || !recipientPubkey) return + if (!isPaymentAttestationForTarget(match, targetEvent.id, recipientPubkey)) return + setAttestationEvent(match) + setAttested(true) + }, + [recipientPubkey, targetEvent?.id] + ) + + const clearAttested = useCallback(() => { + setAttestationEvent(null) + setAttested(false) }, []) const markAttested = useCallback( (attestation: NostrEvent) => { if (!targetEvent?.id || !recipientPubkey) 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 || attestedId.toLowerCase() !== targetEvent.id.toLowerCase()) return + if (!isPaymentAttestationForTarget(attestation, targetEvent.id, recipientPubkey)) return rememberPaymentAttestationFromPublish(attestation) applyMatch(attestation) }, @@ -101,10 +111,6 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) useEffect(() => { if (!targetEvent?.id || !recipientPubkey || !filter) return - if (isLocallyMarkedAttested(recipientPubkey, targetEvent.id)) { - setAttested(true) - } - let cancelled = false setChecking(true) @@ -116,16 +122,17 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) applyMatch(local) return } - if (isLocallyMarkedAttested(recipientPubkey, targetEvent.id)) { - setAttested(true) - return - } const relay = await refreshPaymentAttestationFromRelays( targetEvent.id, recipientPubkey, filter ) - if (!cancelled) applyMatch(relay) + if (cancelled) return + if (relay) { + applyMatch(relay) + } else { + clearAttested() + } } catch { /* optional */ } finally { @@ -136,13 +143,15 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) return () => { cancelled = true } - }, [applyMatch, filter, recipientPubkey, targetEvent?.id, targetId]) + }, [applyMatch, clearAttested, filter, recipientPubkey, targetEvent?.id, targetId]) useEffect(() => { if (!targetEvent?.id || !recipientPubkey) return const handleAttestation = (data: globalThis.Event) => { - markAttested((data as CustomEvent).detail) + const attestation = (data as CustomEvent).detail + if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return + markAttested(attestation) } client.addEventListener('newEvent', handleAttestation) diff --git a/src/lib/event-metadata.ts b/src/lib/event-metadata.ts index 50c06c82..3eeb69c9 100644 --- a/src/lib/event-metadata.ts +++ b/src/lib/event-metadata.ts @@ -465,7 +465,13 @@ export function getRelaySetFromEvent(event: Event, blockedRelays?: string[]): TR } export function getZapInfoFromEvent(receiptEvent: Event) { - if (receiptEvent.kind !== kinds.Zap && receiptEvent.kind !== ExtendedKind.ZAP_REQUEST) return null + if ( + receiptEvent.kind !== kinds.Zap && + receiptEvent.kind !== ExtendedKind.ZAP_RECEIPT && + receiptEvent.kind !== ExtendedKind.ZAP_REQUEST + ) { + return null + } // Kind 9734 — zap request: all data is directly on the event (no bolt11, no description wrapper). if (receiptEvent.kind === ExtendedKind.ZAP_REQUEST) { diff --git a/src/lib/superchat.ts b/src/lib/superchat.ts index 646787a3..a6c1946c 100644 --- a/src/lib/superchat.ts +++ b/src/lib/superchat.ts @@ -101,7 +101,7 @@ export function getPaymentNotificationInfo(event: Event): PaymentNotificationInf /** Payment category for superchat display (9735 → lightning). */ export function getSuperchatPaytoType(event: Event): string { - if (event.kind === kinds.Zap) return 'lightning' + if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) return 'lightning' if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { const payto = getPaymentNotificationInfo(event)?.payto return payto ? parsePaytoTagType(payto) : 'unknown' @@ -119,7 +119,7 @@ export function getSuperchatReferenceFetchId(info: PaymentNotificationInfo): str } export function getSuperchatAmountSats(event: Event): number { - if (event.kind === kinds.Zap) { + if (event.kind === kinds.Zap || event.kind === ExtendedKind.ZAP_RECEIPT) { return getZapInfoFromEvent(event)?.amount ?? 0 } if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { @@ -129,7 +129,7 @@ export function getSuperchatAmountSats(event: Event): number { } export function isSuperchatKind(kind: number): boolean { - return kind === kinds.Zap || kind === ExtendedKind.PAYMENT_NOTIFICATION + return kind === kinds.Zap || kind === ExtendedKind.ZAP_RECEIPT || kind === ExtendedKind.PAYMENT_NOTIFICATION } /** Recipient pubkey for a kind 9735 or 9740 payment the user may attest to. */