mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-04-17 02:47:18 +02:00
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:
253
src/components/nostr/kinds/PollDetailRenderer.tsx
Normal file
253
src/components/nostr/kinds/PollDetailRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
104
src/components/nostr/kinds/PollRenderer.tsx
Normal file
104
src/components/nostr/kinds/PollRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
86
src/components/nostr/kinds/PollResponseRenderer.tsx
Normal file
86
src/components/nostr/kinds/PollResponseRenderer.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
@@ -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
230
src/lib/nip88-helpers.ts
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user