From acd5a5979b30a0fc3b5a9e53ca74790d83acc6b9 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Mon, 6 Apr 2026 18:15:48 +0200 Subject: [PATCH] =?UTF-8?q?Bump=20to=20v0.12.4=20=E2=80=94=20polls,=20cust?= =?UTF-8?q?om=20relay,=20UI=20polish?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit NIP-1068 polls: create and vote on polls inline in the feed. Switch default relay to custom Go relay (relay2.veganostr.com). Note action icons with tooltips, sidebar icon cleanup, search dedup fix, thread indentation fix. --- .github/workflows/release.yml | 10 ++- PKGBUILD | 2 +- README.md | 4 +- ROADMAP.md | 2 +- package.json | 2 +- src-tauri/Cargo.lock | 2 +- src-tauri/Cargo.toml | 2 +- src-tauri/tauri.conf.json | 2 +- src/components/feed/ComposeBox.tsx | 41 +++++++--- src/components/feed/NoteActions.tsx | 68 +++++++++++----- src/components/feed/NoteCard.tsx | 4 + src/components/poll/PollCompose.tsx | 60 ++++++++++++++ src/components/poll/PollWidget.tsx | 121 ++++++++++++++++++++++++++++ src/components/sidebar/Sidebar.tsx | 8 +- src/hooks/usePollVotes.ts | 92 +++++++++++++++++++++ src/lib/nostr/core.ts | 6 +- src/lib/nostr/index.ts | 2 + src/lib/nostr/notes.ts | 8 +- src/lib/nostr/polls.ts | 64 +++++++++++++++ 19 files changed, 449 insertions(+), 51 deletions(-) create mode 100644 src/components/poll/PollCompose.tsx create mode 100644 src/components/poll/PollWidget.tsx create mode 100644 src/hooks/usePollVotes.ts create mode 100644 src/lib/nostr/polls.ts 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 }; +}