- {!omitSenderHeading && (
-
-
-
- {t('Superchat')}
- {recipientPubkey && recipientPubkey !== senderPubkey && (
-
- {t('to')}
-
-
-
- )}
-
- )}
-
- {comment ? (
-
+
+ {hasMetaLine ? (
+
+ {recipientPubkey && recipientPubkey !== senderPubkey ? (
+
+ {t('to')}{' '}
+
+
+ ) : null}
+ {hasTarget ? (
+
) : null}
+ ) : null}
+
+
+ {t('Superchat')}
-
+ {comment ? (
+
+ ) : null}
+
)
}
diff --git a/src/components/Note/SuperchatCommentMarkdown.tsx b/src/components/Note/SuperchatCommentMarkdown.tsx
new file mode 100644
index 00000000..e615581f
--- /dev/null
+++ b/src/components/Note/SuperchatCommentMarkdown.tsx
@@ -0,0 +1,31 @@
+import MarkdownArticle from './MarkdownArticle/MarkdownArticle'
+import { cn } from '@/lib/utils'
+import { Event } from 'nostr-tools'
+import { useMemo } from 'react'
+
+export default function SuperchatCommentMarkdown({
+ event,
+ comment,
+ className
+}: {
+ event: Event
+ comment: string
+ className?: string
+}) {
+ const previewEvent = useMemo(
+ () => ({ ...event, content: comment }) as Event,
+ [event, comment]
+ )
+
+ return (
+
+ )
+}
diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx
index eb19a3e9..075504ed 100644
--- a/src/components/Note/Zap.tsx
+++ b/src/components/Note/Zap.tsx
@@ -2,205 +2,128 @@ import { useFetchEvent } from '@/hooks'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { shouldHideInteractions } from '@/lib/event-filtering'
import { formatAmount } from '@/lib/lightning'
+import { openNoteFromFetchOrCache } from '@/lib/navigation-related-events'
+import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { getSuperchatPaytoType } from '@/lib/superchat'
-import { toNote, toProfile } from '@/lib/link'
+import { toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import { Event } from 'nostr-tools'
import { useMemo, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager'
import Username from '../Username'
-import UserAvatar from '../UserAvatar'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
+import SuperchatCommentMarkdown from './SuperchatCommentMarkdown'
import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
export default function Zap({
event,
- className,
- /** When the parent row already shows the zapper (e.g. reply list), hide the duplicate sender line. */
- omitSenderHeading,
- /** Dense thread row (e.g. kind 1111–sized), not the full note card. */
- variant = 'default'
+ className
}: {
event: Event
className?: string
- omitSenderHeading?: boolean
- variant?: 'default' | 'compact'
}) {
- // In quiet mode, we need to check the target event (if this is a zap receipt for an event)
- // For profile zaps, we can't check quiet mode since we don't have an event
- const zapInfo = useMemo(() => getZapInfoFromEvent(event), [event])
- const { event: targetEvent } = useFetchEvent(zapInfo?.eventId)
-
- // Check if the target event (if any) is in quiet mode
- const inQuietMode = targetEvent ? shouldHideInteractions(targetEvent) : false
-
- // Hide zap receipts in quiet mode as they contain emojis and text
- if (inQuietMode) {
- return null
- }
const { t } = useTranslation()
- const { navigateToNote } = useSmartNoteNavigationOptional()
- const secondaryPage = useSecondaryPageOptional()
- const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
-
- if (!zapInfo || !zapInfo.senderPubkey || (variant === 'default' && !zapInfo.amount)) {
- return (
-
- [{t('Invalid zap receipt')}]
-
- )
- }
+ const zapInfo = useMemo(() => getZapInfoFromEvent(event), [event])
+ const zapRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
+ const zapFetchOpts = useMemo(
+ () => (zapRelayHints.length ? { relayHints: zapRelayHints } : undefined),
+ [zapRelayHints]
+ )
+ const { event: targetEvent } = useFetchEvent(zapInfo?.eventId, undefined, zapFetchOpts)
- // Determine if this is an event zap or profile zap
- const isEventZap = targetEvent || zapInfo?.eventId
- const isProfileZap = !isEventZap && zapInfo?.recipientPubkey
+ const isEventZap = Boolean(targetEvent || zapInfo?.eventId)
+ const isProfileZap = Boolean(!isEventZap && zapInfo?.recipientPubkey)
- // For event zaps, we need to determine the recipient from the zapped event
const actualRecipientPubkey = useMemo(() => {
if (isEventZap && targetEvent) {
- // Event zap - recipient is the author of the zapped event
return targetEvent.pubkey
- } else if (isProfileZap) {
- // Profile zap - recipient is directly specified
+ }
+ if (isProfileZap) {
return zapInfo?.recipientPubkey
}
return undefined
}, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey])
- const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
const paytoType = useMemo(() => getSuperchatPaytoType(event), [event])
+ const { navigateToNote } = useSmartNoteNavigationOptional()
+ const secondaryPage = useSecondaryPageOptional()
+ const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
- const openZapTarget = (e: MouseEvent
) => {
- e.stopPropagation()
- if (isEventZap) {
- if (targetEvent) {
- navigateToNote(toNote(targetEvent), targetEvent)
- } else if (zapInfo.eventId) {
- navigateToNote(toNote(zapInfo.eventId))
- }
- } else if (isProfileZap && actualRecipientPubkey) {
- push(toProfile(actualRecipientPubkey))
- }
- }
+ const inQuietMode = targetEvent ? shouldHideInteractions(targetEvent) : false
- if (variant === 'compact') {
- const hasMetaLine =
- (recipientPubkey && recipientPubkey !== senderPubkey) || isEventZap || isProfileZap
+ if (inQuietMode) {
+ return null
+ }
+ if (!zapInfo || !zapInfo.senderPubkey) {
return (
-
-
-
- {t('Superchat')}
-
- {hasMetaLine ? (
-
- {recipientPubkey && recipientPubkey !== senderPubkey && (
-
- {t('zapped')}{' '}
-
-
- )}
- {(isEventZap || isProfileZap) && (
-
- )}
-
- ) : null}
- {comment ? (
-
- {comment}
-
- ) : null}
+
+ [{t('Invalid zap receipt')}]
)
}
- return (
-
-
+ const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
-
-
-
-
-
- {!omitSenderHeading && (
-
-
-
- {t('Superchat')}
- {recipientPubkey && recipientPubkey !== senderPubkey && (
-
- {t('zapped')}
-
-
-
- )}
-
- )}
+ const openZapTarget = (e: MouseEvent
) => {
+ e.stopPropagation()
+ if (isEventZap && zapInfo?.eventId) {
+ openNoteFromFetchOrCache(navigateToNote, zapInfo.eventId, targetEvent)
+ } else if (isProfileZap && actualRecipientPubkey) {
+ push(toProfile(actualRecipientPubkey))
+ }
+ }
- {comment ? (
-
- ) : null}
+ const hasMetaLine =
+ (recipientPubkey && recipientPubkey !== senderPubkey) || isEventZap || isProfileZap
-
-
- {formatAmount(amount)}
+ return (
+
+ {hasMetaLine ? (
+
+ {recipientPubkey && recipientPubkey !== senderPubkey && (
+
+ {t('zapped')}{' '}
+
- {t('sats')}
-
+ )}
+ {(isEventZap || isProfileZap) && (
+
+ )}
+ ) : null}
+
+
+ {t('Superchat')}
+ {amount != null ? (
+
+ {formatAmount(amount)} {t('sats')}
+
+ ) : null}
-
+ {comment ? (
+
+ ) : null}
+
)
}
-
diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx
index c500af99..9d02837e 100644
--- a/src/components/Note/index.tsx
+++ b/src/components/Note/index.tsx
@@ -250,10 +250,17 @@ export default function Note({
const { navigateToNote } = useSmartNoteNavigationOptional()
const screenSize = useScreenSizeOptional()
const isSmallScreen = screenSize?.isSmallScreen ?? false
- const parentEventId = useMemo(
- () => (hideParentNotePreview ? undefined : getParentBech32Id(event)),
- [event, hideParentNotePreview]
- )
+ const parentEventId = useMemo(() => {
+ if (hideParentNotePreview) return undefined
+ if (
+ event.kind === ExtendedKind.PAYMENT_NOTIFICATION ||
+ event.kind === ExtendedKind.ZAP_RECEIPT ||
+ event.kind === ExtendedKind.ZAP_REQUEST
+ ) {
+ return undefined
+ }
+ return getParentBech32Id(event)
+ }, [event, hideParentNotePreview])
const parentFetchRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const contentPolicy = useContentPolicyOptional()
const defaultShowNsfw = contentPolicy?.defaultShowNsfw ?? true
@@ -683,24 +690,43 @@ export default function Note({
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={deferAuthorAvatar}
/>
-
-
+ {showFull ? (
+
+ ) : (
+
+
+
+
+
-
-
-
-
-
+ )}
>
)}
diff --git a/src/components/PaytoDialog/index.tsx b/src/components/PaytoDialog/index.tsx
index 27546e0e..5f9032bb 100644
--- a/src/components/PaytoDialog/index.tsx
+++ b/src/components/PaytoDialog/index.tsx
@@ -188,7 +188,7 @@ export default function PaytoDialog({
@@ -206,7 +206,8 @@ export default function PaytoDialog({
-
+
+
{isLightning && open ? (
)}
+
{canOfferPostPayment ? (
-
+
) : (
diff --git a/src/components/Profile/ProfileWallSuperchats.tsx b/src/components/Profile/ProfileWallSuperchats.tsx
index d387e8d9..9c0ed80a 100644
--- a/src/components/Profile/ProfileWallSuperchats.tsx
+++ b/src/components/Profile/ProfileWallSuperchats.tsx
@@ -32,9 +32,9 @@ export default function ProfileWallSuperchats({
{superchats.map((event) =>
event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
-
+
) : (
-
+
)
)}
diff --git a/src/components/Profile/index.tsx b/src/components/Profile/index.tsx
index 7b4923db..43d297e0 100644
--- a/src/components/Profile/index.tsx
+++ b/src/components/Profile/index.tsx
@@ -547,6 +547,7 @@ export default function Profile({
{/* Display websites - show first one prominently, others below */}
diff --git a/src/components/ProfileAbout/index.tsx b/src/components/ProfileAbout/index.tsx
index 240a3831..3b661e9b 100644
--- a/src/components/ProfileAbout/index.tsx
+++ b/src/components/ProfileAbout/index.tsx
@@ -18,7 +18,16 @@ import {
EmbeddedWebsocketUrl
} from '../Embedded'
-export default function ProfileAbout({ about, className }: { about?: string; className?: string }) {
+export default function ProfileAbout({
+ about,
+ className,
+ profilePubkey
+}: {
+ about?: string
+ className?: string
+ /** Profile owner pubkey — enables post-payment / message flow on payto links. */
+ profilePubkey?: string
+}) {
const normalized = replaceStandardEmojiShortcodesInContent(about ?? '', [])
if (!normalized.trim()) return null
@@ -56,7 +65,7 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
}
if (node.type === 'payto') {
return (
-
+
)
}
if (node.type === 'hashtag') {
@@ -119,7 +128,7 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
const label = String(token.text ?? href)
if (href.startsWith('payto://')) {
out.push(
-
+
{label}
)
diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx
index 4e77e4fc..3ed80c6d 100644
--- a/src/components/ReplyNote/index.tsx
+++ b/src/components/ReplyNote/index.tsx
@@ -163,7 +163,10 @@ export default function ReplyNote({
- ) : parentEventId ? (
+ ) : parentEventId &&
+ event.kind !== kinds.Zap &&
+ event.kind !== ExtendedKind.PAYMENT_NOTIFICATION &&
+ event.kind !== ExtendedKind.ZAP_RECEIPT ? (
) : event.kind === kinds.Zap ? (
-
+
) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
-
+
) : isNip18RepostKind(event.kind) ? null : (
{
+ if (attested || checking || publishing) return
checkLogin(async () => {
setPublishing(true)
try {
const draft = await createPaymentAttestationDraftEvent(event, { addClientTag: true })
- await publish(draft, { disableFallbacks: true })
+ const published = await publish(draft, { disableFallbacks: true })
+ if (published) {
+ markAttested(published)
+ }
requestProfileWallRefresh(recipientPubkey)
showSimplePublishSuccess(t('Superchat attested'))
} catch (error) {
diff --git a/src/hooks/usePaymentAttestationStatus.tsx b/src/hooks/usePaymentAttestationStatus.tsx
index a50c8a8f..9de3a8f8 100644
--- a/src/hooks/usePaymentAttestationStatus.tsx
+++ b/src/hooks/usePaymentAttestationStatus.tsx
@@ -5,8 +5,26 @@ import {
getSuperchatPaymentRecipientPubkey
} from '@/lib/superchat'
import client from '@/services/client.service'
+import indexedDb from '@/services/indexed-db.service'
import { Event as NostrEvent } from 'nostr-tools'
-import { useEffect, useState } from 'react'
+import { useCallback, useEffect, useLayoutEffect, useMemo, useState } from 'react'
+
+function attestationFilter(recipientPubkey: string, targetEventId: string) {
+ return {
+ kinds: [ExtendedKind.PAYMENT_ATTESTATION],
+ authors: [recipientPubkey],
+ '#e': [targetEventId],
+ limit: 5
+ }
+}
+
+function resolveAttestationMatch(
+ attestations: NostrEvent[],
+ targetEventId: string,
+ recipientPubkey: string
+): NostrEvent | undefined {
+ return findPaymentAttestationForTarget(attestations, targetEventId, recipientPubkey)
+}
export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined) {
const [attested, setAttested] = useState(false)
@@ -16,60 +34,85 @@ export function usePaymentAttestationStatus(targetEvent: NostrEvent | undefined)
const recipientPubkey = targetEvent ? getSuperchatPaymentRecipientPubkey(targetEvent) : null
const targetId = targetEvent?.id?.toLowerCase()
- useEffect(() => {
+ const filter = useMemo(
+ () =>
+ targetEvent?.id && recipientPubkey
+ ? attestationFilter(recipientPubkey, targetEvent.id)
+ : null,
+ [targetEvent?.id, recipientPubkey]
+ )
+
+ const applyMatch = useCallback((match: NostrEvent | undefined) => {
+ if (!match) return
+ setAttestationEvent(match)
+ setAttested(true)
+ }, [])
+
+ const markAttested = useCallback(
+ (attestation: NostrEvent) => {
+ if (!targetEvent?.id || !recipientPubkey) return
+ if (attestation.kind !== ExtendedKind.PAYMENT_ATTESTATION) return
+ if (attestation.pubkey.toLowerCase() !== recipientPubkey.toLowerCase()) return
+ const attestedId = getPaymentAttestationTargetId(attestation)
+ if (attestedId?.toLowerCase() !== targetEvent.id.toLowerCase()) return
+ applyMatch(attestation)
+ },
+ [applyMatch, recipientPubkey, targetEvent?.id]
+ )
+
+ useLayoutEffect(() => {
setAttested(false)
setAttestationEvent(null)
- if (!targetEvent?.id || !recipientPubkey) return
+ if (!targetEvent?.id || !recipientPubkey || !filter) return
+
+ const sessionHits = client.eventService.getSessionEventsMatchingFilters([filter], 5)
+ applyMatch(resolveAttestationMatch(sessionHits, targetEvent.id, recipientPubkey))
+ }, [applyMatch, filter, recipientPubkey, targetEvent?.id])
+
+ useEffect(() => {
+ if (!targetEvent?.id || !recipientPubkey || !filter) return
let cancelled = false
setChecking(true)
- void client
- .fetchEvents(
- [],
- {
- kinds: [ExtendedKind.PAYMENT_ATTESTATION],
- authors: [recipientPubkey],
- '#e': [targetEvent.id],
- limit: 5
- },
- { cache: true, eoseTimeout: 4000, globalTimeout: 10_000 }
- )
- .then((attestations) => {
+ void (async () => {
+ try {
+ const [idbAttestations, localFeedAttestations, relayAttestations] = await Promise.all([
+ indexedDb.getPaymentAttestationsForTargetEvent(targetEvent.id, 20),
+ client.getLocalFeedEvents([{ urls: [], filter }], { maxMatches: 5 }),
+ client.fetchEvents([], filter, {
+ cache: true,
+ eoseTimeout: 4000,
+ globalTimeout: 10_000
+ })
+ ])
+
if (cancelled) return
- const match = findPaymentAttestationForTarget(attestations, targetEvent.id, recipientPubkey)
- setAttestationEvent(match ?? null)
- setAttested(Boolean(match))
- })
- .catch(() => {
+
+ const merged = [...idbAttestations, ...localFeedAttestations, ...relayAttestations]
+ applyMatch(resolveAttestationMatch(merged, targetEvent.id, recipientPubkey))
+ } catch {
/* optional */
- })
- .finally(() => {
+ } finally {
if (!cancelled) setChecking(false)
- })
+ }
+ })()
return () => {
cancelled = true
}
- }, [targetEvent, recipientPubkey, targetId])
+ }, [applyMatch, filter, recipientPubkey, targetEvent?.id, targetId])
useEffect(() => {
if (!targetEvent?.id || !recipientPubkey) return
const handleAttestation = (data: globalThis.Event) => {
- const evt = (data as CustomEvent).detail
- if (!evt || evt.kind !== ExtendedKind.PAYMENT_ATTESTATION) return
- if (evt.pubkey.toLowerCase() !== recipientPubkey.toLowerCase()) return
- const attestedId = getPaymentAttestationTargetId(evt)
- if (attestedId?.toLowerCase() === targetEvent.id.toLowerCase()) {
- setAttested(true)
- setAttestationEvent(evt)
- }
+ markAttested((data as CustomEvent).detail)
}
client.addEventListener('newEvent', handleAttestation)
return () => client.removeEventListener('newEvent', handleAttestation)
- }, [targetEvent?.id, recipientPubkey])
+ }, [markAttested, targetEvent?.id, recipientPubkey])
- return { attested, attestationEvent, checking, recipientPubkey }
+ return { attested, attestationEvent, checking, recipientPubkey, markAttested }
}
diff --git a/src/lib/feed-local-event-match.ts b/src/lib/feed-local-event-match.ts
index 68bcc9c3..500fd126 100644
--- a/src/lib/feed-local-event-match.ts
+++ b/src/lib/feed-local-event-match.ts
@@ -15,8 +15,9 @@ function valuesMatchTag(tagName: string, eventValues: string[], filterValues: un
export function eventMatchesLocalFeedFilter(event: Event, filter: Filter): boolean {
if (Array.isArray(filter.ids) && filter.ids.length > 0 && !filter.ids.includes(event.id)) return false
- if (Array.isArray(filter.authors) && filter.authors.length > 0 && !filter.authors.includes(event.pubkey)) {
- return false
+ if (Array.isArray(filter.authors) && filter.authors.length > 0) {
+ const allowedAuthors = new Set(filter.authors.map((author) => author.toLowerCase()))
+ if (!allowedAuthors.has(event.pubkey.toLowerCase())) return false
}
if (Array.isArray(filter.kinds) && filter.kinds.length > 0 && !filter.kinds.includes(event.kind)) return false
if (typeof filter.since === 'number' && event.created_at < filter.since) return false
diff --git a/src/lib/navigation-related-events.ts b/src/lib/navigation-related-events.ts
index 1b052fb7..f3ef5c11 100644
--- a/src/lib/navigation-related-events.ts
+++ b/src/lib/navigation-related-events.ts
@@ -1,4 +1,5 @@
import { getParentBech32Id, getRootBech32Id } from '@/lib/event'
+import { toNote } from '@/lib/link'
import client from '@/services/client.service'
import type { Event } from 'nostr-tools'
@@ -18,3 +19,25 @@ export function getCachedThreadContextEvents(forEvent: Event): Event[] {
tryAdd(getRootBech32Id(forEvent))
return [...byId.values()]
}
+
+export type NavigateToNoteFn = (url: string, event?: Event, relatedEvents?: Event[]) => void
+
+/** Prefer a fetched event, else the session cache — same seeding as parent preview clicks in feeds. */
+export function resolveCachedNoteEvent(fetched: Event | undefined, noteId?: string): Event | undefined {
+ if (fetched) return fetched
+ if (!noteId?.trim()) return undefined
+ return client.peekSessionCachedEvent(noteId.trim())
+}
+
+export function openNoteFromFetchOrCache(
+ navigateToNote: NavigateToNoteFn,
+ noteId: string,
+ fetched?: Event
+): void {
+ const resolved = resolveCachedNoteEvent(fetched, noteId)
+ if (resolved) {
+ navigateToNote(toNote(resolved), resolved, getCachedThreadContextEvents(resolved))
+ return
+ }
+ navigateToNote(toNote(noteId))
+}