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:
Jure
2026-04-06 18:15:48 +02:00
parent 86b7705c07
commit acd5a5979b
19 changed files with 449 additions and 51 deletions
+32 -9
View File
@@ -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"}`}
>
&#9634;&#9634;
</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>
+46 -22
View File
@@ -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>
+4
View File
@@ -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
+60
View File
@@ -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"
>
&#10005;
</button>
)}
</div>
))}
{options.length < MAX_OPTIONS && (
<button
onClick={addOption}
className="text-accent hover:text-accent-hover text-[11px] transition-colors"
>
Add option
</button>
)}
</div>
);
}
+121
View File
@@ -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">&#10003;</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>&#183; Poll ended</span>}
{closedAt && !isExpired && (
<span>&#183; Ends {new Date(closedAt * 1000).toLocaleDateString()}</span>
)}
{!hasVoted && !isExpired && !isAuthor && loggedIn && total === 0 && pollData && (
<span>&#183; Be the first to vote</span>
)}
</div>
</div>
);
});
+4 -4
View File
@@ -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: "♥" },