22 changed files with 1127 additions and 211 deletions
@ -0,0 +1,262 @@ |
|||||||
|
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) |
||||||
|
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) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (meta?.valueMinimum != null) { |
||||||
|
setSats(Math.max(meta.valueMinimum, 1)) |
||||||
|
} else { |
||||||
|
setSats(21) |
||||||
|
} |
||||||
|
}, [meta?.valueMinimum, event.id]) |
||||||
|
|
||||||
|
const defaultRecipient = meta?.recipients[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.id, pubkey, receipts) : undefined |
||||||
|
|
||||||
|
const showTally = !!meta && (closed || viewerZapped || event.pubkey === pubkey) |
||||||
|
|
||||||
|
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> |
||||||
|
)} |
||||||
|
{(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 && !tally && ( |
||||||
|
<p className="text-xs text-muted-foreground">{t('Loading tally…')}</p> |
||||||
|
)} |
||||||
|
{error && <p className="text-xs text-destructive">{error}</p>} |
||||||
|
<div className="space-y-2"> |
||||||
|
{meta.options.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 && tally.totalSats > 0 && ( |
||||||
|
<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"> |
||||||
|
{satsOpt > 0 ? `${Math.round(satsOpt)} sats` : '—'} |
||||||
|
{counts > 0 ? ` · ${t('{{n}} zaps', { n: counts })}` : ''} |
||||||
|
{tally.totalSats > 0 ? ` (${pct.toFixed(0)}%)` : ''} |
||||||
|
</span> |
||||||
|
)} |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
) |
||||||
|
})} |
||||||
|
</div> |
||||||
|
{meta.consensusThreshold != null && showTally && tally && tally.totalSats > 0 && ( |
||||||
|
<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> |
||||||
|
{meta.recipients.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> |
||||||
|
) |
||||||
|
} |
||||||
@ -0,0 +1,90 @@ |
|||||||
|
import { ExtendedKind, FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' |
||||||
|
import { |
||||||
|
filterZapPollVoteReceiptsForVoter, |
||||||
|
getPollIdFromZapReceipt, |
||||||
|
userZapPollVoteOption |
||||||
|
} from '@/lib/zap-poll' |
||||||
|
import { normalizeUrl } from '@/lib/url' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { Event } from 'nostr-tools' |
||||||
|
import { kinds } from 'nostr-tools' |
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react' |
||||||
|
|
||||||
|
function participationRelayUrls(): string[] { |
||||||
|
const seen = new Set<string>() |
||||||
|
const out: string[] = [] |
||||||
|
for (const u of [...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS]) { |
||||||
|
const n = normalizeUrl(u) || u |
||||||
|
if (!n || seen.has(n)) continue |
||||||
|
seen.add(n) |
||||||
|
out.push(n) |
||||||
|
} |
||||||
|
return out.slice(0, 14) |
||||||
|
} |
||||||
|
|
||||||
|
export type TZapPollProfileRow = { |
||||||
|
poll: Event |
||||||
|
voteReceipt: Event |
||||||
|
optionIndex: number |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Zap poll votes by `profilePubkey` (kind 9735 with P=profile and k=6969 in embedded zap request), |
||||||
|
* resolved to kind 6969 poll events for profile timeline merge. |
||||||
|
*/ |
||||||
|
export function useProfileZapPollParticipation(profilePubkey: string | undefined) { |
||||||
|
const [rows, setRows] = useState<TZapPollProfileRow[]>([]) |
||||||
|
const [loading, setLoading] = useState(false) |
||||||
|
|
||||||
|
const load = useCallback(async () => { |
||||||
|
if (!profilePubkey) { |
||||||
|
setRows([]) |
||||||
|
return |
||||||
|
} |
||||||
|
setLoading(true) |
||||||
|
try { |
||||||
|
const urls = participationRelayUrls() |
||||||
|
const receipts = await client.fetchEvents(urls, { |
||||||
|
kinds: [kinds.Zap], |
||||||
|
'#P': [profilePubkey.trim().toLowerCase()], |
||||||
|
limit: 300 |
||||||
|
}) |
||||||
|
const voteReceipts = filterZapPollVoteReceiptsForVoter(receipts, profilePubkey) |
||||||
|
const pollIds = [...new Set(voteReceipts.map(getPollIdFromZapReceipt).filter(Boolean) as string[])] |
||||||
|
if (pollIds.length === 0) { |
||||||
|
setRows([]) |
||||||
|
return |
||||||
|
} |
||||||
|
const polls = await client.fetchEvents(urls, { |
||||||
|
kinds: [ExtendedKind.ZAP_POLL], |
||||||
|
ids: pollIds, |
||||||
|
limit: pollIds.length |
||||||
|
}) |
||||||
|
const pollById = new Map(polls.map((p) => [p.id, p])) |
||||||
|
const built: TZapPollProfileRow[] = [] |
||||||
|
for (const vr of voteReceipts) { |
||||||
|
const pid = getPollIdFromZapReceipt(vr) |
||||||
|
if (!pid) continue |
||||||
|
const poll = pollById.get(pid) |
||||||
|
if (!poll) continue |
||||||
|
const opt = userZapPollVoteOption(pid, profilePubkey, [vr]) |
||||||
|
if (opt === undefined) continue |
||||||
|
built.push({ poll, voteReceipt: vr, optionIndex: opt }) |
||||||
|
} |
||||||
|
built.sort((a, b) => b.voteReceipt.created_at - a.voteReceipt.created_at) |
||||||
|
setRows(built) |
||||||
|
} catch { |
||||||
|
setRows([]) |
||||||
|
} finally { |
||||||
|
setLoading(false) |
||||||
|
} |
||||||
|
}, [profilePubkey]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
void load() |
||||||
|
}, [load]) |
||||||
|
|
||||||
|
const pollIdsVoted = useMemo(() => new Set(rows.map((r) => r.poll.id)), [rows]) |
||||||
|
|
||||||
|
return { rows, loading, reload: load, pollIdsVoted } |
||||||
|
} |
||||||
@ -0,0 +1,63 @@ |
|||||||
|
import { FAST_READ_RELAY_URLS, SEARCHABLE_RELAY_URLS } from '@/constants' |
||||||
|
import { |
||||||
|
parseZapPollEvent, |
||||||
|
tallyZapPollFromReceipts, |
||||||
|
type TZapPollMeta, |
||||||
|
type TZapPollTally |
||||||
|
} from '@/lib/zap-poll' |
||||||
|
import { normalizeUrl } from '@/lib/url' |
||||||
|
import client from '@/services/client.service' |
||||||
|
import { Event, kinds } from 'nostr-tools' |
||||||
|
import { useCallback, useEffect, useMemo, useState } from 'react' |
||||||
|
|
||||||
|
function tallyRelayUrls(): string[] { |
||||||
|
const seen = new Set<string>() |
||||||
|
const out: string[] = [] |
||||||
|
for (const u of [...SEARCHABLE_RELAY_URLS, ...FAST_READ_RELAY_URLS]) { |
||||||
|
const n = normalizeUrl(u) || u |
||||||
|
if (!n || seen.has(n)) continue |
||||||
|
seen.add(n) |
||||||
|
out.push(n) |
||||||
|
} |
||||||
|
return out.slice(0, 12) |
||||||
|
} |
||||||
|
|
||||||
|
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) |
||||||
|
|
||||||
|
const load = useCallback(async () => { |
||||||
|
if (!meta) return |
||||||
|
setLoading(true) |
||||||
|
setError(null) |
||||||
|
try { |
||||||
|
const urls = tallyRelayUrls() |
||||||
|
const evs = await client.fetchEvents(urls, { |
||||||
|
kinds: [kinds.Zap], |
||||||
|
'#e': [poll.id], |
||||||
|
limit: 500 |
||||||
|
}) |
||||||
|
setReceipts(evs) |
||||||
|
} catch (e) { |
||||||
|
setError(e instanceof Error ? e.message : String(e)) |
||||||
|
} finally { |
||||||
|
setLoading(false) |
||||||
|
} |
||||||
|
}, [poll.id, meta]) |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
void load() |
||||||
|
}, [load]) |
||||||
|
|
||||||
|
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]) |
||||||
|
} |
||||||
@ -0,0 +1,291 @@ |
|||||||
|
import { ExtendedKind } from '@/constants' |
||||||
|
import { getAmountFromInvoice } from '@/lib/lightning' |
||||||
|
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 |
||||||
|
} |
||||||
|
|
||||||
|
/** 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 recipients: { pubkey: string; relay: 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) || !relay) continue |
||||||
|
const n = normalizeUrl(relay) || relay |
||||||
|
recipients.push({ pubkey: pk, relay: n }) |
||||||
|
} |
||||||
|
if (recipients.length === 0) return null |
||||||
|
|
||||||
|
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) |
||||||
|
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 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(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) |
||||||
|
return k?.[1] |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 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 recipientSet = new Set(meta.recipients.map((r) => r.pubkey)) |
||||||
|
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 (getKindFromZapRequestTags(zapReq.tags) !== '6969') 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 pTag = zapReq.tags?.find((t) => t[0] === 'p' && t[1]) |
||||||
|
if (!pTag || !recipientSet.has(pTag[1].trim().toLowerCase())) continue |
||||||
|
const optIdx = getPollOptionFromZapRequestTags(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( |
||||||
|
pollId: string, |
||||||
|
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 (getKindFromZapRequestTags(zapReq.tags) !== '6969') continue |
||||||
|
const eTag = zapReq.tags?.find((t) => t[0] === 'e' && t[1]) |
||||||
|
if (eTag?.[1] !== pollId) continue |
||||||
|
if ((zapReq.pubkey ?? '').trim().toLowerCase() !== pk) continue |
||||||
|
return getPollOptionFromZapRequestTags(zapReq.tags) |
||||||
|
} catch { |
||||||
|
continue |
||||||
|
} |
||||||
|
} |
||||||
|
return undefined |
||||||
|
} |
||||||
|
|
||||||
|
/** Receipts where user is the zapper and vote targets a zap poll (for profile). */ |
||||||
|
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[][] } |
||||||
|
return getKindFromZapRequestTags(zapReq.tags) === '6969' |
||||||
|
} 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