Bump to v0.7.0 — writer tools, NIP-98 uploads, multi-draft, article bookmarks

- 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
This commit is contained in:
Jure
2026-03-18 18:36:08 +01:00
parent c66885440a
commit 092553ab9b
19 changed files with 846 additions and 152 deletions

View File

@@ -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. > **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 ### New in v0.7.0 — Writer Tools & Upload Fix
- **Native file picker** — "+" button in compose box opens OS file browser to attach images/videos; uploaded via Rust backend (bypasses WebView limitations) - **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
- **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 - **Markdown toolbar** — bold, italic, heading, link, image, quote, code, list buttons above the article editor; keyboard shortcuts Ctrl+B/I/K
- **Mention name resolution** — @mentions now show profile display names instead of raw bech32 strings - **Multi-draft management** — create multiple article drafts; draft list with word count, timestamps, delete; auto-migrates old single-draft
- **Connection stability** — relay status indicator uses a 15-second grace period before showing offline; auto-reconnects when all relays drop - **Cover image file picker** — upload button next to URL input in article meta panel
- **Upload reliability** — image uploads use correct nostr.build v2 API field name; Rust-side multipart upload for native file picks - **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 ### 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 - **Article discovery feed** — dedicated "Articles" view in sidebar with Latest and Following tabs; browse kind 30023 articles from all relays or just followed authors

View File

