6 changed files with 656 additions and 135 deletions
@ -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