diff --git a/src/components/ContentPreview/index.tsx b/src/components/ContentPreview/index.tsx index c0f34116..43019645 100644 --- a/src/components/ContentPreview/index.tsx +++ b/src/components/ContentPreview/index.tsx @@ -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 = { 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({ ) } + const { outer: previewOuter, body: previewBody } = splitPreviewLayoutClasses(className) + + const withKindRow = (node: React.ReactNode) => ( +
+ +
{node}
+
+ ) + if ( [ kinds.ShortTextNote, @@ -95,64 +121,71 @@ export default function ContentPreview({ ExtendedKind.PUBLIC_MESSAGE ].includes(event.kind) ) { - return + return withKindRow() } if (event.kind === ExtendedKind.DISCUSSION) { - return + return ( +
+ +
+ +
+
+ ) } if (event.kind === kinds.Highlights) { - return + return withKindRow() } if (event.kind === ExtendedKind.POLL) { - return + return withKindRow() } if (event.kind === kinds.LongFormArticle) { - return + return withKindRow() } if (event.kind === ExtendedKind.VIDEO || event.kind === ExtendedKind.SHORT_VIDEO) { - return + return withKindRow() } if (event.kind === ExtendedKind.PICTURE) { - return + return withKindRow() } if (event.kind === ExtendedKind.GROUP_METADATA) { - return + return withKindRow() } if (event.kind === kinds.CommunityDefinition) { - return + return withKindRow() } if (event.kind === kinds.LiveEvent) { - return + return withKindRow() } if (event.kind === ExtendedKind.ZAP_REQUEST || event.kind === ExtendedKind.ZAP_RECEIPT) { - return + return withKindRow() } if (event.kind === ExtendedKind.APPLICATION_HANDLER_INFO) { - return + return withKindRow() } if (event.kind === ExtendedKind.APPLICATION_HANDLER_RECOMMENDATION) { - return + return withKindRow() } if (event.kind === ExtendedKind.FOLLOW_PACK) { - return + return withKindRow() } if (isNip25ReactionKind(event.kind)) { - return ( -
+ return withKindRow( +
{reactionDisplay.status === 'pending' ? ( ) : reactionDisplay.status === 'vote_up' ? ( @@ -172,20 +205,18 @@ export default function ContentPreview({ } if (event.kind === kinds.Repost) { - return ( -
- {t('Notification boost summary')} -
+ return withKindRow( +
{t('Notification boost summary')}
) } if (event.kind === ExtendedKind.POLL_RESPONSE) { - return ( -
+ return withKindRow( +
{t('Notification poll vote summary')}
) } - return
[{t('Cannot handle event of kind k', { k: event.kind })}]
+ return withKindRow(
[{t('Cannot handle event of kind k', { k: event.kind })}]
) } diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index 55254f7c..7738d6d5 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -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({ const checked = kindGroup.every((k) => temporaryShowKinds.includes(k)) return (
+ {t('Note kind label line', { kind, description })} +

+ ) +} diff --git a/src/components/Note/Poll.tsx b/src/components/Note/Poll.tsx index 217a524c..9ada9357 100644 --- a/src/components/Note/Poll.tsx +++ b/src/components/Note/Poll.tsx @@ -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 .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(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 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 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
-

- {poll.pollType === POLL_TYPE.MULTIPLE_CHOICE && - t('Multiple choice (select one or more)')} -

+ {!isExpired && poll.pollType === POLL_TYPE.MULTIPLE_CHOICE && ( +

{t('Multiple choice (select one or more)')}

+ )}

{!!poll.endsAt && (isExpired @@ -168,36 +176,30 @@ export default function Poll({ event, className }: { event: Event; className?: s

- {/* Poll Options */} + {/* Results rows (read-only when ended or already voted) */}
{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 ( - + ) : ( +
+ {inner} +
) })}
diff --git a/src/components/Note/UnknownNote.tsx b/src/components/Note/UnknownNote.tsx index 1db089eb..4019e20f 100644 --- a/src/components/Note/UnknownNote.tsx +++ b/src/components/Note/UnknownNote.tsx @@ -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 { 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({ ) 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({

{headline}

-

- {showKindAsSubtitle ? ( + {!omitKindLabel ? : null} + {elevated.title?.trim() && !omitKindLabel ? ( +

{kindLabel.description} - ) : null} - {showKindAsSubtitle ? · : null} - {t('Event kind label', { kind: event.kind })} -

+ · + {t('Event kind label', { kind: event.kind })} +

+ ) : null} {showDeclaredKindTag ? (

{t('Unknown note declared kind tag', { value: declaredKindTrimmed })}

) : null} diff --git a/src/components/Note/Zap.tsx b/src/components/Note/Zap.tsx index 4504ff17..981e35a4 100644 --- a/src/components/Note/Zap.tsx +++ b/src/components/Note/Zap.tsx @@ -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 return (
@@ -61,6 +74,60 @@ export default function Zap({ event, className }: { event: Event; className?: st const { senderPubkey, recipientPubkey, amount, comment } = zapInfo + const openZapTarget = (e: MouseEvent) => { + 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 ( +
+
+ + {formatAmount(amount)} + {t('sats')} + {recipientPubkey && recipientPubkey !== senderPubkey && ( + + {t('zapped')}{' '} + + + )} + {(isEventZap || isProfileZap) && ( + + )} +
+ {comment ? ( +

+ {comment} +

+ ) : null} +
+ ) + } + return (
- {/* Zapped note/profile link in bottom-right corner */}
- {meta.consensusThreshold != null && showTally && tally && tally.totalSats > 0 && ( + {meta.consensusThreshold != null && showTally && tally && (

{t('Consensus threshold')}: {meta.consensusThreshold}%

@@ -221,7 +233,7 @@ export default function ZapPoll({ - {meta.recipients.map((r) => ( + {payToRecipients.map((r) => ( {r.pubkey.slice(0, 12)}… diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index be4f4293..96f4b797 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -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({ if (!isRenderableNoteKind(event.kind)) { logger.debug('Note component - rendering UnknownNote for unsupported kind:', event.kind) - content = + content = } else if (mutePubkeySet.has(event.pubkey) && !showMuted) { content = setShowMuted(true)} /> } else if (!defaultShowNsfw && isNsfwEvent(event) && !showNsfw) { @@ -422,10 +423,15 @@ export default function Note({ )} - {size === 'normal' && ( + {(size === 'normal' || + event.kind === ExtendedKind.ZAP_REQUEST || + event.kind === ExtendedKind.ZAP_RECEIPT) && (
+ {webReactionParentUrl ? (
diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 07e3a026..2cdfcc7b 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -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({ }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() + const isZapFeedCard = + event.kind === ExtendedKind.ZAP_RECEIPT || event.kind === ExtendedKind.ZAP_REQUEST + const showNoteStatsRow = !embedded || isZapFeedCard return (
- {!embedded && ( - - )} + {showNoteStatsRow ? ( + + ) : null}
{!embedded && }
diff --git a/src/components/ReplyNote/index.tsx b/src/components/ReplyNote/index.tsx index 233de442..64621073 100644 --- a/src/components/ReplyNote/index.tsx +++ b/src/components/ReplyNote/index.tsx @@ -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' 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({ 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({ >
- +
- +
+ {webReactionParentUrl ? (
@@ -148,6 +158,8 @@ export default function ReplyNote({ )} {t(notificationReactionSummaryKey(reactionDisplay))}
+ ) : event.kind === kinds.Zap ? ( + ) : ( )} diff --git a/src/components/ReplyNoteList/ZapReplyFeedRow.tsx b/src/components/ReplyNoteList/ZapReplyFeedRow.tsx deleted file mode 100644 index 4267afb9..00000000 --- a/src/components/ReplyNoteList/ZapReplyFeedRow.tsx +++ /dev/null @@ -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 ( -
push(toProfile(zap.pubkey))} - > -
- -
-
-
-
- - -
-
- - {formatAmount(zap.amount)} {t('sats')} - - - · - - - -
-
-
- {zap.comment ? : null} -
-
-
- ) -} diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index dd33bd5b..9bb5dde5 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -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' 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 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({ 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(undefined) const [until, setUntil] = useState(undefined) const [loading, setLoading] = useState(false) @@ -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({ // 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({ 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({ return (
{loading && } - {zapsForFeed.map((zap) => ( - - ))} {!loading && until && (
() 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() + 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([]) const [loading, setLoading] = useState(false) const [error, setError] = useState(null) + /** Ignore stale fetch results when `poll.id` changes mid-request. */ + const activePollKeyRef = useRef(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 diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index e9dbdae9..7e29edc5 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -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 { '≥ {{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 { 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', diff --git a/src/lib/event.ts b/src/lib/event.ts index 486f76a3..603e7d0a 100644 --- a/src/lib/event.ts +++ b/src/lib/event.ts @@ -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) { } 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) { 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) { } 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 diff --git a/src/lib/kind-description.ts b/src/lib/kind-description.ts index 723d8a9a..e7f68f19 100644 --- a/src/lib/kind-description.ts +++ b/src/lib/kind-description.ts @@ -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})` } } diff --git a/src/lib/zap-poll-tally-cache.ts b/src/lib/zap-poll-tally-cache.ts new file mode 100644 index 00000000..056a4a8d --- /dev/null +++ b/src/lib/zap-poll-tally-cache.ts @@ -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() + +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) +} diff --git a/src/lib/zap-poll.ts b/src/lib/zap-poll.ts index 128348c2..af3bd5f6 100644 --- a/src/lib/zap-poll.ts +++ b/src/lib/zap-poll.ts @@ -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 { 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 { 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() + 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 option’s 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 { export function tallyZapPollFromReceipts(poll: Event, meta: TZapPollMeta, receipts: Event[]): TZapPollTally { const satsByOption = new Map() const receiptCountByOption = new Map() - 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 } 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( } export function userZapPollVoteOption( - pollId: string, + poll: Event, + meta: TZapPollMeta, userPubkey: string, receipts: Event[] ): number | undefined { @@ -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( 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 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 } diff --git a/src/services/client-events.service.ts b/src/services/client-events.service.ts index eb671050..bdf184a3 100644 --- a/src/services/client-events.service.ts +++ b/src/services/client-events.service.ts @@ -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' 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 { 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() + 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() + 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() + 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