From 512834b64085b4d3da4346e573e6e8ef2ba82745 Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Tue, 11 Nov 2025 09:09:33 +0700 Subject: [PATCH] chore: rewrite the backend (not tested) (#203) * wip: refactor * refactor * clean up * . * rename * add relay auth * . * . * optimize * . * clean up * add encryption crate * . * . * . * . * . * add encryption crate * . * refactor nip4e * . * fix endless loop * fix metadata fetching --- Cargo.lock | 321 +++-- Cargo.toml | 2 - assets/.keep | 0 crates/account/Cargo.toml | 10 +- crates/account/src/lib.rs | 170 ++- crates/assets/src/lib.rs | 2 +- crates/auto_update/Cargo.toml | 2 +- crates/auto_update/src/lib.rs | 18 +- crates/chat/Cargo.toml | 6 +- crates/chat/src/lib.rs | 695 ++++++++-- crates/chat/src/message.rs | 12 + crates/chat/src/room.rs | 136 +- crates/chat_ui/Cargo.toml | 4 +- crates/chat_ui/src/actions.rs | 2 +- crates/chat_ui/src/lib.rs | 36 +- crates/chat_ui/src/text.rs | 4 +- crates/common/Cargo.toml | 9 +- crates/{states => common}/src/constants.rs | 79 +- crates/common/src/display.rs | 12 +- crates/common/src/event.rs | 3 +- crates/common/src/lib.rs | 73 +- crates/{states => common}/src/paths.rs | 0 crates/coop/Cargo.toml | 8 +- crates/coop/src/actions.rs | 10 +- crates/coop/src/chatspace.rs | 1098 +++------------ crates/coop/src/main.rs | 47 +- crates/coop/src/views/backup_keys.rs | 7 +- crates/coop/src/views/compose.rs | 50 +- crates/coop/src/views/edit_profile.rs | 23 +- crates/coop/src/views/login.rs | 15 +- crates/coop/src/views/mod.rs | 2 +- crates/coop/src/views/new_account.rs | 17 +- crates/coop/src/views/onboarding.rs | 13 +- crates/coop/src/views/preferences.rs | 2 +- crates/coop/src/views/screening.rs | 23 +- crates/coop/src/views/setup_relay.rs | 58 +- crates/coop/src/views/sidebar/list_item.rs | 9 +- crates/coop/src/views/sidebar/mod.rs | 72 +- .../coop/src/views/{account.rs => startup.rs} | 64 +- crates/coop/src/views/user_profile.rs | 9 +- crates/encryption/Cargo.toml | 22 + crates/encryption/src/lib.rs | 631 +++++++++ crates/encryption/src/signer.rs | 9 + crates/encryption_ui/Cargo.toml | 27 + crates/encryption_ui/src/lib.rs | 407 ++++++ crates/key_store/Cargo.toml | 4 +- crates/key_store/src/backend.rs | 26 +- crates/key_store/src/lib.rs | 5 +- crates/person/Cargo.toml | 3 +- crates/person/src/lib.rs | 82 +- crates/relay_auth/Cargo.toml | 20 + crates/relay_auth/src/lib.rs | 326 +++++ crates/settings/Cargo.toml | 2 +- crates/settings/src/lib.rs | 12 +- crates/{states => state}/Cargo.toml | 10 +- crates/state/src/lib.rs | 312 +++++ crates/state/src/storage.rs | 87 ++ .../src/state => state/src}/tracker.rs | 14 + crates/states/src/lib.rs | 67 - crates/states/src/state/device.rs | 48 - crates/states/src/state/ingester.rs | 31 - crates/states/src/state/mod.rs | 1197 ----------------- crates/states/src/state/signal.rs | 169 --- crates/ui/src/input/element.rs | 7 +- crates/ui/src/input/state.rs | 32 +- crates/ui/src/menu/menu_item.rs | 2 +- crates/ui/src/menu/popup_menu.rs | 6 +- locales/app.yml | 16 +- 68 files changed, 3503 insertions(+), 3194 deletions(-) delete mode 100644 assets/.keep rename crates/{states => common}/src/constants.rs (71%) rename crates/{states => common}/src/paths.rs (100%) rename crates/coop/src/views/{account.rs => startup.rs} (90%) create mode 100644 crates/encryption/Cargo.toml create mode 100644 crates/encryption/src/lib.rs create mode 100644 crates/encryption/src/signer.rs create mode 100644 crates/encryption_ui/Cargo.toml create mode 100644 crates/encryption_ui/src/lib.rs create mode 100644 crates/relay_auth/Cargo.toml create mode 100644 crates/relay_auth/src/lib.rs rename crates/{states => state}/Cargo.toml (73%) create mode 100644 crates/state/src/lib.rs create mode 100644 crates/state/src/storage.rs rename crates/{states/src/state => state/src}/tracker.rs (72%) delete mode 100644 crates/states/src/lib.rs delete mode 100644 crates/states/src/state/device.rs delete mode 100644 crates/states/src/state/ingester.rs delete mode 100644 crates/states/src/state/mod.rs delete mode 100644 crates/states/src/state/signal.rs diff --git a/Cargo.lock b/Cargo.lock index d26fc25..0ec9ceb 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -7,13 +7,18 @@ name = "account" version = "0.2.11" dependencies = [ "anyhow", + "common", "gpui", "log", - "nostr", "nostr-sdk", + "serde", + "serde_json", + "settings", "smallvec", "smol", - "states", + "state", + "theme", + "ui", ] [[package]] @@ -125,7 +130,7 @@ checksum = "0ae92a5119aa49cdbcf6b9f893fe4e1d98b04ccbf82ee0584ad948a44a734dea" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -369,7 +374,7 @@ checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -445,7 +450,7 @@ checksum = "9035ad2d096bed7955a320ee7e2230574d28fd3c3a0f186cbea1ff3c7eed5dbb" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -524,7 +529,7 @@ dependencies = [ "semver", "smallvec", "smol", - "states", + "state", "tempfile", ] @@ -621,7 +626,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -641,7 +646,7 @@ dependencies = [ "regex", "rustc-hash 2.1.1", "shlex", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -773,7 +778,7 @@ checksum = "27142319e2f4c264581067eaccb9f80acccdde60d8b4bf57cc50cd3152f109ca" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -873,7 +878,7 @@ checksum = "f9abbd1bc6865053c427f7198e6af43bfdedc55ab791faed4fbd361d789575ff" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -942,7 +947,7 @@ dependencies = [ "quote", "serde", "serde_json", - "syn 2.0.108", + "syn 2.0.109", "tempfile", "toml 0.8.23", ] @@ -1036,12 +1041,13 @@ dependencies = [ "account", "anyhow", "common", + "encryption", + "flume", "futures", "fuzzy-matcher", "gpui", "itertools 0.13.0", "log", - "nostr", "nostr-sdk", "person", "serde", @@ -1049,7 +1055,7 @@ dependencies = [ "settings", "smallvec", "smol", - "states", + "state", ] [[package]] @@ -1061,12 +1067,12 @@ dependencies = [ "chat", "common", "emojis", + "encryption", "gpui", "gpui_tokio", "indexset", "itertools 0.13.0", "log", - "nostr", "nostr-sdk", "once_cell", "person", @@ -1076,7 +1082,7 @@ dependencies = [ "settings", "smallvec", "smol", - "states", + "state", "theme", "ui", ] @@ -1199,7 +1205,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1237,19 +1243,18 @@ version = "0.2.11" dependencies = [ "anyhow", "chrono", + "dirs 5.0.1", "futures", "gpui", "itertools 0.13.0", "log", "nostr", - "nostr-connect", "nostr-sdk", "qrcode", "reqwest", "smallvec", "smol", - "states", - "webbrowser", + "whoami", ] [[package]] @@ -1316,8 +1321,8 @@ dependencies = [ "chat", "chat_ui", "common", - "dirs 5.0.1", - "flume", + "encryption", + "encryption_ui", "futures", "gpui", "gpui_tokio", @@ -1326,11 +1331,11 @@ dependencies = [ "itertools 0.13.0", "key_store", "log", - "nostr", "nostr-connect", "nostr-sdk", "oneshot", "person", + "relay_auth", "reqwest_client", "rust-i18n", "serde", @@ -1338,7 +1343,7 @@ dependencies = [ "settings", "smallvec", "smol", - "states", + "state", "theme", "title_bar", "tracing-subscriber", @@ -1617,17 +1622,17 @@ dependencies = [ "proc-macro2", "quote", "rustc_version", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -1706,7 +1711,7 @@ checksum = "97369cbbc041bc366949bc74d34658d6cda5621039731c6310521892a3a20ae0" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -1819,6 +1824,49 @@ dependencies = [ "cfg-if", ] +[[package]] +name = "encryption" +version = "0.2.11" +dependencies = [ + "account", + "anyhow", + "common", + "flume", + "futures", + "gpui", + "log", + "nostr-sdk", + "serde", + "serde_json", + "smallvec", + "smol", + "state", +] + +[[package]] +name = "encryption_ui" +version = "0.2.11" +dependencies = [ + "account", + "anyhow", + "common", + "encryption", + "futures", + "gpui", + "itertools 0.13.0", + "log", + "nostr-sdk", + "person", + "serde", + "serde_json", + "settings", + "smallvec", + "smol", + "state", + "theme", + "ui", +] + [[package]] name = "endi" version = "1.1.0" @@ -1843,7 +1891,7 @@ checksum = "67c78a4d8fdf9953a5c9d458f9efe940fd97a0cab0941c075a813ac594733827" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -1863,7 +1911,7 @@ checksum = "44f23cf4b44bfce11a86ace86f8a73ffdec849c9fd00a386a53d278bd9e81fb3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -1986,7 +2034,7 @@ checksum = "a0aca10fb742cb43f9e7bb8467c91aa9bcb8e3ffbc6a6f7389bb93ffc920577d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -2180,7 +2228,7 @@ checksum = "1a5c6c585bc94aaf2c7b51dd4c2ba22680844aba4c687be581871a6f518c5742" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -2334,7 +2382,7 @@ checksum = "162ee34ebcb7c64a8abebc059ce0fee27c2262618d7b60ed8faf72fef13c3650" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -2520,7 +2568,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.2.2" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2617,18 +2665,18 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "anyhow", "gpui", @@ -2857,7 +2905,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "anyhow", "async-compression", @@ -2882,7 +2930,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "rustls", "rustls-platform-verifier", @@ -3221,7 +3269,7 @@ checksum = "c34819042dc3d3971c46c2190835914dfbe0c3c13f61449b2997f4e9722dfa60" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -3253,9 +3301,9 @@ checksum = "469fb0b9cefa57e3ef31275ee7cacb78f2fdca44e4765491884a2b119d4eb130" [[package]] name = "iri-string" -version = "0.7.8" +version = "0.7.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dbc5ebe9c3a1a7a5127f920a418f7585e9e758e911d0466ed004f393b0e380b2" +checksum = "4f867b9d1d896b67beb18518eda36fdb77a32ea590de864f1325b294a6d14397" dependencies = [ "memchr", "serde", @@ -3369,16 +3417,15 @@ name = "key_store" version = "0.2.11" dependencies = [ "anyhow", + "common", "futures", "gpui", "log", - "nostr", "nostr-sdk", "serde", "serde_json", "smallvec", "smol", - "states", ] [[package]] @@ -3647,6 +3694,15 @@ version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c41e0c4fef86961ac6d6f8a82609f55f31b05e4fce149ac5710e439df7619ba4" +[[package]] +name = "mach2" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a1b95cd5421ec55b445b5ae102f5ea0e768de1f82bd3001e11f426c269c3aea" +dependencies = [ + "libc", +] + [[package]] name = "malloc_buf" version = "0.0.6" @@ -3679,7 +3735,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -3928,8 +3984,8 @@ dependencies = [ [[package]] name = "nostr" -version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#d79245efe2c97424acc93da687d5f8077236a74b" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr#c1a24383cd361a408fc53eeac7c1708a3b589194" dependencies = [ "aes", "base64", @@ -3952,8 +4008,8 @@ dependencies = [ [[package]] name = "nostr-connect" -version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#d79245efe2c97424acc93da687d5f8077236a74b" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr#c1a24383cd361a408fc53eeac7c1708a3b589194" dependencies = [ "async-utility", "nostr", @@ -3964,8 +4020,8 @@ dependencies = [ [[package]] name = "nostr-database" -version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#d79245efe2c97424acc93da687d5f8077236a74b" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr#c1a24383cd361a408fc53eeac7c1708a3b589194" dependencies = [ "flatbuffers", "lru", @@ -3975,16 +4031,16 @@ dependencies = [ [[package]] name = "nostr-gossip" -version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#d79245efe2c97424acc93da687d5f8077236a74b" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr#c1a24383cd361a408fc53eeac7c1708a3b589194" dependencies = [ "nostr", ] [[package]] name = "nostr-gossip-memory" -version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#d79245efe2c97424acc93da687d5f8077236a74b" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr#c1a24383cd361a408fc53eeac7c1708a3b589194" dependencies = [ "indexmap", "lru", @@ -3995,8 +4051,8 @@ dependencies = [ [[package]] name = "nostr-lmdb" -version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#d79245efe2c97424acc93da687d5f8077236a74b" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr#c1a24383cd361a408fc53eeac7c1708a3b589194" dependencies = [ "async-utility", "flume", @@ -4009,8 +4065,8 @@ dependencies = [ [[package]] name = "nostr-relay-pool" -version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#d79245efe2c97424acc93da687d5f8077236a74b" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr#c1a24383cd361a408fc53eeac7c1708a3b589194" dependencies = [ "async-utility", "async-wsocket", @@ -4026,8 +4082,8 @@ dependencies = [ [[package]] name = "nostr-sdk" -version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#d79245efe2c97424acc93da687d5f8077236a74b" +version = "0.44.0" +source = "git+https://github.com/rust-nostr/nostr#c1a24383cd361a408fc53eeac7c1708a3b589194" dependencies = [ "async-utility", "nostr", @@ -4114,7 +4170,7 @@ checksum = "ed3955f1a9c7c0c15e092f9c887db08b1fc683305fdf6eb6684f22555355e202" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -4391,7 +4447,7 @@ checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -4538,7 +4594,7 @@ checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" [[package]] name = "perf" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "collections", "serde", @@ -4554,11 +4610,10 @@ dependencies = [ "gpui", "itertools 0.13.0", "log", - "nostr", "nostr-sdk", "smallvec", "smol", - "states", + "state", ] [[package]] @@ -4591,7 +4646,7 @@ dependencies = [ "phf_shared", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -4626,7 +4681,7 @@ checksum = "6e918e4ff8c4549eb882f14b3a4bc8c8bc93de829416eacf579f1207a8fbf861" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -4757,7 +4812,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "479ca8adacdd7ce8f1fb39ce9ecccbfe93a3f1344b3d0d97f20bc0196208f62b" dependencies = [ "proc-macro2", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -4788,7 +4843,7 @@ dependencies = [ "proc-macro-error-attr2", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -4816,7 +4871,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "52717f9a02b6965224f95ca2a81e2e0c5c43baacd28ca057577988930b6c3d5b" dependencies = [ "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -4937,9 +4992,9 @@ dependencies = [ [[package]] name = "quote" -version = "1.0.41" +version = "1.0.42" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce25767e7b499d1b604768e7cde645d14cc8584231ea6b295e9c9eb22c02e1d1" +checksum = "a338cc41d27e6cc6dce6cefc13a0729dfbb81c262b1f519331575dd80ef3067f" dependencies = [ "proc-macro2", ] @@ -5159,13 +5214,13 @@ checksum = "b7186006dcb21920990093f30e3dea63b7d6e977bf1256be20c3563a5db070da" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "derive_refineable", ] @@ -5199,6 +5254,23 @@ version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7a2d987857b319362043e95f5353c0535c1f58eec5336fdfcf626430af7def58" +[[package]] +name = "relay_auth" +version = "0.2.11" +dependencies = [ + "anyhow", + "common", + "gpui", + "log", + "nostr-sdk", + "settings", + "smallvec", + "smol", + "state", + "theme", + "ui", +] + [[package]] name = "reqwest" version = "0.12.24" @@ -5246,7 +5318,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "anyhow", "bytes", @@ -5300,11 +5372,11 @@ dependencies = [ [[package]] name = "rope" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "arrayvec", - "gpui", "log", + "rayon", "sum_tree", "unicode-segmentation", "util", @@ -5336,7 +5408,7 @@ dependencies = [ "proc-macro2", "quote", "rust-embed-utils", - "syn 2.0.108", + "syn 2.0.109", "walkdir", ] @@ -5379,7 +5451,7 @@ dependencies = [ "serde", "serde_json", "serde_yaml", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -5464,9 +5536,9 @@ dependencies = [ [[package]] name = "rustls" -version = "0.23.34" +version = "0.23.35" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6a9586e9ee2b4f8fab52a0048ca7334d7024eef48e2cb9407e3497bb7cab7fa7" +checksum = "533f54bc6a7d4f647e46ad909549eda97bf5afc1585190ef692b4286b198bd8f" dependencies = [ "aws-lc-rs", "log", @@ -5624,9 +5696,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.5" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1317c3bf3e7df961da95b0a56a172a02abead31276215a0497241a7624b487ce" +checksum = "9558e172d4e8533736ba97870c4b2cd63f84b382a3d6eb063da41b91cce17289" dependencies = [ "dyn-clone", "indexmap", @@ -5638,14 +5710,14 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.0.5" +version = "1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f760a6150d45dd66ec044983c124595ae76912e77ed0b44124cb3e415cce5d9" +checksum = "301858a4023d78debd2353c7426dc486001bddc91ae31a76fb1f55132f7e2633" dependencies = [ "proc-macro2", "quote", "serde_derive_internals", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -5766,7 +5838,7 @@ checksum = "16c2f82143577edb4921b71ede051dac62ca3c16084e918bf7b40c96ae10eb33" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "anyhow", "serde", @@ -5805,7 +5877,7 @@ checksum = "d540f220d3187173da220f885ab66608367b6574e925011a9353e4badda91d79" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -5816,7 +5888,7 @@ checksum = "18d26a20a969b9e3fdf2fc2d9f21eda6c40e2de84c9408bb5d3b05d499aae711" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -5863,7 +5935,7 @@ checksum = "175ee3e80ae9982737ca543e96133087cbd9a485eecc3bc4de9c1a37b47ea59c" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -5921,7 +5993,7 @@ dependencies = [ "serde", "serde_json", "smallvec", - "states", + "state", ] [[package]] @@ -6125,16 +6197,17 @@ checksum = "172175341049678163e979d9107ca3508046d4d2a7c6682bee46ac541b17db69" dependencies = [ "proc-macro-error2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] -name = "states" +name = "state" version = "0.2.11" dependencies = [ "anyhow", - "dirs 5.0.1", - "flume", + "common", + "event-listener 5.4.1", + "gpui", "log", "nostr-gossip-memory", "nostr-lmdb", @@ -6142,8 +6215,8 @@ dependencies = [ "rustls", "serde", "serde_json", + "smallvec", "smol", - "whoami", ] [[package]] @@ -6189,7 +6262,7 @@ dependencies = [ "proc-macro2", "quote", "rustversion", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -6201,7 +6274,7 @@ dependencies = [ "heck 0.5.0", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -6213,12 +6286,11 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "arrayvec", - "futures", - "futures-lite 1.13.0", "log", + "rayon", ] [[package]] @@ -6339,9 +6411,9 @@ dependencies = [ [[package]] name = "syn" -version = "2.0.108" +version = "2.0.109" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "da58917d35242480a05c2897064da0a80589a2a0476c9a3f2fdc83b53502e917" +checksum = "2f17c7e013e88258aa9543dcbe81aca68a667a9ac37cd69c9fbc07858bfe0e2f" dependencies = [ "proc-macro2", "quote", @@ -6374,7 +6446,7 @@ checksum = "728a70f3dbaf5bab7f0c4b1ac8d7ae5ea60a4b5549c8a5914361c99147a709d2" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -6538,7 +6610,7 @@ checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -6549,7 +6621,7 @@ checksum = "3ff15c8ecd7de3849db632e14d18d2571fa09dfc5ed93479bc4485c7a517c913" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -6677,7 +6749,7 @@ checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -6897,7 +6969,7 @@ checksum = "81383ab64e72a7a8b8e13130c49e3dab29def6d0c7d76a03087b3cf71c5c6903" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -7201,7 +7273,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "anyhow", "async-fs", @@ -7216,6 +7288,7 @@ dependencies = [ "itertools 0.14.0", "libc", "log", + "mach2", "nix 0.29.0", "regex", "rust-embed", @@ -7236,11 +7309,11 @@ dependencies = [ [[package]] name = "util_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#50504793e6562b239dcd9399bc668445fae55f29" +source = "git+https://github.com/zed-industries/zed#784fdcaee3be7bbcf9511097cf620256dc2f7ef6" dependencies = [ "perf", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -7438,7 +7511,7 @@ dependencies = [ "bumpalo", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", "wasm-bindgen-shared", ] @@ -7808,7 +7881,7 @@ checksum = "9107ddc059d5b6fbfbffdfa7a7fe3e22a226def0b2608f72e9d552763d3e1ad7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -7819,7 +7892,7 @@ checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -7830,7 +7903,7 @@ checksum = "29bee4b38ea3cde66011baa44dba677c432a78593e202392d1e9070cf2a7fca7" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -7841,7 +7914,7 @@ checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -8437,7 +8510,7 @@ checksum = "b659052874eb698efe5b9e8cf382204678a0086ebf46982b79d6ca3182927e5d" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", "synstructure", ] @@ -8484,7 +8557,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", "zbus_names", "zvariant", "zvariant_utils", @@ -8632,7 +8705,7 @@ checksum = "88d2b8d9c68ad2b9e4340d7832716a4d21a22a1154777ad56ea55c51a9cf3831" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -8652,7 +8725,7 @@ checksum = "d71e5d6e06ab090c67b5e44993ec16b72dcbaabc526db883a360057678b48502" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", "synstructure", ] @@ -8673,7 +8746,7 @@ checksum = "ce36e65b0d2999d2aafac989fb249189a141aee1f53c612c1f37d72631959f69" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -8706,7 +8779,7 @@ checksum = "eadce39539ca5cb3985590102671f2567e659fca9666581ad3411d59207951f3" dependencies = [ "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", ] [[package]] @@ -8757,7 +8830,7 @@ dependencies = [ "proc-macro-crate", "proc-macro2", "quote", - "syn 2.0.108", + "syn 2.0.109", "zvariant_utils", ] @@ -8770,6 +8843,6 @@ dependencies = [ "proc-macro2", "quote", "serde", - "syn 2.0.108", + "syn 2.0.109", "winnow", ] diff --git a/Cargo.toml b/Cargo.toml index 4e20754..7c05491 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -22,7 +22,6 @@ gpui_tokio = { git = "https://github.com/zed-industries/zed" } reqwest_client = { git = "https://github.com/zed-industries/zed" } # Nostr -nostr = { git = "https://github.com/rust-nostr/nostr" } nostr-lmdb = { git = "https://github.com/rust-nostr/nostr" } nostr-connect = { git = "https://github.com/rust-nostr/nostr" } nostr-gossip-memory = { git = "https://github.com/rust-nostr/nostr" } @@ -31,7 +30,6 @@ nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ "nip96", # Others anyhow = "1.0.44" chrono = "0.4.38" -dirs = "5.0" futures = "0.3" itertools = "0.13.0" log = "0.4" diff --git a/assets/.keep b/assets/.keep deleted file mode 100644 index e69de29..0000000 diff --git a/crates/account/Cargo.toml b/crates/account/Cargo.toml index 6100cca..a594b16 100644 --- a/crates/account/Cargo.toml +++ b/crates/account/Cargo.toml @@ -5,14 +5,18 @@ edition.workspace = true publish.workspace = true [dependencies] -states = { path = "../states" } +state = { path = "../state" } +settings = { path = "../settings" } +common = { path = "../common" } +theme = { path = "../theme" } +ui = { path = "../ui" } gpui.workspace = true - -nostr.workspace = true nostr-sdk.workspace = true anyhow.workspace = true smallvec.workspace = true smol.workspace = true log.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/account/src/lib.rs b/crates/account/src/lib.rs index 1a79a46..b4c4730 100644 --- a/crates/account/src/lib.rs +++ b/crates/account/src/lib.rs @@ -1,9 +1,15 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::Error; +use common::BOOTSTRAP_RELAYS; use gpui::{App, AppContext, Context, Entity, Global, Task}; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; +use state::NostrRegistry; -pub fn init(public_key: PublicKey, cx: &mut App) { - Account::set_global(cx.new(|cx| Account::new(public_key, cx)), cx); +pub fn init(cx: &mut App) { + Account::set_global(cx.new(Account::new), cx); } struct GlobalAccount(Entity); @@ -12,10 +18,24 @@ impl Global for GlobalAccount {} pub struct Account { /// The public key of the account - public_key: PublicKey, + public_key: Option, + + /// Status of the current user NIP-65 relays + pub nip65_status: RelayStatus, + + /// Status of the current user NIP-17 relays + pub nip17_status: RelayStatus, /// Tasks for asynchronous operations - _tasks: SmallVec<[Task<()>; 1]>, + _tasks: SmallVec<[Task<()>; 2]>, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] +pub enum RelayStatus { + #[default] + Initial, + NotSet, + Set, } impl Account { @@ -35,20 +55,152 @@ impl Account { } /// Set the global account instance - pub(crate) fn set_global(state: Entity, cx: &mut App) { + fn set_global(state: Entity, cx: &mut App) { cx.set_global(GlobalAccount(state)); } /// Create a new account instance - pub(crate) fn new(public_key: PublicKey, _cx: &mut Context) -> Self { + fn new(cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let mut tasks = smallvec![]; + + tasks.push( + // Observe the nostr signer and set the public key when it sets + cx.spawn({ + let client = Arc::clone(&client); + + async move |this, cx| { + let result = cx + .background_spawn(async move { Self::observe_signer(&client).await }) + .await; + + if let Some(public_key) = result { + this.update(cx, |this, cx| { + this.set_account(public_key, cx); + }) + .expect("Entity has been released") + } + } + }), + ); + Self { - public_key, - _tasks: smallvec![], + public_key: None, + nip65_status: RelayStatus::default(), + nip17_status: RelayStatus::default(), + _tasks: tasks, } } + /// Observe the signer and return the public key when it sets + async fn observe_signer(client: &Client) -> Option { + let loop_duration = Duration::from_millis(800); + + loop { + if let Ok(signer) = client.signer().await { + if let Ok(public_key) = signer.get_public_key().await { + // Get current user's gossip relays + Self::get_gossip_relays(client, public_key).await.ok()?; + + return Some(public_key); + } + } + smol::Timer::after(loop_duration).await; + } + } + + /// Get gossip relays for a given public key + async fn get_gossip_relays(client: &Client, public_key: PublicKey) -> Result<(), Error> { + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + + let filter = Filter::new() + .kind(Kind::RelayList) + .author(public_key) + .limit(1); + + // Subscribe to events from the bootstrapping relays + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await?; + + log::info!("Getting user's gossip relays..."); + + Ok(()) + } + + /// Ensure the user has NIP-65 relays + async fn ensure_nip65_relays(client: &Client, public_key: PublicKey) -> Result { + let filter = Filter::new() + .kind(Kind::RelayList) + .author(public_key) + .limit(1); + + // Count the number of nip65 relays event in the database + let total = client.database().count(filter).await.unwrap_or(0); + + Ok(total > 0) + } + + /// Ensure the user has NIP-17 relays + async fn ensure_nip17_relays(client: &Client, public_key: PublicKey) -> Result { + let filter = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .limit(1); + + // Count the number of nip17 relays event in the database + let total = client.database().count(filter).await.unwrap_or(0); + + Ok(total > 0) + } + + /// Set the public key of the account + pub fn set_account(&mut self, public_key: PublicKey, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + // Update account's public key + self.public_key = Some(public_key); + + // Add background task + self._tasks.push( + // Verify user's nip65 and nip17 relays + cx.spawn(async move |this, cx| { + cx.background_executor() + .timer(Duration::from_secs(10)) + .await; + + let ensure_nip65 = Self::ensure_nip65_relays(&client, public_key).await; + let ensure_nip17 = Self::ensure_nip17_relays(&client, public_key).await; + + this.update(cx, |this, cx| { + this.nip65_status = match ensure_nip65 { + Ok(true) => RelayStatus::Set, + _ => RelayStatus::NotSet, + }; + this.nip17_status = match ensure_nip17 { + Ok(true) => RelayStatus::Set, + _ => RelayStatus::NotSet, + }; + cx.notify(); + }) + .expect("Entity has been released") + }), + ); + + cx.notify(); + } + + /// Check if the account entity has a public key + pub fn has_account(&self) -> bool { + self.public_key.is_some() + } + /// Get the public key of the account pub fn public_key(&self) -> PublicKey { - self.public_key + // This method is only called when user is logged in, so unwrap safely + self.public_key.unwrap() } } diff --git a/crates/assets/src/lib.rs b/crates/assets/src/lib.rs index 2de81bc..65b7b75 100644 --- a/crates/assets/src/lib.rs +++ b/crates/assets/src/lib.rs @@ -5,7 +5,7 @@ use rust_embed::RustEmbed; #[derive(RustEmbed)] #[folder = "../../assets"] #[include = "fonts/**/*"] -#[include = "brand/*"] +#[include = "brand/**/*"] #[include = "icons/**/*"] #[exclude = "*.DS_Store"] pub struct Assets; diff --git a/crates/auto_update/Cargo.toml b/crates/auto_update/Cargo.toml index 1e1c7fe..9227179 100644 --- a/crates/auto_update/Cargo.toml +++ b/crates/auto_update/Cargo.toml @@ -6,7 +6,7 @@ publish.workspace = true [dependencies] common = { path = "../common" } -states = { path = "../states" } +state = { path = "../state" } gpui.workspace = true gpui_tokio.workspace = true diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index 78a3dc5..6208e72 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -4,6 +4,7 @@ use std::sync::Arc; use std::time::Duration; use anyhow::{anyhow, Context as AnyhowContext, Error}; +use common::BOOTSTRAP_RELAYS; use gpui::http_client::{AsyncBody, HttpClient}; use gpui::{ App, AppContext, AsyncApp, BackgroundExecutor, Context, Entity, Global, Subscription, Task, @@ -13,7 +14,7 @@ use semver::Version; use smallvec::{smallvec, SmallVec}; use smol::fs::File; use smol::process::Command; -use states::{app_state, BOOTSTRAP_RELAYS}; +use state::NostrRegistry; const APP_PUBKEY: &str = "npub1y9jvl5vznq49eh9f2gj7679v4042kj80lp7p8fte3ql2cr7hty7qsyca8q"; @@ -230,8 +231,10 @@ impl AutoUpdater { } fn subscribe_to_updates(cx: &App) -> Task<()> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + cx.background_spawn(async move { - let client = app_state().client(); let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); @@ -250,8 +253,14 @@ impl AutoUpdater { } fn check_for_updates(version: Version, cx: &AsyncApp) -> Task, Error>> { + let Ok(client) = cx.update(|cx| { + let nostr = NostrRegistry::global(cx); + nostr.read(cx).client() + }) else { + return Task::ready(Err(anyhow!("Entity has been released"))); + }; + cx.background_spawn(async move { - let client = app_state().client(); let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); @@ -294,11 +303,12 @@ impl AutoUpdater { } fn get_latest_release(&mut self, ids: &[EventId], cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let http_client = cx.http_client(); let ids = ids.to_vec(); let task: Task> = cx.background_spawn(async move { - let client = app_state().client(); let app_pubkey = PublicKey::parse(APP_PUBKEY).unwrap(); let os = std::env::consts::OS; diff --git a/crates/chat/Cargo.toml b/crates/chat/Cargo.toml index 405f2e6..9dbb715 100644 --- a/crates/chat/Cargo.toml +++ b/crates/chat/Cargo.toml @@ -6,20 +6,22 @@ publish.workspace = true [dependencies] common = { path = "../common" } -states = { path = "../states" } +state = { path = "../state" } account = { path = "../account" } +encryption = { path = "../encryption" } person = { path = "../person" } settings = { path = "../settings" } gpui.workspace = true -nostr.workspace = true nostr-sdk.workspace = true + anyhow.workspace = true itertools.workspace = true smallvec.workspace = true smol.workspace = true log.workspace = true futures.workspace = true +flume.workspace = true serde.workspace = true serde_json.workspace = true diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index c1c2e98..c0be7df 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -1,27 +1,53 @@ use std::cmp::Reverse; use std::collections::{HashMap, HashSet}; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::atomic::{AtomicBool, Ordering}; +use std::sync::Arc; +use std::time::Duration; use account::Account; -use anyhow::Error; -use common::event::EventUtils; +use anyhow::{anyhow, Context as AnyhowContext, Error}; +use common::{EventUtils, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT}; +use encryption::Encryption; +use flume::Sender; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; -use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, Window}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task}; +pub use message::*; use nostr_sdk::prelude::*; -use room::RoomKind; +pub use room::*; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; -use states::{app_state, NewMessage}; +use smol::lock::RwLock; +use state::{initialized_at, EventTracker, NostrRegistry, GIFTWRAP_SUBSCRIPTION}; -use crate::room::Room; - -pub mod message; -pub mod room; +mod message; +mod room; pub fn init(cx: &mut App) { ChatRegistry::set_global(cx.new(ChatRegistry::new), cx); } +struct GlobalChatRegistry(Entity); + +impl Global for GlobalChatRegistry {} + +/// Chat Registry +#[derive(Debug)] +pub struct ChatRegistry { + /// Collection of all chat rooms + pub rooms: Vec>, + + /// Loading status of the registry + pub loading: bool, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, + + /// Tasks for asynchronous operations + _tasks: SmallVec<[Task<()>; 4]>, +} + #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] pub enum ChatEvent { OpenRoom(u64), @@ -29,43 +55,271 @@ pub enum ChatEvent { NewChatRequest(RoomKind), } -struct GlobalChatRegistry(Entity); - -impl Global for GlobalChatRegistry {} - -pub struct ChatRegistry { - /// Collection of all chat rooms - pub rooms: Vec>, - - /// Loading status of the registry - pub loading: bool, - - /// Tasks for asynchronous operations - _tasks: SmallVec<[Task<()>; 2]>, +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub enum Signal { + Loading(bool), + Message(NewMessage), + Eose, } impl EventEmitter for ChatRegistry {} impl ChatRegistry { - /// Retrieve the global registry state + /// Retrieve the global chat registry state pub fn global(cx: &App) -> Entity { cx.global::().0.clone() } - /// Set the global registry instance - pub(crate) fn set_global(state: Entity, cx: &mut App) { + /// Set the global chat registry instance + fn set_global(state: Entity, cx: &mut App) { cx.set_global(GlobalChatRegistry(state)); } - /// Create a new registry instance - pub(crate) fn new(_cx: &mut Context) -> Self { + /// Create a new chat registry instance + fn new(cx: &mut Context) -> Self { + let encryption = Encryption::global(cx); + let encryption_key = encryption.read(cx).encryption.clone(); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let tracker = nostr.read(cx).tracker(); + + let status = Arc::new(AtomicBool::new(true)); + let (tx, rx) = flume::bounded::(2048); + + let mut subscriptions = smallvec![]; + let mut tasks = smallvec![]; + + subscriptions.push( + // Observe the encryption global state + cx.observe(&encryption_key, { + let status = Arc::clone(&status); + let tx = tx.clone(); + + move |this, state, cx| { + if let Some(signer) = state.read(cx).clone() { + this.retry_failed_events(&signer, &tx, &status, cx); + } + } + }), + ); + + tasks.push( + // Handle notifications + cx.background_spawn({ + let client = Arc::clone(&client); + let status = Arc::clone(&status); + let tx = tx.clone(); + + async move { Self::handle_notifications(&client, &tracker, &tx, &status).await } + }), + ); + + tasks.push( + // Handle unwrapping status + cx.background_spawn({ + let client = Arc::clone(&client); + async move { Self::handle_unwrapping(&client, &status, &tx).await } + }), + ); + + tasks.push( + // Handle new messages + cx.spawn(async move |this, cx| { + while let Ok(message) = rx.recv_async().await { + match message { + Signal::Message(message) => { + this.update(cx, |this, cx| { + this.new_message(message, cx); + }) + .expect("Entity has been released"); + } + Signal::Eose => { + this.update(cx, |this, cx| { + this.get_rooms(cx); + }) + .expect("Entity has been released"); + } + Signal::Loading(status) => { + this.update(cx, |this, cx| { + this.set_loading(status, cx); + this.get_rooms(cx); + }) + .expect("Entity has been released"); + } + }; + } + }), + ); + Self { rooms: vec![], loading: true, - _tasks: smallvec![], + _subscriptions: subscriptions, + _tasks: tasks, } } + async fn handle_notifications( + client: &Client, + tracker: &Arc>, + tx: &Sender, + status: &Arc, + ) { + let mut notifications = client.notifications(); + log::info!("Listening for notifications"); + + let initialized_at = initialized_at(); + let subscription_id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); + + let mut public_keys = HashSet::new(); + let mut processed_events = HashSet::new(); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, .. } = notification else { + // Skip non-message notifications + continue; + }; + + match message { + RelayMessage::Event { event, .. } => { + if !processed_events.insert(event.id) { + // Skip if the event has already been processed + continue; + } + + if event.kind != Kind::GiftWrap { + // Skip non-gift wrap events + continue; + } + + // Extract the rumor from the gift wrap event + match Self::extract_rumor(client, event.as_ref()).await { + Ok(rumor) => { + // Get all public keys + public_keys.extend(rumor.all_pubkeys()); + + let limit_reached = public_keys.len() >= METADATA_BATCH_LIMIT; + let done = !status.load(Ordering::Acquire) && !public_keys.is_empty(); + + // Get metadata for all public keys if the limit is reached + if limit_reached || done { + let public_keys = std::mem::take(&mut public_keys); + // Get metadata for the public keys + Self::get_metadata(client, public_keys).await.ok(); + } + + match &event.created_at >= initialized_at { + true => { + let new_message = NewMessage::new(event.id, rumor); + let signal = Signal::Message(new_message); + + if let Err(e) = tx.send_async(signal).await { + log::error!("Failed to send signal: {}", e); + } + } + false => { + status.store(true, Ordering::Release); + } + } + } + Err(_e) => { + let mut tracker = tracker.write().await; + tracker.failed_unwrap_events.push(event.as_ref().clone()); + + drop(tracker); + } + } + } + RelayMessage::EndOfStoredEvents(id) => { + if id.as_ref() == &subscription_id { + if let Err(e) = tx.send_async(Signal::Eose).await { + log::error!("Failed to send signal: {}", e); + } + } + } + _ => {} + } + } + } + + async fn handle_unwrapping(client: &Client, status: &Arc, tx: &Sender) { + let loop_duration = Duration::from_secs(20); + let mut is_start_processing = false; + let mut total_loops = 0; + + loop { + if client.has_signer().await { + total_loops += 1; + + if status.load(Ordering::Acquire) { + is_start_processing = true; + + // Reset gift wrap processing flag + _ = status.compare_exchange(true, false, Ordering::Release, Ordering::Relaxed); + + // Send loading signal + if let Err(e) = tx.send_async(Signal::Loading(true)).await { + log::error!("Failed to send signal: {}", e); + } + } else { + // Only run further if we are already processing + // Wait until after 2 loops to prevent exiting early while events are still being processed + if is_start_processing && total_loops >= 2 { + // Send loading signal + if let Err(e) = tx.send_async(Signal::Loading(false)).await { + log::error!("Failed to send signal: {}", e); + } + // Reset the counter + is_start_processing = false; + total_loops = 0; + } + } + } + smol::Timer::after(loop_duration).await; + } + } + + fn retry_failed_events( + &mut self, + signer: &Arc, + tx: &Sender, + status: &Arc, + cx: &mut Context, + ) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let tracker = nostr.read(cx).tracker(); + + let signer = Arc::clone(signer); + let status = Arc::clone(status); + + let tx = tx.clone(); + let initialized_at = initialized_at(); + + self._tasks.push(cx.background_spawn(async move { + let tracker = tracker.read().await; + + for event in tracker.failed_unwrap_events.iter() { + if let Ok(rumor) = Self::try_unwrap_custom(&client, &signer, event).await { + match &event.created_at >= initialized_at { + true => { + let new_message = NewMessage::new(event.id, rumor); + let signal = Signal::Message(new_message); + + if let Err(e) = tx.send_async(signal).await { + log::error!("Failed to send signal: {}", e); + } + } + false => { + status.store(true, Ordering::Release); + } + } + } + } + })); + } + /// Set the loading status of the chat registry pub fn set_loading(&mut self, loading: bool, cx: &mut Context) { self.loading = loading; @@ -147,97 +401,19 @@ impl ChatRegistry { cx.notify(); } - /// Load all rooms from the database. - pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context) { - log::info!("Starting to load chat rooms..."); + /// Push a new room to the chat registry + pub fn push_room(&mut self, room: Entity, cx: &mut Context) { + let id = room.read(cx).id; - // Get the contact bypass setting - let bypass_setting = AppSettings::get_contact_bypass(cx); + if !self.rooms.iter().any(|r| r.read(cx).id == id) { + self.add_room(room, cx); + } - let task: Task, Error>> = cx.background_spawn(async move { - let client = app_state().client(); - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - let contacts = client.database().contacts_public_keys(public_key).await?; - - let authored_filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key); - - let addressed_filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key); - - let authored = client.database().query(authored_filter).await?; - let addressed = client.database().query(addressed_filter).await?; - let events = authored.merge(addressed); - - let mut rooms: HashSet = HashSet::new(); - let mut grouped: HashMap> = HashMap::new(); - - // Process each event and group by room hash - for raw in events.into_iter() { - match UnsignedEvent::from_json(&raw.content) { - Ok(rumor) => { - if rumor.tags.public_keys().peekable().peek().is_some() { - grouped.entry(rumor.uniq_id()).or_default().push(rumor); - } - } - Err(e) => log::warn!("Failed to parse stored rumor: {e}"), - } - } - - for (_room_id, mut messages) in grouped.into_iter() { - messages.sort_by_key(|m| Reverse(m.created_at)); - - let Some(latest) = messages.first() else { - continue; - }; - - let mut room = Room::from(latest); - - if rooms.iter().any(|r| r.id == room.id) { - continue; - } - - let mut public_keys: Vec = room.members().to_vec(); - public_keys.retain(|pk| pk != &public_key); - - let user_sent = messages.iter().any(|m| m.pubkey == public_key); - - let mut bypassed = false; - if bypass_setting { - bypassed = public_keys.iter().any(|k| contacts.contains(k)); - } - - if user_sent || bypassed { - room = room.kind(RoomKind::Ongoing); - } - - rooms.insert(room); - } - - Ok(rooms) - }); - - cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(rooms) => { - this.update_in(cx, move |this, _window, cx| { - this.extend_rooms(rooms, cx); - this.sort(cx); - }) - .ok(); - } - Err(e) => { - log::error!("Failed to load rooms: {e}") - } - }; - }) - .detach(); + cx.emit(ChatEvent::OpenRoom(id)); } - pub(crate) fn extend_rooms(&mut self, rooms: HashSet, cx: &mut Context) { + /// Extend the registry with new rooms. + fn extend_rooms(&mut self, rooms: HashSet, cx: &mut Context) { let mut room_map: HashMap = self .rooms .iter() @@ -264,18 +440,111 @@ impl ChatRegistry { } } - /// Push a new room to the chat registry - pub fn push_room(&mut self, room: Entity, cx: &mut Context) { - let id = room.read(cx).id; + /// Load all rooms from the database. + pub fn get_rooms(&mut self, cx: &mut Context) { + let task = self.create_get_rooms_task(cx); - if !self.rooms.iter().any(|r| r.read(cx).id == id) { - self.add_room(room, cx); - } - - cx.emit(ChatEvent::OpenRoom(id)); + self._tasks.push( + // Run and finished in the background + cx.spawn(async move |this, cx| { + match task.await { + Ok(rooms) => { + this.update(cx, move |this, cx| { + this.extend_rooms(rooms, cx); + this.sort(cx); + }) + .expect("Entity has been released"); + } + Err(e) => { + log::error!("Failed to load rooms: {e}") + } + }; + }), + ); } - /// Refresh messages for a room in the global registry + /// Create a task to load rooms from the database + fn create_get_rooms_task(&self, cx: &App) -> Task, Error>> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + // Get the contact bypass setting + let bypass_setting = AppSettings::get_contact_bypass(cx); + + cx.background_spawn(async move { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + let contacts = client.database().contacts_public_keys(public_key).await?; + + let authored_filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .custom_tag(SingleLetterTag::lowercase(Alphabet::A), public_key); + + // Get all authored events + let authored = client.database().query(authored_filter).await?; + + let addressed_filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .custom_tag(SingleLetterTag::lowercase(Alphabet::P), public_key); + + // Get all addressed events + let addressed = client.database().query(addressed_filter).await?; + + // Merge authored and addressed events + let events = authored.merge(addressed); + + let mut rooms: HashSet = HashSet::new(); + let mut grouped: HashMap> = HashMap::new(); + + // Process each event and group by room hash + for raw in events.into_iter() { + if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) { + if rumor.tags.public_keys().peekable().peek().is_some() { + grouped.entry(rumor.uniq_id()).or_default().push(rumor); + } + } + } + + for (_id, mut messages) in grouped.into_iter() { + messages.sort_by_key(|m| Reverse(m.created_at)); + + let Some(latest) = messages.first() else { + continue; + }; + + let mut room = Room::from(latest); + + if rooms.iter().any(|r| r.id == room.id) { + continue; + } + + let mut public_keys = room.members(); + public_keys.retain(|pk| pk != &public_key); + + // Check if the user has responded to the room + let user_sent = messages.iter().any(|m| m.pubkey == public_key); + + // Determine if the room is ongoing or not + let mut bypassed = false; + + // Check if public keys are from the user's contacts + if bypass_setting { + bypassed = public_keys.iter().any(|k| contacts.contains(k)); + } + + // Set the room's kind based on status + if user_sent || bypassed { + room = room.kind(RoomKind::Ongoing); + } + + rooms.insert(room); + } + + Ok(rooms) + }) + } + + /// Trigger a refresh of the opened chat rooms by their IDs pub fn refresh_rooms(&mut self, ids: Option>, cx: &mut Context) { if let Some(ids) = ids { for room in self.rooms.iter() { @@ -292,15 +561,15 @@ impl ChatRegistry { /// /// If the room doesn't exist, it will be created. /// Updates room ordering based on the most recent messages. - pub fn new_message(&mut self, msg: NewMessage, window: &mut Window, cx: &mut Context) { - let id = msg.rumor.uniq_id(); - let author = msg.rumor.pubkey; + pub fn new_message(&mut self, message: NewMessage, cx: &mut Context) { + let id = message.rumor.uniq_id(); + let author = message.rumor.pubkey; let account = Account::global(cx); if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { - let is_new_event = msg.rumor.created_at > room.read(cx).created_at; - let created_at = msg.rumor.created_at; - let event_for_emit = msg.rumor.clone(); + let is_new_event = message.rumor.created_at > room.read(cx).created_at; + let created_at = message.rumor.created_at; + let event_for_emit = message.rumor.clone(); // Update room room.update(cx, |this, cx| { @@ -314,26 +583,170 @@ impl ChatRegistry { } // Emit the new message to the room - let event_to_emit = event_for_emit.clone(); - cx.defer_in(window, move |this, _window, cx| { - this.emit_message(msg.gift_wrap, event_to_emit, cx); - }); + this.emit_message(message.gift_wrap, event_for_emit.clone(), cx); }); // Resort all rooms in the registry by their created at (after updated) if is_new_event { - cx.defer_in(window, |this, _window, cx| { - this.sort(cx); - }); + self.sort(cx); } } else { // Push the new room to the front of the list - self.add_room(cx.new(|_| Room::from(&msg.rumor)), cx); + self.add_room(cx.new(|_| Room::from(&message.rumor)), cx); // Notify the UI about the new room - cx.defer_in(window, move |_this, _window, cx| { - cx.emit(ChatEvent::NewChatRequest(RoomKind::default())); - }); + cx.emit(ChatEvent::NewChatRequest(RoomKind::default())); } } + + // Unwraps a gift-wrapped event and processes its contents. + async fn extract_rumor(client: &Client, gift_wrap: &Event) -> Result { + // Try to get cached rumor first + if let Ok(event) = Self::get_rumor(client, gift_wrap.id).await { + return Ok(event); + } + + // Try to unwrap with the available signer + let unwrapped = Self::try_unwrap(client, gift_wrap).await?; + let mut rumor_unsigned = unwrapped.rumor; + + // Generate event id for the rumor if it doesn't have one + rumor_unsigned.ensure_id(); + + // Cache the rumor + Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?; + + Ok(rumor_unsigned) + } + + // Helper method to try unwrapping with different signers + async fn try_unwrap(client: &Client, gift_wrap: &Event) -> Result { + let signer = client.signer().await?; + let unwrapped = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?; + + Ok(unwrapped) + } + + /// Helper method to try unwrapping with a custom signer + async fn try_unwrap_custom( + client: &Client, + signer: &T, + gift_wrap: &Event, + ) -> Result + where + T: NostrSigner, + { + let unwrapped = UnwrappedGift::from_gift_wrap(signer, gift_wrap).await?; + let mut rumor_unsigned = unwrapped.rumor; + + // Generate event id for the rumor if it doesn't have one + rumor_unsigned.ensure_id(); + + // Cache the rumor + Self::set_rumor(client, gift_wrap.id, &rumor_unsigned).await?; + + Ok(rumor_unsigned) + } + + /// Stores an unwrapped event in local database with reference to original + async fn set_rumor(client: &Client, id: EventId, rumor: &UnsignedEvent) -> Result<(), Error> { + let rumor_id = rumor.id.context("Rumor is missing an event id")?; + let author = rumor.pubkey; + let conversation = Self::conversation_id(rumor); + + let mut tags = rumor.tags.clone().to_vec(); + + // Add a unique identifier + tags.push(Tag::identifier(id)); + + // Add a reference to the rumor's author + tags.push(Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)), + [author], + )); + + // Add a conversation id + tags.push(Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)), + [conversation.to_string()], + )); + + // Add a reference to the rumor's id + tags.push(Tag::event(rumor_id)); + + // Add references to the rumor's participants + for receiver in rumor.tags.public_keys().copied() { + tags.push(Tag::custom( + TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)), + [receiver], + )); + } + + // Convert rumor to json + let content = rumor.as_json(); + + // Construct the event + let event = EventBuilder::new(Kind::ApplicationSpecificData, content) + .tags(tags) + .sign(&Keys::generate()) + .await?; + + // Save the event to the database + client.database().save_event(&event).await?; + + Ok(()) + } + + /// Retrieves a previously unwrapped event from local database + async fn get_rumor(client: &Client, gift_wrap: EventId) -> Result { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(gift_wrap) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.first_owned() { + UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e)) + } else { + Err(anyhow!("Event is not cached yet.")) + } + } + + /// Get metadata for a list of public keys + async fn get_metadata(client: &Client, public_keys: I) -> Result<(), Error> + where + I: IntoIterator, + { + let authors: Vec = public_keys.into_iter().collect(); + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList]; + + // Return if the list is empty + if authors.is_empty() { + return Err(anyhow!("You need at least one public key".to_string(),)); + } + + let filter = Filter::new() + .limit(authors.len() * kinds.len() + 10) + .authors(authors) + .kinds(kinds); + + // Subscribe to filters to the bootstrap relays + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await?; + + Ok(()) + } + + /// Get the conversation ID for a given rumor (message). + fn conversation_id(rumor: &UnsignedEvent) -> u64 { + let mut hasher = DefaultHasher::new(); + let mut pubkeys: Vec = rumor.tags.public_keys().copied().collect(); + pubkeys.push(rumor.pubkey); + pubkeys.sort(); + pubkeys.dedup(); + pubkeys.hash(&mut hasher); + + hasher.finish() + } } diff --git a/crates/chat/src/message.rs b/crates/chat/src/message.rs index 495f036..b0e34ef 100644 --- a/crates/chat/src/message.rs +++ b/crates/chat/src/message.rs @@ -2,6 +2,18 @@ use std::hash::Hash; use nostr_sdk::prelude::*; +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct NewMessage { + pub gift_wrap: EventId, + pub rumor: UnsignedEvent, +} + +impl NewMessage { + pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self { + Self { gift_wrap, rumor } + } +} + #[derive(Debug, Clone, Hash, PartialEq, Eq)] pub enum Message { User(RenderedMessage), diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 513f6f2..11f0c1d 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -5,12 +5,14 @@ use std::time::Duration; use account::Account; use anyhow::{anyhow, Error}; -use common::display::RenderedProfile; -use common::event::EventUtils; -use gpui::{App, AppContext, Context, EventEmitter, SharedString, SharedUri, Task}; +use common::{EventUtils, RenderedProfile}; +use encryption::{Encryption, SignerKind}; +use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use nostr_sdk::prelude::*; use person::PersonRegistry; -use states::{app_state, SignerKind, SEND_RETRY}; +use state::NostrRegistry; + +const SEND_RETRY: usize = 10; #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)] pub struct SendOptions { @@ -159,30 +161,6 @@ impl Eq for Room {} impl EventEmitter for Room {} -impl From<&Event> for Room { - fn from(val: &Event) -> Self { - let id = val.uniq_id(); - let created_at = val.created_at; - - // Get the members from the event's tags and event's pubkey - let members = val.all_pubkeys(); - - // Get subject from tags - let subject = val - .tags - .find(TagKind::Subject) - .and_then(|tag| tag.content().map(|s| s.to_owned().into())); - - Room { - id, - created_at, - subject, - members, - kind: RoomKind::default(), - } - } -} - impl From<&UnsignedEvent> for Room { fn from(val: &UnsignedEvent) -> Self { let id = val.uniq_id(); @@ -209,15 +187,7 @@ impl From<&UnsignedEvent> for Room { impl Room { /// Constructs a new room with the given receiver and tags. - pub async fn new(subject: Option, receivers: Vec) -> Result { - let client = app_state().client(); - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - if receivers.is_empty() { - return Err(anyhow!("You need to add at least one receiver")); - }; - + pub fn new(subject: Option, author: PublicKey, receivers: Vec) -> Self { // Convert receiver's public keys into tags let mut tags: Tags = Tags::from_list( receivers @@ -235,12 +205,12 @@ impl Room { let mut event = EventBuilder::new(Kind::PrivateDirectMessage, "") .tags(tags) - .build(public_key); + .build(author); // Generate event ID event.ensure_id(); - Ok(Room::from(&event)) + Room::from(&event) } /// Sets the kind of the room and returns the modified room @@ -292,11 +262,11 @@ impl Room { } /// Gets the display image for the room - pub fn display_image(&self, proxy: bool, cx: &App) -> SharedUri { + pub fn display_image(&self, proxy: bool, cx: &App) -> SharedString { if !self.is_group() { self.display_member(cx).avatar(proxy) } else { - SharedUri::from("brand/group.png") + SharedString::from("brand/group.png") } } @@ -358,10 +328,11 @@ impl Room { /// Get messaging relays and encryption keys announcement for each member pub fn connect(&self, cx: &App) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let members = self.members(); cx.background_spawn(async move { - let client = app_state().client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); @@ -396,10 +367,11 @@ impl Room { /// Get all messages belonging to the room pub fn get_messages(&self, cx: &App) -> Task, Error>> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let conversation_id = self.id.to_string(); cx.background_spawn(async move { - let client = app_state().client(); let filter = Filter::new() .kind(Kind::ApplicationSpecificData) .custom_tag(SingleLetterTag::lowercase(Alphabet::C), conversation_id); @@ -419,10 +391,6 @@ impl Room { /// Create a new message event (unsigned) pub fn create_message(&self, content: &str, replies: &[EventId], cx: &App) -> UnsignedEvent { - // Get the app state - let state = app_state(); - let relay_cache = state.relay_cache.read_blocking(); - // Get current user let account = Account::global(cx); let public_key = account.read(cx).public_key(); @@ -437,9 +405,7 @@ impl Room { // NOTE: current user will be removed from the list of receivers for member in self.members.iter() { // Get relay hint if available - let relay_url = relay_cache - .get(member) - .and_then(|urls| urls.iter().nth(0).cloned()); + let relay_url = None; // Construct a public key tag with relay hint let tag = TagStandard::PublicKey { @@ -475,12 +441,12 @@ impl Room { // Construct a direct message event // - // WARNING: never send this event to relays + // WARNING: never sign and send this event to relays let mut event = EventBuilder::new(Kind::PrivateDirectMessage, content) .tags(tags) .build(public_key); - // Generate event ID + // Ensure the event id has been generated event.ensure_id(); event @@ -493,6 +459,14 @@ impl Room { opts: &SendOptions, cx: &App, ) -> Task, Error>> { + let encryption = Encryption::global(cx); + let encryption_key = encryption.read(cx).encryption_key(cx); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let cache_manager = nostr.read(cx).cache_manager(); + let tracker = nostr.read(cx).tracker(); + let rumor = rumor.to_owned(); let opts = opts.to_owned(); @@ -500,16 +474,12 @@ impl Room { let mut members = self.members(); cx.background_spawn(async move { - let state = app_state(); - let client = state.client(); let signer_kind = opts.signer_kind; + let cache = cache_manager.read().await; + let tracker = tracker.read().await; - let relay_cache = state.relay_cache.read().await; - let announcement_cache = state.announcement_cache.read().await; - - let encryption = state.device.read().await.encryption.clone(); // Get the encryption public key - let encryption_pubkey = if let Some(signer) = encryption.as_ref() { + let encryption_pubkey = if let Some(signer) = encryption_key.as_ref() { signer.get_public_key().await.ok() } else { None @@ -523,14 +493,14 @@ impl Room { members.retain(|&pk| pk != user_pubkey); // Determine the signer will be used based on the provided options - let signer = Self::select_signer(&opts.signer_kind, user_signer, encryption)?; + let signer = Self::select_signer(&opts.signer_kind, user_signer, encryption_key)?; // Collect the send reports let mut reports: Vec = vec![]; for member in members.into_iter() { // Get user's messaging relays - let urls = relay_cache.get(&member).cloned().unwrap_or_default(); + let urls = cache.relay(&member).cloned().unwrap_or_default(); // Check if there are any relays to send the message to if urls.is_empty() { @@ -539,8 +509,8 @@ impl Room { } // Get user's encryption public key if available - let encryption = announcement_cache - .get(&member) + let encryption = cache + .announcement(&member) .and_then(|a| a.to_owned().map(|a| a.public_key())); // Skip sending if using encryption signer but receiver's encryption keys not found @@ -551,10 +521,16 @@ impl Room { let receiver = Self::select_receiver(&signer_kind, member, encryption)?; let rumor = rumor.clone(); - let tags = vec![Tag::public_key(member)]; + + // Construct the sealed event + let seal = EventBuilder::seal(&signer, &receiver, rumor.clone()) + .await? + .build(member) + .sign(&signer) + .await?; // Construct the gift wrap event - let event = EventBuilder::gift_wrap(&signer, &receiver, rumor, tags).await?; + let event = EventBuilder::gift_wrap_from_seal(&member, &seal, vec![])?; // Send the gift wrap event to the messaging relays match client.send_event_to(urls, &event).await { @@ -566,8 +542,7 @@ impl Room { if auth { // Wait for authenticated and resent event successfully for attempt in 0..=SEND_RETRY { - let retry_manager = state.tracker().read().await; - let ids = retry_manager.resent_ids(); + let ids = tracker.resent_ids(); // Check if event was successfully resent if let Some(output) = ids.iter().find(|e| e.id() == &id).cloned() { @@ -596,14 +571,20 @@ impl Room { let receiver = Self::select_receiver(&signer_kind, user_pubkey, encryption_pubkey)?; let rumor = rumor.clone(); - let tags = vec![Tag::public_key(user_pubkey)]; + + // Construct the sealed event + let seal = EventBuilder::seal(&signer, &receiver, rumor.clone()) + .await? + .build(user_pubkey) + .sign(&signer) + .await?; // Construct the gift-wrapped event - let event = EventBuilder::gift_wrap(&signer, &receiver, rumor, tags).await?; + let event = EventBuilder::gift_wrap_from_seal(&receiver, &seal, vec![])?; // Only send a backup message to current user if sent successfully to others if opts.backup() && reports.iter().all(|r| r.is_sent_success()) { - let urls = relay_cache.get(&user_pubkey).cloned().unwrap_or_default(); + let urls = cache.relay(&user_pubkey).cloned().unwrap_or_default(); // Check if there are any relays to send the event to if urls.is_empty() { @@ -633,9 +614,12 @@ impl Room { reports: Vec, cx: &App, ) -> Task, Error>> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let cache_manager = nostr.read(cx).cache_manager(); + cx.background_spawn(async move { - let states = app_state(); - let client = states.client(); + let cache = cache_manager.read().await; let mut resend_reports = vec![]; for report in reports.into_iter() { @@ -664,7 +648,7 @@ impl Room { // Process the on hold event if it exists if let Some(event) = report.on_hold { - let urls = states.messaging_relays(receiver).await; + let urls = cache.relay(&receiver).cloned().unwrap_or_default(); // Check if there are any relays to send the event to if urls.is_empty() { @@ -702,15 +686,15 @@ impl Room { fn select_receiver( kind: &SignerKind, - user: PublicKey, + members: PublicKey, encryption: Option, ) -> Result { match kind { SignerKind::Encryption => { Ok(encryption.ok_or_else(|| anyhow!("Receiver's encryption key not found"))?) } - SignerKind::User => Ok(user), - SignerKind::Auto => Ok(encryption.unwrap_or(user)), + SignerKind::User => Ok(members), + SignerKind::Auto => Ok(encryption.unwrap_or(members)), } } } diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index e82ca7b..9119b98 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -5,11 +5,12 @@ edition.workspace = true publish.workspace = true [dependencies] +state = { path = "../state" } ui = { path = "../ui" } theme = { path = "../theme" } common = { path = "../common" } -states = { path = "../states" } account = { path = "../account" } +encryption = { path = "../encryption" } person = { path = "../person" } chat = { path = "../chat" } settings = { path = "../settings" } @@ -17,7 +18,6 @@ settings = { path = "../settings" } gpui.workspace = true gpui_tokio.workspace = true -nostr.workspace = true nostr-sdk.workspace = true anyhow.workspace = true itertools.workspace = true diff --git a/crates/chat_ui/src/actions.rs b/crates/chat_ui/src/actions.rs index 3b6dd33..878bc9c 100644 --- a/crates/chat_ui/src/actions.rs +++ b/crates/chat_ui/src/actions.rs @@ -1,7 +1,7 @@ +use encryption::SignerKind; use gpui::Action; use nostr_sdk::prelude::*; use serde::Deserialize; -use states::SignerKind; #[derive(Action, Clone, PartialEq, Eq, Deserialize)] #[action(namespace = chat, no_json)] diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 2e1cabf..25e4374 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -2,16 +2,15 @@ use std::collections::HashSet; use std::time::Duration; pub use actions::*; -use chat::message::{Message, RenderedMessage}; -use chat::room::{Room, RoomKind, RoomSignal, SendOptions, SendReport}; -use common::display::{RenderedProfile, RenderedTimestamp}; -use common::nip96::nip96_upload; +use chat::{Message, RenderedMessage, Room, RoomKind, RoomSignal, SendOptions, SendReport}; +use common::{nip96_upload, RenderedProfile, RenderedTimestamp}; +use encryption::SignerKind; use gpui::prelude::FluentBuilder; use gpui::{ div, img, list, px, red, relative, rems, svg, white, AnyElement, App, AppContext, ClipboardItem, Context, Element, Entity, EventEmitter, Flatten, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListOffset, ListState, MouseButton, ObjectFit, - ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, SharedUri, + ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, Task, Window, }; use gpui_tokio::Tokio; @@ -22,7 +21,7 @@ use person::PersonRegistry; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use smol::fs; -use states::{app_state, SignerKind}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -147,16 +146,16 @@ impl ChatPanel { cx.subscribe_in(&room, window, move |this, _, signal, window, cx| { match signal { RoomSignal::NewMessage((gift_wrap_id, event)) => { + let nostr = NostrRegistry::global(cx); + let tracker = nostr.read(cx).tracker(); let gift_wrap_id = gift_wrap_id.to_owned(); let message = Message::user(event.clone()); cx.spawn_in(window, async move |this, cx| { - let states = app_state(); - let event_tracker = states.tracker().read().await; - let sent_ids = event_tracker.sent_ids(); + let tracker = tracker.read().await; this.update_in(cx, |this, _window, cx| { - if !sent_ids.contains(&gift_wrap_id) { + if !tracker.sent_ids().contains(&gift_wrap_id) { this.insert_message(message, false, cx); } }) @@ -488,6 +487,9 @@ impl ChatPanel { } fn upload(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + // Get the user's configured NIP96 server let nip96_server = AppSettings::get_media_server(cx); @@ -503,9 +505,8 @@ impl ChatPanel { let path = paths.pop()?; let upload = Tokio::spawn(cx, async move { - let client = app_state().client(); let file = fs::read(path).await.ok()?; - let url = nip96_upload(client, &nip96_server, file).await.ok()?; + let url = nip96_upload(&client, &nip96_server, file).await.ok()?; Some(url) }); @@ -1071,7 +1072,7 @@ impl ChatPanel { .relative() .w_16() .child( - img(SharedUri::from(url.to_string())) + img(url.as_str()) .size_16() .shadow_lg() .rounded(cx.theme().radius) @@ -1238,11 +1239,12 @@ impl ChatPanel { fn on_open_seen_on(&mut self, ev: &SeenOn, window: &mut Window, cx: &mut Context) { let id = ev.0; + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let tracker = nostr.read(cx).tracker(); let task: Task, Error>> = cx.background_spawn(async move { - let states = app_state(); - let client = states.client(); - let event_tracker = states.tracker().read().await; + let tracker = tracker.read().await; let mut relays: Vec = vec![]; let filter = Filter::new() @@ -1252,7 +1254,7 @@ impl ChatPanel { if let Some(event) = client.database().query(filter).await?.first_owned() { if let Some(Ok(id)) = event.tags.identifier().map(EventId::parse) { - if let Some(urls) = event_tracker.seen_on_relays.get(&id).cloned() { + if let Some(urls) = tracker.seen_on_relays.get(&id).cloned() { relays.extend(urls); } } diff --git a/crates/chat_ui/src/text.rs b/crates/chat_ui/src/text.rs index 970af60..cd6df79 100644 --- a/crates/chat_ui/src/text.rs +++ b/crates/chat_ui/src/text.rs @@ -1,7 +1,7 @@ use std::ops::Range; use std::sync::Arc; -use common::display::RenderedProfile; +use common::RenderedProfile; use gpui::{ AnyElement, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString, StyledText, UnderlineStyle, Window, @@ -129,8 +129,6 @@ fn render_plain_text_mut( let range = link.start()..link.end(); let url = link.as_str().to_string(); - log::info!("Found URL: {}", url); - url_matches.push((range, url)); } diff --git a/crates/common/Cargo.toml b/crates/common/Cargo.toml index 4023c61..857374e 100644 --- a/crates/common/Cargo.toml +++ b/crates/common/Cargo.toml @@ -5,12 +5,9 @@ edition.workspace = true publish.workspace = true [dependencies] -states = { path = "../states" } - gpui.workspace = true -nostr-connect.workspace = true nostr-sdk.workspace = true -nostr.workspace = true + anyhow.workspace = true itertools.workspace = true chrono.workspace = true @@ -19,6 +16,8 @@ smol.workspace = true futures.workspace = true reqwest.workspace = true log.workspace = true -webbrowser.workspace = true +dirs = "5.0" qrcode = "0.14.1" +whoami = "1.6.1" +nostr = { git = "https://github.com/rust-nostr/nostr" } diff --git a/crates/states/src/constants.rs b/crates/common/src/constants.rs similarity index 71% rename from crates/states/src/constants.rs rename to crates/common/src/constants.rs index a57ecd2..957f410 100644 --- a/crates/states/src/constants.rs +++ b/crates/common/src/constants.rs @@ -1,44 +1,35 @@ -pub const CLIENT_NAME: &str = "Coop"; -pub const APP_ID: &str = "su.reya.coop"; - -pub const SETTINGS_IDENTIFIER: &str = "coop:settings"; -pub const INBOX_SUB_ID: &str = "inbox"; - -/// Bootstrap Relays. -pub const BOOTSTRAP_RELAYS: [&str; 5] = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://relay.nos.social", - "wss://user.kindpag.es", - "wss://purplepag.es", -]; - -/// Search Relays. -pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"]; - -/// Default relay for Nostr Connect -pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; - -/// Default retry count for fetching NIP-17 relays -pub const RELAY_RETRY: u64 = 2; - -/// Default retry count for sending messages -pub const SEND_RETRY: u64 = 10; - -/// Default timeout (in seconds) for Nostr Connect -pub const NOSTR_CONNECT_TIMEOUT: u64 = 200; - -/// Default timeout (in seconds) for Nostr Connect (Bunker) -pub const BUNKER_TIMEOUT: u64 = 30; - -/// Default timeout (in seconds) for fetching events -pub const QUERY_TIMEOUT: u64 = 3; - -/// Total metadata requests will be grouped. -pub const METADATA_BATCH_LIMIT: usize = 100; - -/// Maximum timeout for grouping metadata requests. (milliseconds) -pub const METADATA_BATCH_TIMEOUT: u64 = 300; - -/// Default width of the sidebar. -pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.; +pub const CLIENT_NAME: &str = "Coop"; +pub const APP_ID: &str = "su.reya.coop"; + +/// Bootstrap Relays. +pub const BOOTSTRAP_RELAYS: [&str; 5] = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://relay.nos.social", + "wss://user.kindpag.es", + "wss://purplepag.es", +]; + +/// Search Relays. +pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"]; + +/// Default relay for Nostr Connect +pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; + +/// Default retry count for fetching NIP-17 relays +pub const RELAY_RETRY: u64 = 2; + +/// Default retry count for sending messages +pub const SEND_RETRY: u64 = 10; + +/// Default timeout (in seconds) for Nostr Connect +pub const NOSTR_CONNECT_TIMEOUT: u64 = 200; + +/// Default timeout (in seconds) for Nostr Connect (Bunker) +pub const BUNKER_TIMEOUT: u64 = 30; + +/// Total metadata requests will be grouped. +pub const METADATA_BATCH_LIMIT: usize = 20; + +/// Default width of the sidebar. +pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.; diff --git a/crates/common/src/display.rs b/crates/common/src/display.rs index dfa4ad2..a435788 100644 --- a/crates/common/src/display.rs +++ b/crates/common/src/display.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use anyhow::{anyhow, Error}; use chrono::{Local, TimeZone}; -use gpui::{Image, ImageFormat, SharedString, SharedUri}; +use gpui::{Image, ImageFormat, SharedString}; use nostr_sdk::prelude::*; use qrcode::render::svg; use qrcode::QrCode; @@ -16,12 +16,12 @@ const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be81 const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl"; pub trait RenderedProfile { - fn avatar(&self, proxy: bool) -> SharedUri; + fn avatar(&self, proxy: bool) -> SharedString; fn display_name(&self) -> SharedString; } impl RenderedProfile for Profile { - fn avatar(&self, proxy: bool) -> SharedUri { + fn avatar(&self, proxy: bool) -> SharedString { self.metadata() .picture .as_ref() @@ -32,12 +32,12 @@ impl RenderedProfile for Profile { "{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1" ); - SharedUri::from(url) + url.into() } else { - SharedUri::from(picture) + picture.into() } }) - .unwrap_or_else(|| SharedUri::from("brand/avatar.png")) + .unwrap_or_else(|| "brand/avatar.png".into()) } fn display_name(&self) -> SharedString { diff --git a/crates/common/src/event.rs b/crates/common/src/event.rs index cf5bafb..c206746 100644 --- a/crates/common/src/event.rs +++ b/crates/common/src/event.rs @@ -48,7 +48,6 @@ impl EventUtils for UnsignedEvent { fn all_pubkeys(&self) -> Vec { let mut public_keys: Vec = self.tags.public_keys().copied().collect(); public_keys.push(self.pubkey); - - public_keys + public_keys.into_iter().unique().sorted().collect() } } diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index a0c6f2c..b68af9d 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,5 +1,68 @@ -pub mod debounced_delay; -pub mod display; -pub mod event; -pub mod nip05; -pub mod nip96; +use std::sync::OnceLock; + +pub use constants::*; +pub use debounced_delay::*; +pub use display::*; +pub use event::*; +pub use nip05::*; +pub use nip96::*; +use nostr_sdk::prelude::*; +pub use paths::*; + +mod constants; +mod debounced_delay; +mod display; +mod event; +mod nip05; +mod nip96; +mod paths; + +static APP_NAME: OnceLock = OnceLock::new(); +static NIP65_RELAYS: OnceLock)>> = OnceLock::new(); +static NIP17_RELAYS: OnceLock> = OnceLock::new(); + +/// Get the app name +pub fn app_name() -> &'static String { + APP_NAME.get_or_init(|| { + let devicename = whoami::devicename(); + let platform = whoami::platform(); + + format!("{CLIENT_NAME} on {platform} ({devicename})") + }) +} + +/// Default NIP-65 Relays. Used for new account +pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option)> { + NIP65_RELAYS.get_or_init(|| { + vec![ + ( + RelayUrl::parse("wss://nostr.mom").unwrap(), + Some(RelayMetadata::Read), + ), + ( + RelayUrl::parse("wss://nostr.bitcoiner.social").unwrap(), + Some(RelayMetadata::Read), + ), + ( + RelayUrl::parse("wss://nostr.oxtr.dev").unwrap(), + Some(RelayMetadata::Write), + ), + ( + RelayUrl::parse("wss://nostr.fmt.wiz.biz").unwrap(), + Some(RelayMetadata::Write), + ), + (RelayUrl::parse("wss://relay.primal.net").unwrap(), None), + (RelayUrl::parse("wss://relay.damus.io").unwrap(), None), + ] + }) +} + +/// Default NIP-17 Relays. Used for new account +pub fn default_nip17_relays() -> &'static Vec { + NIP17_RELAYS.get_or_init(|| { + vec![ + RelayUrl::parse("wss://nip17.com").unwrap(), + RelayUrl::parse("wss://auth.nostr1.com").unwrap(), + ] + }) +} diff --git a/crates/states/src/paths.rs b/crates/common/src/paths.rs similarity index 100% rename from crates/states/src/paths.rs rename to crates/common/src/paths.rs diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index a07892e..41d08c6 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -32,14 +32,17 @@ ui = { path = "../ui" } title_bar = { path = "../title_bar" } theme = { path = "../theme" } common = { path = "../common" } -states = { path = "../states" } +state = { path = "../state" } key_store = { path = "../key_store" } chat = { path = "../chat" } chat_ui = { path = "../chat_ui" } settings = { path = "../settings" } auto_update = { path = "../auto_update" } account = { path = "../account" } +encryption = { path = "../encryption" } +encryption_ui = { path = "../encryption_ui" } person = { path = "../person" } +relay_auth = { path = "../relay_auth" } rust-i18n.workspace = true i18n.workspace = true @@ -49,19 +52,16 @@ reqwest_client.workspace = true nostr-connect.workspace = true nostr-sdk.workspace = true -nostr.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true itertools.workspace = true -dirs.workspace = true log.workspace = true smallvec.workspace = true smol.workspace = true futures.workspace = true oneshot.workspace = true -flume.workspace = true webbrowser.workspace = true indexset = "0.12.3" diff --git a/crates/coop/src/actions.rs b/crates/coop/src/actions.rs index 265326e..fc478cb 100644 --- a/crates/coop/src/actions.rs +++ b/crates/coop/src/actions.rs @@ -1,12 +1,11 @@ use std::sync::Mutex; use gpui::{actions, App}; -use key_store::backend::KeyItem; -use key_store::KeyStore; +use key_store::{KeyItem, KeyStore}; use nostr_connect::prelude::*; -use states::app_state; +use state::NostrRegistry; -actions!(coop, [ReloadMetadata, DarkMode, Settings, Logout, Quit]); +actions!(coop, [KeyringPopup, DarkMode, Settings, Logout, Quit]); actions!(sidebar, [Reload, RelayStatus]); #[derive(Debug, Clone)] @@ -49,10 +48,9 @@ pub fn load_embedded_fonts(cx: &App) { pub fn reset(cx: &mut App) { let backend = KeyStore::global(cx).read(cx).backend(); + let client = NostrRegistry::global(cx).read(cx).client(); cx.spawn(async move |cx| { - let client = app_state().client(); - // Remove the signer client.unset_signer().await; diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index ef1c602..b115f7f 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -1,33 +1,25 @@ -use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; use std::sync::Arc; use account::Account; -use anyhow::{anyhow, Error}; use auto_update::{AutoUpdateStatus, AutoUpdater}; use chat::{ChatEvent, ChatRegistry}; use chat_ui::{CopyPublicKey, OpenPublicKey}; -use common::display::{shorten_pubkey, RenderedProfile}; -use common::event::EventUtils; +use common::{RenderedProfile, DEFAULT_SIDEBAR_WIDTH}; +use encryption::Encryption; +use encryption_ui::EncryptionPanel; use gpui::prelude::FluentBuilder; use gpui::{ - deferred, div, px, relative, rems, App, AppContext, AsyncWindowContext, Axis, ClipboardItem, - Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, - StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, + deferred, div, px, rems, App, AppContext, Axis, ClipboardItem, Context, Entity, + InteractiveElement, IntoElement, ParentElement, Render, SharedString, + StatefulInteractiveElement, Styled, Subscription, Window, }; use i18n::{shared_t, t}; -use itertools::Itertools; -use key_store::backend::KeyItem; -use key_store::KeyStore; +use key_store::{Credential, KeyItem, KeyStore}; use nostr_connect::prelude::*; -use nostr_sdk::prelude::*; use person::PersonRegistry; +use relay_auth::RelayAuth; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; -use states::{ - app_state, default_nip17_relays, default_nip65_relays, Announcement, AuthRequest, Response, - SignalKind, UnwrappingStatus, BOOTSTRAP_RELAYS, DEFAULT_SIDEBAR_WIDTH, -}; use theme::{ActiveTheme, Theme, ThemeMode}; use title_bar::TitleBar; use ui::avatar::Avatar; @@ -36,16 +28,14 @@ use ui::dock_area::dock::DockPlacement; use ui::dock_area::panel::PanelView; use ui::dock_area::{ClosePanel, DockArea, DockItem}; use ui::modal::ModalButtonProps; -use ui::notification::Notification; +use ui::popover::{Popover, PopoverContent}; use ui::popup_menu::PopupMenuExt; -use ui::{h_flex, v_flex, ContextModal, Disableable, IconName, Root, Sizable, StyledExt}; +use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable}; -use crate::actions::{reset, DarkMode, Logout, ReloadMetadata, Settings}; +use crate::actions::{reset, DarkMode, KeyringPopup, Logout, Settings}; use crate::views::compose::compose_button; -use crate::views::setup_relay::SetupRelay; use crate::views::{ - account as account_view, login, new_account, onboarding, preferences, sidebar, user_profile, - welcome, + login, new_account, onboarding, preferences, sidebar, startup, user_profile, welcome, }; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -70,33 +60,27 @@ pub struct ChatSpace { /// App's Dock Area dock: Entity, - /// All authentication requests - auth_requests: Entity>, + /// App's Encryption Panel + encryption_panel: Entity, - /// Local state to determine if the user has set up NIP-17 relays - nip17_ready: bool, + /// Determines if the chat space is ready to use + ready: bool, - /// Local state to determine if the user has set up NIP-65 relays - nip65_ready: bool, - - /// All subscriptions for observing the app state - _subscriptions: SmallVec<[Subscription; 3]>, - - /// All long running tasks - _tasks: SmallVec<[Task<()>; 5]>, + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 4]>, } impl ChatSpace { pub fn new(window: &mut Window, cx: &mut Context) -> Self { let chat = ChatRegistry::global(cx); let keystore = KeyStore::global(cx); + let account = Account::global(cx); let title_bar = cx.new(|_| TitleBar::new()); let dock = cx.new(|cx| DockArea::new(window, cx)); - let auth_requests = cx.new(|_| HashMap::new()); + let encryption_panel = encryption_ui::init(window, cx); let mut subscriptions = smallvec![]; - let mut tasks = smallvec![]; subscriptions.push( // Automatically sync theme with system appearance @@ -106,7 +90,22 @@ impl ChatSpace { ); subscriptions.push( - // Observe keystore changes + // Observe account entity changes + cx.observe_in(&account, window, move |this, state, window, cx| { + if !this.ready && state.read(cx).has_account() { + this.set_default_layout(window, cx); + + // Load all chat room in the database if available + let chat = ChatRegistry::global(cx); + chat.update(cx, |this, cx| { + this.get_rooms(cx); + }); + }; + }), + ); + + subscriptions.push( + // Observe keystore entity changes cx.observe_in(&keystore, window, move |_this, state, window, cx| { if state.read(cx).initialized { let backend = state.read(cx).backend(); @@ -119,10 +118,8 @@ impl ChatSpace { this.update_in(cx, |this, window, cx| { match result { Ok(Some((user, secret))) => { - let public_key = PublicKey::parse(&user).unwrap(); - let secret = String::from_utf8(secret).unwrap(); - - this.set_account_layout(public_key, secret, window, cx); + let credential = Credential::new(user, secret); + this.set_startup_layout(credential, window, cx); } _ => { this.set_onboarding_layout(window, cx); @@ -137,7 +134,7 @@ impl ChatSpace { ); subscriptions.push( - // Handle registry events + // Observe all events emitted by the chat registry cx.subscribe_in(&chat, window, move |this, chat, ev, window, cx| { match ev { ChatEvent::OpenRoom(id) => { @@ -163,448 +160,26 @@ impl ChatSpace { }), ); - tasks.push( - // Handle nostr events in the background - cx.background_spawn(async move { - app_state().handle_notifications().await.ok(); - }), - ); + subscriptions.push( + // Observe the chat registry + cx.observe(&chat, move |this, chat, cx| { + let ids = this.get_all_panels(cx); - tasks.push( - // Listen all metadata requests then batch them into single subscription - cx.background_spawn(async move { - app_state().handle_metadata_batching().await; - }), - ); - - tasks.push( - // Wait for the signer to be set - // Also verify NIP-65 and NIP-17 relays after the signer is set - cx.background_spawn(async move { - app_state().observe_signer().await; - }), - ); - - tasks.push( - // Observe gift wrap process in the background - cx.background_spawn(async move { - app_state().observe_giftwrap().await; - }), - ); - - tasks.push( - // Continuously handle signals from the Nostr channel - cx.spawn_in(window, async move |this, cx| { - Self::handle_signals(this, cx).await + chat.update(cx, |this, cx| { + this.refresh_rooms(ids, cx); + }); }), ); Self { dock, title_bar, - auth_requests, - nip17_ready: true, - nip65_ready: true, + encryption_panel, + ready: false, _subscriptions: subscriptions, - _tasks: tasks, } } - async fn handle_signals(view: WeakEntity, cx: &mut AsyncWindowContext) { - let states = app_state(); - - while let Ok(signal) = states.signal().receiver().recv_async().await { - view.update_in(cx, |this, window, cx| { - let chat = ChatRegistry::global(cx); - let persons = PersonRegistry::global(cx); - let settings = AppSettings::global(cx); - - match signal { - SignalKind::EncryptionNotSet => { - this.init_encryption(window, cx); - } - SignalKind::EncryptionSet(announcement) => { - this.load_encryption(announcement, window, cx); - } - SignalKind::EncryptionRequest(announcement) => { - this.render_request(announcement, window, cx); - } - SignalKind::EncryptionResponse(response) => { - this.receive_encryption(response, window, cx); - } - SignalKind::SignerSet(public_key) => { - // Set the global account state - account::init(public_key, cx); - - // Load user's settings - settings.update(cx, |this, cx| { - this.load_settings(cx); - }); - - // Load all chat rooms - chat.update(cx, |this, cx| { - this.load_rooms(window, cx); - }); - - // Close all opened modals - window.close_all_modals(cx); - - // Setup the default layout for current workspace - this.set_default_layout(window, cx); - } - SignalKind::Auth(req) => { - let url = &req.url; - let auto_auth = AppSettings::get_auto_auth(cx); - let is_authenticated = AppSettings::read_global(cx).is_authenticated(url); - - // Store the auth request in the current view - this.push_auth_request(&req, cx); - - if auto_auth && is_authenticated { - // Automatically authenticate if the relay is authenticated before - this.auth(req, window, cx); - } else { - // Otherwise open the auth request popup - this.open_auth_request(req, window, cx); - } - } - SignalKind::GiftWrapStatus(s) => { - if matches!(s, UnwrappingStatus::Processing | UnwrappingStatus::Complete) { - let all_panels = this.get_all_panel_ids(cx); - - chat.update(cx, |this, cx| { - this.load_rooms(window, cx); - this.refresh_rooms(all_panels, cx); - - if s == UnwrappingStatus::Complete { - this.set_loading(false, cx); - } - }); - } - } - SignalKind::NewProfile(profile) => { - persons.update(cx, |this, cx| { - this.insert_or_update_person(profile, cx); - }); - } - SignalKind::NewMessage(msg) => { - chat.update(cx, |this, cx| { - this.new_message(msg, window, cx); - }); - } - SignalKind::GossipRelaysNotFound => { - this.set_required_gossip_relays(cx); - this.render_setup_gossip_relays_modal(window, cx); - } - SignalKind::MessagingRelaysNotFound => { - this.set_required_dm_relays(cx); - } - }; - }) - .ok(); - } - } - - fn init_encryption(&mut self, window: &mut Window, cx: &mut Context) { - cx.spawn_in(window, async move |this, cx| { - let result = app_state().init_encryption_keys().await; - - this.update_in(cx, |_, window, cx| { - match result { - Ok(_) => { - window.push_notification(t!("encryption.notice"), cx); - } - Err(e) => { - // TODO: ask user to confirm re-running if failed - window.push_notification(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .detach(); - } - - fn load_encryption(&self, ann: Announcement, window: &Window, cx: &Context) { - log::info!("Found encryption announcement: {ann:?}"); - - cx.spawn_in(window, async move |this, cx| { - let state = app_state(); - let result = state.load_encryption_keys(&ann).await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(_) => { - window.push_notification(t!("encryption.reinit"), cx); - } - Err(_) => { - this.request_encryption(ann, window, cx); - } - }; - }) - .ok(); - }) - .detach(); - } - - fn request_encryption(&self, ann: Announcement, window: &Window, cx: &Context) { - cx.spawn_in(window, async move |this, cx| { - let result = app_state().request_encryption_keys().await; - - this.update_in(cx, |this, window, cx| { - match result { - Ok(wait_for_approval) => { - if wait_for_approval { - this.render_pending(ann, window, cx); - } else { - window.push_notification(t!("encryption.success"), cx); - } - } - Err(e) => { - // TODO: ask user to confirm re-running if failed - window.push_notification(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .detach(); - } - - fn receive_encryption(&self, res: Response, window: &Window, cx: &Context) { - cx.spawn_in(window, async move |this, cx| { - let result = app_state().receive_encryption_keys(res).await; - - this.update_in(cx, |_, window, cx| { - match result { - Ok(_) => { - window.push_notification(t!("encryption.success"), cx); - } - Err(e) => { - // TODO: ask user to confirm re-running if failed - window.push_notification(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .detach(); - } - - fn auth(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context) { - let settings = AppSettings::global(cx); - - let challenge = req.challenge.to_owned(); - let url = req.url.to_owned(); - - let challenge_clone = challenge.clone(); - let url_clone = url.clone(); - - // Set Coop is sending auth for this request - self.sending_auth_request(&challenge, cx); - - let task: Task> = cx.background_spawn(async move { - let states = app_state(); - let client = states.client(); - let signer = client.signer().await?; - - // Construct event - let event: Event = EventBuilder::auth(challenge_clone, url_clone.clone()) - .sign(&signer) - .await?; - - // Get the event ID - let id = event.id; - - // Get the relay - let relay = client.pool().relay(url_clone).await?; - let relay_url = relay.url(); - - // Subscribe to notifications - let mut notifications = relay.notifications(); - - // Send the AUTH message - relay.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))?; - - while let Ok(notification) = notifications.recv().await { - match notification { - RelayNotification::Message { - message: RelayMessage::Ok { event_id, .. }, - } => { - if id == event_id { - // Re-subscribe to previous subscription - relay.resubscribe().await?; - - // Get all failed events that need to be resent - let mut tracker = states.tracker().write().await; - - let ids: Vec = tracker - .resend_queue - .iter() - .filter(|(_, url)| relay_url == *url) - .map(|(id, _)| *id) - .collect(); - - for id in ids.into_iter() { - if let Some(relay_url) = tracker.resend_queue.remove(&id) { - if let Some(event) = client.database().event_by_id(&id).await? { - let event_id = relay.send_event(&event).await?; - - let output = Output { - val: event_id, - failed: HashMap::new(), - success: HashSet::from([relay_url]), - }; - - tracker.sent_ids.insert(event_id); - tracker.resent_ids.push(output); - } - } - } - - return Ok(()); - } - } - RelayNotification::AuthenticationFailed => break, - RelayNotification::Shutdown => break, - _ => {} - } - } - - Err(anyhow!("Authentication failed")) - }); - - cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(_) => { - this.update_in(cx, |this, window, cx| { - this.remove_auth_request(&challenge, cx); - - // Save the authenticated relay to automatically authenticate future requests - settings.update(cx, |this, cx| { - this.push_relay(&url, cx); - }); - - // Clear the current notification - window.clear_notification_by_id(SharedString::from(challenge), cx); - - // Push a new notification after current cycle - cx.defer_in(window, move |_, window, cx| { - window.push_notification(format!("{url} has been authenticated"), cx); - }); - }) - .ok(); - } - Err(e) => { - this.update_in(cx, |_, window, cx| { - window.push_notification(Notification::error(e.to_string()), cx); - }) - .ok(); - } - }; - }) - .detach(); - } - - fn open_auth_request(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context) { - let weak_view = cx.entity().downgrade(); - let challenge = req.challenge.to_owned(); - let relay_url = req.url.to_owned(); - let url_as_string = SharedString::from(relay_url.to_string()); - - let note = Notification::new() - .custom_id(SharedString::from(challenge.clone())) - .autohide(false) - .icon(IconName::Info) - .title(t!("auth.label")) - .content(move |_window, cx| { - v_flex() - .gap_2() - .text_sm() - .child(shared_t!("auth.message")) - .child( - v_flex() - .py_1() - .px_1p5() - .rounded_sm() - .text_xs() - .bg(cx.theme().warning_background) - .text_color(cx.theme().warning_foreground) - .child(url_as_string.clone()), - ) - .into_any_element() - }) - .action(move |_window, cx| { - let weak_view = weak_view.clone(); - let req = req.clone(); - let loading = weak_view - .read_with(cx, |this, cx| { - this.is_sending_auth_request(&req.challenge, cx) - }) - .unwrap_or_default(); - - Button::new("approve") - .label(t!("common.approve")) - .small() - .primary() - .loading(loading) - .disabled(loading) - .on_click(move |_e, window, cx| { - weak_view - .update(cx, |this, cx| { - this.auth(req.clone(), window, cx); - }) - .ok(); - }) - }); - - window.push_notification(note, cx); - } - - fn reopen_auth_request(&mut self, window: &mut Window, cx: &mut Context) { - for (_, request) in self.auth_requests.read(cx).clone() { - self.open_auth_request(request, window, cx); - } - } - - fn push_auth_request(&mut self, req: &AuthRequest, cx: &mut Context) { - self.auth_requests.update(cx, |this, cx| { - this.insert(req.url.clone(), req.to_owned()); - cx.notify(); - }); - } - - fn sending_auth_request(&mut self, challenge: &str, cx: &mut Context) { - self.auth_requests.update(cx, |this, cx| { - for (_, req) in this.iter_mut() { - if req.challenge == challenge { - req.sending = true; - cx.notify(); - } - } - }); - } - - fn is_sending_auth_request(&self, challenge: &str, cx: &App) -> bool { - if let Some(req) = self - .auth_requests - .read(cx) - .iter() - .find(|(_, req)| req.challenge == challenge) - { - req.1.sending - } else { - false - } - } - - fn remove_auth_request(&mut self, challenge: &str, cx: &mut Context) { - self.auth_requests.update(cx, |this, cx| { - this.retain(|_, r| r.challenge != challenge); - cx.notify(); - }); - } - fn set_onboarding_layout(&mut self, window: &mut Window, cx: &mut Context) { let panel = Arc::new(onboarding::init(window, cx)); let center = DockItem::panel(panel); @@ -615,14 +190,8 @@ impl ChatSpace { }); } - fn set_account_layout( - &mut self, - public_key: PublicKey, - secret: String, - window: &mut Window, - cx: &mut Context, - ) { - let panel = Arc::new(account_view::init(public_key, secret, window, cx)); + fn set_startup_layout(&mut self, cre: Credential, window: &mut Window, cx: &mut Context) { + let panel = Arc::new(startup::init(cre, window, cx)); let center = DockItem::panel(panel); self.dock.update(cx, |this, cx| { @@ -647,22 +216,13 @@ impl ChatSpace { cx, ); + self.ready = true; self.dock.update(cx, |this, cx| { this.set_left_dock(left, Some(px(DEFAULT_SIDEBAR_WIDTH)), true, window, cx); this.set_center(center, window, cx); }); } - fn set_required_dm_relays(&mut self, cx: &mut Context) { - self.nip17_ready = false; - cx.notify(); - } - - fn set_required_gossip_relays(&mut self, cx: &mut Context) { - self.nip65_ready = false; - cx.notify(); - } - fn on_settings(&mut self, _ev: &Settings, window: &mut Window, cx: &mut Context) { let view = preferences::init(window, cx); @@ -682,51 +242,6 @@ impl ChatSpace { } } - fn on_reload_metadata( - &mut self, - _ev: &ReloadMetadata, - window: &mut Window, - cx: &mut Context, - ) { - let task: Task> = cx.background_spawn(async move { - let states = app_state(); - let client = states.client(); - - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let filter = Filter::new().kind(Kind::PrivateDirectMessage); - - let pubkeys: Vec = client - .database() - .query(filter) - .await? - .into_iter() - .flat_map(|event| event.all_pubkeys()) - .unique() - .collect(); - - let filter = Filter::new() - .kind(Kind::Metadata) - .limit(pubkeys.len()) - .authors(pubkeys); - - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - - Ok(()) - }); - - cx.spawn_in(window, async move |_, cx| { - if task.await.is_ok() { - cx.update(|window, cx| { - window.push_notification(t!("common.refreshed"), cx); - }) - .ok(); - } - }) - .detach(); - } - fn on_sign_out(&mut self, _e: &Logout, _window: &mut Window, cx: &mut Context) { reset(cx); } @@ -755,7 +270,23 @@ impl ChatSpace { window.push_notification(t!("common.copied"), cx); } - fn get_all_panel_ids(&self, cx: &App) -> Option> { + fn on_keyring(&mut self, _ev: &KeyringPopup, window: &mut Window, cx: &mut Context) { + window.open_modal(cx, move |this, _window, _cx| { + this.show_close(true) + .title(shared_t!("keyring_disable.label")) + .child( + v_flex() + .gap_2() + .pb_4() + .text_sm() + .child(shared_t!("keyring_disable.body_1")) + .child(shared_t!("keyring_disable.body_2")) + .child(shared_t!("keyring_disable.body_3")), + ) + }); + } + + fn get_all_panels(&self, cx: &App) -> Option> { let ids: Vec = self .dock .read(cx) @@ -786,361 +317,12 @@ impl ChatSpace { } } - fn render_request(&mut self, ann: Announcement, window: &mut Window, cx: &mut Context) { - let client_name = SharedString::from(ann.client().to_string()); - let target = ann.public_key(); - - let note = Notification::new() - .custom_id(SharedString::from(ann.id().to_hex())) - .autohide(false) - .icon(IconName::Info) - .title(shared_t!("request_encryption.label")) - .content(move |_window, cx| { - v_flex() - .gap_2() - .text_sm() - .child(shared_t!("request_encryption.body")) - .child( - v_flex() - .py_1() - .px_1p5() - .rounded_sm() - .text_xs() - .bg(cx.theme().warning_background) - .text_color(cx.theme().warning_foreground) - .child(client_name.clone()), - ) - .into_any_element() - }) - .action(move |_window, _cx| { - Button::new("approve") - .label(t!("common.approve")) - .small() - .primary() - .loading(false) - .disabled(false) - .on_click(move |_ev, _window, cx| { - cx.background_spawn(async move { - let state = app_state(); - state.response_encryption_keys(target).await.ok(); - }) - .detach(); - }) - }); - - window.push_notification(note, cx); - } - - fn render_pending(&mut self, ann: Announcement, window: &mut Window, cx: &mut Context) { - let client_name = SharedString::from(ann.client().to_string()); - let public_key = shorten_pubkey(ann.public_key(), 8); - let view = cx.entity().downgrade(); - - window.open_modal(cx, move |this, _window, cx| { - let view = view.clone(); - - this.overlay_closable(false) - .show_close(false) - .keyboard(false) - .confirm() - .width(px(460.)) - .button_props( - ModalButtonProps::default() - .cancel_text(t!("common.reset")) - .ok_text(t!("common.hide")), - ) - .title(shared_t!("pending_encryption.label")) - .child( - v_flex() - .gap_2() - .text_sm() - .child( - v_flex() - .justify_center() - .items_center() - .text_center() - .h_16() - .w_full() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .font_semibold() - .child(client_name.clone()) - .child( - div() - .text_xs() - .text_color(cx.theme().text_muted) - .child(SharedString::from(&public_key)), - ), - ) - .child(shared_t!("pending_encryption.body_1", c = client_name)) - .child(shared_t!("pending_encryption.body_2")) - .child( - div() - .text_xs() - .text_color(cx.theme().warning_foreground) - .child(shared_t!("pending_encryption.body_3")), - ), - ) - .on_cancel(move |_ev, window, cx| { - _ = view.update(cx, |_, cx| { - cx.spawn_in(window, async move |this, cx| { - let state = app_state(); - let result = state.init_encryption_keys().await; - - this.update_in(cx, |_, window, cx| { - match result { - Ok(_) => { - window.push_notification(t!("encryption.success"), cx); - window.close_all_modals(cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .detach(); - }); - // false to keep modal open - false - }) - }); - } - - fn render_setup_gossip_relays_modal(&mut self, window: &mut Window, cx: &mut App) { - let relays = default_nip65_relays(); - - window.open_modal(cx, move |this, _window, cx| { - this.overlay_closable(false) - .show_close(false) - .keyboard(false) - .confirm() - .button_props( - ModalButtonProps::default() - .cancel_text(t!("common.configure")) - .ok_text(t!("common.use_default")), - ) - .title(shared_t!("mailbox.modal")) - .child( - v_flex() - .gap_2() - .text_sm() - .child(shared_t!("mailbox.description")) - .child( - v_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().text_muted) - .child(shared_t!("mailbox.write_label")) - .child(shared_t!("mailbox.read_label")), - ) - .child( - div() - .font_semibold() - .text_xs() - .child(shared_t!("common.default")), - ) - .child(v_flex().gap_1().children({ - let mut items = Vec::with_capacity(relays.len()); - - for (url, metadata) in relays { - items.push( - div() - .h_7() - .px_1p5() - .h_flex() - .justify_between() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .text_sm() - .child( - div() - .line_height(relative(1.2)) - .child(SharedString::from(url.to_string())), - ) - .when_some(metadata.as_ref(), |this, metadata| { - this.child( - div() - .text_xs() - .font_semibold() - .line_height(relative(1.2)) - .child(SharedString::from( - metadata.to_string(), - )), - ) - }), - ); - } - - items - })), - ) - .on_cancel(|_, _window, _cx| { - // TODO: add configure relays - // true to close the modal - true - }) - .on_ok(|_, window, cx| { - window - .spawn(cx, async move |cx| { - let states = app_state(); - let relays = default_nip65_relays(); - let result = states.set_nip65(relays).await; - - cx.update(|window, cx| { - match result { - Ok(_) => { - window.close_modal(cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .detach(); - - // false to keep modal open - false - }) - }) - } - - fn render_setup_dm_relays_modal(window: &mut Window, cx: &mut App) { - let relays = default_nip17_relays(); - - window.open_modal(cx, move |this, _window, cx| { - this.overlay_closable(false) - .show_close(false) - .keyboard(false) - .confirm() - .button_props( - ModalButtonProps::default() - .cancel_text(t!("common.configure")) - .ok_text(t!("common.use_default")), - ) - .title(shared_t!("messaging.modal")) - .child( - v_flex() - .gap_2() - .text_sm() - .child(shared_t!("messaging.description")) - .child( - div() - .font_semibold() - .text_xs() - .child(shared_t!("common.default")), - ) - .child(v_flex().gap_1().children({ - let mut items = Vec::with_capacity(relays.len()); - - for url in relays { - items.push( - div() - .h_7() - .px_1p5() - .h_flex() - .justify_between() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .text_sm() - .child( - div() - .line_height(relative(1.2)) - .child(SharedString::from(url.to_string())), - ), - ); - } - - items - })), - ) - .on_cancel(|_, window, cx| { - let view = cx.new(|cx| SetupRelay::new(window, cx)); - let weak_view = view.downgrade(); - - window.open_modal(cx, move |modal, _window, _cx| { - let weak_view = weak_view.clone(); - - modal - .confirm() - .title(shared_t!("relays.modal")) - .child(view.clone()) - .button_props(ModalButtonProps::default().ok_text(t!("common.update"))) - .on_ok(move |_, window, cx| { - weak_view - .update(cx, |this, cx| { - this.set_relays(window, cx); - }) - .ok(); - // true to close the modal - false - }) - }); - - // true to close the modal - true - }) - .on_ok(|_, window, cx| { - window - .spawn(cx, async move |cx| { - let states = app_state(); - let relays = default_nip17_relays(); - let result = states.set_nip17(relays).await; - - cx.update(|window, cx| { - match result { - Ok(_) => { - window.close_modal(cx); - } - Err(e) => { - window.push_notification(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .detach(); - - // false to keep modal open - false - }) - }) - } - - fn render_keyring_warning(window: &mut Window, cx: &mut App) { - window.open_modal(cx, move |this, _window, cx| { - this.overlay_closable(false) - .show_close(false) - .keyboard(false) - .alert() - .button_props(ModalButtonProps::default().ok_text(t!("common.continue"))) - .title(shared_t!("keyring_disable.label")) - .child( - v_flex() - .gap_2() - .text_sm() - .child(shared_t!("keyring_disable.body_1")) - .child(shared_t!("keyring_disable.body_2")) - .child(shared_t!("keyring_disable.body_3")) - .child(shared_t!("keyring_disable.body_4")) - .child( - div() - .text_xs() - .text_color(cx.theme().danger_foreground) - .child(shared_t!("keyring_disable.body_5")), - ), - ) - }); - } - fn titlebar_left(&mut self, _window: &mut Window, cx: &Context) -> impl IntoElement { + let account = Account::global(cx); let chat = ChatRegistry::global(cx); let status = chat.read(cx).loading; - if !Account::has_global(cx) { + if !account.read(cx).has_account() { return div(); } @@ -1164,13 +346,15 @@ impl ChatSpace { } fn titlebar_right(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let file_keystore = KeyStore::global(cx).read(cx).is_using_file_keystore(); let proxy = AppSettings::get_proxy_user_avatars(cx); - let auth_requests = self.auth_requests.read(cx).len(); let auto_update = AutoUpdater::global(cx); + let account = Account::global(cx); + let relay_auth = RelayAuth::global(cx); + let pending_requests = relay_auth.read(cx).pending_requests(cx); + let encryption_panel = self.encryption_panel.downgrade(); h_flex() - .gap_1() + .gap_2() .map(|this| match auto_update.read(cx).status.as_ref() { AutoUpdateStatus::Checking => this.child( div() @@ -1202,20 +386,7 @@ impl ChatSpace { ), _ => this.child(div()), }) - .when(file_keystore, |this| { - this.child( - Button::new("keystore-warning") - .icon(IconName::Warning) - .label("Keyring Disabled") - .ghost() - .xsmall() - .rounded() - .on_click(move |_ev, window, cx| { - Self::render_keyring_warning(window, cx); - }), - ) - }) - .when(auth_requests > 0, |this| { + .when(pending_requests > 0, |this| { this.child( h_flex() .id("requests") @@ -1229,46 +400,99 @@ impl ChatSpace { .text_color(cx.theme().warning_foreground) .hover(|this| this.bg(cx.theme().warning_hover)) .active(|this| this.bg(cx.theme().warning_active)) - .child(shared_t!("auth.requests", u = auth_requests)) - .on_click(cx.listener(move |this, _e, window, cx| { - this.reopen_auth_request(window, cx); - })), - ) - }) - .when(!self.nip17_ready, |this| { - this.child( - Button::new("setup-relays-button") - .icon(IconName::Info) - .label(t!("messaging.button")) - .ghost() - .xsmall() - .rounded() + .child(shared_t!("auth.requests", u = pending_requests)) .on_click(move |_ev, window, cx| { - Self::render_setup_dm_relays_modal(window, cx); + relay_auth.update(cx, |this, cx| { + this.re_ask(window, cx); + }); }), ) }) - .when(Account::has_global(cx), |this| { - let persons = PersonRegistry::global(cx); + .when(account.read(cx).has_account(), |this| { let account = Account::global(cx); let public_key = account.read(cx).public_key(); + + let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get_person(&public_key, cx); + let encryption = Encryption::global(cx); + let has_encryption = encryption.read(cx).has_encryption(cx); + + let keystore = KeyStore::global(cx); + let is_using_file_keystore = keystore.read(cx).is_using_file_keystore(); + + let keyring_label = if is_using_file_keystore { + SharedString::from("Disabled") + } else { + SharedString::from("Enabled") + }; + this.child( - Button::new("user") - .small() - .reverse() - .transparent() - .icon(IconName::CaretDown) - .child(Avatar::new(profile.avatar(proxy)).size(rems(1.49))) - .popup_menu(|this, _window, _cx| { - this.menu(t!("user.dark_mode"), Box::new(DarkMode)) - .menu(t!("user.settings"), Box::new(Settings)) - .separator() - .menu(t!("user.reload_metadata"), Box::new(ReloadMetadata)) - .separator() - .menu(t!("user.sign_out"), Box::new(Logout)) - }), + h_flex() + .gap_1() + .child( + Popover::new("encryption") + .trigger( + Button::new("encryption-trigger") + .tooltip("Manage Encryption Key") + .icon(IconName::Encryption) + .rounded() + .small() + .cta() + .map(|this| match has_encryption { + true => this.ghost_alt(), + false => this.warning(), + }), + ) + .content(move |window, cx| { + let encryption_panel = encryption_panel.clone(); + + cx.new(|cx| { + PopoverContent::new(window, cx, move |_window, _cx| { + if let Some(view) = encryption_panel.upgrade() { + view.clone().into_any_element() + } else { + div().into_any_element() + } + }) + }) + }), + ) + .child( + Button::new("user") + .small() + .reverse() + .transparent() + .icon(IconName::CaretDown) + .child(Avatar::new(profile.avatar(proxy)).size(rems(1.45))) + .popup_menu(move |this, _window, _cx| { + this.label(profile.display_name()) + .menu_with_icon( + t!("user.dark_mode"), + IconName::Sun, + Box::new(DarkMode), + ) + .menu_with_icon( + t!("user.settings"), + IconName::Settings, + Box::new(Settings), + ) + .separator() + .label(SharedString::from("Keyring Service")) + .menu_with_icon_and_disabled( + keyring_label.clone(), + IconName::Encryption, + Box::new(KeyringPopup), + !is_using_file_keystore, + ) + .separator() + .menu_with_icon( + t!("user.sign_out"), + IconName::Logout, + Box::new(Logout), + ) + }), + ), ) }) } @@ -1294,7 +518,7 @@ impl Render for ChatSpace { .on_action(cx.listener(Self::on_sign_out)) .on_action(cx.listener(Self::on_open_pubkey)) .on_action(cx.listener(Self::on_copy_pubkey)) - .on_action(cx.listener(Self::on_reload_metadata)) + .on_action(cx.listener(Self::on_keyring)) .relative() .size_full() .child( diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index ccda44a..e51e82e 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -1,12 +1,12 @@ use std::sync::Arc; use assets::Assets; +use common::{APP_ID, CLIENT_NAME}; use gpui::{ point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, WindowOptions, }; -use states::{app_state, APP_ID, BOOTSTRAP_RELAYS, CLIENT_NAME, SEARCH_RELAYS}; use ui::Root; use crate::actions::{load_embedded_fonts, quit, Quit}; @@ -26,29 +26,6 @@ fn main() { .with_assets(Assets) .with_http_client(Arc::new(reqwest_client::ReqwestClient::new())); - // Initialize app state - let app_state = app_state(); - - // Connect to relays - app.background_executor() - .spawn(async move { - let client = app_state.client(); - - // Get all bootstrapping relays - let mut urls = vec![]; - urls.extend(BOOTSTRAP_RELAYS); - urls.extend(SEARCH_RELAYS); - - // Add relay to the relay pool - for url in urls.into_iter() { - client.add_relay(url).await.ok(); - } - - // Establish connection to relays - client.connect().await; - }) - .detach(); - // Run application app.run(move |cx| { // Load embedded fonts in assets/fonts @@ -102,18 +79,30 @@ fn main() { // Initialize components ui::init(cx); - // Initialize app registry - chat::init(cx); + // Initialize backend for keys storage + key_store::init(cx); + + // Initialize the nostr client + state::init(cx); // Initialize person registry person::init(cx); - // Initialize backend for keys storage - key_store::init(cx); - // Initialize settings settings::init(cx); + // Initialize account state + account::init(cx); + + // Initialize encryption state + encryption::init(cx); + + // Initialize app registry + chat::init(cx); + + // Initialize relay auth registry + relay_auth::init(window, cx); + // Initialize auto update auto_update::init(cx); diff --git a/crates/coop/src/views/backup_keys.rs b/crates/coop/src/views/backup_keys.rs index 03e5181..7003633 100644 --- a/crates/coop/src/views/backup_keys.rs +++ b/crates/coop/src/views/backup_keys.rs @@ -1,7 +1,7 @@ use std::fs; use std::time::Duration; -use dirs::document_dir; +use common::home_dir; use gpui::{ div, AppContext, ClipboardItem, Context, Entity, Flatten, IntoElement, ParentElement, Render, SharedString, Styled, Task, Window, @@ -46,9 +46,8 @@ impl BackupKeys { } pub fn backup(&mut self, window: &mut Window, cx: &mut Context) -> Option> { - let document_dir = document_dir().expect("Failed to get document directory"); - - let path = cx.prompt_for_new_path(&document_dir, Some("My Nostr Account")); + let dir = home_dir(); + let path = cx.prompt_for_new_path(dir, Some("My Nostr Account")); let nsec = self.secret_input.read(cx).value().to_string(); Some(cx.spawn_in(window, async move |this, cx| { diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index db46013..e1d41cf 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -1,11 +1,10 @@ use std::ops::Range; use std::time::Duration; +use account::Account; use anyhow::{anyhow, Error}; -use chat::room::Room; -use chat::ChatRegistry; -use common::display::{RenderedProfile, TextUtils}; -use common::nip05::nip05_profile; +use chat::{ChatRegistry, Room}; +use common::{nip05_profile, RenderedProfile, TextUtils, BOOTSTRAP_RELAYS}; use gpui::prelude::FluentBuilder; use gpui::{ div, px, relative, rems, uniform_list, App, AppContext, Context, Entity, InteractiveElement, @@ -18,7 +17,7 @@ use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; -use states::{app_state, BOOTSTRAP_RELAYS}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -115,7 +114,10 @@ pub struct Compose { } impl Compose { - pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let contacts = cx.new(|_| vec![]); let error_message = cx.new(|_| None); @@ -129,7 +131,6 @@ impl Compose { let mut tasks = smallvec![]; let get_contacts: Task, Error>> = cx.background_spawn(async move { - let client = app_state().client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; let profiles = client.database().contacts(public_key).await?; @@ -194,10 +195,7 @@ impl Compose { } } - async fn request_metadata(public_key: PublicKey) -> Result<(), Error> { - let states = app_state(); - let client = states.client(); - + async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> { let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList]; let filter = Filter::new().author(public_key).kinds(kinds).limit(10); @@ -220,11 +218,13 @@ impl Compose { } fn push_contact(&mut self, contact: Contact, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let pk = contact.public_key; if !self.contacts.read(cx).iter().any(|c| c.public_key == pk) { self._tasks.push(cx.background_spawn(async move { - Self::request_metadata(pk).await.ok(); + Self::request_metadata(&client, pk).await.ok(); })); cx.defer_in(window, |this, window, cx| { @@ -313,6 +313,10 @@ impl Compose { fn submit(&mut self, window: &mut Window, cx: &mut Context) { let chat = ChatRegistry::global(cx); + + let account = Account::global(cx); + let public_key = account.read(cx).public_key(); + let receivers: Vec = self.selected(cx); let subject_input = self.title_input.read(cx).value(); let subject = (!subject_input.is_empty()).then(|| subject_input.to_string()); @@ -322,25 +326,11 @@ impl Compose { return; }; - cx.spawn_in(window, async move |this, cx| { - let result = Room::new(subject, receivers).await; + chat.update(cx, |this, cx| { + this.push_room(cx.new(|_| Room::new(subject, public_key, receivers)), cx); + }); - this.update_in(cx, |this, window, cx| { - match result { - Ok(room) => { - chat.update(cx, |this, cx| { - this.push_room(cx.new(|_| room), cx); - }); - window.close_modal(cx); - } - Err(e) => { - this.set_error(e.to_string(), cx); - } - }; - }) - .ok(); - }) - .detach(); + window.close_modal(cx); } fn set_error(&mut self, error: impl Into, cx: &mut Context) { diff --git a/crates/coop/src/views/edit_profile.rs b/crates/coop/src/views/edit_profile.rs index 5bef198..d6fcfa3 100644 --- a/crates/coop/src/views/edit_profile.rs +++ b/crates/coop/src/views/edit_profile.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use std::time::Duration; use anyhow::Error; -use common::nip96::nip96_upload; +use common::nip96_upload; use gpui::prelude::FluentBuilder; use gpui::{ div, img, App, AppContext, Context, Entity, Flatten, IntoElement, ParentElement, @@ -12,7 +12,7 @@ use i18n::{shared_t, t}; use nostr_sdk::prelude::*; use settings::AppSettings; use smol::fs; -use states::app_state; +use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::input::{InputState, TextInput}; @@ -34,12 +34,18 @@ pub struct EditProfile { impl EditProfile { pub fn new(window: &mut Window, cx: &mut App) -> Entity { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let name_input = cx.new(|cx| InputState::new(window, cx).placeholder(t!("profile.placeholder_name"))); + let avatar_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://example.com/avatar.jpg")); + let website_input = cx.new(|cx| InputState::new(window, cx).placeholder("https://your-website.com")); + let bio_input = cx.new(|cx| { InputState::new(window, cx) .multi_line() @@ -58,7 +64,6 @@ impl EditProfile { }; let task: Task, Error>> = cx.background_spawn(async move { - let client = app_state().client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; let metadata = client @@ -104,8 +109,12 @@ impl EditProfile { } fn upload(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let nip96 = AppSettings::get_media_server(cx); let avatar_input = self.avatar_input.downgrade(); + let paths = cx.prompt_for_paths(PathPromptOptions { files: true, directories: false, @@ -125,9 +134,7 @@ impl EditProfile { let (tx, rx) = oneshot::channel::(); nostr_sdk::async_utility::task::spawn(async move { - if let Ok(url) = - nip96_upload(app_state().client(), &nip96, file_data).await - { + if let Ok(url) = nip96_upload(&client, &nip96, file_data).await { _ = tx.send(url); } }); @@ -168,6 +175,9 @@ impl EditProfile { } pub fn set_metadata(&mut self, cx: &mut Context) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let avatar = self.avatar_input.read(cx).value().to_string(); let name = self.name_input.read(cx).value().to_string(); let bio = self.bio_input.read(cx).value().to_string(); @@ -190,7 +200,6 @@ impl EditProfile { } cx.background_spawn(async move { - let client = app_state().client(); let signer = client.signer().await?; // Sign the new metadata event diff --git a/crates/coop/src/views/login.rs b/crates/coop/src/views/login.rs index 0bcdf96..9546b14 100644 --- a/crates/coop/src/views/login.rs +++ b/crates/coop/src/views/login.rs @@ -1,17 +1,17 @@ use std::time::Duration; use anyhow::anyhow; +use common::BUNKER_TIMEOUT; use gpui::prelude::FluentBuilder; use gpui::{ div, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, Window, }; use i18n::{shared_t, t}; -use key_store::backend::KeyItem; -use key_store::KeyStore; +use key_store::{KeyItem, KeyStore}; use nostr_connect::prelude::*; use smallvec::{smallvec, SmallVec}; -use states::{app_state, BUNKER_TIMEOUT}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; @@ -213,8 +213,10 @@ impl Login { } fn connect(&mut self, signer: NostrConnect, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + cx.background_spawn(async move { - let client = app_state().client(); client.set_signer(signer).await; }) .detach(); @@ -262,6 +264,10 @@ impl Login { pub fn login_with_keys(&mut self, keys: Keys, cx: &mut Context) { let keystore = KeyStore::global(cx).read(cx).backend(); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let username = keys.public_key().to_hex(); let secret = keys.secret_key().to_secret_hex().into_bytes(); @@ -281,7 +287,6 @@ impl Login { // Update the signer cx.background_spawn(async move { - let client = app_state().client(); client.set_signer(keys).await; }) .detach(); diff --git a/crates/coop/src/views/mod.rs b/crates/coop/src/views/mod.rs index 4af54c8..a183275 100644 --- a/crates/coop/src/views/mod.rs +++ b/crates/coop/src/views/mod.rs @@ -1,4 +1,3 @@ -pub mod account; pub mod backup_keys; pub mod compose; pub mod edit_profile; @@ -9,5 +8,6 @@ pub mod preferences; pub mod screening; pub mod setup_relay; pub mod sidebar; +pub mod startup; pub mod user_profile; pub mod welcome; diff --git a/crates/coop/src/views/new_account.rs b/crates/coop/src/views/new_account.rs index edd4992..816c035 100644 --- a/crates/coop/src/views/new_account.rs +++ b/crates/coop/src/views/new_account.rs @@ -1,5 +1,5 @@ use anyhow::{anyhow, Error}; -use common::nip96::nip96_upload; +use common::{default_nip17_relays, default_nip65_relays, nip96_upload, BOOTSTRAP_RELAYS}; use gpui::{ div, relative, rems, AnyElement, App, AppContext, AsyncWindowContext, Context, Entity, EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, @@ -7,12 +7,11 @@ use gpui::{ }; use gpui_tokio::Tokio; use i18n::{shared_t, t}; -use key_store::backend::KeyItem; -use key_store::KeyStore; +use key_store::{KeyItem, KeyStore}; use nostr_sdk::prelude::*; use settings::AppSettings; use smol::fs; -use states::{app_state, default_nip17_relays, default_nip65_relays, BOOTSTRAP_RELAYS}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -106,6 +105,9 @@ impl NewAccount { pub fn set_signer(&mut self, cx: &mut Context) { let keystore = KeyStore::global(cx).read(cx).backend(); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let keys = self.temp_keys.read(cx).clone(); let username = keys.public_key().to_hex(); let secret = keys.secret_key().to_secret_hex().into_bytes(); @@ -130,8 +132,6 @@ impl NewAccount { // Update the signer // Set the client's signer with the current keys let task: Task> = cx.background_spawn(async move { - let client = app_state().client(); - // Set the client's signer with the current keys client.set_signer(keys).await; @@ -178,6 +178,9 @@ impl NewAccount { fn upload(&mut self, window: &mut Window, cx: &mut Context) { self.uploading(true, cx); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + // Get the user's configured NIP96 server let nip96_server = AppSettings::get_media_server(cx); @@ -194,7 +197,7 @@ impl NewAccount { Ok(Some(mut paths)) => { if let Some(path) = paths.pop() { let file = fs::read(path).await?; - let url = nip96_upload(app_state().client(), &nip96_server, file).await?; + let url = nip96_upload(&client, &nip96_server, file).await?; Ok(url) } else { diff --git a/crates/coop/src/views/onboarding.rs b/crates/coop/src/views/onboarding.rs index 73cf698..df41325 100644 --- a/crates/coop/src/views/onboarding.rs +++ b/crates/coop/src/views/onboarding.rs @@ -1,7 +1,7 @@ use std::sync::Arc; use std::time::Duration; -use common::display::TextUtils; +use common::{TextUtils, CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT}; use gpui::prelude::FluentBuilder; use gpui::{ div, img, px, relative, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, @@ -9,11 +9,10 @@ use gpui::{ SharedString, StatefulInteractiveElement, Styled, Task, Window, }; use i18n::{shared_t, t}; -use key_store::backend::KeyItem; -use key_store::KeyStore; +use key_store::{KeyItem, KeyStore}; use nostr_connect::prelude::*; use smallvec::{smallvec, SmallVec}; -use states::{app_state, CLIENT_NAME, NOSTR_CONNECT_RELAY, NOSTR_CONNECT_TIMEOUT}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; @@ -165,8 +164,10 @@ impl Onboarding { } fn connect(&mut self, signer: NostrConnect, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + cx.background_spawn(async move { - let client = app_state().client(); client.set_signer(signer).await; }) .detach(); @@ -223,7 +224,7 @@ impl Focusable for Onboarding { } impl Render for Onboarding { - fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { h_flex() .size_full() .child( diff --git a/crates/coop/src/views/preferences.rs b/crates/coop/src/views/preferences.rs index 3ade856..8c9a043 100644 --- a/crates/coop/src/views/preferences.rs +++ b/crates/coop/src/views/preferences.rs @@ -1,5 +1,5 @@ use account::Account; -use common::display::RenderedProfile; +use common::RenderedProfile; use gpui::http_client::Url; use gpui::{ div, px, relative, rems, App, AppContext, Context, Entity, InteractiveElement, IntoElement, diff --git a/crates/coop/src/views/screening.rs b/crates/coop/src/views/screening.rs index 1aec337..b1233b7 100644 --- a/crates/coop/src/views/screening.rs +++ b/crates/coop/src/views/screening.rs @@ -1,7 +1,7 @@ +use std::sync::Arc; use std::time::Duration; -use common::display::{shorten_pubkey, RenderedProfile, RenderedTimestamp}; -use common::nip05::nip05_verify; +use common::{nip05_verify, shorten_pubkey, RenderedProfile, RenderedTimestamp, BOOTSTRAP_RELAYS}; use gpui::prelude::FluentBuilder; use gpui::{ div, px, relative, rems, uniform_list, App, AppContext, Context, Div, Entity, @@ -13,7 +13,7 @@ use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; -use states::{app_state, BOOTSTRAP_RELAYS}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -35,14 +35,17 @@ pub struct Screening { impl Screening { pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get_person(&public_key, cx); let mut tasks = smallvec![]; - let contact_check: Task), Error>> = - cx.background_spawn(async move { - let client = app_state().client(); + let contact_check: Task), Error>> = cx.background_spawn({ + let client = Arc::clone(&client); + async move { let signer = client.signer().await?; let signer_pubkey = signer.get_public_key().await?; @@ -64,10 +67,10 @@ impl Screening { } Ok((followed, mutual_contacts)) - }); + } + }); let activity_check = cx.background_spawn(async move { - let client = app_state().client(); let filter = Filter::new().author(public_key).limit(1); let mut activity: Option = None; @@ -153,12 +156,12 @@ impl Screening { } fn report(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let public_key = self.profile.public_key(); let task: Task> = cx.background_spawn(async move { - let client = app_state().client(); let signer = client.signer().await?; - let tag = Tag::public_key_report(public_key, Report::Impersonation); let event = EventBuilder::report(vec![tag], "").sign(&signer).await?; diff --git a/crates/coop/src/views/setup_relay.rs b/crates/coop/src/views/setup_relay.rs index 115ef57..c28aad0 100644 --- a/crates/coop/src/views/setup_relay.rs +++ b/crates/coop/src/views/setup_relay.rs @@ -4,15 +4,14 @@ use std::time::Duration; use anyhow::{anyhow, Error}; use gpui::prelude::FluentBuilder; use gpui::{ - div, px, uniform_list, App, AppContext, AsyncWindowContext, Context, Entity, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Subscription, - Task, TextAlign, UniformList, Window, + div, px, uniform_list, App, AppContext, Context, Entity, InteractiveElement, IntoElement, + ParentElement, Render, SharedString, Styled, Subscription, Task, TextAlign, UniformList, + Window, }; use i18n::{shared_t, t}; -use itertools::Itertools; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use states::app_state; +use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::input::{InputEvent, InputState, TextInput}; @@ -39,6 +38,9 @@ pub struct SetupRelay { impl SetupRelay { pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); let mut subscriptions = smallvec![]; @@ -47,7 +49,11 @@ impl SetupRelay { tasks.push( // Load user's relays in the local database cx.spawn_in(window, async move |this, cx| { - if let Ok(relays) = Self::load(cx).await { + let result = cx + .background_spawn(async move { Self::load(&client).await }) + .await; + + if let Ok(relays) = result { this.update(cx, |this, cx| { this.relays.extend(relays); cx.notify(); @@ -79,24 +85,21 @@ impl SetupRelay { } } - fn load(cx: &AsyncWindowContext) -> Task, Error>> { - cx.background_spawn(async move { - let client = app_state().client(); - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; + async fn load(client: &Client) -> Result, Error> { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; - let filter = Filter::new() - .kind(Kind::InboxRelays) - .author(public_key) - .limit(1); + let filter = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .limit(1); - if let Some(event) = client.database().query(filter).await?.first_owned() { - let urls = nip17::extract_owned_relay_list(event).collect(); - Ok(urls) - } else { - Err(anyhow!("Not found.")) - } - }) + if let Some(event) = client.database().query(filter).await?.first_owned() { + let urls = nip17::extract_owned_relay_list(event).collect(); + Ok(urls) + } else { + Err(anyhow!("Not found.")) + } } fn add(&mut self, window: &mut Window, cx: &mut Context) { @@ -150,13 +153,12 @@ impl SetupRelay { return; }; + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let relays = self.relays.clone(); let task: Task> = cx.background_spawn(async move { - let states = app_state(); - let client = states.client(); let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; let tags: Vec = relays .iter() @@ -177,12 +179,6 @@ impl SetupRelay { client.connect_relay(relay).await.ok(); } - // Fetch gift wrap events - states - .get_messages(public_key, &relays.into_iter().collect_vec()) - .await - .ok(); - Ok(()) }); diff --git a/crates/coop/src/views/sidebar/list_item.rs b/crates/coop/src/views/sidebar/list_item.rs index a19964b..3256e26 100644 --- a/crates/coop/src/views/sidebar/list_item.rs +++ b/crates/coop/src/views/sidebar/list_item.rs @@ -1,12 +1,11 @@ use std::rc::Rc; -use chat::room::RoomKind; -use chat::ChatRegistry; +use chat::{ChatRegistry, RoomKind}; use chat_ui::{CopyPublicKey, OpenPublicKey}; use gpui::prelude::FluentBuilder; use gpui::{ div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, - SharedString, SharedUri, StatefulInteractiveElement, Styled, Window, + SharedString, StatefulInteractiveElement, Styled, Window, }; use i18n::t; use nostr_sdk::prelude::*; @@ -26,7 +25,7 @@ pub struct RoomListItem { room_id: Option, public_key: Option, name: Option, - avatar: Option, + avatar: Option, created_at: Option, kind: Option, #[allow(clippy::type_complexity)] @@ -62,7 +61,7 @@ impl RoomListItem { self } - pub fn avatar(mut self, avatar: impl Into) -> Self { + pub fn avatar(mut self, avatar: impl Into) -> Self { self.avatar = Some(avatar.into()); self } diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index cbf98d0..2ee69da 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -3,10 +3,8 @@ use std::ops::Range; use std::time::Duration; use anyhow::{anyhow, Error}; -use chat::room::{Room, RoomKind}; -use chat::{ChatEvent, ChatRegistry}; -use common::debounced_delay::DebouncedDelay; -use common::display::{RenderedTimestamp, TextUtils}; +use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; +use common::{DebouncedDelay, RenderedTimestamp, TextUtils, BOOTSTRAP_RELAYS, SEARCH_RELAYS}; use gpui::prelude::FluentBuilder; use gpui::{ deferred, div, relative, uniform_list, AnyElement, App, AppContext, Context, Entity, @@ -20,7 +18,7 @@ use list_item::RoomListItem; use nostr_sdk::prelude::*; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; -use states::{app_state, BOOTSTRAP_RELAYS, SEARCH_RELAYS}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::dock_area::panel::{Panel, PanelEvent}; @@ -137,8 +135,7 @@ impl Sidebar { } } - async fn request_metadata(public_key: PublicKey) -> Result<(), Error> { - let client = app_state().client(); + async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> { let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList]; let filter = Filter::new().author(public_key).kinds(kinds).limit(10); @@ -152,18 +149,7 @@ impl Sidebar { Ok(()) } - async fn create_temp_room(receiver: PublicKey) -> Result { - // Request to get user's metadata - Self::request_metadata(receiver).await?; - - // Create a temporary room - let room = Room::new(None, vec![receiver]).await?; - - Ok(room) - } - - async fn nip50(query: &str) -> Result, Error> { - let client = app_state().client(); + async fn nip50(client: &Client, query: &str) -> Result, Error> { let signer = client.signer().await?; let public_key = signer.get_public_key().await?; @@ -186,10 +172,13 @@ impl Sidebar { continue; } - // Return a temporary room - if let Ok(room) = Self::create_temp_room(event.pubkey).await { - rooms.insert(room); - } + // Request metadata event's author + Self::request_metadata(client, event.pubkey).await?; + + // Construct room + let room = Room::new(None, public_key, vec![event.pubkey]); + + rooms.insert(room); } } @@ -212,11 +201,13 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let query = query.to_owned(); let query_cloned = query.clone(); let task = smol::future::or( - Tokio::spawn(cx, async move { Self::nip50(&query).await.ok() }), + Tokio::spawn(cx, async move { Self::nip50(&client, &query).await.ok() }), Tokio::spawn(cx, async move { let _ = rx.recv().await.is_ok(); None @@ -263,13 +254,20 @@ impl Sidebar { } fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let address = query.to_owned(); let task = Tokio::spawn(cx, async move { - if let Ok(profile) = common::nip05::nip05_profile(&address).await { - Self::create_temp_room(profile.public_key).await - } else { - Err(anyhow!(t!("sidebar.addr_error"))) + match common::nip05_profile(&address).await { + Ok(profile) => { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + let room = Room::new(None, public_key, vec![profile.public_key]); + + Ok(room) + } + Err(e) => Err(anyhow!(e)), } }); @@ -310,6 +308,9 @@ impl Sidebar { } fn search_by_pubkey(&mut self, query: &str, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let Ok(public_key) = query.to_public_key() else { window.push_notification(t!("common.pubkey_invalid"), cx); self.set_finding(false, window, cx); @@ -317,8 +318,13 @@ impl Sidebar { }; let task: Task> = cx.background_spawn(async move { - // Create a gift wrap event to represent as room - Self::create_temp_room(public_key).await + let signer = client.signer().await?; + let author = signer.get_public_key().await?; + let room = Room::new(None, author, vec![public_key]); + + Self::request_metadata(&client, public_key).await?; + + Ok(room) }); cx.spawn_in(window, async move |this, cx| { @@ -521,14 +527,16 @@ impl Sidebar { fn on_reload(&mut self, _ev: &Reload, window: &mut Window, cx: &mut Context) { ChatRegistry::global(cx).update(cx, |this, cx| { - this.load_rooms(window, cx); + this.get_rooms(cx); }); window.push_notification(t!("common.refreshed"), cx); } fn on_manage(&mut self, _ev: &RelayStatus, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let task: Task, Error>> = cx.background_spawn(async move { - let client = app_state().client(); let subscription = client.subscription(&SubscriptionId::new("inbox")).await; let mut relays: Vec = vec![]; diff --git a/crates/coop/src/views/account.rs b/crates/coop/src/views/startup.rs similarity index 90% rename from crates/coop/src/views/account.rs rename to crates/coop/src/views/startup.rs index 1673049..5aea4ad 100644 --- a/crates/coop/src/views/account.rs +++ b/crates/coop/src/views/startup.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use common::display::RenderedProfile; +use common::{RenderedProfile, BUNKER_TIMEOUT}; use gpui::prelude::FluentBuilder; use gpui::{ div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, @@ -9,12 +9,11 @@ use gpui::{ Window, }; use i18n::{shared_t, t}; -use key_store::backend::KeyItem; -use key_store::KeyStore; +use key_store::{Credential, KeyItem, KeyStore}; use nostr_connect::prelude::*; use person::PersonRegistry; use smallvec::{smallvec, SmallVec}; -use states::{app_state, BUNKER_TIMEOUT}; +use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -24,18 +23,14 @@ use ui::{h_flex, v_flex, ContextModal, Sizable, StyledExt}; use crate::actions::{reset, CoopAuthUrlHandler}; -pub fn init( - public_key: PublicKey, - secret: String, - window: &mut Window, - cx: &mut App, -) -> Entity { - cx.new(|cx| Account::new(public_key, secret, window, cx)) +pub fn init(cre: Credential, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| Startup::new(cre, window, cx)) } -pub struct Account { - public_key: PublicKey, - secret: String, +/// Startup +#[derive(Debug)] +pub struct Startup { + credential: Credential, loading: bool, name: SharedString, @@ -49,13 +44,8 @@ pub struct Account { _tasks: SmallVec<[Task<()>; 1]>, } -impl Account { - fn new( - public_key: PublicKey, - secret: String, - window: &mut Window, - cx: &mut Context, - ) -> Self { +impl Startup { + fn new(credential: Credential, window: &mut Window, cx: &mut Context) -> Self { let tasks = smallvec![]; let mut subscriptions = smallvec![]; @@ -69,8 +59,7 @@ impl Account { ); Self { - public_key, - secret, + credential, loading: false, name: "Account".into(), focus_handle: cx.focus_handle(), @@ -83,9 +72,11 @@ impl Account { fn login(&mut self, window: &mut Window, cx: &mut Context) { self.set_loading(true, cx); + let secret = self.credential.secret(); + // Try to login with bunker - if self.secret.starts_with("bunker://") { - match NostrConnectURI::parse(&self.secret) { + if secret.starts_with("bunker://") { + match NostrConnectURI::parse(secret) { Ok(uri) => { self.login_with_bunker(uri, window, cx); } @@ -98,7 +89,7 @@ impl Account { }; // Fall back to login with keys - match SecretKey::parse(&self.secret) { + match SecretKey::parse(secret) { Ok(secret) => { self.login_with_keys(secret, cx); } @@ -115,6 +106,8 @@ impl Account { window: &mut Window, cx: &mut Context, ) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let keystore = KeyStore::global(cx).read(cx).backend(); // Handle connection in the background @@ -138,8 +131,6 @@ impl Account { this._tasks.push( // Handle connection in the background cx.spawn_in(window, async move |this, cx| { - let client = app_state().client(); - match signer.bunker_uri().await { Ok(_) => { client.set_signer(signer).await; @@ -171,11 +162,12 @@ impl Account { } fn login_with_keys(&mut self, secret: SecretKey, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let keys = Keys::new(secret); // Update the signer cx.background_spawn(async move { - let client = app_state().client(); client.set_signer(keys).await; }) .detach(); @@ -187,7 +179,7 @@ impl Account { } } -impl Panel for Account { +impl Panel for Startup { fn panel_id(&self) -> SharedString { self.name.clone() } @@ -197,19 +189,21 @@ impl Panel for Account { } } -impl EventEmitter for Account {} +impl EventEmitter for Startup {} -impl Focusable for Account { +impl Focusable for Startup { fn focus_handle(&self, _: &App) -> gpui::FocusHandle { self.focus_handle.clone() } } -impl Render for Account { +impl Render for Startup { fn render(&mut self, _window: &mut gpui::Window, cx: &mut Context) -> impl IntoElement { let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get_person(&self.public_key, cx); - let bunker = self.secret.starts_with("bunker://"); + let bunker = self.credential.secret().starts_with("bunker://"); + let profile = persons + .read(cx) + .get_person(&self.credential.public_key(), cx); v_flex() .image_cache(self.image_cache.clone()) diff --git a/crates/coop/src/views/user_profile.rs b/crates/coop/src/views/user_profile.rs index 13ac16e..425f5c6 100644 --- a/crates/coop/src/views/user_profile.rs +++ b/crates/coop/src/views/user_profile.rs @@ -1,7 +1,6 @@ use std::time::Duration; -use common::display::RenderedProfile; -use common::nip05::nip05_verify; +use common::{nip05_verify, RenderedProfile}; use gpui::prelude::FluentBuilder; use gpui::{ div, relative, rems, App, AppContext, ClipboardItem, Context, Entity, IntoElement, @@ -13,7 +12,7 @@ use nostr_sdk::prelude::*; use person::PersonRegistry; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; -use states::app_state; +use state::NostrRegistry; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; @@ -33,13 +32,15 @@ pub struct UserProfile { impl UserProfile { pub fn new(target: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let persons = PersonRegistry::global(cx); let profile = persons.read(cx).get_person(&target, cx); let mut tasks = smallvec![]; let check_follow: Task> = cx.background_spawn(async move { - let client = app_state().client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; let contact_list = client.database().contacts_public_keys(public_key).await?; diff --git a/crates/encryption/Cargo.toml b/crates/encryption/Cargo.toml new file mode 100644 index 0000000..9c64c66 --- /dev/null +++ b/crates/encryption/Cargo.toml @@ -0,0 +1,22 @@ +[package] +name = "encryption" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +state = { path = "../state" } +common = { path = "../common" } +account = { path = "../account" } + +gpui.workspace = true +nostr-sdk.workspace = true + +anyhow.workspace = true +smallvec.workspace = true +smol.workspace = true +futures.workspace = true +flume.workspace = true +log.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/encryption/src/lib.rs b/crates/encryption/src/lib.rs new file mode 100644 index 0000000..52694aa --- /dev/null +++ b/crates/encryption/src/lib.rs @@ -0,0 +1,631 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use account::Account; +use anyhow::{anyhow, Context as AnyhowContext, Error}; +use common::app_name; +use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; +use nostr_sdk::prelude::*; +pub use signer::*; +use smallvec::{smallvec, SmallVec}; +use state::{Announcement, NostrRegistry, Response}; + +mod signer; + +pub fn init(cx: &mut App) { + Encryption::set_global(cx.new(Encryption::new), cx); +} + +struct GlobalEncryption(Entity); + +impl Global for GlobalEncryption {} + +pub struct Encryption { + /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + /// + /// Client Signer that used for communication between devices + client_signer: Entity>>, + + /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + /// + /// Encryption Key used for encryption and decryption instead of the user's identity + pub encryption: Entity>>, + + /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + /// + /// Encryption Key announcement + announcement: Option>, + + /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + /// + /// Requests for encryption keys from other devices + requests: Entity>, + + /// Async task for handling notifications + handle_notifications: Option>, + + /// Async task for handling requests + handle_requests: Option>, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, + + /// Tasks for asynchronous operations + _tasks: SmallVec<[Task<()>; 2]>, +} + +impl Encryption { + /// Retrieve the global encryption state + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + /// Set the global encryption instance + fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalEncryption(state)); + } + + /// Create a new encryption instance + fn new(cx: &mut Context) -> Self { + let account = Account::global(cx); + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let requests = cx.new(|_| HashSet::default()); + let encryption = cx.new(|_| None); + let client_signer = cx.new(|_| None); + + let mut subscriptions = smallvec![]; + let mut tasks = smallvec![]; + + subscriptions.push( + // Observe the account state + cx.observe(&account, |this, state, cx| { + if state.read(cx).has_account() && !this.has_encryption(cx) { + this.get_announcement(cx); + } + }), + ); + + tasks.push( + // Get the client key + cx.spawn(async move |this, cx| { + match Self::get_keys(&client, "client").await { + Ok(keys) => { + this.update(cx, |this, cx| { + this.set_client(Arc::new(keys), cx); + }) + .expect("Entity has been released"); + } + Err(_) => { + let keys = Keys::generate(); + let secret = keys.secret_key().to_secret_hex(); + + // Store the key in the database for future use + Self::set_keys(&client, "client", secret).await.ok(); + + // Update global state + this.update(cx, |this, cx| { + this.set_client(Arc::new(keys), cx); + }) + .expect("Entity has been released"); + } + } + }), + ); + + Self { + requests, + client_signer, + encryption, + announcement: None, + handle_notifications: None, + handle_requests: None, + _subscriptions: subscriptions, + _tasks: tasks, + } + } + + /// Encrypt and store a key in the local database. + async fn set_keys(client: &Client, kind: T, value: String) -> Result<(), Error> + where + T: Into, + { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + // Encrypt the value + let content = signer.nip44_encrypt(&public_key, value.as_ref()).await?; + + // Construct the application data event + let event = EventBuilder::new(Kind::ApplicationSpecificData, content) + .tag(Tag::identifier(format!("coop:{}", kind.into()))) + .build(public_key) + .sign(&Keys::generate()) + .await?; + + // Save the event to the database + client.database().save_event(&event).await?; + + Ok(()) + } + + /// Get and decrypt a key from the local database. + async fn get_keys(client: &Client, kind: T) -> Result + where + T: Into, + { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(format!("coop:{}", kind.into())); + + if let Some(event) = client.database().query(filter).await?.first() { + let content = signer.nip44_decrypt(&public_key, &event.content).await?; + let secret = SecretKey::parse(&content)?; + let keys = Keys::new(secret); + + Ok(keys) + } else { + Err(anyhow!("Key not found")) + } + } + + fn get_announcement(&mut self, cx: &mut Context) { + let task = self._get_announcement(cx); + + self._tasks.push(cx.spawn(async move |this, cx| { + cx.background_executor().timer(Duration::from_secs(5)).await; + + match task.await { + Ok(announcement) => { + this.update(cx, |this, cx| { + this.load_encryption(&announcement, cx); + // Set the announcement + this.announcement = Some(Arc::new(announcement)); + cx.notify(); + }) + .expect("Entity has been released"); + } + Err(err) => { + log::error!("Failed to get announcement: {}", err); + } + }; + })); + } + + fn _get_announcement(&self, cx: &App) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + cx.background_spawn(async move { + let user_signer = client.signer().await?; + let public_key = user_signer.get_public_key().await?; + + let filter = Filter::new() + .kind(Kind::Custom(10044)) + .author(public_key) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.first() { + Ok(Self::extract_announcement(event)?) + } else { + Err(anyhow!("Announcement not found")) + } + }) + } + + /// Load the encryption key that stored in the database + /// + /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + fn load_encryption(&mut self, announcement: &Announcement, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let n = announcement.public_key(); + + cx.spawn(async move |this, cx| { + let result = Self::get_keys(&client, "encryption").await; + + this.update(cx, |this, cx| { + if let Ok(keys) = result { + if keys.public_key() == n { + this.set_encryption(Arc::new(keys), cx); + this.listen_request(cx); + } + } + }) + .expect("Entity has been released"); + }) + .detach(); + } + + /// Listen for the encryption key request from other devices + /// + /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + pub fn listen_request(&mut self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let (tx, rx) = flume::bounded::(50); + + let task: Task> = cx.background_spawn({ + let client = Arc::clone(&client); + + async move { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + let id = SubscriptionId::new("listen-request"); + + let filter = Filter::new() + .author(public_key) + .kind(Kind::Custom(4454)) + .since(Timestamp::now()); + + // Unsubscribe from the previous subscription + client.unsubscribe(&id).await; + + // Subscribe to the new subscription + client.subscribe_with_id(id, filter, None).await?; + + Ok(()) + } + }); + + // Run this task and finish in the background + task.detach(); + + // Handle notifications + self.handle_notifications = Some(cx.background_spawn(async move { + let mut notifications = client.notifications(); + let mut processed_events = HashSet::new(); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, .. } = notification else { + // Skip if the notification is not a message + continue; + }; + + if let RelayMessage::Event { event, .. } = message { + if !processed_events.insert(event.id) { + // Skip if the event has already been processed + continue; + } + + if event.kind != Kind::Custom(4454) { + // Skip if the event is not a encryption events + continue; + }; + + if Self::is_self_authored(&client, &event).await { + if let Ok(announcement) = Self::extract_announcement(&event) { + tx.send_async(announcement).await.ok(); + } + } + } + } + })); + + // Handle requests + self.handle_requests = Some(cx.spawn(async move |this, cx| { + while let Ok(request) = rx.recv_async().await { + this.update(cx, |this, cx| { + this.set_request(request, cx); + }) + .expect("Entity has been released"); + } + })); + } + + /// Overwrite the encryption key + /// + /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + pub fn new_encryption(&self, cx: &App) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let keys = Keys::generate(); + let public_key = keys.public_key(); + let secret = keys.secret_key().to_secret_hex(); + + // Create a task announce the encryption key + cx.background_spawn(async move { + // Store the encryption key to the database + Self::set_keys(&client, "encryption", secret).await?; + + let signer = client.signer().await?; + + // Construct the announcement event + let event = EventBuilder::new(Kind::Custom(10044), "") + .tags(vec![ + Tag::client(app_name()), + Tag::custom(TagKind::custom("n"), vec![public_key]), + ]) + .sign(&signer) + .await?; + + // Send the announcement event to user's relays + client.send_event(&event).await?; + + Ok(keys) + }) + } + + /// Send a request for encryption key from other clients + /// + /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + pub fn send_request(&self, cx: &App) -> Task, Error>> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + // Get the client signer + let Some(client_signer) = self.client_signer.read(cx).clone() else { + return Task::ready(Err(anyhow!("Client Signer is required"))); + }; + + cx.background_spawn(async move { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + let client_pubkey = client_signer.get_public_key().await?; + + // Get the encryption key approval response from the database first + let filter = Filter::new() + .kind(Kind::Custom(4455)) + .author(public_key) + .pubkey(client_pubkey) + .limit(1); + + match client.database().query(filter).await?.first_owned() { + Some(event) => { + let root_device = event + .tags + .find(TagKind::custom("P")) + .and_then(|tag| tag.content()) + .and_then(|content| PublicKey::parse(content).ok()) + .context("Invalid event's tags")?; + + let payload = event.content.as_str(); + let decrypted = client_signer.nip44_decrypt(&root_device, payload).await?; + + let secret = SecretKey::from_hex(&decrypted)?; + let keys = Keys::new(secret); + + Ok(Some(keys)) + } + None => { + // Construct encryption keys request event + let event = EventBuilder::new(Kind::Custom(4454), "") + .tags(vec![ + Tag::client(app_name()), + Tag::custom(TagKind::custom("pubkey"), vec![client_pubkey]), + ]) + .sign(&signer) + .await?; + + // Send a request for encryption keys from other devices + client.send_event(&event).await?; + + // Create a unique ID to control the subscription later + let subscription_id = SubscriptionId::new("listen-response"); + + let filter = Filter::new() + .kind(Kind::Custom(4455)) + .author(public_key) + .pubkey(client_pubkey) + .since(Timestamp::now()); + + // Subscribe to the approval response event + client + .subscribe_with_id(subscription_id, filter, None) + .await?; + + Ok(None) + } + } + }) + } + + /// Send the approval response event + /// + /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + pub fn send_response(&self, target: PublicKey, cx: &App) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + // Get the client signer + let Some(client_signer) = self.client_signer.read(cx).clone() else { + return Task::ready(Err(anyhow!("Client Signer is required"))); + }; + + cx.background_spawn(async move { + let encryption = Self::get_keys(&client, "encryption").await?; + let client_pubkey = client_signer.get_public_key().await?; + + // Encrypt the encryption keys with the client's signer + let payload = client_signer + .nip44_encrypt(&target, &encryption.secret_key().to_secret_hex()) + .await?; + + // Construct the response event + // + // P tag: the current client's public key + // p tag: the requester's public key + let event = EventBuilder::new(Kind::Custom(4455), payload) + .tags(vec![ + Tag::custom(TagKind::custom("P"), vec![client_pubkey]), + Tag::public_key(target), + ]) + .sign(&client_signer) + .await?; + + // Get the current user's signer and public key + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + // Get the current user's relay list + let urls: Vec = client + .database() + .relay_list(public_key) + .await? + .into_iter() + .filter_map(|(url, metadata)| { + if metadata.is_none() || metadata == Some(RelayMetadata::Read) { + Some(url) + } else { + None + } + }) + .collect(); + + // Send the response event to the user's relay list + client.send_event_to(urls, &event).await?; + + Ok(()) + }) + } + + /// Wait for the approval response event + /// + /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md + pub fn wait_for_approval(&self, cx: &App) -> Task> { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let client_signer = self.client_signer.read(cx).clone().unwrap(); + let mut processed_events = HashSet::new(); + + cx.background_spawn(async move { + let mut notifications = client.notifications(); + log::info!("Listening for notifications"); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, .. } = notification else { + // Skip non-message notifications + continue; + }; + + if let RelayMessage::Event { event, .. } = message { + if !processed_events.insert(event.id) { + // Skip if the event has already been processed + continue; + } + + if event.kind != Kind::Custom(4455) { + // Skip non-gift wrap events + continue; + } + + if let Ok(response) = Self::extract_response(&client, &event).await { + let public_key = response.public_key(); + let payload = response.payload(); + + // Decrypt the payload using the client signer + let decrypted = client_signer.nip44_decrypt(&public_key, payload).await?; + let secret = SecretKey::parse(&decrypted)?; + // Construct the encryption keys + let keys = Keys::new(secret); + + return Ok(keys); + } + } + } + + Err(anyhow!("Failed to handle Encryption Key approval response")) + }) + } + + /// Set the client signer for the account + pub fn set_client(&mut self, signer: Arc, cx: &mut Context) { + self.client_signer.update(cx, |this, cx| { + *this = Some(signer); + cx.notify(); + }); + } + + /// Set the encryption signer for the account + pub fn set_encryption(&mut self, signer: Arc, cx: &mut Context) { + self.encryption.update(cx, |this, cx| { + *this = Some(signer); + cx.notify(); + }); + } + + /// Check if the account entity has an encryption key + pub fn has_encryption(&self, cx: &App) -> bool { + self.encryption.read(cx).is_some() + } + + /// Returns the encryption key + pub fn encryption_key(&self, cx: &App) -> Option> { + self.encryption.read(cx).clone() + } + + /// Returns the encryption announcement + pub fn announcement(&self) -> Option> { + self.announcement.clone() + } + + /// Returns the encryption requests + pub fn requests(&self) -> Entity> { + self.requests.clone() + } + + /// Push the encryption request + pub fn set_request(&mut self, request: Announcement, cx: &mut Context) { + self.requests.update(cx, |this, cx| { + this.insert(request); + cx.notify(); + }); + } + + /// Extract an encryption keys announcement from an event. + fn extract_announcement(event: &Event) -> Result { + let public_key = event + .tags + .iter() + .find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "pubkey") + .and_then(|tag| tag.content()) + .and_then(|c| PublicKey::parse(c).ok()) + .context("Cannot parse public key from the event's tags")?; + + let client_name = event + .tags + .find(TagKind::Client) + .and_then(|tag| tag.content()) + .map(|c| c.to_string()) + .context("Cannot parse client name from the event's tags")?; + + Ok(Announcement::new(event.id, client_name, public_key)) + } + + /// Extract an encryption keys response from an event. + async fn extract_response(client: &Client, event: &Event) -> Result { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + if event.pubkey != public_key { + return Err(anyhow!("Event does not belong to current user")); + } + + let client_pubkey = event + .tags + .find(TagKind::custom("P")) + .and_then(|tag| tag.content()) + .and_then(|c| PublicKey::parse(c).ok()) + .context("Cannot parse public key from the event's tags")?; + + Ok(Response::new(event.content.clone(), client_pubkey)) + } + + /// Check if event is published by current user + async fn is_self_authored(client: &Client, event: &Event) -> bool { + if let Ok(signer) = client.signer().await { + if let Ok(public_key) = signer.get_public_key().await { + return public_key == event.pubkey; + } + } + false + } +} diff --git a/crates/encryption/src/signer.rs b/crates/encryption/src/signer.rs new file mode 100644 index 0000000..59513ed --- /dev/null +++ b/crates/encryption/src/signer.rs @@ -0,0 +1,9 @@ +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Deserialize, Serialize)] +pub enum SignerKind { + Encryption, + #[default] + User, + Auto, +} diff --git a/crates/encryption_ui/Cargo.toml b/crates/encryption_ui/Cargo.toml new file mode 100644 index 0000000..62ba28f --- /dev/null +++ b/crates/encryption_ui/Cargo.toml @@ -0,0 +1,27 @@ +[package] +name = "encryption_ui" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +state = { path = "../state" } +ui = { path = "../ui" } +theme = { path = "../theme" } +common = { path = "../common" } +account = { path = "../account" } +encryption = { path = "../encryption" } +person = { path = "../person" } +settings = { path = "../settings" } + +gpui.workspace = true + +nostr-sdk.workspace = true +anyhow.workspace = true +itertools.workspace = true +smallvec.workspace = true +smol.workspace = true +log.workspace = true +futures.workspace = true +serde.workspace = true +serde_json.workspace = true diff --git a/crates/encryption_ui/src/lib.rs b/crates/encryption_ui/src/lib.rs new file mode 100644 index 0000000..675363c --- /dev/null +++ b/crates/encryption_ui/src/lib.rs @@ -0,0 +1,407 @@ +use std::sync::Arc; +use std::time::Duration; + +use anyhow::anyhow; +use common::shorten_pubkey; +use encryption::Encryption; +use futures::FutureExt; +use gpui::prelude::FluentBuilder; +use gpui::{ + div, px, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, SharedString, + Styled, Subscription, Window, +}; +use smallvec::{smallvec, SmallVec}; +use state::Announcement; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::notification::Notification; +use ui::{h_flex, v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt}; + +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| EncryptionPanel::new(window, cx)) +} + +#[derive(Debug)] +pub struct EncryptionPanel { + /// Whether the panel is currently requesting encryption. + requesting: bool, + + /// Whether the panel is currently creating encryption. + creating: bool, + + /// Whether the panel is currently showing an error. + error: Entity>, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, +} + +impl EncryptionPanel { + pub fn new(window: &mut Window, cx: &mut Context) -> Self { + let error = cx.new(|_| None); + + let encryption = Encryption::global(cx); + let requests = encryption.read(cx).requests(); + + let mut subscriptions = smallvec![]; + + subscriptions.push( + // Observe encryption request + cx.observe_in(&requests, window, |this, state, window, cx| { + for req in state.read(cx).clone().into_iter() { + this.ask_for_approval(req, window, cx); + } + }), + ); + + Self { + requesting: false, + creating: false, + error, + _subscriptions: subscriptions, + } + } + + fn set_requesting(&mut self, status: bool, cx: &mut Context) { + self.requesting = status; + cx.notify(); + } + + fn set_creating(&mut self, status: bool, cx: &mut Context) { + self.creating = status; + cx.notify(); + } + + fn set_error(&mut self, error: impl Into, cx: &mut Context) { + self.error.update(cx, |this, cx| { + *this = Some(error.into()); + cx.notify(); + }); + } + + fn request(&mut self, window: &mut Window, cx: &mut Context) { + let encryption = Encryption::global(cx); + let send_request = encryption.read(cx).send_request(cx); + + // Ensure the user has not sent multiple requests + if self.requesting { + return; + } + self.set_requesting(true, cx); + + cx.spawn_in(window, async move |this, cx| { + match send_request.await { + Ok(Some(keys)) => { + this.update(cx, |this, cx| { + this.set_requesting(false, cx); + // Set the encryption key + encryption.update(cx, |this, cx| { + this.set_encryption(Arc::new(keys), cx); + }); + }) + .expect("Entity has been released"); + } + Ok(None) => { + this.update_in(cx, |this, window, cx| { + this.wait_for_approval(window, cx); + }) + .expect("Entity has been released"); + } + Err(e) => { + this.update(cx, |this, cx| { + this.set_requesting(false, cx); + this.set_error(e.to_string(), cx); + }) + .expect("Entity has been released"); + } + } + }) + .detach(); + } + + fn new_encryption(&mut self, window: &mut Window, cx: &mut Context) { + let encryption = Encryption::global(cx); + let reset = encryption.read(cx).new_encryption(cx); + + // Ensure the user has not sent multiple requests + if self.requesting { + return; + } + self.set_creating(true, cx); + + cx.spawn_in(window, async move |this, cx| { + match reset.await { + Ok(keys) => { + this.update(cx, |this, cx| { + this.set_creating(false, cx); + // Set the encryption key + encryption.update(cx, |this, cx| { + this.set_encryption(Arc::new(keys), cx); + this.listen_request(cx); + }); + }) + .expect("Entity has been released"); + } + Err(e) => { + this.update(cx, |this, cx| { + this.set_creating(false, cx); + this.set_error(e.to_string(), cx); + }) + .expect("Entity has been released"); + } + } + }) + .detach(); + } + + fn wait_for_approval(&mut self, window: &mut Window, cx: &mut Context) { + let encryption = Encryption::global(cx); + let wait_for_approval = encryption.read(cx).wait_for_approval(cx); + + cx.spawn_in(window, async move |this, cx| { + let timeout = cx.background_executor().timer(Duration::from_secs(30)); + + let result = futures::select! { + result = wait_for_approval.fuse() => { + // Ok(keys) + result + }, + _ = timeout.fuse() => { + Err(anyhow!("Timeout")) + } + }; + + this.update(cx, |this, cx| { + match result { + Ok(keys) => { + this.set_requesting(false, cx); + // Set the encryption key + encryption.update(cx, |this, cx| { + this.set_encryption(Arc::new(keys), cx); + }); + } + Err(e) => { + this.set_error(e.to_string(), cx); + } + }; + }) + .expect("Entity has been released"); + }) + .detach(); + } + + fn ask_for_approval(&mut self, req: Announcement, window: &mut Window, cx: &mut Context) { + let client_name = SharedString::from(req.client().to_string()); + let target = req.public_key(); + + let note = Notification::new() + .custom_id(SharedString::from(req.id().to_hex())) + .autohide(false) + .icon(IconName::Info) + .title(SharedString::from("Encryption Key Request")) + .content(move |_window, cx| { + v_flex() + .gap_2() + .text_sm() + .child(SharedString::from( + "You've requested for the Encryption Key from:", + )) + .child( + v_flex() + .py_1() + .px_1p5() + .rounded_sm() + .text_xs() + .bg(cx.theme().warning_background) + .text_color(cx.theme().warning_foreground) + .child(client_name.clone()), + ) + .into_any_element() + }) + .action(move |_window, _cx| { + Button::new("approve") + .label("Approve") + .small() + .primary() + .loading(false) + .disabled(false) + .on_click(move |_ev, _window, cx| { + let encryption = Encryption::global(cx); + let send_response = encryption.read(cx).send_response(target, cx); + + send_response.detach(); + }) + }); + + // Push the notification to the current window + window.push_notification(note, cx); + + // Focus the window if it's not active + if !window.is_window_hovered() { + window.activate_window(); + } + } +} + +impl Render for EncryptionPanel { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + const NOTICE: &str = "Found an Encryption Announcement"; + const SUGGEST: &str = "Please request the Encryption Key to continue using."; + + const DESCRIPTION: &str = "Encryption Key is used to replace the User's Identity in encryption and decryption messages. Coop will automatically fallback to User's Identity if needed."; + const WARNING: &str = "Encryption Key is still in the alpha stage. Please be cautious."; + + let encryption = Encryption::global(cx); + let has_encryption = encryption.read(cx).has_encryption(cx); + + v_flex() + .p_2() + .max_w(px(340.)) + .w(px(340.)) + .text_sm() + .when(has_encryption, |this| { + this.child( + h_flex() + .gap_2() + .w_full() + .text_xs() + .font_semibold() + .child( + Icon::new(IconName::CheckCircleFill) + .small() + .text_color(cx.theme().element_active), + ) + .child(SharedString::from("Encryption Key has been set")), + ) + }) + .when(!has_encryption, |this| { + if let Some(announcement) = encryption.read(cx).announcement().as_ref() { + let pubkey = shorten_pubkey(announcement.public_key(), 16); + let name = announcement.client(); + + this.child( + v_flex() + .gap_2() + .child(div().font_semibold().child(SharedString::from(NOTICE))) + .child( + v_flex() + .h_12() + .items_center() + .justify_center() + .rounded(cx.theme().radius) + .bg(cx.theme().warning_background) + .text_color(cx.theme().warning_foreground) + .child(name), + ) + .child( + v_flex() + .gap_1() + .child( + div() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_muted) + .child(SharedString::from("Client Public Key:")), + ) + .child( + h_flex() + .h_7() + .w_full() + .px_2() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .child(SharedString::from(pubkey)), + ), + ) + .child( + div() + .text_xs() + .text_color(cx.theme().text_muted) + .child(SharedString::from(SUGGEST)), + ) + .child( + h_flex() + .mt_2() + .gap_1() + .when(!self.requesting, |this| { + this.child( + Button::new("reset") + .label("Reset") + .flex_1() + .small() + .ghost_alt() + .loading(self.creating) + .disabled(self.creating) + .on_click(cx.listener( + move |this, _ev, window, cx| { + this.new_encryption(window, cx); + }, + )), + ) + }) + .when(!self.creating, |this| { + this.child( + Button::new("request") + .label({ + if self.requesting { + "Wait for approval" + } else { + "Request" + } + }) + .flex_1() + .small() + .primary() + .loading(self.requesting) + .disabled(self.requesting) + .on_click(cx.listener( + move |this, _ev, window, cx| { + this.request(window, cx); + }, + )), + ) + }), + ) + .when_some(self.error.read(cx).as_ref(), |this, error| { + this.child( + div() + .text_xs() + .text_center() + .text_color(cx.theme().danger_foreground) + .child(error.clone()), + ) + }), + ) + } else { + this.child( + v_flex() + .gap_2() + .child( + div() + .font_semibold() + .child(SharedString::from("Set up Encryption Key")), + ) + .child(SharedString::from(DESCRIPTION)) + .child( + div() + .text_xs() + .text_color(cx.theme().warning_foreground) + .child(SharedString::from(WARNING)), + ) + .child( + Button::new("create") + .label("Setup") + .flex_1() + .small() + .primary() + .loading(self.creating) + .disabled(self.creating) + .on_click(cx.listener(move |this, _ev, window, cx| { + this.new_encryption(window, cx); + })), + ), + ) + } + }) + } +} diff --git a/crates/key_store/Cargo.toml b/crates/key_store/Cargo.toml index 49f93f3..616a7f9 100644 --- a/crates/key_store/Cargo.toml +++ b/crates/key_store/Cargo.toml @@ -5,11 +5,9 @@ edition.workspace = true publish.workspace = true [dependencies] -states = { path = "../states" } +common = { path = "../common" } gpui.workspace = true - -nostr.workspace = true nostr-sdk.workspace = true anyhow.workspace = true diff --git a/crates/key_store/src/backend.rs b/crates/key_store/src/backend.rs index 336d9c9..bc1cebc 100644 --- a/crates/key_store/src/backend.rs +++ b/crates/key_store/src/backend.rs @@ -6,9 +6,33 @@ use std::path::PathBuf; use std::pin::Pin; use anyhow::Result; +use common::config_dir; use futures::FutureExt as _; use gpui::AsyncApp; -use states::config_dir; +use nostr_sdk::prelude::*; + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct Credential { + public_key: PublicKey, + secret: String, +} + +impl Credential { + pub fn new(user: String, secret: Vec) -> Self { + Self { + public_key: PublicKey::parse(&user).unwrap(), + secret: String::from_utf8(secret).unwrap(), + } + } + + pub fn public_key(&self) -> PublicKey { + self.public_key + } + + pub fn secret(&self) -> &str { + &self.secret + } +} #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyItem { diff --git a/crates/key_store/src/lib.rs b/crates/key_store/src/lib.rs index 60ad3d9..4a096a6 100644 --- a/crates/key_store/src/lib.rs +++ b/crates/key_store/src/lib.rs @@ -1,11 +1,10 @@ use std::sync::{Arc, LazyLock}; +pub use backend::*; use gpui::{App, AppContext, Context, Entity, Global, Task}; use smallvec::{smallvec, SmallVec}; -use crate::backend::{FileProvider, KeyBackend, KeyringProvider}; - -pub mod backend; +mod backend; static DISABLE_KEYRING: LazyLock = LazyLock::new(|| std::env::var("DISABLE_KEYRING").is_ok_and(|value| !value.is_empty())); diff --git a/crates/person/Cargo.toml b/crates/person/Cargo.toml index b604911..c3804c3 100644 --- a/crates/person/Cargo.toml +++ b/crates/person/Cargo.toml @@ -6,10 +6,9 @@ publish.workspace = true [dependencies] common = { path = "../common" } -states = { path = "../states" } +state = { path = "../state" } gpui.workspace = true -nostr.workspace = true nostr-sdk.workspace = true anyhow.workspace = true itertools.workspace = true diff --git a/crates/person/src/lib.rs b/crates/person/src/lib.rs index bcc0944..c430a0c 100644 --- a/crates/person/src/lib.rs +++ b/crates/person/src/lib.rs @@ -1,9 +1,10 @@ -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; +use std::sync::Arc; -use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, Task}; +use gpui::{App, AppContext, Context, Entity, Global, Task}; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -use states::app_state; +use state::NostrRegistry; pub fn init(cx: &mut App) { PersonRegistry::set_global(cx.new(PersonRegistry::new), cx); @@ -13,6 +14,8 @@ struct GlobalPersonRegistry(Entity); impl Global for GlobalPersonRegistry {} +/// Person Registry +#[derive(Debug)] pub struct PersonRegistry { /// Collection of all persons (user profiles) pub persons: HashMap>, @@ -34,12 +37,58 @@ impl PersonRegistry { /// Create a new person registry instance pub(crate) fn new(cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); let mut tasks = smallvec![]; + tasks.push( + // Handle notifications + cx.spawn({ + let client = Arc::clone(&client); + async move |this, cx| { + let mut notifications = client.notifications(); + log::info!("Listening for notifications"); + + let mut processed_events = HashSet::new(); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, .. } = notification else { + // Skip if the notification is not a message + continue; + }; + + if let RelayMessage::Event { event, .. } = message { + if !processed_events.insert(event.id) { + // Skip if the event has already been processed + continue; + } + + if event.kind != Kind::Metadata { + // Skip if the event is not a metadata event + continue; + }; + + let metadata = Metadata::from_json(&event.content).unwrap_or_default(); + let profile = Profile::new(event.pubkey, metadata); + + this.update(cx, |this, cx| { + this.insert_or_update_person(profile, cx); + }) + .expect("Entity has been released") + } + } + } + }), + ); + tasks.push( // Load all user profiles from the database cx.spawn(async move |this, cx| { - match Self::load_persons(cx).await { + let result = cx + .background_spawn(async move { Self::load_persons(&client).await }) + .await; + + match result { Ok(profiles) => { this.update(cx, |this, cx| { this.bulk_insert_persons(profiles, cx); @@ -59,23 +108,20 @@ impl PersonRegistry { } } - /// Create a task to load all user profiles from the database - fn load_persons(cx: &AsyncApp) -> Task, Error>> { - cx.background_spawn(async move { - let client = app_state().client(); - let filter = Filter::new().kind(Kind::Metadata).limit(200); - let events = client.database().query(filter).await?; + /// Load all user profiles from the database + async fn load_persons(client: &Client) -> Result, Error> { + let filter = Filter::new().kind(Kind::Metadata).limit(200); + let events = client.database().query(filter).await?; - let mut profiles = vec![]; + let mut profiles = vec![]; - for event in events.into_iter() { - let metadata = Metadata::from_json(event.content).unwrap_or_default(); - let profile = Profile::new(event.pubkey, metadata); - profiles.push(profile); - } + for event in events.into_iter() { + let metadata = Metadata::from_json(event.content).unwrap_or_default(); + let profile = Profile::new(event.pubkey, metadata); + profiles.push(profile); + } - Ok(profiles) - }) + Ok(profiles) } /// Insert batch of persons diff --git a/crates/relay_auth/Cargo.toml b/crates/relay_auth/Cargo.toml new file mode 100644 index 0000000..1d96c01 --- /dev/null +++ b/crates/relay_auth/Cargo.toml @@ -0,0 +1,20 @@ +[package] +name = "relay_auth" +version.workspace = true +edition.workspace = true +publish.workspace = true + +[dependencies] +state = { path = "../state" } +settings = { path = "../settings" } +common = { path = "../common" } +theme = { path = "../theme" } +ui = { path = "../ui" } + +gpui.workspace = true +nostr-sdk.workspace = true + +anyhow.workspace = true +smallvec.workspace = true +smol.workspace = true +log.workspace = true diff --git a/crates/relay_auth/src/lib.rs b/crates/relay_auth/src/lib.rs new file mode 100644 index 0000000..8fed623 --- /dev/null +++ b/crates/relay_auth/src/lib.rs @@ -0,0 +1,326 @@ +use std::borrow::Cow; +use std::cell::Cell; +use std::collections::{HashMap, HashSet}; +use std::hash::{Hash, Hasher}; +use std::rc::Rc; + +use anyhow::{anyhow, Error}; +use gpui::{ + App, AppContext, Context, Entity, Global, IntoElement, ParentElement, SharedString, Styled, + Subscription, Task, Window, +}; +use nostr_sdk::prelude::*; +use settings::AppSettings; +use smallvec::{smallvec, SmallVec}; +use state::NostrRegistry; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::notification::Notification; +use ui::{v_flex, ContextModal, Disableable, IconName, Sizable}; + +const AUTH_MESSAGE: &str = + "Approve the authentication request to allow Coop to continue sending or receiving events."; + +pub fn init(window: &mut Window, cx: &mut App) { + RelayAuth::set_global(cx.new(|cx| RelayAuth::new(window, cx)), cx); +} + +struct GlobalRelayAuth(Entity); + +impl Global for GlobalRelayAuth {} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct AuthRequest { + pub url: RelayUrl, + pub challenge: String, +} + +impl Hash for AuthRequest { + fn hash(&self, state: &mut H) { + self.challenge.hash(state); + } +} + +impl AuthRequest { + pub fn new(challenge: impl Into, url: RelayUrl) -> Self { + Self { + challenge: challenge.into(), + url, + } + } +} + +#[derive(Debug)] +pub struct RelayAuth { + /// Entity for managing auth requests + requests: HashSet, + + /// Event subscriptions + _subscriptions: SmallVec<[Subscription; 1]>, + + /// Tasks for asynchronous operations + _tasks: SmallVec<[Task<()>; 1]>, +} + +impl RelayAuth { + /// Retrieve the global relay auth state + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + /// Set the global relay auth instance + fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalRelayAuth(state)); + } + + /// Create a new relay auth instance + fn new(window: &mut Window, cx: &mut Context) -> Self { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let entity = cx.entity(); + + let mut subscriptions = smallvec![]; + let mut tasks = smallvec![]; + + subscriptions.push( + // Observe the current state + cx.observe_in(&entity, window, |this, _, window, cx| { + let auto_auth = AppSettings::get_auto_auth(cx); + + for req in this.requests.clone().into_iter() { + let is_authenticated = AppSettings::read_global(cx).is_authenticated(&req.url); + + if auto_auth && is_authenticated { + // Automatically authenticate if the relay is authenticated before + this.response(req.to_owned(), window, cx); + } else { + // Otherwise open the auth request popup + this.ask_for_approval(req.to_owned(), window, cx); + } + } + }), + ); + + tasks.push( + // Handle notifications + cx.spawn(async move |this, cx| { + let mut notifications = client.notifications(); + let mut challenges: HashSet> = HashSet::new(); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, relay_url } = notification else { + // Skip if the notification is not a message + continue; + }; + + if let RelayMessage::Auth { challenge } = message { + if challenges.insert(challenge.clone()) { + this.update(cx, |this, cx| { + this.requests.insert(AuthRequest::new(challenge, relay_url)); + cx.notify(); + }) + .expect("Entity has been released"); + }; + } + } + }), + ); + + Self { + requests: HashSet::new(), + _subscriptions: subscriptions, + _tasks: tasks, + } + } + + /// Get the number of pending requests. + pub fn pending_requests(&self, _cx: &App) -> usize { + self.requests.len() + } + + /// Reask for approval for all pending requests. + pub fn re_ask(&mut self, window: &mut Window, cx: &mut Context) { + for request in self.requests.clone().into_iter() { + self.ask_for_approval(request, window, cx); + } + } + + /// Respond to an authentication request. + fn response(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context) { + let settings = AppSettings::global(cx); + + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let tracker = nostr.read(cx).tracker(); + + let challenge = req.challenge.to_owned(); + let url = req.url.to_owned(); + + let challenge_clone = challenge.clone(); + let url_clone = url.clone(); + + let task: Task> = cx.background_spawn(async move { + let signer = client.signer().await?; + + // Construct event + let event: Event = EventBuilder::auth(challenge_clone, url_clone.clone()) + .sign(&signer) + .await?; + + // Get the event ID + let id = event.id; + + // Get the relay + let relay = client.pool().relay(url_clone).await?; + let relay_url = relay.url(); + + // Subscribe to notifications + let mut notifications = relay.notifications(); + + // Send the AUTH message + relay.send_msg(ClientMessage::Auth(Cow::Borrowed(&event)))?; + + while let Ok(notification) = notifications.recv().await { + match notification { + RelayNotification::Message { + message: RelayMessage::Ok { event_id, .. }, + } => { + if id == event_id { + // Re-subscribe to previous subscription + relay.resubscribe().await?; + + // Get all failed events that need to be resent + let mut tracker = tracker.write().await; + + let ids: Vec = tracker + .resend_queue + .iter() + .filter(|(_, url)| relay_url == *url) + .map(|(id, _)| *id) + .collect(); + + for id in ids.into_iter() { + if let Some(relay_url) = tracker.resend_queue.remove(&id) { + if let Some(event) = client.database().event_by_id(&id).await? { + let event_id = relay.send_event(&event).await?; + + let output = Output { + val: event_id, + failed: HashMap::new(), + success: HashSet::from([relay_url]), + }; + + tracker.sent_ids.insert(event_id); + tracker.resent_ids.push(output); + } + } + } + + return Ok(()); + } + } + RelayNotification::AuthenticationFailed => break, + RelayNotification::Shutdown => break, + _ => {} + } + } + + Err(anyhow!("Authentication failed")) + }); + + self._tasks.push( + // Handle response in the background + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(_) => { + this.update_in(cx, |this, window, cx| { + // Clear the current notification + window.clear_notification_by_id(SharedString::from(&challenge), cx); + + // Push a new notification + window.push_notification(format!("{url} has been authenticated"), cx); + + // Save the authenticated relay to automatically authenticate future requests + settings.update(cx, |this, cx| { + this.push_relay(&url, cx); + }); + + // Remove the challenge from the list of pending authentications + this.requests.remove(&req); + cx.notify(); + }) + .expect("Entity has been released"); + } + Err(e) => { + this.update_in(cx, |_, window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + }) + .expect("Entity has been released"); + } + }; + }), + ); + } + + /// Push a popup to approve the authentication request. + fn ask_for_approval(&mut self, req: AuthRequest, window: &mut Window, cx: &mut Context) { + let url = SharedString::from(req.url.clone().to_string()); + let entity = cx.entity().downgrade(); + let loading = Rc::new(Cell::new(false)); + + let note = Notification::new() + .custom_id(SharedString::from(&req.challenge)) + .autohide(false) + .icon(IconName::Info) + .title(SharedString::from("Authentication Required")) + .content(move |_window, cx| { + v_flex() + .gap_2() + .text_sm() + .child(SharedString::from(AUTH_MESSAGE)) + .child( + v_flex() + .py_1() + .px_1p5() + .rounded_sm() + .text_xs() + .bg(cx.theme().warning_background) + .text_color(cx.theme().warning_foreground) + .child(url.clone()), + ) + .into_any_element() + }) + .action(move |_window, _cx| { + let entity = entity.clone(); + let req = req.clone(); + + Button::new("approve") + .label("Approve") + .small() + .primary() + .loading(loading.get()) + .disabled(loading.get()) + .on_click({ + let loading = Rc::clone(&loading); + move |_ev, window, cx| { + // Set loading state to true + loading.set(true); + // Process to approve the request + entity + .update(cx, |this, cx| { + this.response(req.clone(), window, cx); + }) + .expect("Entity has been released"); + } + }) + }); + + // Push the notification to the current window + window.push_notification(note, cx); + + // Focus the window if it's not active + if !window.is_window_hovered() { + window.activate_window(); + } + } +} diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index 47ff570..35602cd 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -5,7 +5,7 @@ edition.workspace = true publish.workspace = true [dependencies] -states = { path = "../states" } +state = { path = "../state" } nostr-sdk.workspace = true gpui.workspace = true diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index 1a1dc47..67c7b93 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -3,7 +3,9 @@ use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use smallvec::{smallvec, SmallVec}; -use states::{app_state, SETTINGS_IDENTIFIER}; +use state::NostrRegistry; + +const SETTINGS_IDENTIFIER: &str = "coop:settings"; pub fn init(cx: &mut App) { let state = cx.new(AppSettings::new); @@ -122,8 +124,10 @@ impl AppSettings { } pub fn load_settings(&self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + let task: Task> = cx.background_spawn(async move { - let client = app_state().client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; @@ -153,9 +157,11 @@ impl AppSettings { } pub fn set_settings(&self, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + if let Ok(content) = serde_json::to_string(&self.setting_values) { let task: Task> = cx.background_spawn(async move { - let client = app_state().client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; diff --git a/crates/states/Cargo.toml b/crates/state/Cargo.toml similarity index 73% rename from crates/states/Cargo.toml rename to crates/state/Cargo.toml index 5581fca..ae3b2c0 100644 --- a/crates/states/Cargo.toml +++ b/crates/state/Cargo.toml @@ -1,21 +1,23 @@ [package] -name = "states" +name = "state" version.workspace = true edition.workspace = true publish.workspace = true [dependencies] +common = { path = "../common" } + nostr-sdk.workspace = true nostr-lmdb.workspace = true nostr-gossip-memory.workspace = true -dirs.workspace = true +gpui.workspace = true smol.workspace = true -flume.workspace = true +smallvec.workspace = true log.workspace = true anyhow.workspace = true serde.workspace = true serde_json.workspace = true -whoami = "1.6.1" rustls = "0.23.23" +event-listener = "5.4.1" diff --git a/crates/state/src/lib.rs b/crates/state/src/lib.rs new file mode 100644 index 0000000..2ab832f --- /dev/null +++ b/crates/state/src/lib.rs @@ -0,0 +1,312 @@ +use std::collections::HashSet; +use std::sync::Arc; +use std::time::Duration; + +use anyhow::{anyhow, Error}; +use common::{config_dir, BOOTSTRAP_RELAYS, SEARCH_RELAYS}; +use gpui::{App, AppContext, Context, Entity, Global, Task}; +use nostr_gossip_memory::prelude::*; +use nostr_lmdb::NostrLMDB; +use nostr_sdk::prelude::*; +use smallvec::{smallvec, SmallVec}; +use smol::lock::RwLock; +pub use storage::*; +pub use tracker::*; + +mod storage; +mod tracker; + +pub const GIFTWRAP_SUBSCRIPTION: &str = "default-inbox"; +pub const ENCRYPTION_GIFTWARP_SUBSCRIPTION: &str = "encryption-inbox"; + +pub fn init(cx: &mut App) { + NostrRegistry::set_global(cx.new(NostrRegistry::new), cx); +} + +struct GlobalNostrRegistry(Entity); + +impl Global for GlobalNostrRegistry {} + +/// Nostr Registry +#[derive(Debug)] +pub struct NostrRegistry { + /// Nostr client instance + client: Arc, + + /// Tracks activity related to Nostr events + tracker: Arc>, + + /// Manages caching of nostr events + cache_manager: Arc>, + + /// Tasks for asynchronous operations + _tasks: SmallVec<[Task<()>; 1]>, +} + +impl NostrRegistry { + /// Retrieve the global nostr state + pub fn global(cx: &App) -> Entity { + cx.global::().0.clone() + } + + /// Set the global nostr instance + fn set_global(state: Entity, cx: &mut App) { + cx.set_global(GlobalNostrRegistry(state)); + } + + /// Create a new nostr instance + fn new(cx: &mut Context) -> Self { + // rustls uses the `aws_lc_rs` provider by default + // This only errors if the default provider has already + // been installed. We can ignore this `Result`. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .ok(); + + let path = config_dir().join("nostr"); + let lmdb = NostrLMDB::open(path).expect("Failed to initialize database"); + let gossip = NostrGossipMemory::unbounded(); + + // Nostr client options + let opts = ClientOptions::new() + .automatic_authentication(false) + .verify_subscriptions(false) + .sleep_when_idle(SleepWhenIdle::Enabled { + timeout: Duration::from_secs(600), + }); + + // Construct the nostr client + let client = Arc::new( + ClientBuilder::default() + .gossip(gossip) + .database(lmdb) + .opts(opts) + .build(), + ); + + let tracker = Arc::new(RwLock::new(EventTracker::default())); + let cache_manager = Arc::new(RwLock::new(CacheManager::default())); + + let mut tasks = smallvec![]; + + tasks.push( + // Establish connection to the bootstrap relays + // + // And handle notifications from the nostr relay pool channel + cx.background_spawn({ + let client = Arc::clone(&client); + let cache_manager = Arc::clone(&cache_manager); + let tracker = Arc::clone(&tracker); + let _ = initialized_at(); + + async move { + // Connect to the bootstrap relays + Self::connect(&client).await; + + // Handle notifications from the relay pool + Self::handle_notifications(&client, &cache_manager, &tracker).await; + } + }), + ); + + Self { + client, + tracker, + cache_manager, + _tasks: tasks, + } + } + + /// Establish connection to the bootstrap relays + async fn connect(client: &Client) { + // Get all bootstrapping relays + let mut urls = vec![]; + urls.extend(BOOTSTRAP_RELAYS); + urls.extend(SEARCH_RELAYS); + + // Add relay to the relay pool + for url in urls.into_iter() { + client.add_relay(url).await.ok(); + } + + // Connect to all added relays + client.connect().await; + } + + async fn handle_notifications( + client: &Client, + cache: &Arc>, + tracker: &Arc>, + ) { + let mut notifications = client.notifications(); + log::info!("Listening for notifications"); + + let mut processed_events = HashSet::new(); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, relay_url } = notification else { + // Skip if the notification is not a message + continue; + }; + + match message { + RelayMessage::Event { event, .. } => { + if !processed_events.insert(event.id) { + // Skip if the event has already been processed + continue; + } + + match event.kind { + Kind::RelayList => { + if Self::is_self_authored(client, &event).await { + log::info!("Found relay list event for the current user"); + let author = event.pubkey; + let announcement = Kind::Custom(10044); + + // Fetch user's messaging relays event + _ = Self::subscribe(client, author, Kind::InboxRelays).await; + // Fetch user's encryption announcement event + _ = Self::subscribe(client, author, announcement).await; + // Fetch user's metadata event + _ = Self::subscribe(client, author, Kind::Metadata).await; + // Fetch user's contact list event + _ = Self::subscribe(client, author, Kind::ContactList).await; + } + } + Kind::InboxRelays => { + // Extract up to 3 messaging relays + let urls: Vec = + nip17::extract_relay_list(&event).take(3).cloned().collect(); + + // Subscribe to gift wrap events if event is from current user + if Self::is_self_authored(client, &event).await { + log::info!("Found messaging list event for the current user"); + + if let Err(e) = + Self::get_messages(client, &urls, event.pubkey).await + { + log::error!("Failed to subscribe to gift wrap events: {e}"); + } + } + + // Cache the messaging relays + let mut cache = cache.write().await; + cache.insert_relay(event.pubkey, urls); + } + Kind::ContactList => { + if Self::is_self_authored(client, &event).await { + let pubkeys: Vec<_> = event.tags.public_keys().copied().collect(); + + if let Err(e) = Self::get_metadata_for_list(client, pubkeys).await { + log::error!("Failed to get metadata for list: {e}"); + } + } + } + _ => {} + }; + } + RelayMessage::Ok { + event_id, message, .. + } => { + let msg = MachineReadablePrefix::parse(&message); + let mut tracker = tracker.write().await; + + // Message that need to be authenticated will be handled separately + if let Some(MachineReadablePrefix::AuthRequired) = msg { + // Keep track of events that need to be resent after authentication + tracker.resend_queue.insert(event_id, relay_url); + } else { + // Keep track of events sent by Coop + tracker.sent_ids.insert(event_id); + } + } + _ => {} + } + } + } + + /// Check if event is published by current user + async fn is_self_authored(client: &Client, event: &Event) -> bool { + if let Ok(signer) = client.signer().await { + if let Ok(public_key) = signer.get_public_key().await { + return public_key == event.pubkey; + } + } + false + } + + /// Subscribe for events that match the given kind for a given author + async fn subscribe(client: &Client, author: PublicKey, kind: Kind) -> Result<(), Error> { + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let filter = Filter::new().author(author).kind(kind).limit(1); + + // Subscribe to filters from the user's write relays + client.subscribe(filter, Some(opts)).await?; + + Ok(()) + } + + /// Get all gift wrap events in the messaging relays for a given public key + async fn get_messages( + client: &Client, + urls: &[RelayUrl], + public_key: PublicKey, + ) -> Result<(), Error> { + let id = SubscriptionId::new(GIFTWRAP_SUBSCRIPTION); + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + + // Verify that there are relays provided + if urls.is_empty() { + return Err(anyhow!("No relays provided")); + } + + // Add and connect relays + for url in urls { + client.add_relay(url).await?; + client.connect_relay(url).await?; + } + + // Subscribe to filters to user's messaging relays + client.subscribe_with_id_to(urls, id, filter, None).await?; + + Ok(()) + } + + /// Get metadata for a list of public keys + async fn get_metadata_for_list(client: &Client, pubkeys: Vec) -> Result<(), Error> { + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList]; + + // Return if the list is empty + if pubkeys.is_empty() { + return Err(anyhow!("You need at least one public key".to_string(),)); + } + + let filter = Filter::new() + .limit(pubkeys.len() * kinds.len() + 10) + .authors(pubkeys) + .kinds(kinds); + + // Subscribe to filters to the bootstrap relays + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await?; + + Ok(()) + } + + /// Returns a reference to the nostr client. + pub fn client(&self) -> Arc { + Arc::clone(&self.client) + } + + /// Returns a reference to the event tracker. + pub fn tracker(&self) -> Arc> { + Arc::clone(&self.tracker) + } + + /// Returns a reference to the cache manager. + pub fn cache_manager(&self) -> Arc> { + Arc::clone(&self.cache_manager) + } +} diff --git a/crates/state/src/storage.rs b/crates/state/src/storage.rs new file mode 100644 index 0000000..ba5ebd6 --- /dev/null +++ b/crates/state/src/storage.rs @@ -0,0 +1,87 @@ +use std::collections::{HashMap, HashSet}; + +use gpui::SharedString; +use nostr_sdk::prelude::*; + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct Announcement { + id: EventId, + client: String, + public_key: PublicKey, +} + +impl Announcement { + pub fn new(id: EventId, client_name: String, public_key: PublicKey) -> Self { + Self { + id, + client: client_name, + public_key, + } + } + + pub fn id(&self) -> EventId { + self.id + } + + pub fn public_key(&self) -> PublicKey { + self.public_key + } + + pub fn client(&self) -> SharedString { + SharedString::from(self.client.clone()) + } +} + +#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] +pub struct Response { + payload: String, + public_key: PublicKey, +} + +impl Response { + pub fn new(payload: String, public_key: PublicKey) -> Self { + Self { + payload, + public_key, + } + } + + pub fn public_key(&self) -> PublicKey { + self.public_key + } + + pub fn payload(&self) -> &str { + self.payload.as_str() + } +} + +#[derive(Debug, Clone, Default)] +pub struct CacheManager { + /// Cache of messaging relays for each public key + relay: HashMap>, + + /// Cache of device announcement for each public key + announcement: HashMap>, +} + +impl CacheManager { + pub fn relay(&self, public_key: &PublicKey) -> Option<&HashSet> { + self.relay.get(public_key) + } + + pub fn insert_relay(&mut self, public_key: PublicKey, urls: Vec) { + self.relay.entry(public_key).or_default().extend(urls); + } + + pub fn announcement(&self, public_key: &PublicKey) -> Option<&Option> { + self.announcement.get(public_key) + } + + pub fn insert_announcement( + &mut self, + public_key: PublicKey, + announcement: Option, + ) { + self.announcement.insert(public_key, announcement); + } +} diff --git a/crates/states/src/state/tracker.rs b/crates/state/src/tracker.rs similarity index 72% rename from crates/states/src/state/tracker.rs rename to crates/state/src/tracker.rs index bcc7405..b44ba80 100644 --- a/crates/states/src/state/tracker.rs +++ b/crates/state/src/tracker.rs @@ -1,9 +1,19 @@ use std::collections::{HashMap, HashSet}; +use std::sync::OnceLock; use nostr_sdk::prelude::*; +static INITIALIZED_AT: OnceLock = OnceLock::new(); + +pub fn initialized_at() -> &'static Timestamp { + INITIALIZED_AT.get_or_init(Timestamp::now) +} + #[derive(Debug, Clone, Default)] pub struct EventTracker { + /// Tracking events that have failed to unwrap + pub failed_unwrap_events: Vec, + /// Tracking events that have been resent by Coop in the current session pub resent_ids: Vec>, @@ -18,6 +28,10 @@ pub struct EventTracker { } impl EventTracker { + pub fn failed_unwrap_events(&self) -> &Vec { + &self.failed_unwrap_events + } + pub fn resent_ids(&self) -> &Vec> { &self.resent_ids } diff --git a/crates/states/src/lib.rs b/crates/states/src/lib.rs deleted file mode 100644 index c6199d5..0000000 --- a/crates/states/src/lib.rs +++ /dev/null @@ -1,67 +0,0 @@ -use std::sync::OnceLock; - -use nostr_sdk::prelude::*; -use whoami::{devicename, platform}; - -mod constants; -mod paths; -mod state; - -pub use constants::*; -pub use paths::*; -pub use state::*; - -static APP_STATE: OnceLock = OnceLock::new(); -static APP_NAME: OnceLock = OnceLock::new(); -static NIP65_RELAYS: OnceLock)>> = OnceLock::new(); -static NIP17_RELAYS: OnceLock> = OnceLock::new(); - -/// Initialize the application state. -pub fn app_state() -> &'static AppState { - APP_STATE.get_or_init(AppState::new) -} - -pub fn app_name() -> &'static String { - APP_NAME.get_or_init(|| { - let devicename = devicename(); - let platform = platform(); - - format!("{CLIENT_NAME} on {platform} ({devicename})") - }) -} - -/// Default NIP-65 Relays. Used for new account -pub fn default_nip65_relays() -> &'static Vec<(RelayUrl, Option)> { - NIP65_RELAYS.get_or_init(|| { - vec![ - ( - RelayUrl::parse("wss://nostr.mom").unwrap(), - Some(RelayMetadata::Read), - ), - ( - RelayUrl::parse("wss://nostr.bitcoiner.social").unwrap(), - Some(RelayMetadata::Read), - ), - ( - RelayUrl::parse("wss://nostr.oxtr.dev").unwrap(), - Some(RelayMetadata::Write), - ), - ( - RelayUrl::parse("wss://nostr.fmt.wiz.biz").unwrap(), - Some(RelayMetadata::Write), - ), - (RelayUrl::parse("wss://relay.primal.net").unwrap(), None), - (RelayUrl::parse("wss://relay.damus.io").unwrap(), None), - ] - }) -} - -/// Default NIP-17 Relays. Used for new account -pub fn default_nip17_relays() -> &'static Vec { - NIP17_RELAYS.get_or_init(|| { - vec![ - RelayUrl::parse("wss://nip17.com").unwrap(), - RelayUrl::parse("wss://auth.nostr1.com").unwrap(), - ] - }) -} diff --git a/crates/states/src/state/device.rs b/crates/states/src/state/device.rs deleted file mode 100644 index 171dd1d..0000000 --- a/crates/states/src/state/device.rs +++ /dev/null @@ -1,48 +0,0 @@ -use std::sync::Arc; - -use nostr_sdk::prelude::*; -use serde::{Deserialize, Serialize}; - -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Default, Deserialize, Serialize)] -pub enum SignerKind { - Encryption, - #[default] - User, - Auto, -} - -#[derive(Debug, Clone, Default)] -pub struct Device { - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - /// - /// Client Key that used for communication between devices - pub client: Option>, - - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - /// - /// Encryption key used for encryption and decryption instead of the user's identity - pub encryption: Option>, -} - -impl Device { - pub fn new() -> Self { - Self { - client: None, - encryption: None, - } - } - - pub fn set_client(&mut self, keys: T) - where - T: NostrSigner, - { - self.client = Some(Arc::new(keys)); - } - - pub fn set_encryption(&mut self, keys: T) - where - T: NostrSigner, - { - self.encryption = Some(Arc::new(keys)); - } -} diff --git a/crates/states/src/state/ingester.rs b/crates/states/src/state/ingester.rs deleted file mode 100644 index 7da3cb4..0000000 --- a/crates/states/src/state/ingester.rs +++ /dev/null @@ -1,31 +0,0 @@ -use flume::{Receiver, Sender}; -use nostr_sdk::prelude::*; - -#[derive(Debug, Clone)] -pub struct Ingester { - rx: Receiver, - tx: Sender, -} - -impl Default for Ingester { - fn default() -> Self { - Self::new() - } -} - -impl Ingester { - pub fn new() -> Self { - let (tx, rx) = flume::bounded::(1024); - Self { rx, tx } - } - - pub fn receiver(&self) -> &Receiver { - &self.rx - } - - pub async fn send(&self, public_key: PublicKey) { - if let Err(e) = self.tx.send_async(public_key).await { - log::error!("Failed to send public key: {e}"); - } - } -} diff --git a/crates/states/src/state/mod.rs b/crates/states/src/state/mod.rs deleted file mode 100644 index b4e7fe3..0000000 --- a/crates/states/src/state/mod.rs +++ /dev/null @@ -1,1197 +0,0 @@ -use std::borrow::Cow; -use std::collections::hash_map::DefaultHasher; -use std::collections::{HashMap, HashSet}; -use std::hash::{Hash, Hasher}; -use std::sync::atomic::{AtomicBool, Ordering}; -use std::time::Duration; - -use anyhow::{anyhow, Context, Error}; -use nostr_gossip_memory::prelude::*; -use nostr_lmdb::NostrLMDB; -use nostr_sdk::prelude::*; -use smol::lock::RwLock; - -use crate::constants::{ - BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, METADATA_BATCH_TIMEOUT, QUERY_TIMEOUT, -}; -use crate::paths::config_dir; -use crate::state::ingester::Ingester; -use crate::state::tracker::EventTracker; -use crate::{app_name, INBOX_SUB_ID}; - -mod device; -mod ingester; -mod signal; -mod tracker; - -pub use device::*; -pub use signal::*; - -#[derive(Debug)] -pub struct AppState { - /// A client to interact with Nostr - client: Client, - - /// Signal channel for communication between Nostr and GPUI - signal: Signal, - - /// Ingester channel for processing public keys - ingester: Ingester, - - /// Tracks activity related to Nostr events - event_tracker: RwLock, - - /// Cache of messaging relays for each public key - pub relay_cache: RwLock>>, - - /// Cache of device announcement for each public key - pub announcement_cache: RwLock>>, - - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub device: RwLock, - - /// The timestamp when the application was initialized. - pub initialized_at: Timestamp, - - /// Whether gift wrap processing is in progress. - pub gift_wrap_processing: AtomicBool, -} - -impl Default for AppState { - fn default() -> Self { - Self::new() - } -} - -impl AppState { - pub fn new() -> Self { - // rustls uses the `aws_lc_rs` provider by default - // This only errors if the default provider has already - // been installed. We can ignore this `Result`. - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .ok(); - - let path = config_dir().join("nostr"); - let lmdb = NostrLMDB::open(path).expect("Failed to initialize database"); - let gossip = NostrGossipMemory::unbounded(); - - // Nostr client options - let opts = ClientOptions::new() - .automatic_authentication(false) - .verify_subscriptions(false) - .sleep_when_idle(SleepWhenIdle::Enabled { - timeout: Duration::from_secs(600), - }); - - // Construct the nostr client - let client = ClientBuilder::default() - .gossip(gossip) - .database(lmdb) - .opts(opts) - .build(); - - let device = RwLock::new(Device::default()); - let event_tracker = RwLock::new(EventTracker::default()); - let relay_cache = RwLock::new(HashMap::default()); - let announcement_cache = RwLock::new(HashMap::default()); - - let signal = Signal::default(); - let ingester = Ingester::default(); - - Self { - client, - device, - event_tracker, - relay_cache, - announcement_cache, - signal, - ingester, - initialized_at: Timestamp::now(), - gift_wrap_processing: AtomicBool::new(false), - } - } - - /// Returns a reference to the nostr client - pub fn client(&'static self) -> &'static Client { - &self.client - } - - /// Returns a reference to the device - pub fn device(&'static self) -> &'static RwLock { - &self.device - } - - /// Returns a reference to the event tracker - pub fn tracker(&'static self) -> &'static RwLock { - &self.event_tracker - } - - /// Returns a reference to the signal channel - pub fn signal(&'static self) -> &'static Signal { - &self.signal - } - - /// Returns a reference to the ingester channel - pub fn ingester(&'static self) -> &'static Ingester { - &self.ingester - } - - /// Observes the signer and notifies the app when it's set - pub async fn observe_signer(&'static self) { - let client = self.client(); - let loop_duration = Duration::from_millis(800); - - loop { - if let Ok(signer) = client.signer().await { - if let Ok(public_key) = signer.get_public_key().await { - // Notify the app that the signer has been set - self.signal().send(SignalKind::SignerSet(public_key)).await; - - // Get user's gossip relays - self.get_nip65(public_key).await.ok(); - - // Initialize the relay and announcement caches - self.init_cache().await.ok(); - - // Initialize client key - self.init_client_key().await.ok(); - - // Exit the loop - break; - } - } - - smol::Timer::after(loop_duration).await; - } - } - - /// Observes the gift wrap status and notifies the app when it's set - pub async fn observe_giftwrap(&'static self) { - let client = self.client(); - let loop_duration = Duration::from_secs(20); - let mut is_start_processing = false; - let mut total_loops = 0; - - loop { - if client.has_signer().await { - total_loops += 1; - - if self.gift_wrap_processing.load(Ordering::Acquire) { - is_start_processing = true; - - // Reset gift wrap processing flag - let _ = self.gift_wrap_processing.compare_exchange( - true, - false, - Ordering::Release, - Ordering::Relaxed, - ); - - let signal = SignalKind::GiftWrapStatus(UnwrappingStatus::Processing); - self.signal().send(signal).await; - } else { - // Only run further if we are already processing - // Wait until after 2 loops to prevent exiting early while events are still being processed - if is_start_processing && total_loops >= 2 { - let signal = SignalKind::GiftWrapStatus(UnwrappingStatus::Complete); - self.signal().send(signal).await; - - // Reset the counter - is_start_processing = false; - total_loops = 0; - } - } - } - - smol::Timer::after(loop_duration).await; - } - } - - /// Handles events from the nostr client - pub async fn handle_notifications(&self) -> Result<(), Error> { - let mut processed_events: HashSet = HashSet::new(); - let mut challenges: HashSet> = HashSet::new(); - let mut notifications = self.client.notifications(); - - while let Ok(notification) = notifications.recv().await { - let RelayPoolNotification::Message { message, relay_url } = notification else { - continue; - }; - - match message { - RelayMessage::Event { event, .. } => { - // Keep track of which relays have seen this event - { - let mut event_tracker = self.event_tracker.write().await; - event_tracker - .seen_on_relays - .entry(event.id) - .or_default() - .insert(relay_url); - } - - // Skip events that have already been processed - if !processed_events.insert(event.id) { - continue; - } - - match event.kind { - // Encryption Key announcement event - Kind::Custom(10044) => { - if let Ok(announcement) = self.extract_announcement(&event) { - if self.is_self_authored(&event).await { - self.signal - .send(SignalKind::EncryptionSet(announcement.clone())) - .await; - } - - // Cache the announcement for further queries - let mut announcement_cache = self.announcement_cache.write().await; - announcement_cache.insert(event.pubkey, Some(announcement)); - } - } - // Encryption Key request event - Kind::Custom(4454) => { - if self.is_self_authored(&event).await { - if let Ok(announcement) = self.extract_announcement(&event) { - self.signal - .send(SignalKind::EncryptionRequest(announcement)) - .await; - } - } - } - // Encryption Keys response event - Kind::Custom(4455) => { - if self.is_self_authored(&event).await { - if let Ok(response) = self.extract_response(&event) { - self.signal - .send(SignalKind::EncryptionResponse(response)) - .await; - } - } - } - Kind::RelayList => { - // Get events if relay list belongs to current user - if self.is_self_authored(&event).await { - let author = event.pubkey; - - // Fetch user's metadata event - if let Err(e) = self.subscribe(author, Kind::Metadata).await { - log::error!("Failed to subscribe to metadata event: {e}"); - } - - // Fetch user's contact list event - if let Err(e) = self.subscribe(author, Kind::ContactList).await { - log::error!("Failed to subscribe to contact list event: {e}"); - } - - // Fetch user's encryption announcement event - if let Err(e) = self.get_announcement(author).await { - log::error!("Failed to fetch encryption event: {e}"); - } - - // Fetch user's messaging relays event - if let Err(e) = self.get_nip17(author).await { - log::error!("Failed to fetch messaging relays event: {e}"); - } - } - } - Kind::InboxRelays => { - // Only get up to 3 relays - let urls: Vec = nip17::extract_relay_list(event.as_ref()) - .take(3) - .cloned() - .collect(); - - // Subscribe to gift wrap events if messaging relays belong to the current user - if self.is_self_authored(&event).await { - if let Err(e) = self.get_messages(event.pubkey, &urls).await { - log::error!("Failed to fetch messages: {e}"); - } - } - - // Cache the relay list for further queries - let mut relay_cache = self.relay_cache.write().await; - relay_cache.entry(event.pubkey).or_default().extend(urls); - } - Kind::ContactList => { - if self.is_self_authored(&event).await { - let public_keys: HashSet = - event.tags.public_keys().copied().collect(); - - if let Err(e) = self.get_metadata_for_list(public_keys).await { - log::error!("Failed to get metadata for list: {e}"); - } - } - } - Kind::Metadata => { - let metadata = Metadata::from_json(&event.content).unwrap_or_default(); - let profile = Profile::new(event.pubkey, metadata); - - self.signal.send(SignalKind::NewProfile(profile)).await; - } - Kind::GiftWrap => { - self.extract_rumor(&event).await.ok(); - } - _ => {} - } - } - RelayMessage::EndOfStoredEvents(subscription_id) => { - if subscription_id.as_ref() == &SubscriptionId::new(INBOX_SUB_ID) { - self.signal - .send(SignalKind::GiftWrapStatus(UnwrappingStatus::Processing)) - .await; - } - } - RelayMessage::Auth { challenge } => { - if challenges.insert(challenge.clone()) { - // Send a signal to the ingester to handle the auth request - self.signal - .send(SignalKind::Auth(AuthRequest::new(challenge, relay_url))) - .await; - } - } - RelayMessage::Ok { - event_id, message, .. - } => { - let msg = MachineReadablePrefix::parse(&message); - let mut tracker = self.event_tracker.write().await; - - // Keep track of events sent by Coop - tracker.sent_ids.insert(event_id); - - // Keep track of events that need to be resend after auth - if let Some(MachineReadablePrefix::AuthRequired) = msg { - tracker.resend_queue.insert(event_id, relay_url); - } - } - _ => {} - } - } - - Ok(()) - } - - /// Batch metadata requests into a single subscription - pub async fn handle_metadata_batching(&self) { - let timeout = Duration::from_millis(METADATA_BATCH_TIMEOUT); - let mut processed_pubkeys: HashSet = HashSet::new(); - let mut batch: HashSet = HashSet::new(); - - /// Internal events for the metadata batching system - enum BatchEvent { - PublicKey(PublicKey), - Timeout, - Closed, - } - - loop { - let futs = smol::future::or( - async move { - if let Ok(public_key) = self.ingester.receiver().recv_async().await { - BatchEvent::PublicKey(public_key) - } else { - BatchEvent::Closed - } - }, - async move { - smol::Timer::after(timeout).await; - BatchEvent::Timeout - }, - ); - - match futs.await { - BatchEvent::PublicKey(public_key) => { - // Prevent duplicate keys from being processed - if processed_pubkeys.insert(public_key) { - batch.insert(public_key); - } - - // Process the batch if it's full - if batch.len() >= METADATA_BATCH_LIMIT { - self.get_metadata_for_list(std::mem::take(&mut batch)) - .await - .ok(); - } - } - BatchEvent::Timeout => { - self.get_metadata_for_list(std::mem::take(&mut batch)) - .await - .ok(); - } - BatchEvent::Closed => { - self.get_metadata_for_list(std::mem::take(&mut batch)) - .await - .ok(); - - // Exit the current loop - break; - } - } - } - } - - /// Encrypt and store a key in the local database. - pub async fn set_keys(&self, kind: impl Into, value: String) -> Result<(), Error> { - let signer = self.client.signer().await?; - let public_key = signer.get_public_key().await?; - - // Encrypt the value - let content = signer.nip44_encrypt(&public_key, value.as_ref()).await?; - - // Construct the application data event - let event = EventBuilder::new(Kind::ApplicationSpecificData, content) - .tag(Tag::identifier(format!("coop:{}", kind.into()))) - .build(public_key) - .sign(&Keys::generate()) - .await?; - - // Save the event to the database - self.client.database().save_event(&event).await?; - - Ok(()) - } - - /// Get and decrypt a key from the local database. - pub async fn get_keys(&self, kind: impl Into) -> Result { - let signer = self.client.signer().await?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(format!("coop:{}", kind.into())); - - if let Some(event) = self.client.database().query(filter).await?.first() { - let content = signer.nip44_decrypt(&public_key, &event.content).await?; - let secret = SecretKey::parse(&content)?; - let keys = Keys::new(secret); - - Ok(keys) - } else { - Err(anyhow!("Key not found")) - } - } - - /// Check if event is published by current user - async fn is_self_authored(&self, event: &Event) -> bool { - if let Ok(signer) = self.client.signer().await { - if let Ok(public_key) = signer.get_public_key().await { - return public_key == event.pubkey; - } - } - false - } - - /// Subscribe for events that match the given kind for a given author - pub async fn subscribe(&self, author: PublicKey, kind: Kind) -> Result<(), Error> { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let filter = Filter::new().author(author).kind(kind).limit(1); - - // Subscribe to filters from the user's write relays - self.client.subscribe(filter, Some(opts)).await?; - - Ok(()) - } - - /// Get metadata for a list of public keys - pub async fn get_metadata_for_list(&self, public_keys: I) -> Result<(), Error> - where - I: IntoIterator, - { - let authors: Vec = public_keys.into_iter().collect(); - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList]; - - // Return if the list is empty - if authors.is_empty() { - return Err(anyhow!("You need at least one public key".to_string(),)); - } - - let filter = Filter::new() - .limit(authors.len() * kinds.len() + 10) - .authors(authors) - .kinds(kinds); - - // Subscribe to filters to the bootstrap relays - self.client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await?; - - Ok(()) - } - - /// Get and verify NIP-65 relays for a given public key - pub async fn get_nip65(&self, public_key: PublicKey) -> Result<(), Error> { - let timeout = Duration::from_secs(QUERY_TIMEOUT); - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - - let filter = Filter::new() - .kind(Kind::RelayList) - .author(public_key) - .limit(1); - - // Subscribe to events from the bootstrapping relays - self.client - .subscribe_to(BOOTSTRAP_RELAYS, filter.clone(), Some(opts)) - .await?; - - let tx = self.signal.sender().clone(); - let database = self.client.database().clone(); - - // Verify the received data after a timeout - smol::spawn(async move { - smol::Timer::after(timeout).await; - - if database.count(filter).await.unwrap_or(0) < 1 { - tx.send_async(SignalKind::GossipRelaysNotFound).await.ok(); - } - }) - .detach(); - - Ok(()) - } - - /// Set NIP-65 relays for a current user - pub async fn set_nip65( - &self, - relays: &[(RelayUrl, Option)], - ) -> Result<(), Error> { - let signer = self.client.signer().await?; - - let tags: Vec = relays - .iter() - .cloned() - .map(|(url, metadata)| Tag::relay_metadata(url, metadata)) - .collect(); - - let event = EventBuilder::new(Kind::RelayList, "") - .tags(tags) - .sign(&signer) - .await?; - - // Send event to the public relays - self.client.send_event_to(BOOTSTRAP_RELAYS, &event).await?; - - // Get NIP-17 relays - self.get_nip17(event.pubkey).await?; - - Ok(()) - } - - /// Initialize the relay and announcement caches with events from the local database - pub async fn init_cache(&self) -> Result<(), Error> { - let filter = Filter::new().kind(Kind::InboxRelays); - let events = self.client.database().query(filter).await?; - let mut relay_cache = self.relay_cache.write().await; - - for event in events.into_iter() { - let relays: Vec = - nip17::extract_relay_list(&event).take(3).cloned().collect(); - - // Push all relays to the relay cache - relay_cache.entry(event.pubkey).or_default().extend(relays); - } - - let filter = Filter::new().kind(Kind::Custom(10044)); - let events = self.client.database().query(filter).await?; - let mut announcement_cache = self.announcement_cache.write().await; - - for event in events.into_iter() { - if let Ok(announcement) = self.extract_announcement(&event) { - announcement_cache.insert(event.pubkey, Some(announcement)); - } - } - - Ok(()) - } - - /// Initialize the client keys to communicate between clients - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub async fn init_client_key(&self) -> Result<(), Error> { - // Get the keys from the database or generate new ones - let keys = self - .get_keys("client") - .await - .unwrap_or_else(|_| Keys::generate()); - - // Initialize the client keys - let mut device = self.device.write().await; - device.set_client(keys); - - Ok(()) - } - - /// Get and verify encryption announcement for a given public key - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub async fn get_announcement(&self, public_key: PublicKey) -> Result<(), Error> { - let timeout = Duration::from_secs(QUERY_TIMEOUT); - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - - let filter = Filter::new() - .kind(Kind::Custom(10044)) - .author(public_key) - .limit(1); - - // Subscribe to events from user's nip65 relays - self.client.subscribe(filter.clone(), Some(opts)).await?; - - let tx = self.signal.sender().clone(); - let database = self.client.database().clone(); - - // Verify the received data after a timeout - smol::spawn(async move { - smol::Timer::after(timeout).await; - - if database.count(filter).await.unwrap_or(0) < 1 { - tx.send_async(SignalKind::EncryptionNotSet).await.ok(); - } - }) - .detach(); - - Ok(()) - } - - /// Generate encryption keys and announce them - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub async fn init_encryption_keys(&self) -> Result<(), Error> { - let signer = self.client.signer().await?; - let keys = Keys::generate(); - let public_key = keys.public_key(); - let secret = keys.secret_key().to_secret_hex(); - - // Initialize the encryption keys - let mut device = self.device.write().await; - device.set_encryption(keys); - - // Store the encryption keys for future use - self.set_keys("encryption", secret).await?; - - // Construct the announcement event - let event = EventBuilder::new(Kind::Custom(10044), "") - .tags(vec![ - Tag::client(app_name()), - Tag::custom(TagKind::custom("n"), vec![public_key]), - ]) - .sign(&signer) - .await?; - - // Send the announcement event to the relays - self.client.send_event(&event).await?; - - // Resubscribe to gift wrap events that include the encryption public key - self.get_messages_with_encryption(public_key).await?; - - Ok(()) - } - - /// User has previously set encryption keys, load them from storage - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub async fn load_encryption_keys(&self, announcement: &Announcement) -> Result<(), Error> { - let keys = self.get_keys("encryption").await?; - let public_key = announcement.public_key(); - - // Check if the encryption keys match the announcement - if public_key == keys.public_key() { - // Update encryption keys - let mut device = self.device.write().await; - device.set_encryption(keys); - - // Resubscribe to gift wrap events that include the encryption public key - self.get_messages_with_encryption(public_key).await?; - - Ok(()) - } else { - Err(anyhow!("Not found")) - } - } - - /// Request encryption keys from other clients - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub async fn request_encryption_keys(&self) -> Result { - let mut wait_for_approval = false; - let device = self.device.read().await; - - // Client Keys are always known at this point - let Some(client_key) = device.client.as_ref() else { - return Err(anyhow!("Client Keys is required")); - }; - - let signer = self.client.signer().await?; - let public_key = signer.get_public_key().await?; - let client_pubkey = client_key.get_public_key().await?; - - // Get the encryption keys response from the database first - let filter = Filter::new() - .kind(Kind::Custom(4455)) - .author(public_key) - .pubkey(client_pubkey) - .limit(1); - - match self.client.database().query(filter).await?.first_owned() { - // Found encryption keys that shared by other clients - Some(event) => { - let root_device = event - .tags - .find(TagKind::custom("P")) - .and_then(|tag| tag.content()) - .and_then(|content| PublicKey::parse(content).ok()) - .context("Invalid event's tags")?; - - let payload = event.content.as_str(); - let decrypted = client_key.nip44_decrypt(&root_device, payload).await?; - - let secret = SecretKey::from_hex(&decrypted)?; - let keys = Keys::new(secret); - let public_key = keys.public_key(); - - // No longer need to hold the reader for device - drop(device); - - // Update encryption keys - let mut device = self.device.write().await; - device.set_encryption(keys); - - // Resubscribe to gift wrap events that include the encryption public key - self.get_messages_with_encryption(public_key).await?; - } - None => { - // Construct encryption keys request event - let event = EventBuilder::new(Kind::Custom(4454), "") - .tags(vec![ - Tag::client(app_name()), - Tag::custom(TagKind::custom("pubkey"), vec![client_pubkey]), - ]) - .sign(&signer) - .await?; - - // Send a request for encryption keys from other devices - self.client.send_event(&event).await?; - - // Create a unique ID to control the subscription later - let subscription_id = SubscriptionId::new("request"); - - let filter = Filter::new() - .kind(Kind::Custom(4455)) - .author(public_key) - .pubkey(client_pubkey) - .since(Timestamp::now()); - - // Subscribe to the approval response event - self.client - .subscribe_with_id(subscription_id, filter, None) - .await?; - - wait_for_approval = true; - } - } - - Ok(wait_for_approval) - } - - /// Receive the encryption keys from other clients - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub async fn receive_encryption_keys(&self, res: Response) -> Result<(), Error> { - let device = self.device.read().await; - - // Client Keys are always known at this point - let Some(client_key) = device.client.as_ref() else { - return Err(anyhow!("Client Keys is required")); - }; - - let public_key = res.public_key(); - let payload = res.payload(); - - // Decrypt the payload using the client keys - let decrypted = client_key.nip44_decrypt(&public_key, payload).await?; - let secret = SecretKey::parse(&decrypted)?; - - let keys = Keys::new(secret); - let public_key = keys.public_key(); - - // No longer need to hold the reader for device - drop(device); - - // Update encryption keys - let mut device = self.device.write().await; - device.set_encryption(keys); - - // Resubscribe to gift wrap events that include the encryption public key - self.get_messages_with_encryption(public_key).await?; - - Ok(()) - } - - /// Response the encryption keys request from other clients - /// - /// NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - pub async fn response_encryption_keys(&self, target: PublicKey) -> Result<(), Error> { - let device = self.device.read().await; - - // Client Keys are always known at this point - let Some(client_key) = device.client.as_ref() else { - return Err(anyhow!("Client Keys is required")); - }; - - let encryption = self.get_keys("encryption").await?; - let client_pubkey = client_key.get_public_key().await?; - - // Encrypt the encryption keys with the client's signer - let payload = client_key - .nip44_encrypt(&target, &encryption.secret_key().to_secret_hex()) - .await?; - - // Construct the response event - // - // P tag: the current client's public key - // p tag: the requester's public key - let event = EventBuilder::new(Kind::Custom(4455), payload) - .tags(vec![ - Tag::custom(TagKind::custom("P"), vec![client_pubkey]), - Tag::public_key(target), - ]) - .sign(client_key) - .await?; - - // Get the current user's signer and public key - let signer = self.client.signer().await?; - let public_key = signer.get_public_key().await?; - - // Get the current user's relay list - let urls: Vec = self - .client - .database() - .relay_list(public_key) - .await? - .into_iter() - .filter_map(|(url, metadata)| { - if metadata.is_none() || metadata == Some(RelayMetadata::Read) { - Some(url) - } else { - None - } - }) - .collect(); - - // Send the response event to the user's relay list - self.client.send_event_to(urls, &event).await?; - - Ok(()) - } - - /// Get and verify NIP-17 relays for a given public key - pub async fn get_nip17(&self, public_key: PublicKey) -> Result<(), Error> { - let timeout = Duration::from_secs(QUERY_TIMEOUT); - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - - let filter = Filter::new() - .kind(Kind::InboxRelays) - .author(public_key) - .limit(1); - - // Subscribe to events from the bootstrapping relays - self.client.subscribe(filter.clone(), Some(opts)).await?; - - let tx = self.signal.sender().clone(); - let database = self.client.database().clone(); - - // Verify the received data after a timeout - smol::spawn(async move { - smol::Timer::after(timeout).await; - - if database.count(filter).await.unwrap_or(0) < 1 { - tx.send_async(SignalKind::MessagingRelaysNotFound) - .await - .ok(); - } - }) - .detach(); - - Ok(()) - } - - /// Set NIP-17 relays for a current user - pub async fn set_nip17(&self, relays: &[RelayUrl]) -> Result<(), Error> { - let signer = self.client.signer().await?; - - let event = EventBuilder::new(Kind::InboxRelays, "") - .tags(relays.iter().cloned().map(Tag::relay)) - .sign(&signer) - .await?; - - // Send event to the public relays - self.client.send_event(&event).await?; - - // Get all gift wrap events after published event - self.get_messages(event.pubkey, relays).await?; - - Ok(()) - } - - /// Get all gift wrap events in the messaging relays for a given public key - pub async fn get_messages( - &self, - public_key: PublicKey, - urls: &[RelayUrl], - ) -> Result<(), Error> { - let id = SubscriptionId::new(INBOX_SUB_ID); - let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); - - // Ensure user's have at least one relay - if urls.is_empty() { - return Err(anyhow!("Relays are empty")); - } - - // Ensure connection to relays - for url in urls.iter() { - self.client.add_relay(url).await?; - self.client.connect_relay(url).await?; - } - - // Subscribe to filters to user's messaging relays - self.client - .subscribe_with_id_to(urls, id, filter, None) - .await?; - - Ok(()) - } - - /// Resubscribes to gift wrap events that include the encryption public key - pub async fn get_messages_with_encryption(&self, encryption: PublicKey) -> Result<(), Error> { - let signer = self.client.signer().await?; - let public_key = signer.get_public_key().await?; - let urls = self.messaging_relays(public_key).await; - - let id = SubscriptionId::new(INBOX_SUB_ID); - - let filter = Filter::new() - .kind(Kind::GiftWrap) - .pubkeys(vec![public_key, encryption]); - - // Unsubscribe the previous subscription - self.client.unsubscribe(&id).await; - - // Subscribe to gift wrap events - self.client - .subscribe_with_id_to(&urls, id, filter, None) - .await?; - - log::info!("Subscribed to gift wrap events"); - - Ok(()) - } - - /// Gets messaging relays for public key - pub async fn messaging_relays(&self, public_key: PublicKey) -> Vec { - let mut relay_urls = vec![]; - - let filter = Filter::new() - .kind(Kind::InboxRelays) - .author(public_key) - .limit(1); - - if let Ok(events) = self.client.database().query(filter).await { - if let Some(event) = events.first_owned() { - let urls: Vec = nip17::extract_owned_relay_list(event).collect(); - - // Connect to relays - for url in urls.iter() { - self.client.add_relay(url).await.ok(); - self.client.connect_relay(url).await.ok(); - } - - relay_urls.extend(urls.into_iter().take(3)); - } - } - - relay_urls - } - - /// Stores an unwrapped event in local database with reference to original - async fn set_rumor(&self, gift_wrap: EventId, rumor: &UnsignedEvent) -> Result<(), Error> { - let rumor_id = rumor.id.context("Rumor is missing an event id")?; - let author = rumor.pubkey; - let conversation = self.conversation_id(rumor); - - let mut tags = rumor.tags.clone().to_vec(); - - // Add a unique identifier - tags.push(Tag::identifier(gift_wrap)); - - // Add a reference to the rumor's author - tags.push(Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::A)), - [author], - )); - - // Add a conversation id - tags.push(Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::C)), - [conversation.to_string()], - )); - - // Add a reference to the rumor's id - tags.push(Tag::event(rumor_id)); - - // Add references to the rumor's participants - for receiver in rumor.tags.public_keys().copied() { - tags.push(Tag::custom( - TagKind::SingleLetter(SingleLetterTag::lowercase(Alphabet::P)), - [receiver], - )); - } - - // Convert rumor to json - let content = rumor.as_json(); - - // Construct the event - let event = EventBuilder::new(Kind::ApplicationSpecificData, content) - .tags(tags) - .sign(&Keys::generate()) - .await?; - - // Save the event to the database - self.client.database().save_event(&event).await?; - - Ok(()) - } - - /// Retrieves a previously unwrapped event from local database - async fn get_rumor(&self, gift_wrap: EventId) -> Result { - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(gift_wrap) - .limit(1); - - if let Some(event) = self.client.database().query(filter).await?.first_owned() { - UnsignedEvent::from_json(event.content).map_err(|e| anyhow!(e)) - } else { - Err(anyhow!("Event is not cached yet.")) - } - } - - // Unwraps a gift-wrapped event and processes its contents. - async fn extract_rumor(&self, gift_wrap: &Event) -> Result<(), Error> { - // Try to get cached rumor first - if let Ok(event) = self.get_rumor(gift_wrap.id).await { - self.process_rumor(gift_wrap.id, event).await?; - return Ok(()); - } - - // Try to unwrap with the available signer - let unwrapped = self.try_unwrap_gift_wrap(gift_wrap).await?; - //let sender = unwrapped.sender; - let mut rumor_unsigned = unwrapped.rumor; - - //if !self.verify_rumor_sender(sender, &rumor_unsigned) { - // return Err(anyhow!("Cannot verify the sender")); - //}; - - // Generate event id for the rumor if it doesn't have one - rumor_unsigned.ensure_id(); - - // Cache the rumor - self.set_rumor(gift_wrap.id, &rumor_unsigned).await?; - - // Process the rumor - self.process_rumor(gift_wrap.id, rumor_unsigned).await?; - - Ok(()) - } - - // Helper method to try unwrapping with different signers - async fn try_unwrap_gift_wrap(&self, gift_wrap: &Event) -> Result { - // Try to unwrap with the encryption key if available - // NIP-4e: https://github.com/nostr-protocol/nips/blob/per-device-keys/4e.md - if let Some(signer) = self.device.read().await.encryption.as_ref() { - if let Ok(unwrapped) = UnwrappedGift::from_gift_wrap(signer, gift_wrap).await { - return Ok(unwrapped); - } - } - - // Fallback to unwrap with the user's signer - let signer = self.client.signer().await?; - let unwrapped = UnwrappedGift::from_gift_wrap(&signer, gift_wrap).await?; - - Ok(unwrapped) - } - - /// Process a rumor event. - async fn process_rumor(&self, id: EventId, event: UnsignedEvent) -> Result<(), Error> { - // Send all pubkeys to the metadata batch to sync data - for public_key in event.tags.public_keys().copied() { - self.ingester.send(public_key).await; - } - - match event.created_at >= self.initialized_at { - // New message: send a signal to notify the UI - true => { - let new_message = NewMessage::new(id, event); - self.signal.send(SignalKind::NewMessage(new_message)).await; - } - // Old message: Coop is probably processing the user's messages during initial load - false => { - self.gift_wrap_processing.store(true, Ordering::Release); - } - } - - Ok(()) - } - - /// Get the conversation ID for a given rumor (message). - fn conversation_id(&self, rumor: &UnsignedEvent) -> u64 { - let mut hasher = DefaultHasher::new(); - let mut pubkeys: Vec = rumor.tags.public_keys().copied().collect(); - pubkeys.push(rumor.pubkey); - pubkeys.sort(); - pubkeys.dedup(); - pubkeys.hash(&mut hasher); - - hasher.finish() - } - - /// Verify that the sender of a rumor is the same as the sender of the event. - #[allow(dead_code)] - fn verify_rumor_sender(&self, sender: PublicKey, rumor: &UnsignedEvent) -> bool { - rumor.pubkey == sender - } - - /// Extract an encryption keys announcement from an event. - fn extract_announcement(&self, event: &Event) -> Result { - let public_key = event - .tags - .iter() - .find(|tag| tag.kind().as_str() == "n" || tag.kind().as_str() == "pubkey") - .and_then(|tag| tag.content()) - .and_then(|c| PublicKey::parse(c).ok()) - .context("Cannot parse public key from the event's tags")?; - - let client_name = event - .tags - .find(TagKind::Client) - .and_then(|tag| tag.content()) - .map(|c| c.to_string()) - .context("Cannot parse client name from the event's tags")?; - - Ok(Announcement::new(event.id, client_name, public_key)) - } - - /// Extract an encryption keys response from an event. - fn extract_response(&self, event: &Event) -> Result { - let payload = event.content.clone(); - let root_device = event - .tags - .find(TagKind::custom("P")) - .and_then(|tag| tag.content()) - .and_then(|c| PublicKey::parse(c).ok()) - .context("Cannot parse public key from the event's tags")?; - - Ok(Response::new(payload, root_device)) - } -} diff --git a/crates/states/src/state/signal.rs b/crates/states/src/state/signal.rs deleted file mode 100644 index dcbb854..0000000 --- a/crates/states/src/state/signal.rs +++ /dev/null @@ -1,169 +0,0 @@ -use flume::{Receiver, Sender}; -use nostr_sdk::prelude::*; - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct NewMessage { - pub gift_wrap: EventId, - pub rumor: UnsignedEvent, -} - -impl NewMessage { - pub fn new(gift_wrap: EventId, rumor: UnsignedEvent) -> Self { - Self { gift_wrap, rumor } - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct AuthRequest { - pub url: RelayUrl, - pub challenge: String, - pub sending: bool, -} - -impl AuthRequest { - pub fn new(challenge: impl Into, url: RelayUrl) -> Self { - Self { - challenge: challenge.into(), - sending: false, - url, - } - } -} - -#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] -pub enum UnwrappingStatus { - #[default] - Initialized, - Processing, - Complete, -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct Announcement { - id: EventId, - client: String, - public_key: PublicKey, -} - -impl Announcement { - pub fn new(id: EventId, client_name: String, public_key: PublicKey) -> Self { - Self { - id, - client: client_name, - public_key, - } - } - - pub fn id(&self) -> EventId { - self.id - } - - pub fn public_key(&self) -> PublicKey { - self.public_key - } - - pub fn client(&self) -> &str { - self.client.as_str() - } -} - -#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)] -pub struct Response { - payload: String, - public_key: PublicKey, -} - -impl Response { - pub fn new(payload: String, public_key: PublicKey) -> Self { - Self { - payload, - public_key, - } - } - - pub fn public_key(&self) -> PublicKey { - self.public_key - } - - pub fn payload(&self) -> &str { - self.payload.as_str() - } -} - -/// Signals sent through the global event channel to notify UI -#[derive(Debug)] -pub enum SignalKind { - /// NIP-4e - /// - /// A signal to notify UI that the user has not set encryption keys yet - EncryptionNotSet, - - /// NIP-4e - /// - /// A signal to notify UI that the user has set encryption keys - EncryptionSet(Announcement), - - /// NIP-4e - /// - /// A signal to notify UI that the user has responded to an encryption request - EncryptionResponse(Response), - - /// NIP-4e - /// - /// A signal to notify UI that the user has requested encryption keys from other devices - EncryptionRequest(Announcement), - - /// A signal to notify UI that the client's signer has been set - SignerSet(PublicKey), - - /// A signal to notify UI that the relay requires authentication - Auth(AuthRequest), - - /// A signal to notify UI that a new profile has been received - NewProfile(Profile), - - /// A signal to notify UI that a new gift wrap event has been received - NewMessage(NewMessage), - - /// A signal to notify UI that no messaging relays for current user was found - MessagingRelaysNotFound, - - /// A signal to notify UI that no gossip relays for current user was found - GossipRelaysNotFound, - - /// A signal to notify UI that gift wrap status has changed - GiftWrapStatus(UnwrappingStatus), -} - -#[derive(Debug, Clone)] -pub struct Signal { - rx: Receiver, - tx: Sender, -} - -impl Default for Signal { - fn default() -> Self { - Self::new() - } -} - -impl Signal { - pub fn new() -> Self { - let (tx, rx) = flume::bounded::(2048); - Self { rx, tx } - } - - pub fn receiver(&self) -> &Receiver { - &self.rx - } - - pub fn sender(&self) -> &Sender { - &self.tx - } - - pub async fn send(&self, kind: SignalKind) { - if let Err(e) = self.tx.send_async(kind).await { - log::error!("Failed to send signal: {e}"); - } - } -} diff --git a/crates/ui/src/input/element.rs b/crates/ui/src/input/element.rs index 40619d4..f324f74 100644 --- a/crates/ui/src/input/element.rs +++ b/crates/ui/src/input/element.rs @@ -541,13 +541,10 @@ impl Element for TextElement { let mut bounds = bounds; let (display_text, text_color) = if is_empty { - ( - Rope::from_str_small(placeholder.as_str()), - cx.theme().text_muted, - ) + (Rope::from(placeholder.as_str()), cx.theme().text_muted) } else if state.masked { ( - Rope::from_str_small("*".repeat(text.chars_count()).as_str()), + Rope::from("*".repeat(text.chars_count()).as_str()), cx.theme().text, ) } else { diff --git a/crates/ui/src/input/state.rs b/crates/ui/src/input/state.rs index fb8545c..56b385c 100644 --- a/crates/ui/src/input/state.rs +++ b/crates/ui/src/input/state.rs @@ -718,7 +718,7 @@ impl InputState { /// Set the default value of the input field. pub fn default_value(mut self, value: impl Into) -> Self { let text: SharedString = value.into(); - self.text = Rope::from_str_small(text.as_str()); + self.text = Rope::from(text.as_str()); self.text_wrapper.set_default_text(&self.text); self } @@ -2099,9 +2099,7 @@ impl EntityInputHandler for InputState { .unwrap_or(self.selected_range.into()); let old_text = self.text.clone(); - let executor = cx.background_executor(); - - self.text.replace(range.clone(), new_text, executor); + self.text.replace(range.clone(), new_text); let mut new_offset = (range.start + new_text.len()).min(self.text.len()); @@ -2115,7 +2113,7 @@ impl EntityInputHandler for InputState { if !self.mask_pattern.is_none() { let mask_text = self.mask_pattern.mask(&pending_text); - self.text = Rope::from_str_small(mask_text.as_str()); + self.text = Rope::from(mask_text.as_str()); let new_text_len = (new_text.len() + mask_text.len()).saturating_sub(pending_text.len()); new_offset = (range.start + new_text_len).min(mask_text.len()); @@ -2123,13 +2121,8 @@ impl EntityInputHandler for InputState { } self.push_history(&old_text, &range, new_text); - self.text_wrapper.update( - &self.text, - &range, - &Rope::from_str_small(new_text), - false, - cx, - ); + self.text_wrapper + .update(&self.text, &range, &Rope::from(new_text), false, cx); self.selected_range = (new_offset..new_offset).into(); self.ime_marked_range.take(); self.update_preferred_column(); @@ -2161,9 +2154,7 @@ impl EntityInputHandler for InputState { .unwrap_or(self.selected_range.into()); let old_text = self.text.clone(); - let executor = cx.background_executor(); - - self.text.replace(range.clone(), new_text, executor); + self.text.replace(range.clone(), new_text); if self.mode.is_single_line() { let pending_text = self.text.to_string(); @@ -2174,13 +2165,9 @@ impl EntityInputHandler for InputState { } self.push_history(&old_text, &range, new_text); - self.text_wrapper.update( - &self.text, - &range, - &Rope::from_str_small(new_text), - false, - cx, - ); + self.text_wrapper + .update(&self.text, &range, &Rope::from(new_text), false, cx); + if new_text.is_empty() { // Cancel selection, when cancel IME input. self.selected_range = (range.start..range.start).into(); @@ -2195,6 +2182,7 @@ impl EntityInputHandler for InputState { .into(); } self.mode.update_auto_grow(&self.text_wrapper); + cx.emit(InputEvent::Change); cx.notify(); } diff --git a/crates/ui/src/menu/menu_item.rs b/crates/ui/src/menu/menu_item.rs index 3474b1e..95f2b7f 100644 --- a/crates/ui/src/menu/menu_item.rs +++ b/crates/ui/src/menu/menu_item.rs @@ -88,7 +88,7 @@ impl RenderOnce for MenuItemElement { h_flex() .id(self.id) .group(&self.group_name) - .gap_x_1() + .gap_x_2() .py_1() .px_2() .text_base() diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index 5663935..61d1ba5 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -861,8 +861,8 @@ impl PopupMenu { fn render_icon( has_icon: bool, icon: Option, - _: &mut Window, - _: &mut Context, + _window: &mut Window, + _cx: &mut Context, ) -> Option { if !has_icon { return None; @@ -873,7 +873,7 @@ impl PopupMenu { .h_3p5() .justify_center() .text_sm() - .when_some(icon, |this, icon| this.child(icon.clone().xsmall())); + .when_some(icon, |this, icon| this.child(icon.clone().small())); Some(icon) } diff --git a/locales/app.yml b/locales/app.yml index 18e50a1..c1ee65b 100644 --- a/locales/app.yml +++ b/locales/app.yml @@ -68,15 +68,11 @@ keyring_disable: label: en: "Keyring is disabled" body_1: - en: "Coop cannot access the Keyring Service on your system." + en: "Coop cannot access the Keyring Service on your system. By design, Coop uses Keyring to store your credentials." body_2: - en: "By design, Coop uses Keyring to store your credentials." - body_3: en: "Without access to Keyring, Coop will store your credentials as plain text." - body_4: + body_3: en: "If you want to store your credentials in the Keyring, please enable Keyring and allow Coop to access it." - body_5: - en: "By clicking continue, you agree to store your credentials as plain text." pending_encryption: label: @@ -94,14 +90,6 @@ request_encryption: body: en: "You've requested for the encryption Key from:" -encryption: - notice: - en: "Encryption Key are being generated" - success: - en: "Encryption Key have been successfully set up" - reinit: - en: "Encryption Key are being reinitialized" - auto_update: updating: en: "Installing the new update..."