mirror of
https://github.com/hoornet/vega.git
synced 2026-06-22 12:13:02 -07:00
Bump to v0.12.11 — read-only mode write-action guards
The app now behaves coherently for users without a signer (fully logged
out, or signed in with an npub). No broken Publish buttons, no dead-end
"Not logged in" toasts.
- Add useCanSign() hook in src/stores/user.ts as the single source of
truth for write-capability. Captures both "no pubkey" and
"npub-only login" states.
- ReadOnlyBanner now fires for both states (was only "no pubkey" before).
- Hide account-bound sidebar entries (Bookmarks, Messages, Notifications,
Zaps, V4V) in read-only mode. Read-only-OK views (Feed, Articles, Media,
Podcasts, Search, Follows, Relays, Settings, Support) stay visible.
- Guard every write surface, two patterns:
- Hide inline UI: ComposeBox, InlineReplyBox, NoteActions row, NoteCard
context menu, ArticleFeed "Write article", FollowsView per-row
follow/unfollow, ThreadView root reply, PollWidget vote controls,
RelaysView "Publish list", PodcastPlayerBar ShareButton.
- "Sign in to X" CTA: ArticleEditor publish, QuoteModal post, ZapModal,
EditProfileForm save. CTAs open LoginModal.
- ProfileView edit/follow/mute/zap/DM buttons each gated by canSign.
- ArticleView like/repost/comment/bookmark/zap each gated by canSign.
- Belt-and-suspenders runtime guards in user store follow/unfollow.
Bookmark / mute stores already swallow publish errors via .catch(() => {}),
so they don't need explicit runtime guards.
This commit is contained in:
@@ -69,6 +69,15 @@ 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.11 — Read-only mode polish
|
||||
|
||||
If you haven't created a key yet, or you're signed in with just an `npub` (view-only), the app now behaves correctly throughout: no broken Publish buttons, no dead-end "Not logged in" errors.
|
||||
|
||||
- **Sidebar shows only the views that work without a signer** — Feed, Articles, Media, Podcasts, Search, Follows, Relays, Settings, Support. Account-bound entries (Bookmarks, Messages, Notifications, Zaps, V4V) are hidden until you sign in.
|
||||
- **Write surfaces are guarded everywhere.** Compose box, reply box, reactions, repost, quote, zap, bookmark, follow/unfollow, mute, profile edit, article publish, poll vote, relay-list publish — all properly hidden or replaced with a "Sign in" CTA that opens the login dialog.
|
||||
- **Read-only banner now fires for npub-only logins too**, not just fully logged-out state.
|
||||
- **The original bug:** in v0.12.10 and earlier, opening the article editor without a signer and clicking Publish raised `Failed to publish: Error: Not logged in`. That path no longer exists.
|
||||
|
||||
### v0.12.10 — Security update
|
||||
|
||||
Dependency security bumps only. No functional changes.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# Maintainer: hoornet <hoornet@users.noreply.github.com>
|
||||
pkgname=vega-nostr
|
||||
pkgver=0.12.10
|
||||
pkgver=0.12.11
|
||||
pkgrel=1
|
||||
pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
|
||||
arch=('x86_64')
|
||||
|
||||
Generated
+2
-2
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "vega",
|
||||
"version": "0.12.10",
|
||||
"version": "0.12.11",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "vega",
|
||||
"version": "0.12.10",
|
||||
"version": "0.12.11",
|
||||
"dependencies": {
|
||||
"@nostr-dev-kit/ndk": "^3.0.3",
|
||||
"@tailwindcss/vite": "^4.2.1",
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "vega",
|
||||
"private": true,
|
||||
"version": "0.12.10",
|
||||
"version": "0.12.11",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
|
||||
Generated
+1
-1
@@ -5429,7 +5429,7 @@ checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
|
||||
|
||||
[[package]]
|
||||
name = "vega"
|
||||
version = "0.12.10"
|
||||
version = "0.12.11"
|
||||
dependencies = [
|
||||
"futures-util",
|
||||
"hex",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[package]
|
||||
name = "vega"
|
||||
version = "0.12.10"
|
||||
version = "0.12.11"
|
||||
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.10",
|
||||
"version": "0.12.11",
|
||||
"identifier": "com.hoornet.vega",
|
||||
"build": {
|
||||
"beforeDevCommand": "npm run dev",
|
||||
|
||||
+3
-3
@@ -28,7 +28,7 @@ const V4VView = lazy(() => import("./components/v4v/V4VView").then(m => ({ defau
|
||||
const DebugPanel = lazy(() => import("./components/shared/DebugPanel").then(m => ({ default: m.DebugPanel })));
|
||||
const HelpModal = lazy(() => import("./components/shared/HelpModal").then(m => ({ default: m.HelpModal })));
|
||||
import { useUIStore } from "./stores/ui";
|
||||
import { useUserStore } from "./stores/user";
|
||||
import { useCanSign } from "./stores/user";
|
||||
import { getTheme, applyTheme } from "./lib/themes";
|
||||
import { useUpdater } from "./hooks/useUpdater";
|
||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
@@ -57,8 +57,8 @@ function UpdateBanner() {
|
||||
}
|
||||
|
||||
function ReadOnlyBanner() {
|
||||
const loggedIn = useUserStore((s) => s.loggedIn);
|
||||
if (loggedIn) return null;
|
||||
const canSign = useCanSign();
|
||||
if (canSign) return null;
|
||||
return (
|
||||
<div className="flex items-center justify-center gap-2 px-4 py-1.5 bg-warning/10 border-b border-warning/30 text-[11px] shrink-0">
|
||||
<span className="text-warning">Read-only mode — sign in to post, react, and zap</span>
|
||||
|
||||
@@ -2,8 +2,10 @@ import { useState, useEffect, useRef, useCallback, useMemo } from "react";
|
||||
import { renderMarkdown } from "../../lib/markdown";
|
||||
import { publishArticle } from "../../lib/nostr";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useCanSign } from "../../stores/user";
|
||||
import { MarkdownToolbar, handleEditorKeyDown } from "./MarkdownToolbar";
|
||||
import { useDraftStore, type ArticleDraft } from "../../stores/drafts";
|
||||
import { LoginModal } from "../shared/LoginModal";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
import { readFile } from "@tauri-apps/plugin-fs";
|
||||
import { uploadBytes, uploadImage } from "../../lib/upload";
|
||||
@@ -31,9 +33,11 @@ function formatSavedAgo(elapsedMs: number): string {
|
||||
}
|
||||
|
||||
export function ArticleEditor() {
|
||||
const canSign = useCanSign();
|
||||
const { goBack } = useUIStore();
|
||||
const { activeDraftId, drafts, updateDraft, deleteDraft, setActiveDraft, createDraft } = useDraftStore();
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
|
||||
// If no active draft, show draft list
|
||||
const activeDraft = activeDraftId ? drafts.find((d) => d.id === activeDraftId) : null;
|
||||
@@ -336,13 +340,22 @@ export function ArticleEditor() {
|
||||
Meta
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={!canPublish || publishing || published}
|
||||
className="px-4 py-1 text-[11px] bg-accent hover:bg-accent-hover text-accent-text transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{published ? "Published ✓" : publishing ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
{canSign ? (
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={!canPublish || publishing || published}
|
||||
className="px-4 py-1 text-[11px] bg-accent hover:bg-accent-hover text-accent-text transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{published ? "Published ✓" : publishing ? "Publishing…" : "Publish"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowLoginModal(true)}
|
||||
className="px-4 py-1 text-[11px] border border-accent/60 text-accent hover:bg-accent hover:text-accent-text transition-colors"
|
||||
>
|
||||
Sign in to publish
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
@@ -498,6 +511,7 @@ export function ArticleEditor() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{showLoginModal && <LoginModal onClose={() => setShowLoginModal(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { fetchArticleFeed, getNDK } from "../../lib/nostr";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useUserStore, useCanSign } from "../../stores/user";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { dbLoadArticles, dbSaveNotes } from "../../lib/db";
|
||||
import { ArticleCard } from "./ArticleCard";
|
||||
@@ -9,6 +9,7 @@ import { ArticleCard } from "./ArticleCard";
|
||||
type ArticleTab = "latest" | "following";
|
||||
|
||||
export function ArticleFeed() {
|
||||
const canSign = useCanSign();
|
||||
const { loggedIn, follows } = useUserStore();
|
||||
const { setView } = useUIStore();
|
||||
const [tab, setTab] = useState<ArticleTab>("latest");
|
||||
@@ -66,12 +67,14 @@ export function ArticleFeed() {
|
||||
{/* Header */}
|
||||
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
|
||||
<h1 className="text-text text-sm font-medium">Articles</h1>
|
||||
<button
|
||||
onClick={() => setView("article-editor")}
|
||||
className="text-[11px] px-3 py-1 border border-accent/60 text-accent hover:bg-accent hover:text-accent-text transition-colors"
|
||||
>
|
||||
Write article
|
||||
</button>
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={() => setView("article-editor")}
|
||||
className="text-[11px] px-3 py-1 border border-accent/60 text-accent hover:bg-accent hover:text-accent-text transition-colors"
|
||||
>
|
||||
Write article
|
||||
</button>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Tabs */}
|
||||
|
||||
@@ -3,7 +3,7 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { renderMarkdown } from "../../lib/markdown";
|
||||
import { useAutoResize } from "../../hooks/useAutoResize";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useCanSign } from "../../stores/user";
|
||||
import { useBookmarkStore } from "../../stores/bookmark";
|
||||
import { fetchArticle, publishReaction, publishRepost, publishNote } from "../../lib/nostr";
|
||||
import { nip19 } from "@nostr-dev-kit/ndk";
|
||||
@@ -69,7 +69,7 @@ function AuthorRow({ pubkey, publishedAt, readingTime }: { pubkey: string; publi
|
||||
|
||||
export function ArticleView() {
|
||||
const { pendingArticleNaddr, pendingArticleEvent, goBack } = useUIStore();
|
||||
const { loggedIn } = useUserStore();
|
||||
const canSign = useCanSign();
|
||||
|
||||
const [event, setEvent] = useState<NDKEvent | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -241,7 +241,7 @@ export function ArticleView() {
|
||||
← Back
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{event && loggedIn && (
|
||||
{event && canSign && (
|
||||
<button
|
||||
onClick={handleBookmark}
|
||||
className={`text-[11px] px-3 py-1 border transition-colors ${
|
||||
@@ -254,7 +254,7 @@ export function ArticleView() {
|
||||
{bookmarked ? "▪ Saved" : "▫ Save"}
|
||||
</button>
|
||||
)}
|
||||
{event && loggedIn && (
|
||||
{event && canSign && (
|
||||
<button
|
||||
onClick={() => setShowZap(true)}
|
||||
className="text-[11px] px-3 py-1 border border-border text-zap hover:border-zap/40 hover:bg-zap/5 transition-colors"
|
||||
@@ -262,7 +262,7 @@ export function ArticleView() {
|
||||
⚡ zap {authorName}
|
||||
</button>
|
||||
)}
|
||||
{event && loggedIn && (
|
||||
{event && canSign && (
|
||||
<button
|
||||
onClick={handleRepost}
|
||||
disabled={reposted}
|
||||
@@ -275,7 +275,7 @@ export function ArticleView() {
|
||||
{reposted ? "Reposted" : "Repost"}
|
||||
</button>
|
||||
)}
|
||||
{event && loggedIn && (
|
||||
{event && canSign && (
|
||||
<button
|
||||
onClick={() => setShowComment(!showComment)}
|
||||
className={`text-[11px] px-3 py-1 border transition-colors ${
|
||||
@@ -407,7 +407,7 @@ export function ArticleView() {
|
||||
← Back
|
||||
</button>
|
||||
<div className="flex items-center gap-2">
|
||||
{loggedIn && (
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={handleReaction}
|
||||
disabled={reacted}
|
||||
@@ -420,7 +420,7 @@ export function ArticleView() {
|
||||
{reacted ? "♥ Liked" : "♡ Like"}
|
||||
</button>
|
||||
)}
|
||||
{loggedIn && (
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={handleBookmark}
|
||||
className={`text-[11px] px-3 py-1.5 border transition-colors ${
|
||||
@@ -432,7 +432,7 @@ export function ArticleView() {
|
||||
{bookmarked ? "▪ Saved" : "▫ Save"}
|
||||
</button>
|
||||
)}
|
||||
{loggedIn && (
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={handleRepost}
|
||||
disabled={reposted}
|
||||
@@ -445,7 +445,7 @@ export function ArticleView() {
|
||||
{reposted ? "Reposted" : "Repost"}
|
||||
</button>
|
||||
)}
|
||||
{loggedIn && (
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={() => { setShowComment(true); scrollRef.current?.scrollTo({ top: 0, behavior: "smooth" }); }}
|
||||
className="text-[11px] px-3 py-1.5 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
|
||||
@@ -453,7 +453,7 @@ export function ArticleView() {
|
||||
Comment
|
||||
</button>
|
||||
)}
|
||||
{loggedIn && (
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={() => setShowZap(true)}
|
||||
className="text-[11px] px-4 py-1.5 bg-zap hover:bg-zap/90 text-zap-text transition-colors"
|
||||
|
||||
@@ -3,7 +3,7 @@ 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 { useUserStore, useCanSign } from "../../stores/user";
|
||||
import { useFeedStore } from "../../stores/feed";
|
||||
import { shortenPubkey, profileName } from "../../lib/utils";
|
||||
import { open } from "@tauri-apps/plugin-dialog";
|
||||
@@ -13,6 +13,7 @@ import { EmojiPicker } from "../shared/EmojiPicker";
|
||||
const COMPOSE_DRAFT_KEY = "wrystr_compose_draft";
|
||||
|
||||
export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () => void; onNoteInjected?: (event: import("@nostr-dev-kit/ndk").NDKEvent) => void }) {
|
||||
const canSign = useCanSign();
|
||||
const [text, setText] = useState(() => {
|
||||
try { return localStorage.getItem(COMPOSE_DRAFT_KEY) || ""; }
|
||||
catch { return ""; }
|
||||
@@ -43,6 +44,8 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
|
||||
return () => clearTimeout(t);
|
||||
}, [text]);
|
||||
|
||||
if (!canSign) return null;
|
||||
|
||||
const charCount = text.length;
|
||||
const warnLimit = charCount > 3500;
|
||||
const overLimit = charCount > 4000;
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { publishReply } from "../../lib/nostr";
|
||||
import { useCanSign } from "../../stores/user";
|
||||
import { uploadImage, uploadBytes } from "../../lib/upload";
|
||||
import { useAutoResize } from "../../hooks/useAutoResize";
|
||||
import { useReplyCount } from "../../hooks/useReplyCount";
|
||||
@@ -15,6 +16,7 @@ interface InlineReplyBoxProps {
|
||||
}
|
||||
|
||||
export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps) {
|
||||
const canSign = useCanSign();
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [attachments, setAttachments] = useState<string[]>([]);
|
||||
const [replying, setReplying] = useState(false);
|
||||
@@ -27,6 +29,8 @@ export function InlineReplyBox({ event, name, rootEvent }: InlineReplyBoxProps)
|
||||
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||||
const [, adjustReplyCount] = useReplyCount(event.id);
|
||||
|
||||
if (!canSign) return null;
|
||||
|
||||
const insertAtCursor = (str: string) => {
|
||||
const ta = replyRef.current;
|
||||
if (ta) {
|
||||
|
||||
@@ -4,7 +4,7 @@ import { useProfile } from "../../hooks/useProfile";
|
||||
import { useReactions } from "../../hooks/useReactions";
|
||||
import { useReplyCount } from "../../hooks/useReplyCount";
|
||||
import { useZapCount } from "../../hooks/useZapCount";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useCanSign } from "../../stores/user";
|
||||
import { useBookmarkStore } from "../../stores/bookmark";
|
||||
import { publishReaction, publishRepost } from "../../lib/nostr";
|
||||
import { profileName } from "../../lib/utils";
|
||||
@@ -24,7 +24,7 @@ export function NoteActions({ event, onReplyToggle, showReply, enabled = true }:
|
||||
const profile = useProfile(event.pubkey);
|
||||
const name = profileName(profile, event.pubkey.slice(0, 8) + "…");
|
||||
const avatar = typeof profile?.picture === "string" ? profile.picture : undefined;
|
||||
const { loggedIn } = useUserStore();
|
||||
const canSign = useCanSign();
|
||||
const { bookmarkedIds, addBookmark, removeBookmark } = useBookmarkStore();
|
||||
const isBookmarked = bookmarkedIds.includes(event.id!);
|
||||
|
||||
@@ -42,7 +42,7 @@ export function NoteActions({ event, onReplyToggle, showReply, enabled = true }:
|
||||
const myReactions = reactionsData?.myReactions ?? new Set<string>();
|
||||
|
||||
const handleReact = async (emoji: string) => {
|
||||
if (!loggedIn || reacting || myReactions.has(emoji)) return;
|
||||
if (!canSign || reacting || myReactions.has(emoji)) return;
|
||||
setReacting(true);
|
||||
setShowEmojiPicker(false);
|
||||
try {
|
||||
@@ -111,7 +111,7 @@ export function NoteActions({ event, onReplyToggle, showReply, enabled = true }:
|
||||
))}
|
||||
|
||||
{/* Add reaction button */}
|
||||
{loggedIn && (
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={() => setShowEmojiPicker((v) => !v)}
|
||||
disabled={reacting}
|
||||
@@ -145,58 +145,62 @@ export function NoteActions({ event, onReplyToggle, showReply, enabled = true }:
|
||||
)}
|
||||
</div>
|
||||
|
||||
<span className="text-text-dim text-[10px] select-none">·</span>
|
||||
|
||||
<button
|
||||
onClick={handleRepost}
|
||||
disabled={reposting || reposted}
|
||||
title="Repost"
|
||||
className={`text-[14px] transition-colors disabled:cursor-default ${
|
||||
reposted ? "text-accent" : "text-text hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
⟳{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)}
|
||||
title="Quote"
|
||||
className="text-[14px] text-text hover:text-accent transition-colors"
|
||||
>
|
||||
❝
|
||||
</button>
|
||||
|
||||
{(profile?.lud16 || profile?.lud06) && (
|
||||
{canSign && (
|
||||
<>
|
||||
<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"
|
||||
onClick={handleRepost}
|
||||
disabled={reposting || reposted}
|
||||
title="Repost"
|
||||
className={`text-[14px] transition-colors disabled:cursor-default ${
|
||||
reposted ? "text-accent" : "text-text hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{zapData && zapData.totalSats > 0
|
||||
? `⚡ ${zapData.totalSats.toLocaleString()} sats`
|
||||
: "⚡"}
|
||||
⟳{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)}
|
||||
title="Quote"
|
||||
className="text-[14px] text-text hover:text-accent transition-colors"
|
||||
>
|
||||
❝
|
||||
</button>
|
||||
|
||||
{(profile?.lud16 || profile?.lud06) && (
|
||||
<>
|
||||
<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!)}
|
||||
title={isBookmarked ? "Remove bookmark" : "Bookmark"}
|
||||
className={`text-[14px] transition-colors ${
|
||||
isBookmarked ? "text-accent" : "text-text hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{isBookmarked ? "★" : "☆"}
|
||||
</button>
|
||||
|
||||
<span className="text-text-dim text-[10px] select-none">·</span>
|
||||
</>
|
||||
)}
|
||||
|
||||
<span className="text-text-dim text-[10px] select-none">·</span>
|
||||
|
||||
<button
|
||||
onClick={() => isBookmarked ? removeBookmark(event.id!) : addBookmark(event.id!)}
|
||||
title={isBookmarked ? "Remove bookmark" : "Bookmark"}
|
||||
className={`text-[14px] transition-colors ${
|
||||
isBookmarked ? "text-accent" : "text-text hover:text-accent"
|
||||
}`}
|
||||
>
|
||||
{isBookmarked ? "★" : "☆"}
|
||||
</button>
|
||||
|
||||
<span className="text-text-dim text-[10px] select-none">·</span>
|
||||
|
||||
<button
|
||||
onClick={handleShare}
|
||||
title="Copy link"
|
||||
|
||||
@@ -3,11 +3,11 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { useNip05Verified } from "../../hooks/useNip05Verified";
|
||||
import { useInView } from "../../hooks/useInView";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useUserStore, useCanSign } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { timeAgo, shortenPubkey } from "../../lib/utils";
|
||||
import { getNDK, fetchNoteById, ensureConnected } from "../../lib/nostr";
|
||||
import { fetchNoteById, ensureConnected } from "../../lib/nostr";
|
||||
import { getParentEventId } from "../../lib/threadTree";
|
||||
import { NoteContent } from "./NoteContent";
|
||||
import { NoteActions, LoggedOutStats } from "./NoteActions";
|
||||
@@ -39,7 +39,7 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
|
||||
const verified = useNip05Verified(event.pubkey, nip05, inView);
|
||||
const time = event.created_at ? timeAgo(event.created_at) : "";
|
||||
|
||||
const loggedIn = useUserStore((s) => s.loggedIn);
|
||||
const canSign = useCanSign();
|
||||
const ownPubkey = useUserStore((s) => s.pubkey);
|
||||
const follows = useUserStore((s) => s.follows);
|
||||
const follow = useUserStore((s) => s.follow);
|
||||
@@ -114,7 +114,7 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
|
||||
)}
|
||||
<span className="text-text-dim text-[11px] shrink-0">{time}</span>
|
||||
{/* Context menu — hidden until card hover, not shown for own notes */}
|
||||
{loggedIn && event.pubkey !== ownPubkey && (
|
||||
{canSign && event.pubkey !== ownPubkey && (
|
||||
<div className="relative ml-auto">
|
||||
<button
|
||||
onClick={() => setMenuOpen((v) => !v)}
|
||||
@@ -189,7 +189,7 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
|
||||
{event.kind === 1068 && <PollWidget event={event} />}
|
||||
|
||||
{/* Actions */}
|
||||
{loggedIn && !!getNDK().signer && (
|
||||
{canSign && (
|
||||
<NoteActions
|
||||
event={event}
|
||||
onReplyToggle={() => {
|
||||
@@ -204,8 +204,8 @@ export const NoteCard = memo(function NoteCard({ event, focused, onReplyInThread
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Stats visible when logged out */}
|
||||
{!loggedIn && <LoggedOutStats event={event} enabled={inView} />}
|
||||
{/* Stats visible in read-only mode (logged out or npub-only) */}
|
||||
{!canSign && <LoggedOutStats event={event} enabled={inView} />}
|
||||
|
||||
{/* Inline reply box */}
|
||||
{showReply && <InlineReplyBox event={event} name={name} />}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState, useRef, useEffect } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { publishQuote } from "../../lib/nostr";
|
||||
import { useCanSign } from "../../stores/user";
|
||||
import { LoginModal } from "../shared/LoginModal";
|
||||
|
||||
interface QuoteModalProps {
|
||||
event: NDKEvent;
|
||||
@@ -11,9 +13,11 @@ interface QuoteModalProps {
|
||||
}
|
||||
|
||||
export function QuoteModal({ event, authorName, authorAvatar, onClose, onPublished }: QuoteModalProps) {
|
||||
const canSign = useCanSign();
|
||||
const [text, setText] = useState("");
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
@@ -89,17 +93,27 @@ export function QuoteModal({ event, authorName, authorAvatar, onClose, onPublish
|
||||
{error && <p className="text-danger text-[11px] mt-2">{error}</p>}
|
||||
|
||||
<div className="flex items-center justify-between mt-3">
|
||||
<span className="text-text-dim text-[10px]">Ctrl+Enter to post</span>
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={!canPublish}
|
||||
className="px-4 py-1.5 text-[11px] bg-accent hover:bg-accent-hover text-accent-text transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{publishing ? "Posting…" : "Quote & post"}
|
||||
</button>
|
||||
<span className="text-text-dim text-[10px]">{canSign ? "Ctrl+Enter to post" : ""}</span>
|
||||
{canSign ? (
|
||||
<button
|
||||
onClick={handlePublish}
|
||||
disabled={!canPublish}
|
||||
className="px-4 py-1.5 text-[11px] bg-accent hover:bg-accent-hover text-accent-text transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{publishing ? "Posting…" : "Quote & post"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowLogin(true)}
|
||||
className="px-4 py-1.5 text-[11px] border border-accent/60 text-accent hover:bg-accent hover:text-accent-text transition-colors"
|
||||
>
|
||||
Sign in to post
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{showLogin && <LoginModal onClose={() => setShowLogin(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useUserStore, useCanSign } from "../../stores/user";
|
||||
import { useNotificationsStore } from "../../stores/notifications";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { useNip05Verified } from "../../hooks/useNip05Verified";
|
||||
@@ -23,6 +23,7 @@ function FollowRow({
|
||||
const nip05 = typeof profile?.nip05 === "string" ? profile.nip05 : undefined;
|
||||
const verified = useNip05Verified(pubkey, nip05);
|
||||
|
||||
const canSign = useCanSign();
|
||||
const { follows, follow, unfollow, pubkey: ownPubkey } = useUserStore();
|
||||
const { openProfile } = useUIStore();
|
||||
const isFollowing = follows.includes(pubkey);
|
||||
@@ -68,7 +69,7 @@ function FollowRow({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!isSelf && (
|
||||
{!isSelf && canSign && (
|
||||
<button
|
||||
onClick={() => isFollowing ? unfollow(pubkey) : follow(pubkey)}
|
||||
className={`shrink-0 px-3 py-1 text-[11px] transition-colors ${
|
||||
|
||||
@@ -2,6 +2,7 @@ import { useRef, useEffect, useCallback, useState } from "react";
|
||||
import type { PodcastEpisode } from "../../types/podcast";
|
||||
import { usePodcastStore } from "../../stores/podcast";
|
||||
import { publishNote } from "../../lib/nostr";
|
||||
import { useCanSign } from "../../stores/user";
|
||||
import { stopStreaming } from "../../lib/podcast/v4v";
|
||||
import { V4VIndicator } from "./V4VIndicator";
|
||||
|
||||
@@ -15,8 +16,11 @@ function formatTime(seconds: number): string {
|
||||
}
|
||||
|
||||
function ShareButton({ episode }: { episode: PodcastEpisode | null }) {
|
||||
const canSign = useCanSign();
|
||||
const [state, setState] = useState<"idle" | "confirm" | "shared">("idle");
|
||||
|
||||
if (!canSign) return null;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!episode) return;
|
||||
if (state === "idle") {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { memo } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { usePollVotes } from "../../hooks/usePollVotes";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useUserStore, useCanSign } from "../../stores/user";
|
||||
import { publishPollResponse } from "../../lib/nostr";
|
||||
|
||||
interface PollOption {
|
||||
@@ -26,7 +26,7 @@ function getPollClosedAt(event: NDKEvent): number | null {
|
||||
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 canSign = useCanSign();
|
||||
const myPubkey = useUserStore((s) => s.pubkey);
|
||||
|
||||
if (options.length === 0) return null;
|
||||
@@ -36,11 +36,11 @@ export const PollWidget = memo(function PollWidget({ event }: { event: NDKEvent
|
||||
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 showResults = hasVoted || isExpired || isAuthor || !canSign;
|
||||
const total = pollData?.total ?? 0;
|
||||
|
||||
const handleVote = async (optionIndex: number) => {
|
||||
if (showResults || !loggedIn) return;
|
||||
if (showResults || !canSign) return;
|
||||
addVote(optionIndex);
|
||||
try {
|
||||
await publishPollResponse(event.id!, event.pubkey, optionIndex);
|
||||
@@ -112,7 +112,7 @@ export const PollWidget = memo(function PollWidget({ event }: { event: NDKEvent
|
||||
{closedAt && !isExpired && (
|
||||
<span>· Ends {new Date(closedAt * 1000).toLocaleDateString()}</span>
|
||||
)}
|
||||
{!hasVoted && !isExpired && !isAuthor && loggedIn && total === 0 && pollData && (
|
||||
{!hasVoted && !isExpired && !isAuthor && canSign && total === 0 && pollData && (
|
||||
<span>· Be the first to vote</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -1,11 +1,13 @@
|
||||
import { useState } from "react";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useUserStore, useCanSign } from "../../stores/user";
|
||||
import { LoginModal } from "../shared/LoginModal";
|
||||
import { invalidateProfileCache } from "../../hooks/useProfile";
|
||||
import { publishProfile } from "../../lib/nostr";
|
||||
import { ImageField } from "./ImageField";
|
||||
import { Nip05Field } from "./Nip05Field";
|
||||
|
||||
export function EditProfileForm({ pubkey, onSaved }: { pubkey: string; onSaved: () => void }) {
|
||||
const canSign = useCanSign();
|
||||
const { profile, fetchOwnProfile } = useUserStore();
|
||||
const safeStr = (v: unknown) => (typeof v === "string" ? v : "");
|
||||
const [name, setName] = useState(safeStr(profile?.name));
|
||||
@@ -19,6 +21,7 @@ export function EditProfileForm({ pubkey, onSaved }: { pubkey: string; onSaved:
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saved, setSaved] = useState(false);
|
||||
const [showLoginModal, setShowLoginModal] = useState(false);
|
||||
|
||||
const handleSave = async () => {
|
||||
setSaving(true);
|
||||
@@ -84,14 +87,24 @@ export function EditProfileForm({ pubkey, onSaved }: { pubkey: string; onSaved:
|
||||
</div>
|
||||
{error && <p className="text-danger text-[11px] mb-2">{error}</p>}
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || saved}
|
||||
className="px-4 py-1.5 text-[11px] bg-accent hover:bg-accent-hover text-accent-text transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saved ? "Saved ✓" : saving ? "Saving…" : "Save profile"}
|
||||
</button>
|
||||
{canSign ? (
|
||||
<button
|
||||
onClick={handleSave}
|
||||
disabled={saving || saved}
|
||||
className="px-4 py-1.5 text-[11px] bg-accent hover:bg-accent-hover text-accent-text transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{saved ? "Saved ✓" : saving ? "Saving…" : "Save profile"}
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => setShowLoginModal(true)}
|
||||
className="px-4 py-1.5 text-[11px] border border-accent/60 text-accent hover:bg-accent hover:text-accent-text transition-colors"
|
||||
>
|
||||
Sign in to save
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{showLoginModal && <LoginModal onClose={() => setShowLoginModal(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { NDKEvent, nip19 } from "@nostr-dev-kit/ndk";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useUserStore, useCanSign } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { useReputation } from "../../hooks/useReputation";
|
||||
import { fetchUserNotesNIP65, fetchAuthorArticles, getNDK } from "../../lib/nostr";
|
||||
import { fetchUserNotesNIP65, fetchAuthorArticles } from "../../lib/nostr";
|
||||
import { shortenPubkey, profileName } from "../../lib/utils";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
import { ArticleCard } from "../article/ArticleCard";
|
||||
@@ -47,7 +47,8 @@ function TopFollowerAvatar({ pubkey }: { pubkey: string }) {
|
||||
|
||||
export function ProfileView() {
|
||||
const { selectedPubkey, goBack, openDM } = useUIStore();
|
||||
const { pubkey: ownPubkey, profile: ownProfile, loggedIn, follows, follow, unfollow } = useUserStore();
|
||||
const canSign = useCanSign();
|
||||
const { pubkey: ownPubkey, profile: ownProfile, follows, follow, unfollow } = useUserStore();
|
||||
const pubkey = selectedPubkey!;
|
||||
const isOwn = pubkey === ownPubkey;
|
||||
|
||||
@@ -150,13 +151,13 @@ export function ProfileView() {
|
||||
← {editing ? "Cancel" : "Back"}
|
||||
</button>
|
||||
<h1 className="text-text text-sm font-medium">{isOwn ? "Your Profile" : "Profile"}</h1>
|
||||
{isOwn && !getNDK().signer && (
|
||||
{isOwn && !canSign && (
|
||||
<span className="text-text-dim text-[10px] border border-border px-2 py-0.5">
|
||||
read-only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{isOwn && !editing && !!getNDK().signer && (
|
||||
{isOwn && !editing && canSign && (
|
||||
<button
|
||||
onClick={() => setEditing(true)}
|
||||
className="text-[11px] px-3 py-1 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors"
|
||||
@@ -164,7 +165,7 @@ export function ProfileView() {
|
||||
Edit profile
|
||||
</button>
|
||||
)}
|
||||
{!isOwn && loggedIn && (
|
||||
{!isOwn && canSign && (
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{(lud16 || profile?.lud06) && (
|
||||
<button
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { getNDK, getStoredRelayUrls, addRelay, removeRelay, publishRelayList, fetchRelayRecommendations, normalizeRelayUrl } from "../../lib/nostr";
|
||||
import { useRelayHealthStore } from "../../stores/relayHealth";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useUserStore, useCanSign } from "../../stores/user";
|
||||
import type { RelayHealthResult } from "../../lib/nostr/relayHealth";
|
||||
|
||||
function statusColor(status: RelayHealthResult["status"]): string {
|
||||
@@ -151,6 +151,7 @@ function RelayPoolRow({ url, connected, onRemove }: { url: string; connected: bo
|
||||
|
||||
export function RelaysView() {
|
||||
const { results, checking, lastChecked, checkAll } = useRelayHealthStore();
|
||||
const canSign = useCanSign();
|
||||
const { loggedIn } = useUserStore();
|
||||
const ndk = getNDK();
|
||||
const poolRelays = Array.from(ndk.pool?.relays?.values() ?? []);
|
||||
@@ -279,7 +280,7 @@ export function RelaysView() {
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
{loggedIn && !!getNDK().signer && (
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={handleRepublish}
|
||||
disabled={republishing}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useCanSign } from "../../stores/user";
|
||||
import { useNotificationsStore } from "../../stores/notifications";
|
||||
import { useDraftStore } from "../../stores/drafts";
|
||||
import { useBookmarkStore } from "../../stores/bookmark";
|
||||
import { getNDK } from "../../lib/nostr";
|
||||
import { AccountSwitcher } from "./AccountSwitcher";
|
||||
import pkg from "../../../package.json";
|
||||
|
||||
// Items marked `requiresSigner: true` are hidden in read-only mode
|
||||
// because they're account-bound and have no useful content without a signer.
|
||||
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: "🎙" },
|
||||
{ id: "search" as const, label: "search", icon: "⌕" },
|
||||
{ id: "bookmarks" as const, label: "bookmarks", icon: "★" },
|
||||
{ id: "dm" as const, label: "messages", icon: "✉" },
|
||||
{ id: "notifications" as const, label: "notifications", icon: "🔔" },
|
||||
{ id: "bookmarks" as const, label: "bookmarks", icon: "★", requiresSigner: true },
|
||||
{ id: "dm" as const, label: "messages", icon: "✉", requiresSigner: true },
|
||||
{ id: "notifications" as const, label: "notifications", icon: "🔔", requiresSigner: true },
|
||||
{ id: "follows" as const, label: "follows", icon: "👥" },
|
||||
{ id: "zaps" as const, label: "zaps", icon: "⚡" },
|
||||
{ id: "v4v" as const, label: "v4v", icon: "📡" },
|
||||
{ id: "zaps" as const, label: "zaps", icon: "⚡", requiresSigner: true },
|
||||
{ id: "v4v" as const, label: "v4v", icon: "📡", requiresSigner: true },
|
||||
{ id: "relays" as const, label: "relays", icon: "⟐" },
|
||||
{ id: "settings" as const, label: "settings", icon: "⚙" },
|
||||
{ id: "about" as const, label: "support", icon: "♥" },
|
||||
@@ -26,10 +27,11 @@ const NAV_ITEMS = [
|
||||
|
||||
export function Sidebar() {
|
||||
const { currentView, setView, sidebarCollapsed, toggleSidebar } = useUIStore();
|
||||
const { loggedIn } = useUserStore();
|
||||
const canSign = useCanSign();
|
||||
const { unreadCount: notifUnread, dmUnreadCount, newFollowersCount } = useNotificationsStore();
|
||||
const draftCount = useDraftStore((s) => s.drafts.length);
|
||||
const bookmarkUnread = useBookmarkStore((s) => s.unreadArticleCount());
|
||||
const visibleNav = NAV_ITEMS.filter((item) => !("requiresSigner" in item && item.requiresSigner) || canSign);
|
||||
|
||||
const c = sidebarCollapsed;
|
||||
|
||||
@@ -73,7 +75,7 @@ export function Sidebar() {
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{/* Write article — show icon even when collapsed */}
|
||||
{loggedIn && !!getNDK().signer && (
|
||||
{canSign && (
|
||||
<button
|
||||
onClick={() => setView("article-editor")}
|
||||
title="Write article"
|
||||
@@ -96,7 +98,7 @@ export function Sidebar() {
|
||||
</button>
|
||||
)}
|
||||
|
||||
{NAV_ITEMS.map((item) => {
|
||||
{visibleNav.map((item) => {
|
||||
const badge = item.id === "dm" ? dmUnreadCount : item.id === "notifications" ? notifUnread : item.id === "bookmarks" ? bookmarkUnread : item.id === "follows" ? newFollowersCount : 0;
|
||||
return (
|
||||
<button
|
||||
|
||||
@@ -2,9 +2,9 @@ import { useEffect, useRef, useState } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useAutoResize } from "../../hooks/useAutoResize";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useCanSign } from "../../stores/user";
|
||||
import { useMuteStore } from "../../stores/mute";
|
||||
import { fetchNoteById, fetchThreadEvents, fetchAncestors, publishReply, getNDK, ensureConnected } from "../../lib/nostr";
|
||||
import { fetchNoteById, fetchThreadEvents, fetchAncestors, publishReply, ensureConnected } from "../../lib/nostr";
|
||||
import { buildThreadTree, getRootEventId } from "../../lib/threadTree";
|
||||
import type { ThreadNode } from "../../lib/threadTree";
|
||||
import { debug } from "../../lib/debug";
|
||||
@@ -16,7 +16,7 @@ import { EmojiPicker } from "../shared/EmojiPicker";
|
||||
|
||||
export function ThreadView() {
|
||||
const { selectedNote, goBack } = useUIStore();
|
||||
const { loggedIn } = useUserStore();
|
||||
const canSign = useCanSign();
|
||||
const { mutedPubkeys, contentMatchesMutedKeyword } = useMuteStore();
|
||||
|
||||
const [rootEvent, setRootEvent] = useState<NDKEvent | null>(null);
|
||||
@@ -214,7 +214,7 @@ export function ThreadView() {
|
||||
</div>
|
||||
|
||||
{/* Root reply box (inline, right below root) */}
|
||||
{showRootReply && loggedIn && !!getNDK().signer && (
|
||||
{showRootReply && canSign && (
|
||||
<div className="border-b border-border border-l-2 border-l-accent/40 ml-3 px-3 py-2">
|
||||
<textarea
|
||||
ref={replyRef}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import { useState } from "react";
|
||||
import { useLightningStore, ZapTargetSpec } from "../../stores/lightning";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useCanSign } from "../../stores/user";
|
||||
import { LoginModal } from "../shared/LoginModal";
|
||||
|
||||
const AMOUNT_PRESETS = [21, 100, 500, 1000, 5000];
|
||||
|
||||
@@ -13,6 +15,7 @@ interface ZapModalProps {
|
||||
}
|
||||
|
||||
export function ZapModal({ target, recipientName, onClose }: ZapModalProps) {
|
||||
const canSign = useCanSign();
|
||||
const { nwcUri, zap } = useLightningStore();
|
||||
const { setView } = useUIStore();
|
||||
const [amountSats, setAmountSats] = useState(21);
|
||||
@@ -21,6 +24,7 @@ export function ZapModal({ target, recipientName, onClose }: ZapModalProps) {
|
||||
const [comment, setComment] = useState("");
|
||||
const [state, setState] = useState<ZapState>("idle");
|
||||
const [errorMsg, setErrorMsg] = useState("");
|
||||
const [showLogin, setShowLogin] = useState(false);
|
||||
|
||||
const effectiveAmount = useCustom ? (parseInt(customAmount) || 0) : amountSats;
|
||||
|
||||
@@ -60,8 +64,23 @@ export function ZapModal({ target, recipientName, onClose }: ZapModalProps) {
|
||||
<button onClick={onClose} aria-label="Close" className="text-text-dim hover:text-text text-[11px] transition-colors">✕</button>
|
||||
</div>
|
||||
|
||||
{/* Sign-in required state */}
|
||||
{!canSign && (
|
||||
<div className="px-4 py-5 text-center">
|
||||
<p className="text-text-dim text-[12px] mb-3">
|
||||
Sign in with a private key or remote signer to send zaps.
|
||||
</p>
|
||||
<button
|
||||
onClick={() => setShowLogin(true)}
|
||||
className="px-4 py-1.5 text-[11px] border border-accent/60 text-accent hover:bg-accent hover:text-accent-text transition-colors"
|
||||
>
|
||||
Sign in
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* No wallet state */}
|
||||
{!nwcUri && (
|
||||
{canSign && !nwcUri && (
|
||||
<div className="px-4 py-5 text-center">
|
||||
<p className="text-text-dim text-[12px] mb-3">
|
||||
Connect a Lightning wallet using a Nostr Wallet Connect (NWC) URI to send zaps.
|
||||
@@ -76,7 +95,7 @@ export function ZapModal({ target, recipientName, onClose }: ZapModalProps) {
|
||||
)}
|
||||
|
||||
{/* Zap form */}
|
||||
{nwcUri && state === "idle" && (
|
||||
{canSign && nwcUri && state === "idle" && (
|
||||
<div className="px-4 py-4 space-y-4">
|
||||
{/* Amount presets */}
|
||||
<div>
|
||||
@@ -131,7 +150,7 @@ export function ZapModal({ target, recipientName, onClose }: ZapModalProps) {
|
||||
)}
|
||||
|
||||
{/* Paying state */}
|
||||
{nwcUri && state === "paying" && (
|
||||
{canSign && nwcUri && state === "paying" && (
|
||||
<div className="px-4 py-8 text-center">
|
||||
<div className="text-zap text-2xl mb-2">⚡</div>
|
||||
<p className="text-text-dim text-[12px]">Sending {effectiveAmount} sats…</p>
|
||||
@@ -160,6 +179,7 @@ export function ZapModal({ target, recipientName, onClose }: ZapModalProps) {
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showLogin && <LoginModal onClose={() => setShowLogin(false)} />}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -68,6 +68,15 @@ interface UserState {
|
||||
unfollow: (pubkey: string) => Promise<void>;
|
||||
}
|
||||
|
||||
/** Returns true iff the active account can sign events (has a signer attached). */
|
||||
export function useCanSign(): boolean {
|
||||
return useUserStore((s) => {
|
||||
if (!s.loggedIn || !s.pubkey) return false;
|
||||
const account = s.accounts.find((a) => a.pubkey === s.pubkey);
|
||||
return account?.loginType !== "pubkey";
|
||||
});
|
||||
}
|
||||
|
||||
export const useUserStore = create<UserState>((set, get) => ({
|
||||
pubkey: null,
|
||||
npub: null,
|
||||
@@ -471,6 +480,8 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
},
|
||||
|
||||
follow: async (pubkey: string) => {
|
||||
// Runtime guard — UI should already prevent this, but belt-and-suspenders
|
||||
if (!getNDK().signer) return;
|
||||
const { follows } = get();
|
||||
if (follows.includes(pubkey)) return;
|
||||
const updated = [...follows, pubkey];
|
||||
@@ -479,6 +490,8 @@ export const useUserStore = create<UserState>((set, get) => ({
|
||||
},
|
||||
|
||||
unfollow: async (pubkey: string) => {
|
||||
// Runtime guard — UI should already prevent this, but belt-and-suspenders
|
||||
if (!getNDK().signer) return;
|
||||
const { follows } = get();
|
||||
const updated = follows.filter((pk) => pk !== pubkey);
|
||||
set({ follows: updated });
|
||||
|
||||
Reference in New Issue
Block a user