From bbc778d5ca6ce0a6299c93874c41b3723962f302 Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Sun, 23 Feb 2025 08:29:05 +0700 Subject: [PATCH] feat: sharpen chat experiences (#9) * feat: add global account and refactor chat registry * chore: improve last seen * chore: reduce string alloc * wip: refactor room * chore: fix edit profile panel * chore: refactor open window in main * chore: refactor sidebar * chore: refactor room --- Cargo.lock | 137 +++++++------ Cargo.toml | 4 +- crates/account/Cargo.toml | 16 ++ crates/account/src/lib.rs | 1 + crates/account/src/registry.rs | 117 +++++++++++ crates/app/Cargo.toml | 1 + crates/app/src/main.rs | 228 +++++++++------------ crates/app/src/views/app.rs | 179 +++++++++-------- crates/app/src/views/chat.rs | 175 ++++++++-------- crates/app/src/views/mod.rs | 1 - crates/app/src/views/onboarding.rs | 170 ++++++++-------- crates/app/src/views/profile.rs | 171 ++++++++++------ crates/app/src/views/sidebar/compose.rs | 44 ++--- crates/app/src/views/sidebar/inbox.rs | 182 ----------------- crates/app/src/views/sidebar/mod.rs | 253 ++++++++++++++++++------ crates/app/src/views/startup.rs | 32 --- crates/chats/Cargo.toml | 1 + crates/chats/src/registry.rs | 149 +++++++------- crates/chats/src/room.rs | 223 +++++++++++---------- crates/common/src/last_seen.rs | 69 ++++--- crates/common/src/profile.rs | 86 ++++---- crates/common/src/utils.rs | 74 ++----- crates/ui/src/dock_area/dock.rs | 4 +- 23 files changed, 1167 insertions(+), 1150 deletions(-) create mode 100644 crates/account/Cargo.toml create mode 100644 crates/account/src/lib.rs create mode 100644 crates/account/src/registry.rs delete mode 100644 crates/app/src/views/sidebar/inbox.rs delete mode 100644 crates/app/src/views/startup.rs diff --git a/Cargo.lock b/Cargo.lock index eb8a67f..3332826 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2,6 +2,20 @@ # It is not intended for manual editing. version = 4 +[[package]] +name = "account" +version = "0.0.0" +dependencies = [ + "anyhow", + "common", + "gpui", + "log", + "nostr-sdk", + "oneshot", + "smol", + "state", +] + [[package]] name = "addr2line" version = "0.24.2" @@ -134,9 +148,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.95" +version = "1.0.96" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34ac096ce696dc2fcabef30516bb13c0a68a11d30131d3df6f04711467681b04" +checksum = "6b964d184e89d9b6b67dd2715bc8e74cf3107fb2b529990c90cf517326150bf4" [[package]] name = "arbitrary" @@ -445,9 +459,9 @@ dependencies = [ [[package]] name = "aws-lc-rs" -version = "1.12.2" +version = "1.12.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4c2b7ddaa2c56a367ad27a094ad8ef4faacf8a617c2575acb2ba88949df999ca" +checksum = "4cd755adf9707cf671e31d944a189be3deaaeee11c8bc1d669bb8022ac90fbd0" dependencies = [ "aws-lc-sys", "paste", @@ -456,9 +470,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.25.1" +version = "0.26.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "54ac4f13dad353b209b34cbec082338202cbc01c8f00336b55c750c13ac91f8f" +checksum = "0f9dd2e03ee80ca2822dd6ea431163d2ef259f2066a4d6ccaca6d9dcb386aa43" dependencies = [ "bindgen 0.69.5", "cc", @@ -844,9 +858,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.14" +version = "1.2.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0c3d1b2e905a3a7b00a6141adb0e4c0bb941d11caf55349d863942a1cc44e3c9" +checksum = "c736e259eea577f443d5c86c304f9f4ae0295c43f3ba05c21f1d66b5f06001af" dependencies = [ "jobserver", "libc", @@ -931,6 +945,7 @@ dependencies = [ "itertools 0.13.0", "nostr-sdk", "oneshot", + "smallvec", "smol", "state", ] @@ -1093,7 +1108,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1174,6 +1189,7 @@ checksum = "6245d59a3e82a7fc217c5828a6692dbc6dfb63a0c8c90495621f7b9d79704a0e" name = "coop" version = "0.1.2" dependencies = [ + "account", "anyhow", "chats", "common", @@ -1375,9 +1391,9 @@ dependencies = [ [[package]] name = "ctor" -version = "0.3.5" +version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cdd538fd2dbf2e5932fad5a4f983ff0458891f5ea40973fa2b7d3460ca378914" +checksum = "21d960ecacd0a1bf55e73144b72de745e7bf275c7952c50e36e8af0a0cb7ab1f" dependencies = [ "ctor-proc-macro", ] @@ -1416,7 +1432,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "proc-macro2", "quote", @@ -2135,7 +2151,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2222,7 +2238,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "proc-macro2", "quote", @@ -2445,7 +2461,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "anyhow", "bytes", @@ -2744,9 +2760,9 @@ dependencies = [ [[package]] name = "inout" -version = "0.1.3" +version = "0.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a0c10553d664a4d0bcff9f4215d0aac67a639cc68ef660840afe309b807bc9f5" +checksum = "879f10e63c20629ecabbb64a8010319738c66a5cd0c29b02d63d272b03751d01" dependencies = [ "block-padding", "generic-array", @@ -3019,9 +3035,9 @@ dependencies = [ [[package]] name = "log" -version = "0.4.25" +version = "0.4.26" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "04cbf5b083de1c7e0222a7a51dbfdba1cbe1c6ab0b15e29fff3f6c077fd9cd9f" +checksum = "30bde2b3dc3671ae49d8e2e9f044c7c005836e7a023ee57cffa25ab82764bb9e" dependencies = [ "serde", "value-bag", @@ -3132,7 +3148,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "anyhow", "bindgen 0.70.1", @@ -3206,9 +3222,9 @@ checksum = "68354c5c6bd36d73ff3feceb05efa59b6acb7626617f4962be322a825e61f79a" [[package]] name = "miniz_oxide" -version = "0.8.4" +version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b3b1c9bd4fe1f0f8b387f6eb9eb3b4a1aa26185e5750efb9140301703f62cd1b" +checksum = "8e3e04debbb59698c15bacbb6d93584a8c0ca9cc3213cb423d31f760d8843ce5" dependencies = [ "adler2", "simd-adler32", @@ -3311,7 +3327,7 @@ checksum = "0676bb32a98c1a483ce53e500a81ad9c3d5b3f7c920c28c24e9cb0980d0b5bc8" [[package]] name = "nostr" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#54dbd855c1143f77b5341fd522eb5bcedc5ed5c3" +source = "git+https://github.com/rust-nostr/nostr#75d56eebc45d2160c54b8d134e845708496d266c" dependencies = [ "aes", "base64", @@ -3335,7 +3351,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#54dbd855c1143f77b5341fd522eb5bcedc5ed5c3" +source = "git+https://github.com/rust-nostr/nostr#75d56eebc45d2160c54b8d134e845708496d266c" dependencies = [ "async-utility", "nostr", @@ -3347,7 +3363,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#54dbd855c1143f77b5341fd522eb5bcedc5ed5c3" +source = "git+https://github.com/rust-nostr/nostr#75d56eebc45d2160c54b8d134e845708496d266c" dependencies = [ "flatbuffers", "lru", @@ -3358,7 +3374,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#54dbd855c1143f77b5341fd522eb5bcedc5ed5c3" +source = "git+https://github.com/rust-nostr/nostr#75d56eebc45d2160c54b8d134e845708496d266c" dependencies = [ "async-utility", "heed", @@ -3371,7 +3387,7 @@ dependencies = [ [[package]] name = "nostr-relay-pool" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#54dbd855c1143f77b5341fd522eb5bcedc5ed5c3" +source = "git+https://github.com/rust-nostr/nostr#75d56eebc45d2160c54b8d134e845708496d266c" dependencies = [ "async-utility", "async-wsocket", @@ -3388,7 +3404,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.39.0" -source = "git+https://github.com/rust-nostr/nostr#54dbd855c1143f77b5341fd522eb5bcedc5ed5c3" +source = "git+https://github.com/rust-nostr/nostr#75d56eebc45d2160c54b8d134e845708496d266c" dependencies = [ "async-utility", "nostr", @@ -3750,8 +3766,9 @@ checksum = "945462a4b81e43c4e3ba96bd7b49d834c6f61198356aa858733bc4acf3cbe62e" [[package]] name = "oneshot" -version = "0.1.10" -source = "git+https://github.com/faern/oneshot#d36ef86c3cbcc54764391ae89805c160696cf57c" +version = "0.1.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b4ce411919553d3f9fa53a0880544cda985a112117a0444d5ff1e870a893d6ea" [[package]] name = "oo7" @@ -4266,7 +4283,7 @@ checksum = "3779b94aeb87e8bd4e834cee3650289ee9e0d5677f976ecdb6d219e5f4f6cd94" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.1", - "zerocopy 0.8.18", + "zerocopy 0.8.20", ] [[package]] @@ -4305,7 +4322,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a88e0da7a2c97baa202165137c158d0a2e824ac465d13d81046727b34cb247d3" dependencies = [ "getrandom 0.3.1", - "zerocopy 0.8.18", + "zerocopy 0.8.20", ] [[package]] @@ -4431,9 +4448,9 @@ dependencies = [ [[package]] name = "redox_syscall" -version = "0.5.8" +version = "0.5.9" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "03a862b389f93e68874fbf580b9de08dd02facb9a788ebadaf4a3fd33cf58834" +checksum = "82b568323e98e49e2a0899dcee453dd679fae22d69adf9b11dd508d1549b7e2f" dependencies = [ "bitflags 2.8.0", ] @@ -4452,7 +4469,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "derive_refineable", ] @@ -4581,7 +4598,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "anyhow", "bytes", @@ -4620,9 +4637,9 @@ dependencies = [ [[package]] name = "ring" -version = "0.17.9" +version = "0.17.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e75ec5e92c4d8aede845126adc388046234541629e76029599ed35a003c7ed24" +checksum = "d34b5020fcdea098ef7d95e9f89ec15952123a4a039badd09fabebe9e963e839" dependencies = [ "cc", "cfg-if", @@ -4961,7 +4978,7 @@ checksum = "c2fdfc24bc566f839a2da4c4295b82db7d25a24253867d5c64355abb5799bdbe" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "anyhow", "serde", @@ -4975,18 +4992,18 @@ checksum = "f79dfe2d285b0488816f30e700a7438c5a73d816b5b7d3ac72fbc48b0d185e03" [[package]] name = "serde" -version = "1.0.217" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "02fc4265df13d6fa1d00ecff087228cc0a2b5f3c0e87e258d8b94a156e984c70" +checksum = "e8dfc9d19bdbf6d17e22319da49161d5d0108e4188e8b680aef6299eed22df60" dependencies = [ "serde_derive", ] [[package]] name = "serde_derive" -version = "1.0.217" +version = "1.0.218" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a9bf7cf98d04a2b28aead066b7496853d4779c9cc183c440dbac457641e19a0" +checksum = "f09503e191f4e797cb8aac08e9a4a4695c5edf6a2e70e376d961ddd5c969f82b" dependencies = [ "proc-macro2", "quote", @@ -5015,9 +5032,9 @@ dependencies = [ [[package]] name = "serde_json" -version = "1.0.138" +version = "1.0.139" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d434192e7da787e94a6ea7e9670b26a036d0ca41e0b7efb2676dd32bae872949" +checksum = "44f86c3acccc9c65b153fe1b85a3be07fe5515274ec9f0653b4a0875731c72a6" dependencies = [ "indexmap", "itoa", @@ -5285,7 +5302,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "arrayvec", "log", @@ -5978,9 +5995,9 @@ checksum = "1df77b101bcc4ea3d78dafc5ad7e4f58ceffe0b2b16bf446aeb50b6cb4157656" [[package]] name = "unicode-ident" -version = "1.0.16" +version = "1.0.17" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a210d160f08b701c8721ba1c726c11662f877ea6b7094007e1ca9a1041945034" +checksum = "00e2473a93778eb0bad35909dff6a10d28e63f792f16ed15e404fca9d5eeedbe" [[package]] name = "unicode-linebreak" @@ -6110,7 +6127,7 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#7a6b652ebcf9b7729b4ee69130bce0fbb529e6df" +source = "git+https://github.com/zed-industries/zed#83513bab59c9b387b80211f44f392ae9dc2bcad7" dependencies = [ "anyhow", "async-fs", @@ -6135,9 +6152,9 @@ dependencies = [ [[package]] name = "uuid" -version = "1.13.2" +version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c1f41ffb7cf259f1ecc2876861a17e7142e63ead296f671f81f6ae85903e0d6" +checksum = "93d59ca99a559661b96bf898d8fce28ed87935fd2bea9f05983c1464dd6c71b1" dependencies = [ "getrandom 0.3.1", "serde", @@ -6221,9 +6238,9 @@ dependencies = [ [[package]] name = "vswhom-sys" -version = "0.1.2" +version = "0.1.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3b17ae1f6c8a2b28506cd96d412eebf83b4a0ff2cbefeeb952f2f9dfa44ba18" +checksum = "fb067e4cbd1ff067d1df46c9194b5de0e98efd2810bbc95c5d5e5f25a3231150" dependencies = [ "cc", "libc", @@ -6825,9 +6842,9 @@ checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" [[package]] name = "winnow" -version = "0.7.2" +version = "0.7.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "59690dea168f2198d1a3b0cac23b8063efcd11012f10ae4698f284808c8ef603" +checksum = "0e7f4ea97f6f78012141bcdb6a216b2609f0979ada50b20ca5b52dde2eac2bb1" dependencies = [ "memchr", ] @@ -7091,11 +7108,11 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.18" +version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "79386d31a42a4996e3336b0919ddb90f81112af416270cff95b5f5af22b839c2" +checksum = "dde3bb8c68a8f3f1ed4ac9221aad6b10cece3e60a8e2ea54a6a2dec806d0084c" dependencies = [ - "zerocopy-derive 0.8.18", + "zerocopy-derive 0.8.20", ] [[package]] @@ -7111,9 +7128,9 @@ dependencies = [ [[package]] name = "zerocopy-derive" -version = "0.8.18" +version = "0.8.20" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "76331675d372f91bf8d17e13afbd5fe639200b73d01f0fc748bb059f9cca2db7" +checksum = "eea57037071898bf96a6da35fd626f4f27e9cee3ead2a6c703cf09d472b2e700" dependencies = [ "proc-macro2", "quote", diff --git a/Cargo.toml b/Cargo.toml index d73ae56..6c47027 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -23,7 +23,7 @@ nostr-sdk = { git = "https://github.com/rust-nostr/nostr", features = [ ] } smol = "2" -oneshot = { git = "https://github.com/faern/oneshot" } +oneshot = "0.1.10" serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" dirs = "5.0" @@ -32,7 +32,7 @@ futures = "0.3.30" chrono = "0.4.38" tracing = "0.1.40" anyhow = "1.0.44" -smallvec = "1.13.2" +smallvec = "1.14.0" rust-embed = "8.5.0" log = "0.4" diff --git a/crates/account/Cargo.toml b/crates/account/Cargo.toml new file mode 100644 index 0000000..f9f0b7a --- /dev/null +++ b/crates/account/Cargo.toml @@ -0,0 +1,16 @@ +[package] +name = "account" +version = "0.0.0" +edition = "2021" +publish = false + +[dependencies] +common = { path = "../common" } +state = { path = "../state" } + +gpui.workspace = true +nostr-sdk.workspace = true +anyhow.workspace = true +smol.workspace = true +oneshot.workspace = true +log.workspace = true diff --git a/crates/account/src/lib.rs b/crates/account/src/lib.rs new file mode 100644 index 0000000..d108990 --- /dev/null +++ b/crates/account/src/lib.rs @@ -0,0 +1 @@ +pub mod registry; diff --git a/crates/account/src/registry.rs b/crates/account/src/registry.rs new file mode 100644 index 0000000..24fd0a5 --- /dev/null +++ b/crates/account/src/registry.rs @@ -0,0 +1,117 @@ +use anyhow::anyhow; +use common::{ + constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID}, + profile::NostrProfile, +}; +use gpui::{App, AppContext, AsyncApp, Context, Entity, Global, Task}; +use nostr_sdk::prelude::*; +use state::get_client; +use std::{sync::Arc, time::Duration}; + +struct GlobalAccount(Entity); + +impl Global for GlobalAccount {} + +#[derive(Debug, Clone)] +pub struct Account { + profile: NostrProfile, +} + +impl Account { + pub fn global(cx: &App) -> Option> { + cx.try_global::() + .map(|model| model.0.clone()) + } + + pub fn set_global(account: Entity, cx: &mut App) { + cx.set_global(GlobalAccount(account)); + } + + pub fn login(signer: Arc, cx: &AsyncApp) -> Task> { + let client = get_client(); + let (tx, rx) = oneshot::channel::>(); + + cx.background_spawn(async move { + // Update nostr signer + _ = client.set_signer(signer).await; + // Verify nostr signer and get public key + let result = async { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + let metadata = client + .fetch_metadata(public_key, Duration::from_secs(2)) + .await + .ok() + .unwrap_or_default(); + + Ok::<_, anyhow::Error>(NostrProfile::new(public_key, metadata)) + } + .await; + + tx.send(result.ok()).ok(); + }) + .detach(); + + cx.spawn(|cx| async move { + if let Ok(Some(profile)) = rx.await { + cx.update(|cx| { + let this = cx.new(|cx| { + let this = Account { profile }; + // Run initial sync data for this account + if let Some(task) = this.sync(cx) { + task.detach(); + } + // Return + this + }); + + Self::set_global(this, cx) + }) + } else { + Err(anyhow!("Login failed")) + } + }) + } + + pub fn get(&self) -> &NostrProfile { + &self.profile + } + + fn sync(&self, cx: &mut Context) -> Option> { + let client = get_client(); + let public_key = self.profile.public_key(); + + let task = cx.background_spawn(async move { + // Set the default options for this task + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + + // Create a filter to get contact list + let contact_list = Filter::new() + .kind(Kind::ContactList) + .author(public_key) + .limit(1); + + if let Err(e) = client.subscribe(contact_list, Some(opts)).await { + log::error!("Failed to subscribe to contact list: {}", e); + } + + // Create a filter for getting all gift wrapped events send to current user + let msg = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); + let id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); + + if let Err(e) = client.subscribe_with_id(id, msg.clone(), Some(opts)).await { + log::error!("Failed to subscribe to all messages: {}", e); + } + + // Create a filter to continuously receive new messages. + let new_msg = msg.limit(0); + let id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); + + if let Err(e) = client.subscribe_with_id(id, new_msg, None).await { + log::error!("Failed to subscribe to new messages: {}", e); + } + }); + + Some(task) + } +} diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index be276eb..1c09690 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -13,6 +13,7 @@ ui = { path = "../ui" } common = { path = "../common" } state = { path = "../state" } chats = { path = "../chats" } +account = { path = "../account" } gpui.workspace = true reqwest_client.workspace = true diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 1a8feaa..6fea027 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -1,8 +1,7 @@ use asset::Assets; use chats::registry::ChatRegistry; -use common::{ - constants::{ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID}, - profile::NostrProfile, +use common::constants::{ + ALL_MESSAGES_SUB_ID, APP_ID, APP_NAME, KEYRING_SERVICE, NEW_MESSAGE_SUB_ID, }; use futures::{select, FutureExt}; use gpui::{ @@ -14,16 +13,16 @@ use gpui::{point, SharedString, TitlebarOptions}; #[cfg(target_os = "linux")] use gpui::{WindowBackgroundAppearance, WindowDecorations}; use log::{error, info}; +use nostr_sdk::SubscriptionId; use nostr_sdk::{ - pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, Metadata, PublicKey, - RelayMessage, RelayPoolNotification, SubscribeAutoCloseOptions, + pool::prelude::ReqExitPolicy, Client, Event, Filter, Keys, Kind, PublicKey, RelayMessage, + RelayPoolNotification, SubscribeAutoCloseOptions, }; -use nostr_sdk::{prelude::NostrEventsDatabaseExt, FromBech32, SubscriptionId}; use smol::Timer; use state::get_client; use std::{collections::HashSet, mem, sync::Arc, time::Duration}; use ui::{theme::Theme, Root}; -use views::{app, onboarding, startup}; +use views::{app, onboarding}; mod asset; mod views; @@ -45,7 +44,7 @@ fn main() { // Enable logging tracing_subscriber::fmt::init(); - let (event_tx, event_rx) = smol::channel::bounded::(2048); + let (event_tx, event_rx) = smol::channel::bounded::(1024); let (batch_tx, batch_rx) = smol::channel::bounded::>(100); // Initialize nostr client @@ -176,31 +175,17 @@ fn main() { // Handle re-open window app.on_reopen(move |cx| { let client = get_client(); - let (tx, rx) = oneshot::channel::>(); + let (tx, rx) = oneshot::channel::(); cx.background_spawn(async move { - if let Ok(signer) = client.signer().await { - if let Ok(public_key) = signer.get_public_key().await { - let metadata = - if let Ok(Some(metadata)) = client.database().metadata(public_key).await { - metadata - } else { - Metadata::new() - }; - - _ = tx.send(Some(NostrProfile::new(public_key, metadata))); - } else { - _ = tx.send(None); - } - } else { - _ = tx.send(None); - } + let is_login = client.signer().await.is_ok(); + _ = tx.send(is_login); }) .detach(); cx.spawn(|mut cx| async move { - if let Ok(result) = rx.await { - _ = restore_window(result, &mut cx).await; + if let Ok(is_login) = rx.await { + _ = restore_window(is_login, &mut cx).await; } }) .detach(); @@ -223,115 +208,90 @@ fn main() { items: vec![MenuItem::action("Quit", Quit)], }]); - // Open window with default options - cx.open_window( - WindowOptions { - #[cfg(not(target_os = "linux"))] - titlebar: Some(TitlebarOptions { - title: Some(SharedString::new_static(APP_NAME)), - traffic_light_position: Some(point(px(9.0), px(9.0))), - appears_transparent: true, - }), - window_bounds: Some(WindowBounds::Windowed(Bounds::centered( - None, - size(px(900.0), px(680.0)), - cx, - ))), - #[cfg(target_os = "linux")] - window_background: WindowBackgroundAppearance::Transparent, - #[cfg(target_os = "linux")] - window_decorations: Some(WindowDecorations::Client), - kind: WindowKind::Normal, - ..Default::default() - }, - |window, cx| { - window.set_window_title(APP_NAME); - window.set_app_id(APP_ID); - - #[cfg(not(target_os = "linux"))] - window - .observe_window_appearance(|window, cx| { - Theme::sync_system_appearance(Some(window), cx); - }) - .detach(); - - let handle = window.window_handle(); - let root = cx.new(|cx| Root::new(startup::init(window, cx).into(), window, cx)); - - let task = cx.read_credentials(KEYRING_SERVICE); - let (tx, rx) = oneshot::channel::>(); - - // Read credential in OS Keyring - cx.background_spawn(async { - let profile = if let Ok(Some((npub, secret))) = task.await { - let public_key = PublicKey::from_bech32(&npub).unwrap(); - let secret_hex = String::from_utf8(secret).unwrap(); - let keys = Keys::parse(&secret_hex).unwrap(); - - // Update nostr signer - _ = client.set_signer(keys).await; - - // Get user's metadata - let metadata = if let Ok(Some(metadata)) = - client.database().metadata(public_key).await - { - metadata - } else { - Metadata::new() + // Spawn a task to handle events from nostr channel + cx.spawn(|cx| async move { + while let Ok(signal) = event_rx.recv().await { + cx.update(|cx| { + if let Some(chats) = ChatRegistry::global(cx) { + match signal { + Signal::Eose => chats.update(cx, |this, cx| this.load_chat_rooms(cx)), + Signal::Event(event) => { + chats.update(cx, |this, cx| this.push_message(event, cx)) + } }; - - Some(NostrProfile::new(public_key, metadata)) - } else { - None - }; - - _ = tx.send(profile) - }) - .detach(); - - // Set root view based on credential status - cx.spawn(|mut cx| async move { - if let Ok(Some(profile)) = rx.await { - _ = cx.update_window(handle, |_, window, cx| { - window.replace_root(cx, |window, cx| { - Root::new(app::init(profile, window, cx).into(), window, cx) - }); - }); - } else { - _ = cx.update_window(handle, |_, window, cx| { - window.replace_root(cx, |window, cx| { - Root::new(onboarding::init(window, cx).into(), window, cx) - }); - }); } }) - .detach(); + .ok(); + } + }) + .detach(); - cx.spawn(|cx| async move { - while let Ok(signal) = event_rx.recv().await { - cx.update(|cx| { - match signal { - Signal::Eose => { - if let Some(chats) = ChatRegistry::global(cx) { - chats.update(cx, |this, cx| this.load_chat_rooms(cx)) - } - } - Signal::Event(event) => { - if let Some(chats) = ChatRegistry::global(cx) { - chats.update(cx, |this, cx| this.push_message(event, cx)) - } - } - }; - }) - .ok(); - } - }) - .detach(); + // Set up the window options + let window_opts = WindowOptions { + #[cfg(not(target_os = "linux"))] + titlebar: Some(TitlebarOptions { + title: Some(SharedString::new_static(APP_NAME)), + traffic_light_position: Some(point(px(9.0), px(9.0))), + appears_transparent: true, + }), + window_bounds: Some(WindowBounds::Windowed(Bounds::centered( + None, + size(px(900.0), px(680.0)), + cx, + ))), + #[cfg(target_os = "linux")] + window_background: WindowBackgroundAppearance::Transparent, + #[cfg(target_os = "linux")] + window_decorations: Some(WindowDecorations::Client), + kind: WindowKind::Normal, + ..Default::default() + }; - root - }, - ) - .expect("System error. Please re-open the app."); + // Create a task to read credentials from the keyring service + let task = cx.read_credentials(KEYRING_SERVICE); + let (tx, rx) = oneshot::channel::(); + + // Read credential in OS Keyring + cx.background_spawn(async { + let is_ready = if let Ok(Some((_, secret))) = task.await { + let result = async { + let secret_hex = String::from_utf8(secret)?; + let keys = Keys::parse(&secret_hex)?; + + // Update nostr signer + client.set_signer(keys).await; + + Ok::<_, anyhow::Error>(true) + } + .await; + + result.is_ok() + } else { + false + }; + + _ = tx.send(is_ready) + }) + .detach(); + + cx.spawn(|cx| async move { + if let Ok(is_ready) = rx.await { + if is_ready { + // Open a App window + cx.open_window(window_opts, |window, cx| { + cx.new(|cx| Root::new(app::init(window, cx).into(), window, cx)) + }) + .expect("Failed to open window"); + } else { + // Open a Onboarding window + cx.open_window(window_opts, |window, cx| { + cx.new(|cx| Root::new(onboarding::init(window, cx).into(), window, cx)) + }) + .expect("Failed to open window"); + } + } + }) + .detach(); }); } @@ -347,7 +307,7 @@ async fn sync_metadata(client: &Client, buffer: HashSet) { } } -async fn restore_window(profile: Option, cx: &mut AsyncApp) -> anyhow::Result<()> { +async fn restore_window(is_login: bool, cx: &mut AsyncApp) -> anyhow::Result<()> { let opts = cx .update(|cx| WindowOptions { #[cfg(not(target_os = "linux"))] @@ -370,7 +330,7 @@ async fn restore_window(profile: Option, cx: &mut AsyncApp) -> any }) .expect("Failed to set window options."); - if let Some(profile) = profile { + if is_login { _ = cx.open_window(opts, |window, cx| { window.set_window_title(APP_NAME); window.set_app_id(APP_ID); @@ -382,7 +342,7 @@ async fn restore_window(profile: Option, cx: &mut AsyncApp) -> any }) .detach(); - cx.new(|cx| Root::new(app::init(profile, window, cx).into(), window, cx)) + cx.new(|cx| Root::new(app::init(window, cx).into(), window, cx)) }); } else { _ = cx.open_window(opts, |window, cx| { diff --git a/crates/app/src/views/app.rs b/crates/app/src/views/app.rs index cc14833..631b040 100644 --- a/crates/app/src/views/app.rs +++ b/crates/app/src/views/app.rs @@ -1,4 +1,4 @@ -use common::profile::NostrProfile; +use account::registry::Account; use gpui::{ actions, div, img, impl_internal_actions, prelude::FluentBuilder, px, App, AppContext, Axis, Context, Entity, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, Styled, @@ -38,21 +38,22 @@ impl AddPanel { } } +// Dock actions impl_internal_actions!(dock, [AddPanel]); +// Account actions actions!(account, [Logout]); -pub fn init(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity { - AppView::new(account, window, cx) +pub fn init(window: &mut Window, cx: &mut App) -> Entity { + AppView::new(window, cx) } pub struct AppView { - account: NostrProfile, relays: Entity>>, dock: Entity, } impl AppView { - pub fn new(account: NostrProfile, window: &mut Window, cx: &mut App) -> Entity { + pub fn new(window: &mut Window, cx: &mut App) -> Entity { // Initialize dock layout let dock = cx.new(|cx| DockArea::new(window, cx)); let weak_dock = dock.downgrade(); @@ -83,76 +84,74 @@ impl AppView { }); cx.new(|cx| { - let public_key = account.public_key(); let relays = cx.new(|_| None); - let async_relays = relays.downgrade(); + let this = Self { relays, dock }; // Check user's messaging relays and determine user is ready for NIP17 or not. // If not, show the setup modal and instruct user setup inbox relays - let client = get_client(); - let window_handle = window.window_handle(); - let (tx, rx) = oneshot::channel::>>(); - - let this = Self { - account, - relays, - dock, - }; - - cx.background_spawn(async move { - let filter = Filter::new() - .kind(Kind::InboxRelays) - .author(public_key) - .limit(1); - - let relays = if let Ok(events) = client.database().query(filter).await { - if let Some(event) = events.first_owned() { - Some( - event - .tags - .filter_standardized(TagKind::Relay) - .filter_map(|t| match t { - TagStandard::Relay(url) => Some(url.to_string()), - - _ => None, - }) - .collect::>(), - ) - } else { - None - } - } else { - None - }; - - _ = tx.send(relays); - }) - .detach(); - - cx.spawn(|this, mut cx| async move { - if let Ok(result) = rx.await { - if let Some(relays) = result { - _ = cx.update(|cx| { - _ = async_relays.update(cx, |this, cx| { - *this = Some(relays); - cx.notify(); - }); - }); - } else { - _ = cx.update_window(window_handle, |_, window, cx| { - this.update(cx, |this: &mut Self, cx| { - this.render_setup_relays(window, cx) - }) - }); - } - } - }) - .detach(); + this.verify_user_relays(window, cx); this }) } + fn verify_user_relays(&self, window: &mut Window, cx: &mut Context) { + let Some(account) = Account::global(cx) else { + return; + }; + + let public_key = account.read(cx).get().public_key(); + let client = get_client(); + let window_handle = window.window_handle(); + let (tx, rx) = oneshot::channel::>>(); + + cx.background_spawn(async move { + let filter = Filter::new() + .kind(Kind::InboxRelays) + .author(public_key) + .limit(1); + + let relays = client + .database() + .query(filter) + .await + .ok() + .and_then(|events| events.first_owned()) + .map(|event| { + event + .tags + .filter_standardized(TagKind::Relay) + .filter_map(|t| match t { + TagStandard::Relay(url) => Some(url.to_string()), + _ => None, + }) + .collect::>() + }); + + _ = tx.send(relays); + }) + .detach(); + + cx.spawn(|this, mut cx| async move { + if let Ok(Some(relays)) = rx.await { + _ = cx.update(|cx| { + _ = this.update(cx, |this, cx| { + let relays = cx.new(|_| Some(relays)); + this.relays = relays; + cx.notify(); + }); + }); + } else { + _ = cx.update_window(window_handle, |_, window, cx| { + this.update(cx, |this: &mut Self, cx| { + this.render_setup_relays(window, cx) + }) + }); + } + }) + .detach(); + } + fn render_setup_relays(&self, window: &mut Window, cx: &mut Context) { let relays = cx.new(|cx| Relays::new(None, window, cx)); @@ -254,18 +253,22 @@ impl AppView { })) } - fn render_account(&self) -> impl IntoElement { + fn render_account(&self, cx: &mut Context) -> impl IntoElement { Button::new("account") .ghost() .xsmall() .reverse() .icon(Icon::new(IconName::ChevronDownSmall)) - .child( - img(self.account.avatar()) - .size_5() - .rounded_full() - .object_fit(ObjectFit::Cover), - ) + .when_some(Account::global(cx), |this, account| { + let profile = account.read(cx).get(); + + this.child( + img(profile.avatar()) + .size_5() + .rounded_full() + .object_fit(ObjectFit::Cover), + ) + }) .popup_menu(move |this, _, _cx| { this.menu( "Profile", @@ -286,16 +289,19 @@ impl AppView { fn on_panel_action(&mut self, action: &AddPanel, window: &mut Window, cx: &mut Context) { match &action.panel { - PanelKind::Room(id) => match chat::init(id, window, cx) { - Ok(panel) => { - self.dock.update(cx, |dock_area, cx| { - dock_area.add_panel(panel, action.position, window, cx); - }); + PanelKind::Room(id) => { + // User must be logged in to open a room + match chat::init(id, window, cx) { + Ok(panel) => { + self.dock.update(cx, |dock_area, cx| { + dock_area.add_panel(panel, action.position, window, cx); + }); + } + Err(e) => window.push_notification(e.to_string(), cx), } - Err(e) => window.push_notification(e.to_string(), cx), - }, + } PanelKind::Profile => { - let panel = Arc::new(profile::init(self.account.clone(), window, cx)); + let panel = profile::init(window, cx); self.dock.update(cx, |dock_area, cx| { dock_area.add_panel(panel, action.position, window, cx); @@ -319,8 +325,13 @@ impl AppView { } fn on_logout_action(&mut self, _action: &Logout, window: &mut Window, cx: &mut Context) { - cx.background_spawn(async move { get_client().reset().await }) - .detach(); + let client = get_client(); + + cx.background_spawn(async move { + // Reset nostr client + client.reset().await + }) + .detach(); window.replace_root(cx, |window, cx| { Root::new(onboarding::init(window, cx).into(), window, cx) @@ -353,7 +364,7 @@ impl Render for AppView { .px_2() .child(self.render_appearance_button(window, cx)) .child(self.render_relays_button(window, cx)) - .child(self.render_account()), + .child(self.render_account(cx)), ), ) .child(self.dock.clone()) diff --git a/crates/app/src/views/chat.rs b/crates/app/src/views/chat.rs index 25a4f36..6f62353 100644 --- a/crates/app/src/views/chat.rs +++ b/crates/app/src/views/chat.rs @@ -1,3 +1,4 @@ +use account::registry::Account; use anyhow::anyhow; use async_utility::task::spawn; use chats::{registry::ChatRegistry, room::Room}; @@ -58,14 +59,12 @@ struct ParsedMessage { impl ParsedMessage { pub fn new(profile: &NostrProfile, content: &str, created_at: Timestamp) -> Self { - let avatar = profile.avatar().into(); - let display_name = profile.name().into(); let content = SharedString::new(content); let created_at = LastSeen(created_at).human_readable(); Self { - avatar, - display_name, + avatar: profile.avatar(), + display_name: profile.name(), created_at, content, } @@ -96,13 +95,10 @@ impl Message { pub struct Chat { // Panel id: SharedString, - closable: bool, - zoomable: bool, focus_handle: FocusHandle, // Chat Room room: WeakEntity, messages: Entity>, - new_messages: Option>>, list_state: ListState, subscriptions: Vec, // New Message @@ -119,21 +115,16 @@ impl Chat { window: &mut Window, cx: &mut App, ) -> Entity { - let new_messages = room - .read_with(cx, |this, _| this.new_messages.downgrade()) - .ok(); + let messages = cx.new(|_| vec![Message::placeholder()]); + let attaches = cx.new(|_| None); + let input = cx.new(|cx| { + TextInput::new(window, cx) + .appearance(false) + .text_size(ui::Size::Small) + .placeholder("Message...") + }); cx.new(|cx| { - let messages = cx.new(|_| vec![Message::placeholder()]); - let attaches = cx.new(|_| None); - - let input = cx.new(|cx| { - TextInput::new(window, cx) - .appearance(false) - .text_size(ui::Size::Small) - .placeholder("Message...") - }); - let subscriptions = vec![cx.subscribe_in( &input, window, @@ -157,13 +148,10 @@ impl Chat { }); let mut this = Self { - closable: true, - zoomable: true, focus_handle: cx.focus_handle(), is_uploading: false, id: id.to_string().into(), room, - new_messages, messages, list_state, input, @@ -189,11 +177,16 @@ impl Chat { return; }; - let room = model.read(cx); - let pubkeys: Vec = room.members.iter().map(|m| m.public_key()).collect(); let client = get_client(); let (tx, rx) = oneshot::channel::>(); + let pubkeys: Vec = model + .read(cx) + .members + .iter() + .map(|m| m.public_key()) + .collect(); + cx.background_spawn(async move { let mut result = Vec::new(); @@ -224,7 +217,7 @@ impl Chat { if !item.1 { let name = this .room - .read_with(cx, |this, _| this.name()) + .read_with(cx, |this, _| this.name().unwrap_or("Unnamed".into())) .unwrap_or("Unnamed".into()); this.push_system_message( @@ -245,35 +238,25 @@ impl Chat { return; }; + let room = model.read(cx); let client = get_client(); let (tx, rx) = oneshot::channel::(); - let room = model.read(cx); let pubkeys = room .members .iter() .map(|m| m.public_key()) .collect::>(); - let recv = Filter::new() + let filter = Filter::new() .kind(Kind::PrivateDirectMessage) - .author(room.owner.public_key()) - .pubkeys(pubkeys.iter().copied()); - - let send = Filter::new() - .kind(Kind::PrivateDirectMessage) - .authors(pubkeys) - .pubkey(room.owner.public_key()); + .authors(pubkeys.iter().copied()) + .pubkeys(pubkeys); cx.background_spawn(async move { - let Ok(recv_events) = client.database().query(recv).await else { + let Ok(events) = client.database().query(filter).await else { return; }; - let Ok(send_events) = client.database().query(send).await else { - return; - }; - let events = recv_events.merge(send_events); - _ = tx.send(events); }) .detach(); @@ -303,13 +286,13 @@ impl Chat { } fn push_message(&self, content: String, window: &mut Window, cx: &mut Context) { - let Some(model) = self.room.upgrade() else { + let Some(account) = Account::global(cx) else { return; }; let old_len = self.messages.read(cx).len(); - let room = model.read(cx); - let message = Message::new(ParsedMessage::new(&room.owner, &content, Timestamp::now())); + let profile = account.read(cx).get(); + let message = Message::new(ParsedMessage::new(profile, &content, Timestamp::now())); // Update message list cx.update_entity(&self.messages, |this, cx| { @@ -333,39 +316,40 @@ impl Chat { return; }; - let old_len = self.messages.read(cx).len(); let room = model.read(cx); - let pubkeys = room.pubkeys(); + let pubkeys = room + .members + .iter() + .map(|m| m.public_key()) + .collect::>(); - let (messages, total) = { + let old_len = self.messages.read(cx).len(); + + let (messages, new_len) = { let items: Vec = events .into_iter() .sorted_by_key(|ev| ev.created_at) .filter_map(|ev| { - let mut other_pubkeys: Vec<_> = ev.tags.public_keys().copied().collect(); + let mut other_pubkeys = ev.tags.public_keys().copied().collect::>(); other_pubkeys.push(ev.pubkey); - if compare(&other_pubkeys, &pubkeys) { - let member = if let Some(member) = - room.members.iter().find(|&m| m.public_key() == ev.pubkey) - { - member.to_owned() - } else { - room.owner.to_owned() - }; - - let message = - Message::new(ParsedMessage::new(&member, &ev.content, ev.created_at)); - - Some(message) - } else { - None + if !compare(&other_pubkeys, &pubkeys) { + return None; } + + room.members + .iter() + .find(|m| m.public_key() == ev.pubkey) + .map(|member| { + Message::new(ParsedMessage::new(member, &ev.content, ev.created_at)) + }) }) .collect(); - let total = items.len(); - (items, total) + // Used for update list state + let new_len = items.len(); + + (items, new_len) }; cx.update_entity(&self.messages, |this, cx| { @@ -373,25 +357,27 @@ impl Chat { cx.notify(); }); - self.list_state.splice(old_len..old_len, total); + self.list_state.splice(old_len..old_len, new_len); } fn load_new_messages(&mut self, cx: &mut Context) { - let Some(Some(model)) = self.new_messages.as_ref().map(|state| state.upgrade()) else { + let Some(room) = self.room.upgrade() else { return; }; - let subscription = cx.observe(&model, |view, this, cx| { - let Some(model) = view.room.upgrade() else { + let subscription = cx.observe(&room, |view, this, cx| { + let room = this.read(cx); + + if room.new_messages.is_empty() { return; }; - let room = model.read(cx); let old_messages = view.messages.read(cx); let old_len = old_messages.len(); let items: Vec = this .read(cx) + .new_messages .iter() .filter_map(|event| { if let Some(profile) = room.member(&event.pubkey) { @@ -466,29 +452,33 @@ impl Chat { this.set_disabled(true, window, cx); }); + let room = model.read(cx); + // let subject = Tag::from_standardized_without_cell(TagStandard::Subject(room.title.clone())); + let pubkeys = room.public_keys(); + let async_content = content.clone().to_string(); + let client = get_client(); let window_handle = window.window_handle(); let (tx, rx) = oneshot::channel::>(); - let room = model.read(cx); - let pubkeys = room.pubkeys(); - let async_content = content.clone().to_string(); - let tags: Vec = room - .pubkeys() - .iter() - .filter_map(|pubkey| { - if pubkey != &room.owner.public_key() { - Some(Tag::public_key(*pubkey)) - } else { - None - } - }) - .collect(); - // Send message to all pubkeys cx.background_spawn(async move { + let signer = client.signer().await.unwrap(); + let public_key = signer.get_public_key().await.unwrap(); + let mut errors = Vec::new(); + let tags: Vec = pubkeys + .iter() + .filter_map(|pubkey| { + if pubkey != &public_key { + Some(Tag::public_key(*pubkey)) + } else { + None + } + }) + .collect(); + for pubkey in pubkeys.iter() { if let Err(e) = client .send_private_msg(*pubkey, &async_content, tags.clone()) @@ -709,9 +699,8 @@ impl Panel for Chat { fn title(&self, cx: &App) -> AnyElement { self.room - .read_with(cx, |this, _cx| { - let name = this.name(); - let facepill: Vec = + .read_with(cx, |this, _| { + let facepill: Vec = this.members.iter().map(|member| member.avatar()).collect(); div() @@ -733,20 +722,12 @@ impl Panel for Chat { ) })), ) - .child(name) + .when_some(this.name(), |this, name| this.child(name)) .into_any() }) .unwrap_or("Unnamed".into_any()) } - fn closable(&self, _cx: &App) -> bool { - self.closable - } - - fn zoomable(&self, _cx: &App) -> bool { - self.zoomable - } - fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu { menu.track_focus(&self.focus_handle) } diff --git a/crates/app/src/views/mod.rs b/crates/app/src/views/mod.rs index 9b520a5..db101aa 100644 --- a/crates/app/src/views/mod.rs +++ b/crates/app/src/views/mod.rs @@ -8,4 +8,3 @@ mod welcome; pub mod app; pub mod onboarding; -pub mod startup; diff --git a/crates/app/src/views/onboarding.rs b/crates/app/src/views/onboarding.rs index b2fb1da..c66e7bf 100644 --- a/crates/app/src/views/onboarding.rs +++ b/crates/app/src/views/onboarding.rs @@ -1,11 +1,11 @@ -use common::{profile::NostrProfile, qr::create_qr, utils::preload}; +use account::registry::Account; +use common::qr::create_qr; use gpui::{ div, img, prelude::FluentBuilder, relative, svg, App, AppContext, ClipboardItem, Context, Div, Entity, IntoElement, ParentElement, Render, Styled, Subscription, Window, }; use nostr_connect::prelude::*; -use state::get_client; -use std::{path::PathBuf, time::Duration}; +use std::{path::PathBuf, sync::Arc, time::Duration}; use ui::{ button::{Button, ButtonCustomVariant, ButtonVariants}, input::{InputEvent, TextInput}, @@ -16,8 +16,12 @@ use ui::{ use super::app; +const LOGO_URL: &str = "brand/coop.svg"; +const TITLE: &str = "Welcome to Coop!"; +const SUBTITLE: &str = "A Nostr client for secure communication."; const ALPHA_MESSAGE: &str = "Coop is in the alpha stage of development; It may contain bugs, unfinished features, or unexpected behavior."; + const JOIN_URL: &str = "https://start.njump.me/"; pub fn init(window: &mut Window, cx: &mut App) -> Entity { @@ -62,7 +66,7 @@ impl Onboarding { window, move |this: &mut Self, _, input_event, window, cx| { if let InputEvent::PressEnter = input_event { - this.privkey_login(window, cx); + this.login_with_private_key(window, cx); } }, )]; @@ -80,68 +84,50 @@ impl Onboarding { }) } - fn use_connect(&mut self, window: &mut Window, cx: &mut Context) { + fn login_with_nostr_connect(&mut self, window: &mut Window, cx: &mut Context) { let uri = self.connect_uri.clone(); let app_keys = self.app_keys.clone(); let window_handle = window.window_handle(); - self.use_connect = true; - cx.notify(); + // Show QR Code for login with Nostr Connect + self.use_connect(window, cx); - cx.spawn(|_, mut cx| async move { - let (tx, rx) = oneshot::channel::(); + // Wait for connection + let (tx, rx) = oneshot::channel::(); - cx.background_spawn(async move { - if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None) - { - if let Ok(uri) = signer.bunker_uri().await { - let client = get_client(); + cx.background_spawn(async move { + if let Ok(signer) = NostrConnect::new(uri, app_keys, Duration::from_secs(300), None) { + tx.send(signer).ok(); + } + }) + .detach(); - if let Some(public_key) = uri.remote_signer_public_key() { - let metadata = client - .fetch_metadata(*public_key, Duration::from_secs(2)) - .await - .ok() - .unwrap_or_default(); + cx.spawn(|this, cx| async move { + if let Ok(signer) = rx.await { + cx.spawn(|mut cx| async move { + let signer = Arc::new(signer); - if tx.send(NostrProfile::new(*public_key, metadata)).is_ok() { - _ = client.set_signer(signer).await; - _ = preload(client, *public_key).await; - } - } + if Account::login(signer, &cx).await.is_ok() { + _ = cx.update_window(window_handle, |_, window, cx| { + window.replace_root(cx, |window, cx| { + Root::new(app::init(window, cx).into(), window, cx) + }); + }) } - } - }) - .detach(); - - if let Ok(profile) = rx.await { - _ = cx.update_window(window_handle, |_, window, cx| { - window.replace_root(cx, |window, cx| { - Root::new(app::init(profile, window, cx).into(), window, cx) - }); }) + .detach(); + } else { + _ = cx.update(|cx| { + _ = this.update(cx, |this, cx| { + this.set_loading(false, cx); + }); + }); } }) .detach(); } - fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context) { - self.use_privkey = true; - cx.notify(); - } - - fn reset(&mut self, _window: &mut Window, cx: &mut Context) { - self.use_privkey = false; - self.use_connect = false; - cx.notify(); - } - - fn set_loading(&mut self, status: bool, cx: &mut Context) { - self.is_loading = status; - cx.notify(); - } - - fn privkey_login(&mut self, window: &mut Window, cx: &mut Context) { + fn login_with_private_key(&mut self, window: &mut Window, cx: &mut Context) { let value = self.nsec_input.read(cx).text().to_string(); let window_handle = window.window_handle(); @@ -160,37 +146,47 @@ impl Onboarding { // Show loading spinner self.set_loading(true, cx); - cx.spawn(|_, mut cx| async move { - let client = get_client(); - let (tx, rx) = oneshot::channel::(); + cx.spawn(|this, mut cx| async move { + let signer = Arc::new(keys); - cx.background_spawn(async move { - if let Ok(public_key) = keys.get_public_key().await { - let metadata = client - .fetch_metadata(public_key, Duration::from_secs(2)) - .await - .ok() - .unwrap_or_default(); - - if tx.send(NostrProfile::new(public_key, metadata)).is_ok() { - _ = client.set_signer(keys).await; - _ = preload(client, public_key).await; - } - } - }) - .detach(); - - if let Ok(profile) = rx.await { + if Account::login(signer, &cx).await.is_ok() { _ = cx.update_window(window_handle, |_, window, cx| { window.replace_root(cx, |window, cx| { - Root::new(app::init(profile, window, cx).into(), window, cx) + Root::new(app::init(window, cx).into(), window, cx) }); }) + } else { + _ = cx.update(|cx| { + _ = this.update(cx, |this, cx| { + this.set_loading(false, cx); + }); + }); } }) .detach(); } + fn use_connect(&mut self, _window: &mut Window, cx: &mut Context) { + self.use_connect = true; + cx.notify(); + } + + fn use_privkey(&mut self, _window: &mut Window, cx: &mut Context) { + self.use_privkey = true; + cx.notify(); + } + + fn reset(&mut self, _window: &mut Window, cx: &mut Context) { + self.use_privkey = false; + self.use_connect = false; + cx.notify(); + } + + fn set_loading(&mut self, status: bool, cx: &mut Context) { + self.is_loading = status; + cx.notify(); + } + fn render_selection(&self, window: &mut Window, cx: &mut Context) -> Div { div() .w_full() @@ -205,7 +201,7 @@ impl Onboarding { .primary() .w_full() .on_click(cx.listener(move |this, _, window, cx| { - this.use_connect(window, cx); + this.login_with_nostr_connect(window, cx); })), ) .child( @@ -331,7 +327,7 @@ impl Onboarding { .w_full() .loading(self.is_loading) .on_click(cx.listener(move |this, _, window, cx| { - this.privkey_login(window, cx); + this.login_with_private_key(window, cx); })), ) .child( @@ -368,7 +364,7 @@ impl Render for Onboarding { .gap_4() .child( svg() - .path("brand/coop.svg") + .path(LOGO_URL) .size_12() .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), ) @@ -380,7 +376,7 @@ impl Render for Onboarding { .text_lg() .font_semibold() .line_height(relative(1.2)) - .child("Welcome to Coop!"), + .child(TITLE), ) .child( div() @@ -388,19 +384,19 @@ impl Render for Onboarding { .text_color( cx.theme().base.step(cx, ColorScaleStep::ELEVEN), ) - .child("A Nostr client for secure communication."), + .child(SUBTITLE), ), ), ) - .child(div().w_72().map(|_| { - if self.use_privkey { - self.render_privkey_login(cx) - } else if self.use_connect { - self.render_connect_login(cx) - } else { - self.render_selection(window, cx) - } - })), + .child( + div() + .w_72() + .map(|_| match (self.use_privkey, self.use_connect) { + (true, _) => self.render_privkey_login(cx), + (_, true) => self.render_connect_login(cx), + _ => self.render_selection(window, cx), + }), + ), ) .child( div() @@ -411,8 +407,8 @@ impl Render for Onboarding { .items_center() .justify_center() .text_xs() + .text_center() .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) - .text_align(gpui::TextAlign::Center) .child(ALPHA_MESSAGE), ) } diff --git a/crates/app/src/views/profile.rs b/crates/app/src/views/profile.rs index 3c47b37..357c411 100644 --- a/crates/app/src/views/profile.rs +++ b/crates/app/src/views/profile.rs @@ -1,5 +1,5 @@ use async_utility::task::spawn; -use common::{constants::IMAGE_SERVICE, profile::NostrProfile, utils::nip96_upload}; +use common::{constants::IMAGE_SERVICE, utils::nip96_upload}; use gpui::{ div, img, prelude::FluentBuilder, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, FocusHandle, Focusable, IntoElement, ParentElement, PathPromptOptions, Render, @@ -8,7 +8,7 @@ use gpui::{ use nostr_sdk::prelude::*; use smol::fs; use state::get_client; -use std::str::FromStr; +use std::{str::FromStr, sync::Arc, time::Duration}; use ui::{ button::{Button, ButtonVariants}, dock_area::panel::{Panel, PanelEvent}, @@ -17,12 +17,12 @@ use ui::{ ContextModal, Disableable, Sizable, Size, }; -pub fn init(profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity { - Profile::new(profile, window, cx) +pub fn init(window: &mut Window, cx: &mut App) -> Arc> { + Arc::new(Profile::new(window, cx)) } pub struct Profile { - profile: NostrProfile, + profile: Option, // Form name_input: Entity, avatar_input: Entity, @@ -32,60 +32,108 @@ pub struct Profile { is_submitting: bool, // Panel name: SharedString, - closable: bool, - zoomable: bool, focus_handle: FocusHandle, } impl Profile { - pub fn new(mut profile: NostrProfile, window: &mut Window, cx: &mut App) -> Entity { + pub fn new(window: &mut Window, cx: &mut App) -> Entity { + let window_handle = window.window_handle(); + let name_input = cx.new(|cx| { - let mut input = TextInput::new(window, cx).text_size(Size::XSmall); - if let Some(name) = profile.metadata().display_name.as_ref() { - input.set_text(name, window, cx); - } - input - }); - let avatar_input = cx.new(|cx| { - let mut input = TextInput::new(window, cx).text_size(Size::XSmall).small(); - if let Some(picture) = profile.metadata().picture.as_ref() { - input.set_text(picture, window, cx); - } - input - }); - let bio_input = cx.new(|cx| { - let mut input = TextInput::new(window, cx) + TextInput::new(window, cx) .text_size(Size::XSmall) - .multi_line(); - if let Some(about) = profile.metadata().about.as_ref() { - input.set_text(about, window, cx); - } else { - input.set_placeholder("A short introduce about you."); - } - input - }); - let website_input = cx.new(|cx| { - let mut input = TextInput::new(window, cx).text_size(Size::XSmall); - if let Some(website) = profile.metadata().website.as_ref() { - input.set_text(website, window, cx); - } else { - input.set_placeholder("https://your-website.com"); - } - input + .placeholder("Alice") }); - cx.new(|cx| Self { - profile, - name_input, - avatar_input, - bio_input, - website_input, - is_loading: false, - is_submitting: false, - name: "Profile".into(), - closable: true, - zoomable: true, - focus_handle: cx.focus_handle(), + let avatar_input = cx.new(|cx| { + TextInput::new(window, cx) + .text_size(Size::XSmall) + .small() + .placeholder("https://example.com/avatar.png") + }); + + let website_input = cx.new(|cx| { + TextInput::new(window, cx) + .text_size(Size::XSmall) + .placeholder("https://your-website.com") + }); + + let bio_input = cx.new(|cx| { + TextInput::new(window, cx) + .text_size(Size::XSmall) + .multi_line() + .placeholder("A short introduce about you.") + }); + + cx.new(|cx| { + let this = Self { + name_input, + avatar_input, + bio_input, + website_input, + profile: None, + is_loading: false, + is_submitting: false, + name: "Profile".into(), + focus_handle: cx.focus_handle(), + }; + + let client = get_client(); + let (tx, rx) = oneshot::channel::>(); + + cx.background_spawn(async move { + let result = async { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + let metadata = client + .fetch_metadata(public_key, Duration::from_secs(2)) + .await?; + + Ok::<_, anyhow::Error>(metadata) + } + .await; + + if let Ok(metadata) = result { + _ = tx.send(Some(metadata)); + } else { + _ = tx.send(None); + }; + }) + .detach(); + + cx.spawn(|this, mut cx| async move { + if let Ok(Some(metadata)) = rx.await { + _ = cx.update_window(window_handle, |_, window, cx| { + _ = this.update(cx, |this: &mut Profile, cx| { + this.avatar_input.update(cx, |this, cx| { + if let Some(avatar) = metadata.picture.as_ref() { + this.set_text(avatar, window, cx); + } + }); + this.bio_input.update(cx, |this, cx| { + if let Some(bio) = metadata.about.as_ref() { + this.set_text(bio, window, cx); + } + }); + this.name_input.update(cx, |this, cx| { + if let Some(display_name) = metadata.display_name.as_ref() { + this.set_text(display_name, window, cx); + } + }); + this.website_input.update(cx, |this, cx| { + if let Some(website) = metadata.website.as_ref() { + this.set_text(website, window, cx); + } + }); + this.profile = Some(metadata); + cx.notify(); + }); + }); + } + }) + .detach(); + + this }) } @@ -164,12 +212,13 @@ impl Profile { let bio = self.bio_input.read(cx).text().to_string(); let website = self.website_input.read(cx).text().to_string(); - let mut new_metadata = self - .profile - .metadata() - .to_owned() - .display_name(name) - .about(bio); + let old_metadata = if let Some(metadata) = self.profile.as_ref() { + metadata.clone() + } else { + Metadata::default() + }; + + let mut new_metadata = old_metadata.display_name(name).about(bio); if let Ok(url) = Url::from_str(&avatar) { new_metadata = new_metadata.picture(url); @@ -221,14 +270,6 @@ impl Panel for Profile { self.name.clone().into_any_element() } - fn closable(&self, _cx: &App) -> bool { - self.closable - } - - fn zoomable(&self, _cx: &App) -> bool { - self.zoomable - } - fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu { menu.track_focus(&self.focus_handle) } diff --git a/crates/app/src/views/sidebar/compose.rs b/crates/app/src/views/sidebar/compose.rs index 4a36f2e..71e9bb8 100644 --- a/crates/app/src/views/sidebar/compose.rs +++ b/crates/app/src/views/sidebar/compose.rs @@ -1,9 +1,6 @@ use async_utility::task::spawn; use chats::{registry::ChatRegistry, room::Room}; -use common::{ - profile::NostrProfile, - utils::{random_name, signer_public_key}, -}; +use common::{profile::NostrProfile, utils::random_name}; use gpui::{ div, img, impl_internal_actions, prelude::FluentBuilder, px, relative, uniform_list, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, @@ -86,15 +83,16 @@ impl Compose { let (tx, rx) = oneshot::channel::>(); cx.background_spawn(async move { - if let Ok(public_key) = signer_public_key(client).await { - if let Ok(profiles) = client.database().contacts(public_key).await { - let members: Vec = profiles - .into_iter() - .map(|profile| NostrProfile::new(profile.public_key(), profile.metadata())) - .collect(); + let signer = client.signer().await.unwrap(); + let public_key = signer.get_public_key().await.unwrap(); - _ = tx.send(members); - } + if let Ok(profiles) = client.database().contacts(public_key).await { + let members: Vec = profiles + .into_iter() + .map(|profile| NostrProfile::new(profile.public_key(), profile.metadata())) + .collect(); + + _ = tx.send(members); } }) .detach(); @@ -178,17 +176,19 @@ impl Compose { }); if let Some(chats) = ChatRegistry::global(cx) { - let room = Room::parse(&event, cx); + let room = Room::new(&event, cx); - chats.update(cx, |state, cx| match state.new_room(room, cx) { - Ok(_) => { - // TODO: open chat panel - window.close_modal(cx); - } - Err(e) => { - _ = this.update(cx, |this, cx| { - this.set_error(Some(e.to_string().into()), cx); - }); + chats.update(cx, |state, cx| { + match state.push_room(room, cx) { + Ok(_) => { + // TODO: open chat panel + window.close_modal(cx); + } + Err(e) => { + _ = this.update(cx, |this, cx| { + this.set_error(Some(e.to_string().into()), cx); + }); + } } }); } diff --git a/crates/app/src/views/sidebar/inbox.rs b/crates/app/src/views/sidebar/inbox.rs deleted file mode 100644 index cb72fd7..0000000 --- a/crates/app/src/views/sidebar/inbox.rs +++ /dev/null @@ -1,182 +0,0 @@ -use crate::views::app::{AddPanel, PanelKind}; -use chats::registry::ChatRegistry; -use gpui::{ - div, img, percentage, prelude::FluentBuilder, px, relative, Context, InteractiveElement, - IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, - TextAlign, Window, -}; -use ui::{ - dock_area::dock::DockPlacement, - skeleton::Skeleton, - theme::{scale::ColorScaleStep, ActiveTheme}, - v_flex, Collapsible, Icon, IconName, StyledExt, -}; - -pub struct Inbox { - label: SharedString, - is_collapsed: bool, -} - -impl Inbox { - pub fn new(_window: &mut Window, _cx: &mut Context<'_, Self>) -> Self { - Self { - label: "Inbox".into(), - is_collapsed: false, - } - } - - fn render_skeleton(&self, total: i32) -> impl IntoIterator { - (0..total).map(|_| { - div() - .h_8() - .px_1() - .flex() - .items_center() - .gap_2() - .child(Skeleton::new().flex_shrink_0().size_6().rounded_full()) - .child(Skeleton::new().w_20().h_3().rounded_sm()) - }) - } - - fn render_item(&self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - if let Some(chats) = ChatRegistry::global(cx) { - div().map(|this| { - let state = chats.read(cx); - let rooms = state.rooms(); - - if state.is_loading() { - this.children(self.render_skeleton(5)) - } else if rooms.is_empty() { - this.px_1() - .w_full() - .h_20() - .flex() - .flex_col() - .items_center() - .justify_center() - .text_align(TextAlign::Center) - .rounded(px(cx.theme().radius)) - .bg(cx.theme().base.step(cx, ColorScaleStep::THREE)) - .child( - div() - .text_xs() - .font_semibold() - .line_height(relative(1.2)) - .child("No chats"), - ) - .child( - div() - .text_xs() - .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) - .child("Recent chats will appear here."), - ) - } else { - this.children(rooms.iter().map(|model| { - let room = model.read(cx); - let room_id: SharedString = room.id.to_string().into(); - - div() - .id(room_id) - .h_8() - .px_1() - .flex() - .items_center() - .justify_between() - .text_xs() - .rounded(px(cx.theme().radius)) - .hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR))) - .child(div().flex_1().truncate().font_medium().map(|this| { - if room.is_group { - this.flex() - .items_center() - .gap_2() - .child(img("brand/avatar.png").size_6().rounded_full()) - .child(room.name()) - } else { - this.when_some(room.members.first(), |this, sender| { - this.flex() - .items_center() - .gap_2() - .child( - img(sender.avatar()) - .size_6() - .rounded_full() - .flex_shrink_0(), - ) - .child(sender.name()) - }) - } - })) - .child( - div() - .flex_shrink_0() - .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) - .child(room.last_seen.ago()), - ) - .on_click({ - let id = room.id; - cx.listener(move |this, _, window, cx| { - this.action(id, window, cx); - }) - }) - })) - } - }) - } else { - div().children(self.render_skeleton(5)) - } - } - - fn action(&self, id: u64, window: &mut Window, cx: &mut Context) { - window.dispatch_action( - Box::new(AddPanel::new(PanelKind::Room(id), DockPlacement::Center)), - cx, - ); - } -} - -impl Collapsible for Inbox { - fn collapsed(mut self, collapsed: bool) -> Self { - self.is_collapsed = collapsed; - self - } - - fn is_collapsed(&self) -> bool { - self.is_collapsed - } -} - -impl Render for Inbox { - fn render(&mut self, window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .px_2() - .gap_1() - .child( - div() - .id("inbox") - .h_7() - .px_1() - .flex() - .items_center() - .rounded(px(cx.theme().radius)) - .text_xs() - .font_semibold() - .child( - Icon::new(IconName::ChevronDown) - .size_6() - .when(self.is_collapsed, |this| { - this.rotate(percentage(270. / 360.)) - }), - ) - .child(self.label.clone()) - .hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))) - .on_click(cx.listener(move |view, _event, _window, cx| { - view.is_collapsed = !view.is_collapsed; - cx.notify(); - })), - ) - .when(!self.is_collapsed, |this| { - this.child(self.render_item(window, cx)) - }) - } -} diff --git a/crates/app/src/views/sidebar/mod.rs b/crates/app/src/views/sidebar/mod.rs index de7cdfc..78d753b 100644 --- a/crates/app/src/views/sidebar/mod.rs +++ b/crates/app/src/views/sidebar/mod.rs @@ -1,33 +1,32 @@ -use crate::views::sidebar::inbox::Inbox; +use chats::{registry::ChatRegistry, room::Room}; use compose::Compose; use gpui::{ - div, px, AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, - InteractiveElement, IntoElement, ParentElement, Render, SharedString, - StatefulInteractiveElement, Styled, Window, + div, img, percentage, prelude::FluentBuilder, px, uniform_list, AnyElement, App, AppContext, + Context, Div, Empty, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, + IntoElement, ParentElement, Render, SharedString, Stateful, StatefulInteractiveElement, Styled, + Window, }; use ui::{ button::{Button, ButtonRounded, ButtonVariants}, dock_area::panel::{Panel, PanelEvent}, popup_menu::PopupMenu, theme::{scale::ColorScaleStep, ActiveTheme}, - v_flex, ContextModal, Disableable, Icon, IconName, Sizable, StyledExt, + ContextModal, Disableable, Icon, IconName, Sizable, StyledExt, }; +use super::app::AddPanel; + mod compose; -mod inbox; pub fn init(window: &mut Window, cx: &mut App) -> Entity { Sidebar::new(window, cx) } pub struct Sidebar { - // Panel name: SharedString, - closable: bool, - zoomable: bool, focus_handle: FocusHandle, - // Dock - inbox: Entity, + label: SharedString, + is_collapsed: bool, } impl Sidebar { @@ -35,19 +34,19 @@ impl Sidebar { cx.new(|cx| Self::view(window, cx)) } - fn view(window: &mut Window, cx: &mut Context) -> Self { - let inbox = cx.new(|cx| Inbox::new(window, cx)); + fn view(_window: &mut Window, cx: &mut Context) -> Self { + let focus_handle = cx.focus_handle(); + let label = SharedString::from("Inbox"); Self { name: "Sidebar".into(), - closable: true, - zoomable: true, - focus_handle: cx.focus_handle(), - inbox, + is_collapsed: false, + focus_handle, + label, } } - fn show_compose(&mut self, window: &mut Window, cx: &mut Context) { + fn render_compose(&mut self, window: &mut Window, cx: &mut Context) { let compose = cx.new(|cx| Compose::new(window, cx)); window.open_modal(cx, move |modal, window, cx| { @@ -79,6 +78,73 @@ impl Sidebar { ) }) } + + fn render_room(&self, ix: usize, room: &Entity, cx: &Context) -> Stateful
{ + let room = room.read(cx); + + div() + .id(ix) + .px_1() + .h_8() + .w_full() + .flex() + .items_center() + .justify_between() + .text_xs() + .rounded(px(cx.theme().radius)) + .hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::FOUR))) + .child(div().flex_1().truncate().font_medium().map(|this| { + if room.is_group() { + this.flex() + .items_center() + .gap_2() + .child( + div() + .flex() + .justify_center() + .items_center() + .size_6() + .rounded_full() + .bg(cx.theme().accent.step(cx, ColorScaleStep::THREE)) + .child(Icon::new(IconName::GroupFill).size_3().text_color( + cx.theme().accent.step(cx, ColorScaleStep::TWELVE), + )), + ) + .when_some(room.name(), |this, name| this.child(name)) + } else { + this.when_some(room.first_member(), |this, member| { + this.flex() + .items_center() + .gap_2() + .child(img(member.avatar()).size_6().rounded_full().flex_shrink_0()) + .child(member.name()) + }) + } + })) + .child( + div() + .flex_shrink_0() + .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) + .child(room.ago()), + ) + .on_click({ + let id = room.id; + + cx.listener(move |this, _, window, cx| { + this.open(id, window, cx); + }) + }) + } + + fn open(&self, id: u64, window: &mut Window, cx: &mut Context) { + window.dispatch_action( + Box::new(AddPanel::new( + super::app::PanelKind::Room(id), + ui::dock_area::dock::DockPlacement::Center, + )), + cx, + ); + } } impl Panel for Sidebar { @@ -90,14 +156,6 @@ impl Panel for Sidebar { self.name.clone().into_any_element() } - fn closable(&self, _cx: &App) -> bool { - self.closable - } - - fn zoomable(&self, _cx: &App) -> bool { - self.zoomable - } - fn popup_menu(&self, menu: PopupMenu, _cx: &App) -> PopupMenu { menu.track_focus(&self.focus_handle) } @@ -117,41 +175,116 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - v_flex() - .w_full() - .py_3() - .gap_3() + let entity = cx.entity(); + + div() + .flex() + .flex_col() + .size_full() .child( - v_flex().px_2().gap_1().child( - div() - .id("new") - .flex() - .items_center() - .gap_2() - .px_1() - .h_7() - .text_xs() - .font_semibold() - .rounded(px(cx.theme().radius)) - .child( - div() - .size_6() - .flex() - .items_center() - .justify_center() - .rounded_full() - .bg(cx.theme().accent.step(cx, ColorScaleStep::NINE)) - .child( - Icon::new(IconName::ComposeFill) - .small() - .text_color(cx.theme().base.darken(cx)), - ), - ) - .child("New Message") - .hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))) - .on_click(cx.listener(|this, _, window, cx| this.show_compose(window, cx))), - ), + div() + .px_2() + .py_3() + .w_full() + .flex_shrink_0() + .flex() + .flex_col() + .gap_1() + .child( + div() + .id("new_message") + .flex() + .items_center() + .gap_2() + .px_1() + .h_7() + .text_xs() + .font_semibold() + .rounded(px(cx.theme().radius)) + .child( + div() + .size_6() + .flex() + .items_center() + .justify_center() + .rounded_full() + .bg(cx.theme().accent.step(cx, ColorScaleStep::NINE)) + .child( + Icon::new(IconName::ComposeFill) + .small() + .text_color(cx.theme().base.darken(cx)), + ), + ) + .child("New Message") + .hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))) + .on_click(cx.listener(|this, _, window, cx| { + // Open compose modal + this.render_compose(window, cx); + })), + ) + .child(Empty), + ) + .child( + div() + .px_2() + .w_full() + .flex_1() + .flex() + .flex_col() + .gap_1() + .child( + div() + .id("inbox_header") + .px_1() + .h_7() + .flex() + .items_center() + .flex_shrink_0() + .rounded(px(cx.theme().radius)) + .text_xs() + .font_semibold() + .child( + Icon::new(IconName::ChevronDown) + .size_6() + .when(self.is_collapsed, |this| { + this.rotate(percentage(270. / 360.)) + }), + ) + .child(self.label.clone()) + .hover(|this| this.bg(cx.theme().base.step(cx, ColorScaleStep::THREE))) + .on_click(cx.listener(move |view, _event, _window, cx| { + view.is_collapsed = !view.is_collapsed; + cx.notify(); + })), + ) + .when(!self.is_collapsed, |this| { + this.flex_1() + .w_full() + .when_some(ChatRegistry::global(cx), |this, state| { + let rooms = state.read(cx).rooms(); + let len = rooms.len(); + + this.child( + uniform_list( + entity, + "rooms", + len, + move |this, range, _, cx| { + let mut items = vec![]; + + for ix in range { + if let Some(room) = rooms.get(ix) { + items.push(this.render_room(ix, room, cx)); + } + } + + items + }, + ) + .size_full(), + ) + }) + }), ) - .child(self.inbox.clone()) } } diff --git a/crates/app/src/views/startup.rs b/crates/app/src/views/startup.rs deleted file mode 100644 index b36360e..0000000 --- a/crates/app/src/views/startup.rs +++ /dev/null @@ -1,32 +0,0 @@ -use gpui::{ - div, svg, App, AppContext, Context, Entity, IntoElement, ParentElement, Render, Styled, Window, -}; -use ui::theme::{scale::ColorScaleStep, ActiveTheme}; - -pub fn init(window: &mut Window, cx: &mut App) -> Entity { - Startup::new(window, cx) -} - -pub struct Startup {} - -impl Startup { - pub fn new(_window: &mut Window, cx: &mut App) -> Entity { - cx.new(|_| Self {}) - } -} - -impl Render for Startup { - fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - div() - .size_full() - .flex() - .items_center() - .justify_center() - .child( - svg() - .path("brand/coop.svg") - .size_12() - .text_color(cx.theme().base.step(cx, ColorScaleStep::THREE)), - ) - } -} diff --git a/crates/chats/Cargo.toml b/crates/chats/Cargo.toml index 3701505..9e5cc19 100644 --- a/crates/chats/Cargo.toml +++ b/crates/chats/Cargo.toml @@ -13,5 +13,6 @@ nostr-sdk.workspace = true anyhow.workspace = true itertools.workspace = true chrono.workspace = true +smallvec.workspace = true smol.workspace = true oneshot.workspace = true diff --git a/crates/chats/src/registry.rs b/crates/chats/src/registry.rs index 164a0e4..ddf1771 100644 --- a/crates/chats/src/registry.rs +++ b/crates/chats/src/registry.rs @@ -1,12 +1,11 @@ +use crate::room::Room; use anyhow::anyhow; -use common::utils::{compare, room_hash, signer_public_key}; +use common::{last_seen::LastSeen, utils::room_hash}; use gpui::{App, AppContext, Context, Entity, Global, WeakEntity}; use itertools::Itertools; use nostr_sdk::prelude::*; use state::get_client; -use std::cmp::Reverse; - -use crate::room::Room; +use std::{cmp::Reverse, rc::Rc, sync::RwLock}; pub fn init(cx: &mut App) { ChatRegistry::register(cx); @@ -17,7 +16,7 @@ struct GlobalChatRegistry(Entity); impl Global for GlobalChatRegistry {} pub struct ChatRegistry { - rooms: Vec>, + rooms: Rc>>>, is_loading: bool, } @@ -40,83 +39,67 @@ impl ChatRegistry { // Set global state cx.set_global(GlobalChatRegistry(entity.clone())); - // Observe and load metadata for any new rooms - cx.observe_new::(|this, _window, cx| { - let client = get_client(); - let pubkeys = this.pubkeys(); - let (tx, rx) = oneshot::channel::>(); - - cx.background_spawn(async move { - let mut profiles = Vec::new(); - - for public_key in pubkeys.into_iter() { - if let Ok(metadata) = client.database().metadata(public_key).await { - profiles.push((public_key, metadata.unwrap_or_default())); - } - } - - _ = tx.send(profiles); - }) - .detach(); - - cx.spawn(|this, mut cx| async move { - if let Ok(profiles) = rx.await { - if let Some(room) = this.upgrade() { - _ = cx.update_entity(&room, |this, cx| { - for profile in profiles.into_iter() { - this.set_metadata(profile.0, profile.1); - } - cx.notify(); - }); - } - } - }) - .detach(); - }) - .detach(); - entity }) } fn new(_cx: &mut Context) -> Self { Self { - rooms: vec![], + rooms: Rc::new(RwLock::new(vec![])), is_loading: true, } } pub fn current_rooms_ids(&self, cx: &mut Context) -> Vec { - self.rooms.iter().map(|room| room.read(cx).id).collect() + self.rooms + .read() + .unwrap() + .iter() + .map(|room| room.read(cx).id) + .collect() } pub fn load_chat_rooms(&mut self, cx: &mut Context) { let client = get_client(); - let (tx, rx) = oneshot::channel::>(); + let (tx, rx) = oneshot::channel::>>(); cx.background_spawn(async move { - if let Ok(public_key) = signer_public_key(client).await { - let filter = Filter::new() + let result = async { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + let send = Filter::new() .kind(Kind::PrivateDirectMessage) .author(public_key); - // Get all DM events from database - if let Ok(events) = client.database().query(filter).await { - let result: Vec = events - .into_iter() - .filter(|ev| ev.tags.public_keys().peekable().peek().is_some()) - .unique_by(room_hash) - .sorted_by_key(|ev| Reverse(ev.created_at)) - .collect(); + let recv = Filter::new() + .kind(Kind::PrivateDirectMessage) + .pubkey(public_key); - _ = tx.send(result); - } + let send_events = client.database().query(send).await?; + let recv_events = client.database().query(recv).await?; + + Ok::<_, anyhow::Error>(send_events.merge(recv_events)) + } + .await; + + if let Ok(events) = result { + let result: Vec = events + .into_iter() + .filter(|ev| ev.tags.public_keys().peekable().peek().is_some()) + .unique_by(room_hash) + .sorted_by_key(|ev| Reverse(ev.created_at)) + .collect(); + + _ = tx.send(Some(result)); + } else { + _ = tx.send(None); } }) .detach(); cx.spawn(|this, cx| async move { - if let Ok(events) = rx.await { + if let Ok(Some(events)) = rx.await { if !events.is_empty() { _ = cx.update(|cx| { _ = this.update(cx, |this, cx| { @@ -127,14 +110,14 @@ impl ChatRegistry { let new = room_hash(&ev); // Filter all seen events if !current_rooms.iter().any(|this| this == &new) { - Some(cx.new(|cx| Room::parse(&ev, cx))) + Some(Room::new(&ev, cx)) } else { None } }) .collect(); - this.rooms.extend(items); + this.rooms.write().unwrap().extend(items); this.is_loading = false; cx.notify(); @@ -146,8 +129,8 @@ impl ChatRegistry { .detach(); } - pub fn rooms(&self) -> &Vec> { - &self.rooms + pub fn rooms(&self) -> Vec> { + self.rooms.read().unwrap().clone() } pub fn is_loading(&self) -> bool { @@ -156,18 +139,25 @@ impl ChatRegistry { pub fn get(&self, id: &u64, cx: &App) -> Option> { self.rooms + .read() + .unwrap() .iter() .find(|model| model.read(cx).id == *id) .map(|room| room.downgrade()) } - pub fn new_room(&mut self, room: Room, cx: &mut Context) -> Result<(), anyhow::Error> { - if !self - .rooms + pub fn push_room( + &mut self, + room: Entity, + cx: &mut Context, + ) -> Result<(), anyhow::Error> { + let mut rooms = self.rooms.write().unwrap(); + + if !rooms .iter() - .any(|current| compare(¤t.read(cx).pubkeys(), &room.pubkeys())) + .any(|current| current.read(cx) == room.read(cx)) { - self.rooms.insert(0, cx.new(|_| room)); + rooms.insert(0, room); cx.notify(); Ok(()) @@ -177,32 +167,27 @@ impl ChatRegistry { } pub fn push_message(&mut self, event: Event, cx: &mut Context) { - // Get all pubkeys from event's tags for comparision - let mut pubkeys: Vec<_> = event.tags.public_keys().copied().collect(); - pubkeys.push(event.pubkey); + let id = room_hash(&event); + let mut rooms = self.rooms.write().unwrap(); - if let Some(room) = self - .rooms - .iter() - .find(|room| compare(&room.read(cx).pubkeys(), &pubkeys)) - { + if let Some(room) = rooms.iter().find(|room| room.read(cx).id == id) { room.update(cx, |this, cx| { - this.last_seen.set(event.created_at); - this.new_messages.update(cx, |this, cx| { - this.push(event); - cx.notify(); - }); + if let Some(last_seen) = Rc::get_mut(&mut this.last_seen) { + *last_seen = LastSeen(event.created_at); + } + this.new_messages.push(event); cx.notify(); }); // Re sort rooms by last seen - self.rooms - .sort_by_key(|room| Reverse(room.read(cx).last_seen())); + rooms.sort_by_key(|room| Reverse(room.read(cx).last_seen())); cx.notify(); } else { - let room = cx.new(|cx| Room::parse(&event, cx)); - self.rooms.insert(0, room); + let mut rooms = self.rooms.write().unwrap(); + let new_room = Room::new(&event, cx); + + rooms.insert(0, new_room); cx.notify(); } } diff --git a/crates/chats/src/room.rs b/crates/chats/src/room.rs index b553c01..b914660 100644 --- a/crates/chats/src/room.rs +++ b/crates/chats/src/room.rs @@ -1,138 +1,161 @@ use common::{ last_seen::LastSeen, profile::NostrProfile, - utils::{compare, random_name, room_hash}, + utils::{random_name, room_hash}, }; use gpui::{App, AppContext, Entity, SharedString}; use nostr_sdk::prelude::*; -use std::collections::HashSet; +use smallvec::{smallvec, SmallVec}; +use state::get_client; +use std::{collections::HashSet, rc::Rc}; pub struct Room { pub id: u64, - pub title: Option, - pub owner: NostrProfile, // Owner always match current user - pub members: Vec, // Extract from event's tags - pub last_seen: LastSeen, - pub is_group: bool, - pub new_messages: Entity>, // Hold all new messages + pub last_seen: Rc, + /// Subject of the room (Nostr) + pub title: String, + /// Display name of the room (used for display purposes in Coop) + pub display_name: Option, + /// All members of the room + pub members: SmallVec<[NostrProfile; 2]>, + /// Store all new messages + pub new_messages: Vec, } impl PartialEq for Room { fn eq(&self, other: &Self) -> bool { - compare(&self.pubkeys(), &other.pubkeys()) + self.id == other.id } } impl Room { - pub fn new( - id: u64, - owner: NostrProfile, - members: Vec, - title: Option, - last_seen: LastSeen, - cx: &mut App, - ) -> Self { - let new_messages = cx.new(|_| Vec::new()); - let is_group = members.len() > 1; - let title = if title.is_none() { - Some(random_name(2).into()) - } else { - title - }; - - Self { - id, - owner, - members, - title, - last_seen, - is_group, - new_messages, - } - } - - /// Convert nostr event to room - pub fn parse(event: &Event, cx: &mut App) -> Room { + pub fn new(event: &Event, cx: &mut App) -> Entity { let id = room_hash(event); - let last_seen = LastSeen(event.created_at); - - // Always equal to current user - let owner = NostrProfile::new(event.pubkey, Metadata::default()); - - // Get all pubkeys that invole in this group - let members: Vec = event - .tags - .public_keys() - .collect::>() - .into_iter() - .map(|public_key| NostrProfile::new(*public_key, Metadata::default())) - .collect(); - - // Get title from event's tags + let last_seen = Rc::new(LastSeen(event.created_at)); + // Get the subject from the event's tags, or create a random subject if none is found let title = if let Some(tag) = event.tags.find(TagKind::Subject) { - tag.content().map(|s| s.to_owned().into()) + tag.content() + .map(|s| s.to_owned()) + .unwrap_or(random_name(2)) } else { - None + random_name(2) }; - Self::new(id, owner, members, title, last_seen, cx) + let room = cx.new(|cx| { + let this = Self { + id, + last_seen, + title, + display_name: None, + members: smallvec![], + new_messages: vec![], + }; + + let mut pubkeys = vec![]; + + // Get all pubkeys from event's tags + pubkeys.extend(event.tags.public_keys().collect::>()); + pubkeys.push(event.pubkey); + + let client = get_client(); + let (tx, rx) = oneshot::channel::>(); + + cx.background_spawn(async move { + let signer = client.signer().await.unwrap(); + let signer_pubkey = signer.get_public_key().await.unwrap(); + let mut profiles = vec![]; + + for public_key in pubkeys.into_iter() { + if let Ok(result) = client.database().metadata(public_key).await { + let metadata = result.unwrap_or_default(); + let profile = NostrProfile::new(public_key, metadata); + + if public_key == signer_pubkey { + profiles.push(profile); + } else { + profiles.insert(0, profile); + } + } + } + + _ = tx.send(profiles); + }) + .detach(); + + cx.spawn(|this, cx| async move { + if let Ok(profiles) = rx.await { + _ = cx.update(|cx| { + let display_name = if profiles.len() > 2 { + let merged = profiles + .iter() + .take(2) + .map(|profile| profile.name().to_string()) + .collect::>() + .join(", "); + + let name: SharedString = + format!("{}, +{}", merged, profiles.len() - 2).into(); + + Some(name) + } else { + None + }; + + _ = this.update(cx, |this: &mut Room, cx| { + this.members.extend(profiles); + this.display_name = display_name; + cx.notify(); + }); + }); + } + }) + .detach(); + + this + }); + + room } - /// Set contact's metadata by public key - pub fn set_metadata(&mut self, public_key: PublicKey, metadata: Metadata) { - if self.owner.public_key() == public_key { - self.owner.set_metadata(&metadata); - } - - for member in self.members.iter_mut() { - if member.public_key() == public_key { - member.set_metadata(&metadata); - } - } + pub fn id(&self) -> u64 { + self.id } /// Get room's member by public key pub fn member(&self, public_key: &PublicKey) -> Option { - if &self.owner.public_key() == public_key { - Some(self.owner.clone()) - } else { - self.members - .iter() - .find(|m| &m.public_key() == public_key) - .cloned() - } + self.members + .iter() + .find(|m| &m.public_key() == public_key) + .cloned() + } + + /// Get room's first member's public key + pub fn first_member(&self) -> Option<&NostrProfile> { + self.members.first() + } + + /// Collect room's member's public keys + pub fn public_keys(&self) -> Vec { + self.members.iter().map(|m| m.public_key()).collect() } /// Get room's display name - pub fn name(&self) -> String { - if self.members.len() <= 2 { - self.members - .iter() - .map(|profile| profile.name()) - .collect::>() - .join(", ") - } else { - let name = self - .members - .iter() - .take(2) - .map(|profile| profile.name()) - .collect::>() - .join(", "); - - format!("{}, +{}", name, self.members.len() - 2) - } + pub fn name(&self) -> Option { + self.display_name.clone() } - pub fn last_seen(&self) -> &LastSeen { - &self.last_seen + /// Determine if room is a group + pub fn is_group(&self) -> bool { + self.members.len() > 2 } - /// Get all public keys from current room - pub fn pubkeys(&self) -> Vec { - let mut pubkeys: Vec<_> = self.members.iter().map(|m| m.public_key()).collect(); - pubkeys.push(self.owner.public_key()); + /// Get room's last seen + pub fn last_seen(&self) -> Rc { + self.last_seen.clone() + } - pubkeys + /// Get room's last seen as ago format + pub fn ago(&self) -> SharedString { + self.last_seen.ago() } } diff --git a/crates/common/src/last_seen.rs b/crates/common/src/last_seen.rs index 456ef2f..49035a6 100644 --- a/crates/common/src/last_seen.rs +++ b/crates/common/src/last_seen.rs @@ -1,55 +1,54 @@ -use chrono::{Datelike, Local, TimeZone}; +use chrono::{Local, TimeZone}; use gpui::SharedString; use nostr_sdk::prelude::*; +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; + #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] pub struct LastSeen(pub Timestamp); impl LastSeen { pub fn ago(&self) -> SharedString { let now = Local::now(); - let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap(); - let diff = (now - input_time).num_hours(); + let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) { + chrono::LocalResult::Single(time) => time, + _ => return "Invalid timestamp".into(), + }; + let duration = now.signed_duration_since(input_time); - if diff < 24 { - let duration = now.signed_duration_since(input_time); - - if duration.num_seconds() < 60 { - "now".to_string().into() - } else if duration.num_minutes() == 1 { - "1m".to_string().into() - } else if duration.num_minutes() < 60 { - format!("{}m", duration.num_minutes()).into() - } else if duration.num_hours() == 1 { - "1h".to_string().into() - } else if duration.num_hours() < 24 { - format!("{}h", duration.num_hours()).into() - } else if duration.num_days() == 1 { - "1d".to_string().into() - } else { - format!("{}d", duration.num_days()).into() - } - } else { - input_time.format("%b %d").to_string().into() + 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() } pub fn human_readable(&self) -> SharedString { let now = Local::now(); - let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap(); + let input_time = match Local.timestamp_opt(self.0.as_u64() as i64, 0) { + chrono::LocalResult::Single(time) => time, + _ => return "Invalid timestamp".into(), + }; - if input_time.day() == now.day() { - format!("Today at {}", input_time.format("%H:%M %p")).into() - } else if input_time.day() == now.day() - 1 { - format!("Yesterday at {}", input_time.format("%H:%M %p")).into() - } else { - format!( - "{}, {}", - input_time.format("%d/%m/%y"), - input_time.format("%H:%M %p") - ) - .into() + 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() } pub fn set(&mut self, created_at: Timestamp) { diff --git a/crates/common/src/profile.rs b/crates/common/src/profile.rs index 44c04c5..b67f567 100644 --- a/crates/common/src/profile.rs +++ b/crates/common/src/profile.rs @@ -1,37 +1,24 @@ -use crate::constants::IMAGE_SERVICE; +use gpui::SharedString; use nostr_sdk::prelude::*; -#[derive(Debug, Clone)] +use crate::constants::IMAGE_SERVICE; + +#[derive(Debug, Clone, PartialEq, Eq)] pub struct NostrProfile { public_key: PublicKey, - metadata: Metadata, -} - -impl AsRef for NostrProfile { - fn as_ref(&self) -> &PublicKey { - &self.public_key - } -} - -impl AsRef for NostrProfile { - fn as_ref(&self) -> &Metadata { - &self.metadata - } -} - -impl Eq for NostrProfile {} - -impl PartialEq for NostrProfile { - fn eq(&self, other: &Self) -> bool { - self.public_key() == other.public_key() - } + avatar: SharedString, + name: SharedString, } impl NostrProfile { pub fn new(public_key: PublicKey, metadata: Metadata) -> Self { + let name = Self::extract_name(&public_key, &metadata); + let avatar = Self::extract_avatar(&metadata); + Self { public_key, - metadata, + name, + avatar, } } @@ -40,47 +27,44 @@ impl NostrProfile { self.public_key } - /// Get contact's avatar - pub fn avatar(&self) -> String { - if let Some(picture) = &self.metadata.picture { - if picture.len() > 1 { + pub fn avatar(&self) -> SharedString { + self.avatar.clone() + } + + pub fn name(&self) -> SharedString { + self.name.clone() + } + + fn extract_avatar(metadata: &Metadata) -> SharedString { + metadata + .picture + .as_ref() + .filter(|picture| !picture.is_empty()) + .map(|picture| { format!( "{}/?url={}&w=100&h=100&fit=cover&mask=circle&n=-1", IMAGE_SERVICE, picture ) - } else { - "brand/avatar.png".into() - } - } else { - "brand/avatar.png".into() - } + .into() + }) + .unwrap_or_else(|| "brand/avatar.png".into()) } - /// Get contact's name, fallback to public key as shorted format - pub fn name(&self) -> String { - if let Some(display_name) = &self.metadata.display_name { + fn extract_name(public_key: &PublicKey, metadata: &Metadata) -> SharedString { + if let Some(display_name) = metadata.display_name.as_ref() { if !display_name.is_empty() { - return display_name.to_owned(); + return display_name.into(); } } - if let Some(name) = &self.metadata.name { + if let Some(name) = metadata.name.as_ref() { if !name.is_empty() { - return name.to_owned(); + return name.into(); } } - let pubkey = self.public_key.to_string(); - format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]) - } + let pubkey = public_key.to_hex(); - /// Get contact's metadata - pub fn metadata(&mut self) -> &Metadata { - &self.metadata - } - - /// Set contact's metadata - pub fn set_metadata(&mut self, metadata: &Metadata) { - self.metadata = metadata.clone() + format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into() } } diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index 005e08e..3c10350 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -1,62 +1,12 @@ -use crate::constants::{ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID, NIP96_SERVER}; +use crate::constants::NIP96_SERVER; use itertools::Itertools; use nostr_sdk::prelude::*; use rnglib::{Language, RNG}; use std::{ collections::HashSet, hash::{DefaultHasher, Hash, Hasher}, - time::Duration, }; -pub async fn signer_public_key(client: &Client) -> anyhow::Result { - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - Ok(public_key) -} - -pub async fn preload(client: &Client, public_key: PublicKey) -> anyhow::Result<(), anyhow::Error> { - let sync_opts = SyncOptions::default(); - let subscription = Filter::new() - .kind(Kind::ContactList) - .author(public_key) - .limit(1); - - // Get contact list - _ = client.sync(subscription, &sync_opts).await; - - let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); - let new_message_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); - - // Create a filter for getting all gift wrapped events send to current user - let all_messages = Filter::new().kind(Kind::GiftWrap).pubkey(public_key); - - // Create a filter for getting new message - let new_message = Filter::new() - .kind(Kind::GiftWrap) - .pubkey(public_key) - .limit(0); - - // Subscribe for all messages - _ = client - .subscribe_with_id( - all_messages_sub_id, - all_messages, - Some( - SubscribeAutoCloseOptions::default() - .exit_policy(ReqExitPolicy::WaitDurationAfterEOSE(Duration::from_secs(3))), - ), - ) - .await; - - // Subscribe for new message - _ = client - .subscribe_with_id(new_message_sub_id, new_message, None) - .await; - - Ok(()) -} - pub async fn nip96_upload(client: &Client, file: Vec) -> anyhow::Result { let signer = client.signer().await?; let server_url = Url::parse(NIP96_SERVER)?; @@ -68,10 +18,28 @@ pub async fn nip96_upload(client: &Client, file: Vec) -> anyhow::Result u64 { - let pubkeys: Vec<&PublicKey> = event.tags.public_keys().unique().collect(); let mut hasher = DefaultHasher::new(); + let mut pubkeys: Vec<&PublicKey> = vec![]; + + // Add all public keys from event + pubkeys.push(&event.pubkey); + pubkeys.extend( + event + .tags + .public_keys() + .unique() + .sorted() + .collect::>(), + ); + // Generate unique hash - pubkeys.hash(&mut hasher); + pubkeys + .into_iter() + .unique() + .sorted() + .collect::>() + .hash(&mut hasher); + hasher.finish() } diff --git a/crates/ui/src/dock_area/dock.rs b/crates/ui/src/dock_area/dock.rs index 43e7aac..747f4a6 100644 --- a/crates/ui/src/dock_area/dock.rs +++ b/crates/ui/src/dock_area/dock.rs @@ -358,8 +358,6 @@ impl Render for Dock { return div(); } - let cache_style = gpui::StyleRefinement::default().v_flex().size_full(); - div() .relative() .overflow_hidden() @@ -375,7 +373,7 @@ impl Render for Dock { .map(|this| match &self.panel { DockItem::Split { view, .. } => this.child(view.clone()), DockItem::Tabs { view, .. } => this.child(view.clone()), - DockItem::Panel { view, .. } => this.child(view.clone().view().cached(cache_style)), + DockItem::Panel { view, .. } => this.child(view.clone().view()), }) .child(self.render_resize_handle(window, cx)) .child(DockElement {