diff --git a/src/components/KindFilter/index.tsx b/src/components/KindFilter/index.tsx index 4e8bced9..55254f7c 100644 --- a/src/components/KindFilter/index.tsx +++ b/src/components/KindFilter/index.tsx @@ -19,7 +19,7 @@ const KIND_FILTER_OPTIONS = [ { kindGroup: [kinds.LongFormArticle], label: 'Articles' }, { kindGroup: [ExtendedKind.WIKI_ARTICLE, ExtendedKind.WIKI_ARTICLE_MARKDOWN], label: 'Wiki Articles' }, { 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.PICTURE], label: 'Photo Posts' }, { kindGroup: [ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO], label: 'Video Posts' }, diff --git a/src/components/Note/EventViewer.tsx b/src/components/Note/EventViewer.tsx index 43c6c95b..11b7d69f 100644 --- a/src/components/Note/EventViewer.tsx +++ b/src/components/Note/EventViewer.tsx @@ -3,13 +3,13 @@ import { useMemo, useState } from 'react' import { useTranslation } from 'react-i18next' import dayjs from 'dayjs' import { Button } from '@/components/ui/button' -import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' -import { Copy, Check, ChevronDown, ChevronRight } from 'lucide-react' +import { Copy, Check } from 'lucide-react' import { toast } from 'sonner' import logger from '@/lib/logger' import { cn } from '@/lib/utils' import { isRssThreadSyntheticParentEvent } from '@/lib/rss-article' import { isValidPubkey } from '@/lib/pubkey' +import { getKindDescription } from '@/lib/kind-description' import UserAvatar from '@/components/UserAvatar' import Username from '@/components/Username' @@ -19,36 +19,25 @@ function isAllZeroPlaceholderPubkey(pk: string): boolean { export default function EventViewer({ event, - className, - /** When true, `event.tags` and nested tag rows render expanded (no collapse). */ - expandTagsTree = false + className }: { event: Event className?: string - expandTagsTree?: boolean }) { const { t } = useTranslation() const [copiedJson, setCopiedJson] = useState(false) const [copiedNevent, setCopiedNevent] = useState(false) - const [expanded, setExpanded] = useState>(new Set()) const nevent = useMemo( () => nip19.neventEncode({ id: event.id, author: event.pubkey, kind: event.kind }), [event.id, event.pubkey, event.kind] ) - const setKeyExpanded = (key: string, open: boolean) => { - setExpanded((prev) => { - const next = new Set(prev) - if (open) next.add(key) - else next.delete(key) - return next - }) - } + const jsonPretty = useMemo(() => JSON.stringify(event, null, 2), [event]) const handleCopyJson = async () => { try { - await navigator.clipboard.writeText(JSON.stringify(event, null, 2)) + await navigator.clipboard.writeText(jsonPretty) setCopiedJson(true) toast.success(t('Copied to clipboard')) 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 null - } - if (value === undefined) { - return undefined - } - if (typeof value === 'string') { - return "{value}" - } - if (typeof value === 'number' || typeof value === 'boolean') { - return {String(value)} - } - if (Array.isArray(value)) { - const tagsTreeAlwaysOpen = - expandTagsTree && (key === 'tags' || key.startsWith('tags[')) - if (tagsTreeAlwaysOpen) { - return ( -
0 && 'border-l border-border/50 pl-2')}> -
- Array ({value.length}) -
-
- {value.map((item, idx) => ( -
- [{idx}]{' '} - {renderValue(item, `${key}[${idx}]`, depth + 1)} -
- ))} -
-
- ) - } - const isExpanded = expanded.has(key) - return ( -
0 && 'border-l border-border/50 pl-2')}> - setKeyExpanded(key, open)} - > - - {isExpanded ? ( - - ) : ( - - )} - Array - ({value.length}) - - - {value.map((item, idx) => ( -
- [{idx}]{' '} - {renderValue(item, `${key}[${idx}]`, depth + 1)} -
- ))} -
-
-
- ) - } - if (typeof value === 'object') { - const isExpanded = expanded.has(key) - const entries = Object.entries(value) - return ( -
0 && 'border-l border-border/50 pl-2')}> - setKeyExpanded(key, open)} - > - - {isExpanded ? ( - - ) : ( - - )} - Object - ({entries.length} keys) - - - {entries.map(([k, v]) => ( -
- "{k}":{' '} - {renderValue(v, `${key}.${k}`, depth + 1)} -
- ))} -
-
-
- ) - } - return {String(value)} - } - const createdAtFormatted = dayjs(event.created_at * 1000).format('LLL') const pubkey = event.pubkey ?? '' const hidePubkeyRow = isRssThreadSyntheticParentEvent(event) const showAuthorBadge = !hidePubkeyRow && isValidPubkey(pubkey) && !isAllZeroPlaceholderPubkey(pubkey) + const kindLabel = getKindDescription(event.kind) return ( -
-
-
Event (kind {event.kind})
-
-
-
- nevent - {nevent} -
{!hidePubkeyRow && ( -
- pubkey +
+ {t('Author')} {showAuthorBadge ? ( -
+
) : ( - + {!pubkey ? t('Missing pubkey') : isAllZeroPlaceholderPubkey(pubkey) @@ -216,23 +115,11 @@ export default function EventViewer({ )}
)} -
- kind{' '} - {renderValue(event.kind, 'kind')} -
-
- created_at{' '} - {createdAtFormatted} -
-
- tags{' '} - {renderValue(event.tags, 'tags')} -
-
- content{' '} - {renderValue(event.content, 'content')} -
+ +
+        {jsonPretty}
+      
) } diff --git a/src/components/Note/UnknownNote.tsx b/src/components/Note/UnknownNote.tsx index 0936331f..6dcd123b 100644 --- a/src/components/Note/UnknownNote.tsx +++ b/src/components/Note/UnknownNote.tsx @@ -5,11 +5,24 @@ import ClientSelect from '../ClientSelect' import { extractBookMetadata } from '@/lib/bookstr-parser' import { ExtendedKind } from '@/constants' 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 { 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 }) { const { t } = useTranslation() + const [technicalOpen, setTechnicalOpen] = useState(false) const bookMetadata = useMemo(() => extractBookMetadata(event), [event]) const displayEvent = useMemo(() => { if (event.kind !== ExtendedKind.RSS_THREAD_ROOT) return event @@ -28,6 +41,9 @@ export default function UnknownNote({ event, className }: { event: Event; classN .join(' ') } + const kindLabel = getKindDescription(event.kind) + const contentRaw = event.content?.trim() ?? '' + return (
-
-
{t('Cannot handle event of kind k', { k: event.kind })}
+
+

+ {t('Unsupported event preview')} +

+
+

+ {kindLabel.description} +

+

+ {t('Event kind label', { kind: event.kind })} +

+
+ {isBookstrEvent && ( -
- {bookMetadata.type && Type: {bookMetadata.type}} - {bookMetadata.book && Book: {formatBookName(bookMetadata.book)}} - {bookMetadata.chapter && Chapter: {bookMetadata.chapter}} - {bookMetadata.verse && Verse: {bookMetadata.verse}} - {bookMetadata.version && Version: {bookMetadata.version.toUpperCase()}} +
+ {bookMetadata.type && {t('Type')}: {bookMetadata.type}} + {bookMetadata.book && {t('Book')}: {formatBookName(bookMetadata.book)}} + {bookMetadata.chapter && {t('Chapter')}: {bookMetadata.chapter}} + {bookMetadata.verse && {t('Verse')}: {bookMetadata.verse}} + {bookMetadata.version && {t('Version')}: {bookMetadata.version.toUpperCase()}}
)} + + {contentRaw ? ( +

+ {truncatePreview(contentRaw, CONTENT_PREVIEW_MAX)} +

+ ) : ( +

{t('No text content in event')}

+ )} + + {event.tags.length > 0 ? ( +
+

+ {t('Tags')} +

+
    + {event.tags.map((tag, i) => ( +
  • + {tag[0]} + + {tag.length > 1 ? tag.slice(1).join(' · ') : '—'} + +
  • + ))} +
+
+ ) : null} +
- + + + + + + + + +
) } diff --git a/src/components/Note/ZapPoll.tsx b/src/components/Note/ZapPoll.tsx new file mode 100644 index 00000000..5b452ac1 --- /dev/null +++ b/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('') + const [optionIndex, setOptionIndex] = useState(null) + const [sats, setSats] = useState(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 ( +
+ {t('Invalid zap poll')} +
+ ) + } + + 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 ( +
+
+ + {t('Zap poll (paid votes)')} +
+ {voteHighlightOptionIndex != null && ( +

{t('You voted on this poll (zap receipt)')}

+ )} + {meta.closedAt && ( +

+ {closed + ? t('Poll closed {{time}}', { + time: dayjs.unix(meta.closedAt).format('lll') + }) + : t('Closes {{time}}', { time: dayjs.unix(meta.closedAt).format('lll') })} +

+ )} + {(meta.valueMinimum != null || meta.valueMaximum != null) && ( +

+ {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! })} +

+ )} + {loading && !tally && ( +

{t('Loading tally…')}

+ )} + {error &&

{error}

} +
+ {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 ( +
+ {showTally && tally && tally.totalSats > 0 && ( +
+ )} +
+ {opt.label} + {showTally && tally && ( + + {satsOpt > 0 ? `${Math.round(satsOpt)} sats` : '—'} + {counts > 0 ? ` · ${t('{{n}} zaps', { n: counts })}` : ''} + {tally.totalSats > 0 ? ` (${pct.toFixed(0)}%)` : ''} + + )} +
+
+ ) + })} +
+ {meta.consensusThreshold != null && showTally && tally && tally.totalSats > 0 && ( +

+ {t('Consensus threshold')}: {meta.consensusThreshold}% +

+ )} + {!closed && pubkey && event.pubkey !== pubkey && ( +
+
+ + +
+
+ + +
+
+ + setSats(parseInt(e.target.value, 10) || 0)} + className="h-9" + /> +
+ +
+ )} + {showTally && ( + + )} +
+ ) +} diff --git a/src/components/Note/index.tsx b/src/components/Note/index.tsx index 23de97a2..be4f4293 100644 --- a/src/components/Note/index.tsx +++ b/src/components/Note/index.tsx @@ -50,6 +50,7 @@ import MutedNote from './MutedNote' import NsfwNote from './NsfwNote' import PictureNote from './PictureNote' import Poll from './Poll' +import ZapPoll from './ZapPoll' import NotificationEventCard from './NotificationEventCard' import ReactionEmojiDisplay from './ReactionEmojiDisplay' import UnknownNote from './UnknownNote' @@ -69,7 +70,8 @@ export default function Note({ hideParentNotePreview = false, showFull = false, disableClick = false, - fullCalendarInvite + fullCalendarInvite, + zapPollVoteHighlightOption }: { event: Event originalNoteId?: string @@ -80,6 +82,8 @@ export default function Note({ disableClick?: boolean /** 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 } + /** Profile: highlight option when this row is from a zap vote receipt. */ + zapPollVoteHighlightOption?: number }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() @@ -226,6 +230,17 @@ export default function Note({ ) + } else if (event.kind === ExtendedKind.ZAP_POLL) { + content = ( + <> + + + + ) } else if (event.kind === ExtendedKind.VOICE) { content = } else if (event.kind === ExtendedKind.VOICE_COMMENT) { diff --git a/src/components/NoteCard/MainNoteCard.tsx b/src/components/NoteCard/MainNoteCard.tsx index 7cd03f7a..07e3a026 100644 --- a/src/components/NoteCard/MainNoteCard.tsx +++ b/src/components/NoteCard/MainNoteCard.tsx @@ -17,7 +17,8 @@ export default function MainNoteCard({ embedded, originalNoteId, pinned = false, - hideParentNotePreview = false + hideParentNotePreview = false, + zapPollVoteHighlightOption }: { event: Event className?: string @@ -28,6 +29,7 @@ export default function MainNoteCard({ pinned?: boolean /** Hide the parent note preview (e.g. when showing quotes of current note). */ hideParentNotePreview?: boolean + zapPollVoteHighlightOption?: number }) { const { t } = useTranslation() const { navigateToNote } = useSmartNoteNavigationOptional() @@ -75,6 +77,7 @@ export default function MainNoteCard({ originalNoteId={originalNoteId} disableClick={true} hideParentNotePreview={hideParentNotePreview} + zapPollVoteHighlightOption={zapPollVoteHighlightOption} /> {!embedded && ( diff --git a/src/components/NoteCard/index.tsx b/src/components/NoteCard/index.tsx index 7b2710d7..abdd4d5f 100644 --- a/src/components/NoteCard/index.tsx +++ b/src/components/NoteCard/index.tsx @@ -12,7 +12,8 @@ const NoteCard = memo(function NoteCard({ className, filterMutedNotes = true, pinned = false, - hideParentNotePreview = false + hideParentNotePreview = false, + zapPollVoteHighlightOption }: { event: Event className?: string @@ -20,6 +21,7 @@ const NoteCard = memo(function NoteCard({ pinned?: boolean /** When true, hide the parent/root note preview (e.g. when showing quotes of the current note). */ hideParentNotePreview?: boolean + zapPollVoteHighlightOption?: number }) { const { mutePubkeySet } = useMuteList() const { hideContentMentioningMutedUsers } = useContentPolicy() @@ -50,6 +52,7 @@ const NoteCard = memo(function NoteCard({ className={className} pinned={pinned} hideParentNotePreview={hideParentNotePreview} + zapPollVoteHighlightOption={zapPollVoteHighlightOption} /> ) }, (prevProps, nextProps) => { @@ -60,7 +63,8 @@ const NoteCard = memo(function NoteCard({ prevProps.className === nextProps.className && prevProps.filterMutedNotes === nextProps.filterMutedNotes && prevProps.pinned === nextProps.pinned && - prevProps.hideParentNotePreview === nextProps.hideParentNotePreview + prevProps.hideParentNotePreview === nextProps.hideParentNotePreview && + prevProps.zapPollVoteHighlightOption === nextProps.zapPollVoteHighlightOption ) }) diff --git a/src/components/NoteStats/index.tsx b/src/components/NoteStats/index.tsx index 54e55e50..382eda73 100644 --- a/src/components/NoteStats/index.tsx +++ b/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). */ 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. */ const showLikesPills = !isDiscussion && !isReplyToDiscussion @@ -77,7 +78,9 @@ export default function NoteStats({ )} - {!isRssArticleRoot && } + {!isRssArticleRoot && !isZapPoll && ( + + )}
@@ -101,7 +104,9 @@ export default function NoteStats({ )} - {!isRssArticleRoot && } + {!isRssArticleRoot && !isZapPoll && ( + + )}
diff --git a/src/components/Profile/ProfileFeedWithPins.tsx b/src/components/Profile/ProfileFeedWithPins.tsx index f88eb79a..2a76b662 100644 --- a/src/components/Profile/ProfileFeedWithPins.tsx +++ b/src/components/Profile/ProfileFeedWithPins.tsx @@ -6,6 +6,7 @@ import { isReplyNoteEvent } from '@/lib/event' import { getZapInfoFromEvent } from '@/lib/event-metadata' import { useProfilePins } from '@/hooks/useProfilePins' import { useProfileTimeline } from '@/hooks/useProfileTimeline' +import { useProfileZapPollParticipation } from '@/hooks/useProfileZapPollParticipation' import { useDeletedEvent } from '@/providers/DeletedEventProvider' import { useKindFilter } from '@/providers/KindFilterProvider' import { useZap } from '@/providers/ZapProvider' @@ -78,6 +79,9 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string filterPredicate }) + const { rows: zapPollVoteRows, loading: loadingZapPollVotes, reload: reloadZapPollVotes } = + useProfileZapPollParticipation(pubkey) + const pinIds = useMemo(() => new Set(pinEvents.map((e) => e.id)), [pinEvents]) const passesMainFeedTimelineRules = useCallback( @@ -100,16 +104,53 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string [timelineEvents, pinIds, passesMainFeedTimelineRules] ) + type ProfileMergedRow = { + 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() + if (!q) return true + if (event.content.toLowerCase().includes(q)) return true + return event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(q)) + }, + [searchQuery] + ) + const applySearch = useCallback( (events: Event[]) => { const q = searchQuery.trim().toLowerCase() if (!q) return events - return events.filter((event) => { - if (event.content.toLowerCase().includes(q)) return true - return event.tags.some((tag) => tag.length > 1 && tag[1]?.toLowerCase().includes(q)) - }) + return events.filter((event) => rowMatchesSearch(event)) }, - [searchQuery] + [rowMatchesSearch] ) const filteredPins = useMemo( @@ -117,8 +158,9 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string [pinEvents, applySearch, isEventDeleted] ) 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]) @@ -141,17 +183,18 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string }, [searchQuery, pubkey]) useEffect(() => { - if (!loadingPins && !loadingTimeline) { + if (!loadingPins && !loadingTimeline && !loadingZapPollVotes) { setIsRefreshing(false) } - }, [loadingPins, loadingTimeline]) + }, [loadingPins, loadingTimeline, loadingZapPollVotes]) const refreshAll = useCallback(() => { setIsRefreshing(true) refreshPins() refreshTimeline() + reloadZapPollVotes() void client.fetchDeletionEventsForPubkey(pubkey) - }, [refreshPins, refreshTimeline, pubkey]) + }, [refreshPins, refreshTimeline, reloadZapPollVotes, pubkey]) useImperativeHandle(ref, () => ({ refresh: refreshAll }), [refreshAll]) @@ -169,7 +212,8 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string return () => observer.disconnect() }, [totalVisible, mergedDisplay.length]) - const loading = (loadingPins || loadingTimeline) && mergedDisplay.length === 0 + const loading = + (loadingPins || loadingTimeline || loadingZapPollVotes) && mergedDisplay.length === 0 if (loading) { return ( @@ -190,7 +234,7 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string ) } - if (!mergedDisplay.length && !loadingPins && !loadingTimeline) { + if (!mergedDisplay.length && !loadingPins && !loadingTimeline && !loadingZapPollVotes) { return (
@@ -255,13 +299,14 @@ const ProfileFeedWithPins = forwardRef<{ refresh: () => void }, { pubkey: string )} {displayedFeed.length > 0 && (
- {displayedFeed.map((event) => ( + {displayedFeed.map((row) => ( ))}
diff --git a/src/constants.ts b/src/constants.ts index 3dfb8f45..363f8ff3 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -287,6 +287,8 @@ export const ExtendedKind = { VIDEO: 21, SHORT_VIDEO: 22, POLL: 1068, + /** NIP-B9 zap poll (paid votes via zaps). */ + ZAP_POLL: 6969, POLL_RESPONSE: 1018, COMMENT: 1111, VOICE: 1222, @@ -398,6 +400,7 @@ export const SUPPORTED_KINDS = [ ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO, ExtendedKind.POLL, + ExtendedKind.ZAP_POLL, ExtendedKind.COMMENT, ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT, diff --git a/src/hooks/useProfileZapPollParticipation.tsx b/src/hooks/useProfileZapPollParticipation.tsx new file mode 100644 index 00000000..2a7f1f46 --- /dev/null +++ b/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() + 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([]) + 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 } +} diff --git a/src/hooks/useZapPollTally.tsx b/src/hooks/useZapPollTally.tsx new file mode 100644 index 00000000..69d8f5bd --- /dev/null +++ b/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() + 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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(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]) +} diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index 4457bdda..a931da59 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -390,6 +390,14 @@ export default { Topics: 'Topics', 'Open in a': 'Open in {{a}}', '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 discussion upvote summary': 'upvoted 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)', 'Remove poll': 'Remove poll', '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', Media: 'Media', Interests: 'Interests', @@ -1231,7 +1261,6 @@ export default { '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.', - Option: 'Option', Optional: 'Optional', 'Optional image for the event': 'Optional image for the event', 'Optionally, add the full quote/context to show your highlight within it': diff --git a/src/lib/kind-description.ts b/src/lib/kind-description.ts index a1f7e151..723d8a9a 100644 --- a/src/lib/kind-description.ts +++ b/src/lib/kind-description.ts @@ -42,6 +42,8 @@ export function getKindDescription(kind: number): { number: number; description: return { number: 9802, description: 'Highlight' } case ExtendedKind.POLL: return { number: 1068, description: 'Poll' } + case ExtendedKind.ZAP_POLL: + return { number: 6969, description: 'Zap poll' } case ExtendedKind.PUBLIC_MESSAGE: return { number: 24, description: 'Public Message' } case ExtendedKind.DISCUSSION: diff --git a/src/lib/note-renderable-kinds.ts b/src/lib/note-renderable-kinds.ts index 88d343f3..3faee745 100644 --- a/src/lib/note-renderable-kinds.ts +++ b/src/lib/note-renderable-kinds.ts @@ -18,9 +18,16 @@ const RENDERABLE_NOTE_KINDS = new Set([ ExtendedKind.CITATION_INTERNAL, ExtendedKind.CITATION_EXTERNAL, 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 { return RENDERABLE_NOTE_KINDS.has(kind) } diff --git a/src/lib/zap-poll.ts b/src/lib/zap-poll.ts new file mode 100644 index 00000000..128348c2 --- /dev/null +++ b/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 + totalSats: number + receiptCountByOption: Map +} + +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() + const receiptCountByOption = new Map() + 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() + + 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 + } +} diff --git a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts index 3f8a3e60..99d79366 100644 --- a/src/pages/primary/SpellsPage/fauxSpellFeeds.ts +++ b/src/pages/primary/SpellsPage/fauxSpellFeeds.ts @@ -10,12 +10,13 @@ * 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 { RENDERABLE_NOTE_KINDS_SORTED } from '@/lib/note-renderable-kinds' import { buildProfileAugmentedReadRelayUrls } from '@/lib/favorites-feed-relays' import { normalizeTopic } from '@/lib/discussion-topics' import { userIdToPubkey } from '@/lib/pubkey' import { normalizeUrl } from '@/lib/url' 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). */ 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). - * Not full {@link PROFILE_FEED_KINDS} — that asked relays for huge multi-kind slices per `#p`. - * - * 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. + * Same kinds as {@link RENDERABLE_NOTE_KINDS_SORTED}: anything `Note` renders with a real card, not + * 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). */ -export const NOTIFICATION_SPELL_KINDS = [ - 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 +export const NOTIFICATION_SPELL_KINDS = RENDERABLE_NOTE_KINDS_SORTED /** 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 diff --git a/src/pages/secondary/NotePage/index.tsx b/src/pages/secondary/NotePage/index.tsx index 8bad8929..47300145 100644 --- a/src/pages/secondary/NotePage/index.tsx +++ b/src/pages/secondary/NotePage/index.tsx @@ -177,6 +177,8 @@ const NotePage = forwardRef(({ id, index, hideTitlebar = false, initialEvent }: return 'Note: Highlight' case 1068: // ExtendedKind.POLL return 'Note: Poll' + case 6969: // ExtendedKind.ZAP_POLL + return 'Note: Zap Poll' case 31987: // ExtendedKind.RELAY_REVIEW return 'Note: Relay Review' case 31922: // ExtendedKind.CALENDAR_EVENT_DATE diff --git a/src/services/indexed-db.service.ts b/src/services/indexed-db.service.ts index 26d1d287..f49ee8d5 100644 --- a/src/services/indexed-db.service.ts +++ b/src/services/indexed-db.service.ts @@ -56,7 +56,7 @@ export const StoreNames = { } /** 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). */ const PROFILE_AND_PAYMENT_CACHE_MAX_AGE_MS = 5 * 60 * 1000 @@ -69,6 +69,20 @@ function idbEventToError(ev: Parameters>[0]): 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 { static instance: IndexedDbService static getInstance(): IndexedDbService { @@ -240,6 +254,7 @@ class IndexedDbService { if (!db.objectStoreNames.contains(StoreNames.BADGE_DEFINITION_EVENTS)) { db.createObjectStore(StoreNames.BADGE_DEFINITION_EVENTS, { keyPath: 'key' }) } + ensureMissingObjectStores(db) } } ); @@ -1538,12 +1553,7 @@ class IndexedDbService { request.onupgradeneeded = (event) => { const db = (event.target as IDBOpenDBRequest).result - // Create any missing stores - Object.values(StoreNames).forEach(storeName => { - if (!db.objectStoreNames.contains(storeName)) { - db.createObjectStore(storeName, { keyPath: 'key' }) - } - }) + ensureMissingObjectStores(db) } }) } @@ -1575,12 +1585,17 @@ class IndexedDbService { 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( - stores.map((store) => store.name), + existingStores.map((store) => store.name), 'readwrite' ) await Promise.allSettled( - stores.map(({ name, expirationTimestamp }) => { + existingStores.map(({ name, expirationTimestamp }) => { if (expirationTimestamp < 0) { return Promise.resolve() } diff --git a/src/services/lightning.service.ts b/src/services/lightning.service.ts index b0272978..f9c9f747 100644 --- a/src/services/lightning.service.ts +++ b/src/services/lightning.service.ts @@ -1,5 +1,10 @@ import { FAST_READ_RELAY_URLS, CODY_PUBKEY, JUMBLE_PUBKEY } from '@/constants' import { getZapInfoFromEvent } from '@/lib/event-metadata' +import { + buildZapPollVoteRequestTemplate, + isZapPollVoteEligible, + type TZapPollMeta +} from '@/lib/zap-poll' import { TProfile } from '@/types' import { init, launchPaymentModal } from '@getalby/bitcoin-connect-react' 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 | 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( invoice: string, closeOuterModel?: () => void diff --git a/src/services/local-storage.service.ts b/src/services/local-storage.service.ts index 96fdea32..59be3afd 100644 --- a/src/services/local-storage.service.ts +++ b/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). this.showKinds = 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) const showKind1OPsStr = window.localStorage.getItem(StorageKey.SHOW_KIND_1_OPs) diff --git a/src/services/mention-event-search.service.ts b/src/services/mention-event-search.service.ts index 78d65491..a496fae6 100644 --- a/src/services/mention-event-search.service.ts +++ b/src/services/mention-event-search.service.ts @@ -18,7 +18,8 @@ export const NEVENT_KINDS = [ ExtendedKind.PICTURE, ExtendedKind.VIDEO, ExtendedKind.SHORT_VIDEO, - ExtendedKind.POLL, + ExtendedKind.POLL, + ExtendedKind.ZAP_POLL, ExtendedKind.COMMENT, ExtendedKind.VOICE, ExtendedKind.VOICE_COMMENT,