diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 351e31d..929752c 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -66,7 +66,14 @@ 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.0 — Long-form Article Experience + ### 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 + + ### 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 cards** — title, summary snippet, author avatar+name, cover image thumbnail, reading time, tag chips - **Article search** — search notes, articles, and people in parallel; articles tab in search results; supports full-text (NIP-50) and hashtag search for articles diff --git a/PKGBUILD b/PKGBUILD index c43f726..c74ef4b 100644 --- a/PKGBUILD +++ b/PKGBUILD @@ -1,6 +1,6 @@ # Maintainer: hoornet pkgname=wrystr -pkgver=0.6.0 +pkgver=0.6.1 pkgrel=1 pkgdesc="Cross-platform Nostr desktop client with Lightning integration" arch=('x86_64') diff --git a/package-lock.json b/package-lock.json index f52f6f2..11ffee4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,16 +1,18 @@ { "name": "wrystr", - "version": "0.5.0", + "version": "0.6.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "wrystr", - "version": "0.5.0", + "version": "0.6.0", "dependencies": { "@nostr-dev-kit/ndk": "^3.0.3", "@tailwindcss/vite": "^4.2.1", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", @@ -2123,6 +2125,24 @@ "node": ">= 10" } }, + "node_modules/@tauri-apps/plugin-dialog": { + "version": "2.6.0", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz", + "integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, + "node_modules/@tauri-apps/plugin-fs": { + "version": "2.4.5", + "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-fs/-/plugin-fs-2.4.5.tgz", + "integrity": "sha512-dVxWWGE6VrOxC7/jlhyE+ON/Cc2REJlM35R3PJX3UvFw2XwYhLGQVAIyrehenDdKjotipjYEVc4YjOl3qq90fA==", + "license": "MIT OR Apache-2.0", + "dependencies": { + "@tauri-apps/api": "^2.8.0" + } + }, "node_modules/@tauri-apps/plugin-http": { "version": "2.5.7", "resolved": "https://registry.npmjs.org/@tauri-apps/plugin-http/-/plugin-http-2.5.7.tgz", diff --git a/package.json b/package.json index 9d74d78..85d49fd 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "wrystr", "private": true, - "version": "0.6.0", + "version": "0.6.1", "type": "module", "scripts": { "dev": "vite", @@ -15,6 +15,8 @@ "@nostr-dev-kit/ndk": "^3.0.3", "@tailwindcss/vite": "^4.2.1", "@tauri-apps/api": "^2", + "@tauri-apps/plugin-dialog": "^2.6.0", + "@tauri-apps/plugin-fs": "^2.4.5", "@tauri-apps/plugin-http": "^2.5.7", "@tauri-apps/plugin-opener": "^2", "@tauri-apps/plugin-process": "^2.3.1", diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index bfce455..468cad8 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -234,6 +234,28 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "aws-lc-rs" +version = "1.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94bffc006df10ac2a68c83692d734a465f8ee6c5b384d8545a636f81d858f4bf" +dependencies = [ + "aws-lc-sys", + "zeroize", +] + +[[package]] +name = "aws-lc-sys" +version = "0.38.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4321e568ed89bb5a7d291a7f37997c2c0df89809d7b6d12062c81ddb54aa782e" +dependencies = [ + "cc", + "cmake", + "dunce", + "fs_extra", +] + [[package]] name = "base64" version = "0.21.7" @@ -414,6 +436,8 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aebf35691d1bfb0ac386a69bac2fde4dd276fb618cf8bf4f5318fe285e821bb2" dependencies = [ "find-msvc-tools", + "jobserver", + "libc", "shlex", ] @@ -468,6 +492,15 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cmake" +version = "0.1.57" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +dependencies = [ + "cc", +] + [[package]] name = "combine" version = "4.6.7" @@ -1083,6 +1116,12 @@ dependencies = [ "percent-encoding", ] +[[package]] +name = "fs_extra" +version = "1.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "42703706b716c37f96a77aea830392ad231f44c9e9a67872fa5548707e11b11c" + [[package]] name = "futf" version = "0.1.5" @@ -1957,6 +1996,16 @@ version = "0.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8eaf4bc02d17cbdd7ff4c7438cafcdf7fb9a4613313ad11b4f8fefe7d3fa0130" +[[package]] +name = "jobserver" +version = "0.1.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" +dependencies = [ + "getrandom 0.3.4", + "libc", +] + [[package]] name = "js-sys" version = "0.3.91" @@ -2203,6 +2252,16 @@ version = "0.3.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a" +[[package]] +name = "mime_guess" +version = "2.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e" +dependencies = [ + "mime", + "unicase", +] + [[package]] name = "minisign-verify" version = "0.2.5" @@ -2954,6 +3013,7 @@ version = "0.11.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" dependencies = [ + "aws-lc-rs", "bytes", "getrandom 0.3.4", "lru-slab", @@ -3259,8 +3319,10 @@ dependencies = [ "hyper-util", "js-sys", "log", + "mime_guess", "percent-encoding", "pin-project-lite", + "quinn", "rustls", "rustls-pki-types", "rustls-platform-verifier", @@ -3280,6 +3342,30 @@ dependencies = [ "web-sys", ] +[[package]] +name = "rfd" +version = "0.16.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a15ad77d9e70a92437d8f74c35d99b4e4691128df018833e99f90bcd36152672" +dependencies = [ + "block2", + "dispatch2", + "glib-sys", + "gobject-sys", + "gtk-sys", + "js-sys", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-foundation", + "raw-window-handle", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", + "windows-sys 0.60.2", +] + [[package]] name = "ring" version = "0.17.14" @@ -3342,6 +3428,7 @@ version = "0.23.37" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" dependencies = [ + "aws-lc-rs", "once_cell", "ring", "rustls-pki-types", @@ -3405,6 +3492,7 @@ version = "0.103.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" dependencies = [ + "aws-lc-rs", "ring", "rustls-pki-types", "untrusted", @@ -4173,6 +4261,24 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-dialog" +version = "2.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9204b425d9be8d12aa60c2a83a289cf7d1caae40f57f336ed1155b3a5c0e359b" +dependencies = [ + "log", + "raw-window-handle", + "rfd", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "tauri-plugin-fs", + "thiserror 2.0.18", + "url", +] + [[package]] name = "tauri-plugin-fs" version = "2.4.5" @@ -4826,6 +4932,12 @@ dependencies = [ "unic-common", ] +[[package]] +name = "unicase" +version = "2.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -5827,11 +5939,15 @@ name = "wrystr" version = "0.6.0" dependencies = [ "keyring", + "mime_guess", + "reqwest 0.13.2", "rusqlite", "serde", "serde_json", "tauri", "tauri-build", + "tauri-plugin-dialog", + "tauri-plugin-fs", "tauri-plugin-http", "tauri-plugin-opener", "tauri-plugin-process", diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 11b60f9..f87455e 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -1,6 +1,6 @@ [package] name = "wrystr" -version = "0.6.0" +version = "0.6.1" description = "Cross-platform Nostr desktop client with Lightning integration" authors = ["hoornet"] edition = "2021" @@ -27,4 +27,8 @@ serde_json = "1" keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] } 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/capabilities/default.json b/src-tauri/capabilities/default.json index 313e2ae..db07f97 100644 --- a/src-tauri/capabilities/default.json +++ b/src-tauri/capabilities/default.json @@ -13,6 +13,13 @@ "allow": [ { "url": "https://nostr.build/**" } ] + }, + "dialog:default", + { + "identifier": "fs:allow-read-file", + "allow": [ + { "path": "**" } + ] } ] } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4876fc3..79dc214 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -37,6 +37,51 @@ 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); @@ -142,6 +187,8 @@ pub fn run() { .plugin(tauri_plugin_updater::Builder::new().build()) .plugin(tauri_plugin_process::init()) .plugin(tauri_plugin_http::init()) + .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_fs::init()) .setup(|app| { // ── SQLite ─────────────────────────────────────────────────────── let data_dir = app.path().app_data_dir()?; @@ -203,6 +250,7 @@ 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 14e2b9f..c7be054 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.0", + "version": "0.6.1", "identifier": "com.hoornet.wrystr", "build": { "beforeDevCommand": "npm run dev", diff --git a/src/components/feed/ComposeBox.tsx b/src/components/feed/ComposeBox.tsx index 2b143a7..d78de5b 100644 --- a/src/components/feed/ComposeBox.tsx +++ b/src/components/feed/ComposeBox.tsx @@ -1,12 +1,19 @@ -import { useState, useRef } from "react"; +import { useState, useRef, useEffect } from "react"; import { publishNote } from "../../lib/nostr"; import { uploadImage } from "../../lib/upload"; import { useUserStore } from "../../stores/user"; import { useFeedStore } from "../../stores/feed"; import { shortenPubkey } from "../../lib/utils"; +import { open } from "@tauri-apps/plugin-dialog"; +import { invoke } from "@tauri-apps/api/core"; + +const COMPOSE_DRAFT_KEY = "wrystr_compose_draft"; export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () => void; onNoteInjected?: (event: import("@nostr-dev-kit/ndk").NDKEvent) => void }) { - const [text, setText] = useState(""); + const [text, setText] = useState(() => { + try { return localStorage.getItem(COMPOSE_DRAFT_KEY) || ""; } + catch { return ""; } + }); const [publishing, setPublishing] = useState(false); const [uploading, setUploading] = useState(false); const [error, setError] = useState(null); @@ -16,31 +23,46 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = const avatar = profile?.picture; const name = profile?.displayName || profile?.name || (npub ? shortenPubkey(npub) : ""); + // Auto-save draft with debounce + useEffect(() => { + const t = setTimeout(() => { + if (text.trim()) { + localStorage.setItem(COMPOSE_DRAFT_KEY, text); + } else { + localStorage.removeItem(COMPOSE_DRAFT_KEY); + } + }, 1000); + return () => clearTimeout(t); + }, [text]); + const charCount = text.length; const overLimit = charCount > 280; const canPost = text.trim().length > 0 && !overLimit && !publishing && !uploading; - const handlePaste = async (e: React.ClipboardEvent) => { - const file = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/")); - if (!file) return; - e.preventDefault(); + // Insert a URL at the current cursor position in the textarea + const insertUrl = (url: string) => { + const ta = textareaRef.current; + if (ta) { + const start = ta.selectionStart ?? text.length; + const end = ta.selectionEnd ?? text.length; + const next = text.slice(0, start) + url + text.slice(end); + setText(next); + setTimeout(() => { + ta.selectionStart = ta.selectionEnd = start + url.length; + ta.focus(); + }, 0); + } else { + setText((t) => t + url); + } + }; + + // Upload a web File object (from clipboard/drag-drop) + const handleImageUpload = async (file: File) => { setUploading(true); setError(null); try { const url = await uploadImage(file); - const ta = textareaRef.current; - if (ta) { - const start = ta.selectionStart ?? text.length; - const end = ta.selectionEnd ?? text.length; - const next = text.slice(0, start) + url + text.slice(end); - setText(next); - setTimeout(() => { - ta.selectionStart = ta.selectionEnd = start + url.length; - ta.focus(); - }, 0); - } else { - setText((t) => t + url); - } + insertUrl(url); } catch (err) { setError(`Image upload failed: ${err}`); } finally { @@ -48,6 +70,79 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = } }; + // Upload a file by path using the Rust backend (bypasses WebView FormData issues) + const handleNativeUpload = async (filePath: string) => { + setUploading(true); + setError(null); + try { + const url = await invoke("upload_file", { path: filePath }); + insertUrl(url); + } catch (err) { + setError(`Upload failed: ${err}`); + } finally { + setUploading(false); + } + }; + + const handlePaste = async (e: React.ClipboardEvent) => { + // Try clipboardData.files first (works on Windows, some Linux DEs) + const fileFromFiles = Array.from(e.clipboardData.files).find((f) => f.type.startsWith("image/")); + if (fileFromFiles) { + e.preventDefault(); + handleImageUpload(fileFromFiles); + return; + } + + // Try clipboardData.items (needed for Linux/Wayland screenshot paste) + const items = Array.from(e.clipboardData.items ?? []); + const imageItem = items.find((item) => item.type.startsWith("image/")); + if (imageItem) { + const file = imageItem.getAsFile(); + if (file) { + e.preventDefault(); + handleImageUpload(file); + return; + } + } + + // If pasted text looks like a local file path to a media file, upload it directly + const pastedText = e.clipboardData.getData("text/plain"); + if (pastedText && /\.(jpg|jpeg|png|gif|webp|svg|mp4|webm|mov)$/i.test(pastedText.trim()) && /^(\/|[A-Z]:\\)/.test(pastedText.trim())) { + e.preventDefault(); + handleNativeUpload(pastedText.trim()); + } + }; + + const handleDrop = async (e: React.DragEvent) => { + const file = Array.from(e.dataTransfer.files).find((f) => f.type.startsWith("image/")); + if (!file) return; + e.preventDefault(); + e.stopPropagation(); + handleImageUpload(file); + }; + + const handleDragOver = (e: React.DragEvent) => { + if (Array.from(e.dataTransfer.types).includes("Files")) { + e.preventDefault(); + } + }; + + const handleFilePicker = async () => { + try { + const selected = await open({ + multiple: false, + filters: [ + { name: "Media", extensions: ["jpg", "jpeg", "png", "gif", "webp", "svg", "mp4", "webm", "mov", "ogg", "m4v"] }, + ], + }); + if (!selected) return; + const path = typeof selected === "string" ? selected : selected; + handleNativeUpload(path); + } catch (err) { + setError(`File picker failed: ${err}`); + } + }; + const handleKeyDown = (e: React.KeyboardEvent) => { if (e.key === "Enter" && (e.ctrlKey || e.metaKey)) { e.preventDefault(); @@ -71,6 +166,7 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = }); } setText(""); + localStorage.removeItem(COMPOSE_DRAFT_KEY); textareaRef.current?.focus(); onPublished?.(); } catch (err) { @@ -108,6 +204,8 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () = onChange={(e) => setText(e.target.value)} onKeyDown={handleKeyDown} onPaste={handlePaste} + onDrop={handleDrop} + onDragOver={handleDragOver} 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" @@ -120,8 +218,19 @@ export function ComposeBox({ onPublished, onNoteInjected }: { onPublished?: () =
{uploading ? "uploading image…" : charCount > 0 ? `${charCount}/280` : ""} + {!uploading && charCount > 0 && localStorage.getItem(COMPOSE_DRAFT_KEY) && ( + (draft) + )}
+ Ctrl+Enter to post )} +