@@ -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 **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/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/nostr/` — NDK wrapper (`client.ts` + `index.ts`); all Nostr calls go through here
- `src/lib/lightning/` — NWC client (`nwc.ts`); Lightning payment logic - `src/lib/lightning/` — NWC client (`nwc.ts`); Lightning payment logic
- `src/hooks/``useProfile.ts`, `useReactionCount.ts` - `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/profile/` — ProfileView (own + others, edit form)
- `src/components/thread/` — ThreadView - `src/components/thread/` — ThreadView
- `src/components/search/` — SearchView (NIP-50, hashtag, people, articles) - `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/bookmark/` — BookmarkView
- `src/components/zap/` — ZapModal - `src/components/zap/` — ZapModal
- `src/components/onboarding/` — OnboardingFlow (welcome, create key, backup, login) - `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<T, String>` - Rust commands must return `Result<T, String>`
- OS keychain via `keyring` crate — `store_nsec`, `load_nsec`, `delete_nsec` commands - OS keychain via `keyring` crate — `store_nsec`, `load_nsec`, `delete_nsec` commands
- SQLite note/profile cache via `rusqlite` - 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 - Future: lightning node integration
## Key Conventions (from AGENTS.md) ## 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 - **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) - **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 ## 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 - Reactions (NIP-25) with live network counts
- Follow/unfollow (NIP-02), contact list publishing - Follow/unfollow (NIP-02), contact list publishing
- Profile view + edit (kind 0) with Notes/Articles tab toggle - 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 discovery feed** — dedicated "Articles" view in sidebar; Latest/Following tabs
- **Article reader** — markdown rendering, reading time, bookmark, like, zap - **Article reader** — markdown rendering, reading time, bookmark, like, zap
- **Article search** — NIP-50 + hashtag search for kind 30023 articles - **Article search** — NIP-50 + hashtag search for kind 30023 articles
- **Article cards** — reusable component with title, summary, author, cover thumbnail, reading time, tags - **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 - Zaps: NWC wallet connect (NIP-47) + NIP-57 via NDKZapper
- Search: NIP-50 full-text, hashtag (#t filter), people, articles - Search: NIP-50 full-text, hashtag (#t filter), people, articles
- Settings: relay add/remove (persisted to localStorage), NWC URI, npub copy - 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) - Direct messages (NIP-04 + NIP-17 gift wrap)
- NIP-65 outbox model - NIP-65 outbox model
- Image lightbox (click to expand, arrow key navigation) - 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) - Follow suggestions / discovery (follows-of-follows algorithm)
- Language/script feed filter (Unicode script detection + NIP-32 tags) - Language/script feed filter (Unicode script detection + NIP-32 tags)
- Skeleton loading states, view fade transitions - Skeleton loading states, view fade transitions

View File

@@ -1,6 +1,6 @@
# Maintainer: hoornet <hoornet@users.noreply.github.com> # Maintainer: hoornet <hoornet@users.noreply.github.com>
pkgname=wrystr pkgname=wrystr
pkgver=0.6.1 pkgver=0.7.0
pkgrel=1 pkgrel=1
pkgdesc="Cross-platform Nostr desktop client with Lightning integration" pkgdesc="Cross-platform Nostr desktop client with Lightning integration"
arch=('x86_64') arch=('x86_64')

View File

@@ -44,15 +44,15 @@ sudo dnf install gstreamer1-plugins-base gstreamer1-plugins-good gstreamer1-liba
- Global and following feeds with live relay connection - 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 - **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 - 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 - **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 - **Feed reply context** — replies show "↩ replying to @name"; click to jump to the parent thread
- Reactions (NIP-25) with live network counts - Reactions (NIP-25) with live network counts
- Follow / unfollow (NIP-02) with contact list publishing - Follow / unfollow (NIP-02) with contact list publishing
- **Quote & Repost** (NIP-18) — one-click repost or quote with compose modal - **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 - **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 - **Quoted note inline preview** — `nostr:note1…` / `nostr:nevent1…` renders as an inline card
- Note rendering: images, video, mentions, hashtags, njump.me link interception - 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 - **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. See [ROADMAP.md](./ROADMAP.md) for the full prioritised next steps.
Up next: Up next:
- Relay health checker
- Web of Trust scoring - Web of Trust scoring
- NIP-46 remote signer support - NIP-46 remote signer support
- Reading history / reading list
- Custom feeds / lists - Custom feeds / lists
## Support ## Support

View File

@@ -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 - Could power: feed ranking, spam filtering, people search, follow suggestions
- Needs dedicated design session - 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 - ✓ Discovery: dedicated article feed with Latest/Following tabs
- ✓ Article search (NIP-50 + hashtag for kind 30023) - ✓ Article search (NIP-50 + hashtag for kind 30023)
- ✓ Profile Articles tab — browse any author's long-form posts - ✓ Profile Articles tab — browse any author's long-form posts
- ✓ Reading time estimate, bookmark/like/zap on article reader - ✓ Reading time estimate, bookmark/like/zap on article reader
- Remaining: reading history, table of contents, trending articles - ✓ Markdown toolbar with keyboard shortcuts (Ctrl+B/I/K)
- Editor improvements: markdown toolbar, image upload, tag suggestions - ✓ 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 - Cross-posting to other platforms
### NIP-46 remote signer ### 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 ## 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 ### 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 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 - **Article cards** — title, summary snippet, author avatar+name, cover image thumbnail, reading time, tag chips

View File

@@ -1,7 +1,7 @@
{ {
"name": "wrystr", "name": "wrystr",
"private": true, "private": true,
"version": "0.6.1", "version": "0.7.0",
"type": "module", "type": "module",
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",

View File

@@ -1,6 +1,6 @@
[package] [package]
name = "wrystr" name = "wrystr"
version = "0.6.1" version = "0.7.0"
description = "Cross-platform Nostr desktop client with Lightning integration" description = "Cross-platform Nostr desktop client with Lightning integration"
authors = ["hoornet"] authors = ["hoornet"]
edition = "2021" edition = "2021"
@@ -29,6 +29,4 @@ rusqlite = { version = "0.32", features = ["bundled"] }
tauri-plugin-http = "2.5.7" tauri-plugin-http = "2.5.7"
tauri-plugin-dialog = "2.6.0" tauri-plugin-dialog = "2.6.0"
tauri-plugin-fs = "2.4.5" tauri-plugin-fs = "2.4.5"
reqwest = { version = "0.13.2", default-features = false, features = ["multipart", "rustls"] }
mime_guess = "2.0.5"

View File

@@ -37,51 +37,6 @@ fn delete_nsec(pubkey: String) -> Result<(), String> {
} }
} }
// ── File upload ─────────────────────────────────────────────────────────────
#[tauri::command]
async fn upload_file(path: String) -> Result<String, String> {
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 ──────────────────────────────────────────────── // ── SQLite note/profile cache ────────────────────────────────────────────────
struct DbState(Mutex<Connection>); struct DbState(Mutex<Connection>);
@@ -250,7 +205,6 @@ pub fn run() {
store_nsec, store_nsec,
load_nsec, load_nsec,
delete_nsec, delete_nsec,
upload_file,
db_save_notes, db_save_notes,
db_load_feed, db_load_feed,
db_save_profile, db_save_profile,

View File

@@ -1,7 +1,7 @@
{ {
"$schema": "https://schema.tauri.app/config/2", "$schema": "https://schema.tauri.app/config/2",
"productName": "Wrystr", "productName": "Wrystr",
"version": "0.6.1", "version": "0.7.0",
"identifier": "com.hoornet.wrystr", "identifier": "com.hoornet.wrystr",
"build": { "build": {
"beforeDevCommand": "npm run dev", "beforeDevCommand": "npm run dev",

View File

@@ -1,45 +1,54 @@
import { useState, useEffect } from "react"; import { useState, useEffect, useRef } from "react";
import { marked } from "marked"; import { marked } from "marked";
import { publishArticle } from "../../lib/nostr"; import { publishArticle } from "../../lib/nostr";
import { useUIStore } from "../../stores/ui"; import { useUIStore } from "../../stores/ui";
import { MarkdownToolbar, handleEditorKeyDown } from "./MarkdownToolbar";
const DRAFT_KEY = "wrystr_article_draft"; import { useDraftStore, type ArticleDraft } from "../../stores/drafts";
import { open } from "@tauri-apps/plugin-dialog";
function loadDraft() { import { readFile } from "@tauri-apps/plugin-fs";
try { return JSON.parse(localStorage.getItem(DRAFT_KEY) || "null"); } import { uploadBytes } from "../../lib/upload";
catch { return null; }
}
function saveDraft(data: object) {
localStorage.setItem(DRAFT_KEY, JSON.stringify(data));
}
function clearDraft() {
localStorage.removeItem(DRAFT_KEY);
}
export function ArticleEditor() { export function ArticleEditor() {
const { goBack } = useUIStore(); const { goBack } = useUIStore();
const draft = loadDraft(); const { activeDraftId, drafts, updateDraft, deleteDraft, setActiveDraft, createDraft } = useDraftStore();
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [title, setTitle] = useState(draft?.title || ""); // If no active draft, show draft list
const [content, setContent] = useState(draft?.content || ""); const activeDraft = activeDraftId ? drafts.find((d) => d.id === activeDraftId) : null;
const [summary, setSummary] = useState(draft?.summary || "");
const [image, setImage] = useState(draft?.image || ""); const [title, setTitle] = useState(activeDraft?.title || "");
const [tags, setTags] = useState(draft?.tags || ""); 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 [mode, setMode] = useState<"write" | "preview">("write");
const [showMeta, setShowMeta] = useState(false); const [showMeta, setShowMeta] = useState(false);
const [publishing, setPublishing] = useState(false); const [publishing, setPublishing] = useState(false);
const [uploading, setUploading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [published, setPublished] = useState(false); const [published, setPublished] = useState(false);
// Auto-save draft // Sync state when active draft changes
useEffect(() => { 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(() => { const t = setTimeout(() => {
saveDraft({ title, content, summary, image, tags }); updateDraft(activeDraftId, { title, content, summary, image, tags });
}, 1000); }, 1000);
return () => clearTimeout(t); 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 renderedHtml = marked(content || "*Nothing to preview yet.*") as string;
const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0; const wordCount = content.trim() ? content.trim().split(/\s+/).length : 0;
@@ -57,7 +66,7 @@ export function ArticleEditor() {
image: image.trim() || undefined, image: image.trim() || undefined,
tags: tags.split(",").map((t: string) => t.trim()).filter(Boolean), tags: tags.split(",").map((t: string) => t.trim()).filter(Boolean),
}); });
clearDraft(); if (activeDraftId) deleteDraft(activeDraftId);
setPublished(true); setPublished(true);
setTimeout(goBack, 1500); setTimeout(goBack, 1500);
} catch (err) { } 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<string, string> = {
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 <DraftListView onNewDraft={handleNewDraft} />;
}
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
{/* Header */} {/* Header */}
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0"> <header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors"> <button onClick={() => setActiveDraft(null)} className="text-text-dim hover:text-text text-[11px] transition-colors">
back drafts
</button> </button>
<span className="text-text-dim text-[10px]">{wordCount > 0 ? `${wordCount} words` : "New article"}</span> <span className="text-text-dim text-[10px]">{wordCount > 0 ? `${wordCount} words` : "New article"}</span>
{draft && !published && ( {activeDraft && !published && (
<span className="text-text-dim text-[10px]">· draft saved</span> <span className="text-text-dim text-[10px]">· draft saved</span>
)} )}
{uploading && (
<span className="inline-flex items-center gap-1 text-text-dim text-[10px]">
<span className="w-3 h-3 border border-accent border-t-transparent rounded-full animate-spin" />
uploading
</span>
)}
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@@ -130,13 +182,23 @@ export function ArticleEditor() {
</div> </div>
<div className="flex flex-col gap-2"> <div className="flex flex-col gap-2">
<div> <div>
<label className="text-text-dim text-[10px] block mb-1">Cover image URL</label> <label className="text-text-dim text-[10px] block mb-1">Cover image</label>
<div className="flex gap-1">
<input <input
value={image} value={image}
onChange={(e) => setImage(e.target.value)} onChange={(e) => setImage(e.target.value)}
placeholder="https://…" 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" className="flex-1 bg-bg border border-border px-2 py-1.5 text-text text-[12px] focus:outline-none focus:border-accent/50"
/> />
<button
onClick={handleCoverImagePick}
disabled={uploading}
title="Upload cover image"
className="px-2 py-1.5 text-[11px] border border-border text-text-muted hover:text-text hover:bg-bg-hover transition-colors disabled:opacity-30"
>
{uploading ? "…" : "↑"}
</button>
</div>
</div> </div>
<div> <div>
<label className="text-text-dim text-[10px] block mb-1">Tags (comma-separated)</label> <label className="text-text-dim text-[10px] block mb-1">Tags (comma-separated)</label>
@@ -169,12 +231,25 @@ export function ArticleEditor() {
/> />
</div> </div>
{/* Markdown toolbar */}
{mode === "write" && (
<MarkdownToolbar
textareaRef={textareaRef}
content={content}
setContent={setContent}
setUploading={setUploading}
setError={setError}
/>
)}
{/* Content area */} {/* Content area */}
<div className="flex-1 overflow-y-auto px-6 pb-6"> <div className="flex-1 overflow-y-auto px-6 pb-6">
{mode === "write" ? ( {mode === "write" ? (
<textarea <textarea
ref={textareaRef}
value={content} value={content}
onChange={(e) => setContent(e.target.value)} onChange={(e) => setContent(e.target.value)}
onKeyDown={(e) => handleEditorKeyDown(e, textareaRef, content, setContent)}
placeholder="Write your article in Markdown…" placeholder="Write your article in Markdown…"
className="w-full h-full min-h-[400px] bg-transparent text-text text-[14px] leading-relaxed placeholder:text-text-dim resize-none focus:outline-none font-mono" className="w-full h-full min-h-[400px] bg-transparent text-text text-[14px] leading-relaxed placeholder:text-text-dim resize-none focus:outline-none font-mono"
/> />
@@ -189,3 +264,71 @@ export function ArticleEditor() {
</div> </div>
); );
} }
/** Draft list view — shown when no active draft is selected */
function DraftListView({ onNewDraft }: { onNewDraft: () => void }) {
const { goBack } = useUIStore();
const { drafts, deleteDraft, setActiveDraft } = useDraftStore();
return (
<div className="h-full flex flex-col">
<header className="border-b border-border px-4 py-2.5 flex items-center justify-between shrink-0">
<div className="flex items-center gap-3">
<button onClick={goBack} className="text-text-dim hover:text-text text-[11px] transition-colors">
back
</button>
<h2 className="text-text text-[13px] font-medium">Drafts</h2>
<span className="text-text-dim text-[11px]">{drafts.length} {drafts.length === 1 ? "draft" : "drafts"}</span>
</div>
<button
onClick={onNewDraft}
className="px-3 py-1 text-[11px] bg-accent hover:bg-accent-hover text-white transition-colors"
>
new draft
</button>
</header>
<div className="flex-1 overflow-y-auto">
{drafts.length === 0 && (
<div className="px-4 py-12 text-center space-y-2">
<p className="text-text-dim text-[13px]">No drafts yet.</p>
<p className="text-text-dim text-[11px] opacity-60">
Click "new draft" to start writing an article.
</p>
</div>
)}
{drafts.map((draft: ArticleDraft) => {
const wordCount = draft.content.trim() ? draft.content.trim().split(/\s+/).length : 0;
const updated = new Date(draft.updatedAt).toLocaleDateString(undefined, {
month: "short", day: "numeric", hour: "2-digit", minute: "2-digit",
});
return (
<div
key={draft.id}
className="border-b border-border px-4 py-3 hover:bg-bg-hover transition-colors cursor-pointer flex items-center justify-between"
onClick={() => setActiveDraft(draft.id)}
>
<div className="min-w-0 flex-1">
<h3 className="text-text text-[13px] font-medium truncate">
{draft.title || "Untitled"}
</h3>
<div className="flex items-center gap-2 mt-0.5">
<span className="text-text-dim text-[11px]">{wordCount} words</span>
<span className="text-text-dim text-[10px]">{updated}</span>
</div>
</div>
<button
onClick={(e) => { e.stopPropagation(); deleteDraft(draft.id); }}
className="text-text-dim hover:text-danger text-[11px] transition-colors px-2"
title="Delete draft"
>
×
</button>
</div>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,218 @@
import { open } from "@tauri-apps/plugin-dialog";
import { readFile } from "@tauri-apps/plugin-fs";
import { uploadBytes } from "../../lib/upload";
type MarkdownAction = "bold" | "italic" | "heading" | "link" | "image" | "quote" | "code" | "list";
interface ToolbarProps {
textareaRef: React.RefObject<HTMLTextAreaElement | null>;
content: string;
setContent: (value: string) => void;
setUploading?: (value: boolean) => void;
setError?: (value: string | null) => void;
}
function applyMarkdown(
textarea: HTMLTextAreaElement,
action: MarkdownAction,
content: string,
setContent: (value: string) => void,
insertText?: string,
) {
const start = textarea.selectionStart;
const end = textarea.selectionEnd;
const selected = content.slice(start, end);
let before = "";
let after = "";
let replacement = "";
let cursorOffset = 0;
switch (action) {
case "bold":
before = "**";
after = "**";
replacement = selected || "bold text";
cursorOffset = selected ? 0 : 9; // select "bold text"
break;
case "italic":
before = "*";
after = "*";
replacement = selected || "italic text";
cursorOffset = selected ? 0 : 11;
break;
case "heading":
before = "## ";
after = "";
replacement = selected || "Heading";
break;
case "link":
if (selected) {
before = "[";
after = "](url)";
replacement = selected;
} else {
before = "[";
after = "](url)";
replacement = "link text";
}
break;
case "image":
if (insertText) {
before = "";
after = "";
replacement = insertText;
} else {
before = "![";
after = "](url)";
replacement = selected || "alt text";
}
break;
case "quote":
before = "> ";
after = "";
replacement = selected || "quote";
break;
case "code":
if (selected.includes("\n")) {
before = "```\n";
after = "\n```";
replacement = selected;
} else {
before = "`";
after = "`";
replacement = selected || "code";
}
break;
case "list":
if (selected) {
replacement = selected
.split("\n")
.map((line) => `- ${line}`)
.join("\n");
} else {
before = "- ";
after = "";
replacement = "item";
}
break;
}
const newContent =
content.slice(0, start) + before + replacement + after + content.slice(end);
setContent(newContent);
// Restore focus and selection
requestAnimationFrame(() => {
textarea.focus();
const newCursorPos = start + before.length + replacement.length + after.length;
if (!selected && cursorOffset === 0) {
// Select the placeholder text
textarea.selectionStart = start + before.length;
textarea.selectionEnd = start + before.length + replacement.length;
} else {
textarea.selectionStart = textarea.selectionEnd = newCursorPos;
}
});
}
const TOOLS: { action: MarkdownAction; label: string; title: string }[] = [
{ action: "bold", label: "B", title: "Bold (Ctrl+B)" },
{ action: "italic", label: "I", title: "Italic (Ctrl+I)" },
{ action: "heading", label: "H", title: "Heading" },
{ action: "link", label: "🔗", title: "Link (Ctrl+K)" },
{ action: "image", label: "🖼", title: "Image" },
{ action: "quote", label: "❝", title: "Quote" },
{ action: "code", label: "</>", title: "Code" },
{ action: "list", label: "☰", title: "List" },
];
export function MarkdownToolbar({ textareaRef, content, setContent, setUploading, setError }: ToolbarProps) {
const handleClick = (action: MarkdownAction) => {
if (action === "image") {
handleImageUpload();
return;
}
const textarea = textareaRef.current;
if (!textarea) return;
applyMarkdown(textarea, action, content, setContent);
};
const handleImageUpload = async () => {
try {
const selected = await open({
multiple: false,
filters: [
{ name: "Images", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg"] },
],
});
if (!selected) return;
const filePath = typeof selected === "string" ? selected : selected;
setUploading?.(true);
setError?.(null);
try {
const bytes = await readFile(filePath);
const fileName = filePath.split(/[\\/]/).pop() || "image.png";
const ext = fileName.split(".").pop()?.toLowerCase() || "png";
const mimeMap: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
webp: "image/webp", svg: "image/svg+xml",
};
const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeMap[ext] || "image/png");
const textarea = textareaRef.current;
if (textarea) {
applyMarkdown(textarea, "image", content, setContent, `![${fileName}](${url})`);
}
} finally {
setUploading?.(false);
}
} catch (err) {
setError?.(`Image upload failed: ${err}`);
}
};
return (
<div className="flex items-center gap-0.5 border-b border-border px-2 py-1 bg-bg-raised shrink-0">
{TOOLS.map(({ action, label, title }) => (
<button
key={action}
onClick={() => handleClick(action)}
title={title}
className="px-2 py-0.5 text-[12px] text-text-muted hover:text-text hover:bg-bg-hover transition-colors rounded-sm"
style={action === "bold" ? { fontWeight: "bold" } : action === "italic" ? { fontStyle: "italic" } : undefined}
>
{label}
</button>
))}
</div>
);
}
/** Keyboard shortcut handler for the article editor textarea */
export function handleEditorKeyDown(
e: React.KeyboardEvent<HTMLTextAreaElement>,
textareaRef: React.RefObject<HTMLTextAreaElement | null>,
content: string,
setContent: (value: string) => void,
): boolean {
if (!(e.ctrlKey || e.metaKey)) return false;
const textarea = textareaRef.current;
if (!textarea) return false;
switch (e.key.toLowerCase()) {
case "b":
e.preventDefault();
applyMarkdown(textarea, "bold", content, setContent);
return true;
case "i":
e.preventDefault();
applyMarkdown(textarea, "italic", content, setContent);
return true;
case "k":
e.preventDefault();
applyMarkdown(textarea, "link", content, setContent);
return true;
default:
return false;
}
}

