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
This commit is contained in:
Claude
2026-01-22 21:32:17 +00:00
parent bb39657d5f
commit d2a2c10549
2 changed files with 88 additions and 87 deletions

View File

@@ -1,28 +1,25 @@
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 { KindRenderer } from "./index";
import { EventCardSkeleton } from "@/components/ui/skeleton";
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 poll being voted on
* Displays the vote choice with the embedded poll
*/
export function PollResponseRenderer({ event }: BaseEventProps) {
export function PollResponseRenderer({ event, depth = 0 }: BaseEventProps) {
const pollEventId = getPollEventId(event);
const relayHint = getPollRelayHint(event);
const selectedOptions = getSelectedOptions(event);
// Create event pointer for fetching the poll
const eventPointer = useMemo(() => {
// Create event pointer for the poll
const pollPointer: EventPointer | undefined = useMemo(() => {
if (!pollEventId) return undefined;
return {
id: pollEventId,
@@ -30,26 +27,10 @@ export function PollResponseRenderer({ event }: BaseEventProps) {
};
}, [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]);
// Display selected options (show first for single choice, all for multi)
// We show all options here since we don't have the poll type until it loads
const displayText =
selectedOptions.length > 0 ? selectedOptions.join(", ") : "unknown option";
return (
<BaseEventContainer event={event}>
@@ -59,28 +40,17 @@ export function PollResponseRenderer({ event }: BaseEventProps) {
<Vote className="size-4" />
<span className="text-sm">
Voted for:{" "}
{displayedLabels.length > 0 ? (
<span className="text-foreground font-medium">
{displayedLabels.join(", ")}
</span>
{selectedOptions.length > 0 ? (
<span className="text-foreground font-medium">{displayText}</span>
) : (
<span className="italic">unknown option</span>
)}
</span>
</div>
{/* Embedded poll event (if loaded) */}
{pollEvent && (
<div className="border border-muted rounded">
<KindRenderer event={pollEvent} />
</div>
)}
{/* Loading state */}
{pollEventId && !pollEvent && (
<div className="border border-muted rounded p-2">
<EventCardSkeleton variant="compact" showActions={false} />
</div>
{/* Embedded poll using QuotedEvent */}
{pollPointer && (
<QuotedEvent eventPointer={pollPointer} depth={depth + 1} />
)}
{/* No poll reference */}

View File

@@ -1,5 +1,5 @@
import type { NostrEvent } from "@/types/nostr";
import { getTagValue } from "applesauce-core/helpers";
import { getTagValue, getOrComputeCachedValue } from "applesauce-core/helpers";
import { getTagValues } from "./nostr-utils";
/**
@@ -7,6 +7,8 @@ import { getTagValues } from "./nostr-utils";
*
* 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 {
@@ -24,9 +26,19 @@ export interface PollData {
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
* The question is stored in the event content (no caching needed - direct access)
*/
export function getPollQuestion(event: NostrEvent): string {
return event.content || "";
@@ -37,12 +49,14 @@ export function getPollQuestion(event: NostrEvent): string {
* 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],
}));
return getOrComputeCachedValue(event, PollOptionsSymbol, () =>
event.tags
.filter((tag) => tag[0] === "option" && tag[1] && tag[2])
.map((tag) => ({
id: tag[1],
label: tag[2],
})),
);
}
/**
@@ -50,9 +64,10 @@ export function getPollOptions(event: NostrEvent): PollOption[] {
* Defaults to "singlechoice" if not specified
*/
export function getPollType(event: NostrEvent): PollType {
const pollType = getTagValue(event, "polltype");
if (pollType === "multiplechoice") return "multiplechoice";
return "singlechoice";
return getOrComputeCachedValue(event, PollTypeSymbol, () => {
const pollType = getTagValue(event, "polltype");
return pollType === "multiplechoice" ? "multiplechoice" : "singlechoice";
});
}
/**
@@ -60,21 +75,26 @@ export function getPollType(event: NostrEvent): PollType {
* 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;
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 getTagValues(event, "relay");
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);
@@ -86,48 +106,55 @@ export function isPollEnded(event: NostrEvent): boolean {
* Parse all poll data from a poll event
*/
export function getPollData(event: NostrEvent): PollData {
return {
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 {
const eTag = event.tags.find((tag) => tag[0] === "e");
return eTag?.[1] || 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 {
const eTag = event.tags.find((tag) => tag[0] === "e");
return eTag?.[2] || 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
* For singlechoice polls, only the first response is considered valid
* For multiplechoice polls, the first response for each option ID is valid
* Returns unique option IDs, preserving order
*/
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<string>();
const result: string[] = [];
for (const tag of responses) {
if (!seen.has(tag[1])) {
seen.add(tag[1]);
result.push(tag[1]);
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;
return result;
});
}
/**
@@ -139,6 +166,8 @@ export function getSelectedOptions(event: NostrEvent): string[] {
* - 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[],
@@ -146,10 +175,10 @@ export function countVotes(
pollEndsAt: number | null,
): Map<string, number> {
// Filter out responses after poll end
let validResponses = responses;
if (pollEndsAt !== null) {
validResponses = responses.filter((e) => e.created_at <= pollEndsAt);
}
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>();
@@ -184,15 +213,17 @@ export function countVotes(
/**
* 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 {
let validResponses = responses;
if (pollEndsAt !== null) {
validResponses = responses.filter((e) => e.created_at <= pollEndsAt);
}
const validResponses =
pollEndsAt !== null
? responses.filter((e) => e.created_at <= pollEndsAt)
: responses;
const pubkeys = new Set(validResponses.map((e) => e.pubkey));
return pubkeys.size;