Browse Source

bug-fixes

imwald
Silberengel 3 weeks ago
parent
commit
ed5cb4a819
  1. 2
      src/components/ContentPreview/index.tsx
  2. 152
      src/components/Note/Superchat.tsx
  3. 31
      src/components/Note/SuperchatCommentMarkdown.tsx
  4. 233
      src/components/Note/Zap.tsx
  5. 58
      src/components/Note/index.tsx
  6. 22
      src/components/PaytoDialog/index.tsx
  7. 4
      src/components/Profile/ProfileWallSuperchats.tsx
  8. 1
      src/components/Profile/index.tsx
  9. 15
      src/components/ProfileAbout/index.tsx
  10. 9
      src/components/ReplyNote/index.tsx
  11. 8
      src/components/TurnIntoSuperchatButton/index.tsx
  12. 111
      src/hooks/usePaymentAttestationStatus.tsx
  13. 5
      src/lib/feed-local-event-match.ts
  14. 23
      src/lib/navigation-related-events.ts

2
src/components/ContentPreview/index.tsx

@ -315,7 +315,7 @@ export default function ContentPreview({ @@ -315,7 +315,7 @@ export default function ContentPreview({
if (previewDensity === 'compact') {
return (
<div className={cn('min-w-0', previewOuter)}>
<Zap event={previewEvent} variant="compact" omitSenderHeading className={previewBody} />
<Zap event={previewEvent} className={previewBody} />
</div>
)
}

152
src/components/Note/Superchat.tsx

@ -1,27 +1,25 @@ @@ -1,27 +1,25 @@
import { useFetchEvent } from '@/hooks'
import { openNoteFromFetchOrCache } from '@/lib/navigation-related-events'
import { parsePaytoTagType } from '@/lib/payto'
import { relayHintsFromEventTags } from '@/lib/relay-list-builder'
import { getPaymentNotificationInfo, getSuperchatReferenceFetchId } 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 Superchat({
event,
className,
omitSenderHeading,
variant = 'default'
className
}: {
event: Event
className?: string
omitSenderHeading?: boolean
variant?: 'default' | 'compact'
}) {
const { t } = useTranslation()
const info = useMemo(() => getPaymentNotificationInfo(event), [event])
@ -38,17 +36,16 @@ export default function Superchat({ @@ -38,17 +36,16 @@ export default function Superchat({
[info]
)
const { event: targetEvent } = useFetchEvent(referencedFetchId)
const threadRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const threadFetchOpts = useMemo(
() => (threadRelayHints.length ? { relayHints: threadRelayHints } : undefined),
[threadRelayHints]
)
const { event: targetEvent } = useFetchEvent(referencedFetchId, undefined, threadFetchOpts)
if (!info) {
return (
<div
className={cn(
'text-sm text-muted-foreground',
variant === 'compact' ? 'py-0.5' : 'rounded-lg border border-border bg-muted/20 p-4',
className
)}
>
<div className={cn('py-0.5 text-sm text-muted-foreground', className)}>
[{t('Invalid superchat')}]
</div>
)
@ -57,106 +54,55 @@ export default function Superchat({ @@ -57,106 +54,55 @@ export default function Superchat({
const { senderPubkey, recipientPubkey, comment } = info
const hasThreadTarget = Boolean(targetEvent || referencedFetchId)
const hasTarget = hasThreadTarget || Boolean(recipientPubkey)
const hasMetaLine =
(recipientPubkey && recipientPubkey !== senderPubkey) || hasTarget
const openTarget = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (targetEvent) {
navigateToNote(toNote(targetEvent), targetEvent)
} else if (referencedFetchId) {
navigateToNote(toNote(referencedFetchId))
if (referencedFetchId) {
openNoteFromFetchOrCache(navigateToNote, referencedFetchId, targetEvent)
} else if (recipientPubkey) {
push(toProfile(recipientPubkey))
}
}
if (variant === 'compact') {
const hasMetaLine =
(recipientPubkey && recipientPubkey !== senderPubkey) || hasTarget
return (
<div className={cn('text-sm text-muted-foreground', className)}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<SuperchatPaymentMethodLabel paytoType={paytoType} />
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
</div>
{hasMetaLine ? (
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm">
{recipientPubkey && recipientPubkey !== senderPubkey ? (
<span>
<span>{t('to')}</span>{' '}
<Username
userId={recipientPubkey}
className="inline font-medium text-foreground/85 hover:text-foreground"
/>
</span>
) : null}
{hasTarget ? (
<button
type="button"
onClick={openTarget}
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{hasThreadTarget ? t('Superchat thread') : t('Superchat profile')}
</button>
) : null}
</div>
) : null}
{comment ? (
<p className="mt-2 text-base font-medium leading-snug text-foreground whitespace-pre-wrap break-words">
{comment}
</p>
) : null}
</div>
)
}
return (
<div
className={cn(
'relative rounded-lg border border-yellow-400/35 bg-yellow-400/5 p-4 text-card-foreground shadow-sm',
className
)}
>
{hasTarget ? (
<button
type="button"
onClick={openTarget}
className="absolute bottom-3 right-3 flex items-center gap-2 rounded-md border border-border bg-secondary/80 px-2.5 py-1.5 text-xs font-medium text-secondary-foreground shadow-sm transition-colors hover:bg-secondary"
>
{hasThreadTarget ? t('View thread') : t('View profile')}
</button>
) : null}
<div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36">
<div className="mt-1 shrink-0">
<SuperchatPaymentMethodLabel paytoType={paytoType} className="text-base" />
</div>
<div className="min-w-0 flex-1">
{!omitSenderHeading && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<UserAvatar userId={senderPubkey} size="small" />
<Username userId={senderPubkey} className="font-semibold text-foreground" />
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<span className="w-full basis-full flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t('to')}</span>
<UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold text-foreground" />
</span>
)}
</div>
)}
{comment ? (
<div className="rounded-r-md border-l-[3px] border-yellow-400 bg-muted/40 py-2.5 pl-3 pr-2 dark:bg-muted/25">
<p className="text-xl font-semibold leading-snug tracking-tight text-foreground whitespace-pre-wrap break-words">
{comment}
</p>
</div>
<div className={cn('text-sm text-muted-foreground', className)}>
{hasMetaLine ? (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm">
{recipientPubkey && recipientPubkey !== senderPubkey ? (
<span>
<span>{t('to')}</span>{' '}
<Username
userId={recipientPubkey}
className="inline font-medium text-foreground/85 hover:text-foreground"
/>
</span>
) : null}
{hasTarget ? (
<button
type="button"
onClick={openTarget}
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{hasThreadTarget ? t('Superchat thread') : t('Superchat profile')}
</button>
) : null}
</div>
) : null}
<div
className={cn(
'flex flex-wrap items-center gap-x-2 gap-y-1',
hasMetaLine && 'mt-1'
)}
>
<SuperchatPaymentMethodLabel paytoType={paytoType} />
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
</div>
<TurnIntoSuperchatButton event={event} prominent className="mt-4" />
{comment ? (
<SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" />
) : null}
<TurnIntoSuperchatButton event={event} prominent className="mt-3" />
</div>
)
}

31
src/components/Note/SuperchatCommentMarkdown.tsx

@ -0,0 +1,31 @@ @@ -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 (
<MarkdownArticle
event={previewEvent}
hideMetadata
lazyMedia={false}
className={cn(
'prose-lg max-w-none text-foreground [&_p]:text-xl [&_p]:font-semibold [&_p]:leading-snug',
className
)}
/>
)
}

233
src/components/Note/Zap.tsx

@ -2,205 +2,128 @@ import { useFetchEvent } from '@/hooks' @@ -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 { t } = useTranslation()
const zapInfo = useMemo(() => getZapInfoFromEvent(event), [event])
const { event: targetEvent } = useFetchEvent(zapInfo?.eventId)
const zapRelayHints = useMemo(() => relayHintsFromEventTags(event), [event])
const zapFetchOpts = useMemo(
() => (zapRelayHints.length ? { relayHints: zapRelayHints } : undefined),
[zapRelayHints]
)
const { event: targetEvent } = useFetchEvent(zapInfo?.eventId, undefined, zapFetchOpts)
const isEventZap = Boolean(targetEvent || zapInfo?.eventId)
const isProfileZap = Boolean(!isEventZap && zapInfo?.recipientPubkey)
const actualRecipientPubkey = useMemo(() => {
if (isEventZap && targetEvent) {
return targetEvent.pubkey
}
if (isProfileZap) {
return zapInfo?.recipientPubkey
}
return undefined
}, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey])
const paytoType = useMemo(() => getSuperchatPaytoType(event), [event])
const { navigateToNote } = useSmartNoteNavigationOptional()
const secondaryPage = useSecondaryPageOptional()
const push = secondaryPage?.push ?? ((url: string) => { window.location.href = url })
// 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)) {
if (!zapInfo || !zapInfo.senderPubkey) {
return (
<div
className={cn(
'text-sm text-muted-foreground',
variant === 'compact'
? 'py-0.5'
: 'rounded-lg border border-border bg-muted/20 p-4',
className
)}
>
<div className={cn('py-0.5 text-sm text-muted-foreground', className)}>
[{t('Invalid zap receipt')}]
</div>
)
}
// Determine if this is an event zap or profile zap
const isEventZap = targetEvent || zapInfo?.eventId
const isProfileZap = !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
return zapInfo?.recipientPubkey
}
return undefined
}, [isEventZap, isProfileZap, targetEvent, zapInfo?.recipientPubkey])
const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
const paytoType = useMemo(() => getSuperchatPaytoType(event), [event])
const openZapTarget = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (isEventZap) {
if (targetEvent) {
navigateToNote(toNote(targetEvent), targetEvent)
} else if (zapInfo.eventId) {
navigateToNote(toNote(zapInfo.eventId))
}
if (isEventZap && zapInfo?.eventId) {
openNoteFromFetchOrCache(navigateToNote, zapInfo.eventId, targetEvent)
} else if (isProfileZap && actualRecipientPubkey) {
push(toProfile(actualRecipientPubkey))
}
}
if (variant === 'compact') {
const hasMetaLine =
(recipientPubkey && recipientPubkey !== senderPubkey) || isEventZap || isProfileZap
return (
<div className={cn('text-sm text-muted-foreground', className)}>
<div className="flex flex-wrap items-center gap-x-2 gap-y-1">
<SuperchatPaymentMethodLabel paytoType={paytoType} />
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
</div>
{hasMetaLine ? (
<div className="mt-1 flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm">
{recipientPubkey && recipientPubkey !== senderPubkey && (
<span>
<span>{t('zapped')}</span>{' '}
<Username
userId={recipientPubkey}
className="inline font-medium text-foreground/85 hover:text-foreground"
/>
</span>
)}
{(isEventZap || isProfileZap) && (
<button
type="button"
onClick={openZapTarget}
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{isEventZap
? t('Zapped note')
: isProfileZap && actualRecipientPubkey
? t('Zapped profile')
: t('Zap')}
</button>
)}
</div>
) : null}
{comment ? (
<p className="mt-2 text-base font-medium leading-snug text-foreground whitespace-pre-wrap break-words">
{comment}
</p>
) : null}
</div>
)
}
const hasMetaLine =
(recipientPubkey && recipientPubkey !== senderPubkey) || isEventZap || isProfileZap
return (
<div
className={cn(
'relative rounded-lg border border-border bg-card p-4 text-card-foreground shadow-sm',
className
)}
>
<button
type="button"
onClick={openZapTarget}
className="absolute bottom-3 right-3 flex items-center gap-2 rounded-md border border-border bg-secondary/80 px-2.5 py-1.5 text-xs font-medium text-secondary-foreground shadow-sm transition-colors hover:bg-secondary"
>
{isEventZap ? (
<span className="font-mono text-muted-foreground">
{(targetEvent?.id || zapInfo.eventId)?.substring(0, 12)}
</span>
) : isProfileZap && actualRecipientPubkey ? (
<>
<UserAvatar userId={actualRecipientPubkey} size="xSmall" />
<span>{t('Zapped profile')}</span>
</>
) : (
t('Zap')
)}
</button>
<div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36">
<div className="mt-1 shrink-0">
<SuperchatPaymentMethodLabel paytoType={paytoType} className="text-base" />
</div>
<div className="min-w-0 flex-1">
{!omitSenderHeading && (
<div className="mb-3 flex flex-wrap items-center gap-2">
<UserAvatar userId={senderPubkey} size="small" />
<Username userId={senderPubkey} className="font-semibold text-foreground" />
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<span className="w-full basis-full flex flex-wrap items-center gap-2 text-sm text-muted-foreground">
<span>{t('zapped')}</span>
<UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold text-foreground" />
</span>
)}
</div>
)}
{comment ? (
<div className="mb-3 rounded-r-md border-l-[3px] border-primary bg-muted/40 py-2.5 pl-3 pr-2 dark:bg-muted/25">
<p className="text-xl font-semibold leading-snug tracking-tight text-foreground whitespace-pre-wrap break-words">
{comment}
</p>
</div>
) : null}
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-0.5">
<span className="text-2xl font-bold tabular-nums tracking-tight text-foreground sm:text-3xl">
{formatAmount(amount)}
<div className={cn('text-sm text-muted-foreground', className)}>
{hasMetaLine ? (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm">
{recipientPubkey && recipientPubkey !== senderPubkey && (
<span>
<span>{t('zapped')}</span>{' '}
<Username
userId={recipientPubkey}
className="inline font-medium text-foreground/85 hover:text-foreground"
/>
</span>
<span className="text-base font-medium text-muted-foreground">{t('sats')}</span>
</div>
)}
{(isEventZap || isProfileZap) && (
<button
type="button"
onClick={openZapTarget}
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
>
{isEventZap
? t('Zapped note')
: isProfileZap && actualRecipientPubkey
? t('Zapped profile')
: t('Zap')}
</button>
)}
</div>
) : null}
<div
className={cn(
'flex flex-wrap items-center gap-x-2 gap-y-1',
hasMetaLine && 'mt-1'
)}
>
<SuperchatPaymentMethodLabel paytoType={paytoType} />
<span className="text-base font-semibold text-yellow-400/90">{t('Superchat')}</span>
{amount != null ? (
<span className="text-lg font-bold tabular-nums tracking-tight text-foreground">
{formatAmount(amount)} {t('sats')}
</span>
) : null}
</div>
<TurnIntoSuperchatButton event={event} prominent className="mt-4" />
{comment ? (
<SuperchatCommentMarkdown event={event} comment={comment} className="mt-2" />
) : null}
<TurnIntoSuperchatButton event={event} prominent className="mt-3" />
</div>
)
}

58
src/components/Note/index.tsx

@ -250,10 +250,17 @@ export default function Note({ @@ -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({ @@ -683,24 +690,43 @@ export default function Note({
maxFileSizeKb={showFull ? 2048 : 500}
deferRemoteAvatar={deferAuthorAvatar}
/>
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
{showFull ? (
<div className="flex-1 w-0">
<div className="flex gap-2 items-center">
<Username
userId={event.pubkey}
className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<ClientTag event={event} />
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
</div>
) : (
<div className="flex min-w-0 flex-1 flex-wrap items-center gap-x-2 gap-y-0.5 overflow-hidden">
<Username
userId={event.pubkey}
className={`font-semibold flex truncate ${size === 'small' ? 'text-sm' : ''}`}
className={`max-w-[min(12rem,40vw)] shrink font-semibold truncate ${size === 'small' ? 'text-sm' : ''}`}
skeletonClassName={size === 'small' ? 'h-3' : 'h-4'}
/>
<ClientTag event={event} />
<span className="inline-flex min-w-0 flex-wrap items-center gap-x-1 gap-y-0 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</span>
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
short={isSmallScreen}
/>
</div>
</div>
)}
</>
)}
</div>

22
src/components/PaytoDialog/index.tsx

@ -188,7 +188,7 @@ export default function PaytoDialog({ @@ -188,7 +188,7 @@ export default function PaytoDialog({
<DialogContent
className={cn(
'left-[50%] top-[50%] flex w-[calc(100vw-1.25rem)] max-w-md translate-x-[-50%] translate-y-[-50%] flex-col gap-0',
'max-h-[min(92dvh,720px)] overflow-x-hidden overflow-y-auto p-0 sm:max-w-md sm:p-0',
'max-h-[min(92dvh,720px)] overflow-hidden p-0 sm:max-w-md sm:p-0',
'pb-[max(0.75rem,env(safe-area-inset-bottom))]'
)}
>
@ -206,7 +206,8 @@ export default function PaytoDialog({ @@ -206,7 +206,8 @@ export default function PaytoDialog({
</DialogDescription>
</DialogHeader>
<div className="min-w-0 space-y-4 px-4 py-4 sm:px-5">
<div className="min-h-0 flex-1 overflow-x-hidden overflow-y-auto">
<div className="min-w-0 space-y-4 px-4 py-4 sm:px-5">
{isLightning && open ? (
<LightningInvoiceSection
lightningAddress={authority}
@ -315,26 +316,27 @@ export default function PaytoDialog({ @@ -315,26 +316,27 @@ export default function PaytoDialog({
</div>
</div>
)}
</div>
</div>
{canOfferPostPayment ? (
<DialogFooter className="flex shrink-0 flex-col-reverse gap-2 border-t border-border/60 px-4 py-3 sm:flex-row sm:justify-end sm:px-5">
<DialogFooter className="flex shrink-0 flex-col gap-2 border-t border-border/60 bg-background px-4 py-3 sm:flex-row sm:justify-end sm:px-5">
<Button
ref={sendMessageRef}
type="button"
variant="outline"
variant="default"
className="w-full min-w-0 sm:w-auto"
onClick={() => onOpenChange(false)}
onClick={handleSendMessage}
>
{t('Close')}
{t('Send a message')}
</Button>
<Button
ref={sendMessageRef}
type="button"
variant="default"
variant="outline"
className="w-full min-w-0 sm:w-auto"
onClick={handleSendMessage}
onClick={() => onOpenChange(false)}
>
{t('Send a message')}
{t('Close')}
</Button>
</DialogFooter>
) : (

4
src/components/Profile/ProfileWallSuperchats.tsx

@ -32,9 +32,9 @@ export default function ProfileWallSuperchats({ @@ -32,9 +32,9 @@ export default function ProfileWallSuperchats({
<div className="space-y-2">
{superchats.map((event) =>
event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
<Superchat key={event.id} event={event} variant="compact" />
<Superchat key={event.id} event={event} />
) : (
<Zap key={event.id} event={event} variant="compact" />
<Zap key={event.id} event={event} />
)
)}
</div>

1
src/components/Profile/index.tsx

@ -547,6 +547,7 @@ export default function Profile({ @@ -547,6 +547,7 @@ export default function Profile({
</div>
<ProfileAbout
about={about}
profilePubkey={pubkey}
className="text-wrap break-words whitespace-pre-wrap mt-2 select-text"
/>
{/* Display websites - show first one prominently, others below */}

15
src/components/ProfileAbout/index.tsx

@ -18,7 +18,16 @@ import { @@ -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 @@ -56,7 +65,7 @@ export default function ProfileAbout({ about, className }: { about?: string; cla
}
if (node.type === 'payto') {
return (
<PaytoLink key={`${keyPrefix}-payto-${index}`} paytoUri={node.data} />
<PaytoLink key={`${keyPrefix}-payto-${index}`} paytoUri={node.data} pubkey={profilePubkey} />
)
}
if (node.type === 'hashtag') {
@ -119,7 +128,7 @@ export default function ProfileAbout({ about, className }: { about?: string; cla @@ -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(
<PaytoLink key={`${key}-payto-link`} paytoUri={href}>
<PaytoLink key={`${key}-payto-link`} paytoUri={href} pubkey={profilePubkey}>
{label}
</PaytoLink>
)

9
src/components/ReplyNote/index.tsx

@ -163,7 +163,10 @@ export default function ReplyNote({ @@ -163,7 +163,10 @@ export default function ReplyNote({
<div className="mt-1.5 not-prose max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" />
</div>
) : parentEventId ? (
) : parentEventId &&
event.kind !== kinds.Zap &&
event.kind !== ExtendedKind.PAYMENT_NOTIFICATION &&
event.kind !== ExtendedKind.ZAP_RECEIPT ? (
<ParentNotePreview
appearance="subtle"
className="mt-1.5"
@ -201,9 +204,9 @@ export default function ReplyNote({ @@ -201,9 +204,9 @@ export default function ReplyNote({
)}
</div>
) : event.kind === kinds.Zap ? (
<Zap className="mt-1.5" event={event} omitSenderHeading variant="compact" />
<Zap className="mt-1.5" event={event} />
) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
<Superchat className="mt-1.5" event={event} omitSenderHeading variant="compact" />
<Superchat className="mt-1.5" event={event} />
) : isNip18RepostKind(event.kind) ? null : (
<MarkdownArticle
className="mt-2"

8
src/components/TurnIntoSuperchatButton/index.tsx

@ -56,7 +56,7 @@ function TurnIntoSuperchatButtonInner({ @@ -56,7 +56,7 @@ function TurnIntoSuperchatButtonInner({
const { t } = useTranslation()
const { publish, checkLogin } = useNostr()
const recipientPubkey = getSuperchatPaymentRecipientPubkey(event)
const { attested, checking } = usePaymentAttestationStatus(event)
const { attested, checking, markAttested } = usePaymentAttestationStatus(event)
const [publishing, setPublishing] = useState(false)
if (!recipientPubkey) {
@ -78,11 +78,15 @@ function TurnIntoSuperchatButtonInner({ @@ -78,11 +78,15 @@ function TurnIntoSuperchatButtonInner({
}
const handleAttest = () => {
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) {

111
src/hooks/usePaymentAttestationStatus.tsx

@ -5,8 +5,26 @@ import { @@ -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) @@ -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<NostrEvent>).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<NostrEvent>).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 }
}

5
src/lib/feed-local-event-match.ts

@ -15,8 +15,9 @@ function valuesMatchTag(tagName: string, eventValues: string[], filterValues: un @@ -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

23
src/lib/navigation-related-events.ts

@ -1,4 +1,5 @@ @@ -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[] { @@ -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))
}

Loading…
Cancel
Save