View File

@@ -2,15 +2,21 @@ import { useEffect, useState } from "react";
import { NDKEvent } from "@nostr-dev-kit/ndk"; import { NDKEvent } from "@nostr-dev-kit/ndk";
import { useBookmarkStore } from "../../stores/bookmark"; import { useBookmarkStore } from "../../stores/bookmark";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
import { fetchNoteById } from "../../lib/nostr"; import { fetchNoteById, fetchByAddr } from "../../lib/nostr";
import { NoteCard } from "../feed/NoteCard"; import { NoteCard } from "../feed/NoteCard";
import { ArticleCard } from "../article/ArticleCard";
import { SkeletonNoteList } from "../shared/Skeleton"; import { SkeletonNoteList } from "../shared/Skeleton";
type BookmarkTab = "notes" | "articles";
export function BookmarkView() { export function BookmarkView() {
const { bookmarkedIds, fetchBookmarks } = useBookmarkStore(); const { bookmarkedIds, bookmarkedArticleAddrs, fetchBookmarks } = useBookmarkStore();
const { pubkey } = useUserStore(); const { pubkey } = useUserStore();
const [tab, setTab] = useState<BookmarkTab>("notes");
const [notes, setNotes] = useState<NDKEvent[]>([]); const [notes, setNotes] = useState<NDKEvent[]>([]);
const [loading, setLoading] = useState(false); const [articles, setArticles] = useState<NDKEvent[]>([]);
const [loadingNotes, setLoadingNotes] = useState(false);
const [loadingArticles, setLoadingArticles] = useState(false);
useEffect(() => { useEffect(() => {
if (pubkey) fetchBookmarks(pubkey); if (pubkey) fetchBookmarks(pubkey);
@@ -24,8 +30,16 @@ export function BookmarkView() {
loadNotes(); loadNotes();
}, [bookmarkedIds]); }, [bookmarkedIds]);
useEffect(() => {
if (bookmarkedArticleAddrs.length === 0) {
setArticles([]);
return;
}
loadArticles();
}, [bookmarkedArticleAddrs]);
const loadNotes = async () => { const loadNotes = async () => {
setLoading(true); setLoadingNotes(true);
try { try {
const results = await Promise.all( const results = await Promise.all(
bookmarkedIds.map((id) => fetchNoteById(id)) bookmarkedIds.map((id) => fetchNoteById(id))
@@ -36,36 +50,81 @@ export function BookmarkView() {
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0)) .sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
); );
} finally { } finally {
setLoading(false); setLoadingNotes(false);
} }
}; };
const loadArticles = async () => {
setLoadingArticles(true);
try {
const results = await Promise.all(
bookmarkedArticleAddrs.map((addr) => fetchByAddr(addr))
);
setArticles(
results
.filter((e): e is NDKEvent => e !== null)
.sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))
);
} finally {
setLoadingArticles(false);
}
};
const totalCount = bookmarkedIds.length + bookmarkedArticleAddrs.length;
const loading = tab === "notes" ? loadingNotes : loadingArticles;
const items = tab === "notes" ? notes : articles;
return ( return (
<div className="h-full flex flex-col"> <div className="h-full flex flex-col">
<header className="border-b border-border px-4 py-2.5 shrink-0"> <header className="border-b border-border px-4 py-2.5 shrink-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<h2 className="text-text text-[13px] font-medium">Bookmarks</h2> <h2 className="text-text text-[13px] font-medium">Bookmarks</h2>
<span className="text-text-dim text-[11px]">{bookmarkedIds.length} saved</span> <div className="flex border border-border text-[11px]">
<button
onClick={() => setTab("notes")}
className={`px-3 py-0.5 transition-colors ${tab === "notes" ? "bg-accent/10 text-accent" : "text-text-muted hover:text-text"}`}
>
Notes
</button>
<button
onClick={() => setTab("articles")}
className={`px-3 py-0.5 transition-colors ${tab === "articles" ? "bg-accent/10 text-accent" : "text-text-muted hover:text-text"}`}
>
Articles
</button>
</div>
</div>
<span className="text-text-dim text-[11px]">{totalCount} saved</span>
</div> </div>
</header> </header>
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{loading && notes.length === 0 && ( {loading && items.length === 0 && (
<SkeletonNoteList count={3} /> <SkeletonNoteList count={3} />
)} )}
{!loading && notes.length === 0 && ( {!loading && items.length === 0 && (
<div className="px-4 py-12 text-center space-y-2"> <div className="px-4 py-12 text-center space-y-2">
<p className="text-text-dim text-[13px]">No bookmarks yet.</p> <p className="text-text-dim text-[13px]">
{tab === "notes" ? "No bookmarked notes." : "No bookmarked articles."}
</p>
<p className="text-text-dim text-[11px] opacity-60"> <p className="text-text-dim text-[11px] opacity-60">
Use the <span className="text-accent">save</span> button on any note to bookmark it here. {tab === "notes"
? <>Use the <span className="text-accent">save</span> button on any note to bookmark it here.</>
: <>Use the <span className="text-accent">save</span> button on any article to add it to your reading list.</>
}
</p> </p>
</div> </div>
)} )}
{notes.map((event) => ( {tab === "notes" && notes.map((event) => (
<NoteCard key={event.id} event={event} /> <NoteCard key={event.id} event={event} />
))} ))}
{tab === "articles" && articles.map((event) => (
<ArticleCard key={event.id} event={event} />
))}
</div> </div>
</div> </div>
); );

