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. */