20 changed files with 18 additions and 1097 deletions
@ -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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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