View File

@@ -1,11 +1,11 @@
import { useState, useRef, useEffect } from "react"; import { useState, useRef, useEffect } from "react";
import { publishNote } from "../../lib/nostr"; import { publishNote } from "../../lib/nostr";
import { uploadImage } from "../../lib/upload"; import { uploadImage, uploadBytes } from "../../lib/upload";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
import { useFeedStore } from "../../stores/feed"; import { useFeedStore } from "../../stores/feed";
import { shortenPubkey } from "../../lib/utils"; import { shortenPubkey } from "../../lib/utils";
import { open } from "@tauri-apps/plugin-dialog"; import { open } from "@tauri-apps/plugin-dialog";
import { invoke } from "@tauri-apps/api/core"; import { readFile } from "@tauri-apps/plugin-fs";
const COMPOSE_DRAFT_KEY = "wrystr_compose_draft"; const COMPOSE_DRAFT_KEY = "wrystr_compose_draft";
@@ -70,12 +70,21 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
} }
}; };
// Upload a file by path using the Rust backend (bypasses WebView FormData issues) // Upload a file by path using TS upload with NIP-98 auth
const handleNativeUpload = async (filePath: string) => { const handleNativeUpload = async (filePath: string) => {
setUploading(true); setUploading(true);
setError(null); setError(null);
try { try {
const url = await invoke<string>("upload_file", { path: filePath }); const bytes = await readFile(filePath);
const fileName = filePath.split(/[\\/]/).pop() || "file";
const ext = fileName.split(".").pop()?.toLowerCase() || "";
const mimeMap: Record<string, string> = {
jpg: "image/jpeg", jpeg: "image/jpeg", png: "image/png", gif: "image/gif",
webp: "image/webp", svg: "image/svg+xml", mp4: "video/mp4", webm: "video/webm",
mov: "video/quicktime", ogg: "video/ogg", m4v: "video/mp4",
};
const mimeType = mimeMap[ext] || "application/octet-stream";
const url = await uploadBytes(new Uint8Array(bytes), fileName, mimeType);
insertUrl(url); insertUrl(url);
} catch (err) { } catch (err) {
setError(`Upload failed: ${err}`); setError(`Upload failed: ${err}`);
@@ -217,7 +226,12 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
<div className="flex items-center justify-between mt-1"> <div className="flex items-center justify-between mt-1">
<span className={`text-[10px] ${overLimit ? "text-danger" : "text-text-dim"}`}> <span className={`text-[10px] ${overLimit ? "text-danger" : "text-text-dim"}`}>
{uploading ? "uploading image…" : charCount > 0 ? `${charCount}/280` : ""} {uploading ? (
<span className="inline-flex items-center gap-1">
<span className="w-3 h-3 border border-accent border-t-transparent rounded-full animate-spin" />
uploading
</span>
) : charCount > 0 ? `${charCount}/280` : ""}
{!uploading && charCount > 0 && localStorage.getItem(COMPOSE_DRAFT_KEY) && ( {!uploading && charCount > 0 && localStorage.getItem(COMPOSE_DRAFT_KEY) && (
<span className="ml-1 text-text-dim">(draft)</span> <span className="ml-1 text-text-dim">(draft)</span>
)} )}

View File

@@ -2,6 +2,7 @@ import { useUIStore } from "../../stores/ui";
import { useFeedStore } from "../../stores/feed"; import { useFeedStore } from "../../stores/feed";
import { useUserStore } from "../../stores/user"; import { useUserStore } from "../../stores/user";
import { useNotificationsStore } from "../../stores/notifications"; import { useNotificationsStore } from "../../stores/notifications";
import { useDraftStore } from "../../stores/drafts";
import { getNDK } from "../../lib/nostr"; import { getNDK } from "../../lib/nostr";
import { AccountSwitcher } from "./AccountSwitcher"; import { AccountSwitcher } from "./AccountSwitcher";
import pkg from "../../../package.json"; import pkg from "../../../package.json";
@@ -24,6 +25,7 @@ export function Sidebar() {
const { connected } = useFeedStore(); const { connected } = useFeedStore();
const { loggedIn } = useUserStore(); const { loggedIn } = useUserStore();
const { unreadCount: notifUnread, dmUnreadCount } = useNotificationsStore(); const { unreadCount: notifUnread, dmUnreadCount } = useNotificationsStore();
const draftCount = useDraftStore((s) => s.drafts.length);
const c = sidebarCollapsed; const c = sidebarCollapsed;
@@ -75,8 +77,16 @@ export function Sidebar() {
: "text-text-muted hover:text-text hover:bg-bg-hover" : "text-text-muted hover:text-text hover:bg-bg-hover"
}`} }`}
> >
<span className="w-4 text-center text-[14px]"></span> <span className="relative w-4 text-center text-[14px]">
{c && draftCount > 0 && (
<span className="absolute -top-0.5 -right-0.5 w-1.5 h-1.5 rounded-full bg-accent" />
)}
</span>
{!c && <span>write article</span>} {!c && <span>write article</span>}
{!c && draftCount > 0 && (
<span className="ml-auto text-[10px] bg-accent/20 text-accent px-1 rounded-sm">{draftCount}</span>
)}
</button> </button>
)} )}

