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
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>
|
|
|