From e4cde9a3db8f8cbe8344d7a7ea5b12498eb73086 Mon Sep 17 00:00:00 2001 From: rob thijssen Date: Tue, 15 Sep 2026 23:36:46 +0300 Subject: [PATCH] feat(ui): receive: address, QR, checkphrase and copy; accounts named in place The receive screen shows one account's address as text, as a QR (drawn in the webview by qrcode-generator, since an address is public), and as the five-word checkphrase Rust derives, with a copy button through tauri-plugin-clipboard-manager granted only write-text, with its reason. Accounts can be named in place; the labels live in settings under account_names, keyed by address, and show on the receive screen too. Checked against the dev node: the checkphrase for crystal_alice reads Toast-Terrific-Kitten-Warrior-Churn, the same as `quantus wallet list`; a `quantus send` of 7 DEV from crystal_bob raised the accounts row from 99,957.786510548 to 99,964.786510548 within the next head; Copy flips to Copied; a rename is written to settings.json and survives a lock. Closes #27 Co-Authored-By: Claude Fable 5.1 Claude-Session: https://claude.ai/code/session_014ftBXYuba8ARhQeF74oUgW --- Cargo.lock | 311 +++++++++++++++++++- Cargo.toml | 2 + crates/wallet-app/Cargo.toml | 1 + crates/wallet-app/capabilities/default.toml | 2 + crates/wallet-app/src/lib.rs | 1 + crates/wallet-data/src/settings.rs | 1 + crates/wallet-entities/src/lib.rs | 5 + ui/package.json | 2 + ui/pnpm-lock.yaml | 18 ++ ui/src/App.tsx | 3 +- ui/src/api/clipboard.ts | 8 + ui/src/api/generated/Settings.ts | 7 +- ui/src/components/AccountName.tsx | 51 ++++ ui/src/index.css | 36 +++ ui/src/lib/qr.ts | 10 + ui/src/routes/Accounts.tsx | 5 + ui/src/routes/Receive.tsx | 99 +++++++ 17 files changed, 559 insertions(+), 3 deletions(-) create mode 100644 ui/src/api/clipboard.ts create mode 100644 ui/src/components/AccountName.tsx create mode 100644 ui/src/lib/qr.ts create mode 100644 ui/src/routes/Receive.tsx diff --git a/Cargo.lock b/Cargo.lock index a909bdf..9775cdc 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -100,6 +100,27 @@ version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" +[[package]] +name = "arboard" +version = "3.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0348a1c054491f4bfe6ab86a7b6ab1e44e45d899005de92f58b3df180b36ddaf" +dependencies = [ + "clipboard-win", + "image", + "log", + "objc2", + "objc2-app-kit", + "objc2-core-foundation", + "objc2-core-graphics", + "objc2-foundation", + "parking_lot", + "percent-encoding", + "windows-sys 0.60.2", + "wl-clipboard-rs", + "x11rb", +] + [[package]] name = "argon2" version = "0.5.3" @@ -520,6 +541,12 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" +[[package]] +name = "byteorder-lite" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f1fe948ff07f4bd06c30984e69f5b4899c516a3ef74f34df92a2df2ab535495" + [[package]] name = "bytes" version = "1.12.1" @@ -672,6 +699,15 @@ dependencies = [ "inout", ] +[[package]] +name = "clipboard-win" +version = "5.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bde03770d3df201d4fb868f2c9c59e66a3e4e2bd06692a0fe701e7103c7e84d4" +dependencies = [ + "error-code", +] + [[package]] name = "combine" version = "4.6.8" @@ -1335,6 +1371,12 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "error-code" +version = "3.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0b5343afd4a8365a643ac588dab4cf234a190c7f6c88c9f6dd6ffe00837661b7" + [[package]] name = "event-listener" version = "5.4.2" @@ -1361,6 +1403,12 @@ version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "da7c62ceae207dd37ea5b845da6a0696c799f85e97da1ab5b7910be3c1c80223" +[[package]] +name = "fax" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caf1079563223d5d59d83c85886a56e586cfd5c1a26292e971a0fa266531ac5a" + [[package]] name = "fdeflate" version = "0.3.7" @@ -1404,6 +1452,12 @@ dependencies = [ "static_assertions", ] +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + [[package]] name = "flate2" version = "1.1.10" @@ -1733,6 +1787,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "gethostname" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1bd49230192a3797a9a4d6abe9b3eed6f7fa4c8a8a4947977c6f80025f92cbd8" +dependencies = [ + "rustix", + "windows-link 0.2.1", +] + [[package]] name = "getrandom" version = "0.2.17" @@ -1955,6 +2019,17 @@ dependencies = [ "tracing", ] +[[package]] +name = "half" +version = "2.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ea2d84b969582b4b1864a92dc5d27cd2b77b622a8d79306834f1be5ba20d84b" +dependencies = [ + "cfg-if", + "crunchy", + "zerocopy", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -2326,6 +2401,20 @@ dependencies = [ "icu_properties", ] +[[package]] +name = "image" +version = "0.25.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6506c6c10786659413faa717ceebcb8f70731c0a60cbae39795fdf114519c1a" +dependencies = [ + "bytemuck", + "byteorder-lite", + "moxcms", + "num-traits", + "png 0.18.1", + "tiff", +] + [[package]] name = "impl-codec" version = "0.7.1" @@ -2880,6 +2969,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "moxcms" +version = "0.7.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ac9557c559cd6fc9867e122e20d2cbefc9ca29d80d027a8e39310920ed2f0a97" +dependencies = [ + "num-traits", + "pxfm", +] + [[package]] name = "muda" version = "0.19.3" @@ -3065,6 +3164,7 @@ dependencies = [ "block2", "objc2", "objc2-core-foundation", + "objc2-core-graphics", "objc2-foundation", ] @@ -3302,6 +3402,16 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d" +[[package]] +name = "os_pipe" +version = "1.2.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d8fae84b431384b68627d0f9b3b1245fcf9f46f6c0e3dc902e9dce64edd1967" +dependencies = [ + "libc", + "windows-sys 0.61.2", +] + [[package]] name = "pango" version = "0.18.3" @@ -3411,6 +3521,17 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "petgraph" +version = "0.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" +dependencies = [ + "fixedbitset", + "hashbrown 0.15.5", + "indexmap 2.14.2", +] + [[package]] name = "phf" version = "0.13.1" @@ -3515,7 +3636,7 @@ checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" dependencies = [ "base64 0.22.1", "indexmap 2.14.2", - "quick-xml", + "quick-xml 0.38.4", "serde", "time", ] @@ -3710,6 +3831,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "pxfm" +version = "0.1.30" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d55d956fa96f5ec02be2e13af0e20391a5aa83d6a074e3ad368959d0fab299ea" + [[package]] name = "qp-human-checkphrase" version = "2.2.1" @@ -3758,6 +3885,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "quick-error" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a993555f31e5a609f617c12db6250dedcac1b0a85076912c436e6fc9b2c8e6a3" + [[package]] name = "quick-xml" version = "0.38.4" @@ -3767,6 +3900,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "quick-xml" +version = "0.41.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" +dependencies = [ + "memchr", +] + [[package]] name = "quote" version = "1.0.47" @@ -5406,6 +5548,21 @@ dependencies = [ "walkdir", ] +[[package]] +name = "tauri-plugin-clipboard-manager" +version = "2.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4136fb69d967753d000423d7e5f863f89bf949efbdfbecb43a580426a01a0194" +dependencies = [ + "arboard", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-plugin-dialog" version = "2.7.3" @@ -5631,6 +5788,20 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "tiff" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "af9605de7fee8d9551863fd692cce7637f548dbd9db9180fcc07ccc6d26c336f" +dependencies = [ + "fax", + "flate2", + "half", + "quick-error", + "weezl", + "zune-jpeg", +] + [[package]] name = "time" version = "0.3.45" @@ -6007,6 +6178,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tree_magic_mini" +version = "3.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8765b90061cba6c22b5831f675da109ae5561588290f9fa2317adab2714d5a6" +dependencies = [ + "memchr", + "nom", + "petgraph", +] + [[package]] name = "try-lock" version = "0.2.5" @@ -6271,6 +6453,7 @@ dependencies = [ "serde_json", "tauri", "tauri-build", + "tauri-plugin-clipboard-manager", "tauri-plugin-dialog", "toml 0.8.2", "tracing", @@ -6478,6 +6661,76 @@ dependencies = [ "bitflags 2.13.2", ] +[[package]] +name = "wayland-backend" +version = "0.3.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "38a91b4eaddff87b1cd1074985e3713da4af2c49742d1b356b2c01670a67a078" +dependencies = [ + "cc", + "downcast-rs", + "rustix", + "smallvec", + "wayland-sys", +] + +[[package]] +name = "wayland-client" +version = "0.31.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c36a0f861ad76d0901f2800b46321410d9f73f2ea88aac0650d86c32688073" +dependencies = [ + "bitflags 2.13.2", + "rustix", + "wayland-backend", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols" +version = "0.32.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "563a85523cade2429938e790815fd7319062103b9f4a2dc806e9b53b95982d8f" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-scanner", +] + +[[package]] +name = "wayland-protocols-wlr" +version = "0.3.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eb04e52f7836d7c7976c78ca0250d61e33873c34156a2a1fc9474828ec268234" +dependencies = [ + "bitflags 2.13.2", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-scanner", +] + +[[package]] +name = "wayland-scanner" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "338e30461b3a2b67d70eb30a6d89f8e0c93a833e07d2ae89085cd070c4a00ac0" +dependencies = [ + "proc-macro2", + "quick-xml 0.41.0", + "quote", +] + +[[package]] +name = "wayland-sys" +version = "0.31.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d8eab23fefc9e41f8e841df4a9c707e8a8c4ed26e944ef69297184de2785e3be" +dependencies = [ + "pkg-config", +] + [[package]] name = "web-sys" version = "0.3.105" @@ -6608,6 +6861,12 @@ dependencies = [ "windows-core 0.61.2", ] +[[package]] +name = "weezl" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a28ac98ddc8b9274cb41bb4d9d4d5c425b6020c50c46f25559911905610b4a88" + [[package]] name = "winapi" version = "0.3.9" @@ -7093,6 +7352,24 @@ version = "0.46.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f17a85883d4e6d00e8a97c586de764dabcc06133f7f1d55dce5cdc070ad7fe59" +[[package]] +name = "wl-clipboard-rs" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e9651471a32e87d96ef3a127715382b2d11cc7c8bb9822ded8a7cc94072eb0a3" +dependencies = [ + "libc", + "log", + "os_pipe", + "rustix", + "thiserror 2.0.18", + "tree_magic_mini", + "wayland-backend", + "wayland-client", + "wayland-protocols", + "wayland-protocols-wlr", +] + [[package]] name = "writeable" version = "0.6.4" @@ -7173,6 +7450,23 @@ dependencies = [ "pkg-config", ] +[[package]] +name = "x11rb" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" +dependencies = [ + "gethostname", + "rustix", + "x11rb-protocol", +] + +[[package]] +name = "x11rb-protocol" +version = "0.13.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" + [[package]] name = "x25519-dalek" version = "2.0.1" @@ -7319,3 +7613,18 @@ name = "zmij" version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "29666d0abbfad1e3dc4dcf6144730dd3a3ab225bbbdac83319345b1b44ccfc1b" + +[[package]] +name = "zune-core" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f423a2c17029964870cfaabb1f13dfab7d092a62a29a89264f4d36990ca414a" + +[[package]] +name = "zune-jpeg" +version = "0.4.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "29ce2c8a9384ad323cf564b67da86e21d3cfdff87908bc1223ed5c99bc792713" +dependencies = [ + "zune-core", +] diff --git a/Cargo.toml b/Cargo.toml index c5c6002..f65c9ab 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -63,6 +63,8 @@ tauri-build = { version = "2", features = ["isolation"] } # Native file dialogs, for importing a quantus-cli wallet file. The only # plugin so far; each one is a capability entry with a reason. tauri-plugin-dialog = "2" +# Copying an address to the clipboard from the receive screen. Write only. +tauri-plugin-clipboard-manager = "2" [profile.release] lto = "thin" diff --git a/crates/wallet-app/Cargo.toml b/crates/wallet-app/Cargo.toml index ef4f16b..066b624 100644 --- a/crates/wallet-app/Cargo.toml +++ b/crates/wallet-app/Cargo.toml @@ -23,6 +23,7 @@ wallet-core.workspace = true wallet-data.workspace = true tauri.workspace = true tauri-plugin-dialog.workspace = true +tauri-plugin-clipboard-manager.workspace = true serde.workspace = true serde_json.workspace = true tracing.workspace = true diff --git a/crates/wallet-app/capabilities/default.toml b/crates/wallet-app/capabilities/default.toml index 854a639..6c9aa85 100644 --- a/crates/wallet-app/capabilities/default.toml +++ b/crates/wallet-app/capabilities/default.toml @@ -13,4 +13,6 @@ permissions = [ "core:default", # The native "open file" dialog, so a person can pick a quantus-cli wallet file to import. Only open, never save. "dialog:allow-open", + # Copy an address from the receive screen. Write only: the app never reads what is on the clipboard. + "clipboard-manager:allow-write-text", ] diff --git a/crates/wallet-app/src/lib.rs b/crates/wallet-app/src/lib.rs index 242298c..972ed98 100644 --- a/crates/wallet-app/src/lib.rs +++ b/crates/wallet-app/src/lib.rs @@ -14,6 +14,7 @@ pub fn run() { tauri::Builder::default() .plugin(tauri_plugin_dialog::init()) + .plugin(tauri_plugin_clipboard_manager::init()) .manage(state) .setup(|app| { // The idle rule is applied on every command too; this ticker is diff --git a/crates/wallet-data/src/settings.rs b/crates/wallet-data/src/settings.rs index db27203..d32475d 100644 --- a/crates/wallet-data/src/settings.rs +++ b/crates/wallet-data/src/settings.rs @@ -82,6 +82,7 @@ mod tests { network: ChainId("planck".into()), idle_lock_seconds: 120, show_testnets: true, + account_names: [("qz1".to_string(), "savings".to_string())].into(), }; store.save(&changed).unwrap(); assert_eq!(store.load().unwrap(), changed); diff --git a/crates/wallet-entities/src/lib.rs b/crates/wallet-entities/src/lib.rs index 71c97da..0dddc74 100644 --- a/crates/wallet-entities/src/lib.rs +++ b/crates/wallet-entities/src/lib.rs @@ -13,6 +13,7 @@ //! Amounts are the exception and travel as strings. use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use ts_rs::TS; /// Identifies a chain the wallet knows a profile for, e.g. `quantus`, @@ -429,6 +430,9 @@ pub struct Settings { pub idle_lock_seconds: u64, /// Whether testnet profiles are offered in the switcher. pub show_testnets: bool, + /// Labels people give their accounts, by address. Cosmetic: an + /// address without one is shown as itself. + pub account_names: BTreeMap, } impl Default for Settings { @@ -437,6 +441,7 @@ impl Default for Settings { network: ChainId("quantus".into()), idle_lock_seconds: 10 * 60, show_testnets: false, + account_names: BTreeMap::new(), } } } diff --git a/ui/package.json b/ui/package.json index 45cd38e..30d4868 100644 --- a/ui/package.json +++ b/ui/package.json @@ -16,7 +16,9 @@ "dependencies": { "@tanstack/react-query": "^5.102.8", "@tauri-apps/api": "^2.9.0", + "@tauri-apps/plugin-clipboard-manager": "^2.3.3", "@tauri-apps/plugin-dialog": "^2.7.3", + "qrcode-generator": "^2.0.4", "react": "^19.1.1", "react-dom": "^19.1.1", "react-router-dom": "^7.8.0" diff --git a/ui/pnpm-lock.yaml b/ui/pnpm-lock.yaml index 8afb215..37745f6 100644 --- a/ui/pnpm-lock.yaml +++ b/ui/pnpm-lock.yaml @@ -14,9 +14,15 @@ importers: '@tauri-apps/api': specifier: ^2.9.0 version: 2.11.1 + '@tauri-apps/plugin-clipboard-manager': + specifier: ^2.3.3 + version: 2.3.3 '@tauri-apps/plugin-dialog': specifier: ^2.7.3 version: 2.7.3 + qrcode-generator: + specifier: ^2.0.4 + version: 2.0.4 react: specifier: ^19.1.1 version: 19.3.0 @@ -535,6 +541,9 @@ packages: '@tauri-apps/api@2.11.1': resolution: {integrity: sha512-M2FPuYND2m+wh5hfW9ZpSdxMPdEJovPBWwoHJmwUpysTYNHaOkVFN419m/K0LIgjb/7KU2vBgsUepJWugQCvAA==} + '@tauri-apps/plugin-clipboard-manager@2.3.3': + resolution: {integrity: sha512-KnyoTs9gj1yEgDkSPUNjOIOHjJTr5wk8IWcYMOWxYTIJCip6QwlyPW8u2X+6bd6kHM4fAdZNpxoal0gy/TwJbg==} + '@tauri-apps/plugin-dialog@2.7.3': resolution: {integrity: sha512-CRgE+7TP4tvq9MjBU6f04NLTFIqVMLKHk3hAqlhil00ngK9ACTrXPH3oHpKMProxILodd3YjBoKbMwSI4IEcfA==} @@ -936,6 +945,9 @@ packages: resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} engines: {node: '>=6'} + qrcode-generator@2.0.4: + resolution: {integrity: sha512-mZSiP6RnbHl4xL2Ap5HfkjLnmxfKcPWpWe/c+5XxCuetEenqmNFf1FH/ftXPCtFG5/TDobjsjz6sSNL0Sr8Z9g==} + react-dom@19.3.0: resolution: {integrity: sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==} peerDependencies: @@ -1378,6 +1390,10 @@ snapshots: '@tauri-apps/api@2.11.1': {} + '@tauri-apps/plugin-clipboard-manager@2.3.3': + dependencies: + '@tauri-apps/api': 2.11.1 + '@tauri-apps/plugin-dialog@2.7.3': dependencies: '@tauri-apps/api': 2.11.1 @@ -1805,6 +1821,8 @@ snapshots: punycode@2.3.1: {} + qrcode-generator@2.0.4: {} + react-dom@19.3.0(react@19.3.0): dependencies: react: 19.3.0 diff --git a/ui/src/App.tsx b/ui/src/App.tsx index 8282d3d..3677c72 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -6,6 +6,7 @@ import { StatusBar } from './components/StatusBar' import { Accounts } from './routes/Accounts' import { Create, ImportFile, OnboardingIndex, Restore } from './routes/Onboarding' import { Placeholder } from './routes/Placeholder' +import { Receive } from './routes/Receive' import { SettingsRoute } from './routes/Settings' // The webview renders; it never holds a key, decodes a transaction, or does @@ -46,7 +47,7 @@ function Gate() { } /> } /> - } /> + } /> } /> } /> } /> diff --git a/ui/src/api/clipboard.ts b/ui/src/api/clipboard.ts new file mode 100644 index 0000000..8ccd2d9 --- /dev/null +++ b/ui/src/api/clipboard.ts @@ -0,0 +1,8 @@ +// Write-only clipboard access, for copying an address. The app never reads +// the clipboard; the capability grants only write-text. + +import { writeText } from '@tauri-apps/plugin-clipboard-manager' + +export function copyText(text: string): Promise { + return writeText(text) +} diff --git a/ui/src/api/generated/Settings.ts b/ui/src/api/generated/Settings.ts index f49e722..7c98f8c 100644 --- a/ui/src/api/generated/Settings.ts +++ b/ui/src/api/generated/Settings.ts @@ -16,4 +16,9 @@ idle_lock_seconds: number, /** * Whether testnet profiles are offered in the switcher. */ -show_testnets: boolean, }; +show_testnets: boolean, +/** + * Labels people give their accounts, by address. Cosmetic: an + * address without one is shown as itself. + */ +account_names: { [key in string]?: string }, }; diff --git a/ui/src/components/AccountName.tsx b/ui/src/components/AccountName.tsx new file mode 100644 index 0000000..cfb1465 --- /dev/null +++ b/ui/src/components/AccountName.tsx @@ -0,0 +1,51 @@ +import { useState } from 'react' +import { useSetSettings, useSettings } from '../api/hooks' + +/** An account's label from settings, editable in place. Cosmetic only. */ +export function AccountName({ address }: { address: string }) { + const settings = useSettings() + const set = useSetSettings() + const [editing, setEditing] = useState(false) + const [draft, setDraft] = useState('') + const current = settings.data?.account_names[address] ?? '' + + function save() { + if (!settings.data) return + const names = { ...settings.data.account_names } + const trimmed = draft.trim() + if (trimmed) names[address] = trimmed + else delete names[address] + set.mutate({ ...settings.data, account_names: names }) + setEditing(false) + } + + if (editing) { + return ( + setDraft(e.target.value)} + onBlur={save} + onKeyDown={(e) => { + if (e.key === 'Enter') save() + if (e.key === 'Escape') setEditing(false) + }} + /> + ) + } + return ( + + ) +} diff --git a/ui/src/index.css b/ui/src/index.css index f2f88f6..7d0d7fb 100644 --- a/ui/src/index.css +++ b/ui/src/index.css @@ -317,3 +317,39 @@ select { textarea.input { resize: vertical; } + +/* ---- receive ------------------------------------------------------------- */ +.receive { + display: flex; + gap: 24px; + align-items: flex-start; + flex-wrap: wrap; +} +.qr { + width: 180px; + height: 180px; + padding: 8px; + background: #fff; + border-radius: 6px; + flex: none; +} +.qr svg { + width: 100%; + height: 100%; + display: block; +} +.address { + word-break: break-all; +} +.linkish { + background: none; + border: none; + padding: 0; + font: inherit; + color: inherit; + cursor: pointer; + text-align: left; +} +.linkish:hover { + text-decoration: underline; +} diff --git a/ui/src/lib/qr.ts b/ui/src/lib/qr.ts new file mode 100644 index 0000000..ad841b9 --- /dev/null +++ b/ui/src/lib/qr.ts @@ -0,0 +1,10 @@ +// A QR code as an SVG string. The library runs in the webview because what +// it encodes is an address, which is public; nothing secret passes here. +import qrcode from 'qrcode-generator' + +export function qrSvg(text: string, cellSize = 4): string { + const qr = qrcode(0, 'M') + qr.addData(text) + qr.make() + return qr.createSvgTag({ cellSize, margin: 2, scalable: true }) +} diff --git a/ui/src/routes/Accounts.tsx b/ui/src/routes/Accounts.tsx index 76c6923..7b45983 100644 --- a/ui/src/routes/Accounts.tsx +++ b/ui/src/routes/Accounts.tsx @@ -1,4 +1,5 @@ import { useBalances, useSession, useSettings } from '../api/hooks' +import { AccountName } from '../components/AccountName' import { ChainCaveat } from '../components/ChainCaveat' import { formatAmount } from '../lib/format' @@ -40,6 +41,7 @@ export function Accounts() { + @@ -50,6 +52,9 @@ export function Accounts() { {balances.data.map((b, i) => ( +
Name Address Scheme Spendable
+ + {b.address} {session.data?.accounts[i]?.scheme ?? ''} diff --git a/ui/src/routes/Receive.tsx b/ui/src/routes/Receive.tsx new file mode 100644 index 0000000..fb41b3b --- /dev/null +++ b/ui/src/routes/Receive.tsx @@ -0,0 +1,99 @@ +import { useEffect, useState } from 'react' +import { useQuery } from '@tanstack/react-query' +import { copyText } from '../api/clipboard' +import { useSession, useSettings } from '../api/hooks' +import { checkphrase } from '../api/wallet' +import { ChainCaveat } from '../components/ChainCaveat' +import { qrSvg } from '../lib/qr' + +/** + * One account's address to hand to a sender: as text, as a QR, and as the + * checkphrase they can read back over the phone. The address is the same + * on every Quantus-family chain; the caveat says which one is selected. + */ +export function Receive() { + const session = useSession() + const settings = useSettings() + const accounts = session.data?.accounts ?? [] + const [address, setAddress] = useState('') + const selected = address || accounts[0]?.address || '' + const phrase = useQuery({ + queryKey: ['checkphrase', selected], + queryFn: () => checkphrase(selected), + enabled: !!selected, + staleTime: Infinity, + }) + const [copied, setCopied] = useState(false) + useEffect(() => { + if (!copied) return + const t = setTimeout(() => setCopied(false), 1500) + return () => clearTimeout(t) + }, [copied]) + + if (!selected) return null + const name = settings.data?.account_names[selected] + return ( + <> + +
+
+

Receive

+
+
+ {accounts.length > 1 && ( +
+ + +
+ )} +
+
+
+ {name &&
{name}
} +
+ +
+ {selected} +
+
+
+ +
+ {phrase.data ?? (phrase.isError ? phrase.error.message : '…')} +
+ + Five words derived from the address. A sender who sees the same five words on + their side is sending to this account. + +
+
+ +
+
+
+
+
+ + ) +}