Browse Source

remove zap polls

imwald
Silberengel 4 weeks ago
parent
commit
d51e83c8ea
  1. 4
      package-lock.json
  2. 2
      package.json
  3. 1
      src/components/KindFilter/index.tsx
  4. 304
      src/components/Note/ZapPoll.tsx
  5. 15
      src/components/Note/index.tsx
  6. 3
      src/components/NoteCard/MainNoteCard.tsx
  7. 4
      src/components/NoteCard/index.tsx
  8. 4
      src/components/NoteStats/index.tsx
  9. 43
      src/components/ReplyNoteList/index.tsx
  10. 1
      src/constants.ts
  11. 138
      src/hooks/useZapPollTally.tsx
  12. 1
      src/lib/note-renderable-kinds.ts
  13. 14
      src/lib/thread-interaction-req.ts
  14. 1
      src/lib/wisp-trending-relay.ts
  15. 20
      src/lib/zap-poll-tally-cache.ts
  16. 420
      src/lib/zap-poll.ts
  17. 2
      src/pages/secondary/NotePage/index.tsx
  18. 132
      src/services/lightning.service.ts
  19. 5
      src/services/local-storage.service.ts
  20. 1
      src/services/mention-event-search.service.ts

4
package-lock.json generated

@ -1,12 +1,12 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.12.1", "version": "23.13.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "imwald", "name": "imwald",
"version": "23.12.1", "version": "23.13.0",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@asciidoctor/core": "^3.0.4", "@asciidoctor/core": "^3.0.4",

2
package.json

