From 092553ab9b1c4e0031131b958a41c741fa5ca835 Mon Sep 17 00:00:00 2001 From: Jure <44338+hoornet@users.noreply.github.com> Date: Wed, 18 Mar 2026 18:36:08 +0100 Subject: [PATCH] =?UTF-8?q?Bump=20to=20v0.7.0=20=E2=80=94=20writer=20tools?= =?UTF-8?q?,=20NIP-98=20uploads,=20multi-draft,=20article=20bookmarks?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - NIP-98 HTTP Auth for image uploads with fallback services (void.cat, nostrimg.com) - Markdown toolbar (bold, italic, heading, link, image, quote, code, list) + Ctrl+B/I/K - Multi-draft management with draft list, resume, delete, auto-migrate - Cover image file picker in article meta panel - Article bookmarks via NIP-51 'a' tags; Notes/Articles tabs in BookmarkView - Removed Rust upload_file command; dropped reqwest/mime_guess deps - Upload spinner, draft count badge, empty states --- .github/workflows/release.yml | 18 +- CLAUDE.md | 12 +- PKGBUILD | 2 +- README.md | 8 +- ROADMAP.md | 22 ++- package.json | 2 +- src-tauri/Cargo.toml | 4 +- src-tauri/src/lib.rs | 46 ----- src-tauri/tauri.conf.json | 2 +- src/components/article/ArticleEditor.tsx | 215 ++++++++++++++++---- src/components/article/MarkdownToolbar.tsx | 218 +++++++++++++++++++++ src/components/bookmark/BookmarkView.tsx | 83 ++++++-- src/components/feed/ComposeBox.tsx | 24 ++- src/components/sidebar/Sidebar.tsx | 12 +- src/lib/nostr/client.ts | 46 +++++ src/lib/nostr/index.ts | 2 +- src/lib/upload.ts | 88 +++++++-- src/stores/bookmark.ts | 72 +++++-- src/stores/drafts.ts | 122 ++++++++++++ 19 files changed, 846 insertions(+), 152 deletions(-) create mode 100644 src/components/article/MarkdownToolbar.tsx create mode 100644 src/stores/drafts.ts diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 9b78e4d..7d7104c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -69,12 +69,18 @@ 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. - ### New in v0.6.1 — Media Upload & Feed Polish - - **Native file picker** — "+" button in compose box opens OS file browser to attach images/videos; uploaded via Rust backend (bypasses WebView limitations) - - **File path paste** — pasting a local file path (e.g. `/home/user/photo.jpg`) auto-reads and uploads the file instead of inserting the path as text - - **Mention name resolution** — @mentions now show profile display names instead of raw bech32 strings - - **Connection stability** — relay status indicator uses a 15-second grace period before showing offline; auto-reconnects when all relays drop - - **Upload reliability** — image uploads use correct nostr.build v2 API field name; Rust-side multipart upload for native file picks + ### New in v0.7.0 — Writer Tools & Upload Fix + - **NIP-98 HTTP Auth uploads** — image uploads now authenticate via signed kind 27235 events; fallback to void.cat and nostrimg.com if nostr.build fails + - **Markdown toolbar** — bold, italic, heading, link, image, quote, code, list buttons above the article editor; keyboard shortcuts Ctrl+B/I/K + - **Multi-draft management** — create multiple article drafts; draft list with word count, timestamps, delete; auto-migrates old single-draft + - **Cover image file picker** — upload button next to URL input in article meta panel + - **Article bookmarks** — NIP-51 `a` tag support; Notes/Articles tab toggle in bookmark view + - **Upload moved to TypeScript** — removed Rust upload command; dropped `reqwest`/`mime_guess` deps; lighter binary + - **Upload spinner** — animated spinner during image uploads in compose and editor + - **Draft count badge** — sidebar shows how many drafts you have + + ### Previous: v0.6.1 — Media Upload & Feed Polish + - Native file picker, file path paste upload, mention name resolution, connection stability ### Previous: v0.6.0 — Long-form Article Experience - **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors diff --git a/CLAUDE.md b/CLAUDE.md index 9ce0b82..3546f29 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -45,7 +45,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR **Frontend** (`src/`): React 19 + TypeScript + Vite + Tailwind CSS 4 - `src/App.tsx` — root component; shows `OnboardingFlow` for new users, then view routing via UI store -- `src/stores/` — Zustand stores per domain: `feed.ts`, `user.ts`, `ui.ts`, `lightning.ts` +- `src/stores/` — Zustand stores per domain: `feed.ts`, `user.ts`, `ui.ts`, `lightning.ts`, `drafts.ts` - `src/lib/nostr/` — NDK wrapper (`client.ts` + `index.ts`); all Nostr calls go through here - `src/lib/lightning/` — NWC client (`nwc.ts`); Lightning payment logic - `src/hooks/` — `useProfile.ts`, `useReactionCount.ts` @@ -53,7 +53,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR - `src/components/profile/` — ProfileView (own + others, edit form) - `src/components/thread/` — ThreadView - `src/components/search/` — SearchView (NIP-50, hashtag, people, articles) -- `src/components/article/` — ArticleEditor, ArticleView, ArticleFeed, ArticleCard (NIP-23) +- `src/components/article/` — ArticleEditor, ArticleView, ArticleFeed, ArticleCard, MarkdownToolbar (NIP-23) - `src/components/bookmark/` — BookmarkView - `src/components/zap/` — ZapModal - `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login) @@ -66,6 +66,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR - Rust commands must return `Result` - OS keychain via `keyring` crate — `store_nsec`, `load_nsec`, `delete_nsec` commands - SQLite note/profile cache via `rusqlite` +- File uploads handled entirely in TypeScript with NIP-98 auth (Rust upload_file removed in v0.7.0) - Future: lightning node integration ## Key Conventions (from AGENTS.md) @@ -82,7 +83,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR - **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) +- **P3 (advanced):** NIP-04/44 (DMs), NIP-23 (articles), NIP-96 (file storage), NIP-98 (HTTP Auth — implemented for uploads) ## Current State @@ -92,11 +93,12 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR - Reactions (NIP-25) with live network counts - Follow/unfollow (NIP-02), contact list publishing - Profile view + edit (kind 0) with Notes/Articles tab toggle -- Long-form article editor (NIP-23) with draft auto-save +- Long-form article editor (NIP-23) with **markdown toolbar** (bold, italic, heading, link, image, quote, code, list), **keyboard shortcuts** (Ctrl+B/I/K), **multi-draft management**, **cover image file picker** - **Article discovery feed** — dedicated "Articles" view in sidebar; Latest/Following tabs - **Article reader** — markdown rendering, reading time, bookmark, like, zap - **Article search** — NIP-50 + hashtag search for kind 30023 articles - **Article cards** — reusable component with title, summary, author, cover thumbnail, reading time, tags +- **NIP-98 HTTP Auth** for image uploads with fallback services (nostr.build, void.cat, nostrimg.com) - Zaps: NWC wallet connect (NIP-47) + NIP-57 via NDKZapper - Search: NIP-50 full-text, hashtag (#t filter), people, articles - Settings: relay add/remove (persisted to localStorage), NWC URI, npub copy @@ -106,7 +108,7 @@ CI triggers on the tag and builds all three platforms (Ubuntu, Windows, macOS AR - Direct messages (NIP-04 + NIP-17 gift wrap) - NIP-65 outbox model - Image lightbox (click to expand, arrow key navigation) -- Bookmark list (NIP-51 kind 10003) with sidebar nav +- Bookmark list (NIP-51 kind 10003) with sidebar nav, **Notes/Articles tabs**, article `a` tag support - Follow suggestions / discovery (follows-of-follows algorithm) - Language/script feed filter (Unicode script detection + NIP-32 tags) - Skeleton loading states, view fade transitions diff --git a/PKGBUILD b/PKGBUILD index c74ef4b..7b5b2db 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: hoornet pkgname=wrystr -pkgver=0.6.1 +pkgver=0.7.0 pkgrel=1 pkgdesc="Cross-platform Nostr desktop client with Lightning integration" arch=('x86_64') diff --git a/README.md b/README.md index 7471b2b..e9e50b4 100644 --- a/README.md +++ b/README.md @@ -44,15 +44,15 @@ sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gstreamer1-liba - Global and following feeds with live relay connection - **Language/script feed filter** — filter by writing system (Latin, CJK, Cyrillic, Arabic, Korean, etc.) via dropdown in feed header; uses Unicode detection + NIP-32 language tags - Compose notes, inline replies, full thread view -- **Image paste in compose** — paste an image from clipboard → auto-uploads and inserts the URL +- **Image upload with NIP-98 auth** — paste from clipboard, drag-drop, or use the file picker; uploads authenticated via NIP-98 HTTP Auth with fallback services - **Image lightbox** — click any image to view full-screen; Escape to close, arrow keys to navigate multi-image posts - **Feed reply context** — replies show "↩ replying to @name"; click to jump to the parent thread - Reactions (NIP-25) with live network counts - Follow / unfollow (NIP-02) with contact list publishing - **Quote & Repost** (NIP-18) — one-click repost or quote with compose modal -- **Bookmarks** (NIP-51 kind 10003) — save/unsave notes with one click; dedicated Bookmarks view in sidebar; synced to relays +- **Bookmarks** (NIP-51 kind 10003) — save/unsave notes and articles; **Notes/Articles tabs** in bookmark view; article bookmarks use `a` tags for parameterized replaceable events; synced to relays - **Mute users** (NIP-51) — muted list synced to relays, filtered from feed -- **Long-form article experience** (NIP-23) — write articles with title, tags, cover image, auto-save; **dedicated article feed** with Latest/Following tabs; **article search** by keyword or hashtag; **article reader** with reading time, bookmark, like, and zap; **profile Articles tab** to browse any author's long-form posts +- **Long-form article experience** (NIP-23) — **markdown toolbar** (bold, italic, heading, link, image, quote, code, list) with keyboard shortcuts (Ctrl+B/I/K); **multi-draft management** with draft list, resume, delete; **cover image file picker**; dedicated article feed with Latest/Following tabs; article search by keyword or hashtag; article reader with reading time, bookmark, like, and zap; profile Articles tab - **Quoted note inline preview** — `nostr:note1…` / `nostr:nevent1…` renders as an inline card - Note rendering: images, video, mentions, hashtags, njump.me link interception - **Direct Messages** (NIP-04) — conversation list, thread view, per-message decryption; unread badge in sidebar @@ -109,9 +109,9 @@ npm run tauri build # production binary See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps. Up next: +- Relay health checker - Web of Trust scoring - NIP-46 remote signer support -- Reading history / reading list - Custom feeds / lists ## Support diff --git a/ROADMAP.md b/ROADMAP.md index 1dab990..66c4b29 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -66,13 +66,17 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be - Could power: feed ranking, spam filtering, people search, follow suggestions - Needs dedicated design session -### Long-form features (NIP-23 depth) — partially shipped in v0.6.0 +### Long-form features (NIP-23 depth) — mostly shipped (v0.6.0 + v0.7.0) - ✓ Discovery: dedicated article feed with Latest/Following tabs - ✓ Article search (NIP-50 + hashtag for kind 30023) - ✓ Profile Articles tab — browse any author's long-form posts - ✓ Reading time estimate, bookmark/like/zap on article reader -- Remaining: reading history, table of contents, trending articles -- Editor improvements: markdown toolbar, image upload, tag suggestions +- ✓ Markdown toolbar with keyboard shortcuts (Ctrl+B/I/K) +- ✓ NIP-98 image upload with fallback services +- ✓ Multi-draft management (create, resume, delete) +- ✓ Cover image file picker upload +- ✓ Article bookmarks (NIP-51 `a` tags) with Notes/Articles tabs +- Remaining: reading history, table of contents, trending articles, tag suggestions - Cross-posting to other platforms ### NIP-46 remote signer @@ -88,6 +92,18 @@ Bugs found during testing are fixed before Phase N+1 starts. A release is cut be ## What's already shipped +### v0.7.0 — Writer Tools & Upload Fix +- **NIP-98 HTTP Auth uploads** — image uploads now authenticate via signed kind 27235 events; fallback to void.cat and nostrimg.com if nostr.build fails +- **Markdown toolbar** — bold, italic, heading, link, image, quote, code, list buttons above the article editor textarea +- **Editor keyboard shortcuts** — Ctrl+B bold, Ctrl+I italic, Ctrl+K link +- **Multi-draft management** — create multiple article drafts; draft list view with word count, timestamps, delete; auto-migrates old single-draft localStorage +- **Cover image file picker** — upload button next to URL input in article meta panel +- **Article bookmarks** — bookmarks now support NIP-51 `a` tags for parameterized replaceable events (kind 30023 articles); Notes/Articles tab toggle in BookmarkView +- **Upload moved to TypeScript** — removed Rust `upload_file` command; all uploads go through TS with NIP-98 auth; dropped `reqwest` and `mime_guess` Rust dependencies +- **Upload spinner** — animated spinner in compose box and article editor during image upload +- **Draft count badge** — sidebar "write article" button shows draft count +- **Empty states** — draft list, bookmark articles tab + ### v0.6.0 — Long-form article experience - **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors - **Article cards** — title, summary snippet, author avatar+name, cover image thumbnail, reading time, tag chips diff --git a/package.json b/package.json index 85d49fd..14ed7c3 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wrystr", "private": true, - "version": "0.6.1", + "version": "0.7.0", "type": "module", "scripts": { "dev": "vite", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index f87455e..c9d068b 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wrystr" -version = "0.6.1" +version = "0.7.0" description = "Cross-platform Nostr desktop client with Lightning integration" authors = ["hoornet"] edition = "2021" @@ -29,6 +29,4 @@ rusqlite = { version = "0.32", features = ["bundled"] } tauri-plugin-http = "2.5.7" tauri-plugin-dialog = "2.6.0" tauri-plugin-fs = "2.4.5" -reqwest = { version = "0.13.2", default-features = false, features = ["multipart", "rustls"] } -mime_guess = "2.0.5" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 79dc214..6207123 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,51 +37,6 @@ fn delete_nsec(pubkey: String) -> Result<(), String> { } } -// ── File upload ───────────────────────────────────────────────────────────── - -#[tauri::command] -async fn upload_file(path: String) -> Result { - let file_bytes = std::fs::read(&path).map_err(|e| format!("Failed to read file: {e}"))?; - let file_name = std::path::Path::new(&path) - .file_name() - .and_then(|n| n.to_str()) - .unwrap_or("file") - .to_string(); - let mime = mime_guess::from_path(&path) - .first_or_octet_stream() - .to_string(); - - let part = reqwest::multipart::Part::bytes(file_bytes) - .file_name(file_name) - .mime_str(&mime) - .map_err(|e| format!("MIME error: {e}"))?; - let form = reqwest::multipart::Form::new().part("file", part); - - let client = reqwest::Client::new(); - let resp = client - .post("https://nostr.build/api/v2/upload/files") - .multipart(form) - .send() - .await - .map_err(|e| format!("Upload request failed: {e}"))?; - - if !resp.status().is_success() { - return Err(format!("Upload failed (HTTP {})", resp.status())); - } - - let data: serde_json::Value = resp - .json() - .await - .map_err(|e| format!("Failed to parse response: {e}"))?; - - if data["status"] == "success" { - if let Some(url) = data["data"][0]["url"].as_str() { - return Ok(url.to_string()); - } - } - Err(data["message"].as_str().unwrap_or("Upload failed — no URL returned").to_string()) -} - // ── SQLite note/profile cache ──────────────────────────────────────────────── struct DbState(Mutex); @@ -250,7 +205,6 @@ pub fn run() { store_nsec, load_nsec, delete_nsec, - upload_file, db_save_notes, db_load_feed, db_save_profile, diff --git a/src-tauri/tauri.conf.json b/src-tauri/tauri.conf.json index c7be054..90c2834 100644 --- a/src-tauri/tauri.conf.json +++ b/src-tauri/tauri.conf.json @@ -1,7 +1,7 @@ { "$schema": "https://schema.tauri.app/config/2", "productName": "Wrystr", - "version": "0.6.1", + "version": "0.7.0", "identifier": "com.hoornet.wrystr", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/article/ArticleEditor.tsx b/src/components/article/ArticleEditor.tsx index 6a29317..97df99b 100644 --- a/src/components/article/ArticleEditor.tsx +++ b/src/components/article/ArticleEditor.tsx @@ -1,45 +1,54 @@ -import { useState, useEffect } from "react"; +import { useState, useEffect, useRef } from "react"; import { marked } from "marked"; import { publishArticle } from "../../lib/nostr"; import { useUIStore } from "../../stores/ui"; - -const DRAFT_KEY = "wrystr_article_draft"; - -function loadDraft() { - try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || "null"); } - catch { return null; } -} - -function saveDraft(data: object) { - localStorage.setItem(DRAFT_KEY, JSON.stringify(data)); -} - -function clearDraft() { - localStorage.removeItem(DRAFT_KEY); -} +import { MarkdownToolbar, handleEditorKeyDown } from "./MarkdownToolbar"; +import { useDraftStore, type ArticleDraft } from "../../stores/drafts"; +import { open } from "@tauri-apps/plugin-dialog"; +import { readFile } from "@tauri-apps/plugin-fs"; +import { uploadBytes } from "../../lib/upload"; export function ArticleEditor() { const { goBack } = useUIStore(); - const draft = loadDraft(); + const { activeDraftId, drafts, updateDraft, deleteDraft, setActiveDraft, createDraft } = useDraftStore(); + const textareaRef = useRef(null); - const [title, setTitle] = useState(draft?.title || ""); - const [content, setContent] = useState(draft?.content || ""); - const [summary, setSummary] = useState(draft?.summary || ""); - const [image, setImage] = useState(draft?.image || ""); - const [tags, setTags] = useState(draft?.tags || ""); + // If no active draft, show draft list + const activeDraft = activeDraftId ? drafts.find((d) => d.id === activeDraftId) : null; + + const [title, setTitle] = useState(activeDraft?.title || ""); + const [content, setContent] = useState(activeDraft?.content || ""); + const [summary, setSummary] = useState(activeDraft?.summary || ""); + const [image, setImage] = useState(activeDraft?.image || ""); + const [tags, setTags] = useState(activeDraft?.tags || ""); const [mode, setMode] = useState<"write" | "preview">("write"); const [showMeta, setShowMeta] = useState(false); const [publishing, setPublishing] = useState(false); + const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); const [published, setPublished] = useState(false); - // Auto-save draft + // Sync state when active draft changes useEffect(() => { + if (activeDraft) { + setTitle(activeDraft.title); + setContent(activeDraft.content); + setSummary(activeDraft.summary); + setImage(activeDraft.image); + setTags(activeDraft.tags); + setPublished(false); + setError(null); + } + }, [activeDraftId]); + + // Auto-save to draft store + useEffect(() => { + if (!activeDraftId) return; const t = setTimeout(() => { - saveDraft({ title, content, summary, image, tags }); + updateDraft(activeDraftId, { title, content, summary, image, tags }); }, 1000); return () => clearTimeout(t); - }, [title, content, summary, image, tags]); + }, [title, content, summary, image, tags, activeDraftId]); const renderedHtml = marked(content || "*Nothing to preview yet.*") as string; const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0; @@ -57,7 +66,7 @@ export function ArticleEditor() { image: image.trim() || undefined, tags: tags.split(",").map((t: string) => t.trim()).filter(Boolean), }); - clearDraft(); + if (activeDraftId) deleteDraft(activeDraftId); setPublished(true); setTimeout(goBack, 1500); } catch (err) { @@ -67,18 +76,61 @@ export function ArticleEditor() { } }; + const handleNewDraft = () => { + const id = createDraft(); + setActiveDraft(id); + }; + + const handleCoverImagePick = async () => { + try { + const selected = await open({ + multiple: false, + filters: [{ name: "Images", extensions: ["jpg", "jpeg", "png", "gif", "webp"] }], + }); + if (!selected) return; + setUploading(true); + setError(null); + try { + const filePath = typeof selected === "string" ? selected : selected; + const bytes = await readFile(filePath); + const fileName = filePath.split(/[\\/]/).pop() || "cover.jpg"; + const ext = fileName.split(".").pop()?.toLowerCase() || "jpg"; + const mimeMap: Record = { + jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif", webp: "image/webp", + }; + const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "image/jpeg"); + setImage(url); + } finally { + setUploading(false); + } + } catch (err) { + setError(`Cover upload failed: ${err}`); + } + }; + + // If no active draft, show the draft list + if (!activeDraftId) { + return ; + } + return (
{/* Header */}
- {wordCount > 0 ? `${wordCount} words` : "New article"} - {draft && !published && ( + {activeDraft && !published && ( · draft saved )} + {uploading && ( + + + uploading… + + )}
@@ -130,13 +182,23 @@ export function ArticleEditor() {
- - setImage(e.target.value)} - placeholder="https://…" - className="w-full bg-bg border border-border px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50" - /> + +
+ setImage(e.target.value)} + placeholder="https://…" + className="flex-1 bg-bg border border-border px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50" + /> + +
@@ -169,12 +231,25 @@ export function ArticleEditor() { />
+ {/* Markdown toolbar */} + {mode === "write" && ( + + )} + {/* Content area */}
{mode === "write" ? (