From 807851518af017189711cb4d63e80b6bc0188510 Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Sat, 30 Aug 2025 14:38:00 +0700 Subject: [PATCH] feat: manually handle NIP-42 auth request (#132) * improve fetch relays * . * . * . * refactor * refactor * remove identity * manually auth * auth * prevent duplicate message * clean up --- Cargo.lock | 190 ++- crates/common/src/display.rs | 69 +- crates/coop/Cargo.toml | 3 +- crates/coop/src/actions.rs | 34 + crates/coop/src/chatspace.rs | 1014 ++++++++++++++--- crates/coop/src/main.rs | 459 +------- crates/coop/src/views/account.rs | 39 +- crates/coop/src/views/chat/mod.rs | 43 +- crates/coop/src/views/chat/subject.rs | 4 +- crates/coop/src/views/compose.rs | 2 +- crates/coop/src/views/login.rs | 7 +- crates/coop/src/views/mod.rs | 2 +- crates/coop/src/views/onboarding.rs | 7 +- crates/coop/src/views/preferences.rs | 11 +- crates/coop/src/views/screening.rs | 100 +- .../{messaging_relays.rs => setup_relay.rs} | 130 ++- crates/coop/src/views/sidebar/list_item.rs | 31 +- crates/coop/src/views/sidebar/mod.rs | 15 +- crates/coop/src/views/user_profile.rs | 101 +- crates/global/src/constants.rs | 16 +- crates/global/src/lib.rs | 118 +- crates/identity/Cargo.toml | 22 - crates/identity/src/lib.rs | 122 -- crates/registry/Cargo.toml | 2 +- crates/registry/src/lib.rs | 148 +-- crates/registry/src/message.rs | 34 +- crates/registry/src/room.rs | 159 +-- crates/ui/src/notification.rs | 302 +++-- crates/ui/src/popup_menu.rs | 11 +- crates/ui/src/root.rs | 14 +- crates/ui/src/skeleton.rs | 24 +- crates/ui/src/text.rs | 2 +- locales/app.yml | 18 + 33 files changed, 1810 insertions(+), 1443 deletions(-) create mode 100644 crates/coop/src/actions.rs rename crates/coop/src/views/{messaging_relays.rs => setup_relay.rs} (81%) delete mode 100644 crates/identity/Cargo.toml delete mode 100644 crates/identity/src/lib.rs diff --git a/Cargo.lock b/Cargo.lock index bdb3ede..b9f65f8 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -76,6 +76,12 @@ dependencies = [ "equator", ] +[[package]] +name = "allocator-api2" +version = "0.2.21" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923" + [[package]] name = "android-tzdata" version = "0.1.1" @@ -212,9 +218,9 @@ dependencies = [ [[package]] name = "async-compression" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6448dfb3960f0b038e88c781ead1e7eb7929dfc3a71a1336ec9086c00f6d1e75" +checksum = "5bee399cc3a623ec5a2db2c5b90ee0190a2260241fbe0c023ac8f7bab426aaf8" dependencies = [ "compression-codecs", "compression-core", @@ -228,9 +234,9 @@ dependencies = [ [[package]] name = "async-executor" -version = "1.13.2" +version = "1.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb812ffb58524bdd10860d7d974e2f01cc0950c2438a74ee5ec2e2280c6c4ffa" +checksum = "497c00e0fd83a72a79a39fcbd8e3e2f055d6f6c7e025f3b3d91f4f8e76527fb8" dependencies = [ "async-task", "concurrent-queue", @@ -501,9 +507,9 @@ dependencies = [ [[package]] name = "base62" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "10e52a7bcb1d6beebee21fb5053af9e3cbb7a7ed1a4909e534040e676437ab1f" +checksum = "0104d4d8d15e458f21dcd027ea350bf38e4364954909402f4da075aca8d0f136" dependencies = [ "rustversion", ] @@ -597,9 +603,9 @@ checksum = "5e764a1d40d510daf35e07be9eb06e75770908c27d411ee6c92109c9840eaaf7" [[package]] name = "bit_field" -version = "0.10.2" +version = "0.10.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc827186963e592360843fb5ba4b973e145841266c1357f7180c43526f2e5b61" +checksum = "1e4b40c7323adcfc0a41c4b88143ed58346ff65a288fc144329c5c45e05d70c6" [[package]] name = "bitcoin-internals" @@ -1125,7 +1131,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1181,9 +1187,9 @@ dependencies = [ [[package]] name = "compression-codecs" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "46cc6539bf1c592cff488b9f253b30bc0ec50d15407c2cf45e27bd8f308d5905" +checksum = "c7eea68f0e02c2b0aa8856e9a9478444206d4b6828728e7b0697c0f8cca265cb" dependencies = [ "compression-core", "deflate64", @@ -1195,9 +1201,9 @@ dependencies = [ [[package]] name = "compression-core" -version = "0.4.28" +version = "0.4.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2957e823c15bde7ecf1e8b64e537aa03a6be5fda0e2334e99887669e75b12e01" +checksum = "e47641d3deaf41fb1538ac1f54735925e275eaf3bf4d55c81b137fba797e5cbb" [[package]] name = "concurrent-queue" @@ -1249,7 +1255,6 @@ dependencies = [ "gpui", "gpui_tokio", "i18n", - "identity", "itertools 0.13.0", "log", "nostr", @@ -1262,12 +1267,14 @@ dependencies = [ "serde", "serde_json", "settings", + "signer_proxy", "smallvec", "smol", "theme", "title_bar", "tracing-subscriber", "ui", + "webbrowser", ] [[package]] @@ -1566,7 +1573,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "proc-macro2", "quote", @@ -2305,12 +2312,12 @@ dependencies = [ [[package]] name = "gethostname" -version = "0.4.3" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0176e0459c2e4a1fe232f984bca6890e681076abb9934f6cea7c326f3fc47818" +checksum = "fc257fdb4038301ce4b9cd1b3b51704509692bb3ff716a410cbd07925d9dae55" dependencies = [ - "libc", - "windows-targets 0.48.5", + "rustix 1.0.8", + "windows-targets 0.52.6", ] [[package]] @@ -2336,7 +2343,7 @@ dependencies = [ "js-sys", "libc", "r-efi", - "wasi 0.14.2+wasi-0.2.4", + "wasi 0.14.3+wasi-0.2.4", "wasm-bindgen", ] @@ -2458,7 +2465,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2552,7 +2559,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2564,7 +2571,7 @@ dependencies = [ [[package]] name = "gpui_tokio" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "anyhow", "gpui", @@ -2630,6 +2637,8 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash", ] @@ -2787,7 +2796,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "anyhow", "bytes", @@ -2807,7 +2816,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "rustls", "rustls-platform-verifier", @@ -2900,7 +2909,7 @@ dependencies = [ "libc", "percent-encoding", "pin-project-lite", - "socket2 0.6.0", + "socket2", "system-configuration", "tokio", "tower-service", @@ -3025,26 +3034,6 @@ dependencies = [ "zerovec", ] -[[package]] -name = "identity" -version = "0.2.2" -dependencies = [ - "anyhow", - "client_keys", - "common", - "global", - "gpui", - "log", - "nostr-connect", - "nostr-sdk", - "settings", - "signer_proxy", - "smallvec", - "smol", - "ui", - "webbrowser", -] - [[package]] name = "idna" version = "1.1.0" @@ -3107,9 +3096,9 @@ dependencies = [ [[package]] name = "image-webp" -version = "0.2.3" +version = "0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f6970fe7a5300b4b42e62c52efa0187540a5bef546c60edaf554ef595d2e6f0b" +checksum = "525e9ff3e1a4be2fbea1fdf0e98686a6d98b4d8f937e1bf7402245af1909e8c3" dependencies = [ "byteorder-lite", "quick-error", @@ -3620,7 +3609,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -3858,7 +3847,7 @@ dependencies = [ [[package]] name = "nostr" version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#81c3b2fcc87bac915aaae3fb6c3668508d970087" +source = "git+https://github.com/rust-nostr/nostr#84b1a016cffc30625567a03e2d3bcae86463f075" dependencies = [ "aes", "base64", @@ -3869,6 +3858,7 @@ dependencies = [ "chacha20", "chacha20poly1305", "getrandom 0.2.16", + "hex", "instant", "scrypt", "secp256k1", @@ -3881,7 +3871,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#81c3b2fcc87bac915aaae3fb6c3668508d970087" +source = "git+https://github.com/rust-nostr/nostr#84b1a016cffc30625567a03e2d3bcae86463f075" dependencies = [ "async-utility", "nostr", @@ -3893,7 +3883,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#81c3b2fcc87bac915aaae3fb6c3668508d970087" +source = "git+https://github.com/rust-nostr/nostr#84b1a016cffc30625567a03e2d3bcae86463f075" dependencies = [ "flatbuffers", "lru", @@ -3904,7 +3894,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#81c3b2fcc87bac915aaae3fb6c3668508d970087" +source = "git+https://github.com/rust-nostr/nostr#84b1a016cffc30625567a03e2d3bcae86463f075" dependencies = [ "async-utility", "flume", @@ -3918,11 +3908,12 @@ dependencies = [ [[package]] name = "nostr-relay-pool" version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#81c3b2fcc87bac915aaae3fb6c3668508d970087" +source = "git+https://github.com/rust-nostr/nostr#84b1a016cffc30625567a03e2d3bcae86463f075" dependencies = [ "async-utility", "async-wsocket", "atomic-destructor", + "hex", "lru", "negentropy", "nostr", @@ -3934,7 +3925,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.43.0" -source = "git+https://github.com/rust-nostr/nostr#81c3b2fcc87bac915aaae3fb6c3668508d970087" +source = "git+https://github.com/rust-nostr/nostr#84b1a016cffc30625567a03e2d3bcae86463f075" dependencies = [ "async-utility", "nostr", @@ -3955,12 +3946,11 @@ dependencies = [ [[package]] name = "nu-ansi-term" -version = "0.46.0" +version = "0.50.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "77a8165726e8236064dbb45459242600304b42a5ea24ee2948e18e023bf7ba84" +checksum = "d4a28e057d01f97e61255210fcff094d74ed0466038633e95017f5beb68e4399" dependencies = [ - "overload", - "winapi", + "windows-sys 0.52.0", ] [[package]] @@ -4352,12 +4342,6 @@ dependencies = [ "pin-project-lite", ] -[[package]] -name = "overload" -version = "0.1.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b15813163c1d831bf4a13c3610c05c0d03b39feb07f7e09fa234dac9b15aaf39" - [[package]] name = "page_size" version = "0.6.0" @@ -4615,9 +4599,9 @@ dependencies = [ [[package]] name = "potential_utf" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e5a7c30837279ca13e7c867e9e40053bc68740f988cb07f7ca6df43cc734b585" +checksum = "84df19adbe5b5a0782edcab45899906947ab039ccf4573713735ee7de1e6b08a" dependencies = [ "zerovec", ] @@ -4759,9 +4743,9 @@ dependencies = [ [[package]] name = "quinn" -version = "0.11.8" +version = "0.11.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "626214629cda6781b6dc1d316ba307189c85ba657213ce642d9c77670f8202c8" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" dependencies = [ "bytes", "cfg_aliases", @@ -4770,7 +4754,7 @@ dependencies = [ "quinn-udp", "rustc-hash 2.1.1", "rustls", - "socket2 0.5.10", + "socket2", "thiserror 2.0.16", "tokio", "tracing", @@ -4779,9 +4763,9 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.12" +version = "0.11.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "49df843a9161c85bb8aae55f101bc0bac8bcafd637a620d9122fd7e0b2f7422e" +checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" dependencies = [ "bytes", "getrandom 0.3.3", @@ -4800,16 +4784,16 @@ dependencies = [ [[package]] name = "quinn-udp" -version = "0.5.13" +version = "0.5.14" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fcebb1209ee276352ef14ff8732e24cc2b02bbac986cd74a4c81bcb2f9881970" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" dependencies = [ "cfg_aliases", "libc", "once_cell", - "socket2 0.5.10", + "socket2", "tracing", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -5033,7 +5017,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "derive_refineable", "workspace-hack", @@ -5073,11 +5057,11 @@ name = "registry" version = "0.2.2" dependencies = [ "anyhow", - "chrono", "common", "fuzzy-matcher", "global", "gpui", + "hashbrown 0.15.5", "itertools 0.13.0", "log", "nostr", @@ -5188,7 +5172,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "anyhow", "bytes", @@ -5724,7 +5708,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "anyhow", "serde", @@ -6030,16 +6014,6 @@ version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dd538fb6910ac1099850255cf94a94df6551fbdd602454387d0adb2d1ca6dead" -[[package]] -name = "socket2" -version = "0.5.10" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e22376abed350d73dd1cd119b57ffccad95b4e585a7cda43e286245ce23c0678" -dependencies = [ - "libc", - "windows-sys 0.52.0", -] - [[package]] name = "socket2" version = "0.6.0" @@ -6175,7 +6149,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "arrayvec", "log", @@ -6668,7 +6642,7 @@ dependencies = [ "mio", "pin-project-lite", "slab", - "socket2 0.6.0", + "socket2", "tokio-macros", "windows-sys 0.59.0", ] @@ -6915,9 +6889,9 @@ dependencies = [ [[package]] name = "tracing-subscriber" -version = "0.3.19" +version = "0.3.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e8189decb5ac0fa7bc8b96b7cb9b2701d60d48805aca84a238004d665fcc4008" +checksum = "2054a14f5307d601f88daf0553e1cbf472acc4f2c51afab632431cdcd72124d5" dependencies = [ "nu-ansi-term", "sharded-slab", @@ -7203,7 +7177,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#11545c669e100392a8ca60063476037ab52c7cb5" +source = "git+https://github.com/zed-industries/zed#7d0a303785fd73677c255fb15657e6af8dc1e3e8" dependencies = [ "anyhow", "async-fs", @@ -7372,11 +7346,11 @@ checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" [[package]] name = "wasi" -version = "0.14.2+wasi-0.2.4" +version = "0.14.3+wasi-0.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9683f9a5a998d873c0d21fcbe3c083009670149a8fab228644b8bd36b2c48cb3" +checksum = "6a51ae83037bdd272a9e28ce236db8c07016dd0d50c27038b3f407533c030c95" dependencies = [ - "wit-bindgen-rt", + "wit-bindgen", ] [[package]] @@ -8211,13 +8185,10 @@ dependencies = [ ] [[package]] -name = "wit-bindgen-rt" -version = "0.39.0" +name = "wit-bindgen" +version = "0.45.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6f42320e61fe2cfd34354ecb597f86f413484a798ba44a8ca1165c58d42da6c1" -dependencies = [ - "bitflags 2.9.3", -] +checksum = "052283831dbae3d879dc7f51f3d92703a316ca49f91540417d38591826127814" [[package]] name = "workspace-hack" @@ -8253,22 +8224,23 @@ dependencies = [ [[package]] name = "x11rb" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5d91ffca73ee7f68ce055750bf9f6eca0780b8c85eff9bc046a3b0da41755e12" +checksum = "9993aa5be5a26815fe2c3eacfc1fde061fc1a1f094bf1ad2a18bf9c495dd7414" dependencies = [ "as-raw-xcb-connection", "gethostname", "libc", - "rustix 0.38.44", + "rustix 1.0.8", "x11rb-protocol", + "xcursor", ] [[package]] name = "x11rb-protocol" -version = "0.13.1" +version = "0.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec107c4503ea0b4a98ef47356329af139c0a4f7750e621cf2973cd3385ebcb3d" +checksum = "ea6fc2961e4ef194dcbfe56bb845534d0dc8098940c7e5c012a258bfec6701bd" [[package]] name = "xattr" diff --git a/crates/common/src/display.rs b/crates/common/src/display.rs index 0e86bcb..6afa42a 100644 --- a/crates/common/src/display.rs +++ b/crates/common/src/display.rs @@ -1,21 +1,27 @@ use std::sync::Arc; use anyhow::{anyhow, Error}; +use chrono::{Local, TimeZone}; use global::constants::IMAGE_RESIZE_SERVICE; -use gpui::{Image, ImageFormat, SharedString}; +use gpui::{Image, ImageFormat}; use nostr_sdk::prelude::*; use qrcode::render::svg; use qrcode::QrCode; +const NOW: &str = "now"; +const SECONDS_IN_MINUTE: i64 = 60; +const MINUTES_IN_HOUR: i64 = 60; +const HOURS_IN_DAY: i64 = 24; +const DAYS_IN_MONTH: i64 = 30; const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png"; -pub trait DisplayProfile { - fn avatar_url(&self, proxy: bool) -> SharedString; - fn display_name(&self) -> SharedString; +pub trait ReadableProfile { + fn avatar_url(&self, proxy: bool) -> String; + fn display_name(&self) -> String; } -impl DisplayProfile for Profile { - fn avatar_url(&self, proxy: bool) -> SharedString { +impl ReadableProfile for Profile { + fn avatar_url(&self, proxy: bool) -> String { self.metadata() .picture .as_ref() @@ -25,7 +31,6 @@ impl DisplayProfile for Profile { format!( "{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1" ) - .into() } else { picture.into() } @@ -33,7 +38,7 @@ impl DisplayProfile for Profile { .unwrap_or_else(|| "brand/avatar.png".into()) } - fn display_name(&self) -> SharedString { + fn display_name(&self) -> String { if let Some(display_name) = self.metadata().display_name.as_ref() { if !display_name.is_empty() { return display_name.into(); @@ -50,6 +55,51 @@ impl DisplayProfile for Profile { } } +pub trait ReadableTimestamp { + fn to_human_time(&self) -> String; + fn to_ago(&self) -> String; +} + +impl ReadableTimestamp for Timestamp { + fn to_human_time(&self) -> String { + let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) { + chrono::LocalResult::Single(time) => time, + _ => return "9999".into(), + }; + + let now = Local::now(); + let input_date = input_time.date_naive(); + let now_date = now.date_naive(); + let yesterday_date = (now - chrono::Duration::days(1)).date_naive(); + + let time_format = input_time.format("%H:%M %p"); + + match input_date { + date if date == now_date => format!("Today at {time_format}"), + date if date == yesterday_date => format!("Yesterday at {time_format}"), + _ => format!("{}, {time_format}", input_time.format("%d/%m/%y")), + } + } + + fn to_ago(&self) -> String { + let input_time = match Local.timestamp_opt(self.as_u64() as i64, 0) { + chrono::LocalResult::Single(time) => time, + _ => return "1m".into(), + }; + + let now = Local::now(); + let duration = now.signed_duration_since(input_time); + + match duration { + d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(), + d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()), + d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()), + d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()), + _ => input_time.format("%b %d").to_string(), + } + } +} + pub trait TextUtils { fn to_public_key(&self) -> Result; fn to_qr(&self) -> Option>; @@ -84,7 +134,7 @@ impl> TextUtils for T { } } -pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> SharedString { +pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> String { let Ok(pubkey) = public_key.to_bech32(); format!( @@ -92,5 +142,4 @@ pub fn shorten_pubkey(public_key: PublicKey, len: usize) -> SharedString { &pubkey[0..(len + 1)], &pubkey[pubkey.len() - len..] ) - .into() } diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index cf87e9f..4b8277b 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -30,7 +30,6 @@ icons = [ assets = { path = "../assets" } ui = { path = "../ui" } title_bar = { path = "../title_bar" } -identity = { path = "../identity" } theme = { path = "../theme" } common = { path = "../common" } global = { path = "../global" } @@ -38,6 +37,7 @@ registry = { path = "../registry" } settings = { path = "../settings" } client_keys = { path = "../client_keys" } auto_update = { path = "../auto_update" } +signer_proxy = { path = "../signer_proxy" } rust-i18n.workspace = true i18n.workspace = true @@ -59,5 +59,6 @@ smallvec.workspace = true smol.workspace = true futures.workspace = true oneshot.workspace = true +webbrowser.workspace = true tracing-subscriber = { version = "0.3.18", features = ["fmt"] } diff --git a/crates/coop/src/actions.rs b/crates/coop/src/actions.rs new file mode 100644 index 0000000..5a33cfb --- /dev/null +++ b/crates/coop/src/actions.rs @@ -0,0 +1,34 @@ +use std::sync::Mutex; + +use gpui::{actions, App}; + +actions!(coop, [DarkMode, Settings, Logout, Quit]); + +pub fn load_embedded_fonts(cx: &App) { + let asset_source = cx.asset_source(); + let font_paths = asset_source.list("fonts").unwrap(); + let embedded_fonts = Mutex::new(Vec::new()); + let executor = cx.background_executor(); + + executor.block(executor.scoped(|scope| { + for font_path in &font_paths { + if !font_path.ends_with(".ttf") { + continue; + } + + scope.spawn(async { + let font_bytes = asset_source.load(font_path).unwrap().unwrap(); + embedded_fonts.lock().unwrap().push(font_bytes); + }); + } + })); + + cx.text_system() + .add_fonts(embedded_fonts.into_inner().unwrap()) + .unwrap(); +} + +pub fn quit(_: &Quit, cx: &mut App) { + log::info!("Gracefully quitting the application . . ."); + cx.quit(); +} diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index c20ff90..9a3a9f7 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -1,26 +1,34 @@ +use std::borrow::Cow; +use std::collections::{HashMap, HashSet}; use std::sync::Arc; +use std::time::Duration; -use anyhow::anyhow; +use anyhow::{anyhow, Error}; use auto_update::AutoUpdater; use client_keys::ClientKeys; -use common::display::DisplayProfile; -use global::constants::{ACCOUNT_IDENTIFIER, DEFAULT_SIDEBAR_WIDTH}; -use global::{global_channel, nostr_client, NostrSignal}; +use common::display::ReadableProfile; +use common::event::EventUtils; +use global::constants::{ + ACCOUNT_IDENTIFIER, BOOTSTRAP_RELAYS, DEFAULT_SIDEBAR_WIDTH, METADATA_BATCH_LIMIT, + METADATA_BATCH_TIMEOUT, SEARCH_RELAYS, TOTAL_RETRY, WAIT_FOR_FINISH, +}; +use global::{ingester, nostr_client, sent_ids, starting_time, AuthReq, IngesterSignal, Notice}; use gpui::prelude::FluentBuilder; use gpui::{ - actions, div, px, rems, Action, App, AppContext, AsyncWindowContext, Axis, Context, Entity, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, - StatefulInteractiveElement, Styled, Subscription, Task, WeakEntity, Window, + div, px, rems, App, AppContext, AsyncWindowContext, Axis, Context, Entity, InteractiveElement, + IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, + Subscription, Task, WeakEntity, Window, }; use i18n::{shared_t, t}; -use identity::Identity; use itertools::Itertools; use nostr_connect::prelude::*; use nostr_sdk::prelude::*; -use registry::{Registry, RegistrySignal}; -use serde::Deserialize; +use registry::{Registry, RegistryEvent}; use settings::AppSettings; +use signer_proxy::{BrowserSignerProxy, BrowserSignerProxyOptions}; use smallvec::{smallvec, SmallVec}; +use smol::channel::{Receiver, Sender}; +use smol::lock::Mutex; use theme::{ActiveTheme, Theme, ThemeMode}; use title_bar::TitleBar; use ui::actions::OpenProfile; @@ -29,18 +37,17 @@ use ui::button::{Button, ButtonVariants}; use ui::dock_area::dock::DockPlacement; use ui::dock_area::panel::PanelView; use ui::dock_area::{ClosePanel, DockArea, DockItem}; -use ui::indicator::Indicator; use ui::modal::ModalButtonProps; +use ui::notification::Notification; use ui::popup_menu::PopupMenuExt; use ui::tooltip::Tooltip; use ui::{h_flex, v_flex, ContextModal, IconName, Root, Sizable, StyledExt}; +use crate::actions::{DarkMode, Logout, Settings}; use crate::views::compose::compose_button; -use crate::views::screening::Screening; -use crate::views::user_profile::UserProfile; +use crate::views::setup_relay::setup_nip17_relay; use crate::views::{ - account, chat, login, messaging_relays, new_account, onboarding, preferences, sidebar, - user_profile, welcome, + account, chat, login, new_account, onboarding, preferences, sidebar, user_profile, welcome, }; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -57,38 +64,27 @@ pub fn new_account(window: &mut Window, cx: &mut App) { ChatSpace::set_center_panel(panel, window, cx); } -actions!(user, [DarkMode, Settings, Logout]); - -#[derive(Clone, PartialEq, Eq, Deserialize)] -pub enum PanelKind { - Room(u64), - // More kind will be added here +#[derive(Debug, Clone, Default, PartialEq, Eq, PartialOrd, Ord)] +enum RelayTrackStatus { + #[default] + Waiting, + NotFound, + Found, } -#[derive(Clone, PartialEq, Eq, Deserialize)] -pub enum ModalKind { - Profile, - Compose, - Relay, - Onboarding, - SetupRelay, -} - -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = story, no_json)] -pub struct SelectLocale(SharedString); - -#[derive(Action, Clone, PartialEq, Eq, Deserialize)] -#[action(namespace = modal, no_json)] -pub struct ToggleModal { - pub modal: ModalKind, +#[derive(Debug, Clone, Default)] +struct RelayTracking { + nip17: RelayTrackStatus, + nip65: RelayTrackStatus, } pub struct ChatSpace { title_bar: Entity, dock: Entity, - _subscriptions: SmallVec<[Subscription; 4]>, - _tasks: SmallVec<[Task<()>; 1]>, + auth_requests: Vec<(String, RelayUrl)>, + has_nip17_relays: bool, + _subscriptions: SmallVec<[Subscription; 2]>, + _tasks: SmallVec<[Task<()>; 3]>, } impl ChatSpace { @@ -99,13 +95,21 @@ impl ChatSpace { let title_bar = cx.new(|_| TitleBar::new()); let dock = cx.new(|cx| DockArea::new(window, cx)); + let relay_tracking = Arc::new(Mutex::new(RelayTracking::default())); + let relay_tracking_clone = relay_tracking.clone(); + + let (pubkey_tx, pubkey_rx) = smol::channel::bounded::(1024); + let (event_tx, event_rx) = smol::channel::bounded::(2048); + + let pubkey_tx_clone = pubkey_tx.clone(); + let mut subscriptions = smallvec![]; let mut tasks = smallvec![]; subscriptions.push( // Observe the client keys and show an alert modal if they fail to initialize - cx.observe_in(&client_keys, window, |this, state, window, cx| { - if !state.read(cx).has_keys() { + cx.observe_in(&client_keys, window, |this, keys, window, cx| { + if !keys.read(cx).has_keys() { this.render_client_keys_modal(window, cx); } else { this.load_local_account(window, cx); @@ -113,105 +117,419 @@ impl ChatSpace { }), ); - subscriptions.push( - // Automatically run load function when UserProfile is created - cx.observe_new::(|this, window, cx| { - if let Some(window) = window { - this.load(window, cx); - } - }), - ); - - subscriptions.push( - // Automatically run load function when Screening is created - cx.observe_new::(|this, window, cx| { - if let Some(window) = window { - this.load(window, cx); - } - }), - ); - subscriptions.push( // Subscribe to open chat room requests - cx.subscribe_in( - ®istry, - window, - |this, _e, event, window, cx| match event { - RegistrySignal::Open(room) => { - if let Some(room) = room.upgrade() { - this.dock.update(cx, |this, cx| { - let panel = chat::init(room, window, cx); - this.add_panel(Arc::new(panel), DockPlacement::Center, window, cx); - }); - } else { - window.push_notification(t!("common.room_error"), cx); - } - } - RegistrySignal::Close(..) => { - this.dock.update(cx, |this, cx| { - this.focus_tab_panel(window, cx); + cx.subscribe_in(®istry, window, move |this, _, event, window, cx| { + this.process_registry_event(event, window, cx); + }), + ); - cx.defer_in(window, |_, window, cx| { - window.dispatch_action(Box::new(ClosePanel), cx); - window.close_all_modals(cx); - }); - }); - } - _ => {} - }, - ), + tasks.push( + // Connect to the bootstrap relays + // Then handle nostr events in the background + cx.background_spawn(async move { + Self::connect() + .await + .expect("Failed connect the bootstrap relays. Please restart the application."); + + Self::process_nostr_events(&relay_tracking_clone, &event_tx, &pubkey_tx_clone) + .await + .expect("Failed to handle nostr events. Please restart the application."); + }), + ); + + tasks.push( + // Wait for the signer to be set + // Also verify NIP65 and NIP17 relays after the signer is set + cx.background_spawn(async move { + Self::wait_for_signer_set(&relay_tracking).await; + }), + ); + + tasks.push( + // Listen all metadata requests then batch them into single subscription + cx.background_spawn(async move { + Self::process_batching_metadata(&pubkey_rx).await; + }), + ); + + tasks.push( + // Process gift wrap event in the background + cx.background_spawn(async move { + Self::process_gift_wrap(&pubkey_tx, &event_rx).await; + }), ); tasks.push( // Continuously handle signals from the Nostr channel cx.spawn_in(window, async move |this, cx| { - ChatSpace::handle_signal(this, cx).await + Self::process_nostr_signals(this, cx).await }), ); Self { dock, title_bar, + auth_requests: vec![], + has_nip17_relays: true, _subscriptions: subscriptions, _tasks: tasks, } } - async fn handle_signal(e: WeakEntity, cx: &mut AsyncWindowContext) { - let channel = global_channel(); + async fn connect() -> Result<(), Error> { + let client = nostr_client(); + + for relay in BOOTSTRAP_RELAYS.into_iter() { + client.add_relay(relay).await?; + } + + log::info!("Connected to bootstrap relays"); + + for relay in SEARCH_RELAYS.into_iter() { + client.add_relay(relay).await?; + } + + log::info!("Connected to search relays"); + + // Establish connection to relays + client.connect().await; + + Ok(()) + } + + async fn wait_for_signer_set(relay_tracking: &Arc>) { + let client = nostr_client(); + let ingester = ingester(); + + let mut signer_set = false; + let mut retry = 0; + let mut nip65_retry = 0; + + loop { + if signer_set { + let state = relay_tracking.lock().await; + + if state.nip65 == RelayTrackStatus::Found { + if state.nip17 == RelayTrackStatus::Found { + break; + } else if state.nip17 == RelayTrackStatus::NotFound { + ingester.send(IngesterSignal::DmRelayNotFound).await; + break; + } else { + retry += 1; + if retry == TOTAL_RETRY { + ingester.send(IngesterSignal::DmRelayNotFound).await; + break; + } + } + } else { + nip65_retry += 1; + if nip65_retry == TOTAL_RETRY { + ingester.send(IngesterSignal::DmRelayNotFound).await; + break; + } + } + } + + if !signer_set { + if let Ok(signer) = client.signer().await { + if let Ok(public_key) = signer.get_public_key().await { + signer_set = true; + + // Notify the app that the signer has been set. + ingester.send(IngesterSignal::SignerSet(public_key)).await; + + // Subscribe to the NIP-65 relays for the public key. + if let Err(e) = Self::fetch_nip65_relays(public_key).await { + log::error!("Failed to fetch NIP-65 relays: {e}"); + } + } + } + } + + smol::Timer::after(Duration::from_secs(1)).await; + } + } + + async fn process_batching_metadata(rx: &Receiver) { + 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 { + match smol::future::or( + async { + if let Ok(public_key) = rx.recv().await { + BatchEvent::PublicKey(public_key) + } else { + BatchEvent::Closed + } + }, + async { + smol::Timer::after(timeout).await; + BatchEvent::Timeout + }, + ) + .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::fetch_metadata_for_pubkeys(std::mem::take(&mut batch)).await; + } + } + BatchEvent::Timeout => { + Self::fetch_metadata_for_pubkeys(std::mem::take(&mut batch)).await; + } + BatchEvent::Closed => { + Self::fetch_metadata_for_pubkeys(std::mem::take(&mut batch)).await; + // Exit the current loop + break; + } + } + } + } + + async fn process_gift_wrap(pubkey_tx: &Sender, event_rx: &Receiver) { + let client = nostr_client(); + let ingester = ingester(); + let timeout = Duration::from_secs(WAIT_FOR_FINISH); + + let mut counter = 0; + + loop { + // Signer is unset, probably user is not ready to retrieve gift wrap events + if client.signer().await.is_err() { + smol::Timer::after(Duration::from_secs(1)).await; + continue; + } + + let recv = || async { + // no inline + (event_rx.recv().await).ok() + }; + + let timeout = || async { + smol::Timer::after(timeout).await; + None + }; + + match smol::future::or(recv(), timeout()).await { + Some(event) => { + let cached = Self::unwrap_gift_wrap_event(&event, pubkey_tx).await; + + // Increment the total messages counter if message is not from cache + if !cached { + counter += 1; + } + + // Send partial finish signal to GPUI + if counter >= 20 { + ingester.send(IngesterSignal::PartialFinish).await; + // Reset counter + counter = 0; + } + } + None => { + // Notify the UI that the processing is finished + ingester.send(IngesterSignal::Finish).await; + break; + } + } + } + } + + async fn process_nostr_events( + relay_tracking: &Arc>, + event_tx: &Sender, + pubkey_tx: &Sender, + ) -> Result<(), Error> { + let client = nostr_client(); + let ingester = ingester(); + let sent_ids = sent_ids(); + let auto_close = + SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + + let mut processed_events: HashSet = HashSet::new(); + let mut auth_requests: HashMap> = HashMap::new(); + let mut notifications = client.notifications(); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, relay_url } = notification else { + continue; + }; + + match message { + RelayMessage::Event { event, .. } => { + // Skip events that have already been processed + if !processed_events.insert(event.id) { + continue; + } + + match event.kind { + Kind::RelayList => { + // Get metadata for event's pubkey that matches the current user's pubkey + if let Ok(true) = Self::is_self_event(&event).await { + let mut relay_tracking = relay_tracking.lock().await; + relay_tracking.nip65 = RelayTrackStatus::Found; + + // Fetch user's metadata event + Self::fetch_single_event(Kind::Metadata, event.pubkey).await; + + // Fetch user's contact list event + Self::fetch_single_event(Kind::ContactList, event.pubkey).await; + + // Fetch user's inbox relays event + Self::fetch_single_event(Kind::InboxRelays, event.pubkey).await; + } + } + Kind::InboxRelays => { + if let Ok(true) = Self::is_self_event(&event).await { + let relays: Vec = event + .tags + .filter_standardized(TagKind::Relay) + .filter_map(|t| { + if let TagStandard::Relay(url) = t { + Some(url.to_owned()) + } else { + None + } + }) + .collect(); + + if !relays.is_empty() { + let mut relay_tracking = relay_tracking.lock().await; + relay_tracking.nip17 = RelayTrackStatus::Found; + + for relay in relays.iter() { + if client.add_relay(relay).await.is_err() { + let notice = Notice::RelayFailed(relay.clone()); + ingester.send(IngesterSignal::Notice(notice)).await; + } + if client.connect_relay(relay).await.is_err() { + let notice = Notice::RelayFailed(relay.clone()); + ingester.send(IngesterSignal::Notice(notice)).await; + } + } + + // Subscribe to gift wrap events only in the current user's NIP-17 relays + Self::fetch_gift_wrap(&relays, event.pubkey).await; + } + } + } + Kind::ContactList => { + if let Ok(true) = Self::is_self_event(&event).await { + let public_keys = event.tags.public_keys().copied().collect_vec(); + let kinds = vec![Kind::Metadata, Kind::ContactList]; + let limit = public_keys.len() * kinds.len(); + let filter = + Filter::new().limit(limit).authors(public_keys).kinds(kinds); + + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(auto_close)) + .await + .ok(); + } + } + Kind::Metadata => { + ingester + .send(IngesterSignal::Metadata(event.into_owned())) + .await; + } + Kind::GiftWrap => { + if event_tx.send(event.clone().into_owned()).await.is_err() { + Self::unwrap_gift_wrap_event(&event, pubkey_tx).await; + } + } + _ => {} + } + } + RelayMessage::Auth { challenge } => { + // Prevent duplicate auth requests + if auth_requests + .insert(relay_url.clone(), challenge.clone()) + .is_none() + { + let auth_req = AuthReq::new(challenge, relay_url); + ingester.send(IngesterSignal::Auth(auth_req)).await; + } + } + RelayMessage::Ok { event_id, .. } => { + // Keep track of events sent by Coop + sent_ids.write().await.push(event_id); + } + RelayMessage::Notice(msg) => { + log::info!("Notice: {msg} - {relay_url}"); + } + _ => {} + } + } + + Ok(()) + } + + async fn process_nostr_signals(view: WeakEntity, cx: &mut AsyncWindowContext) { + let ingester = ingester(); + let signals = ingester.signals(); let mut is_open_proxy_modal = false; - while let Ok(signal) = channel.1.recv().await { + while let Ok(signal) = signals.recv().await { cx.update(|window, cx| { let registry = Registry::global(cx); match signal { - NostrSignal::SignerSet(public_key) => { + IngesterSignal::SignerSet(public_key) => { window.close_modal(cx); // Setup the default layout for current workspace - e.update(cx, |this, cx| { + view.update(cx, |this, cx| { this.set_default_layout(window, cx); }) .ok(); - // Initialize identity - identity::init(public_key, window, cx); - // Load all chat rooms registry.update(cx, |this, cx| { + this.set_identity(public_key, cx); this.load_rooms(window, cx); }); } - NostrSignal::SignerUnset => { - e.update(cx, |this, cx| { + IngesterSignal::SignerUnset => { + // Setup the onboarding layout for current workspace + view.update(cx, |this, cx| { this.set_onboarding_layout(window, cx); }) .ok(); + + // Clear all current chat rooms + registry.update(cx, |this, cx| { + this.reset(cx); + }); } - NostrSignal::ProxyDown => { + IngesterSignal::Auth(req) => { + let relay_url = &req.url; + let challenge = &req.challenge; + + view.update(cx, |this, cx| { + this.push_auth_request(challenge, relay_url, cx); + this.open_auth_request(challenge, relay_url, window, cx); + }) + .ok(); + } + IngesterSignal::ProxyDown => { if !is_open_proxy_modal { - e.update(cx, |this, cx| { + view.update(cx, |this, cx| { this.render_proxy_modal(window, cx); }) .ok(); @@ -219,7 +537,7 @@ impl ChatSpace { } } // Load chat rooms and stop the loading status - NostrSignal::Finish => { + IngesterSignal::Finish => { registry.update(cx, |this, cx| { this.load_rooms(window, cx); this.set_loading(false, cx); @@ -230,7 +548,7 @@ impl ChatSpace { }); } // Load chat rooms without setting as finished - NostrSignal::PartialFinish => { + IngesterSignal::PartialFinish => { registry.update(cx, |this, cx| { this.load_rooms(window, cx); // Send a signal to refresh all opened rooms' messages @@ -240,23 +558,25 @@ impl ChatSpace { }); } // Add the new metadata to the registry or update the existing one - NostrSignal::Metadata(event) => { + IngesterSignal::Metadata(event) => { registry.update(cx, |this, cx| { this.insert_or_update_person(event, cx); }); } // Convert the gift wrapped message to a message - NostrSignal::GiftWrap(event) => { - let identity = Identity::read_global(cx).public_key(); + IngesterSignal::GiftWrap((gift_wrap_id, event)) => { registry.update(cx, |this, cx| { - this.event_to_message(identity, event, window, cx); + this.event_to_message(gift_wrap_id, event, window, cx); }); } - NostrSignal::DmRelaysFound => { - // + IngesterSignal::DmRelayNotFound => { + view.update(cx, |this, cx| { + this.set_no_nip17_relays(cx); + }) + .ok(); } - NostrSignal::Notice(_msg) => { - // window.push_notification(msg, cx); + IngesterSignal::Notice(msg) => { + window.push_notification(msg.as_str(), cx); } }; }) @@ -264,7 +584,360 @@ impl ChatSpace { } } - pub fn set_onboarding_layout(&mut self, window: &mut Window, cx: &mut Context) { + /// Checks if an event is belong to the current user + async fn is_self_event(event: &Event) -> Result { + let client = nostr_client(); + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + Ok(public_key == event.pubkey) + } + + /// Fetches a single event by kind and public key + async fn fetch_single_event(kind: Kind, public_key: PublicKey) { + let client = nostr_client(); + let auto_close = + SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let filter = Filter::new().kind(kind).author(public_key).limit(1); + + if let Err(e) = client.subscribe(filter, Some(auto_close)).await { + log::info!("Failed to subscribe: {e}"); + } + } + + async fn fetch_gift_wrap(relays: &[RelayUrl], public_key: PublicKey) { + let client = nostr_client(); + let subscription_id = SubscriptionId::new("inbox"); + let filter = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + + if client + .subscribe_with_id_to(relays.to_owned(), subscription_id, filter, None) + .await + .is_ok() + { + log::info!("Subscribed to messages in: {relays:?}"); + } + } + + /// Fetches NIP-65 relay list for a given public key + async fn fetch_nip65_relays(public_key: PublicKey) -> Result<(), Error> { + let client = nostr_client(); + let auto_close = + SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + + let filter = Filter::new() + .kind(Kind::RelayList) + .author(public_key) + .limit(1); + + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(auto_close)) + .await?; + + Ok(()) + } + + /// Fetches metadata for a list of public keys + async fn fetch_metadata_for_pubkeys(public_keys: HashSet) { + if public_keys.is_empty() { + return; + }; + + let client = nostr_client(); + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let kinds = vec![Kind::Metadata, Kind::ContactList]; + let limit = public_keys.len() * kinds.len(); + let filter = Filter::new().limit(limit).authors(public_keys).kinds(kinds); + + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await + .ok(); + } + + /// Stores an unwrapped event in local database with reference to original + async fn set_unwrapped_event(root: EventId, unwrapped: &Event) -> Result<(), Error> { + let client = nostr_client(); + + // Save unwrapped event + client.database().save_event(unwrapped).await?; + + // Create a reference event pointing to the unwrapped event + let event = EventBuilder::new(Kind::ApplicationSpecificData, "") + .tags(vec![Tag::identifier(root), Tag::event(unwrapped.id)]) + .sign(&Keys::generate()) + .await?; + + // Save reference event + client.database().save_event(&event).await?; + + Ok(()) + } + + /// Retrieves a previously unwrapped event from local database + async fn get_unwrapped_event(root: EventId) -> Result { + let client = nostr_client(); + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(root) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.first_owned() { + let target_id = event.tags.event_ids().collect_vec()[0]; + + if let Some(event) = client.database().event_by_id(target_id).await? { + Ok(event) + } else { + Err(anyhow!("Event not found.")) + } + } else { + Err(anyhow!("Event is not cached yet.")) + } + } + + /// Unwraps a gift-wrapped event and processes its contents. + async fn unwrap_gift_wrap_event(gift: &Event, pubkey_tx: &Sender) -> bool { + let client = nostr_client(); + let ingester = ingester(); + let mut is_cached = false; + + let event = match Self::get_unwrapped_event(gift.id).await { + Ok(event) => { + is_cached = true; + event + } + Err(_) => { + match client.unwrap_gift_wrap(gift).await { + Ok(unwrap) => { + // Sign the unwrapped event with a RANDOM KEYS + let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&Keys::generate()) else { + log::error!("Failed to sign event"); + return false; + }; + + // Save this event to the database for future use. + if let Err(e) = Self::set_unwrapped_event(gift.id, &unwrapped).await { + log::warn!("Failed to cache unwrapped event: {e}") + } + + unwrapped + } + Err(e) => { + log::error!("Failed to unwrap event: {e}"); + return false; + } + } + } + }; + + // Send all pubkeys to the metadata batch to sync data + for public_key in event.all_pubkeys() { + pubkey_tx.send(public_key).await.ok(); + } + + // Send a notify to GPUI if this is a new message + if &event.created_at >= starting_time() { + ingester + .send(IngesterSignal::GiftWrap((gift.id, event))) + .await; + } + + is_cached + } + + fn process_registry_event( + &mut self, + event: &RegistryEvent, + window: &mut Window, + cx: &mut Context, + ) { + match event { + RegistryEvent::Open(room) => { + if let Some(room) = room.upgrade() { + self.dock.update(cx, |this, cx| { + let panel = chat::init(room, window, cx); + this.add_panel(Arc::new(panel), DockPlacement::Center, window, cx); + }); + } else { + window.push_notification(t!("common.room_error"), cx); + } + } + RegistryEvent::Close(..) => { + self.dock.update(cx, |this, cx| { + this.focus_tab_panel(window, cx); + + cx.defer_in(window, |_, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + window.close_all_modals(cx); + }); + }); + } + _ => {} + }; + } + + fn auth( + &mut self, + challenge: &str, + url: &RelayUrl, + window: &mut Window, + cx: &mut Context, + ) { + let challenge = challenge.to_string(); + let url = url.to_owned(); + let challenge_clone = challenge.clone(); + let url_clone = url.clone(); + + let task: Task> = cx.background_spawn(async move { + let client = nostr_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 + match relay.resubscribe().await { + Ok(_) => { + log::info!("{relay_url} - re-subscribe"); + } + Err(e) => return Err(e.into()), + } + + return Ok(()); + } + } + RelayNotification::Shutdown => break, + _ => {} + } + } + + Err(anyhow!("Authentication failed")) + }); + + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(_) => { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.remove_auth_request(&challenge, 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(); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + }) + .ok(); + } + }; + }) + .detach(); + } + + fn open_auth_request( + &mut self, + challenge: &str, + relay_url: &RelayUrl, + window: &mut Window, + cx: &mut Context, + ) { + let weak_view = cx.entity().downgrade(); + let challenge = challenge.to_string(); + let relay_url = relay_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 challenge = challenge.clone(); + let relay_url = relay_url.clone(); + + Button::new("approve") + .label(t!("common.approve")) + .small() + .primary() + .on_click(move |_e, window, cx| { + weak_view + .update(cx, |this, cx| { + this.auth(&challenge, &relay_url, window, cx); + }) + .ok(); + }) + }); + + window.push_notification(note, cx); + } + + fn reopen_auth_request(&mut self, window: &mut Window, cx: &mut Context) { + for (challenge, relay_url) in self.auth_requests.clone().iter() { + self.open_auth_request(challenge, relay_url, window, cx); + } + } + + fn push_auth_request(&mut self, challenge: &str, url: &RelayUrl, cx: &mut Context) { + self.auth_requests.push((challenge.into(), url.to_owned())); + cx.notify(); + } + + fn remove_auth_request(&mut self, challenge: &str, cx: &mut Context) { + if let Some(ix) = self.auth_requests.iter().position(|(c, _)| c == challenge) { + self.auth_requests.remove(ix); + 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); @@ -274,7 +947,7 @@ impl ChatSpace { }); } - pub fn set_account_layout( + fn set_account_layout( &mut self, secret: String, profile: Profile, @@ -355,6 +1028,11 @@ impl ChatSpace { .detach(); } + fn set_no_nip17_relays(&mut self, cx: &mut Context) { + self.has_nip17_relays = false; + cx.notify(); + } + fn on_settings(&mut self, _ev: &Settings, window: &mut Window, cx: &mut Context) { let view = preferences::init(window, cx); @@ -375,14 +1053,9 @@ impl ChatSpace { } fn on_sign_out(&mut self, _e: &Logout, _window: &mut Window, cx: &mut Context) { - Identity::remove_global(cx); - Registry::global(cx).update(cx, |this, cx| { - this.reset(cx); - }); - cx.background_spawn(async move { let client = nostr_client(); - let channel = global_channel(); + let ingester = ingester(); let filter = Filter::new() .kind(Kind::ApplicationSpecificData) @@ -395,7 +1068,7 @@ impl ChatSpace { client.reset().await; // Notify the channel about the signer being unset - channel.0.send(NostrSignal::SignerUnset).await.ok(); + ingester.send(IngesterSignal::SignerUnset).await; }) .detach(); } @@ -497,10 +1170,11 @@ impl ChatSpace { cx: &Context, ) -> impl IntoElement { let registry = Registry::read_global(cx); - let loading = registry.loading; + let loading = self.has_nip17_relays && self.auth_requests.is_empty() && registry.loading; h_flex() .gap_2() + .w_full() .child(compose_button()) .when(loading, |this| { this.child( @@ -511,9 +1185,8 @@ impl ChatSpace { .gap_1() .text_xs() .rounded_full() - .bg(cx.theme().elevated_surface_background) + .bg(cx.theme().surface_background) .child(shared_t!("loading.label")) - .child(Indicator::new().xsmall()) .tooltip(|window, cx| { Tooltip::new(t!("loading.tooltip"), window, cx).into() }), @@ -525,13 +1198,12 @@ impl ChatSpace { &mut self, profile: &Profile, _window: &mut Window, - cx: &Context, + cx: &mut Context, ) -> impl IntoElement { let proxy = AppSettings::get_proxy_user_avatars(cx); - let nip17_relays = Identity::read_global(cx).nip17_relays(); - let updating = AutoUpdater::read_global(cx).status.is_updating(); let updated = AutoUpdater::read_global(cx).status.is_updated(); + let auth_requests = self.auth_requests.len(); h_flex() .gap_1() @@ -539,9 +1211,11 @@ impl ChatSpace { this.child( h_flex() .h_6() + .px_2() .items_center() .justify_center() .text_xs() + .rounded_full() .bg(cx.theme().ghost_element_background_alt) .child(shared_t!("auto_update.updating")), ) @@ -551,9 +1225,11 @@ impl ChatSpace { h_flex() .id("updated") .h_6() + .px_2() .items_center() .justify_center() .text_xs() + .rounded_full() .bg(cx.theme().ghost_element_background_alt) .hover(|this| this.bg(cx.theme().ghost_element_hover)) .active(|this| this.bg(cx.theme().ghost_element_active)) @@ -563,8 +1239,28 @@ impl ChatSpace { }), ) }) - .when_some(nip17_relays, |this, status| { - this.when(!status, |this| this.child(messaging_relays::relay_button())) + .when(auth_requests > 0, |this| { + this.child( + h_flex() + .id("requests") + .h_6() + .px_2() + .items_center() + .justify_center() + .text_xs() + .rounded_full() + .bg(cx.theme().warning_background) + .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.has_nip17_relays, |this| { + this.child(setup_nip17_relay(t!("relays.button_label"))) }) .child( Button::new("user") @@ -582,6 +1278,61 @@ impl ChatSpace { ) } + pub(crate) fn proxy_signer(window: &mut Window, cx: &mut App) { + let Some(Some(root)) = window.root::() else { + return; + }; + + let Ok(chatspace) = root.read(cx).view().clone().downcast::() else { + return; + }; + + chatspace.update(cx, |this, cx| { + let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default()); + let url = proxy.url(); + + this._tasks.push(cx.background_spawn(async move { + let client = nostr_client(); + let ingester = ingester(); + + if proxy.start().await.is_ok() { + webbrowser::open(&url).ok(); + + loop { + if proxy.is_session_active() { + // Save the signer to disk for further logins + if let Ok(public_key) = proxy.get_public_key().await { + let keys = Keys::generate(); + let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)]; + let kind = Kind::ApplicationSpecificData; + + let builder = EventBuilder::new(kind, "extension") + .tags(tags) + .build(public_key) + .sign(&keys) + .await; + + if let Ok(event) = builder { + if let Err(e) = client.database().save_event(&event).await { + log::error!("Failed to save event: {e}"); + }; + } + } + + // Set the client's signer with current proxy signer + client.set_signer(proxy.clone()).await; + + break; + } else { + ingester.send(IngesterSignal::ProxyDown).await; + } + smol::Timer::after(Duration::from_secs(1)).await; + } + } + })); + }); + } + pub(crate) fn all_panels(window: &mut Window, cx: &mut App) -> Option> { let Some(Some(root)) = window.root::() else { return None; @@ -591,7 +1342,7 @@ impl ChatSpace { return None; }; - let ids = chatspace + let ids: Vec = chatspace .read(cx) .dock .read(cx) @@ -599,7 +1350,7 @@ impl ChatSpace { .panel_ids(cx) .into_iter() .filter_map(|panel| panel.parse::().ok()) - .collect_vec(); + .collect(); Some(ids) } @@ -627,12 +1378,11 @@ impl Render for ChatSpace { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let modal_layer = Root::render_modal_layer(window, cx); let notification_layer = Root::render_notification_layer(window, cx); - let logged_in = Identity::has_global(cx); + let registry = Registry::read_global(cx); // Only render titlebar child elements if user is logged in - if logged_in { - let identity = Identity::read_global(cx).public_key(); - let profile = Registry::read_global(cx).get_person(&identity, cx); + if registry.identity.is_some() { + let profile = Registry::read_global(cx).identity(cx); let left_side = self .render_titlebar_left_side(window, cx) diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index 215ae11..a27c845 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -1,193 +1,45 @@ -use std::collections::BTreeSet; -use std::sync::{Arc, Mutex}; -use std::time::Duration; +use std::sync::Arc; -use anyhow::{anyhow, Error}; use assets::Assets; -use common::event::EventUtils; -use global::constants::{ - APP_ID, APP_NAME, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, METADATA_BATCH_TIMEOUT, - SEARCH_RELAYS, WAIT_FOR_FINISH, -}; -use global::{global_channel, nostr_client, processed_events, starting_time, NostrSignal}; +use global::constants::{APP_ID, APP_NAME}; +use global::{ingester, nostr_client, sent_ids, starting_time}; use gpui::{ - actions, point, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, - SharedString, TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, - WindowKind, WindowOptions, + point, px, size, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, SharedString, + TitlebarOptions, WindowBackgroundAppearance, WindowBounds, WindowDecorations, WindowKind, + WindowOptions, }; -use itertools::Itertools; -use nostr_sdk::prelude::*; -use smol::channel::{self, Sender}; use theme::Theme; use ui::Root; +use crate::actions::{load_embedded_fonts, quit, Quit}; + +pub(crate) mod actions; pub(crate) mod chatspace; pub(crate) mod views; i18n::init!(); -actions!(coop, [Quit]); - fn main() { // Initialize logging tracing_subscriber::fmt::init(); - // Initialize the Nostr Client - let client = nostr_client(); + // Initialize the Nostr client + let _client = nostr_client(); + + // Initialize the ingester + let _ingester = ingester(); // Initialize the starting time let _starting_time = starting_time(); + // Initialize the sent IDs storage + let _sent_ids = sent_ids(); + // Initialize the Application let app = Application::new() .with_assets(Assets) .with_http_client(Arc::new(reqwest_client::ReqwestClient::new())); - let (pubkey_tx, pubkey_rx) = channel::bounded::(1024); - let (event_tx, event_rx) = channel::bounded::(2048); - - app.background_executor() - .spawn(async move { - // Subscribe for app updates from the bootstrap relays. - if let Err(e) = connect(client).await { - log::error!("Failed to connect to bootstrap relays: {e}"); - } - - // Handle Nostr notifications. - // - // Send the redefined signal back to GPUI via channel. - if let Err(e) = handle_nostr_notifications(&event_tx).await { - log::error!("Failed to handle Nostr notifications: {e}"); - } - }) - .detach(); - - app.background_executor() - .spawn(async move { - let channel = global_channel(); - - 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. - _ = channel.0.send(NostrSignal::SignerSet(public_key)).await; - - // Get the NIP-65 relays for the public key. - get_nip65_relays(public_key).await.ok(); - - break; - } - } - smol::Timer::after(Duration::from_secs(1)).await; - } - }) - .detach(); - - app.background_executor() - .spawn(async move { - let duration = Duration::from_millis(METADATA_BATCH_TIMEOUT); - let mut processed_pubkeys: BTreeSet = BTreeSet::new(); - let mut batch: BTreeSet = BTreeSet::new(); - - /// Internal events for the metadata batching system - enum BatchEvent { - NewKeys(PublicKey), - Timeout, - Closed, - } - - loop { - let duration = smol::Timer::after(duration); - - let recv = || async { - if let Ok(public_key) = pubkey_rx.recv().await { - BatchEvent::NewKeys(public_key) - } else { - BatchEvent::Closed - } - }; - - let timeout = || async { - duration.await; - BatchEvent::Timeout - }; - - match smol::future::or(recv(), timeout()).await { - BatchEvent::NewKeys(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 { - sync_data_for_pubkeys(std::mem::take(&mut batch)).await; - } - } - BatchEvent::Timeout => { - if !batch.is_empty() { - sync_data_for_pubkeys(std::mem::take(&mut batch)).await; - } - } - BatchEvent::Closed => { - if !batch.is_empty() { - sync_data_for_pubkeys(std::mem::take(&mut batch)).await; - } - break; - } - } - } - }) - .detach(); - - app.background_executor() - .spawn(async move { - let channel = global_channel(); - let mut counter = 0; - - loop { - // Signer is unset, probably user is not ready to retrieve gift wrap events - if client.signer().await.is_err() { - smol::Timer::after(Duration::from_secs(1)).await; - continue; - } - - let duration = smol::Timer::after(Duration::from_secs(WAIT_FOR_FINISH)); - - let recv = || async { - // no inline - (event_rx.recv().await).ok() - }; - - let timeout = || async { - duration.await; - None - }; - - match smol::future::or(recv(), timeout()).await { - Some(event) => { - let cached = unwrap_gift(&event, &pubkey_tx).await; - - // Increment the total messages counter if message is not from cache - if !cached { - counter += 1; - } - - // Send partial finish signal to GPUI - if counter >= 20 { - channel.0.send(NostrSignal::PartialFinish).await.ok(); - // Reset counter - counter = 0; - } - } - None => { - // Notify the UI that the processing is finished - channel.0.send(NostrSignal::Finish).await.ok(); - } - } - } - }) - .detach(); - // Run application app.run(move |cx| { // Load embedded fonts in assets/fonts @@ -264,280 +116,3 @@ fn main() { .expect("Failed to open window. Please restart the application."); }); } - -fn load_embedded_fonts(cx: &App) { - let asset_source = cx.asset_source(); - let font_paths = asset_source.list("fonts").unwrap(); - let embedded_fonts = Mutex::new(Vec::new()); - let executor = cx.background_executor(); - - executor.block(executor.scoped(|scope| { - for font_path in &font_paths { - if !font_path.ends_with(".ttf") { - continue; - } - - scope.spawn(async { - let font_bytes = asset_source.load(font_path).unwrap().unwrap(); - embedded_fonts.lock().unwrap().push(font_bytes); - }); - } - })); - - cx.text_system() - .add_fonts(embedded_fonts.into_inner().unwrap()) - .unwrap(); -} - -fn quit(_: &Quit, cx: &mut App) { - log::info!("Gracefully quitting the application . . ."); - cx.quit(); -} - -async fn connect(client: &Client) -> Result<(), Error> { - for relay in BOOTSTRAP_RELAYS.into_iter() { - client.add_relay(relay).await?; - } - - log::info!("Connected to bootstrap relays"); - - for relay in SEARCH_RELAYS.into_iter() { - client.add_relay(relay).await?; - } - - log::info!("Connected to search relays"); - - // Establish connection to relays - client.connect().await; - - Ok(()) -} - -async fn handle_nostr_notifications(event_tx: &Sender) -> Result<(), Error> { - let client = nostr_client(); - let channel = global_channel(); - let auto_close = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let mut notifications = client.notifications(); - - while let Ok(notification) = notifications.recv().await { - let RelayPoolNotification::Message { message, .. } = notification else { - continue; - }; - - let RelayMessage::Event { event, .. } = message else { - continue; - }; - - // Skip events that have already been processed - if !processed_events().write().await.insert(event.id) { - continue; - } - - match event.kind { - Kind::RelayList => { - // Get metadata for event's pubkey that matches the current user's pubkey - if let Ok(true) = is_from_current_user(&event).await { - let sub_id = SubscriptionId::new("metadata"); - let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::InboxRelays]; - let filter = Filter::new().kinds(kinds).author(event.pubkey).limit(10); - - client - .subscribe_with_id(sub_id, filter, Some(auto_close)) - .await - .ok(); - } - } - Kind::InboxRelays => { - if let Ok(true) = is_from_current_user(&event).await { - // Get all inbox relays - let relays = event - .tags - .filter_standardized(TagKind::Relay) - .filter_map(|t| { - if let TagStandard::Relay(url) = t { - Some(url.to_owned()) - } else { - None - } - }) - .collect_vec(); - - if !relays.is_empty() { - // Add relays to nostr client - for relay in relays.iter() { - _ = client.add_relay(relay).await; - _ = client.connect_relay(relay).await; - } - - let filter = Filter::new().kind(Kind::GiftWrap).pubkey(event.pubkey); - let sub_id = SubscriptionId::new("gift-wrap"); - - // Notify the UI that the current user has set up the DM relays - channel.0.send(NostrSignal::DmRelaysFound).await.ok(); - - if client - .subscribe_with_id_to(relays.clone(), sub_id, filter, None) - .await - .is_ok() - { - log::info!("Subscribing to messages in: {relays:?}"); - } - } - } - } - Kind::ContactList => { - if let Ok(true) = is_from_current_user(&event).await { - let public_keys: Vec = event.tags.public_keys().copied().collect(); - let kinds = vec![Kind::Metadata, Kind::ContactList]; - let lens = public_keys.len() * kinds.len(); - let filter = Filter::new().limit(lens).authors(public_keys).kinds(kinds); - - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(auto_close)) - .await - .ok(); - } - } - Kind::Metadata => { - channel - .0 - .send(NostrSignal::Metadata(event.into_owned())) - .await - .ok(); - } - Kind::GiftWrap => { - event_tx.send(event.into_owned()).await.ok(); - } - _ => {} - } - } - - Ok(()) -} - -async fn get_nip65_relays(public_key: PublicKey) -> Result<(), Error> { - let client = nostr_client(); - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let sub_id = SubscriptionId::new("nip65-relays"); - - let filter = Filter::new() - .kind(Kind::RelayList) - .author(public_key) - .limit(1); - - client.subscribe_with_id(sub_id, filter, Some(opts)).await?; - - Ok(()) -} - -async fn is_from_current_user(event: &Event) -> Result { - let client = nostr_client(); - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - Ok(public_key == event.pubkey) -} - -async fn sync_data_for_pubkeys(public_keys: BTreeSet) { - let client = nostr_client(); - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let kinds = vec![Kind::Metadata, Kind::ContactList]; - - let filter = Filter::new() - .limit(public_keys.len() * kinds.len()) - .authors(public_keys) - .kinds(kinds); - - client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await - .ok(); -} - -/// Stores an unwrapped event in local database with reference to original -async fn set_unwrapped(root: EventId, unwrapped: &Event) -> Result<(), Error> { - let client = nostr_client(); - - // Save unwrapped event - client.database().save_event(unwrapped).await?; - - // Create a reference event pointing to the unwrapped event - let event = EventBuilder::new(Kind::ApplicationSpecificData, "") - .tags(vec![Tag::identifier(root), Tag::event(unwrapped.id)]) - .sign(&Keys::generate()) - .await?; - - // Save reference event - client.database().save_event(&event).await?; - - Ok(()) -} - -/// Retrieves a previously unwrapped event from local database -async fn get_unwrapped(root: EventId) -> Result { - let client = nostr_client(); - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(root) - .limit(1); - - if let Some(event) = client.database().query(filter).await?.first_owned() { - let target_id = event.tags.event_ids().collect_vec()[0]; - - if let Some(event) = client.database().event_by_id(target_id).await? { - Ok(event) - } else { - Err(anyhow!("Event not found.")) - } - } else { - Err(anyhow!("Event is not cached yet.")) - } -} - -/// Unwraps a gift-wrapped event and processes its contents. -async fn unwrap_gift(gift: &Event, pubkey_tx: &Sender) -> bool { - let client = nostr_client(); - let channel = global_channel(); - let mut is_cached = false; - - let event = match get_unwrapped(gift.id).await { - Ok(event) => { - is_cached = true; - event - } - Err(_) => { - match client.unwrap_gift_wrap(gift).await { - Ok(unwrap) => { - // Sign the unwrapped event with a RANDOM KEYS - let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&Keys::generate()) else { - log::error!("Failed to sign event"); - return false; - }; - - // Save this event to the database for future use. - if let Err(e) = set_unwrapped(gift.id, &unwrapped).await { - log::warn!("Failed to cache unwrapped event: {e}") - } - - unwrapped - } - Err(e) => { - log::error!("Failed to unwrap event: {e}"); - return false; - } - } - } - }; - - // Send all pubkeys to the metadata batch to sync data - for public_key in event.all_pubkeys() { - pubkey_tx.send(public_key).await.ok(); - } - - // Send a notify to GPUI if this is a new message - if starting_time() <= &event.created_at { - channel.0.send(NostrSignal::GiftWrap(event)).await.ok(); - } - - is_cached -} diff --git a/crates/coop/src/views/account.rs b/crates/coop/src/views/account.rs index 973ee13..c77e5b0 100644 --- a/crates/coop/src/views/account.rs +++ b/crates/coop/src/views/account.rs @@ -2,10 +2,10 @@ use std::time::Duration; use anyhow::Error; use client_keys::ClientKeys; -use common::display::DisplayProfile; +use common::display::ReadableProfile; use common::handle_auth::CoopAuthUrlHandler; -use global::constants::ACCOUNT_IDENTIFIER; -use global::nostr_client; +use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT}; +use global::{ingester, nostr_client, IngesterSignal}; use gpui::prelude::FluentBuilder; use gpui::{ div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, @@ -13,7 +13,6 @@ use gpui::{ StatefulInteractiveElement, Styled, Task, WeakEntity, Window, }; use i18n::{shared_t, t}; -use identity::Identity; use nostr_connect::prelude::*; use nostr_sdk::prelude::*; use theme::ActiveTheme; @@ -25,6 +24,8 @@ use ui::input::{InputState, TextInput}; use ui::popup_menu::PopupMenu; use ui::{h_flex, v_flex, ContextModal, Disableable, Sizable, StyledExt}; +use crate::chatspace::ChatSpace; + pub fn init( secret: String, profile: Profile, @@ -69,7 +70,7 @@ impl Account { self.nostr_connect(uri, window, cx); } } else if self.is_extension { - self.proxy(window, cx); + self.set_proxy(window, cx); } else if let Ok(enc) = EncryptedSecretKey::from_bech32(&self.stored_secret) { self.keys(enc, window, cx); } else { @@ -82,8 +83,7 @@ impl Account { let client_keys = ClientKeys::global(cx); let app_keys = client_keys.read(cx).keys(); - let secs = 30; - let timeout = Duration::from_secs(secs); + let timeout = Duration::from_secs(BUNKER_TIMEOUT); let mut signer = NostrConnect::new(uri, app_keys, timeout, None).unwrap(); // Handle auth url with the default browser @@ -109,8 +109,8 @@ impl Account { .detach(); } - fn proxy(&mut self, _window: &mut Window, cx: &mut Context) { - Identity::start_browser_proxy(cx); + fn set_proxy(&mut self, window: &mut Window, cx: &mut Context) { + ChatSpace::proxy_signer(window, cx); } fn keys(&mut self, enc: EncryptedSecretKey, window: &mut Window, cx: &mut Context) { @@ -239,26 +239,23 @@ impl Account { .detach(); } - fn logout(&mut self, window: &mut Window, cx: &mut Context) { - let task: Task> = cx.background_spawn(async move { + fn logout(&mut self, _window: &mut Window, cx: &mut Context) { + cx.background_spawn(async move { let client = nostr_client(); + let ingester = ingester(); + let filter = Filter::new() .kind(Kind::ApplicationSpecificData) .identifier(ACCOUNT_IDENTIFIER); // Delete account - client.database().delete(filter).await?; + client.database().delete(filter).await.ok(); - Ok(()) - }); + // Unset the client's signer + client.unset_signer().await; - cx.spawn_in(window, async move |_this, cx| { - if task.await.is_ok() { - cx.update(|_window, cx| { - cx.restart(); - }) - .ok(); - } + // Notify the channel about the signer being unset + ingester.send(IngesterSignal::SignerUnset).await; }) .detach(); } diff --git a/crates/coop/src/views/chat/mod.rs b/crates/coop/src/views/chat/mod.rs index 4f6f39c..75242d3 100644 --- a/crates/coop/src/views/chat/mod.rs +++ b/crates/coop/src/views/chat/mod.rs @@ -1,9 +1,9 @@ -use std::collections::{BTreeSet, HashMap}; +use std::collections::HashMap; use anyhow::anyhow; -use common::display::DisplayProfile; +use common::display::{ReadableProfile, ReadableTimestamp}; use common::nip96::nip96_upload; -use global::nostr_client; +use global::{nostr_client, sent_ids}; use gpui::prelude::FluentBuilder; use gpui::{ div, img, list, px, red, relative, rems, svg, white, Action, AnyElement, App, AppContext, @@ -14,7 +14,6 @@ use gpui::{ }; use gpui_tokio::Tokio; use i18n::{shared_t, t}; -use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; use registry::message::RenderedMessage; @@ -31,7 +30,6 @@ use ui::dock_area::panel::{Panel, PanelEvent}; use ui::emoji_picker::EmojiPicker; use ui::input::{InputEvent, InputState, TextInput}; use ui::modal::ModalButtonProps; -use ui::notification::Notification; use ui::popup_menu::PopupMenu; use ui::text::RenderedText; use ui::{ @@ -56,7 +54,7 @@ pub struct Chat { // Chat Room room: Entity, list_state: ListState, - messages: BTreeSet, + messages: Vec, rendered_texts_by_id: HashMap, reports_by_id: HashMap>, // New Message @@ -107,7 +105,7 @@ impl Chat { } Err(e) => { cx.update(|window, cx| { - window.push_notification(Notification::error(e.to_string()), cx); + window.push_notification(e.to_string(), cx); }) .ok(); } @@ -138,10 +136,10 @@ impl Chat { // Subscribe to room events cx.subscribe_in(&room, window, move |this, _, signal, window, cx| { match signal { - RoomSignal::NewMessage(event) => { - if !this.is_seen_message(event) { + RoomSignal::NewMessage((gift_wrap_id, event)) => { + if !this.is_sent_by_coop(gift_wrap_id) { this.insert_message(event, cx); - }; + } } RoomSignal::Refresh => { this.load_messages(window, cx); @@ -156,7 +154,7 @@ impl Chat { focus_handle: cx.focus_handle(), uploading: false, sending: false, - messages: BTreeSet::new(), + messages: Vec::new(), rendered_texts_by_id: HashMap::new(), reports_by_id: HashMap::new(), room, @@ -219,14 +217,10 @@ impl Chat { content } - /// Check if the event is a seen message - fn is_seen_message(&self, event: &Event) -> bool { - if let Some(message) = self.messages.last() { - let duration = event.created_at.as_u64() - message.created_at.as_u64(); - message.content == event.content && message.author == event.pubkey && duration <= 20 - } else { - false - } + /// Check if the event is sent by Coop + fn is_sent_by_coop(&self, gift_wrap_id: &EventId) -> bool { + let sent_ids = sent_ids(); + sent_ids.read_blocking().contains(gift_wrap_id) } /// Set the sending state of the chat panel @@ -263,7 +257,7 @@ impl Chat { // Get the current room entity let room = self.room.read(cx); - let identity = Identity::read_global(cx).public_key(); + let identity = Registry::read_global(cx).identity(cx).public_key(); // Create a temporary message for optimistic update let temp_message = room.create_temp_message(identity, &content, replies.as_ref()); @@ -346,7 +340,7 @@ impl Chat { let new_len = 1; // Extend the messages list with the new events - self.messages.insert(event.into()); + self.messages.push(event.into()); // Update list state with the new messages self.list_state.splice(old_len..old_len, new_len); @@ -360,11 +354,12 @@ impl Chat { E::Item: Into, { let old_len = self.messages.len(); - let events: Vec<_> = events.into_iter().map(Into::into).collect(); + let events: Vec = events.into_iter().map(Into::into).collect(); let new_len = events.len(); // Extend the messages list with the new events self.messages.extend(events); + self.messages.sort_by_key(|ev| ev.created_at); // Update list state with the new messages self.list_state.splice(old_len..old_len, new_len); @@ -532,7 +527,7 @@ impl Chat { window: &mut Window, cx: &mut Context, ) -> Stateful
{ - let Some(message) = self.messages.iter().nth(ix) else { + let Some(message) = self.messages.get(ix) else { return div().id(ix); }; @@ -591,7 +586,7 @@ impl Chat { .text_color(cx.theme().text) .child(author.display_name()), ) - .child(div().child(message.ago())) + .child(div().child(message.created_at.to_human_time())) .when_some(is_sent_success, |this, status| { this.when(status, |this| { this.child(self.render_message_sent(&id, cx)) diff --git a/crates/coop/src/views/chat/subject.rs b/crates/coop/src/views/chat/subject.rs index 25c1a64..2a04214 100644 --- a/crates/coop/src/views/chat/subject.rs +++ b/crates/coop/src/views/chat/subject.rs @@ -28,8 +28,8 @@ impl Subject { cx.new(|_| Self { input }) } - pub fn new_subject(&self, cx: &App) -> SharedString { - self.input.read(cx).value().clone() + pub fn new_subject(&self, cx: &App) -> String { + self.input.read(cx).value().to_string() } } diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index 59afd1b..39c94ef 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -2,7 +2,7 @@ use std::ops::Range; use std::time::Duration; use anyhow::{anyhow, Error}; -use common::display::{DisplayProfile, TextUtils}; +use common::display::{ReadableProfile, TextUtils}; use common::nip05::nip05_profile; use global::constants::BOOTSTRAP_RELAYS; use global::nostr_client; diff --git a/crates/coop/src/views/login.rs b/crates/coop/src/views/login.rs index 59eade3..de30ae9 100644 --- a/crates/coop/src/views/login.rs +++ b/crates/coop/src/views/login.rs @@ -2,7 +2,7 @@ use std::time::Duration; use client_keys::ClientKeys; use common::handle_auth::CoopAuthUrlHandler; -use global::constants::ACCOUNT_IDENTIFIER; +use global::constants::{ACCOUNT_IDENTIFIER, BUNKER_TIMEOUT}; use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ @@ -264,8 +264,7 @@ impl Login { let client_keys = ClientKeys::global(cx); let app_keys = client_keys.read(cx).keys(); - let secs = 30; - let timeout = Duration::from_secs(secs); + let timeout = Duration::from_secs(BUNKER_TIMEOUT); let mut signer = NostrConnect::new(uri, app_keys, timeout, None).unwrap(); // Handle auth url with the default browser @@ -273,7 +272,7 @@ impl Login { // Start countdown cx.spawn_in(window, async move |this, cx| { - for i in (0..=secs).rev() { + for i in (0..=BUNKER_TIMEOUT).rev() { if i == 0 { this.update(cx, |this, cx| { this.set_countdown(None, cx); diff --git a/crates/coop/src/views/mod.rs b/crates/coop/src/views/mod.rs index 2b4903b..f60093a 100644 --- a/crates/coop/src/views/mod.rs +++ b/crates/coop/src/views/mod.rs @@ -4,11 +4,11 @@ pub mod chat; pub mod compose; pub mod edit_profile; pub mod login; -pub mod messaging_relays; pub mod new_account; pub mod onboarding; pub mod preferences; pub mod screening; +pub mod setup_relay; pub mod sidebar; pub mod user_profile; pub mod welcome; diff --git a/crates/coop/src/views/onboarding.rs b/crates/coop/src/views/onboarding.rs index 2428e1e..c81f1dd 100644 --- a/crates/coop/src/views/onboarding.rs +++ b/crates/coop/src/views/onboarding.rs @@ -12,7 +12,6 @@ use gpui::{ Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, }; use i18n::{shared_t, t}; -use identity::Identity; use nostr_connect::prelude::*; use smallvec::{smallvec, SmallVec}; use theme::ActiveTheme; @@ -21,7 +20,7 @@ use ui::dock_area::panel::{Panel, PanelEvent}; use ui::popup_menu::PopupMenu; use ui::{divider, h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt}; -use crate::chatspace; +use crate::chatspace::{self, ChatSpace}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { Onboarding::new(window, cx) @@ -159,8 +158,8 @@ impl Onboarding { .detach(); } - fn set_proxy(&mut self, _window: &mut Window, cx: &mut Context) { - Identity::start_browser_proxy(cx); + fn set_proxy(&mut self, window: &mut Window, cx: &mut Context) { + ChatSpace::proxy_signer(window, cx); } fn write_uri_to_disk(&mut self, uri: &NostrConnectURI, cx: &mut Context) { diff --git a/crates/coop/src/views/preferences.rs b/crates/coop/src/views/preferences.rs index 35c263b..2bb0504 100644 --- a/crates/coop/src/views/preferences.rs +++ b/crates/coop/src/views/preferences.rs @@ -1,11 +1,11 @@ -use common::display::DisplayProfile; +use common::display::ReadableProfile; use gpui::http_client::Url; use gpui::{ div, px, relative, rems, App, AppContext, Context, Entity, InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Window, }; use i18n::t; -use identity::Identity; +use nostr_sdk::prelude::*; use registry::Registry; use settings::AppSettings; use theme::ActiveTheme; @@ -16,7 +16,7 @@ use ui::modal::ModalButtonProps; use ui::switch::Switch; use ui::{v_flex, ContextModal, IconName, Sizable, Size, StyledExt}; -use crate::views::{edit_profile, messaging_relays}; +use crate::views::{edit_profile, setup_relay}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { Preferences::new(window, cx) @@ -89,7 +89,7 @@ impl Preferences { fn open_relays(&self, window: &mut Window, cx: &mut Context) { let title = SharedString::new(t!("relays.modal_title")); - let view = messaging_relays::init(window, cx); + let view = setup_relay::init(Kind::InboxRelays, window, cx); let weak_view = view.downgrade(); window.open_modal(cx, move |this, _window, _cx| { @@ -115,8 +115,7 @@ impl Preferences { impl Render for Preferences { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let input_state = self.media_input.downgrade(); - let identity = Identity::read_global(cx).public_key(); - let profile = Registry::read_global(cx).get_person(&identity, cx); + let profile = Registry::read_global(cx).identity(cx); let backup_messages = AppSettings::get_backup_messages(cx); let screening = AppSettings::get_screening(cx); diff --git a/crates/coop/src/views/screening.rs b/crates/coop/src/views/screening.rs index 9292df1..747dcfb 100644 --- a/crates/coop/src/views/screening.rs +++ b/crates/coop/src/views/screening.rs @@ -1,4 +1,4 @@ -use common::display::{shorten_pubkey, DisplayProfile}; +use common::display::{shorten_pubkey, ReadableProfile}; use common::nip05::nip05_verify; use global::nostr_client; use gpui::{ @@ -7,41 +7,35 @@ use gpui::{ }; use gpui_tokio::Tokio; use i18n::{shared_t, t}; -use identity::Identity; use nostr_sdk::prelude::*; use registry::Registry; use settings::AppSettings; +use smallvec::{smallvec, SmallVec}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonRounded, ButtonVariants}; use ui::{h_flex, v_flex, ContextModal, Icon, IconName, Sizable, StyledExt}; pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { - Screening::new(public_key, window, cx) + cx.new(|cx| Screening::new(public_key, window, cx)) } pub struct Screening { - public_key: PublicKey, + profile: Profile, verified: bool, followed: bool, dm_relays: bool, mutual_contacts: usize, + _tasks: SmallVec<[Task<()>; 1]>, } impl Screening { - pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity { - cx.new(|_| Self { - public_key, - verified: false, - followed: false, - dm_relays: false, - mutual_contacts: 0, - }) - } + pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let registry = Registry::read_global(cx); + let identity = registry.identity(cx).public_key(); + let profile = registry.get_person(&public_key, cx); - pub fn load(&mut self, window: &mut Window, cx: &mut Context) { - let identity = Identity::read_global(cx).public_key(); - let public_key = self.public_key; + let mut tasks = smallvec![]; let check_trust_score: Task<(bool, usize, bool)> = cx.background_spawn(async move { let client = nostr_client(); @@ -69,7 +63,7 @@ impl Screening { (is_follow, mutual_contacts, dm_relays) }); - let verify_nip05 = if let Some(address) = self.address(cx) { + let verify_nip05 = if let Some(address) = profile.metadata().nip05 { Some(Tokio::spawn(cx, async move { nip05_verify(public_key, &address).await.unwrap_or(false) })) @@ -77,47 +71,54 @@ impl Screening { None }; - cx.spawn_in(window, async move |this, cx| { - let (followed, mutual_contacts, dm_relays) = check_trust_score.await; + tasks.push( + // Load all necessary data + cx.spawn_in(window, async move |this, cx| { + let (followed, mutual_contacts, dm_relays) = check_trust_score.await; - this.update(cx, |this, cx| { - this.followed = followed; - this.mutual_contacts = mutual_contacts; - this.dm_relays = dm_relays; - cx.notify(); - }) - .ok(); + this.update(cx, |this, cx| { + this.followed = followed; + this.mutual_contacts = mutual_contacts; + this.dm_relays = dm_relays; + cx.notify(); + }) + .ok(); - // Update the NIP05 verification status if user has NIP05 address - if let Some(task) = verify_nip05 { - if let Ok(verified) = task.await { - this.update(cx, |this, cx| { - this.verified = verified; - cx.notify(); - }) - .ok(); + // Update the NIP05 verification status if user has NIP05 address + if let Some(task) = verify_nip05 { + if let Ok(verified) = task.await { + this.update(cx, |this, cx| { + this.verified = verified; + cx.notify(); + }) + .ok(); + } } - } - }) - .detach(); + }), + ); + + Self { + profile, + verified: false, + followed: false, + dm_relays: false, + mutual_contacts: 0, + _tasks: tasks, + } } - fn profile(&self, cx: &Context) -> Profile { - let registry = Registry::read_global(cx); - registry.get_person(&self.public_key, cx) - } - - fn address(&self, cx: &Context) -> Option { - self.profile(cx).metadata().nip05 + fn address(&self, _cx: &Context) -> Option { + self.profile.metadata().nip05 } fn open_njump(&mut self, _window: &mut Window, cx: &mut App) { - let Ok(bech32) = self.public_key.to_bech32(); + let Ok(bech32) = self.profile.public_key().to_bech32(); cx.open_url(&format!("https://njump.me/{bech32}")); } fn report(&mut self, window: &mut Window, cx: &mut Context) { - let public_key = self.public_key; + let public_key = self.profile.public_key(); + let task: Task> = cx.background_spawn(async move { let client = nostr_client(); let builder = EventBuilder::report( @@ -145,8 +146,7 @@ impl Screening { impl Render for Screening { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let proxy = AppSettings::get_proxy_user_avatars(cx); - let profile = self.profile(cx); - let shorten_pubkey = shorten_pubkey(profile.public_key(), 8); + let shorten_pubkey = shorten_pubkey(self.profile.public_key(), 8); v_flex() .gap_4() @@ -156,12 +156,12 @@ impl Render for Screening { .items_center() .justify_center() .text_center() - .child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.))) + .child(Avatar::new(self.profile.avatar_url(proxy)).size(rems(4.))) .child( div() .font_semibold() .line_height(relative(1.25)) - .child(profile.display_name()), + .child(self.profile.display_name()), ), ) .child( diff --git a/crates/coop/src/views/messaging_relays.rs b/crates/coop/src/views/setup_relay.rs similarity index 81% rename from crates/coop/src/views/messaging_relays.rs rename to crates/coop/src/views/setup_relay.rs index cf2dd20..0a8d434 100644 --- a/crates/coop/src/views/messaging_relays.rs +++ b/crates/coop/src/views/setup_relay.rs @@ -10,7 +10,9 @@ use gpui::{ TextAlign, UniformList, Window, }; use i18n::{shared_t, t}; +use itertools::Itertools; use nostr_sdk::prelude::*; +use registry::Registry; use smallvec::{smallvec, SmallVec}; use theme::ActiveTheme; use ui::button::{Button, ButtonRounded, ButtonVariants}; @@ -18,21 +20,23 @@ use ui::input::{InputEvent, InputState, TextInput}; use ui::modal::ModalButtonProps; use ui::{h_flex, v_flex, ContextModal, IconName, Sizable, StyledExt}; -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - cx.new(|cx| MessagingRelays::new(window, cx)) +pub fn init(kind: Kind, window: &mut Window, cx: &mut App) -> Entity { + cx.new(|cx| SetupRelay::new(kind, window, cx)) } -pub fn relay_button() -> impl IntoElement { +pub fn setup_nip17_relay(label: T) -> impl IntoElement +where + T: Into, +{ div().child( - Button::new("dm-relays") + Button::new("setup-relays") .icon(IconName::Info) - .label(t!("relays.button_label")) + .label(label) .warning() .xsmall() .rounded(ButtonRounded::Full) .on_click(move |_, window, cx| { - let title = SharedString::new(t!("relays.modal_title")); - let view = cx.new(|cx| MessagingRelays::new(window, cx)); + let view = cx.new(|cx| SetupRelay::new(Kind::InboxRelays, window, cx)); let weak_view = view.downgrade(); window.open_modal(cx, move |modal, _window, _cx| { @@ -40,7 +44,7 @@ pub fn relay_button() -> impl IntoElement { modal .confirm() - .title(title.clone()) + .title(shared_t!("relays.modal_title")) .child(view.clone()) .button_props(ModalButtonProps::default().ok_text(t!("common.update"))) .on_ok(move |_, window, cx| { @@ -57,60 +61,41 @@ pub fn relay_button() -> impl IntoElement { ) } -pub struct MessagingRelays { +pub struct SetupRelay { input: Entity, relays: Vec, error: Option, - #[allow(dead_code)] - subscriptions: SmallVec<[Subscription; 2]>, + _subscriptions: SmallVec<[Subscription; 1]>, + _tasks: SmallVec<[Task<()>; 1]>, } -impl MessagingRelays { - pub fn new(window: &mut Window, cx: &mut Context) -> Self { +impl SetupRelay { + pub fn new(kind: Kind, window: &mut Window, cx: &mut Context) -> Self { + let identity = Registry::read_global(cx).identity(cx).public_key(); let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); + let mut subscriptions = smallvec![]; + let mut tasks = smallvec![]; - subscriptions.push(cx.observe_new::(move |this, window, cx| { - if let Some(window) = window { - this.load(window, cx); - } - })); - - subscriptions.push(cx.subscribe_in( - &input, - window, - move |this: &mut Self, _, event, window, cx| { - if let InputEvent::PressEnter { .. } = event { - this.add(window, cx); - } - }, - )); - - Self { - input, - subscriptions, - relays: vec![], - error: None, - } - } - - fn load(&mut self, window: &mut Window, cx: &mut Context) { - let task: Task, Error>> = cx.background_spawn(async move { + let load_relay = cx.background_spawn(async move { let client = nostr_client(); - 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).author(identity).limit(1); if let Some(event) = client.database().query(filter).await?.first() { let relays = event .tags - .filter(TagKind::Relay) - .filter_map(|tag| RelayUrl::parse(tag.content()?).ok()) - .collect::>(); + .iter() + .filter_map(|tag| tag.as_standardized()) + .filter_map(|tag| { + if let TagStandard::RelayMetadata { relay_url, .. } = tag { + Some(relay_url.to_owned()) + } else if let TagStandard::Relay(url) = tag { + Some(url.to_owned()) + } else { + None + } + }) + .collect_vec(); Ok(relays) } else { @@ -118,16 +103,39 @@ impl MessagingRelays { } }); - cx.spawn_in(window, async move |this, cx| { - if let Ok(relays) = task.await { - this.update(cx, |this, cx| { - this.relays = relays; - cx.notify(); - }) - .ok(); - } - }) - .detach(); + tasks.push( + // Load user's relays in the local database + cx.spawn_in(window, async move |this, cx| { + if let Ok(relays) = load_relay.await { + this.update(cx, |this, cx| { + this.relays = relays; + cx.notify(); + }) + .ok(); + } + }), + ); + + subscriptions.push( + // Subscribe to user's input events + cx.subscribe_in( + &input, + window, + move |this: &mut Self, _, event, window, cx| { + if let InputEvent::PressEnter { .. } = event { + this.add(window, cx); + } + }, + ), + ); + + Self { + input, + relays: vec![], + error: None, + _subscriptions: subscriptions, + _tasks: tasks, + } } fn add(&mut self, window: &mut Window, cx: &mut Context) { @@ -283,11 +291,11 @@ impl MessagingRelays { .justify_center() .text_sm() .text_align(TextAlign::Center) - .child(SharedString::new(t!("relays.add_some_relays"))) + .child(shared_t!("relays.add_some_relays")) } } -impl Render for MessagingRelays { +impl Render for SetupRelay { fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { v_flex() .gap_3() diff --git a/crates/coop/src/views/sidebar/list_item.rs b/crates/coop/src/views/sidebar/list_item.rs index a21a3f3..d42c541 100644 --- a/crates/coop/src/views/sidebar/list_item.rs +++ b/crates/coop/src/views/sidebar/list_item.rs @@ -2,8 +2,8 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - div, rems, App, ClickEvent, Div, InteractiveElement, IntoElement, ParentElement as _, - RenderOnce, SharedString, StatefulInteractiveElement, Styled, Window, + div, rems, App, ClickEvent, InteractiveElement, IntoElement, ParentElement as _, RenderOnce, + SharedString, StatefulInteractiveElement, Styled, Window, }; use i18n::t; use nostr_sdk::prelude::*; @@ -23,7 +23,6 @@ use crate::views::screening; #[derive(IntoElement)] pub struct RoomListItem { ix: usize, - base: Div, room_id: Option, public_key: Option, name: Option, @@ -38,7 +37,6 @@ impl RoomListItem { pub fn new(ix: usize) -> Self { Self { ix, - base: h_flex().h_9().w_full().px_1p5().gap_2(), room_id: None, public_key: None, name: None, @@ -59,18 +57,18 @@ impl RoomListItem { self } - pub fn name(mut self, name: SharedString) -> Self { - self.name = Some(name); + pub fn name(mut self, name: impl Into) -> Self { + self.name = Some(name.into()); self } - pub fn avatar(mut self, avatar: SharedString) -> Self { - self.avatar = Some(avatar); + pub fn avatar(mut self, avatar: impl Into) -> Self { + self.avatar = Some(avatar.into()); self } - pub fn created_at(mut self, created_at: SharedString) -> Self { - self.created_at = Some(created_at); + pub fn created_at(mut self, created_at: impl Into) -> Self { + self.created_at = Some(created_at.into()); self } @@ -111,9 +109,12 @@ impl RenderOnce for RoomListItem { self.handler, ) else { - return self - .base + return h_flex() .id(self.ix) + .h_9() + .w_full() + .px_1p5() + .gap_2() .child(Skeleton::new().flex_shrink_0().size_6().rounded_full()) .child( div() @@ -125,8 +126,12 @@ impl RenderOnce for RoomListItem { ); }; - self.base + h_flex() .id(self.ix) + .h_9() + .w_full() + .px_1p5() + .gap_2() .text_sm() .rounded(cx.theme().radius) .when(!hide_avatar, |this| { diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index fe14721..f23f563 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -4,7 +4,7 @@ use std::time::Duration; use anyhow::{anyhow, Error}; use common::debounced_delay::DebouncedDelay; -use common::display::TextUtils; +use common::display::{ReadableTimestamp, TextUtils}; use global::constants::{BOOTSTRAP_RELAYS, SEARCH_RELAYS}; use global::nostr_client; use gpui::prelude::FluentBuilder; @@ -15,12 +15,11 @@ use gpui::{ }; use gpui_tokio::Tokio; use i18n::t; -use identity::Identity; use itertools::Itertools; use list_item::RoomListItem; use nostr_sdk::prelude::*; use registry::room::{Room, RoomKind}; -use registry::{Registry, RegistrySignal}; +use registry::{Registry, RegistryEvent}; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use theme::ActiveTheme; @@ -82,7 +81,7 @@ impl Sidebar { &chats, window, move |this, _chats, event, _window, cx| { - if let RegistrySignal::NewRequest(kind) = event { + if let RegistryEvent::NewRequest(kind) = event { this.indicator.update(cx, |this, cx| { *this = Some(kind.to_owned()); cx.notify(); @@ -211,7 +210,7 @@ impl Sidebar { window: &mut Window, cx: &mut Context, ) { - let identity = Identity::read_global(cx).public_key(); + let identity = Registry::read_global(cx).identity(cx).public_key(); let query = query.to_owned(); let query_cloned = query.clone(); @@ -271,7 +270,7 @@ impl Sidebar { } fn search_by_nip05(&mut self, query: &str, window: &mut Window, cx: &mut Context) { - let identity = Identity::read_global(cx).public_key(); + let identity = Registry::read_global(cx).identity(cx).public_key(); let address = query.to_owned(); let task = Tokio::spawn(cx, async move { @@ -325,7 +324,7 @@ impl Sidebar { return; }; - let identity = Identity::read_global(cx).public_key(); + let identity = Registry::read_global(cx).identity(cx).public_key(); let task: Task> = cx.background_spawn(async move { // Create a gift wrap event to represent as room Self::create_temp_room(identity, public_key).await @@ -553,7 +552,7 @@ impl Sidebar { .room_id(room_id) .name(this.display_name(cx)) .avatar(this.display_image(proxy, cx)) - .created_at(this.ago()) + .created_at(this.created_at.to_ago()) .public_key(this.members[0]) .kind(this.kind) .on_click(handler), diff --git a/crates/coop/src/views/user_profile.rs b/crates/coop/src/views/user_profile.rs index 245489f..a9a2bc5 100644 --- a/crates/coop/src/views/user_profile.rs +++ b/crates/coop/src/views/user_profile.rs @@ -1,6 +1,6 @@ use std::time::Duration; -use common::display::DisplayProfile; +use common::display::ReadableProfile; use common::nip05::nip05_verify; use global::nostr_client; use gpui::prelude::FluentBuilder; @@ -9,40 +9,35 @@ use gpui::{ ParentElement, Render, SharedString, Styled, Task, Window, }; use gpui_tokio::Tokio; -use i18n::t; -use identity::Identity; +use i18n::{shared_t, t}; use nostr_sdk::prelude::*; use registry::Registry; use settings::AppSettings; +use smallvec::{smallvec, SmallVec}; use theme::ActiveTheme; use ui::avatar::Avatar; use ui::button::{Button, ButtonVariants}; use ui::{h_flex, v_flex, Disableable, Icon, IconName, Sizable, StyledExt}; pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) -> Entity { - UserProfile::new(public_key, window, cx) + cx.new(|cx| UserProfile::new(public_key, window, cx)) } pub struct UserProfile { - public_key: PublicKey, + profile: Profile, followed: bool, verified: bool, copied: bool, + _tasks: SmallVec<[Task<()>; 1]>, } impl UserProfile { - pub fn new(public_key: PublicKey, _window: &mut Window, cx: &mut App) -> Entity { - cx.new(|_| Self { - public_key, - followed: false, - verified: false, - copied: false, - }) - } + pub fn new(public_key: PublicKey, window: &mut Window, cx: &mut Context) -> Self { + let registry = Registry::read_global(cx); + let identity = registry.identity(cx).public_key(); + let profile = registry.get_person(&public_key, cx); - pub fn load(&mut self, window: &mut Window, cx: &mut Context) { - let identity = Identity::read_global(cx).public_key(); - let public_key = self.public_key; + let mut tasks = smallvec![]; let check_follow: Task = cx.background_spawn(async move { let client = nostr_client(); @@ -55,7 +50,7 @@ impl UserProfile { client.database().count(filter).await.unwrap_or(0) >= 1 }); - let verify_nip05 = if let Some(address) = self.address(cx) { + let verify_nip05 = if let Some(address) = profile.metadata().nip05 { Some(Tokio::spawn(cx, async move { nip05_verify(public_key, &address).await.unwrap_or(false) })) @@ -63,41 +58,46 @@ impl UserProfile { None }; - cx.spawn_in(window, async move |this, cx| { - let followed = check_follow.await; + tasks.push( + // Load user profile data + cx.spawn_in(window, async move |this, cx| { + let followed = check_follow.await; - // Update the followed status - this.update(cx, |this, cx| { - this.followed = followed; - cx.notify(); - }) - .ok(); + // Update the followed status + this.update(cx, |this, cx| { + this.followed = followed; + cx.notify(); + }) + .ok(); - // Update the NIP05 verification status if user has NIP05 address - if let Some(task) = verify_nip05 { - if let Ok(verified) = task.await { - this.update(cx, |this, cx| { - this.verified = verified; - cx.notify(); - }) - .ok(); + // Update the NIP05 verification status if user has NIP05 address + if let Some(task) = verify_nip05 { + if let Ok(verified) = task.await { + this.update(cx, |this, cx| { + this.verified = verified; + cx.notify(); + }) + .ok(); + } } - } - }) - .detach(); + }), + ); + + Self { + profile, + followed: false, + verified: false, + copied: false, + _tasks: tasks, + } } - fn profile(&self, cx: &Context) -> Profile { - let registry = Registry::read_global(cx); - registry.get_person(&self.public_key, cx) - } - - fn address(&self, cx: &Context) -> Option { - self.profile(cx).metadata().nip05 + fn address(&self, _cx: &Context) -> Option { + self.profile.metadata().nip05 } fn copy_pubkey(&mut self, window: &mut Window, cx: &mut Context) { - let Ok(bech32) = self.public_key.to_bech32(); + let Ok(bech32) = self.profile.public_key().to_bech32(); let item = ClipboardItem::new_string(bech32); cx.write_to_clipboard(item); @@ -128,9 +128,8 @@ impl UserProfile { impl Render for UserProfile { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { let proxy = AppSettings::get_proxy_user_avatars(cx); - let profile = self.profile(cx); - let Ok(bech32) = profile.public_key().to_bech32(); + let Ok(bech32) = self.profile.public_key().to_bech32(); let shared_bech32 = SharedString::new(bech32); v_flex() @@ -141,14 +140,14 @@ impl Render for UserProfile { .items_center() .justify_center() .text_center() - .child(Avatar::new(profile.avatar_url(proxy)).size(rems(4.))) + .child(Avatar::new(self.profile.avatar_url(proxy)).size(rems(4.))) .child( v_flex() .child( div() .font_semibold() .line_height(relative(1.25)) - .child(profile.display_name()), + .child(self.profile.display_name()), ) .when_some(self.address(cx), |this, address| { this.child( @@ -183,7 +182,7 @@ impl Render for UserProfile { .bg(cx.theme().elevated_surface_background) .text_xs() .font_semibold() - .child(SharedString::new(t!("profile.unknown"))), + .child(shared_t!("profile.unknown")), ) }), ) @@ -235,7 +234,7 @@ impl Render for UserProfile { .child( div() .text_color(cx.theme().text_muted) - .child(SharedString::new(t!("profile.label_bio"))), + .child(shared_t!("profile.label_bio")), ) .child( div() @@ -243,7 +242,7 @@ impl Render for UserProfile { .rounded_md() .bg(cx.theme().elevated_surface_background) .child( - profile + self.profile .metadata() .about .unwrap_or(t!("profile.no_bio").to_string()), diff --git a/crates/global/src/constants.rs b/crates/global/src/constants.rs index fb49331..0c7a43a 100644 --- a/crates/global/src/constants.rs +++ b/crates/global/src/constants.rs @@ -8,15 +8,16 @@ pub const ACCOUNT_IDENTIFIER: &str = "coop:user"; pub const SETTINGS_D: &str = "coop:settings"; /// Bootstrap Relays. -pub const BOOTSTRAP_RELAYS: [&str; 4] = [ +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; 2] = ["wss://search.nos.today", "wss://relay.nostr.band"]; +pub const SEARCH_RELAYS: [&str; 1] = ["wss://relay.nostr.band"]; /// NIP65 Relays. Used for new account pub const NIP65_RELAYS: [&str; 4] = [ @@ -27,14 +28,20 @@ pub const NIP65_RELAYS: [&str; 4] = [ ]; /// Messaging Relays. Used for new account -pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://relay.0xchat.com"]; +pub const NIP17_RELAYS: [&str; 2] = ["wss://nip17.com", "wss://auth.nostr1.com"]; /// 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 TOTAL_RETRY: u64 = 2; + /// 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 = 100; @@ -44,9 +51,6 @@ pub const METADATA_BATCH_TIMEOUT: u64 = 300; /// Maximum timeout for waiting for finish (seconds) pub const WAIT_FOR_FINISH: u64 = 60; -/// Default width for all modals. -pub const DEFAULT_MODAL_WIDTH: f32 = 420.; - /// Default width of the sidebar. pub const DEFAULT_SIDEBAR_WIDTH: f32 = 240.; diff --git a/crates/global/src/lib.rs b/crates/global/src/lib.rs index 9f7bc29..71bb9fd 100644 --- a/crates/global/src/lib.rs +++ b/crates/global/src/lib.rs @@ -1,4 +1,3 @@ -use std::collections::BTreeSet; use std::sync::OnceLock; use std::time::Duration; @@ -13,41 +12,109 @@ use crate::paths::support_dir; pub mod constants; pub mod paths; -/// Signals sent through the global event channel to notify UI components +#[derive(Debug, Clone)] +pub struct AuthReq { + pub challenge: String, + pub url: RelayUrl, +} + +impl AuthReq { + pub fn new(challenge: impl Into, url: RelayUrl) -> Self { + Self { + challenge: challenge.into(), + url, + } + } +} + +#[derive(Debug, Clone)] +pub enum Notice { + RelayFailed(RelayUrl), + AuthFailed(RelayUrl), + Custom(String), +} + +impl Notice { + pub fn as_str(&self) -> String { + match self { + Notice::AuthFailed(url) => format!("Authenticate failed for relay {url}"), + Notice::RelayFailed(url) => format!("Failed to connect the relay {url}"), + Notice::Custom(msg) => msg.into(), + } + } +} + +/// Signals sent through the global event channel to notify UI #[derive(Debug)] -pub enum NostrSignal { - /// Signer has been set +pub enum IngesterSignal { + /// A signal to notify UI that the client's signer has been set SignerSet(PublicKey), - /// Signer has been unset + /// A signal to notify UI that the client's signer has been unset SignerUnset, - /// Browser Signer Proxy service is not running + /// A signal to notify UI that the relay requires authentication + Auth(AuthReq), + + /// A signal to notify UI that the browser proxy service is down ProxyDown, - /// Received a new metadata event from Relay Pool + /// A signal to notify UI that a new metadata event has been received Metadata(Event), - /// Received a new gift wrap event from Relay Pool - GiftWrap(Event), + /// A signal to notify UI that a new gift wrap event has been received + GiftWrap((EventId, Event)), - /// Finished processing all gift wrap events + /// A signal to notify UI that all gift wrap events have been processed Finish, - /// Partially finished processing all gift wrap events + /// A signal to notify UI that partial processing of gift wrap events has been completed PartialFinish, - /// DM relays have been found - DmRelaysFound, + /// A signal to notify UI that no DM relay for current user was found + DmRelayNotFound, - /// Notice from Relay Pool - Notice(String), + /// A signal to notify UI that there are errors or notices occurred + Notice(Notice), +} + +#[derive(Debug)] +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) = smol::channel::bounded::(2048); + Self { rx, tx } + } + + pub fn signals(&self) -> &Receiver { + &self.rx + } + + pub async fn send(&self, signal: IngesterSignal) { + if let Err(e) = self.tx.send(signal).await { + log::error!("Failed to send signal: {e}"); + } + } } static NOSTR_CLIENT: OnceLock = OnceLock::new(); -static GLOBAL_CHANNEL: OnceLock<(Sender, Receiver)> = OnceLock::new(); -static PROCESSED_EVENTS: OnceLock>> = OnceLock::new(); + +static INGESTER: OnceLock = OnceLock::new(); + +static SENT_IDS: OnceLock>> = OnceLock::new(); + static CURRENT_TIMESTAMP: OnceLock = OnceLock::new(); + static FIRST_RUN: OnceLock = OnceLock::new(); pub fn nostr_client() -> &'static Client { @@ -63,7 +130,7 @@ pub fn nostr_client() -> &'static Client { let opts = ClientOptions::new() .gossip(true) - .automatic_authentication(true) + .automatic_authentication(false) .verify_subscriptions(false) // Sleep after idle for 30 seconds .sleep_when_idle(SleepWhenIdle::Enabled { @@ -74,21 +141,18 @@ pub fn nostr_client() -> &'static Client { }) } -pub fn global_channel() -> &'static (Sender, Receiver) { - GLOBAL_CHANNEL.get_or_init(|| { - let (sender, receiver) = smol::channel::bounded::(2048); - (sender, receiver) - }) -} - -pub fn processed_events() -> &'static RwLock> { - PROCESSED_EVENTS.get_or_init(|| RwLock::new(BTreeSet::new())) +pub fn ingester() -> &'static Ingester { + INGESTER.get_or_init(Ingester::new) } pub fn starting_time() -> &'static Timestamp { CURRENT_TIMESTAMP.get_or_init(Timestamp::now) } +pub fn sent_ids() -> &'static RwLock> { + SENT_IDS.get_or_init(|| RwLock::new(Vec::new())) +} + pub fn first_run() -> &'static bool { FIRST_RUN.get_or_init(|| { let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION"))); diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml deleted file mode 100644 index c5b9207..0000000 --- a/crates/identity/Cargo.toml +++ /dev/null @@ -1,22 +0,0 @@ -[package] -name = "identity" -version.workspace = true -edition.workspace = true -publish.workspace = true - -[dependencies] -ui = { path = "../ui" } -global = { path = "../global" } -common = { path = "../common" } -client_keys = { path = "../client_keys" } -settings = { path = "../settings" } -signer_proxy = { path = "../signer_proxy" } - -nostr-sdk.workspace = true -nostr-connect.workspace = true -smol.workspace = true -gpui.workspace = true -anyhow.workspace = true -log.workspace = true -smallvec.workspace = true -webbrowser.workspace = true diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs deleted file mode 100644 index 532ec78..0000000 --- a/crates/identity/src/lib.rs +++ /dev/null @@ -1,122 +0,0 @@ -use std::time::Duration; - -use global::constants::ACCOUNT_IDENTIFIER; -use global::{global_channel, nostr_client, NostrSignal}; -use gpui::{App, AppContext, Context, Entity, Global, Window}; -use nostr_connect::prelude::*; -use signer_proxy::{BrowserSignerProxy, BrowserSignerProxyOptions}; - -pub fn init(public_key: PublicKey, window: &mut Window, cx: &mut App) { - Identity::set_global(cx.new(|cx| Identity::new(public_key, window, cx)), cx); -} - -struct GlobalIdentity(Entity); - -impl Global for GlobalIdentity {} - -pub struct Identity { - public_key: PublicKey, - nip17_relays: Option, - nip65_relays: Option, -} - -impl Identity { - /// Retrieve the Global Identity instance - pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() - } - - /// Retrieve the Identity instance - pub fn read_global(cx: &App) -> &Self { - cx.global::().0.read(cx) - } - - /// Check if the Global Identity instance has been set - pub fn has_global(cx: &App) -> bool { - cx.has_global::() - } - - /// Remove the Global Identity instance - pub fn remove_global(cx: &mut App) { - cx.remove_global::(); - } - - /// Set the Global Identity instance - pub(crate) fn set_global(state: Entity, cx: &mut App) { - cx.set_global(GlobalIdentity(state)); - } - - pub(crate) fn new( - public_key: PublicKey, - _window: &mut Window, - _cx: &mut Context, - ) -> Self { - Self { - public_key, - nip17_relays: None, - nip65_relays: None, - } - } - - /// Returns the current identity's public key - pub fn public_key(&self) -> PublicKey { - self.public_key - } - - /// Returns the current identity's NIP-17 relays status - pub fn nip17_relays(&self) -> Option { - self.nip17_relays - } - - /// Returns the current identity's NIP-65 relays status - pub fn nip65_relays(&self) -> Option { - self.nip65_relays - } - - /// Starts the browser proxy for nostr signer - pub fn start_browser_proxy(cx: &App) { - let proxy = BrowserSignerProxy::new(BrowserSignerProxyOptions::default()); - let url = proxy.url(); - - cx.background_spawn(async move { - let client = nostr_client(); - let channel = global_channel(); - - if proxy.start().await.is_ok() { - webbrowser::open(&url).ok(); - - loop { - if proxy.is_session_active() { - // Save the signer to disk for further logins - if let Ok(public_key) = proxy.get_public_key().await { - let keys = Keys::generate(); - let tags = vec![Tag::identifier(ACCOUNT_IDENTIFIER)]; - let kind = Kind::ApplicationSpecificData; - - let builder = EventBuilder::new(kind, "extension") - .tags(tags) - .build(public_key) - .sign(&keys) - .await; - - if let Ok(event) = builder { - if let Err(e) = client.database().save_event(&event).await { - log::error!("Failed to save event: {e}"); - }; - } - } - - // Set the client's signer with current proxy signer - client.set_signer(proxy.clone()).await; - - break; - } else { - channel.0.send(NostrSignal::ProxyDown).await.ok(); - } - smol::Timer::after(Duration::from_secs(1)).await; - } - } - }) - .detach(); - } -} diff --git a/crates/registry/Cargo.toml b/crates/registry/Cargo.toml index 0b9e299..cd105e2 100644 --- a/crates/registry/Cargo.toml +++ b/crates/registry/Cargo.toml @@ -14,9 +14,9 @@ nostr.workspace = true nostr-sdk.workspace = true anyhow.workspace = true itertools.workspace = true -chrono.workspace = true smallvec.workspace = true smol.workspace = true log.workspace = true fuzzy-matcher = "0.3.7" +hashbrown = "0.15" diff --git a/crates/registry/src/lib.rs b/crates/registry/src/lib.rs index 98ea4bd..349dda1 100644 --- a/crates/registry/src/lib.rs +++ b/crates/registry/src/lib.rs @@ -1,14 +1,12 @@ use std::cmp::Reverse; -use std::collections::{BTreeMap, BTreeSet, HashMap}; use anyhow::Error; use common::event::EventUtils; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; use global::nostr_client; -use gpui::{ - App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window, -}; +use gpui::{App, AppContext, Context, Entity, EventEmitter, Global, Task, WeakEntity, Window}; +use hashbrown::{HashMap, HashSet}; use itertools::Itertools; use nostr_sdk::prelude::*; use room::RoomKind; @@ -29,7 +27,7 @@ struct GlobalRegistry(Entity); impl Global for GlobalRegistry {} #[derive(Debug)] -pub enum RegistrySignal { +pub enum RegistryEvent { Open(WeakEntity), Close(u64), NewRequest(RoomKind), @@ -41,19 +39,21 @@ pub struct Registry { pub rooms: Vec>, /// Collection of all persons (user profiles) - pub persons: BTreeMap>, + pub persons: HashMap>, /// Indicates if rooms are currently being loaded /// /// Always equal to `true` when the app starts pub loading: bool, - /// Subscriptions for observing changes - #[allow(dead_code)] - subscriptions: SmallVec<[Subscription; 2]>, + /// Public Key of the current user + pub identity: Option, + + /// Tasks for asynchronous operations + _tasks: SmallVec<[Task<()>; 1]>, } -impl EventEmitter for Registry {} +impl EventEmitter for Registry {} impl Registry { /// Retrieve the Global Registry state @@ -73,74 +73,68 @@ impl Registry { /// Create a new Registry instance pub(crate) fn new(cx: &mut Context) -> Self { - let mut subscriptions = smallvec![]; + let mut tasks = smallvec![]; - // Load all user profiles from the database when the Registry is created - subscriptions.push(cx.observe_new::(|this, _window, cx| { - let task = this.load_local_person(cx); - this.set_persons_from_task(task, cx); - })); + let load_local_persons: Task, Error>> = + cx.background_spawn(async move { + let client = nostr_client(); + let filter = Filter::new().kind(Kind::Metadata).limit(200); + let events = client.database().query(filter).await?; + let mut profiles = vec![]; - // When any Room is created, load members metadata - subscriptions.push(cx.observe_new::(|this, _window, cx| { - let state = Self::global(cx); - let task = this.load_metadata(cx); + 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); + } - state.update(cx, |this, cx| { - this.set_persons_from_task(task, cx); + Ok(profiles) }); - })); + + tasks.push( + // Load all user profiles from the database when the Registry is created + cx.spawn(async move |this, cx| { + if let Ok(profiles) = load_local_persons.await { + this.update(cx, |this, cx| { + this.set_persons(profiles, cx); + }) + .ok(); + } + }), + ); Self { rooms: vec![], - persons: BTreeMap::new(), + persons: HashMap::new(), + identity: None, loading: true, - subscriptions, + _tasks: tasks, } } - pub fn reset(&mut self, cx: &mut Context) { - self.rooms = vec![]; - self.loading = true; + /// Returns the identity of the user. + /// + /// WARNING: This method will panic if user is not logged in. + pub fn identity(&self, cx: &App) -> Profile { + self.get_person(&self.identity.unwrap(), cx) + } + + /// Sets the identity of the user. + pub fn set_identity(&mut self, identity: PublicKey, cx: &mut Context) { + self.identity = Some(identity); cx.notify(); } - pub(crate) fn set_persons_from_task( - &mut self, - task: Task, Error>>, - cx: &mut Context, - ) { - cx.spawn(async move |this, cx| { - if let Ok(profiles) = task.await { - this.update(cx, |this, cx| { - for profile in profiles { - this.persons - .insert(profile.public_key(), cx.new(|_| profile)); - } - cx.notify(); - }) - .ok(); - } - }) - .detach(); - } - - pub(crate) fn load_local_person(&self, cx: &App) -> Task, Error>> { - cx.background_spawn(async move { - let filter = Filter::new().kind(Kind::Metadata).limit(100); - let events = nostr_client().database().query(filter).await?; - 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); - } - - Ok(profiles) - }) + /// Insert batch of persons + pub fn set_persons(&mut self, profiles: Vec, cx: &mut Context) { + for profile in profiles.into_iter() { + self.persons + .insert(profile.public_key(), cx.new(|_| profile)); + } + cx.notify(); } + /// Get single person pub fn get_person(&self, public_key: &PublicKey, cx: &App) -> Profile { self.persons .get(public_key) @@ -149,6 +143,7 @@ impl Registry { .unwrap_or(Profile::new(public_key.to_owned(), Metadata::default())) } + /// Get group of persons pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec { let mut profiles = vec![]; @@ -160,6 +155,7 @@ impl Registry { profiles } + /// Insert or update a person pub fn insert_or_update_person(&mut self, event: Event, cx: &mut App) { let public_key = event.pubkey; let Ok(metadata) = Metadata::from_json(event.content) else { @@ -213,7 +209,7 @@ impl Registry { /// Close a room. pub fn close_room(&mut self, id: u64, cx: &mut Context) { if self.rooms.iter().any(|r| r.read(cx).id == id) { - cx.emit(RegistrySignal::Close(id)); + cx.emit(RegistryEvent::Close(id)); } } @@ -253,6 +249,14 @@ impl Registry { cx.notify(); } + /// Reset the registry. + pub fn reset(&mut self, cx: &mut Context) { + self.rooms = vec![]; + self.loading = true; + self.identity = None; + 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..."); @@ -260,7 +264,7 @@ impl Registry { // Get the contact bypass setting let contact_bypass = AppSettings::get_contact_bypass(cx); - let task: Task, Error>> = cx.background_spawn(async move { + let task: Task, Error>> = cx.background_spawn(async move { let client = nostr_client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; @@ -279,7 +283,7 @@ impl Registry { let recv_events = client.database().query(recv).await?; let events = send_events.merge(recv_events); - let mut rooms: BTreeSet = BTreeSet::new(); + let mut rooms: HashSet = HashSet::new(); // Process each event and group by room hash for event in events @@ -343,7 +347,7 @@ impl Registry { .detach(); } - pub(crate) fn extend_rooms(&mut self, rooms: BTreeSet, cx: &mut Context) { + pub(crate) fn extend_rooms(&mut self, rooms: HashSet, cx: &mut Context) { let mut room_map: HashMap = HashMap::with_capacity(self.rooms.len()); for (index, room) in self.rooms.iter().enumerate() { @@ -380,7 +384,7 @@ impl Registry { weak_room }; - cx.emit(RegistrySignal::Open(weak_room)); + cx.emit(RegistryEvent::Open(weak_room)); } /// Refresh messages for a room in the global registry @@ -400,7 +404,7 @@ impl Registry { /// Updates room ordering based on the most recent messages. pub fn event_to_message( &mut self, - identity: PublicKey, + gift_wrap_id: EventId, event: Event, window: &mut Window, cx: &mut Context, @@ -408,6 +412,10 @@ impl Registry { let id = event.uniq_id(); let author = event.pubkey; + let Some(identity) = self.identity else { + return; + }; + if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { // Update room room.update(cx, |this, cx| { @@ -420,7 +428,7 @@ impl Registry { // Emit the new message to the room cx.defer_in(window, move |this, _window, cx| { - this.emit_message(event, cx); + this.emit_message(gift_wrap_id, event, cx); }); }); @@ -436,7 +444,7 @@ impl Registry { // Notify the UI about the new room cx.defer_in(window, move |_this, _window, cx| { - cx.emit(RegistrySignal::NewRequest(RoomKind::default())); + cx.emit(RegistryEvent::NewRequest(RoomKind::default())); }); } } diff --git a/crates/registry/src/message.rs b/crates/registry/src/message.rs index 5ca8371..1552bcc 100644 --- a/crates/registry/src/message.rs +++ b/crates/registry/src/message.rs @@ -1,7 +1,5 @@ use std::hash::Hash; -use chrono::{Local, TimeZone}; -use gpui::SharedString; use nostr_sdk::prelude::*; #[derive(Debug, Clone)] @@ -10,8 +8,8 @@ pub struct RenderedMessage { /// Author's public key pub author: PublicKey, /// The content/text of the message - pub content: SharedString, - /// When the message was created + pub content: String, + /// Message created time as unix timestamp pub created_at: Timestamp, /// List of mentioned public keys in the message pub mentions: Vec, @@ -27,7 +25,7 @@ impl From for RenderedMessage { Self { id: inner.id, author: inner.pubkey, - content: inner.content.into(), + content: inner.content, created_at: inner.created_at, mentions, replies_to, @@ -44,7 +42,7 @@ impl From for RenderedMessage { // Event ID must be known id: inner.id.unwrap(), author: inner.pubkey, - content: inner.content.into(), + content: inner.content, created_at: inner.created_at, mentions, replies_to, @@ -90,30 +88,6 @@ impl Hash for RenderedMessage { } } -impl RenderedMessage { - /// Returns a human-readable string representing how long ago the message was created - pub fn ago(&self) -> SharedString { - let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) { - chrono::LocalResult::Single(time) => time, - _ => return "Invalid timestamp".into(), - }; - - let now = Local::now(); - let input_date = input_time.date_naive(); - let now_date = now.date_naive(); - let yesterday_date = (now - chrono::Duration::days(1)).date_naive(); - - let time_format = input_time.format("%H:%M %p"); - - match input_date { - date if date == now_date => format!("Today at {time_format}"), - date if date == yesterday_date => format!("Yesterday at {time_format}"), - _ => format!("{}, {time_format}", input_time.format("%d/%m/%y")), - } - .into() - } -} - fn extract_mentions(content: &str) -> Vec { let parser = NostrParser::new(); let tokens = parser.parse(content); diff --git a/crates/registry/src/room.rs b/crates/registry/src/room.rs index 713a405..1ba1064 100644 --- a/crates/registry/src/room.rs +++ b/crates/registry/src/room.rs @@ -1,23 +1,16 @@ use std::cmp::Ordering; +use std::hash::{Hash, Hasher}; -use anyhow::{anyhow, Error}; -use chrono::{Local, TimeZone}; -use common::display::DisplayProfile; +use anyhow::Error; +use common::display::ReadableProfile; use common::event::EventUtils; use global::nostr_client; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; use itertools::Itertools; use nostr_sdk::prelude::*; -use smallvec::SmallVec; use crate::Registry; -pub(crate) const NOW: &str = "now"; -pub(crate) const SECONDS_IN_MINUTE: i64 = 60; -pub(crate) const MINUTES_IN_HOUR: i64 = 60; -pub(crate) const HOURS_IN_DAY: i64 = 24; -pub(crate) const DAYS_IN_MONTH: i64 = 30; - #[derive(Debug, Clone)] pub struct SendReport { pub receiver: PublicKey, @@ -69,7 +62,7 @@ impl SendReport { #[derive(Debug, Clone)] pub enum RoomSignal { - NewMessage(Box), + NewMessage((EventId, Box)), Refresh, } @@ -85,11 +78,11 @@ pub struct Room { pub id: u64, pub created_at: Timestamp, /// Subject of the room - pub subject: Option, + pub subject: Option, /// Picture of the room - pub picture: Option, + pub picture: Option, /// All members of the room - pub members: SmallVec<[PublicKey; 2]>, + pub members: Vec, /// Kind pub kind: RoomKind, } @@ -112,6 +105,12 @@ impl PartialEq for Room { } } +impl Hash for Room { + fn hash(&self, state: &mut H) { + self.id.hash(state); + } +} + impl Eq for Room {} impl EventEmitter for Room {} @@ -120,21 +119,25 @@ impl Room { pub fn new(event: &Event) -> Self { let id = event.uniq_id(); let created_at = event.created_at; - let public_keys = event.all_pubkeys(); - // Convert pubkeys into members - let members = public_keys.into_iter().unique().sorted().collect(); + // Get the members from the event's tags and event's pubkey + let members = event + .all_pubkeys() + .into_iter() + .unique() + .sorted() + .collect_vec(); // Get the subject from the event's tags let subject = if let Some(tag) = event.tags.find(TagKind::Subject) { - tag.content().map(|s| s.to_owned().into()) + tag.content().map(|s| s.to_owned()) } else { None }; // Get the picture from the event's tags let picture = if let Some(tag) = event.tags.find(TagKind::custom("picture")) { - tag.content().map(|s| s.to_owned().into()) + tag.content().map(|s| s.to_owned()) } else { None }; @@ -177,11 +180,9 @@ impl Room { /// /// The modified Room instance with the new member list after rearrangement pub fn rearrange_by(mut self, rearrange_by: PublicKey) -> Self { - let (not_match, matches): (Vec, Vec) = self - .members - .into_iter() - .partition(|key| key != &rearrange_by); - self.members = not_match.into(); + let (not_match, matches): (Vec, Vec) = + self.members.iter().partition(|&key| key != &rearrange_by); + self.members = not_match; self.members.extend(matches); self } @@ -224,8 +225,8 @@ impl Room { /// /// * `subject` - The new subject to set /// * `cx` - The context to notify about the update - pub fn subject(&mut self, subject: impl Into, cx: &mut Context) { - self.subject = Some(subject.into()); + pub fn subject(&mut self, subject: String, cx: &mut Context) { + self.subject = Some(subject); cx.notify(); } @@ -235,42 +236,11 @@ impl Room { /// /// * `picture` - The new subject to set /// * `cx` - The context to notify about the update - pub fn picture(&mut self, picture: impl Into, cx: &mut Context) { - self.picture = Some(picture.into()); + pub fn picture(&mut self, picture: String, cx: &mut Context) { + self.picture = Some(picture); cx.notify(); } - /// Returns a human-readable string representing how long ago the room was created - /// - /// The string will be formatted differently based on the time elapsed: - /// - Less than a minute: "now" - /// - Less than an hour: "Xm" (minutes) - /// - Less than a day: "Xh" (hours) - /// - Less than a month: "Xd" (days) - /// - More than a month: "MMM DD" (month abbreviation and day) - /// - /// # Returns - /// - /// A SharedString containing the formatted time representation - pub fn ago(&self) -> SharedString { - let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) { - chrono::LocalResult::Single(time) => time, - _ => return "1m".into(), - }; - - let now = Local::now(); - let duration = now.signed_duration_since(input_time); - - match duration { - d if d.num_seconds() < SECONDS_IN_MINUTE => NOW.into(), - d if d.num_minutes() < MINUTES_IN_HOUR => format!("{}m", d.num_minutes()), - d if d.num_hours() < HOURS_IN_DAY => format!("{}h", d.num_hours()), - d if d.num_days() < DAYS_IN_MONTH => format!("{}d", d.num_days()), - _ => input_time.format("%b %d").to_string(), - } - .into() - } - /// Gets the display name for the room /// /// If the room has a subject set, that will be used as the display name. @@ -282,8 +252,8 @@ impl Room { /// /// # Returns /// - /// A SharedString containing the display name - pub fn display_name(&self, cx: &App) -> SharedString { + /// A string containing the display name + pub fn display_name(&self, cx: &App) -> String { if let Some(subject) = self.subject.clone() { subject } else { @@ -305,8 +275,8 @@ impl Room { /// /// # Returns /// - /// A SharedString containing the image path or URL - pub fn display_image(&self, proxy: bool, cx: &App) -> SharedString { + /// A string containing the image path or URL + pub fn display_image(&self, proxy: bool, cx: &App) -> String { if let Some(picture) = self.picture.as_ref() { picture.clone() } else if !self.is_group() { @@ -325,7 +295,7 @@ impl Room { } /// Merge the names of the first two members of the room. - pub(crate) fn merge_name(&self, cx: &App) -> SharedString { + pub(crate) fn merge_name(&self, cx: &App) -> String { let registry = Registry::read_global(cx); if self.is_group() { @@ -346,37 +316,12 @@ impl Room { name = format!("{}, +{}", name, profiles.len() - 2); } - name.into() + name } else { self.first_member(cx).display_name() } } - /// Loads all profiles for this room members from the database - /// - /// # Arguments - /// - /// * `cx` - The App context - /// - /// # Returns - /// - /// A Task that resolves to Result, Error> containing all profiles for this room - pub fn load_metadata(&self, cx: &mut Context) -> Task, Error>> { - let public_keys = self.members.clone(); - - cx.background_spawn(async move { - let database = nostr_client().database(); - let mut profiles = vec![]; - - for public_key in public_keys.into_iter() { - let metadata = database.metadata(public_key).await?.unwrap_or_default(); - profiles.push(Profile::new(public_key, metadata)); - } - - Ok(profiles) - }) - } - /// Loads all messages for this room from the database /// /// # Arguments @@ -397,22 +342,21 @@ impl Room { .authors(members.clone()) .pubkeys(members.clone()); - let events = client + let events: Vec = client .database() .query(filter) .await? .into_iter() - .sorted_by_key(|ev| ev.created_at) .filter(|ev| ev.compare_pubkeys(&members)) - .collect::>(); + .collect(); Ok(events) }) } /// Emits a new message signal to the current room - pub fn emit_message(&self, event: Event, cx: &mut Context) { - cx.emit(RoomSignal::NewMessage(Box::new(event))); + pub fn emit_message(&self, gift_wrap_id: EventId, event: Event, cx: &mut Context) { + cx.emit(RoomSignal::NewMessage((gift_wrap_id, Box::new(event)))); } /// Emits a signal to refresh the current room's messages. @@ -473,7 +417,7 @@ impl Room { let content = content.to_owned(); let subject = self.subject.clone(); let picture = self.picture.clone(); - let public_keys = self.members.clone(); + let mut public_keys = self.members.clone(); cx.background_spawn(async move { let client = nostr_client(); @@ -516,26 +460,25 @@ impl Room { tags.push(Tag::custom(TagKind::custom("picture"), vec![picture])); } - let Some((current_user, receivers)) = public_keys.split_last() else { - return Err(anyhow!("Something is wrong. Cannot get receivers list.")); - }; + // Remove the current public key from the list of receivers + public_keys.retain(|&pk| pk != public_key); // Stored all send errors let mut reports = vec![]; - for receiver in receivers.iter() { + for receiver in public_keys.into_iter() { match client - .send_private_msg(*receiver, &content, tags.clone()) + .send_private_msg(receiver, &content, tags.clone()) .await { Ok(output) => { - reports.push(SendReport::output(*receiver, output)); + reports.push(SendReport::output(receiver, output)); } Err(e) => { if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e { - reports.push(SendReport::nip17_relays_not_found(*receiver)); + reports.push(SendReport::nip17_relays_not_found(receiver)); } else { - reports.push(SendReport::error(*receiver, e.to_string())); + reports.push(SendReport::error(receiver, e.to_string())); } } } @@ -544,17 +487,17 @@ impl Room { // Only send a backup message to current user if sent successfully to others if reports.iter().all(|r| r.is_sent_success()) && backup { match client - .send_private_msg(*current_user, &content, tags.clone()) + .send_private_msg(public_key, &content, tags.clone()) .await { Ok(output) => { - reports.push(SendReport::output(*current_user, output)); + reports.push(SendReport::output(public_key, output)); } Err(e) => { if let nostr_sdk::client::Error::PrivateMsgRelaysNotFound = e { - reports.push(SendReport::nip17_relays_not_found(*current_user)); + reports.push(SendReport::nip17_relays_not_found(public_key)); } else { - reports.push(SendReport::error(*current_user, e.to_string())); + reports.push(SendReport::error(public_key, e.to_string())); } } } diff --git a/crates/ui/src/notification.rs b/crates/ui/src/notification.rs index 1c4d4e4..65e999c 100644 --- a/crates/ui/src/notification.rs +++ b/crates/ui/src/notification.rs @@ -1,15 +1,15 @@ use std::any::TypeId; use std::borrow::Cow; use std::collections::{HashMap, VecDeque}; -use std::sync::Arc; +use std::rc::Rc; use std::time::Duration; use gpui::prelude::FluentBuilder; use gpui::{ - blue, div, green, px, red, yellow, Animation, AnimationExt, App, AppContext, ClickEvent, - Context, DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, - ParentElement as _, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, - Window, + div, px, Animation, AnimationExt, AnyElement, App, AppContext, ClickEvent, Context, + DismissEvent, ElementId, Entity, EventEmitter, InteractiveElement as _, IntoElement, + ParentElement as _, Render, SharedString, StatefulInteractiveElement, StyleRefinement, Styled, + Subscription, Window, }; use smol::Timer; use theme::ActiveTheme; @@ -18,13 +18,30 @@ use crate::animation::cubic_bezier; use crate::button::{Button, ButtonVariants as _}; use crate::{h_flex, v_flex, Icon, IconName, Sizable as _, StyledExt}; +#[derive(Debug, Clone, Copy, Default)] pub enum NotificationType { + #[default] Info, Success, Warning, Error, } +impl NotificationType { + fn icon(&self, cx: &App) -> Icon { + match self { + Self::Info => Icon::new(IconName::Info).text_color(cx.theme().element_active), + Self::Warning => Icon::new(IconName::Report).text_color(cx.theme().warning_foreground), + Self::Success => { + Icon::new(IconName::CheckCircle).text_color(cx.theme().element_foreground) + } + Self::Error => { + Icon::new(IconName::CloseCircle).text_color(cx.theme().danger_foreground) + } + } + } +} + #[derive(Debug, PartialEq, Clone, Hash, Eq)] pub(crate) enum NotificationId { Id(TypeId), @@ -43,8 +60,6 @@ impl From<(TypeId, ElementId)> for NotificationId { } } -type OnClick = Option>; - /// A notification element. pub struct Notification { /// The id is used make the notification unique. @@ -52,48 +67,54 @@ pub struct Notification { /// /// None means the notification will be added to the end of the list. id: NotificationId, - kind: NotificationType, + style: StyleRefinement, + type_: Option, title: Option, - message: SharedString, + message: Option, icon: Option, autohide: bool, - on_click: OnClick, + #[allow(clippy::type_complexity)] + action_builder: Option) -> Button>>, + #[allow(clippy::type_complexity)] + content_builder: Option) -> AnyElement>>, + #[allow(clippy::type_complexity)] + on_click: Option>, closing: bool, } impl From for Notification { fn from(s: String) -> Self { - Self::new(s) + Self::new().message(s) } } impl From> for Notification { fn from(s: Cow<'static, str>) -> Self { - Self::new(s) + Self::new().message(s) } } impl From for Notification { fn from(s: SharedString) -> Self { - Self::new(s) + Self::new().message(s) } } impl From<&'static str> for Notification { fn from(s: &'static str) -> Self { - Self::new(s) + Self::new().message(s) } } impl From<(NotificationType, &'static str)> for Notification { fn from((type_, content): (NotificationType, &'static str)) -> Self { - Self::new(content).with_type(type_) + Self::new().message(content).with_type(type_) } } impl From<(NotificationType, SharedString)> for Notification { fn from((type_, content): (NotificationType, SharedString)) -> Self { - Self::new(content).with_type(type_) + Self::new().message(content).with_type(type_) } } @@ -103,36 +124,52 @@ impl Notification { /// Create a new notification with the given content. /// /// default width is 320px. - pub fn new(message: impl Into) -> Self { + pub fn new() -> Self { let id: SharedString = uuid::Uuid::new_v4().to_string().into(); let id = (TypeId::of::(), id.into()); Self { id: id.into(), + style: StyleRefinement::default(), title: None, - message: message.into(), - kind: NotificationType::Info, + message: None, + type_: None, icon: None, autohide: true, + action_builder: None, + content_builder: None, on_click: None, closing: false, } } + pub fn message(mut self, message: impl Into) -> Self { + self.message = Some(message.into()); + self + } + pub fn info(message: impl Into) -> Self { - Self::new(message).with_type(NotificationType::Info) + Self::new() + .message(message) + .with_type(NotificationType::Info) } pub fn success(message: impl Into) -> Self { - Self::new(message).with_type(NotificationType::Success) + Self::new() + .message(message) + .with_type(NotificationType::Success) } pub fn warning(message: impl Into) -> Self { - Self::new(message).with_type(NotificationType::Warning) + Self::new() + .message(message) + .with_type(NotificationType::Warning) } pub fn error(message: impl Into) -> Self { - Self::new(message).with_type(NotificationType::Error) + Self::new() + .message(message) + .with_type(NotificationType::Error) } /// Set the type for unique identification of the notification. @@ -147,8 +184,8 @@ impl Notification { } /// Set the type and id of the notification, used to uniquely identify the notification. - pub fn id1(mut self, key: impl Into) -> Self { - self.id = (TypeId::of::(), key.into()).into(); + pub fn custom_id(mut self, key: impl Into) -> Self { + self.id = (TypeId::of::(), key.into()).into(); self } @@ -170,7 +207,7 @@ impl Notification { /// Set the type of the notification, default is NotificationType::Info. pub fn with_type(mut self, type_: NotificationType) -> Self { - self.kind = type_; + self.type_ = Some(type_); self } @@ -185,11 +222,21 @@ impl Notification { mut self, on_click: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static, ) -> Self { - self.on_click = Some(Arc::new(on_click)); + self.on_click = Some(Rc::new(on_click)); self } - fn dismiss(&mut self, _: &ClickEvent, _window: &mut Window, cx: &mut Context) { + /// Set the action button of the notification. + pub fn action(mut self, action: F) -> Self + where + F: Fn(&mut Window, &mut Context) -> Button + 'static, + { + self.action_builder = Some(Rc::new(action)); + self + } + + /// Dismiss the notification. + pub fn dismiss(&mut self, _: &mut Window, cx: &mut Context) { self.closing = true; cx.notify(); @@ -207,31 +254,48 @@ impl Notification { }) .detach() } + + /// Set the content of the notification. + pub fn content( + mut self, + content: impl Fn(&mut Window, &mut Context) -> AnyElement + 'static, + ) -> Self { + self.content_builder = Some(Rc::new(content)); + self + } +} + +impl Default for Notification { + fn default() -> Self { + Self::new() + } } impl EventEmitter for Notification {} impl FluentBuilder for Notification {} +impl Styled for Notification { + fn style(&mut self) -> &mut StyleRefinement { + &mut self.style + } +} + impl Render for Notification { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let closing = self.closing; - let icon = match self.icon.clone() { - Some(icon) => icon, - None => match self.kind { - NotificationType::Info => Icon::new(IconName::Info).text_color(blue()), - NotificationType::Warning => Icon::new(IconName::Info).text_color(yellow()), - NotificationType::Error => Icon::new(IconName::CloseCircle).text_color(red()), - NotificationType::Success => Icon::new(IconName::CheckCircle).text_color(green()), - }, + let icon = match self.type_ { + None => self.icon.clone(), + Some(type_) => Some(type_.icon(cx)), }; - div() + h_flex() .id("notification") + .refine_style(&self.style) .group("") .occlude() .relative() - .w_72() + .w_96() .border_1() .border_color(cx.theme().border) .bg(cx.theme().surface_background) @@ -239,52 +303,70 @@ impl Render for Notification { .shadow_md() .p_2() .gap_3() - .child(div().absolute().top_2p5().left_2().child(icon)) + .justify_start() + .items_start() + .when_some(icon, |this, icon| { + this.child(div().flex_shrink_0().pt_1().child(icon)) + }) .child( v_flex() - .pl_6() + .flex_1() .gap_1() - .when_some(self.title.clone(), |this, title| { - this.child(div().text_xs().font_semibold().child(title)) - }) .overflow_hidden() - .child(div().text_xs().child(self.message.clone())), + .when_some(self.title.clone(), |this, title| { + this.child(div().text_sm().font_semibold().child(title)) + }) + .when_some(self.message.clone(), |this, message| { + this.child(div().text_sm().child(message)) + }) + .when_some(self.content_builder.clone(), |this, child_builder| { + this.child(child_builder(window, cx)) + }) + .when_some(self.action_builder.clone(), |this, action_builder| { + this.child(action_builder(window, cx).small().w_full().my_2()) + }), + ) + .child( + div() + .absolute() + .top_2p5() + .right_2p5() + .invisible() + .group_hover("", |this| this.visible()) + .child( + Button::new("close") + .icon(IconName::Close) + .ghost() + .xsmall() + .on_click(cx.listener(|this, _, window, cx| { + this.dismiss(window, cx); + })), + ), ) .when_some(self.on_click.clone(), |this, on_click| { - this.cursor_pointer() - .on_click(cx.listener(move |view, event, window, cx| { - view.dismiss(event, window, cx); - on_click(event, window, cx); - })) - }) - .when(!self.autohide, |this| { - this.child( - h_flex() - .absolute() - .top_1() - .right_1() - .invisible() - .group_hover("", |this| this.visible()) - .child( - Button::new("close") - .icon(IconName::Close) - .ghost() - .xsmall() - .on_click(cx.listener(Self::dismiss)), - ), - ) + this.on_click(cx.listener(move |view, event, window, cx| { + view.dismiss(window, cx); + on_click(event, window, cx); + })) }) .with_animation( ElementId::NamedInteger("slide-down".into(), closing as u64), - Animation::new(Duration::from_secs_f64(0.15)) + Animation::new(Duration::from_secs_f64(0.25)) .with_easing(cubic_bezier(0.4, 0., 0.2, 1.)), move |this, delta| { if closing { let x_offset = px(0.) + delta * px(45.); - this.left(px(0.) + x_offset).opacity(1. - delta) + let opacity = 1. - delta; + this.left(px(0.) + x_offset) + .shadow_none() + .opacity(opacity) + .when(opacity < 0.85, |this| this.shadow_none()) } else { let y_offset = px(-45.) + delta * px(45.); - this.top(px(0.) + y_offset).opacity(delta) + let opacity = delta; + this.top(px(0.) + y_offset) + .opacity(opacity) + .when(opacity < 0.85, |this| this.shadow_none()) } }, ) @@ -296,7 +378,7 @@ pub struct NotificationList { /// Notifications that will be auto hidden. pub(crate) notifications: VecDeque>, expanded: bool, - subscriptions: HashMap, + _subscriptions: HashMap, } impl NotificationList { @@ -304,16 +386,14 @@ impl NotificationList { Self { notifications: VecDeque::new(), expanded: false, - subscriptions: HashMap::new(), + _subscriptions: HashMap::new(), } } - pub fn push( - &mut self, - notification: impl Into, - window: &mut Window, - cx: &mut Context, - ) { + pub fn push(&mut self, notification: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { let notification = notification.into(); let id = notification.id.clone(); let autohide = notification.autohide; @@ -323,28 +403,47 @@ impl NotificationList { let notification = cx.new(|_| notification); - self.subscriptions.insert( + self._subscriptions.insert( id.clone(), cx.subscribe(¬ification, move |view, _, _: &DismissEvent, cx| { view.notifications.retain(|note| id != note.read(cx).id); - view.subscriptions.remove(&id); + view._subscriptions.remove(&id); }), ); self.notifications.push_back(notification.clone()); + if autohide { - // Sleep for 3 seconds to autohide the notification + // Sleep for 5 seconds to autohide the notification cx.spawn_in(window, async move |_, cx| { - Timer::after(Duration::from_secs(3)).await; - _ = notification.update_in(cx, |note, window, cx| { - note.dismiss(&ClickEvent::default(), window, cx) - }); + Timer::after(Duration::from_secs(5)).await; + + if let Err(error) = + notification.update_in(cx, |note, window, cx| note.dismiss(window, cx)) + { + log::error!("Failed to auto hide notification: {error}"); + } }) .detach(); } cx.notify(); } + pub(crate) fn close(&mut self, key: T, window: &mut Window, cx: &mut Context) + where + T: Into, + { + let id = (TypeId::of::(), key.into()).into(); + + if let Some(n) = self.notifications.iter().find(|n| n.read(cx).id == id) { + n.update(cx, |note, cx| { + note.dismiss(window, cx); + }); + } + + cx.notify(); + } + pub fn clear(&mut self, _window: &mut Window, cx: &mut Context) { self.notifications.clear(); cx.notify(); @@ -356,24 +455,25 @@ impl NotificationList { } impl Render for NotificationList { - fn render( - &mut self, - window: &mut gpui::Window, - cx: &mut gpui::Context, - ) -> impl IntoElement { + fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { let size = window.viewport_size(); let items = self.notifications.iter().rev().take(10).rev().cloned(); - div().absolute().top_4().right_4().child( - v_flex() - .id("notification-list") - .h(size.height - px(8.)) - .on_hover(cx.listener(|view, hovered, _, cx| { - view.expanded = *hovered; - cx.notify() - })) - .gap_3() - .children(items), - ) + div() + .id("notification-wrapper") + .absolute() + .top_4() + .right_4() + .child( + v_flex() + .id("notification-list") + .h(size.height - px(8.)) + .gap_3() + .children(items) + .on_hover(cx.listener(|view, hovered, _, cx| { + view.expanded = *hovered; + cx.notify() + })), + ) } } diff --git a/crates/ui/src/popup_menu.rs b/crates/ui/src/popup_menu.rs index 010380a..93f6ca4 100644 --- a/crates/ui/src/popup_menu.rs +++ b/crates/ui/src/popup_menu.rs @@ -3,10 +3,11 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ - actions, anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, Bounds, Context, - Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, - IntoElement, KeyBinding, Keystroke, ParentElement, Pixels, Render, ScrollHandle, SharedString, - StatefulInteractiveElement, Styled, Subscription, WeakEntity, Window, + actions, anchored, canvas, div, px, rems, Action, AnyElement, App, AppContext, AsKeystroke, + Bounds, Context, Corner, DismissEvent, Edges, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, KeyBinding, Keystroke, ParentElement, Pixels, Render, + ScrollHandle, SharedString, StatefulInteractiveElement, Styled, Subscription, WeakEntity, + Window, }; use theme::ActiveTheme; @@ -472,7 +473,7 @@ impl PopupMenu { keybinding .keystrokes() .iter() - .map(|key| key_shortcut(key.clone())), + .map(|key| key_shortcut(key.as_keystroke().clone())), ); return Some(el); diff --git a/crates/ui/src/root.rs b/crates/ui/src/root.rs index 544f58c..894e4de 100644 --- a/crates/ui/src/root.rs +++ b/crates/ui/src/root.rs @@ -3,7 +3,7 @@ use std::rc::Rc; use gpui::prelude::FluentBuilder; use gpui::{ div, AnyView, App, AppContext, Context, Decorations, Entity, FocusHandle, InteractiveElement, - IntoElement, ParentElement as _, Render, Styled, Window, + IntoElement, ParentElement as _, Render, SharedString, Styled, Window, }; use theme::{ActiveTheme, CLIENT_SIDE_DECORATION_ROUNDING}; @@ -34,6 +34,9 @@ pub trait ContextModal: Sized { /// Pushes a notification to the notification list. fn push_notification(&mut self, note: impl Into, cx: &mut App); + /// Clears a notification by its ID. + fn clear_notification_by_id(&mut self, id: SharedString, cx: &mut App); + /// Clear all notifications fn clear_notifications(&mut self, cx: &mut App); @@ -112,6 +115,15 @@ impl ContextModal for Window { }) } + fn clear_notification_by_id(&mut self, id: SharedString, cx: &mut App) { + Root::update(self, cx, move |root, window, cx| { + root.notification.update(cx, |view, cx| { + view.close(id.clone(), window, cx); + }); + cx.notify(); + }) + } + fn notifications(&mut self, cx: &mut App) -> Rc>> { let entity = Root::read(self, cx).notification.clone(); Rc::new(entity.read(cx).notifications()) diff --git a/crates/ui/src/skeleton.rs b/crates/ui/src/skeleton.rs index a944e88..b7d5eb6 100644 --- a/crates/ui/src/skeleton.rs +++ b/crates/ui/src/skeleton.rs @@ -1,21 +1,23 @@ use std::time::Duration; use gpui::{ - bounce, div, ease_in_out, Animation, AnimationExt, Div, IntoElement, ParentElement as _, - RenderOnce, Styled, + bounce, div, ease_in_out, Animation, AnimationExt, IntoElement, RenderOnce, StyleRefinement, + Styled, }; use theme::ActiveTheme; +use crate::StyledExt; + #[derive(IntoElement)] pub struct Skeleton { - base: Div, + style: StyleRefinement, secondary: bool, } impl Skeleton { pub fn new() -> Self { Self { - base: div().w_full().h_4().rounded_md(), + style: StyleRefinement::default(), secondary: false, } } @@ -34,7 +36,7 @@ impl Default for Skeleton { impl Styled for Skeleton { fn style(&mut self) -> &mut gpui::StyleRefinement { - self.base.style() + &mut self.style } } @@ -46,8 +48,13 @@ impl RenderOnce for Skeleton { cx.theme().ghost_element_active }; - div().child( - self.base.bg(color).with_animation( + div() + .w_full() + .h_4() + .rounded_md() + .refine_style(&self.style) + .bg(color) + .with_animation( "skeleton", Animation::new(Duration::from_secs(2)) .repeat() @@ -56,7 +63,6 @@ impl RenderOnce for Skeleton { let v = 1.0 - delta * 0.5; this.opacity(v) }, - ), - ) + ) } } diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index 3fb9997..f16be74 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -1,7 +1,7 @@ use std::ops::Range; use std::sync::Arc; -use common::display::DisplayProfile; +use common::display::ReadableProfile; use gpui::{ AnyElement, AnyView, App, ElementId, HighlightStyle, InteractiveText, IntoElement, SharedString, StyledText, UnderlineStyle, Window, diff --git a/locales/app.yml b/locales/app.yml index b50b130..6fc1f22 100644 --- a/locales/app.yml +++ b/locales/app.yml @@ -35,6 +35,16 @@ common: en: "Open Browser" refreshed: en: "Refreshed" + quit: + en: "Quit" + restart: + en: "Restart" + approve: + en: "Approve" + ignore: + en: "Ignore" + relay: + en: "Relay" auto_update: updating: @@ -82,6 +92,14 @@ proxy: description: en: "Open your default browser and approve the connection request in your Nostr Signer extension" +auth: + label: + en: "Authentication Required" + message: + en: "Approve the authentication request to allow Coop to continue getting your messages." + requests: + en: "You have %{u} total pending authentication requests" + startup: client_keys_warning: en: "Warning"