diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index fffc54e..6a8efb6 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,12 +69,20 @@ jobs: > **Windows note:** The installer is not yet code-signed. Windows SmartScreen will show an "Unknown publisher" warning — click "More info → Run anyway" to install. + ### v0.12.4 — Polls, Custom Relay & UI Polish + - **NIP-1068 Polls** — create polls from the compose box, vote on polls in the feed, animated result bars with vote counts and percentages, per-pubkey dedup, expiry support + - **Custom Go relay** — switched default relay from strfry to Vega's own Go relay (`wss://relay2.veganostr.com`) with 19 NIPs including NIP-45 COUNT, NIP-50 Search, and NIP-77 Negentropy + - **Note action icons** — replaced text labels (reply/repost/share/save) with icons, added tooltips on hover, dot separators for visual clarity + - **Sidebar icons** — distinct icons for V4V (📡) vs Zaps (⚡), better icons for podcasts (🎙), follows (👥), bookmarks (★) + - **Fix duplicate search results** — people search now deduplicates by pubkey across relays + - **Fix thread indentation** — deep threads no longer overflow on narrow/half-screen windows; indent capped at depth 3 with relative spacing + ### v0.12.3 — Fix Direct Messages - **Fix DMs not loading** — NIP-17 gift-wrapped messages were silently dropped by NDK's fetchEvents; switched to subscribe-based fetch that correctly receives kind 1059 events - **Repo cleanup** — hardened .gitignore, updated AGENTS.md and ROADMAP.md ### v0.12.2 — Vega Public Relay - - **`wss://relay.veganostr.com`** — Vega's own public relay, included by default for all users + - **`wss://relay2.veganostr.com`** — Vega's custom Go relay, included by default for all users - Existing users get the relay auto-added on upgrade (one-time migration) ### v0.12.1 — Fixes diff --git a/PKGBUILD b/PKGBUILD index 7149eb5..4333ab6 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: hoornet pkgname=vega-nostr -pkgver=0.12.3 +pkgver=0.12.4 pkgrel=1 pkgdesc="Cross-platform Nostr desktop client with Lightning integration" arch=('x86_64') diff --git a/README.md b/README.md index 2a61f9d..069bd14 100644 --- a/README.md +++ b/README.md @@ -89,7 +89,7 @@ minisign -Vm vega_0.12.1_amd64.AppImage.tar.gz -p vega.pub - **Relay health checker** — NIP-11 info fetch, WebSocket latency probing, online/slow/offline classification; expandable cards show all supported NIPs, software, description; per-relay remove button; "Remove dead" strips offline relays; "Publish list" publishes NIP-65 relay list; auto-checks on mount - **Relay recommendations** — discover relays based on your follows' NIP-65 relay lists; shows follow count, one-click "Add" - **Embedded Nostr relay** — built-in strfry relay with catch-up sync on startup; your notes are always available locally even when remote relays are slow or offline -- **Vega public relay** — `wss://relay.veganostr.com` included by default; aggregation relay ensuring data availability when other relays are flaky or down +- **Vega public relay** — `wss://relay2.veganostr.com` included by default; custom Go relay with NIP-45/50/77, ensuring data availability when other relays are flaky or down - Relay management: add/remove relays, all in one consolidated Relays view - **NIP-65 outbox model** — reads user relay lists (kind 10002) so you see notes from people who publish to their own relays; publish your own relay list to Nostr @@ -196,7 +196,7 @@ npm run tauri build # production binary See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps. Up next: -- Public relay (`wss://relay.veganostr.com`) for data resilience +- Custom Go relay (`wss://relay2.veganostr.com`) with NIP-45 COUNT, NIP-50 Search, NIP-77 Negentropy - Custom feeds / lists - NIP research sprint — expanding protocol support - NIP-58 badges, NIP-72 communities diff --git a/ROADMAP.md b/ROADMAP.md index 9c02b96..7bcd52a 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -133,7 +133,7 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be ### v0.12.x — Podcasts & Value 4 Value (2026-04-05) - **Podcast player** — search, subscribe, play podcasts directly in Vega - **V4V streaming** — stream sats to podcast creators via Lightning (keysend + LNURL-pay); automatic recipient splits (hosts, producers, apps) -- **Own relay** — `wss://relay.veganostr.com` (strfry, Helsinki); wired as default for all users +- **Own relay** — `wss://relay2.veganostr.com` (custom Go relay, Helsinki); 19 NIPs, NIP-45 COUNT, NIP-50 Search, NIP-77 Negentropy - **Media feed fixed** — 24h window replaces 2h, actually returns results now - **Trending feed resilience** — retry on slow startup, preserves existing notes - **Read-only banner** — clear visual indicator when not signed in diff --git a/package.json b/package.json index 04e73a7..abc65df 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "vega", "private": true, - "version": "0.12.3", + "version": "0.12.4", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 9dda8eb..f43b715 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -5320,7 +5320,7 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" [[package]] name = "vega" -version = "0.12.3" +version = "0.12.4" dependencies = [ "futures-util", "hex", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index fe3f83d..102c98a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "vega" -version = "0.12.3" +version = "0.12.4" description = "Cross-platform Nostr desktop client with Lightning integration" authors = ["hoornet"] edition = "2021" diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c6838df..1974d4a 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Vega", - "version": "0.12.3", + "version": "0.12.4", "identifier": "com.hoornet.vega", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/feed/ComposeBox.tsx b/src/components/feed/ComposeBox.tsx index ecad224..ad838b4 100644 --- a/src/components/feed/ComposeBox.tsx +++ b/src/components/feed/ComposeBox.tsx @@ -1,6 +1,7 @@ import { useState, useRef, useEffect } from "react"; -import { publishNote } from "../../lib/nostr"; +import { publishNote, publishPoll } from "../../lib/nostr"; import { uploadImage, uploadBytes } from "../../lib/upload"; +import { PollCompose } from "../poll/PollCompose"; import { useAutoResize } from "../../hooks/useAutoResize"; import { useUserStore } from "../../stores/user"; import { useFeedStore } from "../../stores/feed"; @@ -21,6 +22,8 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [showEmoji, setShowEmoji] = useState(false); + const [isPoll, setIsPoll] = useState(false); + const [pollOptions, setPollOptions] = useState(["", ""]); const autoResize = useAutoResize(3, 12); const textareaRef = useRef(null); @@ -43,7 +46,8 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = const charCount = text.length; const warnLimit = charCount > 3500; const overLimit = charCount > 4000; - const canPost = (text.trim().length > 0 || attachments.length > 0) && !publishing && !uploading; + const pollValid = !isPoll || pollOptions.filter((o) => o.trim()).length >= 2; + const canPost = (text.trim().length > 0 || attachments.length > 0) && !publishing && !uploading && pollValid; // Insert text at the current cursor position in the textarea const insertAtCursor = (str: string) => { @@ -179,11 +183,16 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = setPublishing(true); setError(null); try { - // Build final content: text + attachment URLs on separate lines - const parts = [text.trim(), ...attachments].filter(Boolean); - const content = parts.join("\n"); - - const event = await publishNote(content); + let event; + if (isPoll) { + const validOptions = pollOptions.map((o) => o.trim()).filter(Boolean); + event = await publishPoll(text.trim(), validOptions); + } else { + // Build final content: text + attachment URLs on separate lines + const parts = [text.trim(), ...attachments].filter(Boolean); + const content = parts.join("\n"); + event = await publishNote(content); + } // Inject into feed immediately so the user sees their post if (onNoteInjected) { onNoteInjected(event); @@ -195,6 +204,8 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = } setText(""); setAttachments([]); + setIsPoll(false); + setPollOptions(["", ""]); localStorage.removeItem(COMPOSE_DRAFT_KEY); textareaRef.current?.focus(); onPublished?.(); @@ -269,6 +280,11 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = )} + {/* Poll option inputs */} + {isPoll && ( + + )} + {error && (

