mirror of
https://github.com/hoornet/vega.git
synced 2026-07-05 08:08:14 -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:
@@ -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: "♥" },
|
||||
|
||||
Reference in New Issue
Block a user