Browse Source

bug-fixes

implement zap polling
improve unkown event cards
imwald
Silberengel 1 month ago
parent
commit
df1ed4a564
  1. 2
      src/components/KindFilter/index.tsx
  2. 181
      src/components/Note/EventViewer.tsx
  3. 94
      src/components/Note/UnknownNote.tsx
  4. 262
      src/components/Note/ZapPoll.tsx
  5. 17
      src/components/Note/index.tsx
  6. 5
      src/components/NoteCard/MainNoteCard.tsx
  7. 8
      src/components/NoteCard/index.tsx
  8. 9
      src/components/NoteStats/index.tsx
  9. 75
      src/components/Profile/ProfileFeedWithPins.tsx
  10. 3
      src/constants.ts
  11. 90
      src/hooks/useProfileZapPollParticipation.tsx
  12. 63
      src/hooks/useZapPollTally.tsx
  13. 31
      src/i18n/locales/en.ts
  14. 2
      src/lib/kind-description.ts
  15. 9
      src/lib/note-renderable-kinds.ts
  16. 291
      src/lib/zap-poll.ts
  17. 25
      src/pages/primary/SpellsPage/fauxSpellFeeds.ts
  18. 2
      src/pages/secondary/NotePage/index.tsx
  19. 33
      src/services/indexed-db.service.ts
  20. 126
      src/services/lightning.service.ts
  21. 7
      src/services/local-storage.service.ts
  22. 1
      src/services/mention-event-search.service.ts

2
src/components/KindFilter/index.tsx

