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' @@ -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<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 nostr = useNostrOptional()
const pubkey = nostr?.pubkey ?? null
@ -97,7 +145,15 @@ export default function Poll({ event, className }: { event: Event; className?: s @@ -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 @@ -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)

5
src/components/Note/index.tsx

@ -214,6 +214,8 @@ export default function Note({ @@ -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({ @@ -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({ @@ -500,7 +503,7 @@ export default function Note({
content = (
<>
{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) {

1
src/components/NoteCard/MainNoteCard.tsx

@ -102,6 +102,7 @@ export default function MainNoteCard({ @@ -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}

17
src/components/ReplyNoteList/index.tsx

@ -311,6 +311,11 @@ function replyMatchesThreadForList( @@ -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<NEvent, 'kind'>): 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({ @@ -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({ @@ -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({ @@ -984,6 +990,7 @@ function ReplyNoteList({
const onNewReply = useCallback(
(evt: NEvent) => {
if (isPollVoteKind(evt)) return
if (
shouldHideThreadResponseEvent(
evt,
@ -1217,6 +1224,7 @@ function ReplyNoteList({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -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({ @@ -1500,6 +1514,7 @@ function ReplyNoteList({
const shouldShowFeedItem = useCallback(
(item: NEvent) => {
if (isPollVoteKind(item)) return false
if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) {
return false
}

80
src/lib/zap-poll.ts

@ -1,4 +1,4 @@ @@ -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 = { @@ -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

Loading…
Cancel
Save