mirror of
https://github.com/hoornet/vega.git
synced 2026-04-24 06:40:01 -07:00
Compose, reactions, replies + feed filtering
- Add ComposeBox with Ctrl+Enter shortcut and char limit
- Add reply/like actions to NoteCard with inline reply box
- Add publishNote, publishReaction, publishReply to nostr lib
- Filter bot JSON blobs from feed (content starting with { or [)
- Fix sidebar login button always visible (shrink-0 + nav overflow)
- Restore pubkey sessions from localStorage on startup
- Add CLAUDE.md with project guidance
- Add thread view and onboarding notes to AGENTS.md
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
30
AGENTS.md
30
AGENTS.md
@@ -195,6 +195,36 @@ npm run tauri build # production build
|
||||
|
||||
---
|
||||
|
||||
## Thread View (next major feature after reactions/replies)
|
||||
|
||||
Clicking a note should open a thread view showing:
|
||||
- The root note
|
||||
- All replies in chronological order (or threaded if nested)
|
||||
- An inline reply composer at the bottom
|
||||
|
||||
This is essential for replies to feel complete — right now replies are posted to the network but there's no way to read them in context within the app.
|
||||
|
||||
Implementation notes:
|
||||
- Fetch replies via `kinds: [1], #e: [noteId]` NDK filter
|
||||
- Thread view can be a new `currentView` in the UI store, with a `selectedNote` state
|
||||
- Back navigation returns to the feed
|
||||
|
||||
---
|
||||
|
||||
## Onboarding
|
||||
|
||||
Nostr onboarding is notoriously bad across most clients. Wrystr should be the exception.
|
||||
Goals:
|
||||
- Key generation built-in (no "go get a browser extension first")
|
||||
- Human-readable explanation of what a key is, without crypto jargon
|
||||
- One-click backup flow (show nsec, prompt to save it somewhere)
|
||||
- Optional: sign up with email/password via a custodial key service for non-technical users, with a clear path to self-custody later
|
||||
- New users should see interesting content immediately, not a blank feed
|
||||
|
||||
This is a first-class feature, not an afterthought.
|
||||
|
||||
---
|
||||
|
||||
## What to Avoid
|
||||
|
||||
- Do NOT add new dependencies without checking if something in the existing stack covers it
|
||||
|
||||
57
CLAUDE.md
Normal file
57
CLAUDE.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## What This Is
|
||||
|
||||
Wrystr is a cross-platform Nostr desktop client built with Tauri 2.0 (Rust) + React + TypeScript. It connects to Nostr relays via NDK (Nostr Dev Kit) and aims for Telegram Desktop-quality UX.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run tauri dev # Run full app with hot reload (recommended for development)
|
||||
npm run dev # Vite-only dev server (no Tauri window)
|
||||
npm run build # TypeScript compile + Vite build
|
||||
npm run tauri build # Production binary
|
||||
```
|
||||
|
||||
Prerequisites: Node.js 20+, Rust stable, `@tauri-apps/cli`
|
||||
|
||||
## Architecture
|
||||
|
||||
**Frontend** (`src/`): React 19 + TypeScript + Vite + Tailwind CSS 4
|
||||
|
||||
- `src/App.tsx` — root component, view routing via UI store
|
||||
- `src/stores/` — Zustand stores per domain: `feed.ts`, `user.ts`, `ui.ts`
|
||||
- `src/lib/nostr/` — NDK wrapper; all Nostr calls go through here, never direct NDK in components
|
||||
- `src/types/nostr.ts` — shared TypeScript interfaces (NostrProfile, NostrNote, RelayInfo)
|
||||
- `src/components/feed/` — Feed, NoteCard, NoteContent
|
||||
- `src/components/shared/` — LoginModal, RelaysView, SettingsView
|
||||
- `src/components/sidebar/` — Sidebar navigation
|
||||
|
||||
**Backend** (`src-tauri/`): Rust + Tauri 2.0
|
||||
|
||||
- `src-tauri/src/lib.rs` — Tauri app init and command registration
|
||||
- Rust commands must return `Result<T, String>`
|
||||
- Future: OS keychain for key storage, SQLite, lightning integration
|
||||
|
||||
## Key Conventions (from AGENTS.md)
|
||||
|
||||
- Functional React components only — no class components
|
||||
- Never use `any` — define types in `src/types/`
|
||||
- Tailwind classes only — no inline styles
|
||||
- Private keys must never be exposed to JS; use OS keychain via Rust
|
||||
- New Zustand stores per domain when adding features
|
||||
- NDK interactions only through `src/lib/nostr/` wrapper
|
||||
|
||||
## NIP Priority Reference
|
||||
|
||||
- **P1 (core):** NIP-01, 02, 03, 10, 11, 19, 21, 25, 27, 50
|
||||
- **P2 (monetization):** NIP-47 (NWC/Lightning), NIP-57 (zaps), NIP-65 (relay lists)
|
||||
- **P3 (advanced):** NIP-04/44 (DMs), NIP-23 (articles), NIP-96 (file storage)
|
||||
|
||||
## Current State
|
||||
|
||||
Implemented: relay connection, global feed, note rendering, login (nsec/npub), sidebar navigation, Zustand stores.
|
||||
|
||||
Not yet implemented: compose/post, reactions, zaps, DMs, profile editing, SQLite storage, OS keychain integration.
|
||||
97
src/components/feed/ComposeBox.tsx
Normal file
97
src/components/feed/ComposeBox.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { publishNote } from "../../lib/nostr";
|
||||
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 [error, setError] = useState<string | null>(null);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const { profile, npub } = useUserStore();
|
||||
const avatar = profile?.picture;
|
||||
const name = profile?.displayName || profile?.name || (npub ? shortenPubkey(npub) : "");
|
||||
|
||||
const charCount = text.length;
|
||||
const overLimit = charCount > 280;
|
||||
const canPost = text.trim().length > 0 && !overLimit && !publishing;
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) {
|
||||
e.preventDefault();
|
||||
if (canPost) handlePublish();
|
||||
}
|
||||
};
|
||||
|
||||
const handlePublish = async () => {
|
||||
if (!canPost) return;
|
||||
setPublishing(true);
|
||||
setError(null);
|
||||
try {
|
||||
await publishNote(text.trim());
|
||||
setText("");
|
||||
textareaRef.current?.focus();
|
||||
onPublished?.();
|
||||
} catch (err) {
|
||||
setError(`Failed to publish: ${err}`);
|
||||
} finally {
|
||||
setPublishing(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="border-b border-border px-4 py-3">
|
||||
<div className="flex gap-3">
|
||||
{/* Avatar */}
|
||||
<div className="shrink-0">
|
||||
{avatar ? (
|
||||
<img
|
||||
src={avatar}
|
||||
alt=""
|
||||
className="w-9 h-9 rounded-sm object-cover bg-bg-raised"
|
||||
onError={(e) => { (e.target as HTMLImageElement).style.display = "none"; }}
|
||||
/>
|
||||
) : (
|
||||
<div className="w-9 h-9 rounded-sm bg-bg-raised border border-border flex items-center justify-center text-text-dim text-xs">
|
||||
{name.charAt(0).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input area */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
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"
|
||||
/>
|
||||
|
||||
{error && (
|
||||
<p className="text-danger text-[11px] mb-2">{error}</p>
|
||||
)}
|
||||
|
||||
<div className="flex items-center justify-between mt-1">
|
||||
<span className={`text-[10px] ${overLimit ? "text-danger" : "text-text-dim"}`}>
|
||||
{charCount > 0 && `${charCount}/280`}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<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-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{publishing ? "posting…" : "post"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,9 +1,12 @@
|
||||
import { useEffect } from "react";
|
||||
import { useFeedStore } from "../../stores/feed";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { NoteCard } from "./NoteCard";
|
||||
import { ComposeBox } from "./ComposeBox";
|
||||
|
||||
export function Feed() {
|
||||
const { notes, loading, connected, error, connect, loadFeed } = useFeedStore();
|
||||
const { loggedIn } = useUserStore();
|
||||
|
||||
useEffect(() => {
|
||||
connect().then(() => loadFeed());
|
||||
@@ -31,6 +34,9 @@ export function Feed() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Compose */}
|
||||
{loggedIn && <ComposeBox onPublished={loadFeed} />}
|
||||
|
||||
{/* Feed */}
|
||||
<div className="flex-1 overflow-y-auto">
|
||||
{error && (
|
||||
@@ -51,9 +57,14 @@ export function Feed() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{notes.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
{notes
|
||||
.filter((event) => {
|
||||
const c = event.content.trim();
|
||||
return c.length > 0 && !c.startsWith("{") && !c.startsWith("[");
|
||||
})
|
||||
.map((event) => (
|
||||
<NoteCard key={event.id} event={event} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useState, useRef } from "react";
|
||||
import { NDKEvent } from "@nostr-dev-kit/ndk";
|
||||
import { useProfile } from "../../hooks/useProfile";
|
||||
import { useUserStore } from "../../stores/user";
|
||||
import { timeAgo, shortenPubkey } from "../../lib/utils";
|
||||
import { publishReaction, publishReply } from "../../lib/nostr";
|
||||
import { NoteContent } from "./NoteContent";
|
||||
|
||||
interface NoteCardProps {
|
||||
@@ -14,6 +17,53 @@ export function NoteCard({ event }: NoteCardProps) {
|
||||
const nip05 = profile?.nip05;
|
||||
const time = event.created_at ? timeAgo(event.created_at) : "";
|
||||
|
||||
const { loggedIn } = useUserStore();
|
||||
const [liked, setLiked] = useState(false);
|
||||
const [liking, setLiking] = useState(false);
|
||||
const [showReply, setShowReply] = useState(false);
|
||||
const [replyText, setReplyText] = useState("");
|
||||
const [replying, setReplying] = useState(false);
|
||||
const [replyError, setReplyError] = useState<string | null>(null);
|
||||
const [replySent, setReplySent] = useState(false);
|
||||
const replyRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const handleLike = async () => {
|
||||
if (!loggedIn || liked || liking) return;
|
||||
setLiking(true);
|
||||
try {
|
||||
await publishReaction(event.id, event.pubkey);
|
||||
setLiked(true);
|
||||
} finally {
|
||||
setLiking(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReply = () => {
|
||||
setShowReply((v) => !v);
|
||||
if (!showReply) setTimeout(() => replyRef.current?.focus(), 50);
|
||||
};
|
||||
|
||||
const handleReplySubmit = async () => {
|
||||
if (!replyText.trim() || replying) return;
|
||||
setReplying(true);
|
||||
setReplyError(null);
|
||||
try {
|
||||
await publishReply(replyText.trim(), { id: event.id, pubkey: event.pubkey });
|
||||
setReplyText("");
|
||||
setReplySent(true);
|
||||
setTimeout(() => { setShowReply(false); setReplySent(false); }, 1500);
|
||||
} catch (err) {
|
||||
setReplyError(`Failed: ${err}`);
|
||||
} finally {
|
||||
setReplying(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleReplyKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) handleReplySubmit();
|
||||
if (e.key === "Escape") setShowReply(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<article className="border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors duration-100">
|
||||
<div className="flex gap-3">
|
||||
@@ -39,17 +89,63 @@ export function NoteCard({ event }: NoteCardProps) {
|
||||
{/* Content */}
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-baseline gap-2 mb-0.5">
|
||||
<span className="text-text font-medium truncate text-[13px]">
|
||||
{name}
|
||||
</span>
|
||||
<span className="text-text font-medium truncate text-[13px]">{name}</span>
|
||||
{nip05 && (
|
||||
<span className="text-text-dim text-[10px] truncate max-w-40">
|
||||
{nip05}
|
||||
</span>
|
||||
<span className="text-text-dim text-[10px] truncate max-w-40">{nip05}</span>
|
||||
)}
|
||||
<span className="text-text-dim text-[11px] shrink-0">{time}</span>
|
||||
</div>
|
||||
|
||||
<NoteContent content={event.content} />
|
||||
|
||||
{/* Actions */}
|
||||
{loggedIn && (
|
||||
<div className="flex items-center gap-4 mt-2">
|
||||
<button
|
||||
onClick={handleReply}
|
||||
className={`text-[11px] transition-colors ${
|
||||
showReply ? "text-accent" : "text-text-dim hover:text-text"
|
||||
}`}
|
||||
>
|
||||
reply
|
||||
</button>
|
||||
<button
|
||||
onClick={handleLike}
|
||||
disabled={liked || liking}
|
||||
className={`text-[11px] transition-colors ${
|
||||
liked ? "text-accent" : "text-text-dim hover:text-accent"
|
||||
} disabled:cursor-default`}
|
||||
>
|
||||
{liked ? "♥ liked" : "♡ like"}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Inline reply box */}
|
||||
{showReply && (
|
||||
<div className="mt-2 border-l-2 border-border pl-3">
|
||||
<textarea
|
||||
ref={replyRef}
|
||||
value={replyText}
|
||||
onChange={(e) => setReplyText(e.target.value)}
|
||||
onKeyDown={handleReplyKeyDown}
|
||||
placeholder={`Reply to ${name}…`}
|
||||
rows={2}
|
||||
className="w-full bg-transparent text-text text-[12px] placeholder:text-text-dim resize-none focus:outline-none"
|
||||
/>
|
||||
{replyError && <p className="text-danger text-[10px] mb-1">{replyError}</p>}
|
||||
<div className="flex items-center justify-end gap-2 mt-1">
|
||||
<span className="text-text-dim text-[10px]">Ctrl+Enter</span>
|
||||
<button
|
||||
onClick={handleReplySubmit}
|
||||
disabled={!replyText.trim() || replying}
|
||||
className="px-2 py-0.5 text-[10px] bg-accent hover:bg-accent-hover text-white transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
|
||||
>
|
||||
{replySent ? "replied ✓" : replying ? "posting…" : "reply"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
@@ -42,7 +42,7 @@ export function Sidebar() {
|
||||
</div>
|
||||
|
||||
{/* Nav */}
|
||||
<nav className="flex-1 py-2">
|
||||
<nav className="flex-1 overflow-y-auto py-2">
|
||||
{NAV_ITEMS.map((item) => (
|
||||
<button
|
||||
key={item.id}
|
||||
@@ -61,7 +61,7 @@ export function Sidebar() {
|
||||
|
||||
{/* User / Login */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="border-t border-border">
|
||||
<div className="border-t border-border shrink-0">
|
||||
{loggedIn ? (
|
||||
<div className="px-3 py-2">
|
||||
<div className="flex items-center gap-2 mb-1.5">
|
||||
@@ -105,7 +105,7 @@ export function Sidebar() {
|
||||
|
||||
{/* Status footer */}
|
||||
{!sidebarCollapsed && (
|
||||
<div className="border-t border-border px-3 py-2 text-[10px] text-text-dim">
|
||||
<div className="border-t border-border px-3 py-2 text-[10px] text-text-dim shrink-0">
|
||||
<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>
|
||||
|
||||
@@ -61,6 +61,44 @@ export async function fetchGlobalFeed(limit: number = 50): Promise<NDKEvent[]> {
|
||||
return Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0));
|
||||
}
|
||||
|
||||
export async function publishReaction(eventId: string, eventPubkey: string, reaction = "+"): Promise<void> {
|
||||
const instance = getNDK();
|
||||
if (!instance.signer) throw new Error("Not logged in");
|
||||
|
||||
const event = new NDKEvent(instance);
|
||||
event.kind = NDKKind.Reaction;
|
||||
event.content = reaction;
|
||||
event.tags = [
|
||||
["e", eventId],
|
||||
["p", eventPubkey],
|
||||
];
|
||||
await event.publish();
|
||||
}
|
||||
|
||||
export async function publishReply(content: string, replyTo: { id: string; pubkey: string }): Promise<void> {
|
||||
const instance = getNDK();
|
||||
if (!instance.signer) throw new Error("Not logged in");
|
||||
|
||||
const event = new NDKEvent(instance);
|
||||
event.kind = NDKKind.Text;
|
||||
event.content = content;
|
||||
event.tags = [
|
||||
["e", replyTo.id, "", "reply"],
|
||||
["p", replyTo.pubkey],
|
||||
];
|
||||
await event.publish();
|
||||
}
|
||||
|
||||
export async function publishNote(content: string): Promise<void> {
|
||||
const instance = getNDK();
|
||||
if (!instance.signer) throw new Error("Not logged in");
|
||||
|
||||
const event = new NDKEvent(instance);
|
||||
event.kind = NDKKind.Text;
|
||||
event.content = content;
|
||||
await event.publish();
|
||||
}
|
||||
|
||||
export async function fetchProfile(pubkey: string) {
|
||||
const instance = getNDK();
|
||||
const user = instance.getUser({ pubkey });
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, fetchProfile } from "./client";
|
||||
export { getNDK, connectToRelays, fetchGlobalFeed, publishNote, publishReaction, publishReply, fetchProfile } from "./client";
|
||||
|
||||
10
src/main.tsx
10
src/main.tsx
@@ -2,6 +2,16 @@ import { StrictMode } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import App from "./App";
|
||||
import "./index.css";
|
||||
import { useUserStore } from "./stores/user";
|
||||
|
||||
// Restore session from localStorage
|
||||
const savedPubkey = localStorage.getItem("wrystr_pubkey");
|
||||
const savedLoginType = localStorage.getItem("wrystr_login_type");
|
||||
if (savedPubkey && savedLoginType === "pubkey") {
|
||||
useUserStore.getState().loginWithPubkey(savedPubkey);
|
||||
}
|
||||
// Note: nsec is never stored, so nsec sessions can't be auto-restored.
|
||||
// Future: restore via OS keychain.
|
||||
|
||||
createRoot(document.getElementById("root") as HTMLElement).render(
|
||||
<StrictMode>
|
||||
|
||||
Reference in New Issue
Block a user