mirror of
https://github.com/purrgrammer/grimoire.git
synced 2026-06-05 10:11:12 +02:00
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:
@@ -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 */}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user