View File

@@ -616,6 +616,52 @@ export async function publishBookmarkList(eventIds: string[]): Promise<void> {
await event.publish(); await event.publish();
} }
export async function fetchBookmarkListFull(pubkey: string): Promise<{ eventIds: string[]; articleAddrs: string[] }> {
const instance = getNDK();
const filter: NDKFilter = { kinds: [10003 as NDKKind], authors: [pubkey], limit: 1 };
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
if (events.size === 0) return { eventIds: [], articleAddrs: [] };
const event = Array.from(events).sort((a, b) => (b.created_at ?? 0) - (a.created_at ?? 0))[0];
const eventIds = event.tags.filter((t) => t[0] === "e" && t[1]).map((t) => t[1]);
const articleAddrs = event.tags.filter((t) => t[0] === "a" && t[1]).map((t) => t[1]);
return { eventIds, articleAddrs };
}
export async function publishBookmarkListFull(eventIds: string[], articleAddrs: string[]): Promise<void> {
const instance = getNDK();
if (!instance.signer) return;
const event = new NDKEvent(instance);
event.kind = 10003 as NDKKind;
event.content = "";
event.tags = [
...eventIds.map((id) => ["e", id]),
...articleAddrs.map((addr) => ["a", addr]),
];
await event.publish();
}
export async function fetchByAddr(addr: string): Promise<NDKEvent | null> {
const instance = getNDK();
// addr format: "30023:<pubkey>:<d-tag>"
const parts = addr.split(":");
if (parts.length < 3) return null;
const kind = parseInt(parts[0]);
const pubkey = parts[1];
const dTag = parts.slice(2).join(":");
const filter: NDKFilter = {
kinds: [kind as NDKKind],
authors: [pubkey],
"#d": [dTag],
limit: 1,
};
const events = await instance.fetchEvents(filter, {
cacheUsage: NDKSubscriptionCacheUsage.ONLY_RELAY,
});
return Array.from(events)[0] ?? null;
}
export async function fetchMuteList(pubkey: string): Promise<string[]> { export async function fetchMuteList(pubkey: string): Promise<string[]> {
const instance = getNDK(); const instance = getNDK();
const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 }; const filter: NDKFilter = { kinds: [10000 as NDKKind], authors: [pubkey], limit: 1 };

View File

@@ -1,2 +1,2 @@
export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client"; export { getNDK, connectToRelays, fetchGlobalFeed, fetchFollowFeed, fetchReplies, publishNote, publishArticle, publishProfile, publishReaction, publishRepost, publishQuote, publishReply, publishContactList, fetchBatchEngagement, fetchReactionCount, fetchReplyCount, fetchZapCount, fetchNoteById, fetchUserNotes, fetchProfile, fetchArticle, fetchAuthorArticles, fetchArticleFeed, searchArticles, fetchZapsReceived, fetchZapsSent, fetchDMConversations, fetchDMThread, sendDM, decryptDM, fetchBookmarkList, publishBookmarkList, fetchBookmarkListFull, publishBookmarkListFull, fetchByAddr, fetchMuteList, publishMuteList, getStoredRelayUrls, addRelay, removeRelay, searchNotes, searchUsers, fetchUserRelayList, publishRelayList, fetchUserNotesNIP65, fetchFollowSuggestions, fetchMentions } from "./client";
export type { UserRelayList } from "./client"; export type { UserRelayList } from "./client";

View File

@@ -1,4 +1,33 @@
import { fetch } from "@tauri-apps/plugin-http"; import { fetch } from "@tauri-apps/plugin-http";
import { getNDK } from "./nostr";
import { NDKEvent } from "@nostr-dev-kit/ndk";
const UPLOAD_SERVICES = [
"https://nostr.build/api/v2/upload/files",
"https://void.cat/upload",
"https://nostrimg.com/api/upload",
];
/**
* Create a NIP-98 HTTP Auth event (kind 27235) for a given URL and method.
* Returns a base64-encoded signed event for the Authorization header.
*/
async function createNip98AuthHeader(url: string, method: string): Promise<string> {
const ndk = getNDK();
if (!ndk.signer) throw new Error("Not logged in — cannot sign NIP-98 auth");
const event = new NDKEvent(ndk);
event.kind = 27235;
event.created_at = Math.floor(Date.now() / 1000);
event.content = "";
event.tags = [
["u", url],
["method", method.toUpperCase()],
];
await event.sign();
const encoded = btoa(JSON.stringify(event.rawEvent()));
return `Nostr ${encoded}`;
}
/** /**
* Upload an image file to nostr.build and return the hosted URL. * Upload an image file to nostr.build and return the hosted URL.
@@ -9,33 +38,60 @@ import { fetch } from "@tauri-apps/plugin-http";
* and build a proper Blob with the correct MIME type. * and build a proper Blob with the correct MIME type.
*/ */
export async function uploadImage(file: File): Promise<string> { export async function uploadImage(file: File): Promise<string> {
// Read file bytes — ensures clipboard-pasted images are properly serialized
const bytes = new Uint8Array(await file.arrayBuffer()); const bytes = new Uint8Array(await file.arrayBuffer());
return uploadBytes(bytes, file.name || "image.png", file.type || "image/png"); return uploadBytes(bytes, file.name || "image.png", file.type || "image/png");
} }
/** /**
* Upload raw bytes to nostr.build. Used by the native file picker path * Upload raw bytes with NIP-98 auth. Tries nostr.build first, then fallbacks.
* where we already have a Uint8Array from tauri-plugin-fs.
*/ */
export async function uploadBytes(bytes: Uint8Array, fileName: string, mimeType: string): Promise<string> { export async function uploadBytes(bytes: Uint8Array, fileName: string, mimeType: string): Promise<string> {
const blob = new Blob([bytes], { type: mimeType }); const blob = new Blob([bytes], { type: mimeType });
const errors: string[] = [];
for (const serviceUrl of UPLOAD_SERVICES) {
try {
const form = new FormData(); const form = new FormData();
form.append("file", blob, fileName); form.append("file", blob, fileName);
const resp = await fetch("https://nostr.build/api/v2/upload/files", { const headers: Record<string, string> = {};
try {
headers["Authorization"] = await createNip98AuthHeader(serviceUrl, "POST");
} catch {
// If not logged in, try without auth (some services allow anonymous)
}
const resp = await fetch(serviceUrl, {
method: "POST", method: "POST",
body: form, body: form,
headers,
}); });
if (!resp.ok) { if (!resp.ok) {
throw new Error(`Upload failed (HTTP ${resp.status})`); errors.push(`${serviceUrl}: HTTP ${resp.status}`);
continue;
} }
const data = await resp.json(); const data = await resp.json();
// nostr.build response format
if (data.status === "success" && data.data?.[0]?.url) { if (data.status === "success" && data.data?.[0]?.url) {
return data.data[0].url as string; return data.data[0].url as string;
} }
throw new Error(data.message || "Upload failed — no URL returned"); // void.cat response format
if (data.file?.url) {
return data.file.url as string;
}
// nostrimg.com response format
if (data.url) {
return data.url as string;
}
errors.push(`${serviceUrl}: no URL in response`);
} catch (err) {
errors.push(`${serviceUrl}: ${err}`);
}
}
throw new Error(`All upload services failed:\n${errors.join("\n")}`);
} }

View File

@@ -1,7 +1,8 @@
import { create } from "zustand"; import { create } from "zustand";
import { fetchBookmarkList, publishBookmarkList } from "../lib/nostr"; import { fetchBookmarkList, fetchBookmarkListFull, publishBookmarkListFull } from "../lib/nostr";
const STORAGE_KEY = "wrystr_bookmarks"; const STORAGE_KEY = "wrystr_bookmarks";
const ARTICLE_STORAGE_KEY = "wrystr_bookmarks_articles";
function loadLocal(): string[] { function loadLocal(): string[] {
try { try {
@@ -15,18 +16,46 @@ function saveLocal(ids: string[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(ids)); localStorage.setItem(STORAGE_KEY, JSON.stringify(ids));
} }
function loadArticleAddrs(): string[] {
try {
return JSON.parse(localStorage.getItem(ARTICLE_STORAGE_KEY) ?? "[]");
} catch {
return [];
}
}
function saveArticleAddrs(addrs: string[]) {
localStorage.setItem(ARTICLE_STORAGE_KEY, JSON.stringify(addrs));
}
interface BookmarkState { interface BookmarkState {
bookmarkedIds: string[]; bookmarkedIds: string[];
bookmarkedArticleAddrs: string[]; // "30023:<pubkey>:<d-tag>" format
fetchBookmarks: (pubkey: string) => Promise<void>; fetchBookmarks: (pubkey: string) => Promise<void>;
addBookmark: (eventId: string) => Promise<void>; addBookmark: (eventId: string) => Promise<void>;
removeBookmark: (eventId: string) => Promise<void>; removeBookmark: (eventId: string) => Promise<void>;
isBookmarked: (eventId: string) => boolean; isBookmarked: (eventId: string) => boolean;
addArticleBookmark: (addr: string) => Promise<void>;
removeArticleBookmark: (addr: string) => Promise<void>;
isArticleBookmarked: (addr: string) => boolean;
} }
export const useBookmarkStore = create<BookmarkState>((set, get) => ({ export const useBookmarkStore = create<BookmarkState>((set, get) => ({
bookmarkedIds: loadLocal(), bookmarkedIds: loadLocal(),
bookmarkedArticleAddrs: loadArticleAddrs(),
fetchBookmarks: async (pubkey: string) => { fetchBookmarks: async (pubkey: string) => {
try {
const { eventIds, articleAddrs } = await fetchBookmarkListFull(pubkey);
const localIds = get().bookmarkedIds;
const localAddrs = get().bookmarkedArticleAddrs;
const mergedIds = Array.from(new Set([...eventIds, ...localIds]));
const mergedAddrs = Array.from(new Set([...articleAddrs, ...localAddrs]));
set({ bookmarkedIds: mergedIds, bookmarkedArticleAddrs: mergedAddrs });
saveLocal(mergedIds);
saveArticleAddrs(mergedAddrs);
} catch {
// Fallback to old format
try { try {
const ids = await fetchBookmarkList(pubkey); const ids = await fetchBookmarkList(pubkey);
if (ids.length === 0) return; if (ids.length === 0) return;
@@ -34,28 +63,49 @@ export const useBookmarkStore = create<BookmarkState>((set, get) => ({
const merged = Array.from(new Set([...ids, ...local])); const merged = Array.from(new Set([...ids, ...local]));
set({ bookmarkedIds: merged }); set({ bookmarkedIds: merged });
saveLocal(merged); saveLocal(merged);
} catch { } catch { /* ignore */ }
// Non-critical — local bookmarks still work
} }
}, },
addBookmark: async (eventId: string) => { addBookmark: async (eventId: string) => {
const { bookmarkedIds } = get(); const { bookmarkedIds, bookmarkedArticleAddrs } = get();
if (bookmarkedIds.includes(eventId)) return; if (bookmarkedIds.includes(eventId)) return;
const updated = [...bookmarkedIds, eventId]; const updated = [...bookmarkedIds, eventId];
set({ bookmarkedIds: updated }); set({ bookmarkedIds: updated });
saveLocal(updated); saveLocal(updated);
publishBookmarkList(updated).catch(() => {}); publishBookmarkListFull(updated, bookmarkedArticleAddrs).catch(() => {});
}, },
removeBookmark: async (eventId: string) => { removeBookmark: async (eventId: string) => {
const { bookmarkedArticleAddrs } = get();
const updated = get().bookmarkedIds.filter((id) => id !== eventId); const updated = get().bookmarkedIds.filter((id) => id !== eventId);
set({ bookmarkedIds: updated }); set({ bookmarkedIds: updated });
saveLocal(updated); saveLocal(updated);
publishBookmarkList(updated).catch(() => {}); publishBookmarkListFull(updated, bookmarkedArticleAddrs).catch(() => {});
}, },
isBookmarked: (eventId: string) => { isBookmarked: (eventId: string) => {
return get().bookmarkedIds.includes(eventId); return get().bookmarkedIds.includes(eventId);
}, },
addArticleBookmark: async (addr: string) => {
const { bookmarkedIds, bookmarkedArticleAddrs } = get();
if (bookmarkedArticleAddrs.includes(addr)) return;
const updated = [...bookmarkedArticleAddrs, addr];
set({ bookmarkedArticleAddrs: updated });
saveArticleAddrs(updated);
publishBookmarkListFull(bookmarkedIds, updated).catch(() => {});
},
removeArticleBookmark: async (addr: string) => {
const { bookmarkedIds } = get();
const updated = get().bookmarkedArticleAddrs.filter((a) => a !== addr);
set({ bookmarkedArticleAddrs: updated });
saveArticleAddrs(updated);
publishBookmarkListFull(bookmarkedIds, updated).catch(() => {});
},
isArticleBookmarked: (addr: string) => {
return get().bookmarkedArticleAddrs.includes(addr);
},
})); }));

122
src/stores/drafts.ts Normal file
View File

@@ -0,0 +1,122 @@
import { create } from "zustand";
const STORAGE_KEY = "wrystr_article_drafts";
const ACTIVE_KEY = "wrystr_active_draft";
const OLD_DRAFT_KEY = "wrystr_article_draft";
export interface ArticleDraft {
id: string;
title: string;
content: string;
summary: string;
image: string;
tags: string;
createdAt: number;
updatedAt: number;
}
function generateId(): string {
return Date.now().toString(36) + Math.random().toString(36).slice(2, 8);
}
function loadDrafts(): ArticleDraft[] {
try {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) return JSON.parse(stored);
} catch { /* ignore */ }
// Auto-migrate old single-draft format
try {
const old = localStorage.getItem(OLD_DRAFT_KEY);
if (old) {
const data = JSON.parse(old);
if (data && (data.title || data.content)) {
const migrated: ArticleDraft = {
id: generateId(),
title: data.title || "",
content: data.content || "",
summary: data.summary || "",
image: data.image || "",
tags: data.tags || "",
createdAt: Date.now(),
updatedAt: Date.now(),
};
localStorage.setItem(STORAGE_KEY, JSON.stringify([migrated]));
localStorage.removeItem(OLD_DRAFT_KEY);
return [migrated];
}
}
} catch { /* ignore */ }
return [];
}
function saveDrafts(drafts: ArticleDraft[]) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(drafts));
}
function loadActiveDraftId(): string | null {
return localStorage.getItem(ACTIVE_KEY) || null;
}
function saveActiveDraftId(id: string | null) {
if (id) {
localStorage.setItem(ACTIVE_KEY, id);
} else {
localStorage.removeItem(ACTIVE_KEY);
}
}
interface DraftState {
drafts: ArticleDraft[];
activeDraftId: string | null;
createDraft: () => string;
updateDraft: (id: string, fields: Partial<Pick<ArticleDraft, "title" | "content" | "summary" | "image" | "tags">>) => void;
deleteDraft: (id: string) => void;
setActiveDraft: (id: string | null) => void;
}
export const useDraftStore = create<DraftState>((set, get) => ({
drafts: loadDrafts(),
activeDraftId: loadActiveDraftId(),
createDraft: () => {
const id = generateId();
const draft: ArticleDraft = {
id,
title: "",
content: "",
summary: "",
image: "",
tags: "",
createdAt: Date.now(),
updatedAt: Date.now(),
};
const updated = [draft, ...get().drafts];
set({ drafts: updated, activeDraftId: id });
saveDrafts(updated);
saveActiveDraftId(id);
return id;
},
updateDraft: (id, fields) => {
const updated = get().drafts.map((d) =>
d.id === id ? { ...d, ...fields, updatedAt: Date.now() } : d
);
set({ drafts: updated });
saveDrafts(updated);
},
deleteDraft: (id) => {
const updated = get().drafts.filter((d) => d.id !== id);
const activeId = get().activeDraftId === id ? null : get().activeDraftId;
set({ drafts: updated, activeDraftId: activeId });
saveDrafts(updated);
saveActiveDraftId(activeId);
},
setActiveDraft: (id) => {
set({ activeDraftId: id });
saveActiveDraftId(id);
},
}));