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