You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

519 lines
13 KiB

<script lang="ts">
import { nostrClient } from '../../services/nostr/nostr-client.js';
import { relayManager } from '../../services/nostr/relay-manager.js';
import { sessionManager } from '../../services/auth/session-manager.js';
import { signAndPublish } from '../../services/nostr/auth-handler.js';
import { onMount } from 'svelte';
import type { NostrEvent } from '../../types/nostr.js';
import { KIND } from '../../types/kind-lookup.js';
interface Props {
pollEvent: NostrEvent; // The poll event (kind 1068)
}
let { pollEvent }: Props = $props();
interface PollOption {
id: string;
label: string;
}
interface PollVote {
pubkey: string;
optionIds: string[];
created_at: number;
}
let options = $state<PollOption[]>([]);
let pollType = $state<'singlechoice' | 'multiplechoice'>('singlechoice');
let endsAt = $state<number | null>(null);
let relayUrls = $state<string[]>([]);
let votes = $state<Map<string, PollVote>>(new Map()); // pubkey -> vote
let loading = $state(true);
let submittingVote = $state(false);
let userVote = $state<string[]>([]); // Option IDs the current user has voted for
let hasVoted = $state(false);
// Parse poll event
function parsePoll() {
// Extract options
const optionTags = pollEvent.tags.filter(t => t[0] === 'option' && t.length >= 3);
options = optionTags.map(t => ({
id: t[1],
label: t[2]
}));
// Extract polltype (default to singlechoice)
const polltypeTag = pollEvent.tags.find(t => t[0] === 'polltype');
pollType = (polltypeTag?.[1] === 'multiplechoice') ? 'multiplechoice' : 'singlechoice';
// Extract endsAt
const endsAtTag = pollEvent.tags.find(t => t[0] === 'endsAt');
if (endsAtTag?.[1]) {
endsAt = parseInt(endsAtTag[1], 10);
if (isNaN(endsAt)) endsAt = null;
}
// Extract relay URLs
relayUrls = pollEvent.tags
.filter(t => t[0] === 'relay' && t[1])
.map(t => t[1]);
}
// Check if poll has ended
let isExpired = $derived.by(() => {
if (!endsAt) return false;
return Date.now() / 1000 >= endsAt;
});
// Get vote counts per option
let voteCounts = $derived.by(() => {
const counts = new Map<string, number>();
options.forEach(opt => counts.set(opt.id, 0));
votes.forEach(vote => {
if (pollType === 'singlechoice') {
// For singlechoice, only count the first response
if (vote.optionIds.length > 0) {
const optionId = vote.optionIds[0];
counts.set(optionId, (counts.get(optionId) || 0) + 1);
}
} else {
// For multiplechoice, count all responses (deduplicated)
const uniqueOptions = new Set(vote.optionIds);
uniqueOptions.forEach(optionId => {
counts.set(optionId, (counts.get(optionId) || 0) + 1);
});
}
});
return counts;
});
// Get total vote count
let totalVotes = $derived.by(() => {
return votes.size;
});
// Get percentage for each option
function getPercentage(optionId: string): number {
if (totalVotes === 0) return 0;
const count = voteCounts.get(optionId) || 0;
return (count / totalVotes) * 100;
}
// Load poll responses
async function loadVotes() {
if (relayUrls.length === 0) {
// Use default relays if no relay URLs specified
const relays = relayManager.getFeedReadRelays();
await fetchVotesFromRelays(relays);
} else {
await fetchVotesFromRelays(relayUrls);
}
}
async function fetchVotesFromRelays(relays: string[]) {
loading = true;
try {
const now = Math.floor(Date.now() / 1000);
const until = endsAt ? Math.min(endsAt, now) : now;
const responseEvents = await nostrClient.fetchEvents(
[{ kinds: [KIND.POLL_RESPONSE], '#e': [pollEvent.id], until, limit: 100 }],
relays,
{ useCache: true, cacheResults: true }
);
// One vote per pubkey - keep only the newest vote (highest created_at)
// Sort by created_at descending to process newest first
const sortedEvents = [...responseEvents].sort((a, b) => b.created_at - a.created_at);
const voteMap = new Map<string, PollVote>();
sortedEvents.forEach(event => {
// Only add if we haven't seen this pubkey yet, or if this event is newer
// Since we sorted descending, the first occurrence is the newest
if (!voteMap.has(event.pubkey)) {
// Extract response option IDs
const responseTags = event.tags.filter(t => t[0] === 'response' && t[1]);
const optionIds = responseTags.map(t => t[1]);
voteMap.set(event.pubkey, {
pubkey: event.pubkey,
optionIds,
created_at: event.created_at
});
}
});
votes = voteMap;
// Check if current user has voted
const session = sessionManager.getSession();
if (session) {
const userVoteData = votes.get(session.pubkey);
if (userVoteData) {
userVote = userVoteData.optionIds;
hasVoted = true;
}
}
} catch (error) {
console.error('Error loading poll votes:', error);
} finally {
loading = false;
}
}
// Submit vote
async function submitVote(optionId: string) {
const session = sessionManager.getSession();
if (!session) {
alert('Please log in to vote');
return;
}
if (isExpired) {
alert('This poll has ended');
return;
}
if (submittingVote) {
return; // Prevent double-submission
}
if (pollType === 'singlechoice') {
// Single choice: replace any existing vote
userVote = [optionId];
} else {
// Multiple choice: toggle option
if (userVote.includes(optionId)) {
userVote = userVote.filter(id => id !== optionId);
} else {
userVote = [...userVote, optionId];
}
}
// Create response event
const responseTags = [
['e', pollEvent.id],
...userVote.map(id => ['response', id] as [string, string])
];
submittingVote = true;
try {
const result = await signAndPublish({
kind: KIND.POLL_RESPONSE,
content: '',
tags: responseTags,
created_at: Math.floor(Date.now() / 1000),
pubkey: session.pubkey
}, relayUrls.length > 0 ? relayUrls : undefined);
if (result.success.length > 0) {
// Reload votes after successful publish
await loadVotes();
hasVoted = true;
} else {
// Revert userVote on failure
if (pollType === 'singlechoice') {
userVote = [];
} else {
// For multiple choice, we need to check what the previous state was
// For now, just keep the current state
}
alert('Failed to submit vote. Please try again.');
}
} catch (error) {
console.error('Error submitting vote:', error);
// Revert userVote on error
if (pollType === 'singlechoice') {
userVote = [];
}
alert('Failed to submit vote. Please try again.');
} finally {
submittingVote = false;
}
}
onMount(() => {
parsePoll();
loadVotes();
});
</script>
<div class="poll-card">
<div class="poll-question">
{pollEvent.content}
</div>
{#if isExpired}
<div class="poll-status">
Poll ended
</div>
{:else if submittingVote}
<div class="poll-status">
Submitting vote...
</div>
{:else if hasVoted}
<div class="poll-status">
You have already voted
</div>
{/if}
<div class="poll-options">
{#each options as option (option.id)}
{@const percentage = getPercentage(option.id)}
{@const count = voteCounts.get(option.id) || 0}
{@const isSelected = userVote.includes(option.id)}
<button
class="poll-option"
class:selected={isSelected}
class:expired={isExpired}
class:voted={hasVoted}
onclick={() => !isExpired && !submittingVote && !hasVoted && submitVote(option.id)}
disabled={isExpired || submittingVote || hasVoted}
onkeydown={(e) => {
if (!isExpired && !submittingVote && !hasVoted && (e.key === 'Enter' || e.key === ' ')) {
e.preventDefault();
submitVote(option.id);
}
}}
>
<div class="poll-option-content">
<div class="poll-option-label">
{option.label}
</div>
<div class="poll-option-stats">
<span class="poll-percentage">{percentage.toFixed(1)}%</span>
{#if count > 0}
<span class="poll-count">({count})</span>
{/if}
</div>
</div>
<div class="poll-bar-container">
<div
class="poll-bar"
style="width: {percentage}%"
></div>
</div>
</button>
{/each}
</div>
<div class="poll-footer">
<span class="poll-vote-count">{totalVotes} {totalVotes === 1 ? 'vote' : 'votes'}</span>
{#if !isExpired && !hasVoted}
<button
class="poll-update-button"
onclick={() => loadVotes()}
disabled={loading}
>
{loading ? 'Loading...' : 'Update results'}
</button>
{/if}
</div>
</div>
<style>
.poll-card {
margin: 1rem 0;
padding: 1rem;
background: var(--fog-post, #ffffff);
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
}
:global(.dark) .poll-card {
background: var(--fog-dark-post, #1f2937);
border-color: var(--fog-dark-border, #374151);
}
.poll-question {
font-weight: 600;
margin-bottom: 0.75rem;
color: var(--fog-text, #1f2937);
}
:global(.dark) .poll-question {
color: var(--fog-dark-text, #f9fafb);
}
.poll-status {
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
margin-bottom: 0.75rem;
font-style: italic;
}
:global(.dark) .poll-status {
color: var(--fog-dark-text-light, #6b7280);
}
.poll-options {
display: flex;
flex-direction: column;
gap: 0.5rem;
margin-bottom: 0.75rem;
}
.poll-option {
position: relative;
padding: 0.75rem;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
background: var(--fog-highlight, #f3f4f6);
cursor: pointer;
transition: all 0.2s;
width: 100%;
text-align: left;
font-family: inherit;
font-size: inherit;
}
:global(.dark) .poll-option {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-border, #4b5563);
}
.poll-option:not(.expired):hover {
background: var(--fog-border, #e5e7eb);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .poll-option:not(.expired):hover {
background: var(--fog-dark-border, #4b5563);
border-color: var(--fog-dark-accent, #94a3b8);
}
.poll-option.selected {
border-color: var(--fog-accent, #64748b);
background: var(--fog-highlight, #f3f4f6);
}
:global(.dark) .poll-option.selected {
border-color: var(--fog-dark-accent, #94a3b8);
background: var(--fog-dark-highlight, #374151);
}
.poll-option:disabled {
cursor: default;
opacity: 0.7;
}
.poll-option.voted {
cursor: default;
}
.poll-option.voted:not(.selected) {
opacity: 0.8;
}
.poll-option-content {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 0.25rem;
}
.poll-option-label {
flex: 1;
color: var(--fog-text, #1f2937);
}
:global(.dark) .poll-option-label {
color: var(--fog-dark-text, #f9fafb);
}
.poll-option-stats {
display: flex;
align-items: center;
gap: 0.25rem;
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
}
:global(.dark) .poll-option-stats {
color: var(--fog-dark-text-light, #6b7280);
}
.poll-percentage {
font-weight: 600;
}
.poll-count {
font-size: 0.75rem;
}
.poll-bar-container {
height: 4px;
background: var(--fog-border, #e5e7eb);
border-radius: 2px;
overflow: hidden;
margin-top: 0.5rem;
}
:global(.dark) .poll-bar-container {
background: var(--fog-dark-border, #4b5563);
}
.poll-bar {
height: 100%;
background: var(--fog-accent, #64748b);
transition: width 0.3s ease;
}
:global(.dark) .poll-bar {
background: var(--fog-dark-accent, #94a3b8);
}
.poll-footer {
display: flex;
justify-content: space-between;
align-items: center;
font-size: 0.875rem;
color: var(--fog-text-light, #9ca3af);
padding-top: 0.5rem;
border-top: 1px solid var(--fog-border, #e5e7eb);
}
:global(.dark) .poll-footer {
color: var(--fog-dark-text-light, #6b7280);
border-top-color: var(--fog-dark-border, #4b5563);
}
.poll-vote-count {
font-weight: 500;
}
.poll-update-button {
background: transparent;
border: 1px solid var(--fog-border, #e5e7eb);
border-radius: 0.25rem;
padding: 0.25rem 0.5rem;
font-size: 0.75rem;
color: var(--fog-text, #1f2937);
cursor: pointer;
transition: all 0.2s;
}
:global(.dark) .poll-update-button {
border-color: var(--fog-dark-border, #4b5563);
color: var(--fog-dark-text, #f9fafb);
}
.poll-update-button:hover:not(:disabled) {
background: var(--fog-highlight, #f3f4f6);
border-color: var(--fog-accent, #64748b);
}
:global(.dark) .poll-update-button:hover:not(:disabled) {
background: var(--fog-dark-highlight, #374151);
border-color: var(--fog-dark-accent, #94a3b8);
}
.poll-update-button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
</style>