mirror of
https://github.com/hoornet/vega.git
synced 2026-06-08 14:11:55 -07:00
Bump to v0.12.4 — polls, custom relay, UI polish
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.
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||
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')
|
||||
|
||||
@@ -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
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vega",
|
||||
"private": true,
|
||||
"version": "0.12.3",
|
||||
"version": "0.12.4",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -5320,7 +5320,7 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vega"
|
||||
version = "0.12.3"
|
||||
version = "0.12.4"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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<string | null>(null);
|
||||
const [showEmoji, setShowEmoji] = useState(false);
|
||||
const [isPoll, setIsPoll] = useState(false);
|
||||
const [pollOptions, setPollOptions] = useState<string[]>(["", ""]);
|
||||
const autoResize = useAutoResize(3, 12);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(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?: () =
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Poll option inputs */}
|
||||
{isPoll && (
|
||||
<PollCompose options={pollOptions} onChange={setPollOptions} />
|
||||
)}
|
||||
|
||||
{error && (
|
||||
<p className="text-danger text-[11px] mb-2">{error}</p>
|
||||
)}
|
||||
@@ -303,19 +319,26 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
</div>
|
||||
<button
|
||||
onClick={handleFilePicker}
|
||||
disabled={uploading}
|
||||
disabled={uploading || isPoll}
|
||||
title="Attach image or video"
|
||||
className="text-text-dim hover:text-text text-[16px] transition-colors disabled:opacity-30"
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setIsPoll((v) => !v)}
|
||||
title={isPoll ? "Cancel poll" : "Create poll"}
|
||||
className={`text-[14px] transition-colors ${isPoll ? "text-accent" : "text-text-dim hover:text-text"}`}
|
||||
>
|
||||
▢▢
|
||||
</button>
|
||||
<span className="text-text-dim text-[10px]">Ctrl+Enter to post</span>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={!canPost}
|
||||
className="px-3 py-1 text-[11px] bg-accent hover:bg-accent-hover text-accent-text transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{publishing ? "posting…" : "post"}
|
||||
{publishing ? "posting…" : isPoll ? "post poll" : "post"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -80,13 +80,16 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 mt-2">
|
||||
<button
|
||||
onClick={onReplyToggle}
|
||||
title="Reply"
|
||||
className={`text-[11px] transition-colors ${
|
||||
showReply ? "text-accent" : "text-text-dim hover:text-text"
|
||||
showReply ? "text-accent" : "text-text hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
reply{replyCount !== null && replyCount > 0 ? ` ${replyCount}` : ""}
|
||||
<span className="text-[14px]">↩</span>{replyCount !== null && replyCount > 0 ? ` ${replyCount}` : ""}
|
||||
</button>
|
||||
|
||||
<span className="text-text-dim text-[10px] select-none">·</span>
|
||||
|
||||
{/* Emoji reaction pills */}
|
||||
<div className="relative flex flex-wrap items-center gap-1">
|
||||
{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
|
||||
<button
|
||||
onClick={() => setShowEmojiPicker((v) => !v)}
|
||||
disabled={reacting}
|
||||
className="inline-flex items-center px-1 py-0.5 text-[11px] text-text-dim hover:text-accent border border-transparent hover:border-border rounded-sm transition-colors opacity-0 group-hover/card:opacity-100 disabled:opacity-30"
|
||||
className="inline-flex items-center px-1.5 py-0.5 text-[12px] text-text-dim hover:text-accent border border-border hover:border-accent/40 rounded-sm transition-colors disabled:opacity-30"
|
||||
title="React with emoji"
|
||||
>
|
||||
+
|
||||
@@ -139,46 +143,66 @@ export function NoteActions({ event, onReplyToggle, showReply }: NoteActionsProp
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-text-dim text-[10px] select-none">·</span>
|
||||
|
||||
<button
|
||||
onClick={handleRepost}
|
||||
disabled={reposting || reposted}
|
||||
className={`text-[11px] transition-colors disabled:cursor-default ${
|
||||
reposted ? "text-accent" : "text-text-dim hover:text-accent"
|
||||
title="Repost"
|
||||
className={`text-[14px] transition-colors disabled:cursor-default ${
|
||||
reposted ? "text-accent" : "text-text hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{reposted ? "reposted ✓" : reposting ? "…" : "repost"}
|
||||
⟳{reposted ? <span className="text-[11px] ml-0.5">✓</span> : reposting ? <span className="text-[11px] ml-0.5">…</span> : ""}
|
||||
</button>
|
||||
|
||||
<span className="text-text-dim text-[10px] select-none">·</span>
|
||||
|
||||
<button
|
||||
onClick={() => setShowQuote(true)}
|
||||
className="text-[11px] text-text-dim hover:text-text transition-colors"
|
||||
title="Quote"
|
||||
className="text-[14px] text-text hover:text-accent transition-colors"
|
||||
>
|
||||
quote
|
||||
❝
|
||||
</button>
|
||||
|
||||
{(profile?.lud16 || profile?.lud06) && (
|
||||
<button
|
||||
onClick={() => setShowZap(true)}
|
||||
className="text-[11px] text-text-dim hover:text-zap transition-colors"
|
||||
>
|
||||
{zapData && zapData.totalSats > 0
|
||||
? `⚡ ${zapData.totalSats.toLocaleString()} sats`
|
||||
: "⚡ zap"}
|
||||
</button>
|
||||
<>
|
||||
<span className="text-text-dim text-[10px] select-none">·</span>
|
||||
<button
|
||||
onClick={() => setShowZap(true)}
|
||||
title="Zap"
|
||||
className="text-[11px] text-text hover:text-zap transition-colors"
|
||||
>
|
||||
{zapData && zapData.totalSats > 0
|
||||
? `⚡ ${zapData.totalSats.toLocaleString()} sats`
|
||||
: "⚡"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="text-text-dim text-[10px] select-none">·</span>
|
||||
|
||||
<button
|
||||
onClick={() => isBookmarked ? removeBookmark(event.id!) : addBookmark(event.id!)}
|
||||
className={`text-[11px] transition-colors ${
|
||||
isBookmarked ? "text-accent" : "text-text-dim hover:text-accent"
|
||||
title={isBookmarked ? "Remove bookmark" : "Bookmark"}
|
||||
className={`text-[14px] transition-colors ${
|
||||
isBookmarked ? "text-accent" : "text-text hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{isBookmarked ? "▪ saved" : "▫ save"}
|
||||
{isBookmarked ? "★" : "☆"}
|
||||
</button>
|
||||
|
||||
<span className="text-text-dim text-[10px] select-none">·</span>
|
||||
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className={`text-[11px] transition-colors ${
|
||||
copied ? "text-accent" : "text-text-dim hover:text-text"
|
||||
title="Copy link"
|
||||
className={`text-[14px] transition-colors ${
|
||||
copied ? "text-accent" : "text-text hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{copied ? "copied ✓" : "share"}
|
||||
{copied ? <span className="text-[11px]">✓</span> : "⤴"}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -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
|
||||
</div>
|
||||
<NoteContent content={event.content} mediaOnly />
|
||||
|
||||
{/* Poll options — kind 1068 */}
|
||||
{event.kind === 1068 && <PollWidget event={event} />}
|
||||
|
||||
{/* Actions */}
|
||||
{loggedIn && !!getNDK().signer && (
|
||||
<NoteActions
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
interface PollComposeProps {
|
||||
options: string[];
|
||||
onChange: (options: string[]) => 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 (
|
||||
<div className="border-t border-border/50 pt-2 mt-2 space-y-1.5">
|
||||
<div className="text-text-dim text-[10px] mb-1">Poll options</div>
|
||||
{options.map((opt, i) => (
|
||||
<div key={i} className="flex items-center gap-1.5">
|
||||
<input
|
||||
type="text"
|
||||
value={opt}
|
||||
onChange={(e) => 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 && (
|
||||
<button
|
||||
onClick={() => removeOption(i)}
|
||||
className="text-text-dim hover:text-danger text-[12px] px-1 transition-colors shrink-0"
|
||||
title="Remove option"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
{options.length < MAX_OPTIONS && (
|
||||
<button
|
||||
onClick={addOption}
|
||||
className="text-accent hover:text-accent-hover text-[11px] transition-colors"
|
||||
>
|
||||
+ Add option
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="mt-2 space-y-1.5" data-no-navigate>
|
||||
{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 (
|
||||
<button
|
||||
key={opt.index}
|
||||
onClick={() => handleVote(opt.index)}
|
||||
disabled={showResults}
|
||||
className={`
|
||||
relative w-full text-left px-3 py-2 rounded-sm overflow-hidden
|
||||
transition-all duration-200
|
||||
${showResults
|
||||
? isMyVote
|
||||
? "border border-accent/60"
|
||||
: "border border-border"
|
||||
: "border border-border hover:border-accent/50 cursor-pointer hover:bg-accent/5"
|
||||
}
|
||||
${isExpired ? "opacity-60" : ""}
|
||||
disabled:cursor-default
|
||||
`}
|
||||
>
|
||||
{/* Fill bar — only shown in results mode */}
|
||||
{showResults && (
|
||||
<div
|
||||
className={`absolute inset-y-0 left-0 transition-all duration-500 ease-out ${
|
||||
isMyVote ? "bg-accent/25" : "bg-accent/10"
|
||||
}`}
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
)}
|
||||
|
||||
<div className="relative flex items-center justify-between gap-2">
|
||||
<span className="text-text text-[12px] flex items-center gap-1.5">
|
||||
{showResults && isMyVote && (
|
||||
<span className="text-accent text-[10px]" title="Your vote">✓</span>
|
||||
)}
|
||||
{opt.label}
|
||||
</span>
|
||||
{showResults && (
|
||||
<span className="text-text-dim text-[11px] shrink-0 tabular-nums">
|
||||
{pct}% <span className="text-[10px]">({count})</span>
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Footer: vote count + expiry */}
|
||||
<div className="flex items-center gap-2 text-text-dim text-[10px] pt-0.5">
|
||||
{pollData ? (
|
||||
<span>{total} {total === 1 ? "vote" : "votes"}</span>
|
||||
) : (
|
||||
<span className="animate-pulse">loading votes...</span>
|
||||
)}
|
||||
{isExpired && <span>· Poll ended</span>}
|
||||
{closedAt && !isExpired && (
|
||||
<span>· Ends {new Date(closedAt * 1000).toLocaleDateString()}</span>
|
||||
)}
|
||||
{!hasVoted && !isExpired && !isAuthor && loggedIn && total === 0 && pollData && (
|
||||
<span>· Be the first to vote</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
});
|
||||
@@ -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: "♥" },
|
||||
|
||||
@@ -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<string, PollVotes>();
|
||||
|
||||
const pending = new Map<string, Promise<PollVotes>>();
|
||||
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<PollVotes> {
|
||||
if (pending.has(pollId)) return pending.get(pollId)!;
|
||||
|
||||
const promise = new Promise<PollVotes>((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<PollVotes | null>(() => 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];
|
||||
}
|
||||
@@ -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[] {
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -6,7 +6,7 @@ export async function fetchGlobalFeed(limit: number = 50): Promise<NDKEvent[]> {
|
||||
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<ND
|
||||
if (pubkeys.length === 0) return [];
|
||||
const instance = getNDK();
|
||||
const since = Math.floor(Date.now() / 1000) - 24 * 3600; // last 24h for follows
|
||||
const filter: NDKFilter = { kinds: [NDKKind.Text], authors: pubkeys, limit, since };
|
||||
const filter: NDKFilter = { kinds: [NDKKind.Text, 1068 as NDKKind], authors: pubkeys, 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));
|
||||
}
|
||||
|
||||
export async function fetchUserNotes(pubkey: string, limit = 30): Promise<NDKEvent[]> {
|
||||
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<NDKEvent[]> {
|
||||
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) {
|
||||
|
||||
@@ -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<number, number>;
|
||||
/** 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<NDKEvent> {
|
||||
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<NDKEvent> {
|
||||
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<PollVotes> {
|
||||
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<string, NDKEvent>();
|
||||
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<number, number>();
|
||||
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 };
|
||||
}
|
||||
Reference in New Issue
Block a user