Add NIP-88 Poll support with renderers and helpers (#207)

* 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

* fix(nip88): use RelayLink component and differentiate poll type icons

- Use ListCheck icon for single choice, ListChecks for multi choice
- Replace relay text spans with clickable RelayLink components
- Shorten "Vote on these relays" to "Relays"

* refactor(nip88): production readiness improvements

- Add symbol-based caching to all helper functions using getOrComputeCachedValue
- Use QuotedEvent component for embedded polls in PollResponseRenderer
- Simplify PollResponseRenderer by leveraging QuotedEvent's loading states
- Add clear documentation about what can/cannot be cached

* fix(nip88): restore option label resolution in PollResponseRenderer

---------

Co-authored-by: Claude <noreply@anthropic.com>
This commit is contained in:
Alejandro
2026-01-22 22:39:31 +01:00
committed by GitHub
parent 459159faca
commit 7838b0ab98
5 changed files with 679 additions and 0 deletions

View File

@@ -0,0 +1,253 @@
import { useMemo } from "react";
import type { NostrEvent } from "@/types/nostr";
import {
ListCheck,
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 { RelayLink } from "../RelayLink";
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 (
<div className="flex flex-col gap-6 p-6 max-w-2xl mx-auto">
{/* Header */}
<div className="flex flex-col gap-4">
{/* Poll Type Badge */}
<div className="flex items-center gap-2 text-muted-foreground">
{pollType === "multiplechoice" ? (
<ListChecks className="size-5" />
) : (
<ListCheck className="size-5" />
)}
<span className="text-sm uppercase tracking-wide">
{pollType === "multiplechoice"
? "Multiple Choice"
: "Single Choice"}{" "}
Poll
</span>
{ended && (
<span className="px-2 py-0.5 text-xs bg-muted rounded-full">
Ended
</span>
)}
</div>
{/* Question */}
<h1 className="text-2xl font-bold text-foreground">
{question || "Poll"}
</h1>
{/* Author */}
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<span>by</span>
<UserName pubkey={event.pubkey} className="font-medium" />
</div>
</div>
{/* Stats */}
<div className="flex items-center gap-6 py-3 border-y border-border">
<div className="flex items-center gap-2">
<Users className="size-4 text-muted-foreground" />
<span className="font-medium">{voterCount}</span>
<span className="text-muted-foreground">
{voterCount === 1 ? "voter" : "voters"}
</span>
</div>
{endsAt && (
<div className="flex items-center gap-2">
<Clock className="size-4 text-muted-foreground" />
<span className="text-muted-foreground">
{ended ? "Ended" : "Ends"} {endTimeText}
</span>
</div>
)}
</div>
{/* Options with vote counts */}
<div className="flex flex-col gap-3">
{loading && responses.length === 0 ? (
// Loading skeleton
<div className="flex flex-col gap-3">
{options.map((option) => (
<div
key={option.id}
className="flex flex-col gap-2 p-4 bg-muted/30 rounded-lg"
>
<div className="flex items-center justify-between">
<Skeleton className="h-5 w-32" />
<Skeleton className="h-4 w-16" />
</div>
<Skeleton className="h-2 w-full" />
</div>
))}
</div>
) : (
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 (
<div
key={option.id}
className={`flex flex-col gap-2 p-4 rounded-lg transition-colors ${
isWinner
? "bg-primary/10 border border-primary/30"
: "bg-muted/30"
}`}
>
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
{pollType === "multiplechoice" ? (
<CheckCircle2
className={`size-4 ${isWinner ? "text-primary" : "text-muted-foreground"}`}
/>
) : (
<CircleDot
className={`size-4 ${isWinner ? "text-primary" : "text-muted-foreground"}`}
/>
)}
<span
className={`font-medium ${isWinner ? "text-foreground" : ""}`}
>
{option.label}
</span>
</div>
<div className="flex items-center gap-2 text-sm">
<span
className={`font-mono ${isWinner ? "font-bold" : "text-muted-foreground"}`}
>
{votes}
</span>
<span className="text-muted-foreground">
({percentage.toFixed(1)}%)
</span>
</div>
</div>
<Progress
value={percentage}
className={`h-2 ${isWinner ? "[&>*]:bg-primary" : ""}`}
/>
</div>
);
})
)}
{options.length === 0 && (
<p className="text-center text-muted-foreground py-8">
No poll options defined.
</p>
)}
</div>
{/* Poll metadata */}
{pollRelays.length > 0 && (
<div className="flex flex-col gap-2 pt-4 border-t border-border">
<h3 className="text-sm font-medium text-muted-foreground uppercase tracking-wide">
Relays
</h3>
<div className="flex flex-col gap-1">
{pollRelays.map((relay) => (
<RelayLink
key={relay}
url={relay}
showInboxOutbox={false}
className="text-sm"
/>
))}
</div>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,104 @@
import {
BaseEventProps,
BaseEventContainer,
ClickableEventTitle,
} from "./BaseEventRenderer";
import {
ListCheck,
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 (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-3">
{/* Poll Header */}
<div className="flex items-center gap-2 text-muted-foreground">
{pollType === "multiplechoice" ? (
<ListChecks className="size-4" />
) : (
<ListCheck className="size-4" />
)}
<span className="text-xs uppercase tracking-wide">
{pollType === "multiplechoice"
? "Multiple Choice"
: "Single Choice"}{" "}
Poll
</span>
</div>
{/* Question */}
<ClickableEventTitle
event={event}
className="text-base font-semibold text-foreground leading-tight"
>
{question || "Poll"}
</ClickableEventTitle>
{/* Options Preview */}
{options.length > 0 && (
<div className="flex flex-col gap-1.5">
{options.slice(0, 4).map((option) => (
<div
key={option.id}
className="flex items-center gap-2 text-sm text-muted-foreground"
>
{pollType === "multiplechoice" ? (
<CheckCircle2 className="size-3.5 shrink-0" />
) : (
<CircleDot className="size-3.5 shrink-0" />
)}
<span className="truncate">{option.label}</span>
</div>
))}
{options.length > 4 && (
<span className="text-xs text-muted-foreground">
+{options.length - 4} more options
</span>
)}
</div>
)}
{/* Deadline */}
{endsAt && (
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
<Clock className="size-3" />
{ended ? (
<span>Ended {endTimeText}</span>
) : (
<span>Ends {endTimeText}</span>
)}
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -0,0 +1,86 @@
import { useMemo } from "react";
import type { EventPointer } from "nostr-tools/nip19";
import { BaseEventProps, BaseEventContainer } from "./BaseEventRenderer";
import { Vote } from "lucide-react";
import { useNostrEvent } from "@/hooks/useNostrEvent";
import { QuotedEvent } from "../QuotedEvent";
import {
getPollEventId,
getPollRelayHint,
getSelectedOptions,
getPollOptions,
getPollType,
} from "@/lib/nip88-helpers";
/**
* Renderer for Kind 1018 - Poll Response (NIP-88)
* Displays the vote choice with the embedded poll
*/
export function PollResponseRenderer({ event, depth = 0 }: BaseEventProps) {
const pollEventId = getPollEventId(event);
const relayHint = getPollRelayHint(event);
const selectedOptions = getSelectedOptions(event);
// Create event pointer for the poll
const pollPointer: EventPointer | undefined = useMemo(() => {
if (!pollEventId) return undefined;
return {
id: pollEventId,
relays: relayHint ? [relayHint] : undefined,
};
}, [pollEventId, relayHint]);
// Fetch the poll event to resolve option labels
const pollEvent = useNostrEvent(pollPointer);
// Get poll type and resolve option IDs to labels
const pollType = pollEvent ? getPollType(pollEvent) : "singlechoice";
const displayedLabels = useMemo(() => {
const pollOptions = pollEvent ? getPollOptions(pollEvent) : [];
const labels =
pollOptions.length === 0
? selectedOptions // Fall back to raw IDs if poll not loaded
: 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]);
const displayText =
displayedLabels.length > 0 ? displayedLabels.join(", ") : "unknown option";
return (
<BaseEventContainer event={event}>
<div className="flex flex-col gap-2">
{/* Vote indicator */}
<div className="flex items-center gap-2 text-muted-foreground">
<Vote className="size-4" />
<span className="text-sm">
Voted for:{" "}
{displayedLabels.length > 0 ? (
<span className="text-foreground font-medium">{displayText}</span>
) : (
<span className="italic">unknown option</span>
)}
</span>
</div>
{/* Embedded poll using QuotedEvent */}
{pollPointer && (
<QuotedEvent eventPointer={pollPointer} depth={depth + 1} />
)}
{/* No poll reference */}
{!pollEventId && (
<div className="text-xs text-muted-foreground italic">
Poll reference missing
</div>
)}
</div>
</BaseEventContainer>
);
}

View File

@@ -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<number, React.ComponentType<BaseEventProps>> = {
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)

230
src/lib/nip88-helpers.ts Normal file
View File

@@ -0,0 +1,230 @@
import type { NostrEvent } from "@/types/nostr";
import { getTagValue, getOrComputeCachedValue } 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).
*
* Uses applesauce's symbol-based caching to avoid recomputation.
*/
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;
}
// Symbols for caching computed values on events
const PollOptionsSymbol = Symbol("pollOptions");
const PollTypeSymbol = Symbol("pollType");
const PollEndsAtSymbol = Symbol("pollEndsAt");
const PollRelaysSymbol = Symbol("pollRelays");
const PollDataSymbol = Symbol("pollData");
const PollEventIdSymbol = Symbol("pollEventId");
const PollRelayHintSymbol = Symbol("pollRelayHint");
const SelectedOptionsSymbol = Symbol("selectedOptions");
/**
* Get the poll question from a poll event (kind 1068)
* The question is stored in the event content (no caching needed - direct access)
*/
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 getOrComputeCachedValue(event, PollOptionsSymbol, () =>
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 {
return getOrComputeCachedValue(event, PollTypeSymbol, () => {
const pollType = getTagValue(event, "polltype");
return pollType === "multiplechoice" ? "multiplechoice" : "singlechoice";
});
}
/**
* Get the poll end timestamp (unix seconds)
* Returns null if not specified
*/
export function getPollEndsAt(event: NostrEvent): number | null {
return getOrComputeCachedValue(event, PollEndsAtSymbol, () => {
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 getOrComputeCachedValue(event, PollRelaysSymbol, () =>
getTagValues(event, "relay"),
);
}
/**
* Check if a poll has ended
* Note: This is intentionally NOT cached as it depends on current time
*/
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 getOrComputeCachedValue(event, PollDataSymbol, () => ({
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 {
return getOrComputeCachedValue(event, PollEventIdSymbol, () => {
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 {
return getOrComputeCachedValue(event, PollRelayHintSymbol, () => {
const eTag = event.tags.find((tag) => tag[0] === "e");
return eTag?.[2] || null;
});
}
/**
* Get selected option IDs from a poll response event
* Returns unique option IDs, preserving order
*/
export function getSelectedOptions(event: NostrEvent): string[] {
return getOrComputeCachedValue(event, SelectedOptionsSymbol, () => {
const responses = event.tags.filter(
(tag) => tag[0] === "response" && tag[1],
);
// Return unique option IDs, preserving order
const seen = new Set<string>();
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
*
* Note: This operates on collections and cannot be cached per-event
*/
export function countVotes(
responses: NostrEvent[],
pollType: PollType,
pollEndsAt: number | null,
): Map<string, number> {
// Filter out responses after poll end
const validResponses =
pollEndsAt !== null
? responses.filter((e) => e.created_at <= pollEndsAt)
: responses;
// Keep only the latest response per pubkey
const latestByPubkey = new Map<string, NostrEvent>();
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<string, number>();
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
*
* Note: This operates on collections and cannot be cached per-event
*/
export function getUniqueVoterCount(
responses: NostrEvent[],
pollEndsAt: number | null,
): number {
const validResponses =
pollEndsAt !== null
? responses.filter((e) => e.created_at <= pollEndsAt)
: responses;
const pubkeys = new Set(validResponses.map((e) => e.pubkey));
return pubkeys.size;
}