Browse Source

show superchats on header

imwald
Silberengel 3 weeks ago
parent
commit
a0b9217d1b
  1. 52
      src/components/Note/ReactionEmojiDisplay.tsx
  2. 60
      src/components/Note/Superchat.tsx
  3. 63
      src/components/Note/Zap.tsx
  4. 8
      src/components/Note/index.tsx
  5. 15
      src/components/NoteList/index.tsx
  6. 11
      src/components/Profile/ProfileBadges.tsx
  7. 4
      src/components/Profile/ProfileWallSuperchats.tsx
  8. 4
      src/components/ReplyNote/index.tsx
  9. 67
      src/hooks/useProfileWall.tsx
  10. 96
      src/lib/reaction-display.test.ts
  11. 41
      src/lib/reaction-display.ts
  12. 32
      src/lib/superchat.test.ts
  13. 13
      src/lib/superchat.ts

52
src/components/Note/ReactionEmojiDisplay.tsx

@ -1,11 +1,17 @@
import Emoji from '@/components/Emoji' import Emoji from '@/components/Emoji'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { fetchAuthorNip30EmojiInfos } from '@/lib/nip30-author-emojis' import {
import { resolveReactionEmojiSync } from '@/lib/reaction-display' EMPTY_AUTHOR_NIP30_EMOJIS,
fetchAuthorNip30EmojiInfos,
fetchAuthorNip30EmojiInfosFromIndexedDb,
getAuthorNip30EmojiCache,
subscribeAuthorNip30EmojiCache
} from '@/lib/nip30-author-emojis'
import { resolveAuthorEmojiForReactionShortcode, resolveReactionEmojiSync } from '@/lib/reaction-display'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { TEmoji } from '@/types' import { TEmoji } from '@/types'
import { Event, kinds } from 'nostr-tools' import { Event, kinds } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react' import { useEffect, useMemo, useSyncExternalStore } from 'react'
/** /**
* Renders a reaction glyph (Unicode, standard :shortcode:, or NIP-30 custom image from reactor profile). * Renders a reaction glyph (Unicode, standard :shortcode:, or NIP-30 custom image from reactor profile).
@ -28,28 +34,32 @@ export default function ReactionEmojiDisplay({
[event, maxRawLength] [event, maxRawLength]
) )
const initial: TEmoji | string = const reactorPubkey = event.pubkey?.trim().toLowerCase() ?? ''
sync.mode === 'display' ? sync.value : sync.placeholder const needsAuthorLookup = sync.mode === 'profile'
const [value, setValue] = useState<TEmoji | string>(initial) const authorEmojis = useSyncExternalStore(
(onStoreChange) =>
needsAuthorLookup && /^[0-9a-f]{64}$/.test(reactorPubkey)
? subscribeAuthorNip30EmojiCache(reactorPubkey, onStoreChange)
: () => {},
() =>
needsAuthorLookup && /^[0-9a-f]{64}$/.test(reactorPubkey)
? getAuthorNip30EmojiCache(reactorPubkey)
: EMPTY_AUTHOR_NIP30_EMOJIS,
() => EMPTY_AUTHOR_NIP30_EMOJIS
)
useEffect(() => { useEffect(() => {
setValue(initial) if (!needsAuthorLookup || !/^[0-9a-f]{64}$/.test(reactorPubkey)) return
}, [initial, event.id]) void fetchAuthorNip30EmojiInfosFromIndexedDb(reactorPubkey)
void fetchAuthorNip30EmojiInfos(reactorPubkey)
}, [needsAuthorLookup, reactorPubkey])
useEffect(() => { const value: TEmoji | string = useMemo(() => {
if (sync.mode !== 'profile' || (event.kind !== kinds.Reaction && event.kind !== ExtendedKind.EXTERNAL_REACTION)) if (sync.mode === 'display') return sync.value
return const hit = resolveAuthorEmojiForReactionShortcode(authorEmojis, sync.shortcode)
let cancelled = false return hit ?? sync.placeholder
void fetchAuthorNip30EmojiInfos(event.pubkey).then((infos) => { }, [sync, authorEmojis])
if (cancelled) return
const hit = infos.find((i) => i.shortcode === sync.shortcode)
if (hit) setValue(hit)
})
return () => {
cancelled = true
}
}, [event.pubkey, event.kind, sync])
if ( if (
(event.kind !== kinds.Reaction && event.kind !== ExtendedKind.EXTERNAL_REACTION) || (event.kind !== kinds.Reaction && event.kind !== ExtendedKind.EXTERNAL_REACTION) ||

60
src/components/Note/Superchat.tsx

@ -13,16 +13,22 @@ import Username from '../Username'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel' import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
import SuperchatCommentMarkdown from './SuperchatCommentMarkdown' import SuperchatCommentMarkdown from './SuperchatCommentMarkdown'
import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton' import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
import UserAvatar from '../UserAvatar'
export type SuperchatLayoutVariant = 'notification' | 'profileWall' | 'thread'
export default function Superchat({ export default function Superchat({
event, event,
className, className,
showAttestationAction = false showAttestationAction = false,
variant = 'thread'
}: { }: {
event: Event event: Event
className?: string className?: string
/** Notifications feed only — attest incoming payments. */ /** Notifications feed only — attest incoming payments. */
showAttestationAction?: boolean showAttestationAction?: boolean
/** `notification`: recipient + view links; `profileWall`: sender row; `thread`: body only. */
variant?: SuperchatLayoutVariant
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const info = useMemo(() => getPaymentNotificationInfo(event), [event]) const info = useMemo(() => getPaymentNotificationInfo(event), [event])
@ -56,9 +62,12 @@ export default function Superchat({
const { senderPubkey, recipientPubkey, comment } = info const { senderPubkey, recipientPubkey, comment } = info
const hasThreadTarget = Boolean(targetEvent || referencedFetchId) const hasThreadTarget = Boolean(targetEvent || referencedFetchId)
const hasTarget = hasThreadTarget || Boolean(recipientPubkey) const isNotification = variant === 'notification'
const isProfileWall = variant === 'profileWall'
const hasTarget = isNotification && (hasThreadTarget || Boolean(recipientPubkey))
const hasMetaLine = const hasMetaLine =
(recipientPubkey && recipientPubkey !== senderPubkey) || hasTarget isProfileWall ||
(isNotification && ((recipientPubkey && recipientPubkey !== senderPubkey) || hasTarget))
const openTarget = (e: MouseEvent<HTMLButtonElement>) => { const openTarget = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation() e.stopPropagation()
@ -73,24 +82,37 @@ export default function Superchat({
<div className={cn('text-sm text-muted-foreground', className)}> <div className={cn('text-sm text-muted-foreground', className)}>
{hasMetaLine ? ( {hasMetaLine ? (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm"> <div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm">
{recipientPubkey && recipientPubkey !== senderPubkey ? ( {isProfileWall ? (
<span> <div className="flex min-w-0 items-center gap-2">
<span>{t('to')}</span>{' '} <UserAvatar userId={senderPubkey} size="small" className="shrink-0" />
<Username <Username
userId={recipientPubkey} userId={senderPubkey}
className="inline font-medium text-foreground/85 hover:text-foreground" showAt
className="min-w-0 font-medium text-foreground/85 hover:text-foreground"
/> />
</span> </div>
) : null} ) : (
{hasTarget ? ( <>
<button {recipientPubkey && recipientPubkey !== senderPubkey ? (
type="button" <span>
onClick={openTarget} <span>{t('to')}</span>{' '}
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" <Username
> userId={recipientPubkey}
{hasThreadTarget ? t('Superchat thread') : t('Superchat profile')} className="inline font-medium text-foreground/85 hover:text-foreground"
</button> />
) : null} </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> </div>
) : null} ) : null}
<div <div

63
src/components/Note/Zap.tsx

@ -15,15 +15,19 @@ import Username from '../Username'
import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel' import SuperchatPaymentMethodLabel from './SuperchatPaymentMethodLabel'
import SuperchatCommentMarkdown from './SuperchatCommentMarkdown' import SuperchatCommentMarkdown from './SuperchatCommentMarkdown'
import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton' import TurnIntoSuperchatButton from '../TurnIntoSuperchatButton'
import UserAvatar from '../UserAvatar'
import type { SuperchatLayoutVariant } from './Superchat'
export default function Zap({ export default function Zap({
event, event,
className, className,
showAttestationAction = false showAttestationAction = false,
variant = 'thread'
}: { }: {
event: Event event: Event
className?: string className?: string
showAttestationAction?: boolean showAttestationAction?: boolean
variant?: SuperchatLayoutVariant
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const zapInfo = useMemo(() => getZapInfoFromEvent(event), [event]) const zapInfo = useMemo(() => getZapInfoFromEvent(event), [event])
@ -77,34 +81,51 @@ export default function Zap({
} }
} }
const isNotification = variant === 'notification'
const isProfileWall = variant === 'profileWall'
const hasMetaLine = const hasMetaLine =
(recipientPubkey && recipientPubkey !== senderPubkey) || isEventZap || isProfileZap isProfileWall ||
(isNotification &&
((recipientPubkey && recipientPubkey !== senderPubkey) || isEventZap || isProfileZap))
return ( return (
<div className={cn('text-sm text-muted-foreground', className)}> <div className={cn('text-sm text-muted-foreground', className)}>
{hasMetaLine ? ( {hasMetaLine ? (
<div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm"> <div className="flex flex-wrap items-center gap-x-1.5 gap-y-0.5 text-sm">
{recipientPubkey && recipientPubkey !== senderPubkey && ( {isProfileWall ? (
<span> <div className="flex min-w-0 items-center gap-2">
<span>{t('zapped')}</span>{' '} <UserAvatar userId={senderPubkey} size="small" className="shrink-0" />
<Username <Username
userId={recipientPubkey} userId={senderPubkey}
className="inline font-medium text-foreground/85 hover:text-foreground" showAt
className="min-w-0 font-medium text-foreground/85 hover:text-foreground"
/> />
</span> </div>
)} ) : (
{(isEventZap || isProfileZap) && ( <>
<button {recipientPubkey && recipientPubkey !== senderPubkey && (
type="button" <span>
onClick={openZapTarget} <span>{t('zapped')}</span>{' '}
className="text-muted-foreground underline-offset-2 hover:text-foreground hover:underline" <Username
> userId={recipientPubkey}
{isEventZap className="inline font-medium text-foreground/85 hover:text-foreground"
? t('Zapped note') />
: isProfileZap && actualRecipientPubkey </span>
? t('Zapped profile') )}
: t('Zap')} {(isNotification && (isEventZap || isProfileZap)) && (
</button> <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> </div>
) : null} ) : null}

8
src/components/Note/index.tsx

@ -574,7 +574,12 @@ export default function Note({
content = renderEventContent({ hideMetadata: true }) content = renderEventContent({ hideMetadata: true })
} else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { } else if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
content = ( content = (
<Zap className="mt-2" event={displayEvent} showAttestationAction={showPaymentAttestationAction} /> <Zap
className="mt-2"
event={displayEvent}
showAttestationAction={showPaymentAttestationAction}
variant={showPaymentAttestationAction ? 'notification' : 'thread'}
/>
) )
} else if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) { } else if (event.kind === ExtendedKind.PAYMENT_NOTIFICATION) {
content = ( content = (
@ -582,6 +587,7 @@ export default function Note({
className="mt-2" className="mt-2"
event={displayEvent} event={displayEvent}
showAttestationAction={showPaymentAttestationAction} showAttestationAction={showPaymentAttestationAction}
variant={showPaymentAttestationAction ? 'notification' : 'thread'}
/> />
) )
} else if (event.kind === ExtendedKind.FOLLOW_PACK) { } else if (event.kind === ExtendedKind.FOLLOW_PACK) {

15
src/components/NoteList/index.tsx

@ -17,6 +17,8 @@ import {
isReplyNoteEvent, isReplyNoteEvent,
normalizeReplaceableCoordinateString normalizeReplaceableCoordinateString
} from '@/lib/event' } from '@/lib/event'
import { collectReactionAuthorPubkeysForEmojiPrefetch } from '@/lib/reaction-display'
import { prefetchAuthorNip30EmojisForPubkeys } from '@/lib/nip30-author-emojis'
import { shouldFilterEvent } from '@/lib/event-filtering' import { shouldFilterEvent } from '@/lib/event-filtering'
import { import {
isRelayUrlStrictSupersetIdentityKey, isRelayUrlStrictSupersetIdentityKey,
@ -1112,11 +1114,17 @@ const NoteList = forwardRef(
/** Pending pubkeys sync with rows so useFetchProfile skips per-note fetches before the debounced batch. */ /** Pending pubkeys sync with rows so useFetchProfile skips per-note fetches before the debounced batch. */
useLayoutEffect(() => { useLayoutEffect(() => {
const candidates = new Set<string>() const candidates = new Set<string>()
const emojiAuthors = new Set<string>()
for (const e of timelineEventsForFilter) { for (const e of timelineEventsForFilter) {
collectProfilePrefetchPubkeysFromEvent(e, candidates) collectProfilePrefetchPubkeysFromEvent(e, candidates)
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors)
} }
for (const e of newEvents) { for (const e of newEvents) {
collectProfilePrefetchPubkeysFromEvent(e, candidates) collectProfilePrefetchPubkeysFromEvent(e, candidates)
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors)
}
if (emojiAuthors.size > 0) {
prefetchAuthorNip30EmojisForPubkeys([...emojiAuthors])
} }
const pubkeysKey = [...candidates].sort().join('\n') const pubkeysKey = [...candidates].sort().join('\n')
if (pubkeysKey === lastProfilePrefetchPubkeysKeyRef.current) return if (pubkeysKey === lastProfilePrefetchPubkeysKeyRef.current) return
@ -1712,16 +1720,23 @@ const NoteList = forwardRef(
useEffect(() => { useEffect(() => {
const handle = window.setTimeout(() => { const handle = window.setTimeout(() => {
const candidates = new Set<string>() const candidates = new Set<string>()
const emojiAuthors = new Set<string>()
for (const e of timelineEventsForFilter) { for (const e of timelineEventsForFilter) {
collectProfilePrefetchPubkeysFromEvent(e, candidates) collectProfilePrefetchPubkeysFromEvent(e, candidates)
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors)
} }
for (const e of newEvents) { for (const e of newEvents) {
collectProfilePrefetchPubkeysFromEvent(e, candidates) collectProfilePrefetchPubkeysFromEvent(e, candidates)
collectReactionAuthorPubkeysForEmojiPrefetch([e], emojiAuthors)
} }
for (const e of clientFilteredEvents.slice(0, Math.min(120, Math.max(showCount + 64, 64)))) { for (const e of clientFilteredEvents.slice(0, Math.min(120, Math.max(showCount + 64, 64)))) {
collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(e.id), candidates) collectProfilePrefetchPubkeysFromNoteStats(noteStatsService.getNoteStats(e.id), candidates)
} }
if (emojiAuthors.size > 0) {
prefetchAuthorNip30EmojisForPubkeys([...emojiAuthors])
}
const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk)) const need = [...candidates].filter((pk) => !feedProfileLoadedRef.current.has(pk))
enqueueFeedProfilePubkeys(need) enqueueFeedProfilePubkeys(need)
}, FEED_PROFILE_BATCH_DEBOUNCE_MS) }, FEED_PROFILE_BATCH_DEBOUNCE_MS)

11
src/components/Profile/ProfileBadges.tsx

@ -25,14 +25,17 @@ export default function ProfileBadges({
if (isLoading && badges.length === 0 && superchats.length === 0) { if (isLoading && badges.length === 0 && superchats.length === 0) {
return ( return (
<div className="mt-3 flex flex-wrap gap-2" aria-hidden> <div className="mt-3 min-w-0" aria-hidden>
<Skeleton className="h-14 w-14 rounded-lg" /> <div className="flex flex-wrap gap-2">
<Skeleton className="h-14 w-14 rounded-lg" /> <Skeleton className="h-14 w-14 rounded-lg" />
<Skeleton className="h-14 w-14 rounded-lg" />
</div>
<ProfileWallSuperchats superchats={[]} isLoading />
</div> </div>
) )
} }
if (badges.length === 0 && superchats.length === 0) return null if (badges.length === 0 && superchats.length === 0 && !isLoading) return null
return ( return (
<div className="mt-3 min-w-0"> <div className="mt-3 min-w-0">

4
src/components/Profile/ProfileWallSuperchats.tsx

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

4
src/components/ReplyNote/index.tsx

@ -204,9 +204,9 @@ export default function ReplyNote({
)} )}
</div> </div>
) : event.kind === kinds.Zap ? ( ) : event.kind === kinds.Zap ? (
<Zap className="mt-1.5" event={event} /> <Zap className="mt-1.5" event={event} variant="thread" />
) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? ( ) : event.kind === ExtendedKind.PAYMENT_NOTIFICATION ? (
<Superchat className="mt-1.5" event={event} /> <Superchat className="mt-1.5" event={event} variant="thread" />
) : isNip18RepostKind(event.kind) ? null : ( ) : isNip18RepostKind(event.kind) ? null : (
<MarkdownArticle <MarkdownArticle
className="mt-2" className="mt-2"

67
src/hooks/useProfileWall.tsx

@ -20,6 +20,7 @@ import {
} from '@/lib/nip58-profile-badges' } from '@/lib/nip58-profile-badges'
import { isDirectProfileWallComment } from '@/lib/profile-wall-comments' import { isDirectProfileWallComment } from '@/lib/profile-wall-comments'
import { filterAttestedProfileWallSuperchats, getPaymentAttestationTargetId } from '@/lib/superchat' import { filterAttestedProfileWallSuperchats, getPaymentAttestationTargetId } from '@/lib/superchat'
import { resolveAttestedPaymentIdSet } from '@/lib/payment-attestation-cache'
import { isValidPubkey, userIdToPubkey } from '@/lib/pubkey' import { isValidPubkey, userIdToPubkey } from '@/lib/pubkey'
import { normalizeAnyRelayUrl } from '@/lib/url' import { normalizeAnyRelayUrl } from '@/lib/url'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider' import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
@ -79,6 +80,53 @@ async function fetchBadgeDefinitionOnRelays(
} }
const CACHE_DURATION = 5 * 60 * 1000 const CACHE_DURATION = 5 * 60 * 1000
async function hydrateProfileWallSuperchatTargets(
attestedIds: ReadonlySet<string>,
relayUrls: string[]
): Promise<Event[]> {
const ids = [...attestedIds].filter((id) => /^[0-9a-f]{64}$/i.test(id))
if (ids.length === 0) return []
const byId = new Map<string, Event>()
try {
const local = await client.getLocalFeedEvents(
[{ urls: [], filter: { ids, limit: ids.length } }],
{ maxMatches: ids.length }
)
for (const e of local) byId.set(e.id.toLowerCase(), e)
} catch {
/* optional */
}
for (const id of ids) {
const key = id.toLowerCase()
if (byId.has(key)) continue
try {
const fromPublication = await indexedDb.getEventFromPublicationStore(id)
if (fromPublication) byId.set(fromPublication.id.toLowerCase(), fromPublication)
} catch {
/* optional */
}
}
const missing = ids.filter((id) => !byId.has(id.toLowerCase()))
if (missing.length > 0 && relayUrls.length > 0) {
try {
const fetched = await client.fetchEvents(
relayUrls,
{ ids: missing, limit: missing.length },
{ cache: true, eoseTimeout: 4500, globalTimeout: 12_000, foreground: true }
)
for (const e of fetched) byId.set(e.id.toLowerCase(), e)
} catch {
/* optional */
}
}
return [...byId.values()]
}
const wallCacheByKey = new Map< const wallCacheByKey = new Map<
string, string,
{ badges: ResolvedProfileBadge[]; comments: Event[]; superchats: Event[]; lastUpdated: number } { badges: ResolvedProfileBadge[]; comments: Event[]; superchats: Event[]; lastUpdated: number }
@ -349,6 +397,12 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
) )
} }
const pool = new Map<string, Event>() const pool = new Map<string, Event>()
try {
const idbPayments = await indexedDb.getPaymentNotificationsForRecipient(pkNorm, 200)
for (const e of idbPayments) pool.set(e.id, e)
} catch {
/* optional */
}
try { try {
const localMatches = await client.getLocalFeedEvents( const localMatches = await client.getLocalFeedEvents(
filters.map((filter) => ({ urls: [], filter: filter as TSubRequestFilter })), filters.map((filter) => ({ urls: [], filter: filter as TSubRequestFilter })),
@ -376,6 +430,13 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
/* ignore */ /* ignore */
} }
const attestations = [...pool.values()].filter(
(e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION
)
const attestedIds = await resolveAttestedPaymentIdSet(pkNorm, attestations)
const hydratedTargets = await hydrateProfileWallSuperchatTargets(attestedIds, relayUrls)
for (const e of hydratedTargets) pool.set(e.id, e)
if (profileId) { if (profileId) {
wallComments = [...pool.values()] wallComments = [...pool.values()]
.filter( .filter(
@ -394,14 +455,12 @@ export function useProfileWall(pubkey: string, profileEventId: string | undefine
e.kind === ExtendedKind.ZAP_RECEIPT) && e.kind === ExtendedKind.ZAP_RECEIPT) &&
!isEventDeletedRef.current(e) !isEventDeletedRef.current(e)
) )
const attestations = [...pool.values()].filter(
(e) => e.kind === ExtendedKind.PAYMENT_ATTESTATION
)
wallSuperchats = filterAttestedProfileWallSuperchats( wallSuperchats = filterAttestedProfileWallSuperchats(
paymentEvents, paymentEvents,
attestations, attestations,
pkNorm, pkNorm,
profileId profileId,
attestedIds
) )
} }

96
src/lib/reaction-display.test.ts

@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest'
import { kinds } from 'nostr-tools'
import {
collectReactionAuthorPubkeysForEmojiPrefetch,
reactionNeedsAuthorEmojiLookup,
resolveAuthorEmojiForReactionShortcode,
resolveReactionEmojiSync
} from './reaction-display'
function reactionEvent(
content: string,
tags: string[][] = [],
pubkey = 'aa'.repeat(32)
) {
return {
kind: kinds.Reaction,
id: 'bb'.repeat(32),
pubkey,
content,
tags,
created_at: 1,
sig: 'cc'.repeat(32)
}
}
describe('resolveReactionEmojiSync', () => {
it('uses emoji tag when content is a custom shortcode', () => {
const event = reactionEvent(':jumble:', [
['emoji', 'jumble', 'https://cdn.example/jumble.png']
])
const result = resolveReactionEmojiSync(event, 64)
expect(result).toEqual({
mode: 'display',
value: { shortcode: 'jumble', url: 'https://cdn.example/jumble.png' }
})
})
it('matches emoji tags case-insensitively', () => {
const event = reactionEvent(':Jumble:', [
['emoji', 'jumble', 'https://cdn.example/jumble.png']
])
const result = resolveReactionEmojiSync(event, 64)
expect(result.mode).toBe('display')
if (result.mode === 'display' && typeof result.value === 'object') {
expect(result.value.url).toBe('https://cdn.example/jumble.png')
}
})
it('defers unknown custom shortcodes to author lookup', () => {
const event = reactionEvent(':unknown_custom:', [])
expect(reactionNeedsAuthorEmojiLookup(event)).toBe(true)
expect(resolveReactionEmojiSync(event, 64)).toEqual({
mode: 'profile',
shortcode: 'unknown_custom',
placeholder: ':unknown_custom:'
})
})
it('resolves URL content from emoji tag', () => {
const url = 'https://cdn.example/emoji.png'
const event = reactionEvent(url, [['emoji', 'pic', url]])
const result = resolveReactionEmojiSync(event, 64)
expect(result).toEqual({
mode: 'display',
value: { shortcode: 'pic', url }
})
})
})
describe('collectReactionAuthorPubkeysForEmojiPrefetch', () => {
it('collects reactor pubkeys for profile-lookup reactions', () => {
const pk = 'dd'.repeat(32)
const event = reactionEvent(':custom:', [], pk)
const set = new Set<string>()
collectReactionAuthorPubkeysForEmojiPrefetch([event], set)
expect(set.has(pk)).toBe(true)
})
it('skips reactions with inline emoji tags', () => {
const pk = 'ee'.repeat(32)
const event = reactionEvent(':custom:', [['emoji', 'custom', 'https://x/y.png']], pk)
const set = new Set<string>()
collectReactionAuthorPubkeysForEmojiPrefetch([event], set)
expect(set.size).toBe(0)
})
})
describe('resolveAuthorEmojiForReactionShortcode', () => {
it('finds shortcodes case-insensitively', () => {
const hit = resolveAuthorEmojiForReactionShortcode(
[{ shortcode: 'Firefly', url: 'https://cdn.example/f.png' }],
'firefly'
)
expect(hit?.url).toBe('https://cdn.example/f.png')
})
})

41
src/lib/reaction-display.ts

@ -12,6 +12,28 @@ export type TReactionEmojiSync =
| { mode: 'display'; value: TEmoji | string } | { mode: 'display'; value: TEmoji | string }
| { mode: 'profile'; shortcode: string; placeholder: string } | { mode: 'profile'; shortcode: string; placeholder: string }
function findEmojiByShortcode(infos: readonly TEmoji[], shortcode: string): TEmoji | undefined {
const lower = shortcode.toLowerCase()
return infos.find((e) => e.shortcode === shortcode || e.shortcode.toLowerCase() === lower)
}
/** True when the reaction glyph must be resolved from the reactor’s NIP-30 inventory. */
export function reactionNeedsAuthorEmojiLookup(event: Event): boolean {
return resolveReactionEmojiSync(event, 64).mode === 'profile'
}
/** Collect reactor pubkeys whose custom reaction emoji should be prefetched for feed/notification rows. */
export function collectReactionAuthorPubkeysForEmojiPrefetch(
events: readonly Event[],
candidates: Set<string>
): void {
for (const e of events) {
if (!reactionNeedsAuthorEmojiLookup(e)) continue
const pk = e.pubkey?.trim().toLowerCase()
if (pk && /^[0-9a-f]{64}$/.test(pk)) candidates.add(pk)
}
}
/** /**
* Resolve reaction display without network: emoji tags on the reaction, standard :shortcode: Unicode, * Resolve reaction display without network: emoji tags on the reaction, standard :shortcode: Unicode,
* or defer to profile (reactor kind 0) for custom shortcodes. * or defer to profile (reactor kind 0) for custom shortcodes.
@ -32,10 +54,19 @@ export function resolveReactionEmojiSync(event: Event, maxRawLength: number): TR
const fromReactionTags = getEmojiInfosFromEmojiTags(event.tags) const fromReactionTags = getEmojiInfosFromEmojiTags(event.tags)
const customShortcodes = fromReactionTags.map((e) => e.shortcode) const customShortcodes = fromReactionTags.map((e) => e.shortcode)
if (/^https?:\/\//i.test(raw)) {
const hit = fromReactionTags.find((e) => e.url === raw)
if (hit) return { mode: 'display', value: hit }
}
if (fromReactionTags.length === 1 && raw === fromReactionTags[0].shortcode) {
return { mode: 'display', value: fromReactionTags[0] }
}
const whole = raw.match(WHOLE_SHORTCODE) const whole = raw.match(WHOLE_SHORTCODE)
if (whole) { if (whole) {
const shortcode = whole[1] const shortcode = whole[1]
const hit = fromReactionTags.find((e) => e.shortcode === shortcode) const hit = findEmojiByShortcode(fromReactionTags, shortcode)
if (hit) { if (hit) {
return { mode: 'display', value: hit } return { mode: 'display', value: hit }
} }
@ -52,3 +83,11 @@ export function resolveReactionEmojiSync(event: Event, maxRawLength: number): TR
return { mode: 'display', value: raw } return { mode: 'display', value: raw }
} }
/** Match a custom shortcode from a loaded author NIP-30 inventory. */
export function resolveAuthorEmojiForReactionShortcode(
infos: readonly TEmoji[],
shortcode: string
): TEmoji | undefined {
return findEmojiByShortcode(infos, shortcode)
}

32
src/lib/superchat.test.ts

@ -63,6 +63,15 @@ describe('buildAttestedPaymentIdSet', () => {
expect(ids.has(PAYMENT_ID)).toBe(true) expect(ids.has(PAYMENT_ID)).toBe(true)
expect(ids.size).toBe(2) expect(ids.size).toBe(2)
}) })
it('collects attested ids without a k tag', () => {
const attestation = fakeEvent({
kind: ExtendedKind.PAYMENT_ATTESTATION,
pubkey: RECIPIENT,
tags: [['e', PAYMENT_ID]]
})
expect(buildAttestedPaymentIdSet([attestation], RECIPIENT).has(PAYMENT_ID)).toBe(true)
})
}) })
describe('partitionAttestedSuperchats', () => { describe('partitionAttestedSuperchats', () => {
@ -233,6 +242,29 @@ describe('profile wall payment notifications', () => {
expect(getPaymentNotificationInfo(evt)?.amountSats).toBe(50) expect(getPaymentNotificationInfo(evt)?.amountSats).toBe(50)
}) })
it('rejects 9740 with a thread reference on the profile wall', () => {
const evt = fakeEvent({
id: PAYMENT_ID,
kind: ExtendedKind.PAYMENT_NOTIFICATION,
tags: [
['p', RECIPIENT],
['e', 'f'.repeat(64)],
['amount', '50000']
]
})
const attestation = fakeEvent({
kind: ExtendedKind.PAYMENT_ATTESTATION,
pubkey: RECIPIENT,
tags: [
['e', PAYMENT_ID],
['k', '9740']
]
})
expect(isProfileWallPaymentNotification(evt, RECIPIENT)).toBe(false)
const out = filterAttestedProfileWallSuperchats([evt], [attestation], RECIPIENT)
expect(out).toHaveLength(0)
})
it('filters to attested profile wall superchats', () => { it('filters to attested profile wall superchats', () => {
const paymentId = PAYMENT_ID const paymentId = PAYMENT_ID
const payment = fakeEvent({ const payment = fakeEvent({

13
src/lib/superchat.ts

@ -50,13 +50,11 @@ export function buildAttestedPaymentIdSet(
attestations: Event[], attestations: Event[],
recipientPubkey: string recipientPubkey: string
): Set<string> { ): Set<string> {
const recipient = recipientPubkey.trim().toLowerCase()
const out = new Set<string>() const out = new Set<string>()
for (const attestation of attestations) { for (const attestation of attestations) {
if (attestation.pubkey.toLowerCase() !== recipient) continue if (!hexPubkeysEqual(attestation.pubkey, recipientPubkey)) continue
const targetId = getPaymentAttestationTargetId(attestation) const targetId = getPaymentAttestationTargetId(attestation)
const targetKind = getPaymentAttestationTargetKind(attestation) if (!targetId) continue
if (!targetId || !targetKind) continue
out.add(targetId) out.add(targetId)
} }
return out return out
@ -169,7 +167,7 @@ export function isIncomingPaymentNotificationOrZapReceipt(
return recipient != null && hexPubkeysEqual(recipient, userPubkey) return recipient != null && hexPubkeysEqual(recipient, userPubkey)
} }
export function isAttestedSuperchat(event: Event, attestedIds: Set<string>): boolean { export function isAttestedSuperchat(event: Event, attestedIds: ReadonlySet<string>): boolean {
if (!isSuperchatKind(event.kind)) return false if (!isSuperchatKind(event.kind)) return false
return attestedIds.has(event.id.toLowerCase()) return attestedIds.has(event.id.toLowerCase())
} }
@ -277,9 +275,10 @@ export function filterAttestedProfileWallSuperchats(
paymentEvents: Event[], paymentEvents: Event[],
attestations: Event[], attestations: Event[],
profilePubkey: string, profilePubkey: string,
profileEventId?: string profileEventId?: string,
attestedIdsOverride?: ReadonlySet<string>
): Event[] { ): Event[] {
const attestedIds = buildAttestedPaymentIdSet(attestations, profilePubkey) const attestedIds = attestedIdsOverride ?? buildAttestedPaymentIdSet(attestations, profilePubkey)
return sortSuperchatsByAmountDesc( return sortSuperchatsByAmountDesc(
paymentEvents.filter((e) => { paymentEvents.filter((e) => {
if (e.kind === ExtendedKind.PAYMENT_NOTIFICATION) { if (e.kind === ExtendedKind.PAYMENT_NOTIFICATION) {

Loading…
Cancel
Save