20 changed files with 18 additions and 1097 deletions
@ -1,304 +0,0 @@
@@ -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> |
||||
) |
||||
} |
||||
@ -1,138 +0,0 @@
@@ -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,20 +0,0 @@
@@ -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) |
||||
} |
||||
@ -1,420 +0,0 @@
@@ -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 option’s pubkey. |
||||
*/ |
||||
export function extractVoteOptionFromZapRequest( |
||||
poll: Event, |
||||
meta: TZapPollMeta, |
||||
tags: string[][] | undefined |
||||
): number | undefined { |
||||
const payees = zapRequestPayeePubkeys(tags) |
||||
if (payees.length === 0) return undefined |
||||
const payeeSet = new Set(payees) |
||||
const pollAuthor = poll.pubkey.trim().toLowerCase() |
||||
const paidAuthor = payeeSet.has(pollAuthor) |
||||
const hasCandidatePayee = meta.recipients.some((r) => payeeSet.has(r.pubkey)) |
||||
|
||||
const explicit = getPollOptionFromZapRequestTags(tags) |
||||
const explicitOk = |
||||
explicit !== undefined && meta.options.some((o) => o.index === explicit) ? explicit : undefined |
||||
if (explicitOk !== undefined && (paidAuthor || hasCandidatePayee)) { |
||||
return explicitOk |
||||
} |
||||
|
||||
const j = meta.recipients.findIndex((r) => payeeSet.has(r.pubkey)) |
||||
if (j < 0 || j >= meta.options.length) return undefined |
||||
return meta.options[j]!.index |
||||
} |
||||
|
||||
/** |
||||
* 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 |
||||
} |
||||
} |
||||
Loading…
Reference in new issue