Browse Source

bug-fix polls

imwald
Silberengel 1 month ago
parent
commit
fe27c449c9
  1. 107
      src/components/Note/Poll.tsx
  2. 5
      src/components/Note/index.tsx
  3. 1
      src/components/NoteCard/MainNoteCard.tsx
  4. 17
      src/components/ReplyNoteList/index.tsx
  5. 80
      src/lib/zap-poll.ts

107
src/components/Note/Poll.tsx

@ -13,20 +13,68 @@ import dayjs from 'dayjs'
import { Skeleton } from '@/components/ui/skeleton' import { Skeleton } from '@/components/ui/skeleton'
import { CheckCircle2 } from 'lucide-react' import { CheckCircle2 } from 'lucide-react'
import { Event } from 'nostr-tools' 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 { useTranslation } from 'react-i18next'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback' import { showPublishingFeedback, showSimplePublishSuccess } from '@/lib/publishing-feedback'
import PollOptionContent from './PollOptionContent' 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). * Persists "See results" across remounts (React Strict Mode dev double-mount, list recycle).
* Scoped to this tab session only. * Scoped to this tab session only.
*/ */
const pollSessionRevealResultIds = new Set<string>() const pollSessionRevealResultIds = new Set<string>()
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 { t } = useTranslation()
const nostr = useNostrOptional() const nostr = useNostrOptional()
const pubkey = nostr?.pubkey ?? null const pubkey = nostr?.pubkey ?? null
@ -97,7 +145,15 @@ export default function Poll({ event, className }: { event: Event; className?: s
}, [event, pubkey, favoriteRelays, blockedRelays]) }, [event, pubkey, favoriteRelays, blockedRelays])
useEffect(() => { useEffect(() => {
if (!eagerFetchResults || isExpired || pollResults || isLoadingResults || pollResultsViewportFetchDoneRef.current) {
return
}
void fetchResults()
}, [eagerFetchResults, isExpired, pollResults, isLoadingResults, fetchResults, event.id])
useLayoutEffect(() => {
if ( if (
eagerFetchResults ||
isExpired || isExpired ||
pollResults || pollResults ||
isLoadingResults || isLoadingResults ||
@ -106,18 +162,61 @@ export default function Poll({ event, className }: { event: Event; className?: s
) { ) {
return 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( const observer = new IntersectionObserver(
([entry]) => { ([entry]) => {
if (entry.isIntersecting) { if (entry.isIntersecting) {
setTimeout(() => { setTimeout(() => {
if (isPartiallyInViewport(containerElement)) { if (pollResultsViewportFetchDoneRef.current) return
if (isPollLikelyVisible(containerElement)) {
void fetchResults() void fetchResults()
} }
}, 200) }, 200)
} }
}, },
{ threshold: 0.1 } {
threshold: 0.05,
rootMargin: '100px',
...(scrollRoot ? { root: scrollRoot } : {})
}
) )
observer.observe(containerElement) observer.observe(containerElement)

5
src/components/Note/index.tsx

@ -214,6 +214,8 @@ export default function Note({
hideParentNotePreview = false, hideParentNotePreview = false,
showFull = false, showFull = false,
disableClick = false, disableClick = false,
/** From {@link MainNoteCard}: embedded cards need eager poll results (viewport IO often misses nested scrollers). */
embedded,
fullCalendarInvite, fullCalendarInvite,
zapPollVoteHighlightOption, zapPollVoteHighlightOption,
nip84HighlightEvents nip84HighlightEvents
@ -225,6 +227,7 @@ export default function Note({
hideParentNotePreview?: boolean hideParentNotePreview?: boolean
showFull?: boolean showFull?: boolean
disableClick?: 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 */ /** 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 } fullCalendarInvite?: { event: Event; naddr: string }
/** Profile: highlight option when this row is from a zap vote receipt. */ /** Profile: highlight option when this row is from a zap vote receipt. */
@ -500,7 +503,7 @@ export default function Note({
content = ( content = (
<> <>
{renderEventContent({ hideMetadata: true })} {renderEventContent({ hideMetadata: true })}
<Poll className="mt-2" event={displayEvent} /> <Poll className="mt-2" event={displayEvent} eagerFetchResults={Boolean(embedded)} />
</> </>
) )
} else if (event.kind === ExtendedKind.ZAP_POLL) { } else if (event.kind === ExtendedKind.ZAP_POLL) {

1
src/components/NoteCard/MainNoteCard.tsx

@ -102,6 +102,7 @@ export default function MainNoteCard({
className={embedded ? '' : 'px-4'} className={embedded ? '' : 'px-4'}
size={embedded ? 'small' : 'normal'} size={embedded ? 'small' : 'normal'}
event={event} event={event}
embedded={embedded}
originalNoteId={originalNoteId} originalNoteId={originalNoteId}
disableClick={true} disableClick={true}
hideParentNotePreview={hideParentNotePreview} hideParentNotePreview={hideParentNotePreview}

17
src/components/ReplyNoteList/index.tsx

@ -311,6 +311,11 @@ function replyMatchesThreadForList(
return false return false
} }
/** NIP-69 poll responses (kind 1018): aggregated in the poll UI, not as thread rows under “Antworten”. */
function isPollVoteKind(evt: Pick<NEvent, 'kind'>): boolean {
return evt.kind === ExtendedKind.POLL_RESPONSE
}
function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string { function threadBacklinkRelationLabel(item: NEvent, t: TFunction): string {
if (item.kind === kinds.Highlights) return t('highlighted this note') if (item.kind === kinds.Highlights) return t('highlighted this note')
if (item.kind === kinds.ShortTextNote) return t('quoted this note') if (item.kind === kinds.ShortTextNote) return t('quoted this note')
@ -438,6 +443,7 @@ function ReplyNoteList({
events.forEach((evt) => { events.forEach((evt) => {
if (replyIdSet.has(evt.id)) return if (replyIdSet.has(evt.id)) return
if (isNip25ReactionKind(evt.kind)) return if (isNip25ReactionKind(evt.kind)) return
if (isPollVoteKind(evt)) return
if ( if (
shouldHideThreadResponseEvent( shouldHideThreadResponseEvent(
evt, evt,
@ -944,7 +950,7 @@ function ReplyNoteList({
try { try {
const ev = await eventService.fetchEvent(id) const ev = await eventService.fetchEvent(id)
if (cancelled) return if (cancelled) return
if (ev && replyMatchesThreadForList(ev, event, threadRoot, true)) { if (ev && replyMatchesThreadForList(ev, event, threadRoot, true) && !isPollVoteKind(ev)) {
batch.push(ev) batch.push(ev)
} else { } else {
discussionStatsHydratedReplyIdsRef.current.delete(id) discussionStatsHydratedReplyIdsRef.current.delete(id)
@ -984,6 +990,7 @@ function ReplyNoteList({
const onNewReply = useCallback( const onNewReply = useCallback(
(evt: NEvent) => { (evt: NEvent) => {
if (isPollVoteKind(evt)) return
if ( if (
shouldHideThreadResponseEvent( shouldHideThreadResponseEvent(
evt, evt,
@ -1217,6 +1224,7 @@ function ReplyNoteList({
const urlThreadOnevent = urlThreadRootInfo const urlThreadOnevent = urlThreadRootInfo
? (evt: NEvent) => { ? (evt: NEvent) => {
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
if (isPollVoteKind(evt)) return
if (!isRssArticleUrlThreadInteraction(evt, urlThreadRootInfo.id)) return if (!isRssArticleUrlThreadInteraction(evt, urlThreadRootInfo.id)) return
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers))
return return
@ -1238,6 +1246,7 @@ function ReplyNoteList({
// Filter and add replies (URL threads include kind 9802 highlights of this page) // Filter and add replies (URL threads include kind 9802 highlights of this page)
const regularReplies = allReplies.filter((evt) => { const regularReplies = allReplies.filter((evt) => {
if (isPollVoteKind(evt)) return false
const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)
if (!match) return false if (!match) return false
return !shouldHideThreadResponseEvent( return !shouldHideThreadResponseEvent(
@ -1299,6 +1308,7 @@ function ReplyNoteList({
const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, {
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
if (isPollVoteKind(evt)) return
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers))
return return
addReplies([evt]) addReplies([evt])
@ -1309,6 +1319,7 @@ function ReplyNoteList({
} }
const validNested = nestedAccum.filter( const validNested = nestedAccum.filter(
(evt) => (evt) =>
!isPollVoteKind(evt) &&
!shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)
) )
if (validNested.length > 0) { if (validNested.length > 0) {
@ -1366,6 +1377,7 @@ function ReplyNoteList({
const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, { const nestedReplies = await queryService.fetchEvents(relayUrlsForThreadReq, nestedFilters, {
onevent: (evt: NEvent) => { onevent: (evt: NEvent) => {
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
if (isPollVoteKind(evt)) return
if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers)) if (shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers))
return return
if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return if (!replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)) return
@ -1377,6 +1389,7 @@ function ReplyNoteList({
} }
const validNested = nestedAccum.filter( const validNested = nestedAccum.filter(
(evt) => (evt) =>
!isPollVoteKind(evt) &&
!shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) && !shouldHideThreadResponseEvent(evt, mutePubkeySet, hideContentMentioningMutedUsers) &&
replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)
) )
@ -1454,6 +1467,7 @@ function ReplyNoteList({
setLoading(true) setLoading(true)
const events = await client.loadMoreTimeline(timelineKey, until, LIMIT) const events = await client.loadMoreTimeline(timelineKey, until, LIMIT)
const olderEvents = events.filter((evt) => { const olderEvents = events.filter((evt) => {
if (isPollVoteKind(evt)) return false
if (!rootInfo) return false if (!rootInfo) return false
const matchesThread = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot) const matchesThread = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot)
if (!matchesThread) return false if (!matchesThread) return false
@ -1500,6 +1514,7 @@ function ReplyNoteList({
const shouldShowFeedItem = useCallback( const shouldShowFeedItem = useCallback(
(item: NEvent) => { (item: NEvent) => {
if (isPollVoteKind(item)) return false
if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) {
return false return false
} }

80
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 { getAmountFromInvoice } from '@/lib/lightning'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
import { tagNameEquals } from '@/lib/tag' import { tagNameEquals } from '@/lib/tag'
@ -18,51 +18,101 @@ export type TZapPollMeta = {
primaryRelay: string 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. */ /** Parse NIP-B9 kind 6969 into structured metadata. */
export function parseZapPollEvent(event: Event): TZapPollMeta | null { export function parseZapPollEvent(event: Event): TZapPollMeta | null {
if (event.kind !== ExtendedKind.ZAP_POLL) return 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 recipients: { pubkey: string; relay: string }[] = []
const withRelay: { pubkey: string; relay: string }[] = [] const withRelay: { pubkey: string; relay: string }[] = []
const pubkeyNoRelay: string[] = [] const pubkeyNoRelay: string[] = []
for (const t of pTags) { for (const t of pTags) {
const pk = t[1]?.trim().toLowerCase() const pk = t[1]?.trim().toLowerCase()
const relay = t[2]?.trim()
if (!pk || !/^[0-9a-f]{64}$/.test(pk)) continue if (!pk || !/^[0-9a-f]{64}$/.test(pk)) continue
const relay = relayHintFromPTag(t)
if (relay) { if (relay) {
const n = normalizeUrl(relay) || relay withRelay.push({ pubkey: pk, relay })
withRelay.push({ pubkey: pk, relay: n })
} else { } else {
pubkeyNoRelay.push(pk) pubkeyNoRelay.push(pk)
} }
} }
if (withRelay.length === 0 && pubkeyNoRelay.length === 0) return null
if (withRelay.length > 0) { if (withRelay.length > 0) {
recipients.push(...withRelay) 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) { for (const pk of pubkeyNoRelay) {
if (!recipients.some((r) => r.pubkey === pk)) { if (!recipients.some((r) => r.pubkey === pk)) {
recipients.push({ pubkey: pk, relay: fallbackRelay }) recipients.push({ pubkey: pk, relay: fallbackRelay })
} }
} }
} else { } 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[] = [] const options: TZapPollOption[] = []
for (const t of event.tags) { for (const t of tags) {
if (t[0] !== 'poll_option' || t[1] == null || t[2] == null) continue const name = t[0]
const idx = parseInt(t[1], 10) // `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 if (Number.isNaN(idx)) continue
options.push({ index: idx, label: t[2] }) options.push({ index: idx, label: t[2] })
} }
options.sort((a, b) => a.index - b.index) options.sort((a, b) => a.index - b.index)
if (options.length < 2) return null if (options.length < 2) return null
const vmin = event.tags.find(tagNameEquals('value_minimum'))?.[1] const vmin = tags.find(tagNameEquals('value_minimum'))?.[1]
const vmax = event.tags.find(tagNameEquals('value_maximum'))?.[1] const vmax = tags.find(tagNameEquals('value_maximum'))?.[1]
const consensus = event.tags.find(tagNameEquals('consensus_threshold'))?.[1] const consensus = tags.find(tagNameEquals('consensus_threshold'))?.[1]
const closed = event.tags.find(tagNameEquals('closed_at'))?.[1] const closed = tags.find(tagNameEquals('closed_at'))?.[1]
const valueMinimum = vmin != null && vmin !== '' ? parseInt(vmin, 10) : undefined const valueMinimum = vmin != null && vmin !== '' ? parseInt(vmin, 10) : undefined
const valueMaximum = vmax != null && vmax !== '' ? parseInt(vmax, 10) : undefined const valueMaximum = vmax != null && vmax !== '' ? parseInt(vmax, 10) : undefined

Loading…
Cancel
Save