mirror of
https://github.com/hoornet/vega.git
synced 2026-05-10 14:19:12 -07:00
Bump version to 0.1.7 — Windows playtest fixes
Critical: - NWC wallet now stored per-account (wrystr_nwc_<pubkey>); switching accounts loads the correct wallet automatically - Clear NDK signer before account switch to prevent race where old account could sign outgoing events - LoginModal: add "New account" tab to create a fresh keypair inline (same flow as onboarding, with nsec copy + confirmation checkbox) - ThreadView: add like + zap action row to the root note (was missing) UX: - Zap button now conditional on lud16/lud06 (NoteCard, ProfileView, RootNote) — no zap button shown for profiles without Lightning - Remove "200 notes" counter from sidebar footer - AccountSwitcher: larger active account avatar (w-8), name more prominent; sign-out/remove moved into dropdown only Quick wins: - AboutView: add GitHub Sponsors link - ComposeBox: paste image from clipboard → uploads via nostr.build, inserts URL at cursor with "uploading image…" status Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,11 +1,13 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { publishNote } from "../../lib/nostr";
|
||||
import { uploadImage } from "../../lib/upload";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
|
||||
export function ComposeBox({ onPublished }: { onPublished?: () => void }) {
|
||||
const [text, setText] = useState("");
|
||||
const [publishing, setPublishing] = useState(false);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
@@ -15,7 +17,35 @@ export function ComposeBox({ onPublished }: { onPublished?: () => void }) {
|
||||
|
||||
const charCount = text.length;
|
||||
const overLimit = charCount > 280;
|
||||
const canPost = text.trim().length > 0 && !overLimit && !publishing;
|
||||
const canPost = text.trim().length > 0 && !overLimit && !publishing && !uploading;
|
||||
|
||||
const handlePaste = async (e: React.ClipboardEvent<HTMLTextAreaElement>) => {
|
||||
const file = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/"));
|
||||
if (!file) return;
|
||||
e.preventDefault();
|
||||
setUploading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const url = await uploadImage(file);
|
||||
const ta = textareaRef.current;
|
||||
if (ta) {
|
||||
const start = ta.selectionStart ?? text.length;
|
||||
const end = ta.selectionEnd ?? text.length;
|
||||
const next = text.slice(0, start) + url + text.slice(end);
|
||||
setText(next);
|
||||
setTimeout(() => {
|
||||
ta.selectionStart = ta.selectionEnd = start + url.length;
|
||||
ta.focus();
|
||||
}, 0);
|
||||
} else {
|
||||
setText((t) => t + url);
|
||||
}
|
||||
} catch (err) {
|
||||
setError(`Image upload failed: ${err}`);
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
@@ -66,6 +96,7 @@ export function ComposeBox({ onPublished }: { onPublished?: () => void }) {
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
onPaste={handlePaste}
|
||||
placeholder="What's on your mind?"
|
||||
rows={3}
|
||||
className="w-full bg-transparent text-text text-[13px] placeholder:text-text-dim resize-none focus:outline-none"
|
||||
@@ -77,7 +108,7 @@ export function ComposeBox({ onPublished }: { onPublished?: () => void }) {
|
||||
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className={`text-[10px] ${overLimit ? "text-danger" : "text-text-dim"}`}>
|
||||
{charCount > 0 && `${charCount}/280`}
|
||||
{uploading ? "uploading image…" : charCount > 0 ? `${charCount}/280` : ""}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-dim text-[10px]">Ctrl+Enter to post</span>
|
||||
|
||||
@@ -201,14 +201,16 @@ export function NoteCard({ event }: NoteCardProps) {
|
||||
>
|
||||
quote
|
||||
</button>
|
||||
<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>
|
||||
{(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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -283,12 +283,14 @@ export function ProfileView() {
|
||||
)}
|
||||
{!isOwn && loggedIn && (
|
||||
<div className="flex items-center gap-2">
|
||||
<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"
|
||||
>
|
||||
⚡ zap
|
||||
</button>
|
||||
{(lud16 || profile?.lud06) && (
|
||||
<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"
|
||||
>
|
||||
⚡ zap
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
onClick={handleFollowToggle}
|
||||
disabled={followPending}
|
||||
|
||||
@@ -8,6 +8,7 @@ const LIGHTNING_ADDRESS = "harpos@getalby.com";
|
||||
const BITCOIN_ADDRESS = "bc1qcgaupf80j28ca537xjlcs9dm9s03khezjs7crp";
|
||||
const KOFI_URL = "https://ko-fi.com/jure";
|
||||
const GITHUB_URL = "https://github.com/hoornet/wrystr";
|
||||
const GITHUB_SPONSORS_URL = "https://github.com/sponsors/hoornet";
|
||||
|
||||
function CopyButton({ text }: { text: string }) {
|
||||
const [copied, setCopied] = useState(false);
|
||||
@@ -111,6 +112,17 @@ export function AboutView() {
|
||||
{GITHUB_URL} ↗
|
||||
</a>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-text-muted text-[12px] w-16 shrink-0">Sponsors</span>
|
||||
<a
|
||||
href={GITHUB_SPONSORS_URL}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="text-accent text-[12px] hover:text-accent-hover transition-colors"
|
||||
>
|
||||
{GITHUB_SPONSORS_URL} ↗
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
|
||||
@@ -1,12 +1,84 @@
|
||||
import { useState } from "react";
|
||||
import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
|
||||
interface LoginModalProps {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function NewAccountTab({ onClose }: { onClose: () => void }) {
|
||||
const { loginWithNsec, loginError } = useUserStore();
|
||||
const [signer] = useState(() => NDKPrivateKeySigner.generate());
|
||||
const [confirmed, setConfirmed] = useState(false);
|
||||
const [copied, setCopied] = useState(false);
|
||||
const [logging, setLogging] = useState(false);
|
||||
|
||||
const nsec = signer.nsec;
|
||||
|
||||
const handleCopy = () => {
|
||||
navigator.clipboard.writeText(nsec);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 1500);
|
||||
};
|
||||
|
||||
const handleConfirm = async () => {
|
||||
if (!confirmed) return;
|
||||
setLogging(true);
|
||||
await loginWithNsec(nsec);
|
||||
if (!useUserStore.getState().loginError) {
|
||||
onClose();
|
||||
}
|
||||
setLogging(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-text-muted text-[12px] mb-3">
|
||||
A new private key has been generated for you. Save it somewhere safe — it cannot be recovered.
|
||||
</p>
|
||||
|
||||
<div className="bg-bg border border-border px-3 py-2 font-mono text-[11px] text-text break-all mb-2"
|
||||
style={{ WebkitUserSelect: "text", userSelect: "text" } as React.CSSProperties}
|
||||
>
|
||||
{nsec}
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="text-[11px] px-3 py-1 border border-border text-text-muted hover:text-accent hover:border-accent/40 transition-colors mb-4"
|
||||
>
|
||||
{copied ? "copied ✓" : "copy key"}
|
||||
</button>
|
||||
|
||||
<label className="flex items-start gap-2 cursor-pointer mb-4">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={confirmed}
|
||||
onChange={(e) => setConfirmed(e.target.checked)}
|
||||
className="mt-0.5 shrink-0"
|
||||
/>
|
||||
<span className="text-text-muted text-[12px]">
|
||||
I've saved my private key in a safe place
|
||||
</span>
|
||||
</label>
|
||||
|
||||
{loginError && (
|
||||
<p className="text-danger text-[11px] mb-2">{loginError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleConfirm}
|
||||
disabled={!confirmed || logging}
|
||||
className="w-full px-4 py-2 text-[12px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{logging ? "logging in…" : "create account"}
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function LoginModal({ onClose }: LoginModalProps) {
|
||||
const [tab, setTab] = useState<"nsec" | "pubkey">("nsec");
|
||||
const [tab, setTab] = useState<"nsec" | "pubkey" | "new">("nsec");
|
||||
const [input, setInput] = useState("");
|
||||
const { loginWithNsec, loginWithPubkey, loginError } = useUserStore();
|
||||
|
||||
@@ -15,7 +87,7 @@ export function LoginModal({ onClose }: LoginModalProps) {
|
||||
|
||||
if (tab === "nsec") {
|
||||
await loginWithNsec(input.trim());
|
||||
} else {
|
||||
} else if (tab === "pubkey") {
|
||||
await loginWithPubkey(input.trim());
|
||||
}
|
||||
|
||||
@@ -60,7 +132,7 @@ export function LoginModal({ onClose }: LoginModalProps) {
|
||||
: "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Private key (nsec)
|
||||
Private key
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("pubkey")}
|
||||
@@ -70,50 +142,66 @@ export function LoginModal({ onClose }: LoginModalProps) {
|
||||
: "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
Public key (read-only)
|
||||
Read-only
|
||||
</button>
|
||||
<button
|
||||
onClick={() => setTab("new")}
|
||||
className={`flex-1 px-4 py-2 text-[12px] transition-colors ${
|
||||
tab === "new"
|
||||
? "text-accent border-b-2 border-accent"
|
||||
: "text-text-muted hover:text-text"
|
||||
}`}
|
||||
>
|
||||
New account
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
{/* Content */}
|
||||
<div className="p-4">
|
||||
<label className="block text-text-muted text-[11px] mb-1.5">
|
||||
{tab === "nsec"
|
||||
? "Paste your nsec or hex private key"
|
||||
: "Paste your npub or hex public key"}
|
||||
</label>
|
||||
<input
|
||||
type={tab === "nsec" ? "password" : "text"}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={tab === "nsec" ? "nsec1…" : "npub1…"}
|
||||
autoFocus
|
||||
className="w-full bg-bg border border-border px-3 py-2 text-text text-[13px] font-mono placeholder:text-text-dim focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
{tab === "new" ? (
|
||||
<NewAccountTab onClose={onClose} />
|
||||
) : (
|
||||
<>
|
||||
<label className="block text-text-muted text-[11px] mb-1.5">
|
||||
{tab === "nsec"
|
||||
? "Paste your nsec or hex private key"
|
||||
: "Paste your npub or hex public key"}
|
||||
</label>
|
||||
<input
|
||||
type={tab === "nsec" ? "password" : "text"}
|
||||
value={input}
|
||||
onChange={(e) => setInput(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={tab === "nsec" ? "nsec1…" : "npub1…"}
|
||||
autoFocus
|
||||
className="w-full bg-bg border border-border px-3 py-2 text-text text-[13px] font-mono placeholder:text-text-dim focus:outline-none focus:border-accent/50"
|
||||
/>
|
||||
|
||||
{tab === "nsec" && (
|
||||
<p className="text-text-dim text-[10px] mt-1.5">
|
||||
Your key stays local. Never sent to any server.
|
||||
</p>
|
||||
{tab === "nsec" && (
|
||||
<p className="text-text-dim text-[10px] mt-1.5">
|
||||
Your key stays local. Never sent to any server.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{tab === "pubkey" && (
|
||||
<p className="text-text-dim text-[10px] mt-1.5">
|
||||
Read-only mode — you can browse but not post or zap.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loginError && (
|
||||
<p className="text-danger text-[11px] mt-2">{loginError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={!input.trim()}
|
||||
className="w-full mt-3 px-4 py-2 text-[12px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{tab === "nsec" ? "Login" : "View as read-only"}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{tab === "pubkey" && (
|
||||
<p className="text-text-dim text-[10px] mt-1.5">
|
||||
Read-only mode — you can browse but not post or zap.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{loginError && (
|
||||
<p className="text-danger text-[11px] mt-2">{loginError}</p>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={handleLogin}
|
||||
disabled={!input.trim()}
|
||||
className="w-full mt-3 px-4 py-2 text-[12px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{tab === "nsec" ? "Login" : "View as read-only"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useState } from "react";
|
||||
import { useLightningStore } from "../../stores/lightning";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { isValidNwcUri, parseNwcUri } from "../../lib/lightning/nwc";
|
||||
|
||||
// ── Wallet catalogue ─────────────────────────────────────────────────────────
|
||||
@@ -166,6 +167,7 @@ function PasteStep({
|
||||
onConnected: () => void;
|
||||
}) {
|
||||
const { setNwcUri } = useLightningStore();
|
||||
const { pubkey } = useUserStore();
|
||||
const [input, setInput] = useState("");
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
@@ -191,7 +193,7 @@ function PasteStep({
|
||||
return;
|
||||
}
|
||||
try {
|
||||
setNwcUri(uri);
|
||||
setNwcUri(uri, pubkey ?? undefined);
|
||||
onConnected();
|
||||
} catch (err) {
|
||||
setError(String(err));
|
||||
@@ -254,11 +256,12 @@ function PasteStep({
|
||||
|
||||
export function NWCWizard() {
|
||||
const { nwcUri, clearNwcUri } = useLightningStore();
|
||||
const { pubkey } = useUserStore();
|
||||
const [step, setStep] = useState<"choose" | "paste">("choose");
|
||||
const [selectedWallet, setSelectedWallet] = useState<WalletDef>(GENERIC);
|
||||
|
||||
if (nwcUri) {
|
||||
return <ConnectedState nwcUri={nwcUri} onDisconnect={clearNwcUri} />;
|
||||
return <ConnectedState nwcUri={nwcUri} onDisconnect={() => clearNwcUri(pubkey ?? undefined)} />;
|
||||
}
|
||||
|
||||
if (step === "paste") {
|
||||
|
||||
@@ -4,15 +4,14 @@ import { useUIStore } from "../../stores/ui";
|
||||
import { LoginModal } from "../shared/LoginModal";
|
||||
import { shortenPubkey } from "../../lib/utils";
|
||||
|
||||
function Avatar({ account, size = 6 }: { account: SavedAccount; size?: number }) {
|
||||
function Avatar({ account, size = "w-6 h-6", textSize = "text-[10px]" }: { account: SavedAccount; size?: string; textSize?: string }) {
|
||||
const initial = (account.name || account.npub || "?").charAt(0).toUpperCase();
|
||||
const cls = `w-${size} h-${size} rounded-sm object-cover shrink-0`;
|
||||
if (account.picture) {
|
||||
return (
|
||||
<img
|
||||
src={account.picture}
|
||||
alt=""
|
||||
className={cls}
|
||||
className={`${size} rounded-sm object-cover shrink-0`}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLImageElement).style.display = "none";
|
||||
}}
|
||||
@@ -20,7 +19,7 @@ function Avatar({ account, size = 6 }: { account: SavedAccount; size?: number })
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className={`w-${size} h-${size} rounded-sm bg-accent/20 flex items-center justify-center text-accent text-[10px] shrink-0`}>
|
||||
<div className={`${size} rounded-sm bg-accent/20 flex items-center justify-center text-accent ${textSize} shrink-0`}>
|
||||
{initial}
|
||||
</div>
|
||||
);
|
||||
@@ -88,13 +87,13 @@ export function AccountSwitcher() {
|
||||
return (
|
||||
<>
|
||||
<div className="border-t border-border shrink-0">
|
||||
{/* Expanded account list */}
|
||||
{/* Dropdown — other accounts + actions */}
|
||||
{open && (
|
||||
<div className="border-b border-border">
|
||||
{others.map((a) => (
|
||||
<div
|
||||
key={a.pubkey}
|
||||
className="flex items-center gap-2 px-3 py-1.5 hover:bg-bg-hover cursor-pointer group transition-colors"
|
||||
className="flex items-center gap-2 px-3 py-2 hover:bg-bg-hover cursor-pointer group transition-colors"
|
||||
onClick={() => handleSwitch(a.pubkey)}
|
||||
>
|
||||
<Avatar account={a} />
|
||||
@@ -111,35 +110,15 @@ export function AccountSwitcher() {
|
||||
|
||||
<button
|
||||
onClick={handleAddAccount}
|
||||
className="w-full flex items-center gap-2 px-3 py-1.5 text-text-dim hover:text-accent hover:bg-bg-hover text-[11px] transition-colors"
|
||||
className="w-full flex items-center gap-2 px-3 py-2 text-text-dim hover:text-accent hover:bg-bg-hover text-[11px] transition-colors"
|
||||
>
|
||||
<span className="w-6 text-center text-[12px]">+</span>
|
||||
<span>add account</span>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Current account row */}
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => openProfile(pubkey)}
|
||||
>
|
||||
<Avatar account={current} />
|
||||
<span className="text-text text-[11px] truncate flex-1">{displayName(current)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="text-text-dim hover:text-text text-[10px] transition-colors px-0.5"
|
||||
title="Switch account"
|
||||
>
|
||||
{open ? "▲" : "▼"}
|
||||
</button>
|
||||
</div>
|
||||
<div className="border-t border-border mx-3 my-1" />
|
||||
|
||||
{open && (
|
||||
<div className="flex items-center justify-between mt-1.5">
|
||||
<div className="flex items-center justify-between px-3 py-1.5">
|
||||
<button
|
||||
onClick={() => { setOpen(false); logout(); }}
|
||||
className="text-text-dim hover:text-danger text-[10px] transition-colors"
|
||||
@@ -150,10 +129,30 @@ export function AccountSwitcher() {
|
||||
onClick={() => { setOpen(false); removeAccount(pubkey); }}
|
||||
className="text-text-dim hover:text-danger text-[10px] transition-colors"
|
||||
>
|
||||
remove account
|
||||
remove
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Active account row */}
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<div
|
||||
className="flex items-center gap-2 flex-1 min-w-0 cursor-pointer hover:opacity-80 transition-opacity"
|
||||
onClick={() => openProfile(pubkey)}
|
||||
>
|
||||
<Avatar account={current} size="w-8 h-8" textSize="text-[12px]" />
|
||||
<span className="text-text text-[12px] font-medium truncate flex-1">{displayName(current)}</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="text-text-dim hover:text-text text-[10px] transition-colors px-0.5"
|
||||
title="Switch account"
|
||||
>
|
||||
{open ? "▲" : "▼"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ const NAV_ITEMS = [
|
||||
|
||||
export function Sidebar() {
|
||||
const { currentView, setView, sidebarCollapsed, toggleSidebar } = useUIStore();
|
||||
const { connected, notes } = useFeedStore();
|
||||
const { connected } = useFeedStore();
|
||||
const { loggedIn } = useUserStore();
|
||||
|
||||
const c = sidebarCollapsed;
|
||||
@@ -100,13 +100,10 @@ export function Sidebar() {
|
||||
className={`w-2 h-2 rounded-full inline-block ${connected ? "bg-success" : "bg-danger"}`}
|
||||
/>
|
||||
) : (
|
||||
/* Expanded: dot + label + note count */
|
||||
<div className="text-[10px] text-text-dim">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-success" : "bg-danger"}`} />
|
||||
<span>{connected ? "online" : "offline"}</span>
|
||||
</div>
|
||||
<div className="mt-0.5">{notes.length} notes</div>
|
||||
/* Expanded: dot + label */
|
||||
<div className="flex items-center gap-1.5 text-[10px] text-text-dim">
|
||||
<span className={`w-1.5 h-1.5 rounded-full ${connected ? "bg-success" : "bg-danger"}`} />
|
||||
<span>{connected ? "online" : "offline"}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -3,18 +3,46 @@ import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useUIStore } from "../../stores/ui";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { fetchReplies, publishReply } from "../../lib/nostr";
|
||||
import { useReactionCount } from "../../hooks/useReactionCount";
|
||||
import { useZapCount } from "../../hooks/useZapCount";
|
||||
import { fetchReplies, publishReaction, publishReply, getNDK } from "../../lib/nostr";
|
||||
import { shortenPubkey, timeAgo } from "../../lib/utils";
|
||||
import { NoteContent } from "../feed/NoteContent";
|
||||
import { NoteCard } from "../feed/NoteCard";
|
||||
import { ZapModal } from "../zap/ZapModal";
|
||||
|
||||
function RootNote({ event }: { event: NDKEvent }) {
|
||||
const { openProfile } = useUIStore();
|
||||
const { loggedIn } = useUserStore();
|
||||
const profile = useProfile(event.pubkey);
|
||||
const name = profile?.displayName || profile?.name || shortenPubkey(event.pubkey);
|
||||
const avatar = profile?.picture;
|
||||
const nip05 = profile?.nip05;
|
||||
const time = event.created_at ? timeAgo(event.created_at) : "";
|
||||
const [reactionCount, adjustReactionCount] = useReactionCount(event.id);
|
||||
const zapData = useZapCount(event.id);
|
||||
const [liked, setLiked] = useState(() => {
|
||||
try { return new Set<string>(JSON.parse(localStorage.getItem("wrystr_liked") || "[]")).has(event.id); }
|
||||
catch { return false; }
|
||||
});
|
||||
const [liking, setLiking] = useState(false);
|
||||
const [showZap, setShowZap] = useState(false);
|
||||
const hasLightning = !!(profile?.lud16 || profile?.lud06);
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!loggedIn || liked || liking) return;
|
||||
setLiking(true);
|
||||
try {
|
||||
await publishReaction(event.id, event.pubkey);
|
||||
const likedSet = new Set<string>(JSON.parse(localStorage.getItem("wrystr_liked") || "[]"));
|
||||
likedSet.add(event.id);
|
||||
localStorage.setItem("wrystr_liked", JSON.stringify(Array.from(likedSet)));
|
||||
setLiked(true);
|
||||
adjustReactionCount(1);
|
||||
} finally {
|
||||
setLiking(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="px-4 py-4 border-b border-border">
|
||||
@@ -38,6 +66,39 @@ function RootNote({ event }: { event: NDKEvent }) {
|
||||
</div>
|
||||
<NoteContent content={event.content} />
|
||||
<div className="text-text-dim text-[10px] mt-3">{time}</div>
|
||||
|
||||
{/* Action row */}
|
||||
{loggedIn && !!getNDK().signer && (
|
||||
<div className="flex items-center gap-4 mt-3">
|
||||
<button
|
||||
onClick={handleLike}
|
||||
disabled={liked || liking}
|
||||
className={`text-[11px] transition-colors ${
|
||||
liked ? "text-accent" : "text-text-dim hover:text-accent"
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
{liked ? "♥" : "♡"}{reactionCount !== null && reactionCount > 0 ? ` ${reactionCount}` : liked ? " liked" : " like"}
|
||||
</button>
|
||||
{hasLightning && (
|
||||
<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>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showZap && (
|
||||
<ZapModal
|
||||
target={{ type: "note", event, recipientPubkey: event.pubkey }}
|
||||
recipientName={name}
|
||||
onClose={() => setShowZap(false)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user