diff --git a/AGENTS.md b/AGENTS.md index 4709a23..ff9a592 100644 --- a/AGENTS.md +++ b/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 diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..0970d74 --- /dev/null +++ b/CLAUDE.md @@ -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` +- 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. diff --git a/src/components/feed/ComposeBox.tsx b/src/components/feed/ComposeBox.tsx new file mode 100644 index 0000000..3e97d82 --- /dev/null +++ b/src/components/feed/ComposeBox.tsx @@ -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(null); + const textareaRef = useRef(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 ( +
+
+ {/* Avatar */} +
+ {avatar ? ( + { (e.target as HTMLImageElement).style.display = "none"; }} + /> + ) : ( +
+ {name.charAt(0).toUpperCase()} +
+ )} +
+ + {/* Input area */} +
+