{error}

)} @@ -303,19 +319,26 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = + Ctrl+Enter to post diff --git a/src/components/feed/NoteActions.tsx b/src/components/feed/NoteActions.tsx index 6f22843..a8b0b7b 100644 --- a/src/components/feed/NoteActions.tsx +++ b/src/components/feed/NoteActions.tsx @@ -80,13 +80,16 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
+ · + {/* Emoji reaction pills */}
{sortedGroups.map(([emoji, count]) => ( @@ -94,6 +97,7 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp key={emoji} onClick={() => handleReact(emoji)} disabled={reacting || myReactions.has(emoji)} + title="React" className={`inline-flex items-center gap-0.5 px-1.5 py-0.5 text-[11px] rounded-sm border transition-colors ${ myReactions.has(emoji) ? "border-accent/40 bg-accent/10 text-accent" @@ -110,7 +114,7 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
+ · + + + · + + {(profile?.lud16 || profile?.lud06) && ( - + <> + · + + )} + + · + + + · +
diff --git a/src/components/feed/NoteCard.tsx b/src/components/feed/NoteCard.tsx index f3ee766..b8a5d8c 100644 --- a/src/components/feed/NoteCard.tsx +++ b/src/components/feed/NoteCard.tsx @@ -11,6 +11,7 @@ import { getParentEventId } from "../../lib/threadTree"; import { NoteContent } from "./NoteContent"; import { NoteActions, LoggedOutStats } from "./NoteActions"; import { InlineReplyBox } from "./InlineReplyBox"; +import { PollWidget } from "../poll/PollWidget"; interface NoteCardProps { event: NDKEvent; @@ -178,6 +179,9 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread + {/* Poll options — kind 1068 */} + {event.kind === 1068 && } + {/* Actions */} {loggedIn && !!getNDK().signer && ( void; +} + +const MAX_OPTIONS = 10; +const MIN_OPTIONS = 2; + +export function PollCompose({ options, onChange }: PollComposeProps) { + const updateOption = (index: number, value: string) => { + const next = [...options]; + next[index] = value; + onChange(next); + }; + + const removeOption = (index: number) => { + if (options.length <= MIN_OPTIONS) return; + onChange(options.filter((_, i) => i !== index)); + }; + + const addOption = () => { + if (options.length >= MAX_OPTIONS) return; + onChange([...options, ""]); + }; + + return ( +
+
Poll options
+ {options.map((opt, i) => ( +
+ updateOption(i, e.target.value)} + placeholder={`Option ${i + 1}`} + className="flex-1 bg-transparent border border-border rounded-sm px-2 py-1 text-text text-[12px] placeholder:text-text-dim/50 focus:outline-none focus:border-accent/50" + maxLength={100} + /> + {options.length > MIN_OPTIONS && ( + + )} +
+ ))} + {options.length < MAX_OPTIONS && ( + + )} +
+ ); +} diff --git a/src/components/poll/PollWidget.tsx b/src/components/poll/PollWidget.tsx new file mode 100644 index 0000000..3fe8950 --- /dev/null +++ b/src/components/poll/PollWidget.tsx @@ -0,0 +1,121 @@ +import { memo } from "react"; +import { NDKEvent } from "@nostr-dev-kit/ndk"; +import { usePollVotes } from "../../hooks/usePollVotes"; +import { useUserStore } from "../../stores/user"; +import { publishPollResponse } from "../../lib/nostr"; + +interface PollOption { + index: number; + label: string; +} + +function parsePollOptions(event: NDKEvent): PollOption[] { + return event.tags + .filter((t) => t[0] === "option" && t.length >= 3) + .map((t) => ({ index: parseInt(t[1], 10), label: t[2] })) + .filter((o) => !isNaN(o.index)); +} + +function getPollClosedAt(event: NDKEvent): number | null { + const tag = event.tags.find((t) => t[0] === "closed_at"); + if (!tag?.[1]) return null; + const ts = parseInt(tag[1], 10); + return isNaN(ts) ? null : ts; +} + +export const PollWidget = memo(function PollWidget({ event }: { event: NDKEvent }) { + const options = parsePollOptions(event); + const [pollData, addVote] = usePollVotes(event.id!); + const loggedIn = useUserStore((s) => s.loggedIn); + const myPubkey = useUserStore((s) => s.pubkey); + + if (options.length === 0) return null; + + const closedAt = getPollClosedAt(event); + const now = Math.floor(Date.now() / 1000); + const isExpired = closedAt !== null && closedAt <= now; + const isAuthor = myPubkey === event.pubkey; + const hasVoted = pollData?.myVote !== null && pollData?.myVote !== undefined; + const showResults = hasVoted || isExpired || isAuthor || !loggedIn; + const total = pollData?.total ?? 0; + + const handleVote = async (optionIndex: number) => { + if (showResults || !loggedIn) return; + addVote(optionIndex); + try { + await publishPollResponse(event.id!, event.pubkey, optionIndex); + } catch { + // Optimistic update already applied — relay failure is non-fatal + } + }; + + return ( +
+ {options.map((opt) => { + const count = pollData?.votes.get(opt.index) ?? 0; + const pct = total > 0 ? Math.round((count / total) * 100) : 0; + const isMyVote = pollData?.myVote === opt.index; + + return ( + + ); + })} + + {/* Footer: vote count + expiry */} +
+ {pollData ? ( + {total} {total === 1 ? "vote" : "votes"} + ) : ( + loading votes... + )} + {isExpired && · Poll ended} + {closedAt && !isExpired && ( + · Ends {new Date(closedAt * 1000).toLocaleDateString()} + )} + {!hasVoted && !isExpired && !isAuthor && loggedIn && total === 0 && pollData && ( + · Be the first to vote + )} +
+
+ ); +}); diff --git a/src/components/sidebar/Sidebar.tsx b/src/components/sidebar/Sidebar.tsx index aa0f25d..d410a4c 100644 --- a/src/components/sidebar/Sidebar.tsx +++ b/src/components/sidebar/Sidebar.tsx @@ -11,14 +11,14 @@ const NAV_ITEMS = [ { id: "feed" as const, label: "feed", icon: "◈" }, { id: "articles" as const, label: "articles", icon: "☰" }, { id: "media" as const, label: "media", icon: "▶" }, - { id: "podcasts" as const, label: "podcasts", icon: "P" }, + { id: "podcasts" as const, label: "podcasts", icon: "🎙" }, { id: "search" as const, label: "search", icon: "⌕" }, - { id: "bookmarks" as const, label: "bookmarks", icon: "▪" }, + { id: "bookmarks" as const, label: "bookmarks", icon: "★" }, { id: "dm" as const, label: "messages", icon: "✉" }, { id: "notifications" as const, label: "notifications", icon: "🔔" }, - { id: "follows" as const, label: "follows", icon: "♺" }, + { id: "follows" as const, label: "follows", icon: "👥" }, { id: "zaps" as const, label: "zaps", icon: "⚡" }, - { id: "v4v" as const, label: "v4v", icon: "⚡" }, + { id: "v4v" as const, label: "v4v", icon: "📡" }, { id: "relays" as const, label: "relays", icon: "⟐" }, { id: "settings" as const, label: "settings", icon: "⚙" }, { id: "about" as const, label: "support", icon: "♥" }, diff --git a/src/hooks/usePollVotes.ts b/src/hooks/usePollVotes.ts new file mode 100644 index 0000000..936e19b --- /dev/null +++ b/src/hooks/usePollVotes.ts @@ -0,0 +1,92 @@ +import { useEffect, useRef, useState } from "react"; +import { fetchPollResponses } from "../lib/nostr"; +import type { PollVotes } from "../lib/nostr"; +import { useUserStore } from "../stores/user"; + +const cache = new Map(); + +const pending = new Map>(); +let activeCount = 0; +const MAX_CONCURRENT = 4; +const queue: Array<() => void> = []; + +function runNext() { + if (queue.length > 0 && activeCount < MAX_CONCURRENT) { + const next = queue.shift()!; + next(); + } +} + +function throttledFetch(pollId: string, pubkey?: string): Promise { + if (pending.has(pollId)) return pending.get(pollId)!; + + const promise = new Promise((resolve) => { + const doFetch = () => { + activeCount++; + fetchPollResponses(pollId, pubkey) + .then((result) => { + resolve(result); + }) + .catch(() => { + resolve({ votes: new Map(), myVote: null, total: 0 }); + }) + .finally(() => { + activeCount--; + pending.delete(pollId); + runNext(); + }); + }; + + if (activeCount < MAX_CONCURRENT) { + doFetch(); + } else { + queue.push(doFetch); + } + }); + + pending.set(pollId, promise); + return promise; +} + +export function usePollVotes(pollId: string): [PollVotes | null, (optionIndex: number) => void] { + const [data, setData] = useState(() => cache.get(pollId) ?? null); + const pubkeyRef = useRef(useUserStore.getState().pubkey); + + useEffect(() => { + pubkeyRef.current = useUserStore.getState().pubkey; + }); + + useEffect(() => { + if (cache.has(pollId)) { + setData(cache.get(pollId)!); + return; + } + let cancelled = false; + throttledFetch(pollId, pubkeyRef.current ?? undefined).then((result) => { + if (!cancelled) { + cache.set(pollId, result); + setData(result); + } + }); + return () => { cancelled = true; }; + }, [pollId]); + + const addVote = (optionIndex: number) => { + setData((prev) => { + const votes = new Map(prev?.votes ?? []); + // If changing vote, decrement old option + if (prev?.myVote !== null && prev?.myVote !== undefined) { + const oldCount = votes.get(prev.myVote) ?? 0; + if (oldCount > 1) votes.set(prev.myVote, oldCount - 1); + else votes.delete(prev.myVote); + } + votes.set(optionIndex, (votes.get(optionIndex) ?? 0) + 1); + const total = Array.from(votes.values()).reduce((sum, n) => sum + n, 0); + const next: PollVotes = { votes, myVote: optionIndex, total }; + cache.set(pollId, next); + return next; + }); + }; + + return [data, addVote]; +} diff --git a/src/lib/nostr/core.ts b/src/lib/nostr/core.ts index 03d9430..e0791c1 100644 --- a/src/lib/nostr/core.ts +++ b/src/lib/nostr/core.ts @@ -39,7 +39,7 @@ export async function fetchWithTimeout( export const RELAY_STORAGE_KEY = "wrystr_relays"; export const FALLBACK_RELAYS = [ - "wss://relay.veganostr.com", + "wss://relay2.veganostr.com", "wss://relay.damus.io", "wss://nos.lol", "wss://relay.snort.social", @@ -47,7 +47,7 @@ export const FALLBACK_RELAYS = [ // Override NDK's default outbox relays (purplepag.es can have DNS issues) export const OUTBOX_RELAYS = [ - "wss://relay.veganostr.com/", + "wss://relay2.veganostr.com/", "wss://relay.damus.io/", "wss://nos.lol/", "wss://relay.nostr.band/", @@ -58,7 +58,7 @@ export function normalizeRelayUrl(url: string): string { return url.replace(/\/+$/, ""); } -const VEGA_RELAY = "wss://relay.veganostr.com"; +const VEGA_RELAY = "wss://relay2.veganostr.com"; const VEGA_RELAY_MIGRATED_KEY = "wrystr_vega_relay_added"; export function getStoredRelayUrls(): string[] { diff --git a/src/lib/nostr/index.ts b/src/lib/nostr/index.ts index 5f6877e..6ccde72 100644 --- a/src/lib/nostr/index.ts +++ b/src/lib/nostr/index.ts @@ -12,5 +12,7 @@ export type { AdvancedSearchResults } from "./search"; export { fetchUserRelayList, publishRelayList, fetchRelayRecommendations } from "./relays"; export type { UserRelayList } from "./relays"; export { fetchTrendingCandidates, fetchTrendingHashtags } from "./trending"; +export { publishPoll, publishPollResponse, fetchPollResponses } from "./polls"; +export type { PollVotes } from "./polls"; export { verifyReputation } from "./vertex"; export type { ReputationResult, ReputationEntry } from "./vertex"; diff --git a/src/lib/nostr/notes.ts b/src/lib/nostr/notes.ts index 59f539f..b162f8d 100644 --- a/src/lib/nostr/notes.ts +++ b/src/lib/nostr/notes.ts @@ -6,7 +6,7 @@ export async function fetchGlobalFeed(limit: number = 50): Promise { const instance = getNDK(); // Ask for notes from the last 2 hours to ensure freshness const since = Math.floor(Date.now() / 1000) - 2 * 3600; - const filter: NDKFilter = { kinds: [NDKKind.Text], limit, since }; + const filter: NDKFilter = { kinds: [NDKKind.Text, 1068 as NDKKind], limit, since }; const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); } @@ -24,21 +24,21 @@ export async function fetchFollowFeed(pubkeys: string[], limit = 80): Promise (b.created_at ?? 0) - (a.created_at ?? 0)); } export async function fetchUserNotes(pubkey: string, limit = 30): Promise { const instance = getNDK(); - const filter: NDKFilter = { kinds: [NDKKind.Text], authors: [pubkey], limit }; + const filter: NDKFilter = { kinds: [NDKKind.Text, 1068 as NDKKind], authors: [pubkey], limit }; const events = await fetchWithTimeout(instance, filter, FEED_TIMEOUT); return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)); } export async function fetchUserNotesNIP65(pubkey: string, limit = 30): Promise { const instance = getNDK(); - const filter: NDKFilter = { kinds: [NDKKind.Text], authors: [pubkey], limit }; + const filter: NDKFilter = { kinds: [NDKKind.Text, 1068 as NDKKind], authors: [pubkey], limit }; try { const relayList = await withTimeout(fetchUserRelayList(pubkey), SINGLE_TIMEOUT, { read: [], write: [] }); if (relayList.write.length > 0) { diff --git a/src/lib/nostr/polls.ts b/src/lib/nostr/polls.ts new file mode 100644 index 0000000..2328d32 --- /dev/null +++ b/src/lib/nostr/polls.ts @@ -0,0 +1,64 @@ +import { NDKEvent, NDKFilter, NDKKind } from "@nostr-dev-kit/ndk"; +import { getNDK, fetchWithTimeout, SINGLE_TIMEOUT } from "./core"; + +export interface PollVotes { + /** option index → vote count */ + votes: Map; + /** which option the current user voted for (null if not voted) */ + myVote: number | null; + /** total votes across all options */ + total: number; +} + +export async function publishPoll(question: string, options: string[]): Promise { + const instance = getNDK(); + if (!instance.signer) throw new Error("Not logged in"); + const event = new NDKEvent(instance); + event.kind = 1068 as NDKKind; + event.content = question; + event.tags = options.map((opt, i) => ["option", String(i), opt]); + await event.publish(); + return event; +} + +export async function publishPollResponse(pollId: string, pollPubkey: string, optionIndex: number): Promise { + const instance = getNDK(); + if (!instance.signer) throw new Error("Not logged in"); + const event = new NDKEvent(instance); + event.kind = 1018 as NDKKind; + event.content = String(optionIndex); + event.tags = [["e", pollId], ["p", pollPubkey]]; + await event.publish(); + return event; +} + +export async function fetchPollResponses(pollId: string, myPubkey?: string): Promise { + const instance = getNDK(); + const filter: NDKFilter = { kinds: [1018 as NDKKind], "#e": [pollId] }; + const events = await fetchWithTimeout(instance, filter, SINGLE_TIMEOUT); + + // Deduplicate by pubkey — keep latest response per voter + const byPubkey = new Map(); + for (const e of events) { + const existing = byPubkey.get(e.pubkey); + if (!existing || (e.created_at ?? 0) > (existing.created_at ?? 0)) { + byPubkey.set(e.pubkey, e); + } + } + + const votes = new Map(); + let myVote: number | null = null; + let total = 0; + + for (const [pubkey, event] of byPubkey) { + const idx = parseInt(event.content, 10); + if (isNaN(idx)) continue; + votes.set(idx, (votes.get(idx) ?? 0) + 1); + total++; + if (myPubkey && pubkey === myPubkey) { + myVote = idx; + } + } + + return { votes, myVote, total }; +}