diff --git a/src/components/Note/Poll.tsx b/src/components/Note/Poll.tsx index 94a0653d..5858e446 100644 --- a/src/components/Note/Poll.tsx +++ b/src/components/Note/Poll.tsx @@ -13,20 +13,68 @@ import dayjs from 'dayjs' import { Skeleton } from '@/components/ui/skeleton' import { CheckCircle2 } from 'lucide-react' import { Event } from 'nostr-tools' -import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState } from 'react' import { useTranslation } from 'react-i18next' import { toast } from 'sonner' import logger from '@/lib/logger' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import PollOptionContent from './PollOptionContent' +/** Nearest ancestor that scrolls — use as IntersectionObserver root so polls in split panes still load. */ +function nearestScrollportRoot(el: HTMLElement | null): Element | undefined { + if (!el) return undefined + let cur: HTMLElement | null = el.parentElement + while (cur && cur !== document.documentElement) { + const st = window.getComputedStyle(cur) + const oy = st.overflowY + const ox = st.overflowX + if ( + oy === 'auto' || + oy === 'scroll' || + oy === 'overlay' || + ox === 'auto' || + ox === 'scroll' || + ox === 'overlay' + ) { + return cur + } + cur = cur.parentElement + } + return undefined +} + +function rectsOverlap(a: DOMRectReadOnly, b: DOMRectReadOnly): boolean { + return a.bottom > b.top && a.top < b.bottom && a.right > b.left && a.left < b.right +} + +/** Visible in window or in nearest scrollport (split-pane columns, nested scroll). */ +function isPollLikelyVisible(el: HTMLElement): boolean { + const root = nearestScrollportRoot(el) + if (root) { + return rectsOverlap(el.getBoundingClientRect(), root.getBoundingClientRect()) + } + return isPartiallyInViewport(el) +} + /** * Persists "See results" across remounts (React Strict Mode dev double-mount, list recycle). * Scoped to this tab session only. */ const pollSessionRevealResultIds = new Set() -export default function Poll({ event, className }: { event: Event; className?: string }) { +export default function Poll({ + event, + className, + /** + * When the poll is shown inside another card (nostr: embed), fetch results on mount: + * viewport-only IntersectionObserver often never fires in nested / overflow layouts. + */ + eagerFetchResults = false +}: { + event: Event + className?: string + eagerFetchResults?: boolean +}) { const { t } = useTranslation() const nostr = useNostrOptional() const pubkey = nostr?.pubkey ?? null @@ -97,7 +145,15 @@ export default function Poll({ event, className }: { event: Event; className?: s }, [event, pubkey, favoriteRelays, blockedRelays]) useEffect(() => { + if (!eagerFetchResults || isExpired || pollResults || isLoadingResults || pollResultsViewportFetchDoneRef.current) { + return + } + void fetchResults() + }, [eagerFetchResults, isExpired, pollResults, isLoadingResults, fetchResults, event.id]) + + useLayoutEffect(() => { if ( + eagerFetchResults || isExpired || pollResults || isLoadingResults || @@ -106,18 +162,61 @@ export default function Poll({ event, className }: { event: Event; className?: s ) { return } + const tryFetch = () => { + if (pollResultsViewportFetchDoneRef.current || pollResults) return + if (isPollLikelyVisible(containerElement)) { + void fetchResults() + } + } + tryFetch() + let r2 = 0 + const r1 = requestAnimationFrame(() => { + tryFetch() + r2 = requestAnimationFrame(tryFetch) + }) + const t = window.setTimeout(tryFetch, 400) + return () => { + cancelAnimationFrame(r1) + cancelAnimationFrame(r2) + window.clearTimeout(t) + } + }, [ + eagerFetchResults, + containerElement, + isExpired, + pollResults, + isLoadingResults, + fetchResults + ]) + useEffect(() => { + if ( + isExpired || + pollResults || + isLoadingResults || + !containerElement || + pollResultsViewportFetchDoneRef.current + ) { + return + } + + const scrollRoot = nearestScrollportRoot(containerElement) const observer = new IntersectionObserver( ([entry]) => { if (entry.isIntersecting) { setTimeout(() => { - if (isPartiallyInViewport(containerElement)) { + if (pollResultsViewportFetchDoneRef.current) return + if (isPollLikelyVisible(containerElement)) { void fetchResults() } }, 200) } }, - { threshold: 0.1 } + { + threshold: 0.05, + rootMargin: '100px', + ...(scrollRoot ? { root: scrollRoot } : {}) + } ) observer.observe(containerElement) diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index ac1aa0c4..6e3196f9 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -214,6 +214,8 @@ export default function Note({ hideParentNotePreview = false, showFull = false, disableClick = false, + /** From {@link MainNoteCard}: embedded cards need eager poll results (viewport IO often misses nested scrollers). */ + embedded, fullCalendarInvite, zapPollVoteHighlightOption, nip84HighlightEvents @@ -225,6 +227,7 @@ export default function Note({ hideParentNotePreview?: boolean showFull?: boolean disableClick?: boolean + embedded?: boolean /** When viewing a kind-24 invite, use this to replace the embedded calendar with the full card (RSVP) in content */ fullCalendarInvite?: { event: Event; naddr: string } /** Profile: highlight option when this row is from a zap vote receipt. */ @@ -500,7 +503,7 @@ export default function Note({ content = ( <> {renderEventContent({ hideMetadata: true })} - + ) } else if (event.kind === ExtendedKind.ZAP_POLL) { diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index d60060e6..eca38707 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -102,6 +102,7 @@ export default function MainNoteCard({ className={embedded ? '' : 'px-4'} size={embedded ? 'small' : 'normal'} event={event} + embedded={embedded} originalNoteId={originalNoteId} disableClick={true} hideParentNotePreview={hideParentNotePreview} diff --git a/src/components/ReplyNoteList/index.tsx b/src/components/ReplyNoteList/index.tsx index 5eaed53c..501697f7 100644 --- a/src/components/ReplyNoteList/index.tsx +++ b/src/components/ReplyNoteList/index.tsx @@ -311,6 +311,11 @@ function replyMatchesThreadForList( return false } +/** NIP-69 poll responses (kind 1018): aggregated in the poll UI, not as thread rows under “Antworten”. */ +function isPollVoteKind(evt: Pick): boolean { + return evt.kind === ExtendedKind.POLL_RESPONSE +} + function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string { if (item.kind === kinds.Highlights) return t('highlighted this note') if (item.kind === kinds.ShortTextNote) return t('quoted this note') @@ -438,6 +443,7 @@ function ReplyNoteList({ events.forEach((evt) => { if (replyIdSet.has(evt.id)) return if (isNip25ReactionKind(evt.kind)) return + if (isPollVoteKind(evt)) return if ( shouldHideThreadResponseEvent( evt, @@ -944,7 +950,7 @@ function ReplyNoteList({ try { const ev = await eventService.fetchEvent(id) if (cancelled) return - if (ev && replyMatchesThreadForList(ev, event, threadRoot, true)) { + if (ev && replyMatchesThreadForList(ev, event, threadRoot, true) && !isPollVoteKind(ev)) { batch.push(ev) } else { discussionStatsHydratedReplyIdsRef.current.delete(id) @@ -984,6 +990,7 @@ function ReplyNoteList({ const onNewReply = useCallback( (evt: NEvent) => { + if (isPollVoteKind(evt)) return if ( shouldHideThreadResponseEvent( evt, @@ -1217,6 +1224,7 @@ function ReplyNoteList({ const urlThreadOnevent = urlThreadRootInfo ? (evt: NEvent) => { if (fetchGeneration !== replyFetchGenRef.current) return + if (isPollVoteKind(evt)) return if (!isRssArticleUrlThreadInteraction(evt, urlThreadRootInfo.id)) return if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) return @@ -1238,6 +1246,7 @@ function ReplyNoteList({ // Filter and add replies (URL threads include kind 9802 highlights of this page) const regularReplies = allReplies.filter((evt) => { + if (isPollVoteKind(evt)) return false const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) if (!match) return false return !shouldHideThreadResponseEvent( @@ -1299,6 +1308,7 @@ function ReplyNoteList({ const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { onevent: (evt: NEvent) => { if (fetchGeneration !== replyFetchGenRef.current) return + if (isPollVoteKind(evt)) return if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) return addReplies([evt]) @@ -1309,6 +1319,7 @@ function ReplyNoteList({ } const validNested = nestedAccum.filter( (evt) => + !isPollVoteKind(evt) && !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) ) if (validNested.length > 0) { @@ -1366,6 +1377,7 @@ function ReplyNoteList({ const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { onevent: (evt: NEvent) => { if (fetchGeneration !== replyFetchGenRef.current) return + if (isPollVoteKind(evt)) return if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) return if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return @@ -1377,6 +1389,7 @@ function ReplyNoteList({ } const validNested = nestedAccum.filter( (evt) => + !isPollVoteKind(evt) && !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) && replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) ) @@ -1454,6 +1467,7 @@ function ReplyNoteList({ setLoading(true) const events = await client.loadMoreTimeline(timelineKey, until, LIMIT) const olderEvents = events.filter((evt) => { + if (isPollVoteKind(evt)) return false if (!rootInfo) return false const matchesThread = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) if (!matchesThread) return false @@ -1500,6 +1514,7 @@ function ReplyNoteList({ const shouldShowFeedItem = useCallback( (item: NEvent) => { + if (isPollVoteKind(item)) return false if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { return false } diff --git a/src/lib/zap-poll.ts b/src/lib/zap-poll.ts index af3bd5f6..6faaccbe 100644 --- a/src/lib/zap-poll.ts +++ b/src/lib/zap-poll.ts @@ -1,4 +1,4 @@ -import { ExtendedKind } from '@/constants' +import { ExtendedKind, FAST_READ_RELAY_URLS } from '@/constants' import { getAmountFromInvoice } from '@/lib/lightning' import { userIdToPubkey } from '@/lib/pubkey' import { tagNameEquals } from '@/lib/tag' @@ -18,51 +18,101 @@ export type TZapPollMeta = { primaryRelay: string } +/** `wss` / `ws` relay URL on `r` or `relay` tags (Primal megaFeed / zap vote relay list). */ +function firstWsRelayFromEventTags(tags: string[][]): string | undefined { + for (const t of tags) { + const u = t[1]?.trim() + if (!u || (t[0] !== 'r' && t[0] !== 'relay')) continue + if (u.startsWith('wss://') || u.startsWith('ws://')) { + return normalizeUrl(u) || u + } + } + return undefined +} + +/** + * Relay hint on a `p` tag: Primal web publishes `['p', pubkey, relay]`; `zapVote` also reads index 3. + * Only treat values that look like relay URLs as relays (pubkey-only `p` tags stay pubkey-no-relay). + */ +function relayHintFromPTag(t: string[]): string | undefined { + for (const i of [2, 3] as const) { + const c = t[i]?.trim() + if (!c || c === 'mention') continue + if (c.startsWith('wss://') || c.startsWith('ws://')) { + return normalizeUrl(c) || c + } + } + return undefined +} + +function defaultZapPollReadRelay(tags: string[][]): string { + return ( + firstWsRelayFromEventTags(tags) ?? + FAST_READ_RELAY_URLS[0] ?? + 'wss://relay.damus.io' + ) +} + /** Parse NIP-B9 kind 6969 into structured metadata. */ export function parseZapPollEvent(event: Event): TZapPollMeta | null { if (event.kind !== ExtendedKind.ZAP_POLL) return null - const pTags = event.tags.filter(tagNameEquals('p')) + const tags = event.tags + const authorPk = event.pubkey.trim().toLowerCase() + if (!/^[0-9a-f]{64}$/.test(authorPk)) return null + + const hintRelay = firstWsRelayFromEventTags(tags) + const fallbackRelay = hintRelay ?? defaultZapPollReadRelay(tags) + + const pTags = 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)) continue + const relay = relayHintFromPTag(t) if (relay) { - const n = normalizeUrl(relay) || relay - withRelay.push({ pubkey: pk, relay: n }) + withRelay.push({ pubkey: pk, relay }) } 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 + const primary = withRelay[0]!.relay + for (const pk of pubkeyNoRelay) { + if (!recipients.some((r) => r.pubkey === pk)) { + recipients.push({ pubkey: pk, relay: primary }) + } + } + } else if (pubkeyNoRelay.length > 0) { for (const pk of pubkeyNoRelay) { if (!recipients.some((r) => r.pubkey === pk)) { recipients.push({ pubkey: pk, relay: fallbackRelay }) } } } else { - return null + // Primal: no `p` on poll → zap the poll author (see primal-web-app src/lib/zap.ts zapVote). + recipients.push({ pubkey: authorPk, relay: fallbackRelay }) } const options: TZapPollOption[] = [] - for (const t of event.tags) { - if (t[0] !== 'poll_option' || t[1] == null || t[2] == null) continue - const idx = parseInt(t[1], 10) + for (const t of tags) { + const name = t[0] + // `poll_option` everywhere in megaFeed; some paths used `option` (same shape). + if ((name !== 'poll_option' && name !== 'option') || t[1] == null || t[2] == null) continue + const idx = parseInt(String(t[1]), 10) if (Number.isNaN(idx)) continue options.push({ index: idx, label: t[2] }) } options.sort((a, b) => a.index - b.index) if (options.length < 2) return null - const vmin = event.tags.find(tagNameEquals('value_minimum'))?.[1] - const vmax = event.tags.find(tagNameEquals('value_maximum'))?.[1] - const consensus = event.tags.find(tagNameEquals('consensus_threshold'))?.[1] - const closed = event.tags.find(tagNameEquals('closed_at'))?.[1] + const vmin = tags.find(tagNameEquals('value_minimum'))?.[1] + const vmax = tags.find(tagNameEquals('value_maximum'))?.[1] + const consensus = tags.find(tagNameEquals('consensus_threshold'))?.[1] + const closed = tags.find(tagNameEquals('closed_at'))?.[1] const valueMinimum = vmin != null && vmin !== '' ? parseInt(vmin, 10) : undefined const valueMaximum = vmax != null && vmax !== '' ? parseInt(vmax, 10) : undefined