Browse Source

add kind labels to all cards

imwald
Silberengel 1 month ago
parent
commit
6cabd97cdb
  1. 77
      src/components/ContentPreview/index.tsx
  2. 5
      src/components/KindFilter/index.tsx
  3. 29
      src/components/Note/NoteKindLabel.tsx
  4. 141
      src/components/Note/Poll.tsx
  5. 20
      src/components/Note/UnknownNote.tsx
  6. 113
      src/components/Note/Zap.tsx
  7. 38
      src/components/Note/ZapPoll.tsx
  8. 13
      src/components/Note/index.tsx
  9. 25
      src/components/NoteCard/MainNoteCard.tsx
  10. 22
      src/components/ReplyNote/index.tsx
  11. 56
      src/components/ReplyNoteList/ZapReplyFeedRow.tsx
  12. 58
      src/components/ReplyNoteList/index.tsx
  13. 5
      src/hooks/useProfileZapPollParticipation.tsx
  14. 103
      src/hooks/useZapPollTally.tsx
  15. 4
      src/i18n/locales/en.ts
  16. 32
      src/lib/event.ts
  17. 46
      src/lib/kind-description.ts
  18. 20
      src/lib/zap-poll-tally-cache.ts
  19. 115
      src/lib/zap-poll.ts
  20. 108
      src/services/client-events.service.ts

77
src/components/ContentPreview/index.tsx

