6 changed files with 656 additions and 135 deletions
@ -0,0 +1,519 @@
@@ -0,0 +1,519 @@
|
||||
<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: 1000 }], |
||||
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> |
||||
Loading…
Reference in new issue