@ -19,7 +19,7 @@ const KIND_FILTER_OPTIONS = [
{ kindGroup: [kinds.LongFormArticle], label: 'Articles' }, { kindGroup: [kinds.LongFormArticle], label: 'Articles' },
{ kindGroup: [ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Wiki Articles' }, { kindGroup: [ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Wiki Articles' },
{ kindGroup: [kinds.Highlights], label: 'Highlights' }, { kindGroup: [kinds.Highlights], label: 'Highlights' },
{ kindGroup: [ExtendedKind.POLL], label: 'Polls' }, { kindGroup: [ExtendedKind.POLL, ExtendedKind.ZAP_POLL], label: 'Polls' },
{ kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' }, { kindGroup: [ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT], label: 'Voice Posts' },
{ kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' }, { kindGroup: [ExtendedKind.PICTURE], label: 'Photo Posts' },
{ kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' }, { kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' },

181
src/components/Note/EventViewer.tsx

@ -3,13 +3,13 @@ import { useMemo, useState } from 'react'
import { useTranslation } from 'react-i18next' import { useTranslation } from 'react-i18next'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' import { Copy, Check } from 'lucide-react'
import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-react'
import { toast } from 'sonner' import { toast } from 'sonner'
import logger from '@/lib/logger' import logger from '@/lib/logger'
import { cn } from '@/lib/utils' import { cn } from '@/lib/utils'
import { isRssThreadSyntheticParentEvent } from '@/lib/rss-article' import { isRssThreadSyntheticParentEvent } from '@/lib/rss-article'
import { isValidPubkey } from '@/lib/pubkey' import { isValidPubkey } from '@/lib/pubkey'
import { getKindDescription } from '@/lib/kind-description'
import UserAvatar from '@/components/UserAvatar' import UserAvatar from '@/components/UserAvatar'
import Username from '@/components/Username' import Username from '@/components/Username'
@ -19,36 +19,25 @@ function isAllZeroPlaceholderPubkey(pk: string): boolean {
export default function EventViewer({ export default function EventViewer({
event, event,
className, className
/** When true, `event.tags` and nested tag rows render expanded (no collapse). */
expandTagsTree = false
}: { }: {
event: Event event: Event
className?: string className?: string
expandTagsTree?: boolean
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const [copiedJson, setCopiedJson] = useState(false) const [copiedJson, setCopiedJson] = useState(false)
const [copiedNevent, setCopiedNevent] = useState(false) const [copiedNevent, setCopiedNevent] = useState(false)
const [expanded, setExpanded] = useState<Set<string>>(new Set())
const nevent = useMemo( const nevent = useMemo(
() => nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind }), () => nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind }),
[event.id, event.pubkey, event.kind] [event.id, event.pubkey, event.kind]
) )
const setKeyExpanded = (key: string, open: boolean) => { const jsonPretty = useMemo(() => JSON.stringify(event, null, 2), [event])
setExpanded((prev) => {
const next = new Set(prev)
if (open) next.add(key)
else next.delete(key)
return next
})
}
const handleCopyJson = async () => { const handleCopyJson = async () => {
try { try {
await navigator.clipboard.writeText(JSON.stringify(event, null, 2)) await navigator.clipboard.writeText(jsonPretty)
setCopiedJson(true) setCopiedJson(true)
toast.success(t('Copied to clipboard')) toast.success(t('Copied to clipboard'))
setTimeout(() => setCopiedJson(false), 2000) setTimeout(() => setCopiedJson(false), 2000)
@ -70,143 +59,53 @@ export default function EventViewer({
} }
} }
const renderValue = (value: unknown, key: string, depth = 0): React.ReactNode => {
if (value === null) {
return <span className="text-muted-foreground">null</span>
}
if (value === undefined) {
return <span className="text-muted-foreground">undefined</span>
}
if (typeof value === 'string') {
return <span className="text-green-600 dark:text-green-400">"{value}"</span>
}
if (typeof value === 'number' || typeof value === 'boolean') {
return <span className="text-blue-600 dark:text-blue-400">{String(value)}</span>
}
if (Array.isArray(value)) {
const tagsTreeAlwaysOpen =
expandTagsTree && (key === 'tags' || key.startsWith('tags['))
if (tagsTreeAlwaysOpen) {
return (
<div className={cn('ml-2', depth > 0 && 'border-l border-border/50 pl-2')}>
<div className="text-xs text-muted-foreground mb-1">
Array ({value.length})
</div>
<div className="ml-4">
{value.map((item, idx) => (
<div key={idx} className="mb-1">
<span className="text-muted-foreground text-xs">[{idx}]</span>{' '}
{renderValue(item, `${key}[${idx}]`, depth + 1)}
</div>
))}
</div>
</div>
)
}
const isExpanded = expanded.has(key)
return (
<div className={cn('ml-2', depth > 0 && 'border-l border-border/50 pl-2')}>
<Collapsible
open={isExpanded}
onOpenChange={(open) => setKeyExpanded(key, open)}
>
<CollapsibleTrigger
type="button"
className="flex items-center gap-1 text-sm hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span className="text-muted-foreground">Array</span>
<span className="text-xs text-muted-foreground">({value.length})</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1 ml-4">
{value.map((item, idx) => (
<div key={idx} className="mb-1">
<span className="text-muted-foreground text-xs">[{idx}]</span>{' '}
{renderValue(item, `${key}[${idx}]`, depth + 1)}
</div>
))}
</CollapsibleContent>
</Collapsible>
</div>
)
}
if (typeof value === 'object') {
const isExpanded = expanded.has(key)
const entries = Object.entries(value)
return (
<div className={cn('ml-2', depth > 0 && 'border-l border-border/50 pl-2')}>
<Collapsible
open={isExpanded}
onOpenChange={(open) => setKeyExpanded(key, open)}
>
<CollapsibleTrigger
type="button"
className="flex items-center gap-1 text-sm hover:text-foreground"
>
{isExpanded ? (
<ChevronDown className="h-3 w-3" />
) : (
<ChevronRight className="h-3 w-3" />
)}
<span className="text-muted-foreground">Object</span>
<span className="text-xs text-muted-foreground">({entries.length} keys)</span>
</CollapsibleTrigger>
<CollapsibleContent className="mt-1 ml-4">
{entries.map(([k, v]) => (
<div key={k} className="mb-1">
<span className="text-purple-600 dark:text-purple-400 font-medium">"{k}"</span>:{' '}
{renderValue(v, `${key}.${k}`, depth + 1)}
</div>
))}
</CollapsibleContent>
</Collapsible>
</div>
)
}
return <span className="text-muted-foreground">{String(value)}</span>
}
const createdAtFormatted = dayjs(event.created_at * 1000).format('LLL') const createdAtFormatted = dayjs(event.created_at * 1000).format('LLL')
const pubkey = event.pubkey ?? '' const pubkey = event.pubkey ?? ''
const hidePubkeyRow = isRssThreadSyntheticParentEvent(event) const hidePubkeyRow = isRssThreadSyntheticParentEvent(event)
const showAuthorBadge = const showAuthorBadge =
!hidePubkeyRow && isValidPubkey(pubkey) && !isAllZeroPlaceholderPubkey(pubkey) !hidePubkeyRow && isValidPubkey(pubkey) && !isAllZeroPlaceholderPubkey(pubkey)
const kindLabel = getKindDescription(event.kind)
return ( return (
<div className={cn('border rounded-lg p-4 bg-muted/30', className)}> <div className={cn('rounded-lg border border-border bg-muted/20 p-4', className)}>
<div className="flex items-center justify-between mb-3"> <div className="flex flex-wrap items-start justify-between gap-2 border-b border-border/60 pb-3">
<div className="text-sm font-semibold">Event (kind {event.kind})</div> <div>
<Button variant="ghost" size="sm" onClick={handleCopyJson} className="h-7"> <div className="text-sm font-semibold text-foreground">{kindLabel.description}</div>
<div className="mt-0.5 text-xs text-muted-foreground">
{t('Event kind and time', { kind: event.kind, time: createdAtFormatted })}
</div>
</div>
<Button variant="outline" size="sm" onClick={handleCopyJson} className="h-8 shrink-0 gap-1.5">
{copiedJson ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />} {copiedJson ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
{t('Copy JSON')}
</Button> </Button>
</div> </div>
<div className="text-sm space-y-2">
<div className="flex items-center gap-2 flex-wrap"> <div className="mt-3 space-y-2 text-sm">
<span className="text-purple-600 dark:text-purple-400 font-medium shrink-0">nevent</span> <div className="flex flex-wrap items-center gap-2">
<code className="truncate text-green-600 dark:text-green-400 text-xs">{nevent}</code> <span className="shrink-0 font-medium text-muted-foreground">{t('nevent')}</span>
<Button variant="ghost" size="sm" onClick={handleCopyNevent} className="h-6 w-6 p-0 shrink-0"> <code className="min-w-0 flex-1 truncate rounded bg-muted/80 px-2 py-0.5 font-mono text-xs text-foreground">
{copiedNevent ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />} {nevent}
</code>
<Button variant="ghost" size="sm" onClick={handleCopyNevent} className="h-7 w-7 shrink-0 p-0">
{copiedNevent ? <Check className="h-3.5 w-3.5" /> : <Copy className="h-3.5 w-3.5" />}
</Button> </Button>
</div> </div>
{!hidePubkeyRow && ( {!hidePubkeyRow && (
<div className="flex items-center gap-2 flex-wrap"> <div className="flex flex-wrap items-center gap-2">
<span className="text-purple-600 dark:text-purple-400 font-medium shrink-0">pubkey</span> <span className="shrink-0 font-medium text-muted-foreground">{t('Author')}</span>
{showAuthorBadge ? ( {showAuthorBadge ? (
<div className="flex items-center gap-1.5 min-w-0"> <div className="flex min-w-0 items-center gap-1.5">
<UserAvatar userId={pubkey} size="xSmall" /> <UserAvatar userId={pubkey} size="xSmall" />
<Username <Username
userId={pubkey} userId={pubkey}
className="font-normal min-w-0" className="min-w-0 font-normal"
skeletonClassName="h-4" skeletonClassName="h-4"
withoutSkeleton withoutSkeleton
/> />
</div> </div>
) : ( ) : (
<span className="text-muted-foreground text-xs break-all"> <span className="break-all text-xs text-muted-foreground">
{!pubkey {!pubkey
? t('Missing pubkey') ? t('Missing pubkey')
: isAllZeroPlaceholderPubkey(pubkey) : isAllZeroPlaceholderPubkey(pubkey)
@ -216,23 +115,11 @@ export default function EventViewer({
)} )}
</div> </div>
)} )}
<div>
<span className="text-purple-600 dark:text-purple-400 font-medium">kind</span>{' '}
{renderValue(event.kind, 'kind')}
</div>
<div>
<span className="text-purple-600 dark:text-purple-400 font-medium">created_at</span>{' '}
<span className="text-muted-foreground">{createdAtFormatted}</span>
</div>
<div className="font-mono">
<span className="text-purple-600 dark:text-purple-400 font-medium">tags</span>{' '}
{renderValue(event.tags, 'tags')}
</div>
<div className="font-mono">
<span className="text-purple-600 dark:text-purple-400 font-medium">content</span>{' '}
{renderValue(event.content, 'content')}
</div>
</div> </div>
<pre className="mt-4 max-h-[min(50vh,28rem)] overflow-auto rounded-md border border-border/80 bg-background/90 p-3 font-mono text-xs leading-relaxed text-foreground">
{jsonPretty}
</pre>
</div> </div>
) )
} }

94
src/components/Note/UnknownNote.tsx

@ -5,11 +5,24 @@ import ClientSelect from '../ClientSelect'
import { extractBookMetadata } from '@/lib/bookstr-parser' import { extractBookMetadata } from '@/lib/bookstr-parser'
import { ExtendedKind } from '@/constants' import { ExtendedKind } from '@/constants'
import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article' import { canonicalizeRssArticleUrl, getArticleUrlFromCommentITags } from '@/lib/rss-article'
import { useMemo } from 'react' import { getKindDescription } from '@/lib/kind-description'
import { useMemo, useState } from 'react'
import EventViewer from './EventViewer' import EventViewer from './EventViewer'
import { Button } from '@/components/ui/button'
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible'
import { ChevronDown, ChevronRight } from 'lucide-react'
const CONTENT_PREVIEW_MAX = 800
function truncatePreview(text: string, max: number): string {
const t = text.trim()
if (t.length <= max) return t
return `${t.slice(0, max).trimEnd()}`
}
export default function UnknownNote({ event, className }: { event: Event; className?: string }) { export default function UnknownNote({ event, className }: { event: Event; className?: string }) {
const { t } = useTranslation() const { t } = useTranslation()
const [technicalOpen, setTechnicalOpen] = useState(false)
const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const bookMetadata = useMemo(() => extractBookMetadata(event), [event])
const displayEvent = useMemo(() => { const displayEvent = useMemo(() => {
if (event.kind !== ExtendedKind.RSS_THREAD_ROOT) return event if (event.kind !== ExtendedKind.RSS_THREAD_ROOT) return event
@ -28,6 +41,9 @@ export default function UnknownNote({ event, className }: { event: Event; classN
.join(' ') .join(' ')
} }
const kindLabel = getKindDescription(event.kind)
const contentRaw = event.content?.trim() ?? ''
return ( return (
<div <div
className={cn( className={cn(
@ -35,20 +51,78 @@ export default function UnknownNote({ event, className }: { event: Event; classN
className className
)} )}
> >
<div className="flex flex-col gap-2 items-center text-muted-foreground font-medium"> <div className="rounded-lg border border-border bg-card px-4 py-3 text-card-foreground shadow-sm space-y-3">
<div>{t('Cannot handle event of kind k', { k: event.kind })}</div> <p className="text-sm text-muted-foreground leading-snug">
{t('Unsupported event preview')}
</p>
<div>
<h3 className="text-base font-semibold leading-tight text-foreground">
{kindLabel.description}
</h3>
<p className="mt-0.5 text-xs text-muted-foreground font-mono tabular-nums">
{t('Event kind label', { kind: event.kind })}
</p>
</div>
{isBookstrEvent && ( {isBookstrEvent && (
<div className="text-xs text-muted-foreground space-x-2"> <div className="text-xs text-muted-foreground flex flex-wrap gap-x-3 gap-y-1">
{bookMetadata.type && <span>Type: {bookMetadata.type}</span>} {bookMetadata.type && <span>{t('Type')}: {bookMetadata.type}</span>}
{bookMetadata.book && <span>Book: {formatBookName(bookMetadata.book)}</span>} {bookMetadata.book && <span>{t('Book')}: {formatBookName(bookMetadata.book)}</span>}
{bookMetadata.chapter && <span>Chapter: {bookMetadata.chapter}</span>} {bookMetadata.chapter && <span>{t('Chapter')}: {bookMetadata.chapter}</span>}
{bookMetadata.verse && <span>Verse: {bookMetadata.verse}</span>} {bookMetadata.verse && <span>{t('Verse')}: {bookMetadata.verse}</span>}
{bookMetadata.version && <span>Version: {bookMetadata.version.toUpperCase()}</span>} {bookMetadata.version && <span>{t('Version')}: {bookMetadata.version.toUpperCase()}</span>}
</div> </div>
)} )}
{contentRaw ? (
<p className="text-sm leading-relaxed whitespace-pre-wrap break-words text-foreground/95">
{truncatePreview(contentRaw, CONTENT_PREVIEW_MAX)}
</p>
) : (
<p className="text-sm text-muted-foreground italic">{t('No text content in event')}</p>
)}
{event.tags.length > 0 ? (
<div className="border-t border-border/80 pt-3">
<p className="text-xs font-medium uppercase tracking-wide text-muted-foreground mb-2">
{t('Tags')}
</p>
<ul className="space-y-1.5 text-sm">
{event.tags.map((tag, i) => (
<li key={i} className="flex gap-2 rounded-md bg-muted/40 px-2 py-1.5">
<span className="shrink-0 font-medium text-foreground/90">{tag[0]}</span>
<span className="min-w-0 break-all text-muted-foreground">
{tag.length > 1 ? tag.slice(1).join(' · ') : '—'}
</span>
</li>
))}
</ul>
</div>
) : null}
<ClientSelect event={event} /> <ClientSelect event={event} />
</div> </div>
<EventViewer event={displayEvent} expandTagsTree />
<Collapsible open={technicalOpen} onOpenChange={setTechnicalOpen}>
<CollapsibleTrigger asChild>
<Button
type="button"
variant="outline"
size="sm"
className="w-full justify-between gap-2 font-normal"
>
<span>{t('Technical details')}</span>
{technicalOpen ? (
<ChevronDown className="h-4 w-4 shrink-0 opacity-70" />
) : (
<ChevronRight className="h-4 w-4 shrink-0 opacity-70" />
)}
</Button>
</CollapsibleTrigger>
<CollapsibleContent className="mt-2">
<EventViewer event={displayEvent} />
</CollapsibleContent>
</Collapsible>
</div> </div>
) )
} }

262
src/components/Note/ZapPoll.tsx

@ -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>
)
}

17
src/components/Note/index.tsx

@ -50,6 +50,7 @@ import MutedNote from './MutedNote'
import NsfwNote from './NsfwNote' import NsfwNote from './NsfwNote'
import PictureNote from './PictureNote' import PictureNote from './PictureNote'
import Poll from './Poll' import Poll from './Poll'
import ZapPoll from './ZapPoll'
import NotificationEventCard from './NotificationEventCard' import NotificationEventCard from './NotificationEventCard'
import ReactionEmojiDisplay from './ReactionEmojiDisplay' import ReactionEmojiDisplay from './ReactionEmojiDisplay'
import UnknownNote from './UnknownNote' import UnknownNote from './UnknownNote'
@ -69,7 +70,8 @@ export default function Note({
hideParentNotePreview = false, hideParentNotePreview = false,
showFull = false, showFull = false,
disableClick = false, disableClick = false,
fullCalendarInvite fullCalendarInvite,
zapPollVoteHighlightOption
}: { }: {
event: Event event: Event
originalNoteId?: string originalNoteId?: string
@ -80,6 +82,8 @@ export default function Note({
disableClick?: boolean disableClick?: boolean
/** When viewing a kind-24 invite, use this to replace the embedded calendar with the full card (RSVP) in content */ /** When viewing a kind-24 invite, use this to replace the embedded calendar with the full card (RSVP) in content */
fullCalendarInvite?: { event: Event; naddr: string } fullCalendarInvite?: { event: Event; naddr: string }
/** Profile: highlight option when this row is from a zap vote receipt. */
zapPollVoteHighlightOption?: number
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
@ -226,6 +230,17 @@ export default function Note({
<Poll className="mt-2" event={event} /> <Poll className="mt-2" event={event} />
</> </>
) )
} else if (event.kind === ExtendedKind.ZAP_POLL) {
content = (
<>
<MarkdownArticle className="mt-2" event={event} hideMetadata={true} />
<ZapPoll
className="mt-2"
event={event}
voteHighlightOptionIndex={zapPollVoteHighlightOption}
/>
</>
)
} else if (event.kind === ExtendedKind.VOICE) { } else if (event.kind === ExtendedKind.VOICE) {
content = <AudioPlayer className="mt-2" src={event.content} /> content = <AudioPlayer className="mt-2" src={event.content} />
} else if (event.kind === ExtendedKind.VOICE_COMMENT) { } else if (event.kind === ExtendedKind.VOICE_COMMENT) {

5
src/components/NoteCard/MainNoteCard.tsx

@ -17,7 +17,8 @@ export default function MainNoteCard({
embedded, embedded,
originalNoteId, originalNoteId,
pinned = false, pinned = false,
hideParentNotePreview = false hideParentNotePreview = false,
zapPollVoteHighlightOption
}: { }: {
event: Event event: Event
className?: string className?: string
@ -28,6 +29,7 @@ export default function MainNoteCard({
pinned?: boolean pinned?: boolean
/** Hide the parent note preview (e.g. when showing quotes of current note). */ /** Hide the parent note preview (e.g. when showing quotes of current note). */
hideParentNotePreview?: boolean hideParentNotePreview?: boolean
zapPollVoteHighlightOption?: number
}) { }) {
const { t } = useTranslation() const { t } = useTranslation()
const { navigateToNote } = useSmartNoteNavigationOptional() const { navigateToNote } = useSmartNoteNavigationOptional()
@ -75,6 +77,7 @@ export default function MainNoteCard({
originalNoteId={originalNoteId} originalNoteId={originalNoteId}
disableClick={true} disableClick={true}
hideParentNotePreview={hideParentNotePreview} hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption}
/> />
</Collapsible> </Collapsible>
{!embedded && ( {!embedded && (

8
src/components/NoteCard/index.tsx

@ -12,7 +12,8 @@ const NoteCard = memo(function NoteCard({
className, className,
filterMutedNotes = true, filterMutedNotes = true,
pinned = false, pinned = false,
hideParentNotePreview = false hideParentNotePreview = false,
zapPollVoteHighlightOption
}: { }: {
event: Event event: Event
className?: string className?: string
@ -20,6 +21,7 @@ const NoteCard = memo(function NoteCard({
pinned?: boolean pinned?: boolean
/** When true, hide the parent/root note preview (e.g. when showing quotes of the current note). */ /** When true, hide the parent/root note preview (e.g. when showing quotes of the current note). */
hideParentNotePreview?: boolean hideParentNotePreview?: boolean
zapPollVoteHighlightOption?: number
}) { }) {
const { mutePubkeySet } = useMuteList() const { mutePubkeySet } = useMuteList()
const { hideContentMentioningMutedUsers } = useContentPolicy() const { hideContentMentioningMutedUsers } = useContentPolicy()
@ -50,6 +52,7 @@ const NoteCard = memo(function NoteCard({
className={className} className={className}
pinned={pinned} pinned={pinned}
hideParentNotePreview={hideParentNotePreview} hideParentNotePreview={hideParentNotePreview}
zapPollVoteHighlightOption={zapPollVoteHighlightOption}
/> />
) )
}, (prevProps, nextProps) => { }, (prevProps, nextProps) => {
@ -60,7 +63,8 @@ const NoteCard = memo(function NoteCard({
prevProps.className === nextProps.className && prevProps.className === nextProps.className &&
prevProps.filterMutedNotes === nextProps.filterMutedNotes && prevProps.filterMutedNotes === nextProps.filterMutedNotes &&
prevProps.pinned === nextProps.pinned && prevProps.pinned === nextProps.pinned &&
prevProps.hideParentNotePreview === nextProps.hideParentNotePreview prevProps.hideParentNotePreview === nextProps.hideParentNotePreview &&
prevProps.zapPollVoteHighlightOption === nextProps.zapPollVoteHighlightOption
) )
}) })

9
src/components/NoteStats/index.tsx

@ -45,6 +45,7 @@ export default function NoteStats({
/** Synthetic RSS article root: no boost/quote/zap; still show reaction breakdown (NIP-25 + kind-17 web). */ /** Synthetic RSS article root: no boost/quote/zap; still show reaction breakdown (NIP-25 + kind-17 web). */
const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT const isRssArticleRoot = event.kind === ExtendedKind.RSS_THREAD_ROOT
const isZapPoll = event.kind === ExtendedKind.ZAP_POLL
/** Emoji reaction pills (aggregated likes). Shown for RSS/Web URL threads so the side panel matches feed rows. */ /** Emoji reaction pills (aggregated likes). Shown for RSS/Web URL threads so the side panel matches feed rows. */
const showLikesPills = !isDiscussion && !isReplyToDiscussion const showLikesPills = !isDiscussion && !isReplyToDiscussion
@ -77,7 +78,9 @@ export default function NoteStats({
<RepostButton event={event} hideCount={hideInteractions} /> <RepostButton event={event} hideCount={hideInteractions} />
)} )}
<LikeButton event={event} hideCount={hideInteractions} /> <LikeButton event={event} hideCount={hideInteractions} />
{!isRssArticleRoot && <ZapButton event={event} hideCount={hideInteractions} />} {!isRssArticleRoot && !isZapPoll && (
<ZapButton event={event} hideCount={hideInteractions} />
)}
<BookmarkButton event={event} /> <BookmarkButton event={event} />
<SeenOnButton event={event} /> <SeenOnButton event={event} />
</div> </div>
@ -101,7 +104,9 @@ export default function NoteStats({
<RepostButton event={event} hideCount={hideInteractions} /> <RepostButton event={event} hideCount={hideInteractions} />
)} )}
<LikeButton event={event} hideCount={hideInteractions} /> <LikeButton event={event} hideCount={hideInteractions} />
{!isRssArticleRoot && <ZapButton event={event} hideCount={hideInteractions} />} {!isRssArticleRoot && !isZapPoll && (
<ZapButton event={event} hideCount={hideInteractions} />
)}
</div> </div>
<div className="flex items-center"> <div className="flex items-center">
<BookmarkButton event={event} /> <BookmarkButton event={event} />

75
src/components/Profile/ProfileFeedWithPins.tsx

@ -6,6 +6,7 @@ import { isReplyNoteEvent } from '@/lib/event'
import { getZapInfoFromEvent } from '@/lib/event-metadata' import { getZapInfoFromEvent } from '@/lib/event-metadata'
import { useProfilePins } from '@/hooks/useProfilePins' import { useProfilePins } from '@/hooks/useProfilePins'
import { useProfileTimeline } from '@/hooks/useProfileTimeline' import { useProfileTimeline } from '@/hooks/useProfileTimeline'
import { useProfileZapPollParticipation } from '@/hooks/useProfileZapPollParticipation'
import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useDeletedEvent } from '@/providers/DeletedEventProvider'
import { useKindFilter } from '@/providers/KindFilterProvider' import { useKindFilter } from '@/providers/KindFilterProvider'
import { useZap } from '@/providers/ZapProvider' import { useZap } from '@/providers/ZapProvider'
@ -78,6 +79,9 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
filterPredicate filterPredicate
}) })
const { rows: zapPollVoteRows, loading: loadingZapPollVotes, reload: reloadZapPollVotes } =
useProfileZapPollParticipation(pubkey)
const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents]) const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents])
const passesMainFeedTimelineRules = useCallback( const passesMainFeedTimelineRules = useCallback(
@ -100,25 +104,63 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
[timelineEvents, pinIds, passesMainFeedTimelineRules] [timelineEvents, pinIds, passesMainFeedTimelineRules]
) )
const applySearch = useCallback( type ProfileMergedRow = {
(events: Event[]) => { key: string
event: Event
sortAt: number
zapPollVoteHighlight?: number
}
const mergedRestRows = useMemo((): ProfileMergedRow[] => {
const showZapPollVotes = profileTimelineShowKinds.includes(ExtendedKind.ZAP_POLL)
const timelinePollIds = new Set(
restTimeline.filter((e) => e.kind === ExtendedKind.ZAP_POLL).map((e) => e.id)
)
const noteRows: ProfileMergedRow[] = restTimeline.map((e) => ({
key: e.id,
event: e,
sortAt: e.created_at
}))
const voteRows: ProfileMergedRow[] = showZapPollVotes
? zapPollVoteRows
.filter((r) => !timelinePollIds.has(r.poll.id))
.map((r) => ({
key: `zap-poll-vote:${r.voteReceipt.id}`,
event: r.poll,
sortAt: r.voteReceipt.created_at,
zapPollVoteHighlight: r.optionIndex
}))
: []
return [...noteRows, ...voteRows].sort((a, b) => b.sortAt - a.sortAt)
}, [restTimeline, zapPollVoteRows, profileTimelineShowKinds])
const rowMatchesSearch = useCallback(
(event: Event) => {
const q = searchQuery.trim().toLowerCase() const q = searchQuery.trim().toLowerCase()
if (!q) return events if (!q) return true
return events.filter((event) => {
if (event.content.toLowerCase().includes(q)) return true if (event.content.toLowerCase().includes(q)) return true
return event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(q)) return event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(q))
})
}, },
[searchQuery] [searchQuery]
) )
const applySearch = useCallback(
(events: Event[]) => {
const q = searchQuery.trim().toLowerCase()
if (!q) return events
return events.filter((event) => rowMatchesSearch(event))
},
[rowMatchesSearch]
)
const filteredPins = useMemo( const filteredPins = useMemo(
() => applySearch(pinEvents).filter((e) => !isEventDeleted(e)), () => applySearch(pinEvents).filter((e) => !isEventDeleted(e)),
[pinEvents, applySearch, isEventDeleted] [pinEvents, applySearch, isEventDeleted]
) )
const filteredRest = useMemo( const filteredRest = useMemo(
() => applySearch(restTimeline).filter((e) => !isEventDeleted(e)), () =>
[restTimeline, applySearch, isEventDeleted] mergedRestRows.filter((row) => rowMatchesSearch(row.event) && !isEventDeleted(row.event)),
[mergedRestRows, rowMatchesSearch, isEventDeleted]
) )
const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest]) const mergedDisplay = useMemo(() => [...filteredPins, ...filteredRest], [filteredPins, filteredRest])
@ -141,17 +183,18 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
}, [searchQuery, pubkey]) }, [searchQuery, pubkey])
useEffect(() => { useEffect(() => {
if (!loadingPins && !loadingTimeline) { if (!loadingPins && !loadingTimeline && !loadingZapPollVotes) {
setIsRefreshing(false) setIsRefreshing(false)
} }
}, [loadingPins, loadingTimeline]) }, [loadingPins, loadingTimeline, loadingZapPollVotes])
const refreshAll = useCallback(() => { const refreshAll = useCallback(() => {
setIsRefreshing(true) setIsRefreshing(true)
refreshPins() refreshPins()
refreshTimeline() refreshTimeline()
reloadZapPollVotes()
void client.fetchDeletionEventsForPubkey(pubkey) void client.fetchDeletionEventsForPubkey(pubkey)
}, [refreshPins, refreshTimeline, pubkey]) }, [refreshPins, refreshTimeline, reloadZapPollVotes, pubkey])
useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll])
@ -169,7 +212,8 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
return () => observer.disconnect() return () => observer.disconnect()
}, [totalVisible, mergedDisplay.length]) }, [totalVisible, mergedDisplay.length])
const loading = (loadingPins || loadingTimeline) && mergedDisplay.length === 0 const loading =
(loadingPins || loadingTimeline || loadingZapPollVotes) && mergedDisplay.length === 0
if (loading) { if (loading) {
return ( return (
@ -190,7 +234,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
) )
} }
if (!mergedDisplay.length && !loadingPins && !loadingTimeline) { if (!mergedDisplay.length && !loadingPins && !loadingTimeline && !loadingZapPollVotes) {
return ( return (
<div className="mt-4 px-2"> <div className="mt-4 px-2">
<div className="flex flex-wrap items-center gap-2 mb-4"> <div className="flex flex-wrap items-center gap-2 mb-4">
@ -255,13 +299,14 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string
)} )}
{displayedFeed.length > 0 && ( {displayedFeed.length > 0 && (
<div className="space-y-2" aria-label={t('Posts')}> <div className="space-y-2" aria-label={t('Posts')}>
{displayedFeed.map((event) => ( {displayedFeed.map((row) => (
<NoteCard <NoteCard
key={event.id} key={row.key}
className="w-full" className="w-full"
event={event} event={row.event}
filterMutedNotes={false} filterMutedNotes={false}
pinned={false} pinned={false}
zapPollVoteHighlightOption={row.zapPollVoteHighlight}
/> />
))} ))}
</div> </div>

3
src/constants.ts

@ -287,6 +287,8 @@ export const ExtendedKind = {
VIDEO: 21, VIDEO: 21,
SHORT_VIDEO: 22, SHORT_VIDEO: 22,
POLL: 1068, POLL: 1068,
/** NIP-B9 zap poll (paid votes via zaps). */
ZAP_POLL: 6969,
POLL_RESPONSE: 1018, POLL_RESPONSE: 1018,
COMMENT: 1111, COMMENT: 1111,
VOICE: 1222, VOICE: 1222,
@ -398,6 +400,7 @@ export const SUPPORTED_KINDS = [
ExtendedKind.VIDEO, ExtendedKind.VIDEO,
ExtendedKind.SHORT_VIDEO, ExtendedKind.SHORT_VIDEO,
ExtendedKind.POLL, ExtendedKind.POLL,
ExtendedKind.ZAP_POLL,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE, ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,

90
src/hooks/useProfileZapPollParticipation.tsx

@ -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 }
}

63
src/hooks/useZapPollTally.tsx

@ -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])
}

31
src/i18n/locales/en.ts

@ -390,6 +390,14 @@ export default {
Topics: 'Topics', Topics: 'Topics',
'Open in a': 'Open in {{a}}', 'Open in a': 'Open in {{a}}',
'Cannot handle event of kind k': 'Cannot handle event of kind {{k}}', 'Cannot handle event of kind k': 'Cannot handle event of kind {{k}}',
'Unsupported event preview':
'There isn’t a dedicated card for this event type yet. Here’s a readable preview.',
'No text content in event': 'No text in this event.',
'Technical details': 'Technical details',
'Event kind and time': 'Kind {{kind}} · {{time}}',
'Event kind label': 'Kind {{kind}}',
'Copy JSON': 'Copy JSON',
Verse: 'Verse',
'Notification reaction summary': 'reacted to this note.', 'Notification reaction summary': 'reacted to this note.',
'Notification discussion upvote summary': 'upvoted in this discussion.', 'Notification discussion upvote summary': 'upvoted in this discussion.',
'Notification discussion downvote summary': 'downvoted in this discussion.', 'Notification discussion downvote summary': 'downvoted in this discussion.',
@ -630,6 +638,28 @@ export default {
'Relay URLs (optional, comma-separated)': 'Relay URLs (optional, comma-separated)', 'Relay URLs (optional, comma-separated)': 'Relay URLs (optional, comma-separated)',
'Remove poll': 'Remove poll', 'Remove poll': 'Remove poll',
'Refresh results': 'Refresh results', 'Refresh results': 'Refresh results',
'Zap poll (paid votes)': 'Zap poll (paid votes)',
'Invalid zap poll': 'Invalid zap poll',
'You voted on this poll (zap receipt)': 'You voted on this poll (zap receipt)',
'Poll closed {{time}}': 'Poll closed {{time}}',
'Closes {{time}}': 'Closes {{time}}',
'Vote size': 'Vote size',
'{{n}} sats (fixed)': '{{n}} sats (fixed)',
'{{min}}–{{max}} sats': '{{min}}–{{max}} sats',
'≥ {{n}} sats': '≥ {{n}} sats',
'≤ {{n}} sats': '≤ {{n}} sats',
'Loading tally…': 'Loading tally…',
'Consensus threshold': 'Consensus threshold',
'Pay to': 'Pay to',
Recipient: 'Recipient',
Option: 'Option',
'Select option': 'Select option',
'Select an option': 'Select an option',
'Vote with zap': 'Vote with zap',
'Zap sent': 'Zap sent',
'Zapping…': 'Zapping…',
'Refresh tally': 'Refresh tally',
'{{n}} zaps': '{{n}} zaps',
Poll: 'Poll', Poll: 'Poll',
Media: 'Media', Media: 'Media',
Interests: 'Interests', Interests: 'Interests',
@ -1231,7 +1261,6 @@ export default {
'Open Timestamp': 'Open Timestamp', 'Open Timestamp': 'Open Timestamp',
'Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.': 'Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.':
'Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.', 'Opens in a new tab. Copy a GIF URL there, then paste below. If this picker closed, click “Insert GIF” again to paste.',
Option: 'Option',
Optional: 'Optional', Optional: 'Optional',
'Optional image for the event': 'Optional image for the event', 'Optional image for the event': 'Optional image for the event',
'Optionally, add the full quote/context to show your highlight within it': 'Optionally, add the full quote/context to show your highlight within it':

2
src/lib/kind-description.ts

@ -42,6 +42,8 @@ export function getKindDescription(kind: number): { number: number; description:
return { number: 9802, description: 'Highlight' } return { number: 9802, description: 'Highlight' }
case ExtendedKind.POLL: case ExtendedKind.POLL:
return { number: 1068, description: 'Poll' } return { number: 1068, description: 'Poll' }
case ExtendedKind.ZAP_POLL:
return { number: 6969, description: 'Zap poll' }
case ExtendedKind.PUBLIC_MESSAGE: case ExtendedKind.PUBLIC_MESSAGE:
return { number: 24, description: 'Public Message' } return { number: 24, description: 'Public Message' }
case ExtendedKind.DISCUSSION: case ExtendedKind.DISCUSSION:

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

@ -18,9 +18,16 @@ const RENDERABLE_NOTE_KINDS = new Set<number>([
ExtendedKind.CITATION_INTERNAL, ExtendedKind.CITATION_INTERNAL,
ExtendedKind.CITATION_EXTERNAL, ExtendedKind.CITATION_EXTERNAL,
ExtendedKind.CITATION_HARDCOPY, ExtendedKind.CITATION_HARDCOPY,
ExtendedKind.CITATION_PROMPT ExtendedKind.CITATION_PROMPT,
ExtendedKind.ZAP_POLL
]) ])
/**
* Every kind the main `Note` component renders with a dedicated UI (not the unknown-event fallback).
* Used by the notifications spell client filter so mention events use the same cards as elsewhere.
*/
export const RENDERABLE_NOTE_KINDS_SORTED = [...RENDERABLE_NOTE_KINDS].sort((a, b) => a - b)
export function isRenderableNoteKind(kind: number): boolean { export function isRenderableNoteKind(kind: number): boolean {
return RENDERABLE_NOTE_KINDS.has(kind) return RENDERABLE_NOTE_KINDS.has(kind)
} }

291
src/lib/zap-poll.ts

@ -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
}
}

25
src/pages/primary/SpellsPage/fauxSpellFeeds.ts

@ -10,12 +10,13 @@
* uses **one** shard: all subscribed topics in one `#t` filter (NIP-01 OR semantics). * uses **one** shard: all subscribed topics in one `#t` filter (NIP-01 OR semantics).
*/ */
import { DEFAULT_FEED_SHOW_KINDS, ExtendedKind, READ_ONLY_RELAY_URLS } from '@/constants' import { DEFAULT_FEED_SHOW_KINDS, ExtendedKind, READ_ONLY_RELAY_URLS } from '@/constants'
import { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds'
import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays'
import { normalizeTopic } from '@/lib/discussion-topics' import { normalizeTopic } from '@/lib/discussion-topics'
import { userIdToPubkey } from '@/lib/pubkey' import { userIdToPubkey } from '@/lib/pubkey'
import { normalizeUrl } from '@/lib/url' import { normalizeUrl } from '@/lib/url'
import type { TFeedSubRequest } from '@/types' import type { TFeedSubRequest } from '@/types'
import { type Event, type Filter, kinds } from 'nostr-tools' import { type Event, type Filter } from 'nostr-tools'
/** Default caps for every faux spell feed (relays per subrequest, events per REQ). */ /** Default caps for every faux spell feed (relays per subrequest, events per REQ). */
export const FAUX_SPELL_MAX_RELAYS = 10 export const FAUX_SPELL_MAX_RELAYS = 10
@ -47,25 +48,11 @@ export function applyFauxSpellCapsToSubRequests(requests: TFeedSubRequest[]): TF
} }
/** /**
* Mention/notification-shaped kinds only (aligned with global notification-shaped kinds, plus zap receipts). * Same kinds as {@link RENDERABLE_NOTE_KINDS_SORTED}: anything `Note` renders with a real card, not
* Not full {@link PROFILE_FEED_KINDS} that asked relays for huge multi-kind slices per `#p`. * the unknown-event fallback. Live notifications REQ uses `#p` only (no relay `kinds`); this list is applied in
* * NoteList via `clientSideKindFilter` so only supported cards appear (other mention kinds are dropped).
* Live notifications spell: REQ uses `#p` only (no relay `kinds`); {@link NOTIFICATION_SPELL_KINDS} is applied
* in NoteList via `clientSideKindFilter` so the timeline buffer is not filled by other kinds that mention you.
*/ */
export const NOTIFICATION_SPELL_KINDS = [ export const NOTIFICATION_SPELL_KINDS = RENDERABLE_NOTE_KINDS_SORTED
kinds.ShortTextNote,
kinds.Repost,
kinds.Reaction,
ExtendedKind.EXTERNAL_REACTION,
kinds.Zap,
ExtendedKind.COMMENT,
ExtendedKind.POLL_RESPONSE,
ExtendedKind.VOICE_COMMENT,
ExtendedKind.POLL,
ExtendedKind.PUBLIC_MESSAGE,
ExtendedKind.ZAP_RECEIPT
] as const
/** Live notifications spell: longer than NoteList’s default 15s before empty state (slow `#p` on some relays). */ /** Live notifications spell: longer than NoteList’s default 15s before empty state (slow `#p` on some relays). */
export const NOTIFICATION_SPELL_LOADING_SAFETY_MS = 90_000 export const NOTIFICATION_SPELL_LOADING_SAFETY_MS = 90_000

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

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

33
src/services/indexed-db.service.ts

@ -56,7 +56,7 @@ export const StoreNames = {
} }
/** Schema version we expect. When adding stores or migrations, bump this. */ /** Schema version we expect. When adding stores or migrations, bump this. */
const DB_VERSION = 29 const DB_VERSION = 30
/** Max age for profile and payment info cache before we refetch (5 min). */ /** Max age for profile and payment info cache before we refetch (5 min). */
const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000 const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000
@ -69,6 +69,20 @@ function idbEventToError(ev: Parameters<NonNullable<IDBRequest['onerror']>>[0]):
return new Error(message) return new Error(message)
} }
/** Create any object stores from {@link StoreNames} that are missing (e.g. after partial upgrades). */
function ensureMissingObjectStores(db: IDBDatabase): void {
for (const storeName of Object.values(StoreNames)) {
if (db.objectStoreNames.contains(storeName)) continue
if (storeName === StoreNames.RSS_FEED_ITEMS) {
const store = db.createObjectStore(storeName, { keyPath: 'key' })
store.createIndex('feedUrl', 'feedUrl', { unique: false })
store.createIndex('pubDate', 'pubDate', { unique: false })
} else {
db.createObjectStore(storeName, { keyPath: 'key' })
}
}
}
class IndexedDbService { class IndexedDbService {
static instance: IndexedDbService static instance: IndexedDbService
static getInstance(): IndexedDbService { static getInstance(): IndexedDbService {
@ -240,6 +254,7 @@ class IndexedDbService {
if (!db.objectStoreNames.contains(StoreNames.BADGE_DEFINITION_EVENTS)) { if (!db.objectStoreNames.contains(StoreNames.BADGE_DEFINITION_EVENTS)) {
db.createObjectStore(StoreNames.BADGE_DEFINITION_EVENTS, { keyPath: 'key' }) db.createObjectStore(StoreNames.BADGE_DEFINITION_EVENTS, { keyPath: 'key' })
} }
ensureMissingObjectStores(db)
} }
} }
); );
@ -1538,12 +1553,7 @@ class IndexedDbService {
request.onupgradeneeded = (event) => { request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result const db = (event.target as IDBOpenDBRequest).result
// Create any missing stores ensureMissingObjectStores(db)
Object.values(StoreNames).forEach(storeName => {
if (!db.objectStoreNames.contains(storeName)) {
db.createObjectStore(storeName, { keyPath: 'key' })
}
})
} }
}) })
} }
@ -1575,12 +1585,17 @@ class IndexedDbService {
expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days expirationTimestamp: Date.now() - 1000 * 60 * 60 * 24 // 1 days
} }
] ]
const names = this.db.objectStoreNames
const existingStores = stores.filter((s) => names.contains(s.name))
if (existingStores.length === 0) {
return
}
const transaction = this.db!.transaction( const transaction = this.db!.transaction(
stores.map((store) => store.name), existingStores.map((store) => store.name),
'readwrite' 'readwrite'
) )
await Promise.allSettled( await Promise.allSettled(
stores.map(({ name, expirationTimestamp }) => { existingStores.map(({ name, expirationTimestamp }) => {
if (expirationTimestamp < 0) { if (expirationTimestamp < 0) {
return Promise.resolve() return Promise.resolve()
} }

126
src/services/lightning.service.ts

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

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

@ -293,11 +293,16 @@ class LocalStorageService {
} }
} }
} }
if (showKindsVersion < 10) {
if (showKinds.includes(ExtendedKind.POLL) && !showKinds.includes(ExtendedKind.ZAP_POLL)) {
showKinds.push(ExtendedKind.ZAP_POLL)
}
}
// v9: boosts are optional in the same filter list as other kinds; do not auto-enable (leave absent). // v9: boosts are optional in the same filter list as other kinds; do not auto-enable (leave absent).
this.showKinds = showKinds this.showKinds = showKinds
} }
this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds)) this.persistSetting(StorageKey.SHOW_KINDS, JSON.stringify(this.showKinds))
this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '9') this.persistSetting(StorageKey.SHOW_KINDS_VERSION, '10')
// Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set) // Feed filter: kind 1 OPs, kind 1 replies, kind 1111 (migrate from legacy showRepliesAndComments if set)
const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs) const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs)

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

@ -19,6 +19,7 @@ export const NEVENT_KINDS = [
ExtendedKind.VIDEO, ExtendedKind.VIDEO,
ExtendedKind.SHORT_VIDEO, ExtendedKind.SHORT_VIDEO,
ExtendedKind.POLL, ExtendedKind.POLL,
ExtendedKind.ZAP_POLL,
ExtendedKind.COMMENT, ExtendedKind.COMMENT,
ExtendedKind.VOICE, ExtendedKind.VOICE,
ExtendedKind.VOICE_COMMENT, ExtendedKind.VOICE_COMMENT,

Loading…
Cancel
Save