Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 10 additions & 34 deletions src/components/PollDisplay.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,11 +8,11 @@
import {
parsePollFromEvent,
isPollExpired,
countVotes,
buildVoteTags,
type PollData,
type PollResults
} from '$lib/polls';
import { getVoteResults, type VoteHandle } from '$lib/voteCache';

export let event: NDKEvent;

Expand All @@ -23,13 +23,13 @@
voters: new Set(),
votesByPubkey: new Map()
};
let voteEvents: NDKEvent[] = [];
let selectedOptions: Set<string> = new Set();
let showResults = false;
let voting = false;
let voted = false;
let voteError = '';
let voteSub: any = null;
let voteHandle: VoteHandle | null = null;
let unsubResults: (() => void) | null = null;

$: expired = pollData ? isPollExpired(pollData.endsAt) : false;
$: userVoted = $userPublickey ? results.voters.has($userPublickey) : false;
Expand All @@ -45,41 +45,18 @@
onMount(() => {
pollData = parsePollFromEvent(event);
if (pollData) {
fetchVotes();
voteHandle = getVoteResults(event.id, pollData.pollType);
unsubResults = voteHandle.results.subscribe((r) => {
results = r;
});
}
});

onDestroy(() => {
if (voteSub) {
try {
voteSub.stop();
} catch {}
}
if (unsubResults) unsubResults();
if (voteHandle) voteHandle.cleanup();
});

function fetchVotes() {
if (!$ndk || !event.id) return;

const processedIds = new Set<string>();

voteSub = $ndk.subscribe(
{ kinds: [1018 as number], '#e': [event.id] },
{ closeOnEose: false }
);

voteSub.on('event', (e: NDKEvent) => {
if (processedIds.has(e.id)) return;
processedIds.add(e.id);
voteEvents = [...voteEvents, e];
recountVotes();
});
}

function recountVotes() {
if (!pollData) return;
results = countVotes(voteEvents, pollData.pollType);
}

function toggleOption(optionId: string) {
if (displayResults || voting || expired) return;

Expand Down Expand Up @@ -114,8 +91,7 @@
await publishQueue.publishWithRetry(voteEvent, 'all');

voted = true;
voteEvents = [...voteEvents, voteEvent];
recountVotes();
voteHandle?.addLocalVote(voteEvent);
} catch (err) {
console.error('[PollDisplay] Failed to vote:', err);
voteError = 'Failed to submit vote. Please try again.';
Expand Down
163 changes: 161 additions & 2 deletions src/lib/primalCache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -185,8 +185,8 @@ export class PrimalCacheService {
} catch (error) {
console.error('[PrimalCache] Error parsing profile metadata:', error);
}
} else if (event.kind === 1 || event.kind === 1068) {
// Note or poll event
} else if (event.kind === 1 || event.kind === 1068 || event.kind === 1018) {
// Note, poll, or vote event
pending.events.push(event);
} else if (event.kind === 30023) {
// Longform article event
Expand Down Expand Up @@ -549,6 +549,121 @@ export class PrimalCacheService {
});
}

/**
* Fetch polls (kind 1068) from Primal cache
*/
public async fetchPolls(
options: { limit?: number; since?: number; until?: number } = {},
timeoutMs: number = 8000
): Promise<PrimalFeedResponse> {
if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
await this.connect();
}

if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not connected');
}

const { limit = 100, since, until } = options;
const requestId = this.generateRequestId();

const cacheParams: Record<string, unknown> = {
limit,
kind: 1068
};

if (since) cacheParams.since = since;
if (until) cacheParams.until = until;

const request = [
'REQ',
requestId,
{
cache: ['explore_global_latest_with_filter', cacheParams]
}
];

return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error('Poll fetch request timed out'));
}, timeoutMs);

this.pendingRequests.set(requestId, {
resolve: (value) => resolve(value as PrimalFeedResponse),
reject,
timeout,
profiles: [],
events: [],
follows: [],
userStats: null,
type: 'feed'
});

try {
this.ws!.send(JSON.stringify(request));
} catch (error) {
clearTimeout(timeout);
this.pendingRequests.delete(requestId);
reject(error as Error);
}
});
}

/**
* Batch-fetch vote events (kind:1018) for multiple polls via standard Nostr REQ.
* Returns raw PrimalEvent[] for all votes referencing the given poll IDs.
*/
public async fetchVoteEvents(
pollIds: string[],
timeoutMs: number = 6000
): Promise<PrimalFeedResponse> {
if (!pollIds || pollIds.length === 0) {
return { events: [], profiles: [] };
}

if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
await this.connect();
}

if (!this.ws || this.ws.readyState !== WebSocket.OPEN) {
throw new Error('WebSocket not connected');
}

const requestId = this.generateRequestId();
const request = ['REQ', requestId, {
kinds: [1018],
'#e': pollIds,
limit: Math.min(pollIds.length * 100, 5000)
}];
Comment on lines +633 to +638
Copy link

Copilot AI Mar 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fetchVoteEvents sets limit: pollIds.length * 100. With a large poll batch (e.g., up to 200 polls on /polls), this can become a 20k-event request which may be slow or rejected by the cache server. Consider capping the limit and/or chunking pollIds into multiple requests (then merging results) to keep request size predictable.

Copilot uses AI. Check for mistakes.

return new Promise((resolve, reject) => {
const timeout = setTimeout(() => {
this.pendingRequests.delete(requestId);
reject(new Error('Vote fetch request timed out'));
}, timeoutMs);

this.pendingRequests.set(requestId, {
resolve: (value) => resolve(value as PrimalFeedResponse),
reject,
timeout,
profiles: [],
events: [],
follows: [],
userStats: null,
type: 'feed'
});

try {
this.ws!.send(JSON.stringify(request));
} catch (error) {
clearTimeout(timeout);
this.pendingRequests.delete(requestId);
reject(error as Error);
}
});
}

/**
* Fetch articles by hashtags using standard Nostr REQ
* Falls back to regular REQ if cache endpoints don't support articles
Expand Down Expand Up @@ -841,6 +956,50 @@ export async function fetchGlobalFromPrimal(
return { events, profiles };
}

/**
* Fetch polls (kind 1068) from Primal cache
* Converts Primal events to NDKEvent format for compatibility
*/
export async function fetchPollsFromPrimal(
ndk: NDK,
options: { limit?: number; since?: number; until?: number } = {}
): Promise<PrimalFeedResult> {
const cache = getPrimalCache();
if (!cache) {
throw new Error('Primal cache not available');
}

const { limit = 100, since, until } = options;

const response = await cache.fetchPolls({ limit, since, until });

const events = response.events.map(e => primalEventToNDKLike(e, ndk));

const profiles = new Map<string, PrimalProfile>();
for (const profile of response.profiles) {
profiles.set(profile.pubkey, profile);
}

return { events, profiles };
}

/**
* Batch-fetch vote events (kind:1018) for multiple polls from Primal cache.
* Returns NDKEvent[] for direct use in vote counting.
*/
export async function fetchVoteEventsFromPrimal(
ndk: NDK,
pollIds: string[]
): Promise<NDKEvent[]> {
const cache = getPrimalCache();
if (!cache) {
throw new Error('Primal cache not available');
}

const response = await cache.fetchVoteEvents(pollIds);
return response.events.map(e => primalEventToNDKLike(e, ndk));
}

/**
* Helper to calculate "7 days ago" timestamp
*/
Expand Down
Loading
Loading