@ -30,6 +30,7 @@ import ApplicationHandlerInfo from '../ApplicationHandlerInfo' @@ -30,6 +30,7 @@ import ApplicationHandlerInfo from '../ApplicationHandlerInfo'
import ApplicationHandlerRecommendation from '../ApplicationHandlerRecommendation'
import FollowPackPreview from './FollowPackPreview'
import ReactionEmojiDisplay from '../Note/ReactionEmojiDisplay'
import NoteKindLabel from '../Note/NoteKindLabel'
/** Inert event so hooks can run before `event` is defined. */
const CONTENT_PREVIEW_HOOK_PLACEHOLDER = {
@ -42,6 +43,22 @@ const CONTENT_PREVIEW_HOOK_PLACEHOLDER = { @@ -42,6 +43,22 @@ const CONTENT_PREVIEW_HOOK_PLACEHOLDER = {
sig: ''
} as Event
/** Keep spacing/margins on the outer wrapper; put line-clamp on the preview body so it still clamps text. */
function splitPreviewLayoutClasses(className?: string) {
if (!className?.trim()) return { outer: undefined, body: undefined }
const tokens = className.trim().split(/\s+/)
const body: string[] = []
const outer: string[] = []
for (const tok of tokens) {
if (tok.startsWith('line-clamp')) body.push(tok)
else outer.push(tok)
}
return {
outer: outer.length ? outer.join(' ') : undefined,
body: body.length ? body.join(' ') : undefined
}
}
export default function ContentPreview({
event,
className
@ -85,6 +102,15 @@ export default function ContentPreview({ @@ -85,6 +102,15 @@ export default function ContentPreview({
)
}
const { outer: previewOuter, body: previewBody } = splitPreviewLayoutClasses(className)
const withKindRow = (node: React.ReactNode) => (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}>
<NoteKindLabel kind={event.kind} size="small" />
<div className={cn('min-w-0', previewBody)}>{node}</div>
</div>
)
if (
[
kinds.ShortTextNote,
@ -95,64 +121,71 @@ export default function ContentPreview({ @@ -95,64 +121,71 @@ export default function ContentPreview({
ExtendedKind.PUBLIC_MESSAGE
].includes(event.kind)
) {
return <NormalContentPreview event={event} className={className} />
return withKindRow(<NormalContentPreview event={event} />)
}
if (event.kind === ExtendedKind.DISCUSSION) {
return <DiscussionNote event={event} className={className} size="small" />
return (
<div className={cn('flex min-w-0 flex-col gap-1', previewOuter)}>
<NoteKindLabel kind={event.kind} size="small" />
<div className={cn('min-w-0', previewBody)}>
<DiscussionNote event={event} size="small" />
</div>
</div>
)
}
if (event.kind === kinds.Highlights) {
return <HighlightPreview event={event} className={className} />
return withKindRow(<HighlightPreview event={event} />)
}
if (event.kind === ExtendedKind.POLL) {
return <PollPreview event={event} className={className} />
return withKindRow(<PollPreview event={event} />)
}
if (event.kind === kinds.LongFormArticle) {
return <LongFormArticlePreview event={event} className={className} />
return withKindRow(<LongFormArticlePreview event={event} />)
}
if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) {
return <VideoNotePreview event={event} className={className} />
return withKindRow(<VideoNotePreview event={event} />)
}
if (event.kind === ExtendedKind.PICTURE) {
return <PictureNotePreview event={event} className={className} />
return withKindRow(<PictureNotePreview event={event} />)
}
if (event.kind === ExtendedKind.GROUP_METADATA) {
return <GroupMetadataPreview event={event} className={className} />
return withKindRow(<GroupMetadataPreview event={event} />)
}
if (event.kind === kinds.CommunityDefinition) {
return <CommunityDefinitionPreview event={event} className={className} />
return withKindRow(<CommunityDefinitionPreview event={event} />)
}
if (event.kind === kinds.LiveEvent) {
return <LiveEventPreview event={event} className={className} />
return withKindRow(<LiveEventPreview event={event} />)
}
if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) {
return <ZapPreview event={event} className={className} />
return withKindRow(<ZapPreview event={event} />)
}
if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) {
return <ApplicationHandlerInfo event={event} className={className} />
return withKindRow(<ApplicationHandlerInfo event={event} />)
}
if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) {
return <ApplicationHandlerRecommendation event={event} className={className} />
return withKindRow(<ApplicationHandlerRecommendation event={event} />)
}
if (event.kind === ExtendedKind.FOLLOW_PACK) {
return <FollowPackPreview event={event} className={className} />
return withKindRow(<FollowPackPreview event={event} />)
}
if (isNip25ReactionKind(event.kind)) {
return (
<div className={cn('pointer-events-none flex items-center gap-1.5 text-sm text-muted-foreground', className)}>
return withKindRow(
<div className="pointer-events-none flex items-center gap-1.5 text-sm text-muted-foreground">
{reactionDisplay.status === 'pending' ? (
<Skeleton className="size-4 shrink-0 rounded-sm" aria-hidden />
) : reactionDisplay.status === 'vote_up' ? (
@ -172,20 +205,18 @@ export default function ContentPreview({ @@ -172,20 +205,18 @@ export default function ContentPreview({
}
if (event.kind === kinds.Repost) {
return (
<div className={cn('pointer-events-none text-sm text-muted-foreground', className)}>
{t('Notification boost summary')}
</div>
return withKindRow(
<div className="pointer-events-none text-sm text-muted-foreground">{t('Notification boost summary')}</div>
)
}
if (event.kind === ExtendedKind.POLL_RESPONSE) {
return (
<div className={cn('pointer-events-none text-sm text-muted-foreground', className)}>
return withKindRow(
<div className="pointer-events-none text-sm text-muted-foreground">
{t('Notification poll vote summary')}
</div>
)
}
return <div className={className}>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>
return withKindRow(<div>[{t('Cannot handle event of kind k', { k: event.kind })}]</div>)
}

5
src/components/KindFilter/index.tsx

@ -19,7 +19,8 @@ const KIND_FILTER_OPTIONS = [ @@ -19,7 +19,8 @@ const KIND_FILTER_OPTIONS = [
{ kindGroup: [kinds.LongFormArticle], label: 'Articles' },
{ kindGroup: [ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Wiki Articles' },
{ kindGroup: [kinds.Highlights], label: 'Highlights' },
{ kindGroup: [ExtendedKind.POLL, ExtendedKind.ZAP_POLL], label: 'Polls' },
{ kindGroup: [ExtendedKind.POLL], label: 'Polls' },
{ kindGroup: [ExtendedKind.ZAP_POLL], label: 'Zap polls' },
{ kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },
{ kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' },
{ kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' },
@ -186,7 +187,7 @@ export default function KindFilter({ @@ -186,7 +187,7 @@ export default function KindFilter({
const checked = kindGroup.every((k) => temporaryShowKinds.includes(k))
return (
<div
key={label}
key={kindGroup.join('-')}
className={cn(
'cursor-pointer grid gap-1.5 rounded-lg border px-4 py-3',
checked ? 'border-primary/60 bg-primary/5' : 'clickable'

29
src/components/Note/NoteKindLabel.tsx

@ -0,0 +1,29 @@ @@ -0,0 +1,29 @@
import { cn } from '@/lib/utils'
import { getKindDescription } from '@/lib/kind-description'
import { useTranslation } from 'react-i18next'
export default function NoteKindLabel({
kind,
className,
size = 'normal'
}: {
kind: number
className?: string
size?: 'normal' | 'small'
}) {
const { t } = useTranslation()
const { description } = getKindDescription(kind)
return (
<p
className={cn(
'text-muted-foreground/80 select-none',
size === 'small' ? 'text-[10px] leading-snug' : 'text-[11px] sm:text-xs leading-snug',
className
)}
data-note-kind-label
>
{t('Note kind label line', { kind, description })}
</p>
)
}

141
src/components/Note/Poll.tsx

@ -10,7 +10,7 @@ import dayjs from 'dayjs' @@ -10,7 +10,7 @@ import dayjs from 'dayjs'
import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle2 } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import logger from '@/lib/logger'
@ -35,15 +35,37 @@ export default function Poll({ event, className }: { event: Event; className?: s @@ -35,15 +35,37 @@ export default function Poll({ event, className }: { event: Event; className?: s
.filter(([, voters]) => voters.has(pubkey))
.map(([optionId]) => optionId)
}, [pollResults, pubkey])
const validPollOptionIds = useMemo(() => poll?.options.map((option) => option.id) || [], [poll])
const isExpired = useMemo(() => poll?.endsAt && dayjs().unix() > poll.endsAt, [poll])
const isMultipleChoice = useMemo(() => poll?.pollType === POLL_TYPE.MULTIPLE_CHOICE, [poll])
const canVote = useMemo(() => !isExpired && !votedOptionIds.length, [isExpired, votedOptionIds])
const showResults = useMemo(() => {
return resultsRevealed || event.pubkey === pubkey || !canVote
}, [resultsRevealed, event.pubkey, pubkey, canVote])
return Boolean(isExpired) || resultsRevealed || event.pubkey === pubkey || !canVote
}, [isExpired, resultsRevealed, event.pubkey, pubkey, canVote])
const [containerElement, setContainerElement] = useState<HTMLDivElement | null>(null)
const fetchResults = useCallback(async () => {
const meta = getPollMetadataFromEvent(event)
if (!meta) return undefined
setIsLoadingResults(true)
try {
const relays = await ensurePollRelays(event.pubkey, meta)
const optionIds = meta.options.map((o) => o.id)
const multi = meta.pollType === POLL_TYPE.MULTIPLE_CHOICE
return await pollResultsService.fetchResults(
event.id,
relays,
optionIds,
multi,
meta.endsAt
)
} catch (error) {
logger.error('Failed to fetch poll results', { error, eventId: event.id })
toast.error('Failed to fetch poll results: ' + (error as Error).message)
} finally {
setIsLoadingResults(false)
}
}, [event])
useEffect(() => {
if (pollResults || isLoadingResults || !containerElement) return
@ -52,7 +74,7 @@ export default function Poll({ event, className }: { event: Event; className?: s @@ -52,7 +74,7 @@ export default function Poll({ event, className }: { event: Event; className?: s
if (entry.isIntersecting) {
setTimeout(() => {
if (isPartiallyInViewport(containerElement)) {
fetchResults()
void fetchResults()
}
}, 200)
}
@ -65,31 +87,18 @@ export default function Poll({ event, className }: { event: Event; className?: s @@ -65,31 +87,18 @@ export default function Poll({ event, className }: { event: Event; className?: s
return () => {
observer.unobserve(containerElement)
}
}, [pollResults, isLoadingResults, containerElement])
}, [pollResults, isLoadingResults, containerElement, fetchResults])
useEffect(() => {
if (!poll || !isExpired) return
setResultsRevealed(true)
void fetchResults()
}, [poll, isExpired, fetchResults])
if (!poll) {
return null
}
const fetchResults = async () => {
setIsLoadingResults(true)
try {
const relays = await ensurePollRelays(event.pubkey, poll)
return await pollResultsService.fetchResults(
event.id,
relays,
validPollOptionIds,
isMultipleChoice,
poll.endsAt
)
} catch (error) {
logger.error('Failed to fetch poll results', { error, eventId: event.id })
toast.error('Failed to fetch poll results: ' + (error as Error).message)
} finally {
setIsLoadingResults(false)
}
}
const handleOptionClick = (optionId: string) => {
if (isExpired) return
@ -154,10 +163,9 @@ export default function Poll({ event, className }: { event: Event; className?: s @@ -154,10 +163,9 @@ export default function Poll({ event, className }: { event: Event; className?: s
<div className={className} ref={setContainerElement}>
<div className="space-y-2">
<div className="text-sm text-muted-foreground">
<p>
{poll.pollType === POLL_TYPE.MULTIPLE_CHOICE &&
t('Multiple choice (select one or more)')}
</p>
{!isExpired && poll.pollType === POLL_TYPE.MULTIPLE_CHOICE && (
<p>{t('Multiple choice (select one or more)')}</p>
)}
<p>
{!!poll.endsAt &&
(isExpired
@ -168,36 +176,30 @@ export default function Poll({ event, className }: { event: Event; className?: s @@ -168,36 +176,30 @@ export default function Poll({ event, className }: { event: Event; className?: s
</p>
</div>
{/* Poll Options */}
{/* Results rows (read-only when ended or already voted) */}
<div className="grid gap-2">
{poll.options.map((option) => {
const votes = pollResults?.results?.[option.id]?.size ?? 0
const totalVotes = pollResults?.totalVotes ?? 0
const percentage = showResults && totalVotes > 0 ? (votes / totalVotes) * 100 : 0
const percentage =
showResults && totalVotes > 0 ? (votes / totalVotes) * 100 : showResults ? 0 : 0
const isMax =
pollResults && pollResults.totalVotes > 0 && showResults
? Object.values(pollResults.results).every((res) => res.size <= votes)
: false
return (
<button
key={option.id}
title={option.label}
className={cn(
'relative w-full px-4 py-3 rounded-lg border transition-all flex items-center gap-2 overflow-hidden',
canVote ? 'cursor-pointer' : 'cursor-not-allowed',
canVote &&
(selectedOptionIds.includes(option.id)
? 'border-primary bg-primary/20'
: 'hover:border-primary/40 hover:bg-primary/5')
)}
onClick={(e) => {
e.stopPropagation()
handleOptionClick(option.id)
}}
disabled={!canVote}
>
{/* Content */}
const rowClass = cn(
'relative w-full px-4 py-3 rounded-lg border flex items-center gap-2 overflow-hidden',
canVote && 'transition-all',
canVote ? 'cursor-pointer' : 'cursor-default',
canVote &&
(selectedOptionIds.includes(option.id)
? 'border-primary bg-primary/20'
: 'hover:border-primary/40 hover:bg-primary/5')
)
const inner = (
<>
<div className="flex items-center gap-2 flex-1 w-0 z-10">
<div className={cn('line-clamp-2 text-left', isMax ? 'font-semibold' : '')}>
{option.label}
@ -209,23 +211,42 @@ export default function Poll({ event, className }: { event: Event; className?: s @@ -209,23 +211,42 @@ export default function Poll({ event, className }: { event: Event; className?: s
{showResults && (
<div
className={cn(
'text-muted-foreground shrink-0 z-10',
'text-muted-foreground shrink-0 z-10 tabular-nums',
isMax ? 'font-semibold text-foreground' : ''
)}
>
{percentage.toFixed(1)}%
{totalVotes > 0 ? `${percentage.toFixed(1)}%` : '0%'}
</div>
)}
{showResults && (
<div
className={cn(
'absolute inset-0 rounded-r-sm transition-all duration-700 ease-out',
isMax ? 'bg-primary/60' : 'bg-muted/90'
)}
style={{ width: `${percentage}%` }}
/>
)}
</>
)
{/* Progress Bar Background */}
<div
className={cn(
'absolute inset-0 rounded-r-sm transition-all duration-700 ease-out',
isMax ? 'bg-primary/60' : 'bg-muted/90'
)}
style={{ width: `${percentage}%` }}
/>
return canVote ? (
<button
key={option.id}
type="button"
title={option.label}
className={rowClass}
onClick={(e) => {
e.stopPropagation()
handleOptionClick(option.id)
}}
>
{inner}
</button>
) : (
<div key={option.id} className={cn(rowClass, 'border-border bg-card/30')} title={option.label}>
{inner}
</div>
)
})}
</div>

20
src/components/Note/UnknownNote.tsx

@ -6,6 +6,7 @@ import { extractBookMetadata } from '@/lib/bookstr-parser' @@ -6,6 +6,7 @@ import { extractBookMetadata } from '@/lib/bookstr-parser'
import { ExtendedKind } from '@/constants'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { getKindDescription } from '@/lib/kind-description'
import NoteKindLabel from './NoteKindLabel'
import { useMemo, useState } from 'react'
import EventViewer from './EventViewer'
import { Button } from '@/components/ui/button'
@ -130,12 +131,15 @@ function extractElevatedTags(tags: string[][]): ElevatedTags { @@ -130,12 +131,15 @@ function extractElevatedTags(tags: string[][]): ElevatedTags {
export default function UnknownNote({
event,
className,
showAuthorSummary
showAuthorSummary,
omitKindLabel
}: {
event: Event
className?: string
/** When the parent does not render an author header (e.g. embedded unsupported notes). */
showAuthorSummary?: boolean
/** When the parent `Note` already shows a kind line above this body. */
omitKindLabel?: boolean
}) {
const { t } = useTranslation()
const [technicalOpen, setTechnicalOpen] = useState(false)
@ -167,7 +171,6 @@ export default function UnknownNote({ @@ -167,7 +171,6 @@ export default function UnknownNote({
)
const headline = elevated.title?.trim() || kindLabel.description
const showKindAsSubtitle = !!elevated.title?.trim()
const contentNorm = contentRaw ? normText(contentRaw) : ''
const elevatedBlocksNorm = [elevated.summary, elevated.description, elevated.tagContent]
@ -223,13 +226,14 @@ export default function UnknownNote({ @@ -223,13 +226,14 @@ export default function UnknownNote({
<div>
<h3 className="text-base font-semibold leading-tight text-foreground">{headline}</h3>
<p className="mt-0.5 text-xs text-muted-foreground">
{showKindAsSubtitle ? (
{!omitKindLabel ? <NoteKindLabel kind={event.kind} size="small" className="mt-1" /> : null}
{elevated.title?.trim() && !omitKindLabel ? (
<p className="mt-0.5 text-xs text-muted-foreground">
<span className="text-foreground/80">{kindLabel.description}</span>
) : null}
{showKindAsSubtitle ? <span className="mx-1.5 text-border">·</span> : null}
<span className="font-mono tabular-nums">{t('Event kind label', { kind: event.kind })}</span>
</p>
<span className="mx-1.5 text-border">·</span>
<span className="font-mono tabular-nums">{t('Event kind label', { kind: event.kind })}</span>
</p>
) : null}
{showDeclaredKindTag ? (
<p className="mt-1 text-xs text-muted-foreground">{t('Unknown note declared kind tag', { value: declaredKindTrimmed })}</p>
) : null}

113
src/components/Note/Zap.tsx

@ -6,13 +6,25 @@ import { toNote, toProfile } from '@/lib/link' @@ -6,13 +6,25 @@ import { toNote, toProfile } from '@/lib/link'
import { cn } from '@/lib/utils'
import { Zap as ZapIcon } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useMemo } from 'react'
import { useMemo, type MouseEvent } from 'react'
import { useTranslation } from 'react-i18next'
import { useSmartNoteNavigationOptional, useSecondaryPageOptional } from '@/PageManager'
import Username from '../Username'
import UserAvatar from '../UserAvatar'
export default function Zap({ event, className }: { event: Event; className?: string }) {
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'
}: {
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])
@ -34,7 +46,8 @@ export default function Zap({ event, className }: { event: Event; className?: st @@ -34,7 +46,8 @@ export default function Zap({ event, className }: { event: Event; className?: st
return (
<div
className={cn(
'text-sm text-muted-foreground rounded-lg border border-border bg-muted/20 p-4',
'text-sm text-muted-foreground rounded-lg border border-border bg-muted/20',
variant === 'compact' ? 'px-3 py-2' : 'p-4',
className
)}
>
@ -61,6 +74,60 @@ export default function Zap({ event, className }: { event: Event; className?: st @@ -61,6 +74,60 @@ export default function Zap({ event, className }: { event: Event; className?: st
const { senderPubkey, recipientPubkey, amount, comment } = zapInfo
const openZapTarget = (e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation()
if (isEventZap) {
if (targetEvent) {
navigateToNote(toNote(targetEvent.id), targetEvent)
} else if (zapInfo.eventId) {
navigateToNote(toNote(zapInfo.eventId))
}
} else if (isProfileZap && actualRecipientPubkey) {
push(toProfile(actualRecipientPubkey))
}
}
if (variant === 'compact') {
return (
<div
className={cn(
'rounded-md border-l-2 border-primary/50 bg-primary/[0.06] pl-3 pr-2 py-2 text-sm text-foreground dark:bg-primary/[0.08]',
className
)}
>
<div className="flex flex-wrap items-baseline gap-x-2 gap-y-1">
<ZapIcon className="size-4 shrink-0 text-primary" strokeWidth={2} aria-hidden />
<span className="font-semibold tabular-nums text-foreground">{formatAmount(amount)}</span>
<span className="text-muted-foreground">{t('sats')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<span className="text-muted-foreground text-xs">
<span className="text-foreground/80">{t('zapped')}</span>{' '}
<Username userId={recipientPubkey} className="inline font-medium text-foreground" />
</span>
)}
{(isEventZap || isProfileZap) && (
<button
type="button"
onClick={openZapTarget}
className="text-xs font-medium text-primary hover:underline"
>
{isEventZap
? t('Zapped note')
: isProfileZap && actualRecipientPubkey
? t('Zapped profile')
: t('Zap')}
</button>
)}
</div>
{comment ? (
<p className="mt-2 text-sm leading-snug text-foreground/90 whitespace-pre-wrap break-words">
{comment}
</p>
) : null}
</div>
)
}
return (
<div
className={cn(
@ -68,23 +135,9 @@ export default function Zap({ event, className }: { event: Event; className?: st @@ -68,23 +135,9 @@ export default function Zap({ event, className }: { event: Event; className?: st
className
)}
>
{/* Zapped note/profile link in bottom-right corner */}
<button
type="button"
onClick={(e) => {
e.stopPropagation()
if (isEventZap) {
// Event zap - navigate to the zapped event
if (targetEvent) {
navigateToNote(toNote(targetEvent.id), targetEvent)
} else if (zapInfo.eventId) {
navigateToNote(toNote(zapInfo.eventId))
}
} else if (isProfileZap && actualRecipientPubkey) {
// Profile zap - navigate to the zapped profile
push(toProfile(actualRecipientPubkey))
}
}}
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 ? (
@ -104,17 +157,19 @@ export default function Zap({ event, className }: { event: Event; className?: st @@ -104,17 +157,19 @@ export default function Zap({ event, className }: { event: Event; className?: st
<div className="flex items-start gap-3 pb-10 pr-2 sm:pr-36">
<ZapIcon size={28} className="mt-0.5 shrink-0 text-primary" strokeWidth={2} />
<div className="min-w-0 flex-1">
<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-sm text-muted-foreground">{t('zapped')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<>
<UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold text-foreground" />
</>
)}
</div>
{!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-sm text-muted-foreground">{t('zapped')}</span>
{recipientPubkey && recipientPubkey !== senderPubkey && (
<>
<UserAvatar userId={recipientPubkey} size="small" />
<Username userId={recipientPubkey} className="font-semibold text-foreground" />
</>
)}
</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">

38
src/components/Note/ZapPoll.tsx

@ -39,6 +39,16 @@ export default function ZapPoll({ @@ -39,6 +39,16 @@ export default function ZapPoll({
const nostr = useNostrOptional()
const pubkey = nostr?.pubkey ?? null
const meta = useZapPollMeta(event)
/** Same pubkey can appear on multiple `p` tags; Select keys/values must be unique. */
const payToRecipients = useMemo(() => {
if (!meta) return []
const seen = new Set<string>()
return meta.recipients.filter((r) => {
if (seen.has(r.pubkey)) return false
seen.add(r.pubkey)
return true
})
}, [meta])
const { receipts, tally, loading, error, reload } = useZapPollTally(event, meta)
const [recipientPk, setRecipientPk] = useState<string>('')
@ -56,16 +66,17 @@ export default function ZapPoll({ @@ -56,16 +66,17 @@ export default function ZapPoll({
}
}, [meta?.valueMinimum, event.id])
const defaultRecipient = meta?.recipients[0]?.pubkey ?? ''
const defaultRecipient = payToRecipients[0]?.pubkey ?? ''
const effectiveRecipient = recipientPk || defaultRecipient
const closed = meta ? isZapPollPastDeadline(event, meta) : false
const viewerZapped = pubkey && meta ? userHasZappedPoll(event.id, pubkey, receipts) : false
const myVoteOption =
pubkey && meta ? userZapPollVoteOption(event.id, pubkey, receipts) : undefined
pubkey && meta ? userZapPollVoteOption(event, meta, pubkey, receipts) : undefined
const showTally =
!!meta && (closed || viewerZapped || event.pubkey === pubkey || tallyRevealed)
!!meta &&
(closed || viewerZapped || event.pubkey === pubkey || tallyRevealed)
const satsBounds = useMemo(() => {
if (!meta) return { min: 1, max: undefined as number | undefined }
@ -138,7 +149,7 @@ export default function ZapPoll({ @@ -138,7 +149,7 @@ export default function ZapPoll({
: t('Closes {{time}}', { time: dayjs.unix(meta.closedAt).format('lll') })}
</p>
)}
{(meta.valueMinimum != null || meta.valueMaximum != null) && (
{!closed && (meta.valueMinimum != null || meta.valueMaximum != null) && (
<p className="text-xs text-muted-foreground">
{t('Vote size')}:{' '}
{meta.valueMinimum != null && meta.valueMaximum != null
@ -150,10 +161,13 @@ export default function ZapPoll({ @@ -150,10 +161,13 @@ export default function ZapPoll({
: t('≤ {{n}} sats', { n: meta.valueMaximum! })}
</p>
)}
{loading && !tally && (
{loading ? (
<p className="text-xs text-muted-foreground">{t('Loading tally…')}</p>
)}
) : null}
{error && <p className="text-xs text-destructive">{error}</p>}
{!loading && showTally && tally && tally.totalSats === 0 && (
<p className="text-xs text-muted-foreground">{t('Zap poll no votes yet')}</p>
)}
{meta && !closed && !showTally && (
<Button
type="button"
@ -184,7 +198,7 @@ export default function ZapPoll({ @@ -184,7 +198,7 @@ export default function ZapPoll({
isMine && 'ring-2 ring-primary/50'
)}
>
{showTally && tally && tally.totalSats > 0 && (
{showTally && tally && (
<div
className="absolute inset-y-0 left-0 bg-primary/15"
style={{ width: `${pct}%` }}
@ -193,10 +207,8 @@ export default function ZapPoll({ @@ -193,10 +207,8 @@ export default function ZapPoll({
<div className="relative flex items-center justify-between gap-2 px-3 py-2">
<span className="text-sm break-words">{opt.label}</span>
{showTally && tally && (
<span className="text-xs text-muted-foreground shrink-0">
{satsOpt > 0 ? `${Math.round(satsOpt)} sats` : '—'}
{counts > 0 ? ` · ${t('{{n}} zaps', { n: counts })}` : ''}
{tally.totalSats > 0 ? ` (${pct.toFixed(0)}%)` : ''}
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{`${Math.round(satsOpt)} sats · ${t('{{n}} zaps', { n: counts })} (${pct.toFixed(0)}%)`}
</span>
)}
</div>
@ -204,7 +216,7 @@ export default function ZapPoll({ @@ -204,7 +216,7 @@ export default function ZapPoll({
)
})}
</div>
{meta.consensusThreshold != null && showTally && tally && tally.totalSats > 0 && (
{meta.consensusThreshold != null && showTally && tally && (
<p className="text-xs text-muted-foreground">
{t('Consensus threshold')}: {meta.consensusThreshold}%
</p>
@ -221,7 +233,7 @@ export default function ZapPoll({ @@ -221,7 +233,7 @@ export default function ZapPoll({
<SelectValue placeholder={t('Recipient')} />
</SelectTrigger>
<SelectContent>
{meta.recipients.map((r) => (
{payToRecipients.map((r) => (
<SelectItem key={r.pubkey} value={r.pubkey}>
{r.pubkey.slice(0, 12)}
</SelectItem>

13
src/components/Note/index.tsx

@ -54,6 +54,7 @@ import ZapPoll from './ZapPoll' @@ -54,6 +54,7 @@ import ZapPoll from './ZapPoll'
import NotificationEventCard from './NotificationEventCard'
import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote'
import NoteKindLabel from './NoteKindLabel'
import { Skeleton } from '@/components/ui/skeleton'
import VideoNote from './VideoNote'
import RelayReview from './RelayReview'
@ -149,7 +150,7 @@ export default function Note({ @@ -149,7 +150,7 @@ export default function Note({
if (!isRenderableNoteKind(event.kind)) {
logger.debug('Note component - rendering UnknownNote for unsupported kind:', event.kind)
content = <UnknownNote className="mt-2" event={event} />
content = <UnknownNote className="mt-2" event={event} omitKindLabel />
} else if (mutePubkeySet.has(event.pubkey) && !showMuted) {
content = <MutedNote show={() => setShowMuted(true)} />
} else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) {
@ -422,10 +423,15 @@ export default function Note({ @@ -422,10 +423,15 @@ export default function Note({
<MessageSquare className="w-4 h-4 text-blue-500" />
</button>
)}
{size === 'normal' && (
{(size === 'normal' ||
event.kind === ExtendedKind.ZAP_REQUEST ||
event.kind === ExtendedKind.ZAP_RECEIPT) && (
<NoteOptions
event={event}
className="py-1 shrink-0 [&_svg]:size-5"
className={cn(
'py-1 shrink-0',
size === 'small' ? '[&_svg]:size-4' : '[&_svg]:size-5'
)}
initialHighlightData={highlightData}
highlightDefaultContent={highlightDefaultContent}
isPostEditorOpen={postEditorOpen}
@ -444,6 +450,7 @@ export default function Note({ @@ -444,6 +450,7 @@ export default function Note({
)}
</div>
</div>
<NoteKindLabel kind={event.kind} size={size} className="mt-1" />
{webReactionParentUrl ? (
<div className="mt-2 not-prose max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" />

25
src/components/NoteCard/MainNoteCard.tsx

@ -1,3 +1,4 @@ @@ -1,3 +1,4 @@
import { ExtendedKind } from '@/constants'
import { Separator } from '@/components/ui/separator'
import { toNote } from '@/lib/link'
import { useSmartNoteNavigationOptional } from '@/PageManager'
@ -33,6 +34,9 @@ export default function MainNoteCard({ @@ -33,6 +34,9 @@ export default function MainNoteCard({
}) {
const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional()
const isZapFeedCard =
event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST
const showNoteStatsRow = !embedded || isZapFeedCard
return (
<div
@ -44,7 +48,15 @@ export default function MainNoteCard({ @@ -44,7 +48,15 @@ export default function MainNoteCard({
if (sel && !sel.isCollapsed) return
// Don't navigate if clicking on interactive elements
const target = e.target as HTMLElement
if (target.closest('button') || target.closest('[role="button"]') || target.closest('a') || target.closest('[data-parent-note-preview]') || target.closest('[data-user-avatar]') || target.closest('[data-username]')) {
if (
target.closest('button') ||
target.closest('[role="button"]') ||
target.closest('a') ||
target.closest('[data-parent-note-preview]') ||
target.closest('[data-user-avatar]') ||
target.closest('[data-username]') ||
target.closest('[data-note-stats]')
) {
return
}
// For embedded notes, allow clicks (don't exclude [data-embedded-note])
@ -80,9 +92,14 @@ export default function MainNoteCard({ @@ -80,9 +92,14 @@ export default function MainNoteCard({
zapPollVoteHighlightOption={zapPollVoteHighlightOption}
/>
</Collapsible>
{!embedded && (
<NoteStats className="mt-3 px-4" event={event} fetchIfNotExisting={true} />
)}
{showNoteStatsRow ? (
<NoteStats
className={embedded ? 'mt-2 px-2 sm:px-3' : 'mt-3 px-4'}
event={event}
fetchIfNotExisting={true}
displayTopZapsAndLikes={isZapFeedCard}
/>
) : null}
</div>
{!embedded && <Separator />}
</div>

22
src/components/ReplyNote/index.tsx

@ -10,13 +10,14 @@ import { @@ -10,13 +10,14 @@ import {
DISCUSSION_DOWNVOTE_DISPLAY,
DISCUSSION_UPVOTE_DISPLAY
} from '@/lib/discussion-votes'
import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { isMentioningMutedUsers, isNip25ReactionKind } from '@/lib/event'
import { getWebExternalReactionTargetUrl } from '@/lib/rss-article'
import { toNote } from '@/lib/link'
import { useContentPolicy } from '@/providers/ContentPolicyProvider'
import { useMuteList } from '@/contexts/mute-list-context'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import { Event } from 'nostr-tools'
import { Event, kinds } from 'nostr-tools'
import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import ClientTag from '../ClientTag'
@ -31,6 +32,8 @@ import ParentNotePreview from '../ParentNotePreview' @@ -31,6 +32,8 @@ import ParentNotePreview from '../ParentNotePreview'
import WebPreview from '../WebPreview'
import UserAvatar from '../UserAvatar'
import Username from '../Username'
import NoteKindLabel from '../Note/NoteKindLabel'
import Zap from '../Note/Zap'
export default function ReplyNote({
event,
@ -59,6 +62,12 @@ export default function ReplyNote({ @@ -59,6 +62,12 @@ export default function ReplyNote({
event.kind === ExtendedKind.EXTERNAL_REACTION ? getWebExternalReactionTargetUrl(event) : undefined,
[event]
)
const headerUserId = useMemo(() => {
if (event.kind !== kinds.Zap) return event.pubkey
const info = getZapInfoFromEvent(event)
return info?.senderPubkey ?? event.pubkey
}, [event])
const show = useMemo(() => {
if (showMuted) {
return true
@ -91,20 +100,20 @@ export default function ReplyNote({ @@ -91,20 +100,20 @@ export default function ReplyNote({
>
<Collapsible>
<div className="flex space-x-2 items-start px-4 pt-3">
<UserAvatar userId={event.pubkey} size="medium" className="shrink-0 mt-0.5" />
<UserAvatar userId={headerUserId} size="medium" className="shrink-0 mt-0.5" />
<div className="w-full overflow-hidden">
<div className="flex items-start justify-between gap-2">
<div className="flex-1 w-0">
<div className="flex gap-1 items-center">
<Username
userId={event.pubkey}
userId={headerUserId}
className="text-sm font-semibold text-muted-foreground hover:text-foreground truncate"
skeletonClassName="h-3"
/>
<ClientTag event={event} />
</div>
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Nip05 pubkey={event.pubkey} append="·" />
<Nip05 pubkey={headerUserId} append="·" />
<FormattedTimestamp
timestamp={event.created_at}
className="shrink-0"
@ -116,6 +125,7 @@ export default function ReplyNote({ @@ -116,6 +125,7 @@ export default function ReplyNote({
<NoteOptions event={event} className="shrink-0 [&_svg]:size-5" />
</div>
</div>
<NoteKindLabel kind={event.kind} size="small" className="mt-0.5" />
{webReactionParentUrl ? (
<div className="mt-2 not-prose max-w-full" data-parent-note-preview>
<WebPreview url={webReactionParentUrl} className="w-full" />
@ -148,6 +158,8 @@ export default function ReplyNote({ @@ -148,6 +158,8 @@ export default function ReplyNote({
)}
<span>{t(notificationReactionSummaryKey(reactionDisplay))}</span>
</div>
) : event.kind === kinds.Zap ? (
<Zap className="mt-2" event={event} omitSenderHeading variant="compact" />
) : (
<MarkdownArticle
className="mt-2"
@ -175,7 +187,7 @@ export default function ReplyNote({ @@ -175,7 +187,7 @@ export default function ReplyNote({
<NoteStats
className="ml-14 pl-1 mr-4 mt-2"
event={event}
displayTopZapsAndLikes
displayTopZapsAndLikes={event.kind !== kinds.Zap}
fetchIfNotExisting
/>
)}

56
src/components/ReplyNoteList/ZapReplyFeedRow.tsx

@ -1,56 +0,0 @@ @@ -1,56 +0,0 @@
import Content from '@/components/Content'
import { FormattedTimestamp } from '@/components/FormattedTimestamp'
import Nip05 from '@/components/Nip05'
import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username'
import { formatAmount } from '@/lib/lightning'
import { toProfile } from '@/lib/link'
import { useSecondaryPage } from '@/PageManager'
import { useScreenSize } from '@/providers/ScreenSizeProvider'
import type { TNoteStats } from '@/services/note-stats.service'
import { Zap } from 'lucide-react'
import { useTranslation } from 'react-i18next'
export type TZapFeedEntry = TNoteStats['zaps'][number]
export default function ZapReplyFeedRow({ zap }: { zap: TZapFeedEntry }) {
const { t } = useTranslation()
const { push } = useSecondaryPage()
const { isSmallScreen } = useScreenSize()
return (
<div
className="clickable pb-3 border-b transition-colors duration-500"
onClick={() => push(toProfile(zap.pubkey))}
>
<div className="flex items-start space-x-2 px-4 pt-3">
<UserAvatar userId={zap.pubkey} size="medium" className="mt-0.5 shrink-0" />
<div className="min-w-0 w-full overflow-hidden">
<div className="flex items-start justify-between gap-2">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<Zap className="size-4 shrink-0 text-primary" strokeWidth={2.5} aria-hidden />
<Username
userId={zap.pubkey}
className="truncate text-sm font-semibold text-muted-foreground hover:text-foreground"
skeletonClassName="h-3"
/>
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-1 text-sm text-muted-foreground">
<span className="font-semibold tabular-nums text-foreground">
{formatAmount(zap.amount)} {t('sats')}
</span>
<span className="text-muted-foreground/80" aria-hidden>
·
</span>
<Nip05 pubkey={zap.pubkey} append="·" />
<FormattedTimestamp timestamp={zap.created_at} className="shrink-0" short={isSmallScreen} />
</div>
</div>
</div>
{zap.comment ? <Content className="mt-2 text-sm" content={zap.comment} /> : null}
</div>
</div>
</div>
)
}

58
src/components/ReplyNoteList/index.tsx

@ -17,7 +17,6 @@ import { @@ -17,7 +17,6 @@ import {
isReplaceableEvent,
isReplyNoteEvent
} from '@/lib/event'
import { shouldHideInteractions } from '@/lib/event-filtering'
import logger from '@/lib/logger'
import { normalizeUrl } from '@/lib/url'
import { toNote } from '@/lib/link'
@ -30,8 +29,7 @@ import { useReply } from '@/providers/ReplyProvider' @@ -30,8 +29,7 @@ import { useReply } from '@/providers/ReplyProvider'
import { useUserTrust } from '@/contexts/user-trust-context'
import { useCurrentRelays } from '@/providers/CurrentRelaysProvider'
import { useFavoriteRelays } from '@/providers/FavoriteRelaysProvider'
import client from '@/services/client.service'
import { eventService, queryService } from '@/services/client.service'
import client, { eventService, queryService } from '@/services/client.service'
import noteStatsService from '@/services/note-stats.service'
import discussionFeedCache from '@/services/discussion-feed-cache.service'
import { buildReplyReadRelayList, relayHintsFromEventTags } from '@/lib/relay-list-builder'
@ -49,14 +47,13 @@ import { SuppressEmbeddedNoteContext } from '@/contexts/suppress-embedded-note-c @@ -49,14 +47,13 @@ import { SuppressEmbeddedNoteContext } from '@/contexts/suppress-embedded-note-c
import { LoadingBar } from '../LoadingBar'
import NoteCard, { NoteCardLoadingSkeleton } from '../NoteCard'
import ReplyNote, { ReplyNoteSkeleton } from '../ReplyNote'
import ZapReplyFeedRow from './ZapReplyFeedRow'
type TRootInfo =
| { type: 'E'; id: string; pubkey: string }
| { type: 'A'; id: string; eventId: string; pubkey: string; relay?: string }
| { type: 'I'; id: string }
const LIMIT = 100
const LIMIT = 200
const SHOW_COUNT = 10
function ReplyNoteList({
@ -275,15 +272,6 @@ function ReplyNoteList({ @@ -275,15 +272,6 @@ function ReplyNoteList({
return merged.sort((a, b) => b.created_at - a.created_at)
}, [replies, quoteEvents, showQuotes, sort, replyIdSet])
const zapsForFeed = useMemo(() => {
if (shouldHideInteractions(event)) return []
const raw = noteStats?.zaps ?? []
const nonZero = raw.filter((z) => z.amount > 0) // Suppress 0 sat zaps (spam)
const filtered =
isTrustLoaded && hideUntrustedInteractions ? nonZero.filter((z) => isUserTrusted(z.pubkey)) : nonZero
return [...filtered].sort((a, b) => b.amount - a.amount) // Largest to smallest
}, [event, noteStats, isTrustLoaded, hideUntrustedInteractions, isUserTrusted])
const [timelineKey] = useState<string | undefined>(undefined)
const [until, setUntil] = useState<number | undefined>(undefined)
const [loading, setLoading] = useState<boolean>(false)
@ -452,32 +440,27 @@ function ReplyNoteList({ @@ -452,32 +440,27 @@ function ReplyNoteList({
const fetchGeneration = ++replyFetchGenRef.current
const init = async () => {
// Check cache first - get cached data even if stale (for instant display)
// Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip
if (rootInfo.type === 'E' || rootInfo.type === 'A') {
const fromSession = eventService.getSessionThreadInteractionEvents(rootInfo)
if (fromSession.length > 0) {
addReplies(fromSession)
}
}
// Check cache next — discussion cache merges with relay results
const cachedData = discussionFeedCache.getCachedReplies(rootInfo)
const hasFreshCache = discussionFeedCache.hasFreshCache(rootInfo)
const hasCache = cachedData !== null
if (hasCache) {
// Display cached data immediately (even if stale) for instant switching
addReplies(cachedData)
setLoading(false)
} else {
// No cache at all, show loading while fetching
setLoading(true)
}
// Always fetch fresh data from relays to update cache
// If we have fresh cache, we can skip fetching (but still do it in background after a delay)
// If we have stale cache or no cache, fetch immediately
if (hasFreshCache) {
// Fresh cache: fetch in background after a short delay to avoid unnecessary requests
setTimeout(() => {
fetchFromRelays()
}, 2000) // Wait 2 seconds before background refresh
} else {
// Stale or no cache: fetch immediately
fetchFromRelays()
}
// Always refetch soon so relays fill gaps; no artificial delay (was 2s and caused empty threads)
void fetchFromRelays()
async function fetchFromRelays() {
if (!rootInfo) return // Type guard
@ -506,13 +489,13 @@ function ReplyNoteList({ @@ -506,13 +489,13 @@ function ReplyNoteList({
// Fetch all reply types for event-based replies
filters.push({
'#e': [rootInfo.id],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap],
limit: LIMIT
})
// Also fetch with uppercase E tag for replaceable events
filters.push({
'#E': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap],
limit: LIMIT
})
// Kind-1 notes that quote via #q without e-tags (still part of this thread)
@ -534,12 +517,12 @@ function ReplyNoteList({ @@ -534,12 +517,12 @@ function ReplyNoteList({
filters.push(
{
'#a': [rootInfo.id],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
kinds: [kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap],
limit: LIMIT
},
{
'#A': [rootInfo.id],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT],
kinds: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap],
limit: LIMIT
}
)
@ -672,9 +655,6 @@ function ReplyNoteList({ @@ -672,9 +655,6 @@ function ReplyNoteList({
return (
<div className="min-h-[80vh] pb-12">
{loading && <LoadingBar />}
{zapsForFeed.map((zap) => (
<ZapReplyFeedRow key={zap.pr} zap={zap} />
))}
{!loading && until && (
<div
className={`text-sm text-center text-muted-foreground border-b py-2 ${!loading ? 'hover:text-foreground cursor-pointer' : ''}`}

5
src/hooks/useProfileZapPollParticipation.tsx

@ -2,6 +2,7 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/con @@ -2,6 +2,7 @@ import { ExtendedKind, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/con
import {
filterZapPollVoteReceiptsForVoter,
getPollIdFromZapReceipt,
parseZapPollEvent,
userZapPollVoteOption
} from '@/lib/zap-poll'
import { normalizeUrl } from '@/lib/url'
@ -67,7 +68,9 @@ export function useProfileZapPollParticipation(profilePubkey: string | undefined @@ -67,7 +68,9 @@ export function useProfileZapPollParticipation(profilePubkey: string | undefined
if (!pid) continue
const poll = pollById.get(pid)
if (!poll) continue
const opt = userZapPollVoteOption(pid, profilePubkey, [vr])
const pollMeta = parseZapPollEvent(poll)
if (!pollMeta) continue
const opt = userZapPollVoteOption(poll, pollMeta, profilePubkey, [vr])
if (opt === undefined) continue
built.push({ poll, voteReceipt: vr, optionIndex: opt })
}

103
src/hooks/useZapPollTally.tsx

@ -5,50 +5,125 @@ import { @@ -5,50 +5,125 @@ import {
type TZapPollMeta,
type TZapPollTally
} from '@/lib/zap-poll'
import { peekZapPollTallyReceipts, storeZapPollTallyReceipts } from '@/lib/zap-poll-tally-cache'
import { normalizeUrl } from '@/lib/url'
import client from '@/services/client.service'
import client, { eventService } from '@/services/client.service'
import { Event, kinds } from 'nostr-tools'
import { useCallback, useEffect, useMemo, useState } from 'react'
import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react'
function tallyRelayUrls(): string[] {
/** Zap receipts for a poll often live on relays hinted on the poll’s `p` tags, not only the global read set. */
function tallyRelayUrls(meta: TZapPollMeta): string[] {
const seen = new Set<string>()
const out: string[] = []
for (const u of [...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS]) {
const n = normalizeUrl(u) || u
if (!n || seen.has(n)) continue
const push = (raw: string) => {
const n = normalizeUrl(raw) || raw?.trim()
if (!n || seen.has(n)) return
seen.add(n)
out.push(n)
}
return out.slice(0, 12)
for (const r of meta.recipients) {
push(r.relay)
}
for (const u of [...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS]) {
push(u)
}
return out.slice(0, 28)
}
function normalizePollHexId(id: string): string | null {
const k = id.trim().toLowerCase()
return /^[0-9a-f]{64}$/.test(k) ? k : null
}
function dedupeReceipts(lists: Event[]): Event[] {
const byId = new Map<string, Event>()
for (const ev of lists) {
if (!byId.has(ev.id)) byId.set(ev.id, ev)
}
return [...byId.values()]
}
function seedReceiptsFromSession(pollKey: string): { seeded: Event[]; hadWarmList: boolean } {
const cached = peekZapPollTallyReceipts(pollKey)
const sessionEvs = eventService.getSessionZapReceiptsForTargetEventId(pollKey)
const seeded = dedupeReceipts([...(cached ?? []), ...sessionEvs])
const hadWarmList = cached !== undefined || sessionEvs.length > 0
return { seeded, hadWarmList }
}
export function useZapPollTally(poll: Event, meta: TZapPollMeta | null) {
const [receipts, setReceipts] = useState<Event[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
/** Ignore stale fetch results when `poll.id` changes mid-request. */
const activePollKeyRef = useRef<string | null>(null)
activePollKeyRef.current = normalizePollHexId(poll.id)
/** Before paint: session tally cache + session LRU zaps so drawer matches feed immediately. */
useLayoutEffect(() => {
if (!meta) {
setReceipts([])
setLoading(false)
setError(null)
return
}
const pollKey = normalizePollHexId(poll.id)
if (!pollKey) {
setLoading(false)
return
}
const { seeded, hadWarmList } = seedReceiptsFromSession(pollKey)
setReceipts(seeded)
setLoading(!hadWarmList && seeded.length === 0)
setError(null)
}, [poll.id, meta])
const load = useCallback(async () => {
if (!meta) return
setLoading(true)
if (!meta) {
setLoading(false)
return
}
const pollKey = normalizePollHexId(poll.id)
if (!pollKey) {
setLoading(false)
return
}
const { seeded, hadWarmList } = seedReceiptsFromSession(pollKey)
setReceipts(seeded)
if (!hadWarmList && seeded.length === 0) {
setLoading(true)
}
setError(null)
try {
const urls = tallyRelayUrls()
const urls = tallyRelayUrls(meta)
const evs = await client.fetchEvents(urls, {
kinds: [kinds.Zap],
'#e': [poll.id],
limit: 500
})
setReceipts(evs)
if (activePollKeyRef.current !== pollKey) return
const merged = dedupeReceipts([...seeded, ...evs])
setReceipts(merged)
storeZapPollTallyReceipts(pollKey, merged)
} catch (e) {
setError(e instanceof Error ? e.message : String(e))
if (activePollKeyRef.current !== pollKey) return
if (!hadWarmList && seeded.length === 0) {
setError(e instanceof Error ? e.message : String(e))
}
} finally {
setLoading(false)
if (activePollKeyRef.current === pollKey) {
setLoading(false)
}
}
}, [poll.id, meta])
useEffect(() => {
if (!meta) return
if (!normalizePollHexId(poll.id)) return
void load()
}, [load])
}, [load, meta, poll.id])
const tally = useMemo((): TZapPollTally | null => {
if (!meta) return null

4
src/i18n/locales/en.ts

@ -396,6 +396,7 @@ export default { @@ -396,6 +396,7 @@ export default {
'Technical details': 'Technical details',
'Event kind and time': 'Kind {{kind}} · {{time}}',
'Event kind label': 'Kind {{kind}}',
'Note kind label line': 'KIND: {{kind}} · {{description}}',
'Unknown note declared kind tag': 'Tagged kind: {{value}}',
'Unknown note tagged pubkey': 'Tagged pubkey',
'Unknown note tagged content': 'Content',
@ -653,6 +654,8 @@ export default { @@ -653,6 +654,8 @@ export default {
'≥ {{n}} sats': '≥ {{n}} sats',
'≤ {{n}} sats': '≤ {{n}} sats',
'Loading tally…': 'Loading tally…',
'Zap poll no votes yet':
'No zap votes found on the relays we queried (try Refresh tally, or votes may live on other relays).',
'Consensus threshold': 'Consensus threshold',
'Pay to': 'Pay to',
Recipient: 'Recipient',
@ -740,6 +743,7 @@ export default { @@ -740,6 +743,7 @@ export default {
Highlights: 'Highlights',
'A note from': 'A note from',
Polls: 'Polls',
'Zap polls': 'Zap polls',
'Voice Posts': 'Voice Posts',
'Photo Posts': 'Photo Posts',
'Video Posts': 'Video Posts',

32
src/lib/event.ts

@ -116,6 +116,18 @@ export function getParentETag(event?: Event) { @@ -116,6 +116,18 @@ export function getParentETag(event?: Event) {
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E'))
}
// Kind 9735: zapped note id is on `e` / `E` (or addressable target on `a` / `A`)
if (event.kind === kinds.Zap) {
const firstHex = getFirstHexEventIdFromETags(event.tags)
if (firstHex) {
return (
event.tags.find((t) => t[0] === 'e' && t[1] === firstHex) ??
event.tags.find((t) => t[0] === 'E' && t[1] === firstHex)
)
}
return event.tags.find(tagNameEquals('e')) ?? event.tags.find(tagNameEquals('E'))
}
if (event.kind !== kinds.ShortTextNote) return undefined
let tag = event.tags.find(([tagName, , , marker]) => {
@ -135,8 +147,11 @@ export function getParentETag(event?: Event) { @@ -135,8 +147,11 @@ export function getParentETag(event?: Event) {
}
export function getParentATag(event?: Event) {
if (!event) return undefined
if (event.kind === kinds.Zap) {
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
}
if (
!event ||
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, ExtendedKind.DISCUSSION].includes(event.kind)
) {
return undefined
@ -174,6 +189,16 @@ export function getRootETag(event?: Event) { @@ -174,6 +189,16 @@ export function getRootETag(event?: Event) {
return event.tags.find(tagNameEquals('E'))
}
// Kind 9735: thread root for note zaps is the zapped event id on `e` / `E`
if (event.kind === kinds.Zap) {
const firstHex = getFirstHexEventIdFromETags(event.tags)
if (!firstHex) return undefined
return (
event.tags.find((t) => t[0] === 'e' && t[1] === firstHex) ??
event.tags.find((t) => t[0] === 'E' && t[1] === firstHex)
)
}
if (event.kind !== kinds.ShortTextNote) return undefined
let tag = event.tags.find(([tagName, , , marker]) => {
@ -189,8 +214,11 @@ export function getRootETag(event?: Event) { @@ -189,8 +214,11 @@ export function getRootETag(event?: Event) {
}
export function getRootATag(event?: Event) {
if (!event) return undefined
if (event.kind === kinds.Zap) {
return event.tags.find(tagNameEquals('a')) ?? event.tags.find(tagNameEquals('A'))
}
if (
!event ||
![kinds.ShortTextNote, ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, ExtendedKind.DISCUSSION].includes(event.kind)
) {
return undefined

46
src/lib/kind-description.ts

@ -48,6 +48,52 @@ export function getKindDescription(kind: number): { number: number; description: @@ -48,6 +48,52 @@ export function getKindDescription(kind: number): { number: number; description:
return { number: 24, description: 'Public Message' }
case ExtendedKind.DISCUSSION:
return { number: 11, description: 'Discussion' }
case kinds.Metadata:
return { number: 0, description: 'Profile metadata' }
case kinds.Repost:
return { number: 6, description: 'Repost' }
case kinds.Reaction:
return { number: 7, description: 'Reaction' }
case ExtendedKind.EXTERNAL_REACTION:
return { number: 17, description: 'External reaction' }
case kinds.CommunityDefinition:
return { number: 34550, description: 'Community' }
case kinds.LiveEvent:
return { number: 30311, description: 'Live event' }
case ExtendedKind.ZAP_REQUEST:
return { number: 9734, description: 'Zap request' }
case ExtendedKind.ZAP_RECEIPT:
return { number: 9735, description: 'Zap receipt' }
case ExtendedKind.RELAY_REVIEW:
return { number: 31987, description: 'Relay review' }
case ExtendedKind.PUBLICATION:
return { number: 30040, description: 'Publication' }
case ExtendedKind.CALENDAR_EVENT_DATE:
return { number: 31922, description: 'Calendar event (date)' }
case ExtendedKind.CALENDAR_EVENT_TIME:
return { number: 31923, description: 'Calendar event (time)' }
case ExtendedKind.CALENDAR_EVENT_RSVP:
return { number: 31925, description: 'Calendar RSVP' }
case ExtendedKind.POLL_RESPONSE:
return { number: 1018, description: 'Poll vote' }
case ExtendedKind.FOLLOW_PACK:
return { number: 39089, description: 'Follow pack' }
case ExtendedKind.GROUP_METADATA:
return { number: 39000, description: 'Group metadata' }
case ExtendedKind.APPLICATION_HANDLER_INFO:
return { number: 31990, description: 'Application handler' }
case ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION:
return { number: 31989, description: 'Handler recommendation' }
case ExtendedKind.SPELL:
return { number: 777, description: 'Spell / filter' }
case ExtendedKind.RSS_THREAD_ROOT:
return { number: 99999, description: 'Web article thread' }
case ExtendedKind.FILE_METADATA:
return { number: 1063, description: 'File metadata' }
case ExtendedKind.REPORT:
return { number: 1984, description: 'Report' }
case ExtendedKind.WEB_BOOKMARK:
return { number: 39701, description: 'Web bookmark' }
default:
return { number: kind, description: `Event (kind ${kind})` }
}

20
src/lib/zap-poll-tally-cache.ts

@ -0,0 +1,20 @@ @@ -0,0 +1,20 @@
import type { Event } from 'nostr-tools'
/** In-memory: successful tally fetches this tab session (incl. empty tallies). */
const receiptsByPollId = new Map<string, Event[]>()
function cacheKey(pollHexId: string): string | null {
const k = pollHexId.trim().toLowerCase()
return /^[0-9a-f]{64}$/.test(k) ? k : null
}
export function peekZapPollTallyReceipts(pollHexId: string): Event[] | undefined {
const k = cacheKey(pollHexId)
if (!k || !receiptsByPollId.has(k)) return undefined
return receiptsByPollId.get(k)!
}
export function storeZapPollTallyReceipts(pollHexId: string, receipts: Event[]) {
const k = cacheKey(pollHexId)
if (k) receiptsByPollId.set(k, receipts)
}

115
src/lib/zap-poll.ts

@ -1,5 +1,6 @@ @@ -1,5 +1,6 @@
import { ExtendedKind } from '@/constants'
import { getAmountFromInvoice } from '@/lib/lightning'
import { userIdToPubkey } from '@/lib/pubkey'
import { tagNameEquals } from '@/lib/tag'
import { normalizeUrl } from '@/lib/url'
import type { Event, EventTemplate } from 'nostr-tools'
@ -22,14 +23,31 @@ export function parseZapPollEvent(event: Event): TZapPollMeta | null { @@ -22,14 +23,31 @@ export function parseZapPollEvent(event: Event): TZapPollMeta | null {
if (event.kind !== ExtendedKind.ZAP_POLL) return null
const pTags = event.tags.filter(tagNameEquals('p'))
const recipients: { pubkey: string; relay: string }[] = []
const withRelay: { pubkey: string; relay: string }[] = []
const pubkeyNoRelay: string[] = []
for (const t of pTags) {
const pk = t[1]?.trim().toLowerCase()
const relay = t[2]?.trim()
if (!pk || !/^[0-9a-f]{64}$/.test(pk) || !relay) continue
const n = normalizeUrl(relay) || relay
recipients.push({ pubkey: pk, relay: n })
if (!pk || !/^[0-9a-f]{64}$/.test(pk)) continue
if (relay) {
const n = normalizeUrl(relay) || relay
withRelay.push({ pubkey: pk, relay: n })
} else {
pubkeyNoRelay.push(pk)
}
}
if (withRelay.length === 0 && pubkeyNoRelay.length === 0) return null
if (withRelay.length > 0) {
recipients.push(...withRelay)
const fallbackRelay = withRelay[0]!.relay
for (const pk of pubkeyNoRelay) {
if (!recipients.some((r) => r.pubkey === pk)) {
recipients.push({ pubkey: pk, relay: fallbackRelay })
}
}
} else {
return null
}
if (recipients.length === 0) return null
const options: TZapPollOption[] = []
for (const t of event.tags) {
@ -130,14 +148,76 @@ function getPollOptionFromZapRequestTags(tags: unknown): number | undefined { @@ -130,14 +148,76 @@ function getPollOptionFromZapRequestTags(tags: unknown): number | undefined {
if (!Array.isArray(tags)) return undefined
const po = (tags as string[][]).find((t) => t[0] === 'poll_option' && t[1] != null)
if (!po) return undefined
const n = parseInt(po[1], 10)
const n = parseInt(String(po[1]), 10)
return Number.isNaN(n) ? undefined : n
}
function getKindFromZapRequestTags(tags: unknown): string | undefined {
if (!Array.isArray(tags)) return undefined
const k = (tags as string[][]).find((t) => t[0] === 'k' && t[1] != null)
return k?.[1]
const k = (tags as string[][]).find((t) => t[0] === 'k' && t[1] != null && String(t[1]).length > 0)
if (!k) return undefined
return String(k[1])
}
/**
* NIP-57 `k` is often missing; some clients wrongly send `1` when zapping a poll.
* We only reject kinds that clearly point at another event class (not exhaustive).
*/
function zapTargetKindAllowsPollTally(tags: string[][] | undefined): boolean {
const k = getKindFromZapRequestTags(tags)
if (k == null || k === '') return true
if (k === '6969' || k === String(ExtendedKind.ZAP_POLL)) return true
if (k === '1' || k === String(kinds.ShortTextNote)) return true
return false
}
function normalizeZapRequestPTagPubkey(raw: string | undefined): string | undefined {
if (!raw) return undefined
const pk = userIdToPubkey(raw).trim().toLowerCase()
return /^[0-9a-f]{64}$/.test(pk) ? pk : undefined
}
/** Every `p` on the embedded zap request (some clients put author first, LN recipient second). */
function zapRequestPayeePubkeys(tags: string[][] | undefined): string[] {
if (!tags) return []
const out: string[] = []
const seen = new Set<string>()
for (const t of tags) {
if (t[0] !== 'p' || !t[1]) continue
const pk = normalizeZapRequestPTagPubkey(t[1])
if (!pk || seen.has(pk)) continue
seen.add(pk)
out.push(pk)
}
return out
}
/**
* Resolve vote option: explicit `poll_option` tag, or infer from which poll candidate (`p`) was paid.
* Matches clients (e.g. Primal) that omit `poll_option` but pay the options pubkey.
*/
export function extractVoteOptionFromZapRequest(
poll: Event,
meta: TZapPollMeta,
tags: string[][] | undefined
): number | undefined {
const payees = zapRequestPayeePubkeys(tags)
if (payees.length === 0) return undefined
const payeeSet = new Set(payees)
const pollAuthor = poll.pubkey.trim().toLowerCase()
const paidAuthor = payeeSet.has(pollAuthor)
const hasCandidatePayee = meta.recipients.some((r) => payeeSet.has(r.pubkey))
const explicit = getPollOptionFromZapRequestTags(tags)
const explicitOk =
explicit !== undefined && meta.options.some((o) => o.index === explicit) ? explicit : undefined
if (explicitOk !== undefined && (paidAuthor || hasCandidatePayee)) {
return explicitOk
}
const j = meta.recipients.findIndex((r) => payeeSet.has(r.pubkey))
if (j < 0 || j >= meta.options.length) return undefined
return meta.options[j]!.index
}
/**
@ -146,7 +226,6 @@ function getKindFromZapRequestTags(tags: unknown): string | undefined { @@ -146,7 +226,6 @@ function getKindFromZapRequestTags(tags: unknown): string | undefined {
export function tallyZapPollFromReceipts(poll: Event, meta: TZapPollMeta, receipts: Event[]): TZapPollTally {
const satsByOption = new Map<number, number>()
const receiptCountByOption = new Map<number, number>()
const recipientSet = new Set(meta.recipients.map((r) => r.pubkey))
const equalMinMax =
meta.valueMinimum != null &&
meta.valueMaximum != null &&
@ -171,14 +250,12 @@ export function tallyZapPollFromReceipts(poll: Event, meta: TZapPollMeta, receip @@ -171,14 +250,12 @@ export function tallyZapPollFromReceipts(poll: Event, meta: TZapPollMeta, receip
} catch {
continue
}
if (getKindFromZapRequestTags(zapReq.tags) !== '6969') continue
if (!zapTargetKindAllowsPollTally(zapReq.tags)) continue
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1])
if (!eTag || eTag[1] !== poll.id) continue
const voterPk = (zapReq.pubkey ?? '').trim().toLowerCase()
if (!voterPk || voterPk === poll.pubkey) continue
const pTag = zapReq.tags?.find((t) => t[0] === 'p' && t[1])
if (!pTag || !recipientSet.has(pTag[1].trim().toLowerCase())) continue
const optIdx = getPollOptionFromZapRequestTags(zapReq.tags)
const optIdx = extractVoteOptionFromZapRequest(poll, meta, zapReq.tags)
if (optIdx === undefined || !satsByOption.has(optIdx)) continue
const bolt11 = r.tags.find(tagNameEquals('bolt11'))?.[1]
@ -237,7 +314,8 @@ export function userHasZappedPoll( @@ -237,7 +314,8 @@ export function userHasZappedPoll(
}
export function userZapPollVoteOption(
pollId: string,
poll: Event,
meta: TZapPollMeta,
userPubkey: string,
receipts: Event[]
): number | undefined {
@ -248,11 +326,11 @@ export function userZapPollVoteOption( @@ -248,11 +326,11 @@ export function userZapPollVoteOption(
if (!desc) continue
try {
const zapReq = JSON.parse(desc) as { pubkey?: string; tags?: string[][] }
if (getKindFromZapRequestTags(zapReq.tags) !== '6969') continue
if (!zapTargetKindAllowsPollTally(zapReq.tags)) continue
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1])
if (eTag?.[1] !== pollId) continue
if (eTag?.[1] !== poll.id) continue
if ((zapReq.pubkey ?? '').trim().toLowerCase() !== pk) continue
return getPollOptionFromZapRequestTags(zapReq.tags)
return extractVoteOptionFromZapRequest(poll, meta, zapReq.tags)
} catch {
continue
}
@ -260,7 +338,7 @@ export function userZapPollVoteOption( @@ -260,7 +338,7 @@ export function userZapPollVoteOption(
return undefined
}
/** Receipts where user is the zapper and vote targets a zap poll (for profile). */
/** Receipts where user is the zapper and zap request looks like a vote on some event (kind 6969 or unspecified `k`). */
export function filterZapPollVoteReceiptsForVoter(receipts: Event[], profilePubkey: string): Event[] {
const pk = profilePubkey.trim().toLowerCase()
return receipts.filter((r) => {
@ -271,7 +349,8 @@ export function filterZapPollVoteReceiptsForVoter(receipts: Event[], profilePubk @@ -271,7 +349,8 @@ export function filterZapPollVoteReceiptsForVoter(receipts: Event[], profilePubk
if (!desc) return false
try {
const zapReq = JSON.parse(desc) as { tags?: string[][] }
return getKindFromZapRequestTags(zapReq.tags) === '6969'
if (!zapReq.tags?.some((t) => t[0] === 'e' && t[1])) return false
return zapTargetKindAllowsPollTally(zapReq.tags)
} catch {
return false
}

108
src/services/client-events.service.ts

@ -1,4 +1,16 @@ @@ -1,4 +1,16 @@
import { ExtendedKind } from '@/constants'
import logger from '@/lib/logger'
import {
getParentATag,
getParentETag,
getQuotedEventHexIdFromQTags,
getRootATag,
getRootETag,
isNip25ReactionKind,
isReplyNoteEvent,
isReplaceableEvent
} from '@/lib/event'
import { getFirstHexEventIdFromETags } from '@/lib/tag'
import type { Event as NEvent, Filter } from 'nostr-tools'
import { kinds, nip19 } from 'nostr-tools'
import DataLoader from 'dataloader'
@ -6,7 +18,6 @@ import { LRUCache } from 'lru-cache' @@ -6,7 +18,6 @@ import { LRUCache } from 'lru-cache'
import indexedDb from './indexed-db.service'
import type { QueryService } from './client-query.service'
import client from './client.service'
import { isReplaceableEvent } from '@/lib/event'
import { shouldDropEventOnIngest } from '@/lib/event-ingest-filter'
import { buildComprehensiveRelayList } from '@/lib/relay-list-builder'
@ -409,6 +420,101 @@ export class EventService { @@ -409,6 +420,101 @@ export class EventService {
return results
}
/**
* Kind 9735 in session LRU whose top-level `e` references the given hex event id (e.g. zap poll / note).
* Used to show tally immediately when opening the note drawer after the feed already saw these receipts.
*/
getSessionZapReceiptsForTargetEventId(targetEventHexId: string): NEvent[] {
const id = targetEventHexId.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(id)) return []
const out: NEvent[] = []
for (const [, event] of this.sessionEventCache.entries()) {
if (event.kind !== kinds.Zap) continue
if (shouldDropEventOnIngest(event)) continue
const matches = event.tags.some(
(t) => (t[0] === 'e' || t[0] === 'E') && t[1]?.toLowerCase() === id
)
if (matches) out.push(event)
}
return out
}
/**
* Reply-shaped events already in the session LRU for this thread (notes, kind 1111, voice comments, zaps),
* found by BFS over e/E/q and (for `a`-root threads) a-tag links. Merges with relay fetches via ReplyProvider.
*/
getSessionThreadInteractionEvents(root: { type: 'E' | 'A' | 'I'; id: string }): NEvent[] {
if (root.type === 'I') return []
const threadKeys = new Set<string>()
if (root.type === 'E') {
const id = root.id.trim().toLowerCase()
if (!/^[0-9a-f]{64}$/.test(id)) return []
threadKeys.add(id)
} else {
threadKeys.add(root.id.trim().toLowerCase())
}
const linkRefs = (ev: NEvent): string[] => {
const ids = new Set<string>()
const add = (v?: string) => {
if (v == null || v === '') return
ids.add(v.trim().toLowerCase())
}
add(getParentETag(ev)?.[1])
add(getRootETag(ev)?.[1])
add(getQuotedEventHexIdFromQTags(ev))
if (ev.kind === kinds.Zap) {
add(getFirstHexEventIdFromETags(ev.tags))
}
if (
ev.kind === kinds.ShortTextNote ||
ev.kind === ExtendedKind.COMMENT ||
ev.kind === ExtendedKind.VOICE_COMMENT
) {
for (const t of ev.tags) {
if ((t[0] === 'e' || t[0] === 'E') && t[1]) add(t[1])
}
}
if (root.type === 'A') {
add(getRootATag(ev)?.[1])
add(getParentATag(ev)?.[1])
for (const t of ev.tags) {
if ((t[0] === 'a' || t[0] === 'A') && t[1]) add(t[1])
}
}
return [...ids]
}
const seen = new Set<string>()
const out: NEvent[] = []
const maxRounds = 14
for (let round = 0; round < maxRounds; round++) {
let added = 0
for (const [, ev] of this.sessionEventCache.entries()) {
if (shouldDropEventOnIngest(ev)) continue
if (!isReplyNoteEvent(ev)) continue
if (isNip25ReactionKind(ev.kind)) continue
if (seen.has(ev.id)) continue
if (!linkRefs(ev).some((id) => threadKeys.has(id))) continue
out.push(ev)
seen.add(ev.id)
added++
const eid = ev.id.trim().toLowerCase()
if (/^[0-9a-f]{64}$/.test(eid)) threadKeys.add(eid)
if (root.type === 'A') {
for (const t of ev.tags) {
if ((t[0] === 'a' || t[0] === 'A') && t[1]) {
threadKeys.add(t[1].trim().toLowerCase())
}
}
}
}
if (added === 0) break
}
return out
}
/**
* Extract relay hints from event tags
* Relay hints are in the 3rd position (index 2) of e, a, q, etc. tags

Loading…
Cancel
Save