diff --git a/ROADMAP.md b/ROADMAP.md index 4a9c063..4a8193e 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -25,12 +25,21 @@ platform that happens to live on Nostr, not a social feed that happens to suppor - nsec sessions don't survive app restart — keychain fixes this permanently - Tauri has keychain plugins ready (`tauri-plugin-keychain`) -### 2. SQLite note caching +### 2. Multi-account / profile switcher +- Nostr users regularly maintain separate identities (personal, professional, pseudonymous) +- Near-blocker-level friction discovered during Windows playtest — session re-login every + restart is currently the #1 UX pain point +- Depends on OS keychain (#1) — keys must persist for instant switching +- UI: small account switcher in sidebar footer; click → list of saved accounts; one click to switch +- No re-login flow — switching is instant once accounts are stored in keychain +- v1: stored nsec accounts only; v2 could add NIP-46 remote signer support + +### 3. SQLite note caching - Notes disappear on every restart — no local persistence - Would make the app feel dramatically more solid and fast - Rust backend is the right place for this -### 3. About / Funding page +### 4. About / Funding page - Hardcoded in-app page with all support options - Bitcoin on-chain address with scannable QR code - Lightning address with scannable QR code @@ -39,39 +48,57 @@ platform that happens to live on Nostr, not a social feed that happens to suppor - Lives in the sidebar footer or as a dedicated view — tasteful, never nagging - Ties into the zap infrastructure already built -### 4. Mute / ignore user + anti-spam +### 5. Mute / ignore user + anti-spam - "Ignore this user" from profile or note context menu (NIP-51 mute list) - Mute list persisted to Nostr so it follows you across clients - Settings toggles for basic spam filters (e.g. hide notes from accounts < N days old, hide notes with no followers, hide pure bot patterns) - Consider: Web of Trust (WOT) score as an optional feed filter — needs design session -### 5. Quote / Repost (NIP-18) +### 6. Quote / Repost (NIP-18) - "Quote" wraps a note in your own post with added commentary - "Repost" is a plain re-broadcast (kind 6) - Both are standard and expected by Nostr users - Quote is more valuable — it drives conversation -### 6. Sidebar: collapsible to icon-only + auto-hide +### 7. NWC setup UX — guided wizard +- Plain-text NWC URI field is confusing for non-technical users (confirmed in Windows playtest) +- Wizard: detect wallet type (Alby Hub, Mutiny, Phoenix), deep-link to the right wallet page, + show inline validation + clear error states on connection failure +- Keep raw URI field as advanced fallback + +### 8. System tray / minimize to tray +- Standard expectation for any messaging/social app on Windows +- Without it, closing the window exits — unexpected for a persistent social client +- Research needed for macOS (menu bar?) and Linux (varies by DE) before implementing +- Tauri 2.0 has a tray API — Windows implementation should be straightforward + +### 9. Zap history view +- Sent and received zaps should be visible in the app +- Zap infrastructure (NIP-47 + NIP-57) already built — this is display-layer only +- v1: simple list in a "Zaps" tab on the profile view, or a section in Settings +- Good demo material for OpenSats reviewers + +### 10. Sidebar: collapsible to icon-only + auto-hide - Toggle already exists (clicking WRYSTR collapses to w-12 icons), but it's not obvious - Make the toggle affordance clearer — a visible ‹ / › button - Auto-hide mode: sidebar expands on hover/click, collapses automatically after N seconds of activity in the main pane - Most important: the icon-only state should be the default or easily reachable -### 7. Profile helpers for newcomers +### 11. Profile helpers for newcomers - **NIP-05**: link to a guide or offer a basic self-hosted verification path - **Avatar / banner image upload**: instead of pasting a URL, let users upload directly (NIP-96 file storage or a simple Blossom upload via Tauri) - Newcomers fill in a URL field and have no idea what to put — this is a friction point -### 8. Search: improve full-text + people +### 12. Search: improve full-text + people - NIP-50 full-text (`bitcoin` query) returns zero results on most relays — the UI should detect this and suggest using `#hashtag` instead, or show which relays support it - People search only works on NIP-50-capable relays; most don't support it - Consider: local people search by scanning follows-of-follows graph -### 9. Direct Messages (NIP-44 / NIP-17) +### 13. Direct Messages (NIP-44 / NIP-17) - Significant complexity (encryption, key handling, inbox model) - Major feature gap but non-trivial to implement well - NIP-17 (private DMs) is the modern standard; NIP-44 is the encryption layer @@ -85,6 +112,8 @@ platform that happens to live on Nostr, not a social feed that happens to suppor - The current UI is functional but has "amateur web app" feel on some surfaces - Target bar remains Telegram Desktop — fast, keyboard-navigable, feels native not webby - Specific surfaces to revisit: note cards, thread view, profile header, modals +- **Windows playtest notes (10 Mar 2026):** install went smoothly, window resize/maximise + feels native; full design review still needed ### Web of Trust (WOT) - Nostr has a concept of social graph distance for trust scoring diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 4917256..ec6d1e1 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1807,6 +1807,16 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "keyring" +version = "3.6.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eebcc3aff044e5944a8fbaf69eb277d11986064cba30c468730e8b9909fb551c" +dependencies = [ + "log", + "zeroize", +] + [[package]] name = "kuchikiki" version = "0.8.8-speedreader" @@ -4983,8 +4993,9 @@ dependencies = [ [[package]] name = "wrystr" -version = "0.1.0" +version = "0.1.1" dependencies = [ + "keyring", "serde", "serde_json", "tauri", @@ -5138,6 +5149,12 @@ dependencies = [ "synstructure", ] +[[package]] +name = "zeroize" +version = "1.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0" + [[package]] name = "zerotrie" version = "0.2.3" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 7aa5b36..61b25de 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -22,4 +22,5 @@ tauri = { version = "2", features = ["devtools"] } tauri-plugin-opener = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +keyring = "3" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 4a277ef..6ec62c5 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,14 +1,41 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +use keyring::Entry; + +const KEYRING_SERVICE: &str = "wrystr"; + +/// Store an nsec in the OS keychain, keyed by pubkey (hex). #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn store_nsec(pubkey: String, nsec: String) -> Result<(), String> { + let entry = Entry::new(KEYRING_SERVICE, &pubkey).map_err(|e| e.to_string())?; + entry.set_password(&nsec).map_err(|e| e.to_string()) +} + +/// Load a stored nsec from the OS keychain. Returns None if no entry exists. +#[tauri::command] +fn load_nsec(pubkey: String) -> Result, String> { + let entry = Entry::new(KEYRING_SERVICE, &pubkey).map_err(|e| e.to_string())?; + match entry.get_password() { + Ok(nsec) => Ok(Some(nsec)), + Err(keyring::Error::NoEntry) => Ok(None), + Err(e) => Err(e.to_string()), + } +} + +/// Delete a stored nsec from the OS keychain. +#[tauri::command] +fn delete_nsec(pubkey: String) -> Result<(), String> { + let entry = Entry::new(KEYRING_SERVICE, &pubkey).map_err(|e| e.to_string())?; + match entry.delete_credential() { + Ok(()) => Ok(()), + Err(keyring::Error::NoEntry) => Ok(()), // already gone — that's fine + Err(e) => Err(e.to_string()), + } } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .invoke_handler(tauri::generate_handler![store_nsec, load_nsec, delete_nsec]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/src/main.tsx b/src/main.tsx index 2733724..cfaea7e 100644 --- a/src/main.tsx +++ b/src/main.tsx @@ -4,14 +4,8 @@ import App from "./App"; import "./index.css"; import { useUserStore } from "./stores/user"; -// Restore session from localStorage -const savedPubkey = localStorage.getItem("wrystr_pubkey"); -const savedLoginType = localStorage.getItem("wrystr_login_type"); -if (savedPubkey && savedLoginType === "pubkey") { - useUserStore.getState().loginWithPubkey(savedPubkey); -} -// Note: nsec is never stored, so nsec sessions can't be auto-restored. -// Future: restore via OS keychain. +// Restore session — pubkey (read-only) or nsec via OS keychain +useUserStore.getState().restoreSession(); createRoot(document.getElementById("root") as HTMLElement).render( diff --git a/src/stores/user.ts b/src/stores/user.ts index 124a04b..5e83b06 100644 --- a/src/stores/user.ts +++ b/src/stores/user.ts @@ -2,6 +2,7 @@ import { create } from "zustand"; import { NDKPrivateKeySigner } from "@nostr-dev-kit/ndk"; import { getNDK, publishContactList } from "../lib/nostr"; import { nip19 } from "@nostr-dev-kit/ndk"; +import { invoke } from "@tauri-apps/api/core"; interface UserState { pubkey: string | null; @@ -14,6 +15,7 @@ interface UserState { loginWithNsec: (nsec: string) => Promise; loginWithPubkey: (pubkey: string) => Promise; logout: () => void; + restoreSession: () => Promise; fetchOwnProfile: () => Promise; fetchFollows: () => Promise; follow: (pubkey: string) => Promise; @@ -55,10 +57,13 @@ export const useUserStore = create((set, get) => ({ set({ pubkey, npub, loggedIn: true, loginError: null }); - // Store login (pubkey only, never the nsec) + // Persist pubkey for session restoration localStorage.setItem("wrystr_pubkey", pubkey); localStorage.setItem("wrystr_login_type", "nsec"); + // Store nsec in OS keychain (best-effort — gracefully ignored if unavailable) + invoke("store_nsec", { pubkey, nsec: nsecInput }).catch(() => {}); + // Fetch profile and follows get().fetchOwnProfile(); get().fetchFollows(); @@ -99,11 +104,39 @@ export const useUserStore = create((set, get) => ({ logout: () => { const ndk = getNDK(); ndk.signer = undefined; + const { pubkey } = get(); + if (pubkey) { + invoke("delete_nsec", { pubkey }).catch(() => {}); + } localStorage.removeItem("wrystr_pubkey"); localStorage.removeItem("wrystr_login_type"); set({ pubkey: null, npub: null, profile: null, follows: [], loggedIn: false, loginError: null }); }, + restoreSession: async () => { + const savedPubkey = localStorage.getItem("wrystr_pubkey"); + const savedLoginType = localStorage.getItem("wrystr_login_type"); + if (!savedPubkey) return; + + if (savedLoginType === "pubkey") { + await get().loginWithPubkey(savedPubkey); + return; + } + + if (savedLoginType === "nsec") { + try { + const nsec = await invoke("load_nsec", { pubkey: savedPubkey }); + if (nsec) { + await get().loginWithNsec(nsec); + } + // If no keychain entry (first run after feature lands, or keychain unavailable), + // the user will be prompted to log in again — same as before. + } catch { + // Keychain unavailable (e.g. no secret service on this Linux session) — stay logged out. + } + } + }, + fetchOwnProfile: async () => { const { pubkey } = get(); if (!pubkey) return;