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 (
);
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;
+};