From 9d24b717f438babb13d2dfed43eaa24b44332fe5 Mon Sep 17 00:00:00 2001 From: Wes Date: Mon, 9 Mar 2026 14:44:29 -0700 Subject: [PATCH] feat(desktop): connect chat to relay --- desktop/package.json | 1 + desktop/pnpm-lock.yaml | 18 + desktop/src-tauri/Cargo.lock | 869 +++++++++++++++++- desktop/src-tauri/Cargo.toml | 3 + desktop/src-tauri/capabilities/default.json | 2 +- desktop/src-tauri/src/lib.rs | 156 +++- desktop/src/app/AppShell.tsx | 103 ++- desktop/src/features/channels/hooks.ts | 56 ++ desktop/src/features/chat/ui/ChatHeader.tsx | 24 +- .../src/features/chat/ui/MessageComposer.tsx | 61 +- .../src/features/chat/ui/MessageTimeline.tsx | 65 +- desktop/src/features/messages/hooks.ts | 179 ++++ .../messages/lib/formatTimelineMessages.ts | 52 ++ desktop/src/features/messages/types.ts | 9 + .../src/features/sidebar/ui/AppSidebar.tsx | 172 +++- desktop/src/main.tsx | 18 +- desktop/src/shared/api/hooks.ts | 11 + desktop/src/shared/api/relayClient.ts | 450 +++++++++ desktop/src/shared/api/tauri.ts | 65 ++ desktop/src/shared/api/types.ts | 26 + 20 files changed, 2252 insertions(+), 88 deletions(-) create mode 100644 desktop/src/features/channels/hooks.ts create mode 100644 desktop/src/features/messages/hooks.ts create mode 100644 desktop/src/features/messages/lib/formatTimelineMessages.ts create mode 100644 desktop/src/features/messages/types.ts create mode 100644 desktop/src/shared/api/hooks.ts create mode 100644 desktop/src/shared/api/relayClient.ts create mode 100644 desktop/src/shared/api/tauri.ts create mode 100644 desktop/src/shared/api/types.ts diff --git a/desktop/package.json b/desktop/package.json index c3802bf24..49bd2c7a4 100644 --- a/desktop/package.json +++ b/desktop/package.json @@ -20,6 +20,7 @@ "@radix-ui/react-separator": "^1.1.8", "@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-tooltip": "^1.2.8", + "@tanstack/react-query": "^5.90.21", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", "class-variance-authority": "^0.7.1", diff --git a/desktop/pnpm-lock.yaml b/desktop/pnpm-lock.yaml index d526619ec..fb955b1b0 100644 --- a/desktop/pnpm-lock.yaml +++ b/desktop/pnpm-lock.yaml @@ -23,6 +23,9 @@ importers: '@radix-ui/react-tooltip': specifier: ^1.2.8 version: 1.2.8(@types/react-dom@19.2.3(@types/react@19.2.14))(@types/react@19.2.14)(react-dom@19.2.4(react@19.2.4))(react@19.2.4) + '@tanstack/react-query': + specifier: ^5.90.21 + version: 5.90.21(react@19.2.4) '@tauri-apps/api': specifier: ^2 version: 2.10.1 @@ -900,6 +903,14 @@ packages: cpu: [x64] os: [win32] + '@tanstack/query-core@5.90.20': + resolution: {integrity: sha512-OMD2HLpNouXEfZJWcKeVKUgQ5n+n3A2JFmBaScpNDUqSrQSjiveC7dKMe53uJUg1nDG16ttFPz2xfilz6i2uVg==} + + '@tanstack/react-query@5.90.21': + resolution: {integrity: sha512-0Lu6y5t+tvlTJMTO7oh5NSpJfpg/5D41LlThfepTixPYkJ0sE2Jj0m0f6yYqujBwIXlId87e234+MxG3D3g7kg==} + peerDependencies: + react: ^18 || ^19 + '@tauri-apps/api@2.10.1': resolution: {integrity: sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==} @@ -2184,6 +2195,13 @@ snapshots: '@rollup/rollup-win32-x64-msvc@4.59.0': optional: true + '@tanstack/query-core@5.90.20': {} + + '@tanstack/react-query@5.90.21(react@19.2.4)': + dependencies: + '@tanstack/query-core': 5.90.20 + react: 19.2.4 + '@tauri-apps/api@2.10.1': {} '@tauri-apps/cli-darwin-arm64@2.10.1': diff --git a/desktop/src-tauri/Cargo.lock b/desktop/src-tauri/Cargo.lock index b87d82289..4d1ee376b 100644 --- a/desktop/src-tauri/Cargo.lock +++ b/desktop/src-tauri/Cargo.lock @@ -8,6 +8,16 @@ version = "2.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" +[[package]] +name = "aead" +version = "0.5.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d122413f284cf2d62fb1b7db97e02edb8cda96d769b16e443a4f6195e35662b0" +dependencies = [ + "crypto-common", + "generic-array", +] + [[package]] name = "aho-corasick" version = "1.1.4" @@ -47,6 +57,12 @@ version = "1.0.102" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +[[package]] +name = "arrayvec" +version = "0.7.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c02d123df017efcdfbd739ef81735b36c5ba83ec3c59c80a9d7ecc718f92e50" + [[package]] name = "async-broadcast" version = "0.7.2" @@ -213,6 +229,16 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "base58ck" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2c8d66485a3a2ea485c1913c4572ce0256067a5377ac8c75c4960e1cda98605f" +dependencies = [ + "bitcoin-internals", + "bitcoin_hashes", +] + [[package]] name = "base64" version = "0.21.7" @@ -225,6 +251,83 @@ version = "0.22.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6" +[[package]] +name = "base64ct" +version = "1.8.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06" + +[[package]] +name = "bech32" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "32637268377fc7b10a8c6d51de3e7fba1ce5dd371a96e342b34e6078db558e7f" + +[[package]] +name = "bip39" +version = "2.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90dbd31c98227229239363921e60fcf5e558e43ec69094d46fc4996f08d1d5bc" +dependencies = [ + "bitcoin_hashes", + "serde", + "unicode-normalization", +] + +[[package]] +name = "bitcoin" +version = "0.32.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1e499f9fc0407f50fe98af744ab44fa67d409f76b6772e1689ec8485eb0c0f66" +dependencies = [ + "base58ck", + "bech32", + "bitcoin-internals", + "bitcoin-io", + "bitcoin-units", + "bitcoin_hashes", + "hex-conservative", + "hex_lit", + "secp256k1", + "serde", +] + +[[package]] +name = "bitcoin-internals" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "30bdbe14aa07b06e6cfeffc529a1f099e5fbe249524f8125358604df99a4bed2" +dependencies = [ + "serde", +] + +[[package]] +name = "bitcoin-io" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2dee39a0ee5b4095224a0cfc6bf4cc1baf0f9624b96b367e53b66d974e51d953" + +[[package]] +name = "bitcoin-units" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5285c8bcaa25876d07f37e3d30c303f2609179716e11d688f51e8f1fe70063e2" +dependencies = [ + "bitcoin-internals", + "serde", +] + +[[package]] +name = "bitcoin_hashes" +version = "0.14.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26ec84b80c482df901772e931a9a681e26a1b9ee2302edeff23cb30328745c8b" +dependencies = [ + "bitcoin-io", + "hex-conservative", + "serde", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -249,6 +352,15 @@ dependencies = [ "generic-array", ] +[[package]] +name = "block-padding" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a8894febbff9f758034a5b8e12d87918f56dfc64a8e1fe757d65e29041538d93" +dependencies = [ + "generic-array", +] + [[package]] name = "block2" version = "0.6.2" @@ -386,6 +498,15 @@ dependencies = [ "toml 0.9.12+spec-1.1.0", ] +[[package]] +name = "cbc" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "26b52a9543ae338f279b96b0b9fed9c8093744685043739079ce85cd58f289a6" +dependencies = [ + "cipher", +] + [[package]] name = "cc" version = "1.2.56" @@ -429,6 +550,30 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "chacha20" +version = "0.9.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c3613f74bd2eac03dad61bd53dbe620703d4371614fe0bc3b9f04dd36fe4e818" +dependencies = [ + "cfg-if", + "cipher", + "cpufeatures", +] + +[[package]] +name = "chacha20poly1305" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "10cd79432192d1c0f4e1a0fef9527696cc039165d729fb41b3f4f4f354c2dc35" +dependencies = [ + "aead", + "chacha20", + "cipher", + "poly1305", + "zeroize", +] + [[package]] name = "chrono" version = "0.4.44" @@ -441,6 +586,17 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "cipher" +version = "0.4.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "773f3b9af64447d2ce9850330c473515014aa235e6a783b02db81ff39e4a3dad" +dependencies = [ + "crypto-common", + "inout", + "zeroize", +] + [[package]] name = "combine" version = "4.6.7" @@ -476,6 +632,16 @@ dependencies = [ "version_check", ] +[[package]] +name = "core-foundation" +version = "0.9.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "core-foundation" version = "0.10.1" @@ -499,9 +665,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "064badf302c3194842cf2c5d61f56cc88e54a759313879cdf03abdd27d0c3b97" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "core-graphics-types", - "foreign-types", + "foreign-types 0.5.0", "libc", ] @@ -512,7 +678,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb" dependencies = [ "bitflags 2.11.0", - "core-foundation", + "core-foundation 0.10.1", "libc", ] @@ -556,6 +722,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "78c8292055d1c1df0cce5d180393dc8cce0abec0a7102adb6c7b1eef6016d60a" dependencies = [ "generic-array", + "rand_core 0.6.4", "typenum", ] @@ -631,6 +798,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "data-encoding" +version = "2.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7a1e2f27636f116493b8b860f5546edb47c8d8f8ea73e1d2a20be88e28d1fea" + [[package]] name = "deranged" version = "0.5.8" @@ -662,6 +835,7 @@ checksum = "9ed9a281f7bc9b7576e61468ba615a66a5c8cfdff42420a70aa82701a3b1e292" dependencies = [ "block-buffer", "crypto-common", + "subtle", ] [[package]] @@ -787,6 +961,15 @@ version = "1.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ef6b89e5b37196644d8796de5268852ff179b44e96276cf4290264843743bb7" +[[package]] +name = "encoding_rs" +version = "0.8.35" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "75030f3c4f45dafd7586dd6780965a8c7e8e285a5ecb86713e63a79c5b2766f3" +dependencies = [ + "cfg-if", +] + [[package]] name = "endi" version = "1.1.1" @@ -915,6 +1098,15 @@ version = "0.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" +[[package]] +name = "foreign-types" +version = "0.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1" +dependencies = [ + "foreign-types-shared 0.1.1", +] + [[package]] name = "foreign-types" version = "0.5.0" @@ -922,7 +1114,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965" dependencies = [ "foreign-types-macros", - "foreign-types-shared", + "foreign-types-shared 0.3.1", ] [[package]] @@ -936,6 +1128,12 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "foreign-types-shared" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b" + [[package]] name = "foreign-types-shared" version = "0.3.1" @@ -1181,8 +1379,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ff2abc00be7fca6ebc474524697ae276ad847ad0a6b3faa4bcb027e9a4614ad0" dependencies = [ "cfg-if", + "js-sys", "libc", "wasi 0.11.1+wasi-snapshot-preview1", + "wasm-bindgen", ] [[package]] @@ -1358,6 +1558,25 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "h2" +version = "0.4.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2f44da3a8150a6703ed5d34e164b875fd14c2cdab9af1252a9a1020bde2bdc54" +dependencies = [ + "atomic-waker", + "bytes", + "fnv", + "futures-core", + "futures-sink", + "http", + "indexmap 2.13.0", + "slab", + "tokio", + "tokio-util", + "tracing", +] + [[package]] name = "hashbrown" version = "0.12.3" @@ -1403,6 +1622,30 @@ version = "0.4.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70" +[[package]] +name = "hex-conservative" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fda06d18ac606267c40c04e41b9947729bf8b9efe74bd4e82b61a5f26a510b9f" +dependencies = [ + "arrayvec", +] + +[[package]] +name = "hex_lit" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3011d1213f159867b13cfd6ac92d2cd5f1345762c63be3554e84092d85a50bbd" + +[[package]] +name = "hmac" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6c49c37c09c17a53d937dfbb742eb3a961d65a994e6bcdcf37e7399d0cc8ab5e" +dependencies = [ + "digest", +] + [[package]] name = "html5ever" version = "0.29.1" @@ -1464,6 +1707,7 @@ dependencies = [ "bytes", "futures-channel", "futures-core", + "h2", "http", "http-body", "httparse", @@ -1475,6 +1719,38 @@ dependencies = [ "want", ] +[[package]] +name = "hyper-rustls" +version = "0.27.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3c93eb611681b207e1fe55d5a71ecf91572ec8a6705cdb6857f7d8d5242cf58" +dependencies = [ + "http", + "hyper", + "hyper-util", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tower-service", +] + +[[package]] +name = "hyper-tls" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "70206fc6890eaca9fde8a0bf71caa2ddfc9fe045ac9e5c70df101a7dbde866e0" +dependencies = [ + "bytes", + "http-body-util", + "hyper", + "hyper-util", + "native-tls", + "tokio", + "tokio-native-tls", + "tower-service", +] + [[package]] name = "hyper-util" version = "0.1.20" @@ -1493,9 +1769,11 @@ dependencies = [ "percent-encoding", "pin-project-lite", "socket2", + "system-configuration", "tokio", "tower-service", "tracing", + "windows-registry", ] [[package]] @@ -1678,6 +1956,28 @@ dependencies = [ "cfb", ] +[[package]] +name = "inout" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" +dependencies = [ + "block-padding", + "generic-array", +] + +[[package]] +name = "instant" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0242819d153cba4b4b05a5a8f2a7e9bbf97b6055b2a002b395c96b5ff3c0222" +dependencies = [ + "cfg-if", + "js-sys", + "wasm-bindgen", + "web-sys", +] + [[package]] name = "ipnet" version = "2.12.0" @@ -2001,6 +2301,23 @@ dependencies = [ "windows-sys 0.60.2", ] +[[package]] +name = "native-tls" +version = "0.2.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "465500e14ea162429d264d44189adc38b199b62b1c21eea9f69e4b73cb03bbf2" +dependencies = [ + "libc", + "log", + "openssl", + "openssl-probe", + "openssl-sys", + "schannel", + "security-framework", + "security-framework-sys", + "tempfile", +] + [[package]] name = "ndk" version = "0.9.0" @@ -2031,6 +2348,18 @@ dependencies = [ "jni-sys", ] +[[package]] +name = "negentropy" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e664971378a3987224f7a0e10059782035e89899ae403718ee07de85bec42afe" + +[[package]] +name = "negentropy" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "43a88da9dd148bbcdce323dd6ac47d369b4769d4a3b78c6c52389b9269f77932" + [[package]] name = "new_debug_unreachable" version = "1.0.6" @@ -2043,6 +2372,32 @@ version = "0.1.14" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "72ef4a56884ca558e5ddb05a1d1e7e1bfd9a68d9ed024c21704cc98872dae1bb" +[[package]] +name = "nostr" +version = "0.37.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8aad4b767bbed24ac5eb4465bfb83bc1210522eb99d67cf4e547ec2ec7e47786" +dependencies = [ + "async-trait", + "base64 0.22.1", + "bech32", + "bip39", + "bitcoin", + "cbc", + "chacha20", + "chacha20poly1305", + "getrandom 0.2.17", + "instant", + "negentropy 0.3.1", + "negentropy 0.4.3", + "once_cell", + "scrypt", + "serde", + "serde_json", + "unicode-normalization", + "url", +] + [[package]] name = "num-conv" version = "0.2.0" @@ -2209,6 +2564,12 @@ version = "1.21.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d" +[[package]] +name = "opaque-debug" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c08d65885ee38876c4f86fa503fb49d7b507c2b62552df7c70b2fce627e06381" + [[package]] name = "open" version = "5.3.3" @@ -2221,6 +2582,50 @@ dependencies = [ "pathdiff", ] +[[package]] +name = "openssl" +version = "0.10.75" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328" +dependencies = [ + "bitflags 2.11.0", + "cfg-if", + "foreign-types 0.3.2", + "libc", + "once_cell", + "openssl-macros", + "openssl-sys", +] + +[[package]] +name = "openssl-macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.117", +] + +[[package]] +name = "openssl-probe" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7c87def4c32ab89d880effc9e097653c8da5d6ef28e6b539d313baaacfbafcbe" + +[[package]] +name = "openssl-sys" +version = "0.9.111" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321" +dependencies = [ + "cc", + "libc", + "pkg-config", + "vcpkg", +] + [[package]] name = "option-ext" version = "0.2.0" @@ -2291,12 +2696,33 @@ dependencies = [ "windows-link 0.2.1", ] +[[package]] +name = "password-hash" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "346f04948ba92c43e8469c1ee6736c7563d71012b17d40745260fe106aac2166" +dependencies = [ + "base64ct", + "rand_core 0.6.4", + "subtle", +] + [[package]] name = "pathdiff" version = "0.2.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "df94ce210e5bc13cb6651479fa48d14f601d9858cfe0467f43ae157023b938d3" +[[package]] +name = "pbkdf2" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8ed6a7761f76e3b9f92dfb0a60a6a6477c61024b775147ff0973a02653abaf2" +dependencies = [ + "digest", + "hmac", +] + [[package]] name = "percent-encoding" version = "2.3.2" @@ -2506,6 +2932,17 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "poly1305" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8159bd90725d2df49889a078b54f4f79e87f1f8a8444194cdca81d38f5393abf" +dependencies = [ + "cpufeatures", + "opaque-debug", + "universal-hash", +] + [[package]] name = "potential_utf" version = "0.1.4" @@ -2669,6 +3106,16 @@ dependencies = [ "rand_core 0.6.4", ] +[[package]] +name = "rand" +version = "0.9.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +dependencies = [ + "rand_chacha 0.9.0", + "rand_core 0.9.5", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -2683,10 +3130,20 @@ dependencies = [ name = "rand_chacha" version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +checksum = "e6c10a63a0fa32252be49d21e7709d4d4baf8d231c2dbce1eaa8141b9b127d88" +dependencies = [ + "ppv-lite86", + "rand_core 0.6.4", +] + +[[package]] +name = "rand_chacha" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb" dependencies = [ "ppv-lite86", - "rand_core 0.6.4", + "rand_core 0.9.5", ] [[package]] @@ -2707,6 +3164,15 @@ dependencies = [ "getrandom 0.2.17", ] +[[package]] +name = "rand_core" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c" +dependencies = [ + "getrandom 0.3.4", +] + [[package]] name = "rand_hc" version = "0.2.0" @@ -2800,6 +3266,46 @@ version = "0.8.10" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dc897dd8d9e8bd1ed8cdad82b5966c3e0ecae09fb1907d58efaa013543185d0a" +[[package]] +name = "reqwest" +version = "0.12.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eddd3ca559203180a307f12d114c268abf583f59b03cb906fd0b3ff8646c1147" +dependencies = [ + "base64 0.22.1", + "bytes", + "encoding_rs", + "futures-core", + "h2", + "http", + "http-body", + "http-body-util", + "hyper", + "hyper-rustls", + "hyper-tls", + "hyper-util", + "js-sys", + "log", + "mime", + "native-tls", + "percent-encoding", + "pin-project-lite", + "rustls-pki-types", + "serde", + "serde_json", + "serde_urlencoded", + "sync_wrapper", + "tokio", + "tokio-native-tls", + "tower", + "tower-http", + "tower-service", + "url", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + [[package]] name = "reqwest" version = "0.13.2" @@ -2834,6 +3340,20 @@ dependencies = [ "web-sys", ] +[[package]] +name = "ring" +version = "0.17.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a4689e6c2294d81e88dc6261c768b63bc4fcdb852be6d1352498b114f61383b7" +dependencies = [ + "cc", + "cfg-if", + "getrandom 0.2.17", + "libc", + "untrusted", + "windows-sys 0.52.0", +] + [[package]] name = "rustc_version" version = "0.4.1" @@ -2856,12 +3376,61 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "rustls" +version = "0.23.37" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +dependencies = [ + "once_cell", + "ring", + "rustls-pki-types", + "rustls-webpki", + "subtle", + "zeroize", +] + +[[package]] +name = "rustls-pki-types" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" +dependencies = [ + "zeroize", +] + +[[package]] +name = "rustls-webpki" +version = "0.103.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +dependencies = [ + "ring", + "rustls-pki-types", + "untrusted", +] + [[package]] name = "rustversion" version = "1.0.22" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b39cdef0fa800fc44525c84ccb54a029961a8215f9619753635a9c0d2538d46d" +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "salsa20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "97a22f5af31f73a954c10289c93e8a50cc23d971e80ee446f1f6f7137a088213" +dependencies = [ + "cipher", +] + [[package]] name = "same-file" version = "1.0.6" @@ -2871,6 +3440,15 @@ dependencies = [ "winapi-util", ] +[[package]] +name = "schannel" +version = "0.1.28" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1" +dependencies = [ + "windows-sys 0.61.2", +] + [[package]] name = "schemars" version = "0.8.22" @@ -2928,6 +3506,62 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49" +[[package]] +name = "scrypt" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0516a385866c09368f0b5bcd1caff3366aace790fcd46e2bb032697bb172fd1f" +dependencies = [ + "password-hash", + "pbkdf2", + "salsa20", + "sha2", +] + +[[package]] +name = "secp256k1" +version = "0.29.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9465315bc9d4566e1724f0fffcbcc446268cb522e60f9a27bcded6b19c108113" +dependencies = [ + "bitcoin_hashes", + "rand 0.8.5", + "secp256k1-sys", + "serde", +] + +[[package]] +name = "secp256k1-sys" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d4387882333d3aa8cb20530a17c69a3752e97837832f34f6dccc760e715001d9" +dependencies = [ + "cc", +] + +[[package]] +name = "security-framework" +version = "3.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.10.1", + "core-foundation-sys", + "libc", + "security-framework-sys", +] + +[[package]] +name = "security-framework-sys" +version = "2.17.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2691df843ecc5d231c0b14ece2acc3efb62c0a398c7e1d875f3983ce020e3" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "selectors" version = "0.24.0" @@ -3015,6 +3649,7 @@ version = "1.0.149" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86" dependencies = [ + "indexmap 2.13.0", "itoa", "memchr", "serde", @@ -3051,6 +3686,18 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_urlencoded" +version = "0.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d3491c14715ca2294c4d6a88f15e84739788c1d030eed8c110436aafdaa2f3fd" +dependencies = [ + "form_urlencoded", + "itoa", + "ryu", + "serde", +] + [[package]] name = "serde_with" version = "3.17.0" @@ -3114,6 +3761,17 @@ dependencies = [ "stable_deref_trait", ] +[[package]] +name = "sha1" +version = "0.10.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" +dependencies = [ + "cfg-if", + "cpufeatures", + "digest", +] + [[package]] name = "sha2" version = "0.10.9" @@ -3233,11 +3891,14 @@ dependencies = [ name = "sprout" version = "0.1.0" dependencies = [ + "nostr", + "reqwest 0.12.28", "serde", "serde_json", "tauri", "tauri-build", "tauri-plugin-opener", + "tauri-plugin-websocket", ] [[package]] @@ -3277,6 +3938,12 @@ version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "subtle" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" + [[package]] name = "swift-rs" version = "1.0.7" @@ -3330,6 +3997,27 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "system-configuration" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a13f3d0daba03132c0aa9767f98351b3488edc2c100cda2d2ec2b04f3d8d3c8b" +dependencies = [ + "bitflags 2.11.0", + "core-foundation 0.9.4", + "system-configuration-sys", +] + +[[package]] +name = "system-configuration-sys" +version = "0.6.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e1d1b10ced5ca923a1fcb8d03e96b8d3268065d724548c0211415ff6ac6bac4" +dependencies = [ + "core-foundation-sys", + "libc", +] + [[package]] name = "system-deps" version = "6.2.2" @@ -3351,7 +4039,7 @@ checksum = "6e06d52c379e63da659a483a958110bbde891695a0ecb53e48cc7786d5eda7bb" dependencies = [ "bitflags 2.11.0", "block2", - "core-foundation", + "core-foundation 0.10.1", "core-graphics", "crossbeam-channel", "dispatch2", @@ -3428,7 +4116,7 @@ dependencies = [ "percent-encoding", "plist", "raw-window-handle", - "reqwest", + "reqwest 0.13.2", "serde", "serde_json", "serde_repr", @@ -3551,6 +4239,26 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-websocket" +version = "2.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fe037be7e1c30be639fe12dc3077e8ec4709e6f30ca4a6016b7edc76232ae9ee" +dependencies = [ + "futures-util", + "http", + "log", + "rand 0.9.2", + "rustls", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", + "tokio", + "tokio-tungstenite", +] + [[package]] name = "tauri-runtime" version = "2.10.1" @@ -3756,6 +4464,21 @@ dependencies = [ "zerovec", ] +[[package]] +name = "tinyvec" +version = "1.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bfa5fdc3bce6191a1dbc8c02d5c8bffcf557bafa17c124c5264a458f1b0613fa" +dependencies = [ + "tinyvec_macros", +] + +[[package]] +name = "tinyvec_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" + [[package]] name = "tokio" version = "1.50.0" @@ -3770,6 +4493,42 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "tokio-native-tls" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbae76ab933c85776efabc971569dd6119c580d8f5d448769dec1764bf796ef2" +dependencies = [ + "native-tls", + "tokio", +] + +[[package]] +name = "tokio-rustls" +version = "0.26.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1729aa945f29d91ba541258c8df89027d5792d85a8841fb65e8bf0f4ede4ef61" +dependencies = [ + "rustls", + "tokio", +] + +[[package]] +name = "tokio-tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d25a406cddcc431a75d3d9afc6a7c0f7428d4891dd973e4d54c56b46127bf857" +dependencies = [ + "futures-util", + "log", + "rustls", + "rustls-pki-types", + "tokio", + "tokio-rustls", + "tungstenite", + "webpki-roots 0.26.11", +] + [[package]] name = "tokio-util" version = "0.7.18" @@ -3992,6 +4751,25 @@ version = "0.2.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" +[[package]] +name = "tungstenite" +version = "0.28.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8628dcc84e5a09eb3d8423d6cb682965dea9133204e8fb3efee74c2a0c259442" +dependencies = [ + "bytes", + "data-encoding", + "http", + "httparse", + "log", + "rand 0.9.2", + "rustls", + "rustls-pki-types", + "sha1", + "thiserror 2.0.18", + "utf-8", +] + [[package]] name = "typeid" version = "1.0.3" @@ -4062,6 +4840,15 @@ version = "1.0.24" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" +[[package]] +name = "unicode-normalization" +version = "0.1.25" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5fd4f6878c9cb28d874b009da9e8d183b5abc80117c40bbd187a1fde336be6e8" +dependencies = [ + "tinyvec", +] + [[package]] name = "unicode-segmentation" version = "1.12.0" @@ -4074,6 +4861,22 @@ version = "0.2.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" +[[package]] +name = "universal-hash" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc1de2c688dc15305988b563c3854064043356019f97a4b46276fe734c4f07ea" +dependencies = [ + "crypto-common", + "subtle", +] + +[[package]] +name = "untrusted" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ecb6da28b8a351d773b68d5825ac39017e680750f980f3a1a85cd8dd28a47c1" + [[package]] name = "url" version = "2.5.8" @@ -4123,6 +4926,12 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vcpkg" +version = "0.2.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426" + [[package]] name = "version-compare" version = "0.2.1" @@ -4364,6 +5173,24 @@ dependencies = [ "system-deps", ] +[[package]] +name = "webpki-roots" +version = "0.26.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "521bc38abb08001b01866da9f51eb7c5d647a19260e00054a8c7fd5f9e57f7a9" +dependencies = [ + "webpki-roots 1.0.6", +] + +[[package]] +name = "webpki-roots" +version = "1.0.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +dependencies = [ + "rustls-pki-types", +] + [[package]] name = "webview2-com" version = "0.38.2" @@ -4549,6 +5376,17 @@ dependencies = [ "windows-link 0.1.3", ] +[[package]] +name = "windows-registry" +version = "0.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "02752bf7fbdcce7f2a27a742f798510f3e5ad88dbe84871e5168e2120c3d5720" +dependencies = [ + "windows-link 0.2.1", + "windows-result 0.4.1", + "windows-strings 0.5.1", +] + [[package]] name = "windows-result" version = "0.3.4" @@ -4594,6 +5432,15 @@ dependencies = [ "windows-targets 0.42.2", ] +[[package]] +name = "windows-sys" +version = "0.52.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "282be5f36a8ce781fad8c8ae18fa3f9beff57ec1b52cb3de0789201425d9a33d" +dependencies = [ + "windows-targets 0.52.6", +] + [[package]] name = "windows-sys" version = "0.59.0" @@ -5138,6 +5985,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/desktop/src-tauri/Cargo.toml b/desktop/src-tauri/Cargo.toml index ee03a2837..3f31fcb35 100644 --- a/desktop/src-tauri/Cargo.toml +++ b/desktop/src-tauri/Cargo.toml @@ -20,5 +20,8 @@ tauri-build = { version = "2", features = [] } [dependencies] tauri = { version = "2", features = [] } tauri-plugin-opener = "2" +tauri-plugin-websocket = "2" serde = { version = "1", features = ["derive"] } serde_json = "1" +nostr = "0.37" +reqwest = { version = "0.12", features = ["json"] } diff --git a/desktop/src-tauri/capabilities/default.json b/desktop/src-tauri/capabilities/default.json index f77836402..99de5006c 100644 --- a/desktop/src-tauri/capabilities/default.json +++ b/desktop/src-tauri/capabilities/default.json @@ -3,5 +3,5 @@ "identifier": "default", "description": "Capability for the main window", "windows": ["main"], - "permissions": ["core:default", "opener:default"] + "permissions": ["core:default", "opener:default", "websocket:default"] } diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 4a277ef35..45a621acf 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,14 +1,162 @@ -// Learn more about Tauri commands at https://tauri.app/develop/calling-rust/ +use std::sync::Mutex; + +use nostr::{EventBuilder, JsonUtil, Keys, Kind, Tag, ToBech32}; +use serde::{Deserialize, Serialize}; + +pub struct AppState { + pub keys: Mutex, + pub http_client: reqwest::Client, +} + +#[derive(Serialize)] +pub struct IdentityInfo { + pub pubkey: String, + pub display_name: String, +} + +#[derive(Serialize, Deserialize)] +pub struct ChannelInfo { + pub id: String, + pub name: String, + pub channel_type: String, + pub description: String, + pub participants: Vec, + pub participant_pubkeys: Vec, +} + +fn relay_ws_url() -> String { + std::env::var("SPROUT_RELAY_URL").unwrap_or_else(|_| "ws://localhost:3000".to_string()) +} + +fn relay_api_base_url() -> String { + if let Ok(base) = std::env::var("SPROUT_RELAY_HTTP") { + return base; + } + + relay_ws_url() + .replace("wss://", "https://") + .replace("ws://", "http://") +} + +async fn build_authed_request( + client: &reqwest::Client, + path: &str, + state: &AppState, +) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + let pubkey_hex = keys.public_key().to_hex(); + let url = format!("{}{}", relay_api_base_url(), path); + + Ok(client.get(url).header("X-Pubkey", pubkey_hex)) +} + +#[tauri::command] +fn get_identity(state: tauri::State<'_, AppState>) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + let pubkey = keys.public_key(); + let pubkey_hex = pubkey.to_hex(); + let bech32 = pubkey + .to_bech32() + .map_err(|e| format!("bech32 encode failed: {e}"))?; + let display_name = if bech32.len() > 16 { + format!("{}…{}", &bech32[..10], &bech32[bech32.len() - 4..]) + } else { + bech32 + }; + + Ok(IdentityInfo { + pubkey: pubkey_hex, + display_name, + }) +} + #[tauri::command] -fn greet(name: &str) -> String { - format!("Hello, {}! You've been greeted from Rust!", name) +fn get_relay_ws_url() -> String { + relay_ws_url() +} + +#[tauri::command] +fn sign_event( + kind: u16, + content: String, + tags: Vec>, + state: tauri::State<'_, AppState>, +) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + + let nostr_tags = tags + .into_iter() + .map(|tag| Tag::parse(tag).map_err(|e| format!("invalid tag: {e}"))) + .collect::, _>>()?; + + let event = EventBuilder::new(Kind::Custom(kind), content) + .tags(nostr_tags) + .sign_with_keys(&keys) + .map_err(|e| format!("sign failed: {e}"))?; + + Ok(event.as_json()) +} + +#[tauri::command] +fn create_auth_event( + challenge: String, + relay_url: String, + state: tauri::State<'_, AppState>, +) -> Result { + let keys = state.keys.lock().map_err(|e| e.to_string())?; + + let tags = vec![ + Tag::parse(vec!["relay", &relay_url]).map_err(|e| format!("relay tag failed: {e}"))?, + Tag::parse(vec!["challenge", &challenge]) + .map_err(|e| format!("challenge tag failed: {e}"))?, + ]; + + let event = EventBuilder::new(Kind::Custom(22242), "") + .tags(tags) + .sign_with_keys(&keys) + .map_err(|e| format!("sign failed: {e}"))?; + + Ok(event.as_json()) +} + +#[tauri::command] +async fn get_channels(state: tauri::State<'_, AppState>) -> Result, String> { + let request = build_authed_request(&state.http_client, "/api/channels", &state).await?; + let response = request + .send() + .await + .map_err(|e| format!("request failed: {e}"))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + return Err(format!("relay returned {status}: {body}")); + } + + response + .json::>() + .await + .map_err(|e| format!("parse failed: {e}")) } #[cfg_attr(mobile, tauri::mobile_entry_point)] pub fn run() { + let app_state = AppState { + keys: Mutex::new(Keys::generate()), + http_client: reqwest::Client::new(), + }; + tauri::Builder::default() .plugin(tauri_plugin_opener::init()) - .invoke_handler(tauri::generate_handler![greet]) + .plugin(tauri_plugin_websocket::init()) + .manage(app_state) + .invoke_handler(tauri::generate_handler![ + get_identity, + get_relay_ws_url, + sign_event, + create_auth_event, + get_channels, + ]) .run(tauri::generate_context!()) .expect("error while running tauri application"); } diff --git a/desktop/src/app/AppShell.tsx b/desktop/src/app/AppShell.tsx index d8a503957..52ea80d61 100644 --- a/desktop/src/app/AppShell.tsx +++ b/desktop/src/app/AppShell.tsx @@ -1,24 +1,115 @@ -import { currentChannel, messages } from "@/features/chat/data/chatData"; +import * as React from "react"; + import { ChatHeader } from "@/features/chat/ui/ChatHeader"; +import { + useChannelsQuery, + useSelectedChannel, +} from "@/features/channels/hooks"; import { MessageComposer } from "@/features/chat/ui/MessageComposer"; import { MessageTimeline } from "@/features/chat/ui/MessageTimeline"; +import { + useChannelMessagesQuery, + useChannelSubscription, + useSendMessageMutation, +} from "@/features/messages/hooks"; +import { formatTimelineMessages } from "@/features/messages/lib/formatTimelineMessages"; import { AppSidebar } from "@/features/sidebar/ui/AppSidebar"; +import { useIdentityQuery } from "@/shared/api/hooks"; import { SidebarInset, SidebarProvider } from "@/shared/ui/sidebar"; export function AppShell() { + const identityQuery = useIdentityQuery(); + const channelsQuery = useChannelsQuery(); + const channels = channelsQuery.data ?? []; + const { selectedChannel, setSelectedChannelId } = useSelectedChannel( + channels, + null, + ); + + const messagesQuery = useChannelMessagesQuery(selectedChannel); + useChannelSubscription(selectedChannel); + + const sendMessageMutation = useSendMessageMutation( + selectedChannel, + identityQuery.data, + ); + + const timelineMessages = React.useMemo( + () => + formatTimelineMessages( + messagesQuery.data ?? [], + selectedChannel, + identityQuery.data?.pubkey, + ), + [identityQuery.data?.pubkey, messagesQuery.data, selectedChannel], + ); + + const channelDescription = selectedChannel + ? selectedChannel.channelType === "forum" + ? `${selectedChannel.description} Forum channels are listed, but this first pass only wires message streams and DMs.` + : selectedChannel.description + : "Connect to the relay to browse channels and read messages."; + return ( - + { + React.startTransition(() => setSelectedChannelId(channelId)); + }} + selectedChannelId={selectedChannel?.id ?? null} + />
- - + + { + await sendMessageMutation.mutateAsync(content); + }} + placeholder={ + selectedChannel?.channelType === "forum" + ? "Forum posting is not wired in this pass." + : selectedChannel + ? `Message #${selectedChannel.name}` + : "Select a channel" + } + />
diff --git a/desktop/src/features/channels/hooks.ts b/desktop/src/features/channels/hooks.ts new file mode 100644 index 000000000..47557724e --- /dev/null +++ b/desktop/src/features/channels/hooks.ts @@ -0,0 +1,56 @@ +import * as React from "react"; +import { useQuery } from "@tanstack/react-query"; + +import { getChannels } from "@/shared/api/tauri"; +import type { Channel } from "@/shared/api/types"; + +export function useChannelsQuery() { + return useQuery({ + queryKey: ["channels"], + queryFn: getChannels, + staleTime: 30_000, + }); +} + +export function useSelectedChannel( + channels: Channel[], + preferredChannelId: string | null, +) { + const [selectedChannelId, setSelectedChannelId] = React.useState< + string | null + >(preferredChannelId); + + const selectedChannel = React.useMemo( + () => + channels.find((channel) => channel.id === selectedChannelId) ?? + channels.find((channel) => channel.channelType !== "forum") ?? + channels[0] ?? + null, + [channels, selectedChannelId], + ); + + React.useEffect(() => { + if (!selectedChannel && channels.length === 0) { + return; + } + + if (!selectedChannelId && selectedChannel) { + setSelectedChannelId(selectedChannel.id); + return; + } + + if ( + selectedChannelId && + !channels.some((channel) => channel.id === selectedChannelId) && + selectedChannel + ) { + setSelectedChannelId(selectedChannel.id); + } + }, [channels, selectedChannel, selectedChannelId]); + + return { + selectedChannel, + selectedChannelId, + setSelectedChannelId, + }; +} diff --git a/desktop/src/features/chat/ui/ChatHeader.tsx b/desktop/src/features/chat/ui/ChatHeader.tsx index 1c9a0dde7..4a0b099d5 100644 --- a/desktop/src/features/chat/ui/ChatHeader.tsx +++ b/desktop/src/features/chat/ui/ChatHeader.tsx @@ -1,20 +1,38 @@ -import { Hash } from "lucide-react"; +import { CircleDot, FileText, Hash } from "lucide-react"; +import type { ChannelType } from "@/shared/api/types"; import { SidebarTrigger } from "@/shared/ui/sidebar"; type ChatHeaderProps = { title: string; description: string; + channelType?: ChannelType; }; -export function ChatHeader({ title, description }: ChatHeaderProps) { +function ChannelIcon({ channelType }: { channelType?: ChannelType }) { + if (channelType === "dm") { + return ; + } + + if (channelType === "forum") { + return ; + } + + return ; +} + +export function ChatHeader({ + title, + description, + channelType, +}: ChatHeaderProps) { return (
- +

{title}

diff --git a/desktop/src/features/chat/ui/MessageComposer.tsx b/desktop/src/features/chat/ui/MessageComposer.tsx index 55cd5920f..34c59bb5b 100644 --- a/desktop/src/features/chat/ui/MessageComposer.tsx +++ b/desktop/src/features/chat/ui/MessageComposer.tsx @@ -1,39 +1,84 @@ import { Paperclip, SendHorizontal, SmilePlus } from "lucide-react"; +import * as React from "react"; import { Button } from "@/shared/ui/button"; import { Input } from "@/shared/ui/input"; type MessageComposerProps = { channelName: string; + disabled?: boolean; + isSending?: boolean; + onSend: (content: string) => Promise; + placeholder?: string; }; -export function MessageComposer({ channelName }: MessageComposerProps) { +export function MessageComposer({ + channelName, + disabled = false, + isSending = false, + onSend, + placeholder, +}: MessageComposerProps) { + const [content, setContent] = React.useState(""); + + const handleSubmit = React.useCallback( + async (event: React.FormEvent) => { + event.preventDefault(); + + const trimmed = content.trim(); + if (!trimmed || disabled || isSending) { + return; + } + + setContent(""); + + try { + await onSend(trimmed); + } catch { + setContent(trimmed); + } + }, + [content, disabled, isSending, onSend], + ); + return (
-
+
{ + void handleSubmit(event); + }} + > setContent(event.target.value)} + placeholder={placeholder ?? `Message #${channelName}`} + value={content} />
- -
-
-
+
); diff --git a/desktop/src/features/chat/ui/MessageTimeline.tsx b/desktop/src/features/chat/ui/MessageTimeline.tsx index fc442957f..a7240fc77 100644 --- a/desktop/src/features/chat/ui/MessageTimeline.tsx +++ b/desktop/src/features/chat/ui/MessageTimeline.tsx @@ -1,12 +1,16 @@ -import type { Message } from "@/features/chat/data/chatData"; +import type { TimelineMessage } from "@/features/messages/types"; import { cn } from "@/shared/lib/cn"; import { Separator } from "@/shared/ui/separator"; +import { Skeleton } from "@/shared/ui/skeleton"; type MessageTimelineProps = { - messages: Message[]; + messages: TimelineMessage[]; + isLoading?: boolean; + emptyTitle?: string; + emptyDescription?: string; }; -function MessageRow({ message }: { message: Message }) { +function MessageRow({ message }: { message: TimelineMessage }) { const initials = message.author .split(" ") .map((part) => part[0]) @@ -34,6 +38,11 @@ function MessageRow({ message }: { message: Message }) { {message.role}

{message.time}

+ {message.pending ? ( +

+ Sending +

+ ) : null}

{message.body} @@ -43,7 +52,31 @@ function MessageRow({ message }: { message: Message }) { ); } -export function MessageTimeline({ messages }: MessageTimelineProps) { +function TimelineSkeleton() { + const skeletonRows = ["first", "second", "third", "fourth"]; + + return ( + <> + {skeletonRows.map((row) => ( +

+ +
+ + + +
+
+ ))} + + ); +} + +export function MessageTimeline({ + messages, + isLoading = false, + emptyTitle = "No messages yet", + emptyDescription = "Send the first message to start the thread.", +}: MessageTimelineProps) { return (
@@ -55,12 +88,24 @@ export function MessageTimeline({ messages }: MessageTimelineProps) {
- {messages.map((message) => ( - - ))} + {isLoading ? : null} + + {!isLoading && messages.length === 0 ? ( +
+

+ {emptyTitle} +

+

+ {emptyDescription} +

+
+ ) : null} + + {!isLoading + ? messages.map((message) => ( + + )) + : null}
); diff --git a/desktop/src/features/messages/hooks.ts b/desktop/src/features/messages/hooks.ts new file mode 100644 index 000000000..e782e6172 --- /dev/null +++ b/desktop/src/features/messages/hooks.ts @@ -0,0 +1,179 @@ +import { useEffect, useEffectEvent } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; + +import { relayClient } from "@/shared/api/relayClient"; +import type { Channel, Identity, RelayEvent } from "@/shared/api/types"; + +type MessageQueryContext = { + optimisticId: string; + previousMessages: RelayEvent[]; + queryKey: readonly ["channel-messages", string]; +}; + +function mergeMessages( + current: RelayEvent[], + incoming: RelayEvent, +): RelayEvent[] { + const deduped = current.filter( + (message) => + message.id !== incoming.id && + !(message.pending && incoming.content === message.content), + ); + + return [...deduped, incoming].sort( + (left, right) => left.created_at - right.created_at, + ); +} + +function createOptimisticMessage( + channelId: string, + content: string, + identity: Identity, +): RelayEvent { + return { + id: `optimistic-${crypto.randomUUID()}`, + pubkey: identity.pubkey, + created_at: Math.floor(Date.now() / 1_000), + kind: 4_0001, + tags: [["e", channelId]], + content, + sig: "", + pending: true, + }; +} + +export function useChannelMessagesQuery(channel: Channel | null) { + return useQuery({ + enabled: channel !== null && channel.channelType !== "forum", + queryKey: ["channel-messages", channel?.id ?? "none"], + queryFn: async () => { + if (!channel) { + throw new Error("No channel selected."); + } + + return relayClient.fetchChannelHistory(channel.id); + }, + staleTime: Number.POSITIVE_INFINITY, + gcTime: 30 * 60 * 1_000, + }); +} + +export function useChannelSubscription(channel: Channel | null) { + const queryClient = useQueryClient(); + + const appendMessage = useEffectEvent((event: RelayEvent) => { + if (!channel) { + return; + } + + queryClient.setQueryData( + ["channel-messages", channel.id], + (current = []) => mergeMessages(current, event), + ); + }); + + useEffect(() => { + if (!channel || channel.channelType === "forum") { + return; + } + + let isDisposed = false; + let cleanup: (() => Promise) | undefined; + + relayClient + .subscribeToChannel(channel.id, (event) => { + if (!isDisposed) { + appendMessage(event); + } + }) + .then((dispose) => { + if (isDisposed) { + void dispose(); + return; + } + + cleanup = dispose; + }) + .catch((error) => { + console.error("Failed to subscribe to channel", channel.id, error); + }); + + return () => { + isDisposed = true; + if (cleanup) { + void cleanup(); + } + }; + }, [channel]); +} + +export function useSendMessageMutation( + channel: Channel | null, + identity: Identity | undefined, +) { + const queryClient = useQueryClient(); + + return useMutation< + RelayEvent, + Error, + string, + MessageQueryContext | undefined + >({ + mutationFn: async (content) => { + if (!channel || channel.channelType === "forum") { + throw new Error("This channel does not support message sending yet."); + } + + return relayClient.sendMessage(channel.id, content); + }, + onMutate: async (content) => { + if (!channel || !identity || channel.channelType === "forum") { + return undefined; + } + + const queryKey = ["channel-messages", channel.id] as const; + await queryClient.cancelQueries({ queryKey }); + + const previousMessages = + queryClient.getQueryData(queryKey) ?? []; + const optimisticMessage = createOptimisticMessage( + channel.id, + content.trim(), + identity, + ); + + queryClient.setQueryData( + queryKey, + mergeMessages(previousMessages, optimisticMessage), + ); + + return { + optimisticId: optimisticMessage.id, + previousMessages, + queryKey, + }; + }, + onError: (_error, _content, context) => { + if (!context) { + return; + } + + queryClient.setQueryData(context.queryKey, context.previousMessages); + }, + onSuccess: (message, _content, context) => { + if (!context) { + return; + } + + queryClient.setQueryData( + context.queryKey, + (current = []) => { + const withoutOptimistic = current.filter( + (item) => item.id !== context.optimisticId, + ); + return mergeMessages(withoutOptimistic, message); + }, + ); + }, + }); +} diff --git a/desktop/src/features/messages/lib/formatTimelineMessages.ts b/desktop/src/features/messages/lib/formatTimelineMessages.ts new file mode 100644 index 000000000..631de6e15 --- /dev/null +++ b/desktop/src/features/messages/lib/formatTimelineMessages.ts @@ -0,0 +1,52 @@ +import type { Channel, RelayEvent } from "@/shared/api/types"; + +import type { TimelineMessage } from "@/features/messages/types"; + +function truncatePubkey(pubkey: string) { + return `${pubkey.slice(0, 8)}…${pubkey.slice(-4)}`; +} + +function formatMessageAuthor( + event: RelayEvent, + channel: Channel | null, + currentPubkey: string | undefined, +) { + if (currentPubkey && event.pubkey === currentPubkey) { + return "You"; + } + + if (channel?.channelType === "dm") { + const participantIndex = channel.participantPubkeys.indexOf(event.pubkey); + if (participantIndex >= 0) { + return ( + channel.participants[participantIndex] ?? truncatePubkey(event.pubkey) + ); + } + } + + return truncatePubkey(event.pubkey); +} + +export function formatTimelineMessages( + events: RelayEvent[], + channel: Channel | null, + currentPubkey: string | undefined, +): TimelineMessage[] { + return events.map((event) => ({ + id: event.id, + author: formatMessageAuthor(event, channel, currentPubkey), + role: + currentPubkey && event.pubkey === currentPubkey + ? "Local" + : channel?.channelType === "dm" + ? "Participant" + : "Pubkey", + time: new Intl.DateTimeFormat("en-US", { + hour: "numeric", + minute: "2-digit", + }).format(new Date(event.created_at * 1_000)), + body: event.content, + accent: currentPubkey === event.pubkey, + pending: event.pending, + })); +} diff --git a/desktop/src/features/messages/types.ts b/desktop/src/features/messages/types.ts new file mode 100644 index 000000000..298ae3da2 --- /dev/null +++ b/desktop/src/features/messages/types.ts @@ -0,0 +1,9 @@ +export type TimelineMessage = { + id: string; + author: string; + role: string; + time: string; + body: string; + accent?: boolean; + pending?: boolean; +}; diff --git a/desktop/src/features/sidebar/ui/AppSidebar.tsx b/desktop/src/features/sidebar/ui/AppSidebar.tsx index fc88d584a..576335f45 100644 --- a/desktop/src/features/sidebar/ui/AppSidebar.tsx +++ b/desktop/src/features/sidebar/ui/AppSidebar.tsx @@ -1,72 +1,76 @@ -import { CircleDot, Hash, Lock, Plus } from "lucide-react"; +import { CircleDot, FileText, Hash } from "lucide-react"; +import * as React from "react"; -import { - sidebarSections, - type Channel, - type ChannelSection, -} from "@/features/sidebar/data/sidebarData"; +import type { Channel } from "@/shared/api/types"; import { ThemeToggle } from "@/shared/theme/ThemeToggle"; import { Sidebar, SidebarContent, SidebarFooter, SidebarGroup, - SidebarGroupAction, SidebarGroupContent, SidebarGroupLabel, SidebarHeader, SidebarInput, SidebarMenu, - SidebarMenuBadge, SidebarMenuButton, SidebarMenuItem, + SidebarMenuSkeleton, SidebarSeparator, } from "@/shared/ui/sidebar"; -function SidebarChannelIcon({ variant }: Pick) { - if (variant === "private") { - return ; - } +type AppSidebarProps = { + channels: Channel[]; + isLoading: boolean; + errorMessage?: string; + selectedChannelId: string | null; + onSelectChannel: (channelId: string) => void; +}; - if (variant === "direct") { +function SidebarChannelIcon({ channel }: { channel: Channel }) { + if (channel.channelType === "dm") { return ; } + if (channel.channelType === "forum") { + return ; + } + return ; } -function SidebarChannelItem({ channel }: { channel: Channel }) { - return ( - - - - {channel.name} - - {channel.unread ? ( - {channel.unread} - ) : null} - - ); -} +function SidebarSection({ + items, + selectedChannelId, + title, + onSelectChannel, +}: { + items: Channel[]; + selectedChannelId: string | null; + title: string; + onSelectChannel: (channelId: string) => void; +}) { + if (items.length === 0) { + return null; + } -function SidebarChannelSection({ section }: { section: ChannelSection }) { return ( - {section.title} - - - + {title} - {section.items.map((channel) => ( - + {items.map((channel) => ( + + onSelectChannel(channel.id)} + tooltip={channel.name} + type="button" + > + + {channel.name} + + ))} @@ -74,7 +78,37 @@ function SidebarChannelSection({ section }: { section: ChannelSection }) { ); } -export function AppSidebar() { +export function AppSidebar({ + channels, + isLoading, + errorMessage, + selectedChannelId, + onSelectChannel, +}: AppSidebarProps) { + const skeletonRows = ["first", "second", "third", "fourth", "fifth", "sixth"]; + const [query, setQuery] = React.useState(""); + const deferredQuery = React.useDeferredValue(query.trim().toLowerCase()); + + const filteredChannels = React.useMemo(() => { + if (!deferredQuery) { + return channels; + } + + return channels.filter((channel) => + channel.name.toLowerCase().includes(deferredQuery), + ); + }, [channels, deferredQuery]); + + const streamChannels = filteredChannels.filter( + (channel) => channel.channelType === "stream", + ); + const forumChannels = filteredChannels.filter( + (channel) => channel.channelType === "forum", + ); + const directMessages = filteredChannels.filter( + (channel) => channel.channelType === "dm", + ); + return ( @@ -89,15 +123,63 @@ export function AppSidebar() {

- + setQuery(event.target.value)} + placeholder="Jump to channel" + value={query} + />
- {sidebarSections.map((section) => ( - - ))} + {isLoading ? ( + + Channels + + + {skeletonRows.map((row) => ( + + ))} + + + + ) : null} + + {!isLoading ? ( + <> + + + + + ) : null} + + {!isLoading && filteredChannels.length === 0 ? ( +
+ No channels match that filter. +
+ ) : null} + + {errorMessage ? ( +
+ {errorMessage} +
+ ) : null}
diff --git a/desktop/src/main.tsx b/desktop/src/main.tsx index 8be4e558e..309fde463 100644 --- a/desktop/src/main.tsx +++ b/desktop/src/main.tsx @@ -1,13 +1,25 @@ import React from "react"; import ReactDOM from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { App } from "@/app/App"; import "@/shared/styles/globals.css"; import { ThemeProvider } from "@/shared/theme/ThemeProvider"; +const queryClient = new QueryClient({ + defaultOptions: { + queries: { + retry: 1, + refetchOnWindowFocus: false, + }, + }, +}); + ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render( - - - + + + + + , ); diff --git a/desktop/src/shared/api/hooks.ts b/desktop/src/shared/api/hooks.ts new file mode 100644 index 000000000..5193746c9 --- /dev/null +++ b/desktop/src/shared/api/hooks.ts @@ -0,0 +1,11 @@ +import { useQuery } from "@tanstack/react-query"; + +import { getIdentity } from "@/shared/api/tauri"; + +export function useIdentityQuery() { + return useQuery({ + queryKey: ["identity"], + queryFn: getIdentity, + staleTime: Number.POSITIVE_INFINITY, + }); +} diff --git a/desktop/src/shared/api/relayClient.ts b/desktop/src/shared/api/relayClient.ts new file mode 100644 index 000000000..c726e7e92 --- /dev/null +++ b/desktop/src/shared/api/relayClient.ts @@ -0,0 +1,450 @@ +import { Channel, invoke } from "@tauri-apps/api/core"; + +import { + createAuthEvent, + getRelayWsUrl, + signRelayEvent, +} from "@/shared/api/tauri"; +import type { RelayEvent } from "@/shared/api/types"; + +type RelaySubscriptionFilter = { + kinds: number[]; + "#e": string[]; + limit: number; +}; + +type HistorySubscription = { + mode: "history"; + events: RelayEvent[]; + resolve: (events: RelayEvent[]) => void; + reject: (error: Error) => void; + timeout: ReturnType; +}; + +type LiveSubscription = { + mode: "live"; + onEvent: (event: RelayEvent) => void; +}; + +type PendingEvent = { + event: RelayEvent; + resolve: (event: RelayEvent) => void; + reject: (error: Error) => void; + timeout: ReturnType; +}; + +type RelaySubscription = HistorySubscription | LiveSubscription; + +function sortEvents(events: RelayEvent[]) { + return [...events].sort((left, right) => left.created_at - right.created_at); +} + +function getTextPayload(message: unknown) { + if (typeof message === "string") { + return message; + } + + if ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === "Text" && + "data" in message && + typeof message.data === "string" + ) { + return message.data; + } + + if ( + typeof message === "object" && + message !== null && + "Text" in message && + typeof message.Text === "string" + ) { + return message.Text; + } + + return null; +} + +class RelayClient { + private wsId: number | null = null; + private relayUrl: string | null = null; + private connectPromise: Promise | null = null; + private authRequest: { + pendingEventId: string; + resolve: () => void; + reject: (error: Error) => void; + timeout: ReturnType; + } | null = null; + private subscriptions = new Map(); + private pendingEvents = new Map(); + private activeLiveSubscriptionId: string | null = null; + + async fetchChannelHistory(channelId: string, limit = 50) { + await this.ensureConnected(); + + return new Promise((resolve, reject) => { + const subId = `history-${crypto.randomUUID()}`; + const timeout = window.setTimeout(() => { + this.subscriptions.delete(subId); + void this.closeSubscription(subId); + reject(new Error("Timed out while loading channel history.")); + }, 8_000); + + this.subscriptions.set(subId, { + mode: "history", + events: [], + resolve, + reject, + timeout, + }); + + void this.sendRaw([ + "REQ", + subId, + this.buildChannelFilter(channelId, limit), + ]).catch((error) => { + window.clearTimeout(timeout); + this.subscriptions.delete(subId); + reject( + error instanceof Error + ? error + : new Error("Failed to request channel history."), + ); + }); + }); + } + + async sendMessage(channelId: string, content: string) { + await this.ensureConnected(); + + const event = await signRelayEvent({ + kind: 40001, + content: content.trim(), + tags: [["e", channelId]], + }); + + return new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + this.pendingEvents.delete(event.id); + reject(new Error("Timed out while sending the message.")); + }, 8_000); + + this.pendingEvents.set(event.id, { + event, + resolve, + reject, + timeout, + }); + + void this.sendRaw(["EVENT", event]).catch((error) => { + window.clearTimeout(timeout); + this.pendingEvents.delete(event.id); + reject( + error instanceof Error + ? error + : new Error("Failed to send the message."), + ); + }); + }); + } + + async subscribeToChannel( + channelId: string, + onEvent: (event: RelayEvent) => void, + ) { + await this.ensureConnected(); + + const subId = `live-${crypto.randomUUID()}`; + const previousSubscriptionId = this.activeLiveSubscriptionId; + this.activeLiveSubscriptionId = subId; + + if (previousSubscriptionId) { + this.subscriptions.delete(previousSubscriptionId); + await this.closeSubscription(previousSubscriptionId); + } + + this.subscriptions.set(subId, { + mode: "live", + onEvent, + }); + + await this.sendRaw(["REQ", subId, this.buildChannelFilter(channelId, 50)]); + + return async () => { + const active = this.subscriptions.get(subId); + if (!active || active.mode !== "live") { + return; + } + + this.subscriptions.delete(subId); + if (this.activeLiveSubscriptionId === subId) { + this.activeLiveSubscriptionId = null; + } + await this.closeSubscription(subId); + }; + } + + private async ensureConnected() { + if (this.connectPromise) { + return this.connectPromise; + } + + if (this.wsId !== null) { + return; + } + + this.connectPromise = this.connect(); + + try { + await this.connectPromise; + } finally { + this.connectPromise = null; + } + } + + private async connect() { + if (!this.relayUrl) { + this.relayUrl = await getRelayWsUrl(); + } + + const onMessageChannel = new Channel((message) => { + void this.handleWsMessage(message); + }); + + this.wsId = await invoke("plugin:websocket|connect", { + url: this.relayUrl, + onMessage: onMessageChannel, + config: {}, + }); + + await new Promise((resolve, reject) => { + const timeout = window.setTimeout(() => { + this.authRequest = null; + this.resetConnection( + new Error("Timed out while waiting for relay authentication."), + ); + reject(new Error("Timed out while waiting for relay authentication.")); + }, 8_000); + + this.authRequest = { + pendingEventId: "", + resolve, + reject, + timeout, + }; + }); + } + + private buildChannelFilter( + channelId: string, + limit: number, + ): RelaySubscriptionFilter { + return { + kinds: [40001], + "#e": [channelId], + limit, + }; + } + + private async sendRaw(payload: unknown[]) { + if (this.wsId === null) { + throw new Error("Relay socket is not connected."); + } + + await invoke("plugin:websocket|send", { + id: this.wsId, + message: { + type: "Text", + data: JSON.stringify(payload), + }, + }); + } + + private async closeSubscription(subId: string) { + if (this.wsId === null) { + return; + } + + await this.sendRaw(["CLOSE", subId]); + } + + private async handleWsMessage(message: unknown) { + if ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === "Close" + ) { + this.resetConnection(new Error("Relay connection closed.")); + return; + } + + if ( + typeof message === "object" && + message !== null && + "type" in message && + message.type === "Error" + ) { + this.resetConnection(new Error("Relay connection errored.")); + return; + } + + const payload = getTextPayload(message); + if (!payload) { + return; + } + + let data: unknown; + try { + data = JSON.parse(payload); + } catch { + return; + } + + if (!Array.isArray(data) || data.length === 0) { + return; + } + + const [type, ...rest] = data; + if (type === "AUTH" && typeof rest[0] === "string") { + await this.handleAuthChallenge(rest[0]); + return; + } + + if (type === "EVENT" && typeof rest[0] === "string" && rest[1]) { + this.handleEvent(rest[0], rest[1] as RelayEvent); + return; + } + + if ( + type === "OK" && + typeof rest[0] === "string" && + typeof rest[1] === "boolean" + ) { + this.handleOk( + rest[0], + rest[1], + typeof rest[2] === "string" ? rest[2] : "", + ); + return; + } + + if (type === "EOSE" && typeof rest[0] === "string") { + this.handleEose(rest[0]); + } + } + + private async handleAuthChallenge(challenge: string) { + if (!this.relayUrl) { + this.relayUrl = await getRelayWsUrl(); + } + + const event = await createAuthEvent({ + challenge, + relayUrl: this.relayUrl, + }); + + if (!this.authRequest) { + return; + } + + this.authRequest.pendingEventId = event.id; + await this.sendRaw(["AUTH", event]); + } + + private handleEvent(subId: string, event: RelayEvent) { + const subscription = this.subscriptions.get(subId); + if (!subscription) { + return; + } + + if (subscription.mode === "history") { + subscription.events.push(event); + return; + } + + subscription.onEvent(event); + } + + private handleEose(subId: string) { + const subscription = this.subscriptions.get(subId); + if (!subscription || subscription.mode !== "history") { + return; + } + + window.clearTimeout(subscription.timeout); + this.subscriptions.delete(subId); + void this.closeSubscription(subId); + subscription.resolve(sortEvents(subscription.events)); + } + + private handleOk(eventId: string, success: boolean, message: string) { + if (this.authRequest && this.authRequest.pendingEventId === eventId) { + window.clearTimeout(this.authRequest.timeout); + const authRequest = this.authRequest; + this.authRequest = null; + + if (success) { + authRequest.resolve(); + } else { + const error = new Error(message || "Relay authentication rejected."); + authRequest.reject(error); + this.resetConnection(error); + } + + return; + } + + const pendingEvent = this.pendingEvents.get(eventId); + if (!pendingEvent) { + return; + } + + window.clearTimeout(pendingEvent.timeout); + this.pendingEvents.delete(eventId); + + if (success) { + pendingEvent.resolve(pendingEvent.event); + } else { + pendingEvent.reject(new Error(message || "Relay rejected the event.")); + } + } + + private resetConnection(error: Error) { + if (this.wsId !== null) { + void invoke("plugin:websocket|disconnect", { id: this.wsId }).catch( + () => { + return; + }, + ); + } + + this.wsId = null; + + if (this.authRequest) { + window.clearTimeout(this.authRequest.timeout); + this.authRequest.reject(error); + this.authRequest = null; + } + + for (const [subId, subscription] of this.subscriptions) { + if (subscription.mode === "history") { + window.clearTimeout(subscription.timeout); + subscription.reject(error); + } + this.subscriptions.delete(subId); + } + + for (const [eventId, pendingEvent] of this.pendingEvents) { + window.clearTimeout(pendingEvent.timeout); + pendingEvent.reject(error); + this.pendingEvents.delete(eventId); + } + + this.activeLiveSubscriptionId = null; + } +} + +export const relayClient = new RelayClient(); diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts new file mode 100644 index 000000000..4f676094e --- /dev/null +++ b/desktop/src/shared/api/tauri.ts @@ -0,0 +1,65 @@ +import { invoke } from "@tauri-apps/api/core"; + +import type { + Channel, + ChannelType, + Identity, + RelayEvent, +} from "@/shared/api/types"; + +type RawIdentity = { + pubkey: string; + display_name: string; +}; + +type RawChannel = { + id: string; + name: string; + channel_type: ChannelType; + description: string; + participants: string[]; + participant_pubkeys: string[]; +}; + +export async function getIdentity(): Promise { + const identity = await invoke("get_identity"); + + return { + pubkey: identity.pubkey, + displayName: identity.display_name, + }; +} + +export function getRelayWsUrl(): Promise { + return invoke("get_relay_ws_url"); +} + +export async function getChannels(): Promise { + const channels = await invoke("get_channels"); + + return channels.map((channel) => ({ + id: channel.id, + name: channel.name, + channelType: channel.channel_type, + description: channel.description, + participants: channel.participants, + participantPubkeys: channel.participant_pubkeys, + })); +} + +export async function signRelayEvent(input: { + kind: number; + content: string; + tags: string[][]; +}): Promise { + const eventJson = await invoke("sign_event", input); + return JSON.parse(eventJson) as RelayEvent; +} + +export async function createAuthEvent(input: { + challenge: string; + relayUrl: string; +}): Promise { + const eventJson = await invoke("create_auth_event", input); + return JSON.parse(eventJson) as RelayEvent; +} diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts new file mode 100644 index 000000000..3474f05a2 --- /dev/null +++ b/desktop/src/shared/api/types.ts @@ -0,0 +1,26 @@ +export type ChannelType = "stream" | "forum" | "dm"; + +export type Channel = { + id: string; + name: string; + channelType: ChannelType; + description: string; + participants: string[]; + participantPubkeys: string[]; +}; + +export type Identity = { + pubkey: string; + displayName: string; +}; + +export type RelayEvent = { + id: string; + pubkey: string; + created_at: number; + kind: number; + tags: string[][]; + content: string; + sig: string; + pending?: boolean; +};