@ -1,6 +1,6 @@
{ {
"name": "imwald", "name": "imwald",
"version": "23.12.1", "version": "23.13.0",
"description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery", "description": "Imwald — a user-friendly Nostr client focused on relay feed browsing, publications, and relay discovery",
"private": true, "private": true,
"type": "module", "type": "module",

1
src/components/KindFilter/index.tsx

@ -21,7 +21,6 @@ const KIND_FILTER_OPTIONS = [
{ kindGroup: [kinds.LongFormArticle, ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Articles' }, { kindGroup: [kinds.LongFormArticle, ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Articles' },
{ kindGroup: [kinds.Highlights], label: 'Highlights' }, { kindGroup: [kinds.Highlights], label: 'Highlights' },
{ kindGroup: [ExtendedKind.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.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },
{ kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' }, { kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' },
{ kindGroup: [...NIP71_VIDEO_KINDS], label: 'Video Posts' }, { kindGroup: [...NIP71_VIDEO_KINDS], label: 'Video Posts' },

304
src/components/Note/ZapPoll.tsx

@ -1,304 +0,0 @@
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue
} from '@/components/ui/select'
import { cn } from '@/lib/utils'
import {
isZapPollPastDeadline,
isZapPollVoteEligible,
userHasZappedPoll,
userZapPollVoteOption
} from '@/lib/zap-poll'
import { useZapPollMeta, useZapPollTally } from '@/hooks/useZapPollTally'
import { useNostrOptional } from '@/providers/nostr-context'
import lightning from '@/services/lightning.service'
import { Zap } from 'lucide-react'
import { Event } from 'nostr-tools'
import { useEffect, useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next'
import { toast } from 'sonner'
import dayjs from 'dayjs'
export default function ZapPoll({
event,
className,
voteHighlightOptionIndex
}: {
event: Event
className?: string
/** When showing this poll because the profile user voted, highlight that option. */
voteHighlightOptionIndex?: number
}) {
const { t } = useTranslation()
const nostr = useNostrOptional()
const pubkey = nostr?.pubkey ?? null
const meta = useZapPollMeta(event)
/** Same pubkey can appear on multiple `p` tags; Select keys/values must be unique. */
const payToRecipients = useMemo(() => {
if (!meta) return []
const seen = new Set<string>()
return meta.recipients.filter((r) => {
if (seen.has(r.pubkey)) return false
seen.add(r.pubkey)
return true
})
}, [meta])
const { receipts, tally, loading, error, reload } = useZapPollTally(event, meta)
const [recipientPk, setRecipientPk] = useState<string>('')
const [optionIndex, setOptionIndex] = useState<number | null>(null)
const [sats, setSats] = useState<number>(21)
const [zapping, setZapping] = useState(false)
/** Show sat/zap breakdown without having voted (card UX). */
const [tallyRevealed, setTallyRevealed] = useState(false)
useEffect(() => {
if (meta?.valueMinimum != null) {
setSats(Math.max(meta.valueMinimum, 1))
} else {
setSats(21)
}
}, [meta?.valueMinimum, event.id])
const defaultRecipient = payToRecipients[0]?.pubkey ?? ''
const effectiveRecipient = recipientPk || defaultRecipient
const closed = meta ? isZapPollPastDeadline(event, meta) : false
const viewerZapped = pubkey && meta ? userHasZappedPoll(event.id, pubkey, receipts) : false
const myVoteOption =
pubkey && meta ? userZapPollVoteOption(event, meta, pubkey, receipts) : undefined
const showTally =
!!meta &&
(closed || viewerZapped || event.pubkey === pubkey || tallyRevealed)
/** When results are visible, list options by total sats (largest first). */
const optionsDisplayOrder = useMemo(() => {
if (!meta) return []
if (!showTally || !tally) return meta.options
return [...meta.options].sort((a, b) => {
const sa = tally.satsByOption.get(a.index) ?? 0
const sb = tally.satsByOption.get(b.index) ?? 0
if (sb !== sa) return sb - sa
return a.index - b.index
})
}, [meta, showTally, tally])
const satsBounds = useMemo(() => {
if (!meta) return { min: 1, max: undefined as number | undefined }
return {
min: Math.max(1, meta.valueMinimum ?? 1),
max: meta.valueMaximum
}
}, [meta])
if (!meta) {
return (
<div className={cn('text-sm text-muted-foreground rounded-lg border border-border p-3', className)}>
{t('Invalid zap poll')}
</div>
)
}
const handleZapVote = async () => {
if (!pubkey) {
nostr?.startLogin()
return
}
if (optionIndex === null) {
toast.error(t('Select an option'))
return
}
const eligible = isZapPollVoteEligible(event, meta, pubkey, sats)
if (!eligible.ok) {
toast.error(eligible.reason)
return
}
setZapping(true)
try {
const result = await lightning.zapPollVote(
pubkey,
event,
meta,
effectiveRecipient,
optionIndex,
sats,
'',
undefined
)
if (result) {
toast.success(t('Zap sent'))
await reload()
}
} catch (e) {
toast.error((e as Error).message)
} finally {
setZapping(false)
}
}
return (
<div className={cn('rounded-lg border border-border bg-card/40 p-3 space-y-3', className)}>
<div className="flex items-center gap-2 text-sm font-medium text-amber-700 dark:text-amber-400">
<Zap className="size-4 shrink-0" aria-hidden />
<span>{t('Zap poll (paid votes)')}</span>
</div>
{voteHighlightOptionIndex != null && (
<p className="text-xs text-muted-foreground">{t('You voted on this poll (zap receipt)')}</p>
)}
{meta.closedAt && (
<p className="text-xs text-muted-foreground">
{closed
? t('Poll closed {{time}}', {
time: dayjs.unix(meta.closedAt).format('lll')
})
: t('Closes {{time}}', { time: dayjs.unix(meta.closedAt).format('lll') })}
</p>
)}
{!closed && (meta.valueMinimum != null || meta.valueMaximum != null) && (
<p className="text-xs text-muted-foreground">
{t('Vote size')}:{' '}
{meta.valueMinimum != null && meta.valueMaximum != null
? meta.valueMinimum === meta.valueMaximum
? t('{{n}} sats (fixed)', { n: meta.valueMinimum })
: t('{{min}}–{{max}} sats', { min: meta.valueMinimum, max: meta.valueMaximum })
: meta.valueMinimum != null
? t('≥ {{n}} sats', { n: meta.valueMinimum })
: t('≤ {{n}} sats', { n: meta.valueMaximum! })}
</p>
)}
{loading ? (
<p className="text-xs text-muted-foreground">{t('Loading tally…')}</p>
) : null}
{error && <p className="text-xs text-destructive">{error}</p>}
{!loading && showTally && tally && tally.totalSats === 0 && (
<p className="text-xs text-muted-foreground">{t('Zap poll no votes yet')}</p>
)}
{meta && !closed && !showTally && (
<Button
type="button"
variant="outline"
size="sm"
className="w-full"
onClick={(e) => {
e.stopPropagation()
setTallyRevealed(true)
void reload()
}}
>
{t('See results')}
</Button>
)}
<div className="space-y-2">
{optionsDisplayOrder.map((opt) => {
const satsOpt = tally?.satsByOption.get(opt.index) ?? 0
const pct = tally && tally.totalSats > 0 ? (100 * satsOpt) / tally.totalSats : 0
const counts = tally?.receiptCountByOption.get(opt.index) ?? 0
const isMine =
myVoteOption === opt.index || voteHighlightOptionIndex === opt.index
return (
<div
key={opt.index}
className={cn(
'relative overflow-hidden rounded-md border border-border/80',
isMine && 'ring-2 ring-primary/50'
)}
>
{showTally && tally && (
<div
className="absolute inset-y-0 left-0 bg-primary/15"
style={{ width: `${pct}%` }}
/>
)}
<div className="relative flex items-center justify-between gap-2 px-3 py-2">
<span className="text-sm break-words">{opt.label}</span>
{showTally && tally && (
<span className="text-xs text-muted-foreground shrink-0 tabular-nums">
{`${Math.round(satsOpt)} sats · ${t('{{n}} zaps', { n: counts })} (${pct.toFixed(0)}%)`}
</span>
)}
</div>
</div>
)
})}
</div>
{meta.consensusThreshold != null && showTally && tally && (
<p className="text-xs text-muted-foreground">
{t('Consensus threshold')}: {meta.consensusThreshold}%
</p>
)}
{!closed && pubkey && event.pubkey !== pubkey && (
<div className="space-y-2 border-t border-border pt-3">
<div className="space-y-1">
<Label className="text-xs">{t('Pay to')}</Label>
<Select
value={effectiveRecipient}
onValueChange={(v) => setRecipientPk(v)}
>
<SelectTrigger className="h-9">
<SelectValue placeholder={t('Recipient')} />
</SelectTrigger>
<SelectContent>
{payToRecipients.map((r) => (
<SelectItem key={r.pubkey} value={r.pubkey}>
{r.pubkey.slice(0, 12)}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('Option')}</Label>
<Select
value={optionIndex !== null ? String(optionIndex) : ''}
onValueChange={(v) => setOptionIndex(parseInt(v, 10))}
>
<SelectTrigger className="h-9">
<SelectValue placeholder={t('Select option')} />
</SelectTrigger>
<SelectContent>
{meta.options.map((o) => (
<SelectItem key={o.index} value={String(o.index)}>
{o.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<div className="space-y-1">
<Label className="text-xs">{t('Sats')}</Label>
<Input
type="number"
min={satsBounds.min}
max={satsBounds.max}
value={sats}
onChange={(e) => setSats(parseInt(e.target.value, 10) || 0)}
className="h-9"
/>
</div>
<Button
type="button"
size="sm"
className="w-full gap-2"
disabled={zapping || optionIndex === null}
onClick={() => void handleZapVote()}
>
<Zap className="size-4" />
{zapping ? t('Zapping…') : t('Vote with zap')}
</Button>
</div>
)}
{showTally && (
<Button type="button" variant="ghost" size="sm" className="text-xs" onClick={() => void reload()}>
{t('Refresh tally')}
</Button>
)}
</div>
)
}

15
src/components/Note/index.tsx

@ -71,7 +71,6 @@ import MutedNote from './MutedNote'
import NsfwNote from './NsfwNote' import NsfwNote from './NsfwNote'
import PictureNote from './PictureNote' import PictureNote from './PictureNote'
import Poll from './Poll' import Poll from './Poll'
import ZapPoll from './ZapPoll'
import NotificationEventCard from './NotificationEventCard' import NotificationEventCard from './NotificationEventCard'
import ReactionEmojiDisplay from './ReactionEmojiDisplay' import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote' import UnknownNote from './UnknownNote'
@ -224,7 +223,6 @@ export default function Note({
/** From {@link MainNoteCard}: embedded cards need eager poll results (viewport IO often misses nested scrollers). */ /** From {@link MainNoteCard}: embedded cards need eager poll results (viewport IO often misses nested scrollers). */
embedded, embedded,
fullCalendarInvite, fullCalendarInvite,
zapPollVoteHighlightOption,
nip84HighlightEvents, nip84HighlightEvents,
deferAuthorAvatar = false, deferAuthorAvatar = false,
pinned = false pinned = false
@ -241,8 +239,6 @@ export default function Note({
pinned?: boolean pinned?: 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. */
zapPollVoteHighlightOption?: number
/** Kind-9802 events that cite this note; when spans match {@link displayEvent.content}, render green marks (note page OP). */ /** Kind-9802 events that cite this note; when spans match {@link displayEvent.content}, render green marks (note page OP). */
nip84HighlightEvents?: Event[] nip84HighlightEvents?: Event[]
/** When true, defer remote profile avatars until near-viewport (dense lists e.g. merged NIP-50 search). */ /** When true, defer remote profile avatars until near-viewport (dense lists e.g. merged NIP-50 search). */
@ -533,17 +529,6 @@ export default function Note({
<Poll className="mt-2" event={displayEvent} eagerFetchResults={Boolean(embedded)} /> <Poll className="mt-2" event={displayEvent} eagerFetchResults={Boolean(embedded)} />
</> </>
) )
} else if (event.kind === ExtendedKind.ZAP_POLL) {
content = (
<>
{renderEventContent({ hideMetadata: true })}
<ZapPoll
className="mt-2"
event={displayEvent}
voteHighlightOptionIndex={zapPollVoteHighlightOption}
/>
</>
)
} else if (event.kind === ExtendedKind.VOICE) { } else if (event.kind === ExtendedKind.VOICE) {
content = <AudioPlayer className="mt-2" src={event.content} /> content = <AudioPlayer className="mt-2" src={event.content} />
} else if (event.kind === ExtendedKind.VOICE_COMMENT) { } else if (event.kind === ExtendedKind.VOICE_COMMENT) {

3
src/components/NoteCard/MainNoteCard.tsx

@ -21,7 +21,6 @@ export default function MainNoteCard({
originalNoteId, originalNoteId,
pinned = false, pinned = false,
hideParentNotePreview = false, hideParentNotePreview = false,
zapPollVoteHighlightOption,
bottomNoteLabel, bottomNoteLabel,
showFull = false, showFull = false,
fetchNoteStatsIfMissing = true, fetchNoteStatsIfMissing = true,
@ -37,7 +36,6 @@ export default function MainNoteCard({
pinned?: boolean pinned?: boolean
/** Hide the parent note preview (e.g. when showing quotes of current note). */ /** Hide the parent note preview (e.g. when showing quotes of current note). */
hideParentNotePreview?: boolean hideParentNotePreview?: boolean
zapPollVoteHighlightOption?: number
bottomNoteLabel?: string bottomNoteLabel?: string
showFull?: boolean showFull?: boolean
/** When false, skip relay-backed stats prefetch (e.g. merged NIP-50 search lists). */ /** When false, skip relay-backed stats prefetch (e.g. merged NIP-50 search lists). */
@ -121,7 +119,6 @@ export default function MainNoteCard({
originalNoteId={originalNoteId} originalNoteId={originalNoteId}
disableClick={true} disableClick={true}
hideParentNotePreview={hideParentNotePreview} hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption}
showFull={showFull} showFull={showFull}
deferAuthorAvatar={deferAuthorAvatar} deferAuthorAvatar={deferAuthorAvatar}
pinned={pinned} pinned={pinned}

4
src/components/NoteCard/index.tsx

@ -15,7 +15,6 @@ const NoteCard = memo(function NoteCard({
filterMutedNotes = true, filterMutedNotes = true,
pinned = false, pinned = false,
hideParentNotePreview = false, hideParentNotePreview = false,
zapPollVoteHighlightOption,
bottomNoteLabel, bottomNoteLabel,
fetchNoteStatsIfMissing = true, fetchNoteStatsIfMissing = true,
deferAuthorAvatar = true, deferAuthorAvatar = true,
@ -27,7 +26,6 @@ const NoteCard = memo(function NoteCard({
pinned?: boolean pinned?: boolean
/** When true, hide the parent/root note preview (e.g. when showing quotes of the current note). */ /** When true, hide the parent/root note preview (e.g. when showing quotes of the current note). */
hideParentNotePreview?: boolean hideParentNotePreview?: boolean
zapPollVoteHighlightOption?: number
/** Optional label rendered at the bottom of the card (e.g. why this event is in a composed feed). */ /** Optional label rendered at the bottom of the card (e.g. why this event is in a composed feed). */
bottomNoteLabel?: string bottomNoteLabel?: string
fetchNoteStatsIfMissing?: boolean fetchNoteStatsIfMissing?: boolean
@ -68,7 +66,6 @@ const NoteCard = memo(function NoteCard({
className={className} className={className}
pinned={pinned} pinned={pinned}
hideParentNotePreview={hideParentNotePreview} hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption}
bottomNoteLabel={bottomNoteLabel} bottomNoteLabel={bottomNoteLabel}
fetchNoteStatsIfMissing={fetchNoteStatsIfMissing} fetchNoteStatsIfMissing={fetchNoteStatsIfMissing}
deferAuthorAvatar={deferAuthorAvatar} deferAuthorAvatar={deferAuthorAvatar}
@ -84,7 +81,6 @@ const NoteCard = memo(function NoteCard({
prevProps.filterMutedNotes === nextProps.filterMutedNotes && prevProps.filterMutedNotes === nextProps.filterMutedNotes &&
prevProps.pinned === nextProps.pinned && prevProps.pinned === nextProps.pinned &&
prevProps.hideParentNotePreview === nextProps.hideParentNotePreview && prevProps.hideParentNotePreview === nextProps.hideParentNotePreview &&
prevProps.zapPollVoteHighlightOption === nextProps.zapPollVoteHighlightOption &&
prevProps.bottomNoteLabel === nextProps.bottomNoteLabel && prevProps.bottomNoteLabel === nextProps.bottomNoteLabel &&
prevProps.fetchNoteStatsIfMissing === nextProps.fetchNoteStatsIfMissing && prevProps.fetchNoteStatsIfMissing === nextProps.fetchNoteStatsIfMissing &&
prevProps.deferAuthorAvatar === nextProps.deferAuthorAvatar && prevProps.deferAuthorAvatar === nextProps.deferAuthorAvatar &&

4
src/components/NoteStats/index.tsx

@ -66,8 +66,6 @@ export default function NoteStats({
const statsRelayFetchTier = isRssArticleRoot ? relayMergeTier : hintRelays.length > 0 ? 1 : 0 const statsRelayFetchTier = isRssArticleRoot ? relayMergeTier : hintRelays.length > 0 ? 1 : 0
const statsRelaysRef = useRef(statsRelays) const statsRelaysRef = useRef(statsRelays)
statsRelaysRef.current = statsRelays statsRelaysRef.current = statsRelays
const isZapPoll = event.kind === ExtendedKind.ZAP_POLL
const shouldDeferStatsFetch = const shouldDeferStatsFetch =
deferFetchUntilNearViewport ?? (fetchIfNotExisting && !foregroundStats) deferFetchUntilNearViewport ?? (fetchIfNotExisting && !foregroundStats)
const containerRef = useRef<HTMLDivElement>(null) const containerRef = useRef<HTMLDivElement>(null)
@ -110,7 +108,7 @@ export default function NoteStats({
isReplyToDiscussion={isReplyToDiscussion} isReplyToDiscussion={isReplyToDiscussion}
useIconOnlyLikeTrigger={useIconOnlyLikeTrigger} useIconOnlyLikeTrigger={useIconOnlyLikeTrigger}
/> />
{!isRssArticleRoot && !isZapPoll && ( {!isRssArticleRoot && (
<ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} /> <ZapButtonWithStats event={event} hideCount={hideInteractions} noteStats={noteStats} />
)} )}
</> </>

43
src/components/ReplyNoteList/index.tsx

@ -302,11 +302,6 @@ function isPollVoteKind(evt: Pick<NEvent, 'kind'>): boolean {
return evt.kind === ExtendedKind.POLL_RESPONSE return evt.kind === ExtendedKind.POLL_RESPONSE
} }
/** Zap-poll (6969): kind 9735 receipts are paid votes — hide from “Antworten” so amounts/options are not tied to identities here. */
function isZapPollThreadZapReceipt(evt: Pick<NEvent, 'kind'>, op: Pick<NEvent, 'kind'>): boolean {
return op.kind === ExtendedKind.ZAP_POLL && evt.kind === kinds.Zap
}
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')
@ -418,7 +413,6 @@ function ReplyNoteList({
events.forEach((evt) => { events.forEach((evt) => {
if (replyIdSet.has(evt.id)) return if (replyIdSet.has(evt.id)) return
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) return
if ( if (
shouldHideThreadResponseEvent( shouldHideThreadResponseEvent(
evt, evt,
@ -450,10 +444,7 @@ function ReplyNoteList({
const { zaps: zapsPartitioned, nonZaps } = partitionZapReceipts(replyEvents) const { zaps: zapsPartitioned, nonZaps } = partitionZapReceipts(replyEvents)
const zaps = const zaps = filterZapReceiptsByReplyThreshold(zapsPartitioned, zapReplyThreshold)
event.kind === ExtendedKind.ZAP_POLL
? []
: filterZapReceiptsByReplyThreshold(zapsPartitioned, zapReplyThreshold)
const replyScoreById = const replyScoreById =
sort === 'top' || sort === 'controversial' || sort === 'most-zapped' sort === 'top' || sort === 'controversial' || sort === 'most-zapped'
? new Map( ? new Map(
@ -568,7 +559,7 @@ function ReplyNoteList({
/** Quotes + time-sorted feeds must not interleave zap receipts chronologically */ /** Quotes + time-sorted feeds must not interleave zap receipts chronologically */
const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => { const zapsThenTimeSorted = (merged: NEvent[], direction: 'asc' | 'desc') => {
const { zaps, nonZaps } = partitionZapReceipts(merged) const { zaps, nonZaps } = partitionZapReceipts(merged)
const zapsShown = event.kind === ExtendedKind.ZAP_POLL ? [] : zaps const zapsShown = zaps
const sortedNon = [...nonZaps].sort((a, b) => const sortedNon = [...nonZaps].sort((a, b) =>
direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at direction === 'asc' ? a.created_at - b.created_at : b.created_at - a.created_at
) )
@ -580,7 +571,7 @@ function ReplyNoteList({
// E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs) // E/A: zaps (sats desc) → thread replies (1 / 1111 / 1244, excluding #q-only) → tail (quotes, highlights, long-form refs)
if (rootInfo?.type === 'E' || rootInfo?.type === 'A') { if (rootInfo?.type === 'E' || rootInfo?.type === 'A') {
const { zaps, nonZaps } = partitionZapReceipts(replies) const { zaps, nonZaps } = partitionZapReceipts(replies)
const zapsShown = event.kind === ExtendedKind.ZAP_POLL ? [] : zaps const zapsShown = zaps
const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo)) const middle = nonZaps.filter((e) => !isEaThreadTailBacklinkCandidate(e, rootInfo))
const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo)) const tailFromReplies = nonZaps.filter((e) => isEaThreadTailBacklinkCandidate(e, rootInfo))
const tailSeen = new Set<string>() const tailSeen = new Set<string>()
@ -598,7 +589,7 @@ function ReplyNoteList({
// Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A // Web article / URL thread (NIP-22): same zaps → middle → tail layout as E/A
if (rootInfo?.type === 'I') { if (rootInfo?.type === 'I') {
const { zaps, nonZaps } = partitionZapReceipts(replies) const { zaps, nonZaps } = partitionZapReceipts(replies)
const zapsShownI = event.kind === ExtendedKind.ZAP_POLL ? [] : zaps const zapsShownI = zaps
const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind)) const middle = nonZaps.filter((e) => !isWebThreadTailKind(e.kind))
const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind)) const tailFromReplies = nonZaps.filter((e) => isWebThreadTailKind(e.kind))
const tailSeen = new Set<string>() const tailSeen = new Set<string>()
@ -919,7 +910,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) && !isPollVoteKind(ev) && !isZapPollThreadZapReceipt(ev, event)) { 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)
@ -960,7 +951,6 @@ function ReplyNoteList({
const onNewReply = useCallback( const onNewReply = useCallback(
(evt: NEvent) => { (evt: NEvent) => {
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) return
if (isNip18RepostKind(evt.kind)) { if (isNip18RepostKind(evt.kind)) {
if ( if (
rootInfo && rootInfo &&
@ -1025,12 +1015,8 @@ function ReplyNoteList({
// Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip // Session LRU (timeline / note-stats / prior panels): thread replies before relay round-trip
if (rootInfo.type === 'E' || rootInfo.type === 'A') { if (rootInfo.type === 'E' || rootInfo.type === 'A') {
const fromSession = eventService.getSessionThreadInteractionEvents(rootInfo) const fromSession = eventService.getSessionThreadInteractionEvents(rootInfo)
const fromSessionForUi = if (fromSession.length > 0) {
event.kind === ExtendedKind.ZAP_POLL addReplies(fromSession)
? fromSession.filter((e) => !isZapPollThreadZapReceipt(e, event))
: fromSession
if (fromSessionForUi.length > 0) {
addReplies(fromSessionForUi)
} }
} }
@ -1039,11 +1025,7 @@ function ReplyNoteList({
const hasCache = cachedData !== null const hasCache = cachedData !== null
if (hasCache && cachedData) { if (hasCache && cachedData) {
const cachedForUi = addReplies(cachedData)
event.kind === ExtendedKind.ZAP_POLL
? cachedData.filter((e) => !isZapPollThreadZapReceipt(e, event))
: cachedData
addReplies(cachedForUi)
setLoading(false) setLoading(false)
} else { } else {
setLoading(true) setLoading(true)
@ -1121,7 +1103,6 @@ function ReplyNoteList({
? (evt: NEvent) => { ? (evt: NEvent) => {
if (fetchGeneration !== replyFetchGenRef.current) return if (fetchGeneration !== replyFetchGenRef.current) return
if (isPollVoteKind(evt)) return if (isPollVoteKind(evt)) return
if (isZapPollThreadZapReceipt(evt, event)) 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
@ -1148,7 +1129,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 if (isPollVoteKind(evt)) return false
if (isZapPollThreadZapReceipt(evt, event)) return false false
const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromBatch) const match = replyMatchesThreadForList(evt, event, rootInfo, isDiscussionRoot, threadWalkFromBatch)
if (!match) return false if (!match) return false
return !shouldHideThreadResponseEvent( return !shouldHideThreadResponseEvent(
@ -1170,10 +1151,7 @@ function ReplyNoteList({
logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only') logger.warn('[ReplyNoteList] Cache returned null after store, using fetched replies only')
mergedForUi = regularReplies mergedForUi = regularReplies
} else { } else {
mergedForUi = mergedForUi = mergedCachedReplies
event.kind === ExtendedKind.ZAP_POLL
? mergedCachedReplies.filter((e) => !isZapPollThreadZapReceipt(e, event))
: mergedCachedReplies
} }
const repliesForStatsPrime = mergedForUi const repliesForStatsPrime = mergedForUi
addReplies(mergedForUi) addReplies(mergedForUi)
@ -1420,7 +1398,6 @@ function ReplyNoteList({
const shouldShowFeedItem = useCallback( const shouldShowFeedItem = useCallback(
(item: NEvent) => { (item: NEvent) => {
if (isPollVoteKind(item)) return false if (isPollVoteKind(item)) return false
if (isZapPollThreadZapReceipt(item, event)) return false
if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) { if (shouldHideThreadResponseEvent(item, mutePubkeySet, hideContentMentioningMutedUsers)) {
return false return false
} }

1
src/constants.ts

@ -880,7 +880,6 @@ export const SUPPORTED_KINDS = [
ExtendedKind.SHORT_VIDEO, ExtendedKind.SHORT_VIDEO,
ExtendedKind.VIDEO_ADDRESSABLE, ExtendedKind.VIDEO_ADDRESSABLE,
ExtendedKind.POLL, ExtendedKind.POLL,
ExtendedKind.ZAP_POLL,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE, ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,

138
src/hooks/useZapPollTally.tsx

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

1
src/lib/note-renderable-kinds.ts

@ -22,7 +22,6 @@ const RENDERABLE_NOTE_KINDS = new Set<number>([
ExtendedKind.CITATION_EXTERNAL, ExtendedKind.CITATION_EXTERNAL,
ExtendedKind.CITATION_HARDCOPY, ExtendedKind.CITATION_HARDCOPY,
ExtendedKind.CITATION_PROMPT, ExtendedKind.CITATION_PROMPT,
ExtendedKind.ZAP_POLL,
ExtendedKind.WEB_BOOKMARK ExtendedKind.WEB_BOOKMARK
]) ])

14
src/lib/thread-interaction-req.ts

@ -29,7 +29,6 @@ export type BuildThreadInteractionFiltersInput = {
*/ */
export function buildThreadInteractionFilters(input: BuildThreadInteractionFiltersInput): Filter[] { export function buildThreadInteractionFilters(input: BuildThreadInteractionFiltersInput): Filter[] {
const { root, opEventKind, limit } = input const { root, opEventKind, limit } = input
const isZapPoll = opEventKind === ExtendedKind.ZAP_POLL
const kindsNoteCommentVoiceZap = sortedUniqueKinds([ const kindsNoteCommentVoiceZap = sortedUniqueKinds([
kinds.ShortTextNote, kinds.ShortTextNote,
@ -37,17 +36,12 @@ export function buildThreadInteractionFilters(input: BuildThreadInteractionFilte
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,
kinds.Zap kinds.Zap
]) ])
const kindsNoteCommentVoice = sortedUniqueKinds([ const kindsPrimaryThread = kindsNoteCommentVoiceZap
kinds.ShortTextNote, const kindsUpperEThread = sortedUniqueKinds([
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE_COMMENT ExtendedKind.VOICE_COMMENT,
kinds.Zap
]) ])
const kindsPrimaryThread = isZapPoll ? kindsNoteCommentVoice : kindsNoteCommentVoiceZap
const kindsUpperEThread = sortedUniqueKinds(
isZapPoll
? [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT]
: [ExtendedKind.COMMENT, ExtendedKind.VOICE_COMMENT, kinds.Zap]
)
const kindsOnETag = sortedUniqueKinds([ const kindsOnETag = sortedUniqueKinds([
...kindsPrimaryThread, ...kindsPrimaryThread,

1
src/lib/wisp-trending-relay.ts

@ -22,7 +22,6 @@ export const WISP_TRENDING_FEED_KINDS: readonly number[] = [
1, 1,
6, 6,
1068, 1068,
6969,
30023, 30023,
ExtendedKind.PICTURE, ExtendedKind.PICTURE,
ExtendedKind.VIDEO, ExtendedKind.VIDEO,

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

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

420
src/lib/zap-poll.ts

@ -1,420 +0,0 @@
import { ExtendedKind, FAST_READ_RELAY_URLS } 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'
import { kinds } from 'nostr-tools'
export type TZapPollOption = { index: number; label: string }
export type TZapPollMeta = {
options: TZapPollOption[]
recipients: { pubkey: string; relay: string }[]
valueMinimum?: number
valueMaximum?: number
consensusThreshold?: number
closedAt?: number
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 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()
if (!pk || !/^[0-9a-f]{64}$/.test(pk)) continue
const relay = relayHintFromPTag(t)
if (relay) {
withRelay.push({ pubkey: pk, relay })
} else {
pubkeyNoRelay.push(pk)
}
}
if (withRelay.length > 0) {
recipients.push(...withRelay)
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 {
// 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 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 = 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
let consensusThreshold =
consensus != null && consensus !== '' ? parseInt(consensus, 10) : undefined
if (consensusThreshold === 0) consensusThreshold = undefined
let closedAt = closed != null && closed !== '' ? parseInt(closed, 10) : undefined
if (closedAt != null && closedAt <= event.created_at) closedAt = undefined
return {
options,
recipients,
valueMinimum: Number.isFinite(valueMinimum) ? valueMinimum : undefined,
valueMaximum: Number.isFinite(valueMaximum) ? valueMaximum : undefined,
consensusThreshold: Number.isFinite(consensusThreshold) ? consensusThreshold : undefined,
closedAt: Number.isFinite(closedAt) ? closedAt : undefined,
primaryRelay: recipients[0]!.relay
}
}
export function isZapPollPastDeadline(_poll: Event, meta: TZapPollMeta, nowSec = Math.floor(Date.now() / 1000)): boolean {
if (!meta.closedAt) return false
return nowSec > meta.closedAt
}
export function isZapPollVoteEligible(
poll: Event,
meta: TZapPollMeta,
voterPubkey: string,
amountSats: number
): { ok: true } | { ok: false; reason: string } {
const v = voterPubkey.trim().toLowerCase()
if (v === poll.pubkey) return { ok: false, reason: 'Poll authors cannot vote on their own poll' }
if (meta.closedAt && Math.floor(Date.now() / 1000) > meta.closedAt) {
return { ok: false, reason: 'Poll is closed' }
}
if (meta.valueMinimum != null && amountSats < meta.valueMinimum) {
return { ok: false, reason: `Minimum ${meta.valueMinimum} sats` }
}
if (meta.valueMaximum != null && amountSats > meta.valueMaximum) {
return { ok: false, reason: `Maximum ${meta.valueMaximum} sats` }
}
return { ok: true }
}
/** Build kind 9734 template for a NIP-B9 vote (after validation). */
export function buildZapPollVoteRequestTemplate(params: {
poll: Event
meta: TZapPollMeta
recipientPubkey: string
optionIndex: number
amountMillisats: number
relays: string[]
comment?: string
}): EventTemplate {
const { poll, meta, recipientPubkey, optionIndex, amountMillisats, relays, comment } = params
const relay = meta.primaryRelay
const pk = recipientPubkey.trim().toLowerCase()
const tags: string[][] = [
['p', pk, relay],
['e', poll.id, relay],
['relays', ...relays],
['amount', String(amountMillisats)],
['k', '6969'],
['poll_option', String(optionIndex)]
]
return {
kind: ExtendedKind.ZAP_REQUEST,
created_at: Math.round(Date.now() / 1000),
content: comment ?? '',
tags
}
}
export type TZapPollTally = {
satsByOption: Map<number, number>
totalSats: number
receiptCountByOption: Map<number, number>
}
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(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 && String(t[1]).length > 0)
if (!k) return undefined
return String(k[1])
}
/**
* NIP-57 `k` is often missing; some clients wrongly send `1` when zapping a poll.
* We only reject kinds that clearly point at another event class (not exhaustive).
*/
function zapTargetKindAllowsPollTally(tags: string[][] | undefined): boolean {
const k = getKindFromZapRequestTags(tags)
if (k == null || k === '') return true
if (k === '6969' || k === String(ExtendedKind.ZAP_POLL)) return true
if (k === '1' || k === String(kinds.ShortTextNote)) return true
return false
}
function normalizeZapRequestPTagPubkey(raw: string | undefined): string | undefined {
if (!raw) return undefined
const pk = userIdToPubkey(raw).trim().toLowerCase()
return /^[0-9a-f]{64}$/.test(pk) ? pk : undefined
}
/** Every `p` on the embedded zap request (some clients put author first, LN recipient second). */
function zapRequestPayeePubkeys(tags: string[][] | undefined): string[] {
if (!tags) return []
const out: string[] = []
const seen = new Set<string>()
for (const t of tags) {
if (t[0] !== 'p' || !t[1]) continue
const pk = normalizeZapRequestPTagPubkey(t[1])
if (!pk || seen.has(pk)) continue
seen.add(pk)
out.push(pk)
}
return out
}
/**
* Resolve vote option: explicit `poll_option` tag, or infer from which poll candidate (`p`) was paid.
* Matches clients (e.g. Primal) that omit `poll_option` but pay the options pubkey.
*/
export function extractVoteOptionFromZapRequest(
poll: Event,
meta: TZapPollMeta,
tags: string[][] | undefined
): number | undefined {
const payees = zapRequestPayeePubkeys(tags)
if (payees.length === 0) return undefined
const payeeSet = new Set(payees)
const pollAuthor = poll.pubkey.trim().toLowerCase()
const paidAuthor = payeeSet.has(pollAuthor)
const hasCandidatePayee = meta.recipients.some((r) => payeeSet.has(r.pubkey))
const explicit = getPollOptionFromZapRequestTags(tags)
const explicitOk =
explicit !== undefined && meta.options.some((o) => o.index === explicit) ? explicit : undefined
if (explicitOk !== undefined && (paidAuthor || hasCandidatePayee)) {
return explicitOk
}
const j = meta.recipients.findIndex((r) => payeeSet.has(r.pubkey))
if (j < 0 || j >= meta.options.length) return undefined
return meta.options[j]!.index
}
/**
* Tally NIP-B9 results from zap receipts (kind 9735) per NIP-B9 rules (sats only).
*/
export function tallyZapPollFromReceipts(poll: Event, meta: TZapPollMeta, receipts: Event[]): TZapPollTally {
const satsByOption = new Map<number, number>()
const receiptCountByOption = new Map<number, number>()
const equalMinMax =
meta.valueMinimum != null &&
meta.valueMaximum != null &&
meta.valueMinimum === meta.valueMaximum
const oneVotePerOptionPerUser = equalMinMax
const seenUserOption = new Set<string>()
let totalSats = 0
for (const opt of meta.options) {
satsByOption.set(opt.index, 0)
receiptCountByOption.set(opt.index, 0)
}
for (const r of receipts) {
if (r.kind !== kinds.Zap) continue
const desc = r.tags.find(tagNameEquals('description'))?.[1]
if (!desc) continue
let zapReq: { pubkey?: string; tags?: string[][] }
try {
zapReq = JSON.parse(desc) as { pubkey?: string; tags?: string[][] }
} catch {
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 optIdx = extractVoteOptionFromZapRequest(poll, meta, zapReq.tags)
if (optIdx === undefined || !satsByOption.has(optIdx)) continue
const bolt11 = r.tags.find(tagNameEquals('bolt11'))?.[1]
if (!bolt11) continue
let amountSats: number
try {
amountSats = getAmountFromInvoice(bolt11)
} catch {
continue
}
if (!Number.isFinite(amountSats) || amountSats <= 0) continue
if (meta.valueMaximum != null && amountSats > meta.valueMaximum) continue
if (meta.valueMinimum != null && amountSats < meta.valueMinimum) continue
if (meta.closedAt != null) {
if (r.created_at < poll.created_at || r.created_at > meta.closedAt) continue
}
if (oneVotePerOptionPerUser) {
const key = `${voterPk}:${optIdx}`
if (seenUserOption.has(key)) continue
seenUserOption.add(key)
}
satsByOption.set(optIdx, (satsByOption.get(optIdx) ?? 0) + amountSats)
receiptCountByOption.set(optIdx, (receiptCountByOption.get(optIdx) ?? 0) + 1)
totalSats += amountSats
}
return { satsByOption, totalSats, receiptCountByOption }
}
export function userHasZappedPoll(
pollId: string,
userPubkey: string,
receipts: Event[]
): boolean {
const pk = userPubkey.trim().toLowerCase()
for (const r of receipts) {
if (r.kind !== kinds.Zap) continue
const desc = r.tags.find(tagNameEquals('description'))?.[1]
if (!desc) continue
try {
const zapReq = JSON.parse(desc) as { pubkey?: string; tags?: string[][] }
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1])
if (eTag?.[1] !== pollId) continue
if ((zapReq.pubkey ?? '').trim().toLowerCase() === pk) return true
const pSender = r.tags.find(tagNameEquals('P'))?.[1]
if (pSender && pSender.trim().toLowerCase() === pk) return true
} catch {
continue
}
}
return false
}
export function userZapPollVoteOption(
poll: Event,
meta: TZapPollMeta,
userPubkey: string,
receipts: Event[]
): number | undefined {
const pk = userPubkey.trim().toLowerCase()
for (const r of receipts) {
if (r.kind !== kinds.Zap) continue
const desc = r.tags.find(tagNameEquals('description'))?.[1]
if (!desc) continue
try {
const zapReq = JSON.parse(desc) as { pubkey?: string; tags?: string[][] }
if (!zapTargetKindAllowsPollTally(zapReq.tags)) continue
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1])
if (eTag?.[1] !== poll.id) continue
if ((zapReq.pubkey ?? '').trim().toLowerCase() !== pk) continue
return extractVoteOptionFromZapRequest(poll, meta, zapReq.tags)
} catch {
continue
}
}
return undefined
}
/** 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) => {
if (r.kind !== kinds.Zap) return false
const pSender = r.tags.find(tagNameEquals('P'))?.[1]?.trim().toLowerCase()
if (pSender !== pk) return false
const desc = r.tags.find(tagNameEquals('description'))?.[1]
if (!desc) return false
try {
const zapReq = JSON.parse(desc) as { tags?: string[][] }
if (!zapReq.tags?.some((t) => t[0] === 'e' && t[1])) return false
return zapTargetKindAllowsPollTally(zapReq.tags)
} catch {
return false
}
})
}
export function getPollIdFromZapReceipt(receipt: Event): string | undefined {
const desc = receipt.tags.find(tagNameEquals('description'))?.[1]
if (!desc) return undefined
try {
const zapReq = JSON.parse(desc) as { tags?: string[][] }
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1])
return eTag?.[1]
} catch {
return undefined
}
}

2
src/pages/secondary/NotePage/index.tsx

@ -256,8 +256,6 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }:
return 'Note: Highlight' return 'Note: Highlight'
case 1068: // ExtendedKind.POLL case 1068: // ExtendedKind.POLL
return 'Note: Poll' return 'Note: Poll'
case 6969: // ExtendedKind.ZAP_POLL
return 'Note: Zap Poll'
case 31987: // ExtendedKind.RELAY_REVIEW case 31987: // ExtendedKind.RELAY_REVIEW
return 'Note: Relay Review' return 'Note: Relay Review'
case 31922: // ExtendedKind.CALENDAR_EVENT_DATE case 31922: // ExtendedKind.CALENDAR_EVENT_DATE

132
src/services/lightning.service.ts

@ -1,10 +1,5 @@
import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, CODY_PUBKEY, IMWALD_MAINTAINER_PUBKEY } from '@/constants' import { FAST_READ_RELAY_URLS, FAST_WRITE_RELAY_URLS, CODY_PUBKEY, IMWALD_MAINTAINER_PUBKEY } from '@/constants'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import {
buildZapPollVoteRequestTemplate,
isZapPollVoteEligible,
type TZapPollMeta
} from '@/lib/zap-poll'
import { TProfile } from '@/types' import { TProfile } from '@/types'
import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react' import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react'
import { Invoice } from '@getalby/lightning-tools' import { Invoice } from '@getalby/lightning-tools'
@ -164,133 +159,6 @@ class LightningService {
}) })
} }
/** NIP-B9: pay-to-vote on a zap poll (kind 6969). */
async zapPollVote(
sender: string,
pollEvent: NostrEvent,
meta: TZapPollMeta,
recipientPubkey: string,
optionIndex: number,
sats: number,
comment: string,
closeOuterModel?: () => void,
includePublicReceipt: boolean = storage.getIncludePublicZapReceipt()
): Promise<{ preimage: string; invoice: string } | null> {
if (!client.signer) {
throw new Error('You need to be logged in to zap')
}
const eligible = isZapPollVoteEligible(pollEvent, meta, sender, sats)
if (!eligible.ok) {
throw new Error(eligible.reason)
}
const rec = recipientPubkey.trim().toLowerCase()
if (!meta.recipients.some((r) => r.pubkey === rec)) {
throw new Error('Recipient is not a poll payout pubkey')
}
const [profile, senderRelayList] = await Promise.all([
(async () => {
const profileEvent = await replaceableEventService.fetchReplaceableEvent(rec, kinds.Metadata)
return profileEvent ? getProfileFromEvent(profileEvent) : undefined
})(),
client.fetchRelayList(sender)
])
if (!profile) {
throw new Error('Recipient not found')
}
const zapEndpoint = await this.getZapEndpoint(profile)
if (!zapEndpoint) {
throw new Error("Recipient's lightning address is invalid")
}
const { callback, lnurl } = zapEndpoint
const amount = sats * 1000
const zapRelays = includePublicReceipt
? senderRelayList.write.slice(0, 4).concat(FAST_READ_RELAY_URLS)
: []
const zapRequestDraft = buildZapPollVoteRequestTemplate({
poll: pollEvent,
meta,
recipientPubkey: rec,
optionIndex,
amountMillisats: amount,
relays: zapRelays,
comment
})
const zapRequest = await client.signer.signEvent(zapRequestDraft)
const zapRequestRes = await fetchWithTimeout(
`${callback}?amount=${amount}&nostr=${encodeURI(JSON.stringify(zapRequest))}&lnurl=${lnurl}`,
{ timeoutMs: 25_000 }
)
const zapRequestResBody = await zapRequestRes.json()
if (zapRequestResBody.error) {
throw new Error(zapRequestResBody.message)
}
const { pr, verify, reason } = zapRequestResBody
if (!pr) {
throw new Error(reason ?? 'Failed to create invoice')
}
if (this.provider) {
const { preimage } = await this.provider.sendPayment(pr)
closeOuterModel?.()
return { preimage, invoice: pr }
}
return new Promise((resolve) => {
runAfterReleasingRadixScrollLock(closeOuterModel, () => {
let checkPaymentInterval: ReturnType<typeof setInterval> | undefined
let subCloser: SubCloser | undefined
const { setPaid } = launchPaymentModal({
invoice: pr,
onPaid: (response) => {
clearInterval(checkPaymentInterval)
subCloser?.close()
resolve({ preimage: response.preimage, invoice: pr })
},
onCancelled: () => {
clearInterval(checkPaymentInterval)
subCloser?.close()
resolve(null)
}
})
if (verify) {
checkPaymentInterval = setInterval(async () => {
const invoice = new Invoice({ pr, verify })
const paid = await invoice.verifyPayment()
if (paid && invoice.preimage) {
setPaid({
preimage: invoice.preimage
})
}
}, 1000)
} else {
const filter: Filter = {
kinds: [kinds.Zap],
'#p': [rec],
'#e': [pollEvent.id],
since: dayjs().subtract(1, 'minute').unix()
}
subCloser = client.subscribe(
senderRelayList.write.concat(FAST_READ_RELAY_URLS).slice(0, 4),
filter,
{
onevent: (evt) => {
const info = getZapInfoFromEvent(evt)
if (!info) return
if (info.invoice === pr) {
setPaid({ preimage: info.preimage ?? '' })
}
}
}
)
}
})
})
}
async payInvoice( async payInvoice(
invoice: string, invoice: string,
closeOuterModel?: () => void closeOuterModel?: () => void

5
src/services/local-storage.service.ts

@ -320,11 +320,6 @@ class LocalStorageService {
} }
} }
} }
if (showKindsVersion < 10) {
if (showKinds.includes(ExtendedKind.POLL) && !showKinds.includes(ExtendedKind.ZAP_POLL)) {
showKinds.push(ExtendedKind.ZAP_POLL)
}
}
if (showKindsVersion < 11) { if (showKindsVersion < 11) {
if (!showKinds.includes(ExtendedKind.GIT_RELEASE)) { if (!showKinds.includes(ExtendedKind.GIT_RELEASE)) {
showKinds.push(ExtendedKind.GIT_RELEASE) showKinds.push(ExtendedKind.GIT_RELEASE)

1
src/services/mention-event-search.service.ts

@ -26,7 +26,6 @@ export const NEVENT_KINDS = [
ExtendedKind.PICTURE, ExtendedKind.PICTURE,
...NIP71_VIDEO_KINDS, ...NIP71_VIDEO_KINDS,
ExtendedKind.POLL, ExtendedKind.POLL,
ExtendedKind.ZAP_POLL,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE, ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,

Loading…
Cancel
Save