From bda6864438210c592366c3a55174efdf83ab87b8 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 22 Jan 2026 21:14:06 +0000 Subject: [PATCH] feat(nip88): add poll event renderers Implement NIP-88 poll events support with: - PollRenderer: Feed view for kind 1068 polls showing question, options, type - PollDetailRenderer: Detail view with live vote counts and percentages - PollResponseRenderer: Feed view for kind 1018 showing voted options - nip88-helpers: Utilities for parsing poll data and counting votes --- .../nostr/kinds/PollDetailRenderer.tsx | 247 ++++++++++++++++++ src/components/nostr/kinds/PollRenderer.tsx | 94 +++++++ .../nostr/kinds/PollResponseRenderer.tsx | 95 +++++++ src/components/nostr/kinds/index.tsx | 6 + src/lib/nip88-helpers.ts | 199 ++++++++++++++ 5 files changed, 641 insertions(+) create mode 100644 src/components/nostr/kinds/PollDetailRenderer.tsx create mode 100644 src/components/nostr/kinds/PollRenderer.tsx create mode 100644 src/components/nostr/kinds/PollResponseRenderer.tsx create mode 100644 src/lib/nip88-helpers.ts diff --git a/src/components/nostr/kinds/PollDetailRenderer.tsx b/src/components/nostr/kinds/PollDetailRenderer.tsx new file mode 100644 index 0000000..cdf3f79 --- /dev/null +++ b/src/components/nostr/kinds/PollDetailRenderer.tsx @@ -0,0 +1,247 @@ +import { useMemo } from "react"; +import type { NostrEvent } from "@/types/nostr"; +import { + ListChecks, + Clock, + Users, + CheckCircle2, + CircleDot, +} from "lucide-react"; +import { useTimeline } from "@/hooks/useTimeline"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { useGrimoire } from "@/core/state"; +import { Progress } from "@/components/ui/progress"; +import { UserName } from "../UserName"; +import { Skeleton } from "@/components/ui/skeleton"; +import { AGGREGATOR_RELAYS } from "@/services/loaders"; +import { + getPollQuestion, + getPollOptions, + getPollType, + getPollEndsAt, + getPollRelays, + isPollEnded, + countVotes, + getUniqueVoterCount, +} from "@/lib/nip88-helpers"; + +/** + * Detail renderer for Kind 1068 - Poll (NIP-88) + * Shows full poll with vote counts and percentages + */ +export function PollDetailRenderer({ event }: { event: NostrEvent }) { + const { locale } = useGrimoire(); + + // Parse poll data + const question = getPollQuestion(event); + const options = getPollOptions(event); + const pollType = getPollType(event); + const endsAt = getPollEndsAt(event); + const pollRelays = getPollRelays(event); + const ended = isPollEnded(event); + + // Determine relays to fetch responses from + const relays = useMemo(() => { + if (pollRelays.length > 0) { + return [...pollRelays, ...AGGREGATOR_RELAYS]; + } + return AGGREGATOR_RELAYS; + }, [pollRelays]); + + // Fetch poll responses + const responseFilter = useMemo( + () => ({ + kinds: [1018], + "#e": [event.id], + }), + [event.id], + ); + + const { events: responses, loading } = useTimeline( + `poll-responses-${event.id}`, + responseFilter, + relays, + ); + + // Calculate votes + const voteCounts = useMemo( + () => countVotes(responses, pollType, endsAt), + [responses, pollType, endsAt], + ); + + const voterCount = useMemo( + () => getUniqueVoterCount(responses, endsAt), + [responses, endsAt], + ); + + // Calculate total votes for percentages + const totalVotes = useMemo(() => { + let total = 0; + for (const count of voteCounts.values()) { + total += count; + } + return total; + }, [voteCounts]); + + // Find the winning option(s) + const maxVotes = useMemo(() => { + let max = 0; + for (const count of voteCounts.values()) { + if (count > max) max = count; + } + return max; + }, [voteCounts]); + + const endTimeText = endsAt + ? formatTimestamp(endsAt, "absolute", locale.locale) + : null; + + return ( +
+ {/* Header */} +
+ {/* Poll Type Badge */} +
+ + + {pollType === "multiplechoice" + ? "Multiple Choice" + : "Single Choice"}{" "} + Poll + + {ended && ( + + Ended + + )} +
+ + {/* Question */} +

+ {question || "Poll"} +

+ + {/* Author */} +
+ by + +
+
+ + {/* Stats */} +
+
+ + {voterCount} + + {voterCount === 1 ? "voter" : "voters"} + +
+ {endsAt && ( +
+ + + {ended ? "Ended" : "Ends"} {endTimeText} + +
+ )} +
+ + {/* Options with vote counts */} +
+ {loading && responses.length === 0 ? ( + // Loading skeleton +
+ {options.map((option) => ( +
+
+ + +
+ +
+ ))} +
+ ) : ( + options.map((option) => { + const votes = voteCounts.get(option.id) || 0; + const percentage = totalVotes > 0 ? (votes / totalVotes) * 100 : 0; + const isWinner = votes > 0 && votes === maxVotes; + + return ( +
+
+
+ {pollType === "multiplechoice" ? ( + + ) : ( + + )} + + {option.label} + +
+
+ + {votes} + + + ({percentage.toFixed(1)}%) + +
+
+ *]:bg-primary" : ""}`} + /> +
+ ); + }) + )} + + {options.length === 0 && ( +

+ No poll options defined. +

+ )} +
+ + {/* Poll metadata */} + {pollRelays.length > 0 && ( +
+

+ Vote on these relays +

+
+ {pollRelays.map((relay) => ( + + {relay.replace(/^wss?:\/\//, "")} + + ))} +
+
+ )} +
+ ); +} diff --git a/src/components/nostr/kinds/PollRenderer.tsx b/src/components/nostr/kinds/PollRenderer.tsx new file mode 100644 index 0000000..48c12f7 --- /dev/null +++ b/src/components/nostr/kinds/PollRenderer.tsx @@ -0,0 +1,94 @@ +import { + BaseEventProps, + BaseEventContainer, + ClickableEventTitle, +} from "./BaseEventRenderer"; +import { ListChecks, Clock, CheckCircle2, CircleDot } from "lucide-react"; +import { formatTimestamp } from "@/hooks/useLocale"; +import { useGrimoire } from "@/core/state"; +import { + getPollQuestion, + getPollOptions, + getPollType, + getPollEndsAt, + isPollEnded, +} from "@/lib/nip88-helpers"; + +/** + * Renderer for Kind 1068 - Poll (NIP-88) + * Displays poll question, options, type, and deadline in feed view + */ +export function PollRenderer({ event }: BaseEventProps) { + const { locale } = useGrimoire(); + + const question = getPollQuestion(event); + const options = getPollOptions(event); + const pollType = getPollType(event); + const endsAt = getPollEndsAt(event); + const ended = isPollEnded(event); + + const endTimeText = endsAt + ? formatTimestamp(endsAt, "absolute", locale.locale) + : null; + + return ( + +
+ {/* Poll Header */} +
+ + + {pollType === "multiplechoice" + ? "Multiple Choice" + : "Single Choice"}{" "} + Poll + +
+ + {/* Question */} + + {question || "Poll"} + + + {/* Options Preview */} + {options.length > 0 && ( +
+ {options.slice(0, 4).map((option) => ( +
+ {pollType === "multiplechoice" ? ( + + ) : ( + + )} + {option.label} +
+ ))} + {options.length > 4 && ( + + +{options.length - 4} more options + + )} +
+ )} + + {/* Deadline */} + {endsAt && ( +
+ + {ended ? ( + Ended {endTimeText} + ) : ( + Ends {endTimeText} + )} +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/PollResponseRenderer.tsx b/src/components/nostr/kinds/PollResponseRenderer.tsx new file mode 100644 index 0000000..91dbdb1 --- /dev/null +++ b/src/components/nostr/kinds/PollResponseRenderer.tsx @@ -0,0 +1,95 @@ +import { useMemo } from "react"; +import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer"; +import { Vote } from "lucide-react"; +import { useNostrEvent } from "@/hooks/useNostrEvent"; +import { KindRenderer } from "./index"; +import { EventCardSkeleton } from "@/components/ui/skeleton"; +import { + getPollEventId, + getPollRelayHint, + getSelectedOptions, + getPollOptions, + getPollType, +} from "@/lib/nip88-helpers"; + +/** + * Renderer for Kind 1018 - Poll Response (NIP-88) + * Displays the vote choice with the poll being voted on + */ +export function PollResponseRenderer({ event }: BaseEventProps) { + const pollEventId = getPollEventId(event); + const relayHint = getPollRelayHint(event); + const selectedOptions = getSelectedOptions(event); + + // Create event pointer for fetching the poll + const eventPointer = useMemo(() => { + if (!pollEventId) return undefined; + return { + id: pollEventId, + relays: relayHint ? [relayHint] : undefined, + }; + }, [pollEventId, relayHint]); + + // Fetch the poll event + const pollEvent = useNostrEvent(eventPointer); + + // Get poll type from the poll event + const pollType = pollEvent ? getPollType(pollEvent) : "singlechoice"; + + // Map selected option IDs to labels + const displayedLabels = useMemo(() => { + const pollOptions = pollEvent ? getPollOptions(pollEvent) : []; + const labels = + pollOptions.length === 0 + ? selectedOptions + : selectedOptions.map((optionId) => { + const option = pollOptions.find((o) => o.id === optionId); + return option ? option.label : optionId; + }); + + // For singlechoice polls, only show the first vote + return pollType === "singlechoice" ? labels.slice(0, 1) : labels; + }, [selectedOptions, pollEvent, pollType]); + + return ( + +
+ {/* Vote indicator */} +
+ + + Voted for:{" "} + {displayedLabels.length > 0 ? ( + + {displayedLabels.join(", ")} + + ) : ( + unknown option + )} + +
+ + {/* Embedded poll event (if loaded) */} + {pollEvent && ( +
+ +
+ )} + + {/* Loading state */} + {pollEventId && !pollEvent && ( +
+ +
+ )} + + {/* No poll reference */} + {!pollEventId && ( +
+ Poll reference missing +
+ )} +
+
+ ); +} diff --git a/src/components/nostr/kinds/index.tsx b/src/components/nostr/kinds/index.tsx index fc5058c..9ce8574 100644 --- a/src/components/nostr/kinds/index.tsx +++ b/src/components/nostr/kinds/index.tsx @@ -154,6 +154,9 @@ import { RelayDiscoveryRenderer } from "./RelayDiscoveryRenderer"; import { RelayDiscoveryDetailRenderer } from "./RelayDiscoveryDetailRenderer"; import { GoalRenderer } from "./GoalRenderer"; import { GoalDetailRenderer } from "./GoalDetailRenderer"; +import { PollRenderer } from "./PollRenderer"; +import { PollDetailRenderer } from "./PollDetailRenderer"; +import { PollResponseRenderer } from "./PollResponseRenderer"; /** * Registry of kind-specific renderers @@ -173,7 +176,9 @@ const kindRenderers: Record> = { 20: Kind20Renderer, // Picture (NIP-68) 21: Kind21Renderer, // Video Event (NIP-71) 22: Kind22Renderer, // Short Video (NIP-71) + 1018: PollResponseRenderer, // Poll Response (NIP-88) 1063: Kind1063Renderer, // File Metadata (NIP-94) + 1068: PollRenderer, // Poll (NIP-88) 1111: Kind1111Renderer, // Post (NIP-22) 1222: VoiceMessageRenderer, // Voice Message (NIP-A0) 1311: LiveChatMessageRenderer, // Live Chat Message (NIP-53) @@ -286,6 +291,7 @@ const detailRenderers: Record< 3: Kind3DetailView, // Contact List Detail 8: BadgeAwardDetailRenderer, // Badge Award Detail (NIP-58) 777: SpellDetailRenderer, // Spell Detail + 1068: PollDetailRenderer, // Poll Detail (NIP-88) 1337: Kind1337DetailRenderer, // Code Snippet Detail (NIP-C0) 1617: PatchDetailRenderer, // Patch Detail (NIP-34) 1618: PullRequestDetailRenderer, // Pull Request Detail (NIP-34) diff --git a/src/lib/nip88-helpers.ts b/src/lib/nip88-helpers.ts new file mode 100644 index 0000000..c1ecbd4 --- /dev/null +++ b/src/lib/nip88-helpers.ts @@ -0,0 +1,199 @@ +import type { NostrEvent } from "@/types/nostr"; +import { getTagValue } from "applesauce-core/helpers"; +import { getTagValues } from "./nostr-utils"; + +/** + * NIP-88 Poll Helpers + * + * Utilities for parsing and working with poll events (kind 1068) + * and poll response events (kind 1018). + */ + +export interface PollOption { + id: string; + label: string; +} + +export type PollType = "singlechoice" | "multiplechoice"; + +export interface PollData { + question: string; + options: PollOption[]; + relays: string[]; + pollType: PollType; + endsAt: number | null; +} + +/** + * Get the poll question from a poll event (kind 1068) + * The question is stored in the event content + */ +export function getPollQuestion(event: NostrEvent): string { + return event.content || ""; +} + +/** + * Get all poll options from a poll event + * Options are stored as ["option", "option_id", "option_label"] tags + */ +export function getPollOptions(event: NostrEvent): PollOption[] { + return event.tags + .filter((tag) => tag[0] === "option" && tag[1] && tag[2]) + .map((tag) => ({ + id: tag[1], + label: tag[2], + })); +} + +/** + * Get the poll type (singlechoice or multiplechoice) + * Defaults to "singlechoice" if not specified + */ +export function getPollType(event: NostrEvent): PollType { + const pollType = getTagValue(event, "polltype"); + if (pollType === "multiplechoice") return "multiplechoice"; + return "singlechoice"; +} + +/** + * Get the poll end timestamp (unix seconds) + * Returns null if not specified + */ +export function getPollEndsAt(event: NostrEvent): number | null { + const endsAt = getTagValue(event, "endsAt"); + if (!endsAt) return null; + const timestamp = parseInt(endsAt, 10); + return isNaN(timestamp) ? null : timestamp; +} + +/** + * Get the relays where poll responses should be found + */ +export function getPollRelays(event: NostrEvent): string[] { + return getTagValues(event, "relay"); +} + +/** + * Check if a poll has ended + */ +export function isPollEnded(event: NostrEvent): boolean { + const endsAt = getPollEndsAt(event); + if (!endsAt) return false; + return Date.now() / 1000 > endsAt; +} + +/** + * Parse all poll data from a poll event + */ +export function getPollData(event: NostrEvent): PollData { + return { + question: getPollQuestion(event), + options: getPollOptions(event), + relays: getPollRelays(event), + pollType: getPollType(event), + endsAt: getPollEndsAt(event), + }; +} + +/** + * Get the poll event ID from a poll response event (kind 1018) + */ +export function getPollEventId(event: NostrEvent): string | null { + const eTag = event.tags.find((tag) => tag[0] === "e"); + return eTag?.[1] || null; +} + +/** + * Get the relay hint for the poll event from a poll response + */ +export function getPollRelayHint(event: NostrEvent): string | null { + const eTag = event.tags.find((tag) => tag[0] === "e"); + return eTag?.[2] || null; +} + +/** + * Get selected option IDs from a poll response event + * For singlechoice polls, only the first response is considered valid + * For multiplechoice polls, the first response for each option ID is valid + */ +export function getSelectedOptions(event: NostrEvent): string[] { + const responses = event.tags.filter((tag) => tag[0] === "response" && tag[1]); + // Return unique option IDs, preserving order + const seen = new Set(); + const result: string[] = []; + for (const tag of responses) { + if (!seen.has(tag[1])) { + seen.add(tag[1]); + result.push(tag[1]); + } + } + return result; +} + +/** + * Count votes from an array of poll response events + * Returns a map of option ID to vote count + * + * Per NIP-88: + * - Only one vote per pubkey is counted + * - The event with the largest timestamp (within poll limits) wins + * - For singlechoice, only the first response tag is counted + * - For multiplechoice, the first response tag for each option ID is counted + */ +export function countVotes( + responses: NostrEvent[], + pollType: PollType, + pollEndsAt: number | null, +): Map { + // Filter out responses after poll end + let validResponses = responses; + if (pollEndsAt !== null) { + validResponses = responses.filter((e) => e.created_at <= pollEndsAt); + } + + // Keep only the latest response per pubkey + const latestByPubkey = new Map(); + for (const response of validResponses) { + const existing = latestByPubkey.get(response.pubkey); + if (!existing || response.created_at > existing.created_at) { + latestByPubkey.set(response.pubkey, response); + } + } + + // Count votes + const counts = new Map(); + for (const response of latestByPubkey.values()) { + const selectedOptions = getSelectedOptions(response); + + if (pollType === "singlechoice") { + // Only count first option + const optionId = selectedOptions[0]; + if (optionId) { + counts.set(optionId, (counts.get(optionId) || 0) + 1); + } + } else { + // Count all unique options + for (const optionId of selectedOptions) { + counts.set(optionId, (counts.get(optionId) || 0) + 1); + } + } + } + + return counts; +} + +/** + * Get unique voter count from poll responses + */ +export function getUniqueVoterCount( + responses: NostrEvent[], + pollEndsAt: number | null, +): number { + let validResponses = responses; + if (pollEndsAt !== null) { + validResponses = responses.filter((e) => e.created_at <= pollEndsAt); + } + + const pubkeys = new Set(validResponses.map((e) => e.pubkey)); + return pubkeys.size; +}