diff --git a/Cargo.lock b/Cargo.lock index 1f777df..8ccf699 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -184,9 +184,9 @@ dependencies = [ [[package]] name = "async-channel" -version = "2.4.0" +version = "2.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16c74e56284d2188cabb6ad99603d1ace887a5d7e7b695d01b728155ed9ed427" +checksum = "924ed96dd52d1b75e9c1a3e6275715fd320f5f9439fb5a4a11fa51f4221158d2" dependencies = [ "concurrent-queue", "event-listener-strategy", @@ -730,9 +730,9 @@ dependencies = [ [[package]] name = "blocking" -version = "1.6.1" +version = "1.6.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "703f41c54fc768e63e091340b424302bb1c29ef4aa0c7f10fe849dfb114d29ea" +checksum = "e83f8d02be6967315521be875afa792a316e28d57b5a2d401897e2a7921b7f21" dependencies = [ "async-channel", "async-task", @@ -856,9 +856,9 @@ dependencies = [ [[package]] name = "cc" -version = "1.2.27" +version = "1.2.29" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d487aa071b5f64da6f19a3e848e3578944b726ee5a4854b82172f02aa876bfdc" +checksum = "5c1599538de2394445747c8cf7935946e3cc27e9625f889d979bfb2aaf569362" dependencies = [ "jobserver", "libc", @@ -935,29 +935,6 @@ dependencies = [ "zeroize", ] -[[package]] -name = "chats" -version = "1.0.0" -dependencies = [ - "anyhow", - "chrono", - "common", - "fuzzy-matcher", - "global", - "gpui", - "i18n", - "identity", - "itertools 0.13.0", - "log", - "nostr", - "nostr-sdk", - "oneshot", - "rust-i18n", - "settings", - "smallvec", - "smol", -] - [[package]] name = "chrono" version = "0.4.41" @@ -1001,10 +978,8 @@ dependencies = [ "anyhow", "global", "gpui", - "i18n", "log", "nostr-sdk", - "rust-i18n", "smallvec", ] @@ -1100,7 +1075,7 @@ dependencies = [ [[package]] name = "collections" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "indexmap", "rustc-hash 2.1.1", @@ -1195,7 +1170,6 @@ version = "1.0.0" dependencies = [ "anyhow", "auto_update", - "chats", "client_keys", "common", "dirs 5.0.1", @@ -1210,6 +1184,7 @@ dependencies = [ "nostr-connect", "nostr-sdk", "oneshot", + "registry", "reqwest_client", "rust-embed", "rust-i18n", @@ -1487,7 +1462,7 @@ dependencies = [ [[package]] name = "derive_refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "proc-macro2", "quote", @@ -2333,7 +2308,7 @@ dependencies = [ [[package]] name = "gpui" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "anyhow", "as-raw-xcb-connection", @@ -2425,7 +2400,7 @@ dependencies = [ [[package]] name = "gpui_macros" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "heck 0.5.0", "proc-macro2", @@ -2648,7 +2623,7 @@ dependencies = [ [[package]] name = "http_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "anyhow", "bytes", @@ -2665,7 +2640,7 @@ dependencies = [ [[package]] name = "http_client_tls" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "rustls", "rustls-platform-verifier", @@ -2733,9 +2708,9 @@ dependencies = [ [[package]] name = "hyper-util" -version = "0.1.14" +version = "0.1.15" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dc2fdfdbff08affe55bb779f33b053aa1fe5dd5b54c257343c17edfa55711bdb" +checksum = "7f66d5bd4c6f02bf0542fad85d626775bab9258cf795a4256dcaf3161114d1df" dependencies = [ "base64", "bytes", @@ -2883,12 +2858,10 @@ dependencies = [ "common", "global", "gpui", - "i18n", "log", "nostr-connect", "nostr-sdk", "oneshot", - "rust-i18n", "settings", "smallvec", "ui", @@ -3444,7 +3417,7 @@ dependencies = [ [[package]] name = "media" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "anyhow", "bindgen 0.71.1", @@ -3667,7 +3640,7 @@ dependencies = [ [[package]] name = "nostr" version = "0.42.1" -source = "git+https://github.com/rust-nostr/nostr#5a1aacb2fecdb4177574345a1b07ffbfa99e006d" +source = "git+https://github.com/rust-nostr/nostr#c4d16c691f5bc03448cf95bb8b2f59f7d5d0ca79" dependencies = [ "aes", "base64", @@ -3690,7 +3663,7 @@ dependencies = [ [[package]] name = "nostr-connect" version = "0.42.0" -source = "git+https://github.com/rust-nostr/nostr#5a1aacb2fecdb4177574345a1b07ffbfa99e006d" +source = "git+https://github.com/rust-nostr/nostr#c4d16c691f5bc03448cf95bb8b2f59f7d5d0ca79" dependencies = [ "async-utility", "nostr", @@ -3702,7 +3675,7 @@ dependencies = [ [[package]] name = "nostr-database" version = "0.42.0" -source = "git+https://github.com/rust-nostr/nostr#5a1aacb2fecdb4177574345a1b07ffbfa99e006d" +source = "git+https://github.com/rust-nostr/nostr#c4d16c691f5bc03448cf95bb8b2f59f7d5d0ca79" dependencies = [ "flatbuffers", "lru", @@ -3713,7 +3686,7 @@ dependencies = [ [[package]] name = "nostr-lmdb" version = "0.42.0" -source = "git+https://github.com/rust-nostr/nostr#5a1aacb2fecdb4177574345a1b07ffbfa99e006d" +source = "git+https://github.com/rust-nostr/nostr#c4d16c691f5bc03448cf95bb8b2f59f7d5d0ca79" dependencies = [ "async-utility", "heed", @@ -3726,7 +3699,7 @@ dependencies = [ [[package]] name = "nostr-relay-pool" version = "0.42.0" -source = "git+https://github.com/rust-nostr/nostr#5a1aacb2fecdb4177574345a1b07ffbfa99e006d" +source = "git+https://github.com/rust-nostr/nostr#c4d16c691f5bc03448cf95bb8b2f59f7d5d0ca79" dependencies = [ "async-utility", "async-wsocket", @@ -3742,7 +3715,7 @@ dependencies = [ [[package]] name = "nostr-sdk" version = "0.42.0" -source = "git+https://github.com/rust-nostr/nostr#5a1aacb2fecdb4177574345a1b07ffbfa99e006d" +source = "git+https://github.com/rust-nostr/nostr#c4d16c691f5bc03448cf95bb8b2f59f7d5d0ca79" dependencies = [ "async-utility", "nostr", @@ -4749,7 +4722,7 @@ dependencies = [ [[package]] name = "refineable" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "derive_refineable", "workspace-hack", @@ -4784,6 +4757,29 @@ version = "0.8.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2b15c43186be67a4fd63bee50d0303afffcef381492ebe2c5d87f324e1b8815c" +[[package]] +name = "registry" +version = "1.0.0" +dependencies = [ + "anyhow", + "chrono", + "common", + "fuzzy-matcher", + "global", + "gpui", + "i18n", + "identity", + "itertools 0.13.0", + "log", + "nostr", + "nostr-sdk", + "oneshot", + "rust-i18n", + "settings", + "smallvec", + "smol", +] + [[package]] name = "reqwest" version = "0.12.15" @@ -4879,7 +4875,7 @@ dependencies = [ [[package]] name = "reqwest_client" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "anyhow", "bytes", @@ -4911,9 +4907,9 @@ dependencies = [ [[package]] name = "rgb" -version = "0.8.50" +version = "0.8.51" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "57397d16646700483b67d2dd6511d79318f9d057fdbd21a4066aeac8b41d310a" +checksum = "a457e416a0f90d246a4c3288bd7a25b2304ca727f253f95be383dd17af56be8f" dependencies = [ "bytemuck", ] @@ -5242,9 +5238,9 @@ dependencies = [ [[package]] name = "schemars" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1375ba8ef45a6f15d83fa8748f1079428295d403d6ea991d09ab100155fbc06d" +checksum = "82d20c4491bc164fa2f6c5d44565947a52ad80b9505d8e36f8d54c27c739fcd0" dependencies = [ "dyn-clone", "indexmap", @@ -5256,9 +5252,9 @@ dependencies = [ [[package]] name = "schemars_derive" -version = "1.0.3" +version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2b13ed22d6d49fe23712e068770b5c4df4a693a2b02eeff8e7ca3135627a24f6" +checksum = "33d020396d1d138dc19f1165df7545479dcd58d93810dc5d646a16e55abefa80" dependencies = [ "proc-macro2", "quote", @@ -5361,7 +5357,7 @@ checksum = "0f7d95a54511e0c7be3f51e8867aa8cf35148d7b9445d44de2f943e2b206e749" [[package]] name = "semantic_version" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "anyhow", "serde", @@ -5492,10 +5488,8 @@ dependencies = [ "anyhow", "global", "gpui", - "i18n", "log", "nostr-sdk", - "rust-i18n", "serde", "serde_json", "smallvec", @@ -5739,7 +5733,7 @@ checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" [[package]] name = "sum_tree" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "arrayvec", "log", @@ -6135,9 +6129,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.46.0" +version = "1.46.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1140bb80481756a8cbe10541f37433b459c5aa1e727b4c020fbfebdc25bf3ec4" +checksum = "0cc3a2344dafbe23a245241fe8b09735b521110d30fcefbbd5feb1797ca35d17" dependencies = [ "backtrace", "bytes", @@ -6648,7 +6642,7 @@ checksum = "b6c140620e7ffbb22c2dee59cafe6084a59b5ffc27a8859a5f0d494b5d52b6be" [[package]] name = "util" version = "0.1.0" -source = "git+https://github.com/zed-industries/zed#5253702200f4fb491b6608ac0a0c6df89b224b0f" +source = "git+https://github.com/zed-industries/zed#f1db3b4e1d639b3aacbe472ddeb4471d844d4e04" dependencies = [ "anyhow", "async-fs", @@ -7740,9 +7734,9 @@ dependencies = [ [[package]] name = "zbus" -version = "5.7.1" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3a7c7cee313d044fca3f48fa782cb750c79e4ca76ba7bc7718cd4024cdf6f68" +checksum = "597f45e98bc7e6f0988276012797855613cd8269e23b5be62cc4e5d28b7e515d" dependencies = [ "async-broadcast", "async-executor", @@ -7773,9 +7767,9 @@ dependencies = [ [[package]] name = "zbus_macros" -version = "5.7.1" +version = "5.8.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a17e7e5eec1550f747e71a058df81a9a83813ba0f6a95f39c4e218bdc7ba366a" +checksum = "e5c8e4e14dcdd9d97a98b189cd1220f30e8394ad271e8c987da84f73693862c2" dependencies = [ "proc-macro-crate", "proc-macro2", @@ -7924,9 +7918,9 @@ dependencies = [ [[package]] name = "zvariant" -version = "5.5.3" +version = "5.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d30786f75e393ee63a21de4f9074d4c038d52c5b1bb4471f955db249f9dffb1" +checksum = "d91b3680bb339216abd84714172b5138a4edac677e641ef17e1d8cb1b3ca6e6f" dependencies = [ "endi", "enumflags2", @@ -7939,9 +7933,9 @@ dependencies = [ [[package]] name = "zvariant_derive" -version = "5.5.3" +version = "5.6.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75fda702cd42d735ccd48117b1630432219c0e9616bf6cb0f8350844ee4d9580" +checksum = "3a8c68501be459a8dbfffbe5d792acdd23b4959940fc87785fb013b32edbc208" dependencies = [ "proc-macro-crate", "proc-macro2", diff --git a/crates/auto_update/src/lib.rs b/crates/auto_update/src/lib.rs index 430b0be..3b98138 100644 --- a/crates/auto_update/src/lib.rs +++ b/crates/auto_update/src/lib.rs @@ -4,7 +4,7 @@ use std::ffi::OsString; use std::path::PathBuf; use anyhow::{anyhow, Context as _, Error}; -use global::shared_state; +use global::nostr_client; use gpui::{App, AppContext, Context, Entity, Global, SemanticVersion, Task}; use nostr_sdk::prelude::*; use smol::fs::{self, File}; @@ -128,10 +128,9 @@ impl AutoUpdater { self.set_status(AutoUpdateStatus::Downloading, cx); let task: Task> = cx.background_spawn(async move { - let database = shared_state().client().database(); let ids = event.tags.event_ids().copied(); let filter = Filter::new().ids(ids).kind(Kind::FileMetadata); - let events = database.query(filter).await?; + let events = nostr_client().database().query(filter).await?; if let Some(event) = events.into_iter().find(|event| event.content == OS) { let tag = event.tags.find(TagKind::Url).context("url not found")?; diff --git a/crates/chats/src/constants.rs b/crates/chats/src/constants.rs deleted file mode 100644 index ef2505e..0000000 --- a/crates/chats/src/constants.rs +++ /dev/null @@ -1,5 +0,0 @@ -pub(crate) const NOW: &str = "now"; -pub(crate) const SECONDS_IN_MINUTE: i64 = 60; -pub(crate) const MINUTES_IN_HOUR: i64 = 60; -pub(crate) const HOURS_IN_DAY: i64 = 24; -pub(crate) const DAYS_IN_MONTH: i64 = 30; diff --git a/crates/client_keys/Cargo.toml b/crates/client_keys/Cargo.toml index ac797f4..c5d5258 100644 --- a/crates/client_keys/Cargo.toml +++ b/crates/client_keys/Cargo.toml @@ -7,8 +7,6 @@ publish.workspace = true [dependencies] global = { path = "../global" } -rust-i18n.workspace = true -i18n.workspace = true nostr-sdk.workspace = true gpui.workspace = true anyhow.workspace = true diff --git a/crates/client_keys/src/lib.rs b/crates/client_keys/src/lib.rs index 4feb7ff..f27798d 100644 --- a/crates/client_keys/src/lib.rs +++ b/crates/client_keys/src/lib.rs @@ -1,11 +1,8 @@ -use global::constants::KEYRING_URL; -use global::shared_state; +use global::{constants::KEYRING_URL, first_run}; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Window}; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; -i18n::init!(); - pub fn init(cx: &mut App) { ClientKeys::set_global(cx.new(ClientKeys::new), cx); } @@ -66,7 +63,7 @@ impl ClientKeys { this.set_keys(Some(keys), false, true, cx); }) .ok(); - } else if shared_state().first_run() { + } else if *first_run() { // Generate a new keys and update this.update(cx, |this, cx| { this.new_keys(cx); diff --git a/crates/common/src/display.rs b/crates/common/src/display.rs new file mode 100644 index 0000000..8d309a3 --- /dev/null +++ b/crates/common/src/display.rs @@ -0,0 +1,48 @@ +use global::constants::IMAGE_RESIZE_SERVICE; +use gpui::SharedString; +use nostr_sdk::prelude::*; + +const FALLBACK_IMG: &str = "https://image.nostr.build/c30703b48f511c293a9003be8100cdad37b8798b77a1dc3ec6eb8a20443d5dea.png"; + +pub trait DisplayProfile { + fn avatar_url(&self, proxy: bool) -> SharedString; + fn display_name(&self) -> SharedString; +} + +impl DisplayProfile for Profile { + fn avatar_url(&self, proxy: bool) -> SharedString { + self.metadata() + .picture + .as_ref() + .filter(|picture| !picture.is_empty()) + .map(|picture| { + if proxy { + format!( + "{IMAGE_RESIZE_SERVICE}/?url={picture}&w=100&h=100&fit=cover&mask=circle&default={FALLBACK_IMG}&n=-1" + ) + .into() + } else { + picture.into() + } + }) + .unwrap_or_else(|| "brand/avatar.png".into()) + } + + fn display_name(&self) -> SharedString { + if let Some(display_name) = self.metadata().display_name.as_ref() { + if !display_name.is_empty() { + return display_name.into(); + } + } + + if let Some(name) = self.metadata().name.as_ref() { + if !name.is_empty() { + return name.into(); + } + } + + let pubkey = self.public_key().to_hex(); + + format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]).into() + } +} diff --git a/crates/common/src/lib.rs b/crates/common/src/lib.rs index cc38757..81e0919 100644 --- a/crates/common/src/lib.rs +++ b/crates/common/src/lib.rs @@ -1,51 +1,51 @@ -use std::collections::HashSet; -use std::hash::{DefaultHasher, Hash, Hasher}; -use std::sync::Arc; - -use gpui::{Image, ImageFormat}; -use itertools::Itertools; -use nostr_sdk::prelude::*; -use qrcode_generator::QrCodeEcc; - -pub mod debounced_delay; -pub mod handle_auth; -pub mod nip05; -pub mod nip96; -pub mod profile; - -pub fn room_hash(event: &Event) -> u64 { - let mut hasher = DefaultHasher::new(); - let mut pubkeys: Vec = vec![]; - - // Add all public keys from event - pubkeys.push(event.pubkey); - pubkeys.extend(event.tags.public_keys().collect::>()); - - // Generate unique hash - pubkeys - .into_iter() - .unique() - .sorted() - .collect::>() - .hash(&mut hasher); - - hasher.finish() -} - -pub fn string_to_qr(data: &str) -> Option> { - let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256) else { - return None; - }; - - Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes))) -} - -pub fn compare(a: &[T], b: &[T]) -> bool -where - T: Eq + Hash, -{ - let a: HashSet<_> = a.iter().collect(); - let b: HashSet<_> = b.iter().collect(); - - a == b -} +use std::collections::HashSet; +use std::hash::{DefaultHasher, Hash, Hasher}; +use std::sync::Arc; + +use gpui::{Image, ImageFormat}; +use itertools::Itertools; +use nostr_sdk::prelude::*; +use qrcode_generator::QrCodeEcc; + +pub mod debounced_delay; +pub mod display; +pub mod handle_auth; +pub mod nip05; +pub mod nip96; + +pub fn room_hash(event: &Event) -> u64 { + let mut hasher = DefaultHasher::new(); + let mut pubkeys: Vec = vec![]; + + // Add all public keys from event + pubkeys.push(event.pubkey); + pubkeys.extend(event.tags.public_keys().collect::>()); + + // Generate unique hash + pubkeys + .into_iter() + .unique() + .sorted() + .collect::>() + .hash(&mut hasher); + + hasher.finish() +} + +pub fn string_to_qr(data: &str) -> Option> { + let Ok(bytes) = qrcode_generator::to_png_to_vec_from_str(data, QrCodeEcc::Medium, 256) else { + return None; + }; + + Some(Arc::new(Image::from_bytes(ImageFormat::Png, bytes))) +} + +pub fn compare(a: &[T], b: &[T]) -> bool +where + T: Eq + Hash, +{ + let a: HashSet<_> = a.iter().collect(); + let b: HashSet<_> = b.iter().collect(); + + a == b +} diff --git a/crates/coop/Cargo.toml b/crates/coop/Cargo.toml index bb39831..c24d628 100644 --- a/crates/coop/Cargo.toml +++ b/crates/coop/Cargo.toml @@ -14,7 +14,7 @@ identity = { path = "../identity" } theme = { path = "../theme" } common = { path = "../common" } global = { path = "../global" } -chats = { path = "../chats" } +registry = { path = "../registry" } settings = { path = "../settings" } client_keys = { path = "../client_keys" } auto_update = { path = "../auto_update" } diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index 67f0dc8..f9dafc0 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -1,10 +1,9 @@ use std::sync::Arc; use anyhow::Error; -use chats::{ChatRegistry, RoomEmitter}; use client_keys::ClientKeys; use global::constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH}; -use global::shared_state; +use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ div, px, relative, Action, App, AppContext, Axis, Context, Entity, IntoElement, ParentElement, @@ -13,6 +12,7 @@ use gpui::{ use i18n::t; use identity::Identity; use nostr_connect::prelude::*; +use registry::{Registry, RoomEmitter}; use serde::Deserialize; use smallvec::{smallvec, SmallVec}; use theme::{ActiveTheme, Theme, ThemeMode}; @@ -84,7 +84,7 @@ impl ChatSpace { }); cx.new(|cx| { - let chats = ChatRegistry::global(cx); + let registry = Registry::global(cx); let client_keys = ClientKeys::global(cx); let identity = Identity::global(cx); let mut subscriptions = smallvec![]; @@ -153,11 +153,11 @@ impl ChatSpace { &identity, window, |this: &mut Self, state, window, cx| { - if !state.read(cx).has_profile() { + if !state.read(cx).has_signer() { this.open_onboarding(window, cx); } else { // Load all chat rooms from database - ChatRegistry::global(cx).update(cx, |this, cx| { + Registry::global(cx).update(cx, |this, cx| { this.load_rooms(window, cx); }); // Open chat panels @@ -175,7 +175,7 @@ impl ChatSpace { // Subscribe to open chat room requests subscriptions.push(cx.subscribe_in( - &chats, + ®istry, window, |this: &mut Self, _state, event, window, cx| { if let RoomEmitter::Open(room) = event { @@ -187,10 +187,7 @@ impl ChatSpace { this.add_panel(panel, placement, window, cx); }); } else { - window.push_notification( - SharedString::new(t!("chatspace.failed_to_open_room")), - cx, - ); + window.push_notification(t!("chatspace.failed_to_open_room"), cx); } } }, @@ -283,7 +280,7 @@ impl ChatSpace { fn verify_messaging_relays(&self, cx: &App) -> Task> { cx.background_spawn(async move { - let client = shared_state().client(); + let client = nostr_client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; let filter = Filter::new() diff --git a/crates/coop/src/main.rs b/crates/coop/src/main.rs index eb7f5aa..ffb4205 100644 --- a/crates/coop/src/main.rs +++ b/crates/coop/src/main.rs @@ -1,12 +1,17 @@ +use std::collections::BTreeSet; use std::sync::Arc; +use std::time::Duration; +use anyhow::{anyhow, Error}; use asset::Assets; use auto_update::AutoUpdater; -use chats::ChatRegistry; #[cfg(not(target_os = "linux"))] use global::constants::APP_NAME; -use global::constants::{ALL_MESSAGES_SUB_ID, APP_ID}; -use global::{shared_state, NostrSignal}; +use global::constants::{ + ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, + METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS, +}; +use global::{nostr_client, NostrSignal}; use gpui::{ actions, px, size, App, AppContext, Application, Bounds, KeyBinding, Menu, MenuItem, WindowBounds, WindowKind, WindowOptions, @@ -15,7 +20,10 @@ use gpui::{ use gpui::{point, SharedString, TitlebarOptions}; #[cfg(target_os = "linux")] use gpui::{WindowBackgroundAppearance, WindowDecorations}; -use nostr_sdk::SubscriptionId; +use itertools::Itertools; +use nostr_sdk::prelude::*; +use registry::Registry; +use smol::channel::{self, Sender}; use theme::Theme; use ui::Root; @@ -31,15 +39,145 @@ fn main() { // Initialize logging tracing_subscriber::fmt::init(); + // Initialize the Nostr Client + let client = nostr_client(); + // Initialize the Application let app = Application::new() .with_assets(Assets) .with_http_client(Arc::new(reqwest_client::ReqwestClient::new())); - // Initialize the Global State and process events in a separate thread. + let (signal_tx, signal_rx) = channel::bounded::(2048); + let (mta_tx, mta_rx) = channel::bounded::(1024); + let (event_tx, event_rx) = channel::unbounded::(); + + let signal_tx_clone = signal_tx.clone(); + let mta_tx_clone = mta_tx.clone(); + app.background_executor() .spawn(async move { - shared_state().start().await; + // Subscribe for app updates from the bootstrap relays. + if let Err(e) = connect(client).await { + log::error!("Failed to connect to bootstrap relays: {e}"); + } + + // Connect to bootstrap relays. + if let Err(e) = subscribe_for_app_updates(client).await { + log::error!("Failed to subscribe for app updates: {e}"); + } + + // Handle Nostr notifications. + // + // Send the redefined signal back to GPUI via channel. + if let Err(e) = + handle_nostr_notifications(client, &signal_tx_clone, &mta_tx_clone, &event_tx).await + { + log::error!("Failed to handle Nostr notifications: {e}"); + } + }) + .detach(); + + app.background_executor() + .spawn(async move { + let duration = Duration::from_millis(METADATA_BATCH_TIMEOUT); + let mut batch: BTreeSet = BTreeSet::new(); + + /// Internal events for the metadata batching system + enum BatchEvent { + NewKeys(PublicKey), + Timeout, + Closed, + } + + loop { + let duration = smol::Timer::after(duration); + + let recv = || async { + if let Ok(public_key) = mta_rx.recv().await { + BatchEvent::NewKeys(public_key) + } else { + BatchEvent::Closed + } + }; + + let timeout = || async { + duration.await; + BatchEvent::Timeout + }; + + match smol::future::or(recv(), timeout()).await { + BatchEvent::NewKeys(public_key) => { + batch.insert(public_key); + // Process immediately if batch limit reached + if batch.len() >= METADATA_BATCH_LIMIT { + sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await; + } + } + BatchEvent::Timeout => { + if !batch.is_empty() { + sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await; + } + } + BatchEvent::Closed => { + if !batch.is_empty() { + sync_data_for_pubkeys(client, std::mem::take(&mut batch)).await; + } + break; + } + } + } + }) + .detach(); + + app.background_executor() + .spawn(async move { + let mut counter = 0; + + loop { + // Signer is unset, probably user is not ready to retrieve gift wrap events + if client.signer().await.is_err() { + continue; + } + + let duration = smol::Timer::after(Duration::from_secs(75)); + + let recv = || async { + // prevent inline format + (event_rx.recv().await).ok() + }; + + let timeout = || async { + duration.await; + None + }; + + match smol::future::or(recv(), timeout()).await { + Some(event) => { + // Process the gift wrap event unwrapping + let is_cached = + try_unwrap_event(client, &signal_tx, &mta_tx, &event, false).await; + + // Increment the total messages counter if message is not from cache + if !is_cached { + counter += 1; + } + + // Send partial finish signal to GPUI + if counter >= 20 { + signal_tx.send(NostrSignal::PartialFinish).await.ok(); + // Reset counter + counter = 0; + } + } + None => { + signal_tx.send(NostrSignal::Finish).await.ok(); + break; + } + } + } + + // Event channel is no longer needed when all gift wrap events have been processed + event_rx.close(); }) .detach(); @@ -98,6 +236,8 @@ fn main() { cx.activate(true); // Initialize components ui::init(cx); + // Initialize app registry + registry::init(cx); // Initialize settings settings::init(cx); // Initialize client keys @@ -106,44 +246,51 @@ fn main() { identity::init(window, cx); // Initialize auto update auto_update::init(cx); - // Initialize chat state - chats::init(cx); // Spawn a task to handle events from nostr channel cx.spawn_in(window, async move |_, cx| { let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); - while let Ok(signal) = shared_state().signal().recv().await { + while let Ok(signal) = signal_rx.recv().await { cx.update(|window, cx| { - let chats = ChatRegistry::global(cx); + let registry = Registry::global(cx); let auto_updater = AutoUpdater::global(cx); match signal { - NostrSignal::Event(event) => { - chats.update(cx, |this, cx| { - this.event_to_message(event, window, cx); - }); - } // Load chat rooms and stop the loading status NostrSignal::Finish => { - chats.update(cx, |this, cx| { + registry.update(cx, |this, cx| { this.load_rooms(window, cx); this.set_loading(false, cx); }); } // Load chat rooms without setting as finished NostrSignal::PartialFinish => { - chats.update(cx, |this, cx| { + registry.update(cx, |this, cx| { this.load_rooms(window, cx); }); } + // Load chat rooms without setting as finished NostrSignal::Eose(subscription_id) => { + // Only load chat rooms if the subscription ID matches the all_messages_sub_id if subscription_id == all_messages_sub_id { - chats.update(cx, |this, cx| { + registry.update(cx, |this, cx| { this.load_rooms(window, cx); }); } } + // Add the new metadata to the registry or update the existing one + NostrSignal::Metadata(event) => { + registry.update(cx, |this, cx| { + this.insert_or_update_person(event, cx); + }); + } + // Convert the gift wrapped message to a message + NostrSignal::GiftWrap(event) => { + registry.update(cx, |this, cx| { + this.event_to_message(event, window, cx); + }); + } NostrSignal::Notice(_msg) => { // window.push_notification(msg, cx); } @@ -170,3 +317,262 @@ fn quit(_: &Quit, cx: &mut App) { log::info!("Gracefully quitting the application . . ."); cx.quit(); } + +async fn connect(client: &Client) -> Result<(), Error> { + for relay in BOOTSTRAP_RELAYS.into_iter() { + client.add_relay(relay).await?; + } + + log::info!("Connected to bootstrap relays"); + + for relay in SEARCH_RELAYS.into_iter() { + client.add_relay(relay).await?; + } + + log::info!("Connected to search relays"); + + // Establish connection to relays + client.connect().await; + + Ok(()) +} + +async fn handle_nostr_notifications( + client: &Client, + signal_tx: &Sender, + mta_tx: &Sender, + event_tx: &Sender, +) -> Result<(), Error> { + let new_messages_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + + let mut notifications = client.notifications(); + let mut processed_events: BTreeSet = BTreeSet::new(); + let mut processed_dm_relays: BTreeSet = BTreeSet::new(); + + while let Ok(notification) = notifications.recv().await { + let RelayPoolNotification::Message { message, .. } = notification else { + continue; + }; + + match message { + RelayMessage::Event { + event, + subscription_id, + } => { + if processed_events.contains(&event.id) { + continue; + } + // Skip events that have already been processed + processed_events.insert(event.id); + + match event.kind { + Kind::GiftWrap => { + if *subscription_id == new_messages_sub_id { + let event = event.as_ref(); + _ = try_unwrap_event(client, signal_tx, mta_tx, event, false).await; + } else { + event_tx.send(event.into_owned()).await.ok(); + } + } + Kind::Metadata => { + signal_tx + .send(NostrSignal::Metadata(event.into_owned())) + .await + .ok(); + } + Kind::ContactList => { + if let Ok(true) = check_author(client, &event).await { + for public_key in event.tags.public_keys().copied() { + mta_tx.send(public_key).await.ok(); + } + } + } + Kind::RelayList => { + if processed_dm_relays.contains(&event.pubkey) { + continue; + } + // Skip public keys that have already been processed + processed_dm_relays.insert(event.pubkey); + + let filter = Filter::new() + .author(event.pubkey) + .kind(Kind::InboxRelays) + .limit(1); + + let relay_urls = nip65::extract_owned_relay_list(event.into_owned()) + .map(|(url, _)| url) + .collect_vec(); + + if !relay_urls.is_empty() { + client + .subscribe_to(relay_urls, filter, Some(opts)) + .await + .ok(); + + log::info!("Subscribe for messaging relays") + } + } + Kind::ReleaseArtifactSet => { + let ids = event.tags.event_ids().copied(); + let filter = Filter::new().ids(ids).kind(Kind::FileMetadata); + + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await + .ok(); + + signal_tx + .send(NostrSignal::AppUpdate(event.into_owned())) + .await + .ok(); + } + _ => {} + } + } + RelayMessage::EndOfStoredEvents(subscription_id) => { + signal_tx + .send(NostrSignal::Eose(subscription_id.into_owned())) + .await?; + } + _ => {} + } + } + + Ok(()) +} + +async fn subscribe_for_app_updates(client: &Client) -> Result<(), Error> { + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + + let coordinate = Coordinate { + kind: Kind::Custom(32267), + public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"), + identifier: APP_ID.into(), + }; + + let filter = Filter::new() + .kind(Kind::ReleaseArtifactSet) + .coordinate(&coordinate) + .limit(1); + + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await?; + + Ok(()) +} + +async fn check_author(client: &Client, event: &Event) -> Result { + let signer = client.signer().await?; + let public_key = signer.get_public_key().await?; + + Ok(public_key == event.pubkey) +} + +async fn sync_data_for_pubkeys(client: &Client, public_keys: BTreeSet) { + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList]; + + let filter = Filter::new() + .limit(public_keys.len() * kinds.len()) + .authors(public_keys) + .kinds(kinds); + + if let Err(e) = client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await + { + log::error!("Failed to sync metadata: {e}"); + } +} + +/// Stores an unwrapped event in local database with reference to original +async fn set_unwrapped(client: &Client, root: EventId, event: &Event) -> Result<(), Error> { + // Must be use the random generated keys to sign this event + let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json()) + .tags(vec![Tag::identifier(root), Tag::event(root)]) + .sign(&Keys::generate()) + .await?; + + // Only save this event into the local database + client.database().save_event(&event).await?; + + Ok(()) +} + +/// Retrieves a previously unwrapped event from local database +async fn get_unwrapped(client: &Client, target: EventId) -> Result { + let filter = Filter::new() + .kind(Kind::ApplicationSpecificData) + .identifier(target) + .event(target) + .limit(1); + + if let Some(event) = client.database().query(filter).await?.first_owned() { + Ok(Event::from_json(event.content)?) + } else { + Err(anyhow!("Event is not cached yet")) + } +} + +/// Unwraps a gift-wrapped event and processes its contents. +/// +/// # Arguments +/// * `event` - The gift-wrapped event to unwrap +/// * `incoming` - Whether this is a newly received event (true) or old +/// +/// # Returns +/// Returns `true` if the event was successfully loaded from cache or saved after unwrapping. +async fn try_unwrap_event( + client: &Client, + signal_tx: &Sender, + mta_tx: &Sender, + event: &Event, + incoming: bool, +) -> bool { + let mut is_cached = false; + + let event = match get_unwrapped(client, event.id).await { + Ok(event) => { + is_cached = true; + event + } + Err(_) => { + match client.unwrap_gift_wrap(event).await { + Ok(unwrap) => { + let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&Keys::generate()) else { + return false; + }; + + // Save this event to the database for future use. + if let Err(e) = set_unwrapped(client, event.id, &unwrapped).await { + log::error!("Failed to save event: {e}") + } + + unwrapped + } + Err(_) => return false, + } + } + }; + + // Save the event to the database, use for query directly. + if let Err(e) = client.database().save_event(&event).await { + log::error!("Failed to save event: {e}") + } + + // Send all pubkeys to the batch to sync metadata + mta_tx.send(event.pubkey).await.ok(); + + for public_key in event.tags.public_keys().copied() { + mta_tx.send(public_key).await.ok(); + } + + // Send a notify to GPUI if this is a new message + if incoming { + signal_tx.send(NostrSignal::GiftWrap(event)).await.ok(); + } + + is_cached +} diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat.rs index 9382c14..4d54fee 100644 --- a/crates/coop/src/views/chat.rs +++ b/crates/coop/src/views/chat.rs @@ -3,16 +3,14 @@ use std::collections::HashMap; use std::rc::Rc; use std::sync::Arc; -use chats::message::Message; -use chats::room::{Room, RoomKind, SendError}; +use common::display::DisplayProfile; use common::nip96::nip96_upload; -use common::profile::RenderProfile; -use global::shared_state; +use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ - div, img, list, px, red, relative, rems, svg, white, Action, AnyElement, App, AppContext, - ClipboardItem, Context, Div, Element, Empty, Entity, EventEmitter, Flatten, FocusHandle, - Focusable, InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, + div, img, list, px, red, rems, white, Action, AnyElement, App, AppContext, ClipboardItem, + Context, Div, Element, Empty, Entity, EventEmitter, Flatten, FocusHandle, Focusable, + InteractiveElement, IntoElement, ListAlignment, ListState, ObjectFit, ParentElement, PathPromptOptions, Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, StyledImage, Subscription, Window, }; @@ -20,6 +18,9 @@ use i18n::t; use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; +use registry::message::Message; +use registry::room::{Room, RoomKind, SendError}; +use registry::Registry; use serde::Deserialize; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; @@ -71,15 +72,7 @@ impl Chat { pub fn new(room: Entity, window: &mut Window, cx: &mut App) -> Entity { let attaches = cx.new(|_| None); let replies_to = cx.new(|_| None); - - let messages = cx.new(|_| { - let message = Message::builder() - .content(t!("chat.private_conversation_notice").into()) - .build_rc() - .unwrap(); - - vec![message] - }); + let messages = cx.new(|_| vec![]); let input = cx.new(|cx| { InputState::new(window, cx) @@ -220,15 +213,11 @@ impl Chat { // TODO: find a better way to prevent duplicate messages during optimistic updates fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context) -> bool { - let Some(account) = Identity::get_global(cx).profile() else { + let Some(identity) = Identity::read_global(cx).public_key() else { return false; }; - let Some(author) = new_msg.author.as_ref() else { - return false; - }; - - if account.public_key() != author.public_key() { + if new_msg.author != identity { return false; } @@ -237,12 +226,7 @@ impl Chat { self.messages .read(cx) .iter() - .filter(|m| { - m.borrow() - .author - .as_ref() - .is_some_and(|p| p.public_key() == account.public_key()) - }) + .filter(|m| m.borrow().author == identity) .any(|existing| { let existing = existing.borrow(); // Check if messages are within the time window @@ -297,10 +281,10 @@ impl Chat { }); this.messages.update(cx, |this, cx| { - if let Some(msg) = id.and_then(|id| { - this.iter().find(|msg| msg.borrow().id == Some(id)).cloned() - }) { - msg.borrow_mut().errors = Some(reports); + if let Some(msg) = + this.iter().find(|msg| msg.borrow().id == id).cloned() + { + msg.borrow_mut().errors = Some(reports.into()); cx.notify(); } }); @@ -330,7 +314,7 @@ impl Chat { .messages .read(cx) .iter() - .position(|m| m.borrow().id == Some(id)) + .position(|m| m.borrow().id == id) { self.list_state.scroll_to_reveal_item(ix); } @@ -350,7 +334,7 @@ impl Chat { fn remove_reply(&mut self, id: EventId, cx: &mut Context) { self.replies_to.update(cx, |this, cx| { if let Some(replies) = this { - if let Some(ix) = replies.iter().position(|m| m.id == Some(id)) { + if let Some(ix) = replies.iter().position(|m| m.id == id) { replies.remove(ix); cx.notify(); } @@ -391,9 +375,7 @@ impl Chat { // Spawn task via async utility instead of GPUI context nostr_sdk::async_utility::task::spawn(async move { - let url = nip96_upload(shared_state().client(), &nip96, file_data) - .await - .ok(); + let url = nip96_upload(nostr_client(), &nip96, file_data).await.ok(); _ = tx.send(url); }); @@ -482,6 +464,9 @@ impl Chat { } fn render_reply(&mut self, message: &Message, cx: &Context) -> impl IntoElement { + let registry = Registry::read_global(cx); + let profile = registry.get_person(&message.author, cx); + div() .w_full() .pl_2() @@ -503,7 +488,7 @@ impl Chat { .child( div() .text_color(cx.theme().text_accent) - .child(message.author.as_ref().unwrap().render_name()), + .child(profile.display_name()), ), ) .child( @@ -512,7 +497,7 @@ impl Chat { .xsmall() .ghost() .on_click({ - let id = message.id.unwrap(); + let id = message.id; cx.listener(move |this, _, _, cx| { this.remove_reply(id, cx); }) @@ -541,43 +526,16 @@ impl Chat { let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; let hide_avatar = AppSettings::get_global(cx).settings.hide_user_avatars; + let registry = Registry::read_global(cx); let message = message.borrow(); - - // Message without ID, Author probably the placeholder - let (Some(id), Some(author)) = (message.id, message.author.as_ref()) else { - return div() - .id(ix) - .group("") - .w_full() - .relative() - .flex() - .gap_3() - .px_3() - .py_2() - .w_full() - .h_32() - .flex() - .flex_col() - .items_center() - .justify_center() - .text_center() - .text_xs() - .text_color(cx.theme().text_placeholder) - .line_height(relative(1.3)) - .child( - svg() - .path("brand/coop.svg") - .size_10() - .text_color(cx.theme().elevated_surface_background), - ) - .child(message.content.clone()); - }; + let author = registry.get_person(&message.author, cx); + let mentions = registry.get_group_person(&message.mentions, cx); let texts = self .text_data - .entry(id) - .or_insert_with(|| RichText::new(message.content.to_string(), &message.mentions)); + .entry(message.id) + .or_insert_with(|| RichText::new(message.content.to_string(), &mentions)); div() .id(ix) @@ -591,7 +549,7 @@ impl Chat { .flex() .gap_3() .when(!hide_avatar, |this| { - this.child(Avatar::new(author.render_avatar(proxy)).size(rems(2.))) + this.child(Avatar::new(author.avatar_url(proxy)).size(rems(2.))) }) .child( div() @@ -610,7 +568,7 @@ impl Chat { div() .font_semibold() .text_color(cx.theme().text) - .child(author.render_name()), + .child(author.display_name()), ) .child( div() @@ -627,7 +585,7 @@ impl Chat { .messages .read(cx) .iter() - .find(|msg| msg.borrow().id == Some(*id)) + .find(|msg| msg.borrow().id == *id) .cloned() { let message = message.borrow(); @@ -643,13 +601,7 @@ impl Chat { .child( div() .text_color(cx.theme().text_accent) - .child( - message - .author - .as_ref() - .unwrap() - .render_name(), - ), + .child(author.display_name()), ) .child( div() @@ -664,7 +616,7 @@ impl Chat { .elevated_surface_background) }) .on_click({ - let id = message.id.unwrap(); + let id = message.id; cx.listener(move |this, _, _, cx| { this.scroll_to(id, cx) }) @@ -881,7 +833,7 @@ fn message_border(cx: &App) -> Div { .bg(cx.theme().border_transparent) } -fn message_errors(errors: Vec, cx: &App) -> Div { +fn message_errors(errors: SmallVec<[SendError; 1]>, cx: &App) -> Div { div() .flex() .flex_col() @@ -898,7 +850,7 @@ fn message_errors(errors: Vec, cx: &App) -> Div { .gap_1() .text_color(cx.theme().text_muted) .child(SharedString::new(t!("chat.send_to_label"))) - .child(error.profile.render_name()), + .child(error.profile.display_name()), ) .child(error.message) })) diff --git a/crates/coop/src/views/compose.rs b/crates/coop/src/views/compose.rs index d4e2efb..6b9841a 100644 --- a/crates/coop/src/views/compose.rs +++ b/crates/coop/src/views/compose.rs @@ -2,11 +2,10 @@ use std::ops::Range; use std::time::Duration; use anyhow::{anyhow, Error}; -use chats::room::{Room, RoomKind}; -use chats::ChatRegistry; +use common::display::DisplayProfile; use common::nip05::nip05_profile; -use common::profile::RenderProfile; -use global::shared_state; +use global::constants::BOOTSTRAP_RELAYS; +use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ div, img, px, red, relative, uniform_list, App, AppContext, Context, Entity, @@ -16,37 +15,37 @@ use gpui::{ use i18n::t; use itertools::Itertools; use nostr_sdk::prelude::*; +use registry::room::{Room, RoomKind}; +use registry::Registry; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use smol::Timer; use theme::ActiveTheme; -use ui::{ - button::{Button, ButtonVariants}, - input::{InputEvent, InputState, TextInput}, - notification::Notification, - ContextModal, Disableable, Icon, IconName, Sizable, StyledExt, -}; +use ui::button::{Button, ButtonVariants}; +use ui::input::{InputEvent, InputState, TextInput}; +use ui::notification::Notification; +use ui::{ContextModal, Disableable, Icon, IconName, Sizable, StyledExt}; pub fn init(window: &mut Window, cx: &mut App) -> Entity { cx.new(|cx| Compose::new(window, cx)) } -#[derive(Debug, Clone)] +#[derive(Debug)] struct Contact { - profile: Profile, + public_key: PublicKey, select: bool, } -impl AsRef for Contact { - fn as_ref(&self) -> &Profile { - &self.profile +impl AsRef for Contact { + fn as_ref(&self) -> &PublicKey { + &self.public_key } } impl Contact { - pub fn new(profile: Profile) -> Self { + pub fn new(public_key: PublicKey) -> Self { Self { - profile, + public_key, select: false, } } @@ -88,20 +87,21 @@ impl Compose { &user_input, window, move |this, _input, event, window, cx| { - match event { - InputEvent::PressEnter { .. } => this.add_and_select_contact(window, cx), - InputEvent::Change(_) => {} - _ => {} + if let InputEvent::PressEnter { .. } = event { + this.add_and_select_contact(window, cx) }; }, )); let get_contacts: Task, Error>> = cx.background_spawn(async move { - let client = shared_state().client(); + let client = nostr_client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; let profiles = client.database().contacts(public_key).await?; - let contacts = profiles.into_iter().map(Contact::new).collect_vec(); + let contacts = profiles + .into_iter() + .map(|profile| Contact::new(profile.public_key())) + .collect_vec(); Ok(contacts) }); @@ -110,7 +110,7 @@ impl Compose { match get_contacts.await { Ok(contacts) => { this.update(cx, |this, cx| { - this.contacts(contacts, cx); + this.extend_contacts(contacts, cx); }) .ok(); } @@ -135,6 +135,28 @@ impl Compose { } } + async fn request_metadata(client: &Client, public_key: PublicKey) -> Result<(), Error> { + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + let kinds = vec![Kind::Metadata, Kind::ContactList, Kind::RelayList]; + let filter = Filter::new().author(public_key).kinds(kinds).limit(10); + + client + .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) + .await?; + + Ok(()) + } + + fn parse_pubkey(content: &str) -> Result { + if content.starts_with("nprofile1") { + Ok(Nip19Profile::from_bech32(content)?.public_key) + } else if content.starts_with("npub1") { + Ok(PublicKey::parse(content)?) + } else { + Err(anyhow!(t!("common.pubkey_invalid"))) + } + } + pub fn compose(&mut self, window: &mut Window, cx: &mut Context) { let public_keys: Vec = self.selected(cx); @@ -158,7 +180,7 @@ impl Compose { } let event: Task> = cx.background_spawn(async move { - let signer = shared_state().client().signer().await?; + let signer = nostr_client().signer().await?; let public_key = signer.get_public_key().await?; let room = EventBuilder::private_msg_rumor(public_keys[0], "") @@ -180,7 +202,7 @@ impl Compose { }) .ok(); - ChatRegistry::global(cx).update(cx, |this, cx| { + Registry::global(cx).update(cx, |this, cx| { this.push_room(cx.new(|_| room), cx); }); @@ -199,7 +221,10 @@ impl Compose { .detach(); } - fn contacts(&mut self, contacts: impl IntoIterator, cx: &mut Context) { + fn extend_contacts(&mut self, contacts: I, cx: &mut Context) + where + I: IntoIterator, + { self.contacts .extend(contacts.into_iter().map(|contact| cx.new(|_| contact))); cx.notify(); @@ -209,15 +234,12 @@ impl Compose { if !self .contacts .iter() - .any(|e| e.read(cx).profile.public_key() == contact.profile.public_key()) + .any(|e| e.read(cx).public_key == contact.public_key) { self.contacts.insert(0, cx.new(|_| contact)); cx.notify(); } else { - self.set_error( - Some(t!("compose.contact_existed", name = contact.profile.name()).into()), - cx, - ); + self.set_error(Some(t!("compose.contact_existed").into()), cx); } } @@ -226,7 +248,7 @@ impl Compose { .iter() .filter_map(|contact| { if contact.read(cx).select { - Some(contact.read(cx).profile.public_key()) + Some(contact.read(cx).public_key) } else { None } @@ -245,7 +267,7 @@ impl Compose { this.set_loading(true, cx); }); - let task: Task> = if content.contains("@") { + let task: Task> = if content.contains("@") { cx.background_spawn(async move { let (tx, rx) = oneshot::channel::>(); @@ -255,82 +277,54 @@ impl Compose { }); if let Ok(Some(profile)) = rx.await { + let client = nostr_client(); let public_key = profile.public_key; - let metadata = shared_state() - .client() - .fetch_metadata(public_key, Duration::from_secs(2)) - .await? - .unwrap_or_default(); - let profile = Profile::new(public_key, metadata); - let contact = Contact::new(profile).select(); + let contact = Contact::new(public_key).select(); + + Self::request_metadata(client, public_key).await?; Ok(contact) } else { Err(anyhow!(t!("common.not_found"))) } }) - } else if content.starts_with("nprofile1") { - let Some(public_key) = Nip19Profile::from_bech32(&content) - .map(|nip19| nip19.public_key) - .ok() - else { - self.set_error(Some(t!("common.pubkey_invalid").into()), cx); - return; - }; - + } else if let Ok(public_key) = Self::parse_pubkey(&content) { cx.background_spawn(async move { - let metadata = shared_state() - .client() - .fetch_metadata(public_key, Duration::from_secs(2)) - .await? - .unwrap_or_default(); + let client = nostr_client(); + let contact = Contact::new(public_key).select(); - let profile = Profile::new(public_key, metadata); - let contact = Contact::new(profile).select(); + Self::request_metadata(client, public_key).await?; Ok(contact) }) } else { - let Ok(public_key) = PublicKey::parse(&content) else { - self.set_error(Some(t!("common.pubkey_invalid").into()), cx); - return; - }; - - cx.background_spawn(async move { - let metadata = shared_state() - .client() - .fetch_metadata(public_key, Duration::from_secs(2)) - .await? - .unwrap_or_default(); - - let profile = Profile::new(public_key, metadata); - let contact = Contact::new(profile).select(); - - Ok(contact) - }) + self.set_error(Some(t!("common.pubkey_invalid").into()), cx); + return; }; - cx.spawn_in(window, async move |this, cx| match task.await { - Ok(contact) => { - cx.update(|window, cx| { - this.update(cx, |this, cx| { - this.push_contact(contact, cx); - this.set_adding(false, cx); - this.user_input.update(cx, |this, cx| { - this.set_value("", window, cx); - this.set_loading(false, cx); - }); + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contact) => { + cx.update(|window, cx| { + this.update(cx, |this, cx| { + this.push_contact(contact, cx); + this.set_adding(false, cx); + this.user_input.update(cx, |this, cx| { + this.set_value("", window, cx); + this.set_loading(false, cx); + }); + }) + .ok(); }) .ok(); - }) - .ok(); - } - Err(e) => { - this.update(cx, |this, cx| { - this.set_error(Some(e.to_string().into()), cx); - }) - .ok(); - } + } + Err(e) => { + this.update(cx, |this, cx| { + this.set_error(Some(e.to_string().into()), cx); + }) + .ok(); + } + }; }) .detach(); } @@ -374,6 +368,7 @@ impl Compose { fn list_items(&self, range: Range, cx: &Context) -> Vec { let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + let registry = Registry::read_global(cx); let mut items = Vec::with_capacity(self.contacts.len()); for ix in range { @@ -381,14 +376,16 @@ impl Compose { continue; }; - let profile = entity.read(cx).as_ref(); + let public_key = entity.read(cx).as_ref(); + let profile = registry.get_person(public_key, cx); let selected = entity.read(cx).select; items.push( div() .id(ix) .w_full() - .h_10() + .h_11() + .py_1() .px_3() .flex() .items_center() @@ -399,14 +396,14 @@ impl Compose { .items_center() .gap_3() .text_sm() - .child(img(profile.render_avatar(proxy)).size_7().flex_shrink_0()) - .child(profile.render_name()), + .child(img(profile.avatar_url(proxy)).size_7().flex_shrink_0()) + .child(profile.display_name()), ) .when(selected, |this| { this.child( Icon::new(IconName::CheckCircleFill) .small() - .text_color(cx.theme().ring), + .text_color(cx.theme().text_accent), ) }) .hover(|this| this.bg(cx.theme().elevated_surface_background)) @@ -542,7 +539,6 @@ impl Render for Compose { this.list_items(range, cx) }), ) - .pb_4() .min_h(px(280.)), ) } diff --git a/crates/coop/src/views/login.rs b/crates/coop/src/views/login.rs index dc4454e..60923f3 100644 --- a/crates/coop/src/views/login.rs +++ b/crates/coop/src/views/login.rs @@ -1,4 +1,3 @@ -use i18n::t; use std::sync::Arc; use std::time::Duration; @@ -12,6 +11,7 @@ use gpui::{ EventEmitter, FocusHandle, Focusable, Image, InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, Subscription, Window, }; +use i18n::t; use identity::Identity; use nostr_connect::prelude::*; use smallvec::{smallvec, SmallVec}; @@ -581,15 +581,13 @@ impl Render for Login { })), ) .when_some(self.countdown.read(cx).as_ref(), |this, i| { + let msg = t!("login.approve_message", i = i); this.child( div() .text_xs() .text_center() .text_color(cx.theme().text_muted) - .child(SharedString::new(t!( - "login.approve_message", - i = i - ))), + .child(SharedString::new(msg)), ) }) .when_some(self.error.read(cx).clone(), |this, error| { diff --git a/crates/coop/src/views/new_account.rs b/crates/coop/src/views/new_account.rs index 18480be..69230c6 100644 --- a/crates/coop/src/views/new_account.rs +++ b/crates/coop/src/views/new_account.rs @@ -1,5 +1,5 @@ use common::nip96::nip96_upload; -use global::shared_state; +use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ div, img, relative, AnyElement, App, AppContext, Context, Entity, EventEmitter, Flatten, @@ -102,7 +102,7 @@ impl NewAccount { .ok(); true }) - .on_ok(move |_, _window, cx| { + .on_ok(move |_, window, cx| { let metadata = metadata.clone(); let value = weak_input .read_with(cx, |state, _cx| state.value().to_owned()) @@ -110,7 +110,7 @@ impl NewAccount { if let Some(password) = value { Identity::global(cx).update(cx, |this, cx| { - this.new_identity(Keys::generate(), password.to_string(), metadata, cx); + this.new_identity(password.to_string(), metadata, window, cx); }); } @@ -161,9 +161,7 @@ impl NewAccount { let (tx, rx) = oneshot::channel::(); nostr_sdk::async_utility::task::spawn(async move { - if let Ok(url) = - nip96_upload(shared_state().client(), &nip96, file_data).await - { + if let Ok(url) = nip96_upload(nostr_client(), &nip96, file_data).await { _ = tx.send(url); } }); diff --git a/crates/coop/src/views/onboarding.rs b/crates/coop/src/views/onboarding.rs index 9fe7870..db79f2e 100644 --- a/crates/coop/src/views/onboarding.rs +++ b/crates/coop/src/views/onboarding.rs @@ -1,7 +1,7 @@ use anyhow::anyhow; -use common::profile::RenderProfile; +use common::display::DisplayProfile; use global::constants::ACCOUNT_D; -use global::shared_state; +use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ div, relative, rems, svg, AnyElement, App, AppContext, Context, Entity, EventEmitter, @@ -46,14 +46,12 @@ impl Onboarding { let local_account = cx.new(|_| None); let task = cx.background_spawn(async move { - let database = shared_state().client().database(); - let filter = Filter::new() .kind(Kind::ApplicationSpecificData) .identifier(ACCOUNT_D) .limit(1); - if let Some(event) = database.query(filter).await?.first_owned() { + if let Some(event) = nostr_client().database().query(filter).await?.first_owned() { let public_key = event .tags .public_keys() @@ -62,10 +60,14 @@ impl Onboarding { .first() .cloned() .unwrap(); - let metadata = database.metadata(public_key).await?.unwrap_or_default(); - let profile = Profile::new(public_key, metadata); - Ok(profile) + let metadata = nostr_client() + .database() + .metadata(public_key) + .await? + .unwrap_or_default(); + + Ok(Profile::new(public_key, metadata)) } else { Err(anyhow!("Not found")) } @@ -213,15 +215,13 @@ impl Render for Onboarding { .gap_1() .font_semibold() .child( - Avatar::new( - profile.render_avatar(proxy), - ) - .size(rems(1.5)), + Avatar::new(profile.avatar_url(proxy)) + .size(rems(1.5)), ) .child( div() .pb_px() - .child(profile.render_name()), + .child(profile.display_name()), ), ), ) diff --git a/crates/coop/src/views/preferences.rs b/crates/coop/src/views/preferences.rs index f9f09ab..836bbe7 100644 --- a/crates/coop/src/views/preferences.rs +++ b/crates/coop/src/views/preferences.rs @@ -1,4 +1,4 @@ -use common::profile::RenderProfile; +use common::display::DisplayProfile; use global::constants::{DEFAULT_MODAL_WIDTH, NIP96_SERVER}; use gpui::http_client::Url; use gpui::prelude::FluentBuilder; @@ -8,6 +8,7 @@ use gpui::{ }; use i18n::t; use identity::Identity; +use registry::Registry; use settings::AppSettings; use theme::ActiveTheme; use ui::avatar::Avatar; @@ -74,9 +75,15 @@ impl Preferences { impl Render for Preferences { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let input_state = self.media_input.downgrade(); + let registry = Registry::read_global(cx); let settings = AppSettings::get_global(cx).settings.as_ref(); + let profile = Identity::read_global(cx) + .public_key() + .map(|pk| registry.get_person(&pk, cx)); + + let input_state = self.media_input.downgrade(); + div() .track_focus(&self.focus_handle) .size_full() @@ -97,7 +104,7 @@ impl Render for Preferences { .font_semibold() .child(SharedString::new(t!("preferences.account_header"))), ) - .when_some(Identity::get_global(cx).profile(), |this, profile| { + .when_some(profile, |this, profile| { this.child( div() .w_full() @@ -112,7 +119,7 @@ impl Render for Preferences { .gap_2() .child( Avatar::new( - profile.render_avatar(settings.proxy_user_avatars), + profile.avatar_url(settings.proxy_user_avatars), ) .size(rems(2.4)), ) @@ -124,7 +131,7 @@ impl Render for Preferences { div() .line_height(relative(1.3)) .font_semibold() - .child(profile.render_name()), + .child(profile.display_name()), ) .child( div() diff --git a/crates/coop/src/views/profile.rs b/crates/coop/src/views/profile.rs index cc5dd4b..0852989 100644 --- a/crates/coop/src/views/profile.rs +++ b/crates/coop/src/views/profile.rs @@ -2,7 +2,7 @@ use std::str::FromStr; use std::time::Duration; use common::nip96::nip96_upload; -use global::shared_state; +use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ div, img, App, AppContext, Context, Entity, Flatten, IntoElement, ParentElement, @@ -57,7 +57,7 @@ impl Profile { }; let task: Task, Error>> = cx.background_spawn(async move { - let client = shared_state().client(); + let client = nostr_client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; let metadata = client @@ -106,7 +106,7 @@ impl Profile { } fn upload(&mut self, window: &mut Window, cx: &mut Context) { - let nip96_server = AppSettings::get_global(cx).settings.media_server.clone(); + let nip96 = AppSettings::get_global(cx).settings.media_server.clone(); let avatar_input = self.avatar_input.downgrade(); let paths = cx.prompt_for_paths(PathPromptOptions { files: true, @@ -126,8 +126,7 @@ impl Profile { let (tx, rx) = oneshot::channel::(); nostr_sdk::async_utility::task::spawn(async move { - let client = shared_state().client(); - if let Ok(url) = nip96_upload(client, &nip96_server, file_data).await { + if let Ok(url) = nip96_upload(nostr_client(), &nip96, file_data).await { _ = tx.send(url); } }); @@ -193,27 +192,29 @@ impl Profile { } let task: Task> = cx.background_spawn(async move { - let _ = shared_state().client().set_metadata(&new_metadata).await?; + nostr_client().set_metadata(&new_metadata).await?; Ok(()) }); - cx.spawn_in(window, async move |this, cx| match task.await { - Ok(_) => { - cx.update(|window, cx| { - window.push_notification(t!("profile.updated_successfully"), cx); - this.update(cx, |this, cx| { - this.set_submitting(false, cx); + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(_) => { + cx.update(|window, cx| { + window.push_notification(t!("profile.updated_successfully"), cx); + this.update(cx, |this, cx| { + this.set_submitting(false, cx); + }) + .ok(); }) .ok(); - }) - .ok(); - } - Err(e) => { - cx.update(|window, cx| { - window.push_notification(e.to_string(), cx); - }) - .ok(); - } + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(e.to_string(), cx); + }) + .ok(); + } + }; }) .detach(); } diff --git a/crates/coop/src/views/relays.rs b/crates/coop/src/views/relays.rs index f1bb653..a0aa07b 100644 --- a/crates/coop/src/views/relays.rs +++ b/crates/coop/src/views/relays.rs @@ -1,6 +1,6 @@ use anyhow::Error; use global::constants::NEW_MESSAGE_SUB_ID; -use global::shared_state; +use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ div, px, uniform_list, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, @@ -35,7 +35,7 @@ impl Relays { let input = cx.new(|cx| InputState::new(window, cx).placeholder("wss://example.com")); let relays = cx.new(|cx| { let task: Task, Error>> = cx.background_spawn(async move { - let client = shared_state().client(); + let client = nostr_client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; let filter = Filter::new() @@ -106,7 +106,7 @@ impl Relays { let relays = self.relays.read(cx).clone(); let task: Task> = cx.background_spawn(async move { - let client = shared_state().client(); + let client = nostr_client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index c8d2315..3865a4e 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -3,14 +3,12 @@ use std::ops::Range; use std::time::Duration; use anyhow::Error; -use chats::room::{Room, RoomKind}; -use chats::{ChatRegistry, RoomEmitter}; use common::debounced_delay::DebouncedDelay; +use common::display::DisplayProfile; use common::nip05::nip05_verify; -use common::profile::RenderProfile; use element::DisplayRoom; use global::constants::{DEFAULT_MODAL_WIDTH, SEARCH_RELAYS}; -use global::shared_state; +use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ div, px, relative, rems, uniform_list, AnyElement, App, AppContext, ClipboardItem, Context, @@ -18,9 +16,12 @@ use gpui::{ Render, RetainAllImageCache, SharedString, StatefulInteractiveElement, Styled, Subscription, Task, Window, }; +use i18n::t; use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; +use registry::room::{Room, RoomKind}; +use registry::{Registry, RoomEmitter}; use settings::AppSettings; use smallvec::{smallvec, SmallVec}; use theme::ActiveTheme; @@ -35,7 +36,6 @@ use ui::skeleton::Skeleton; use ui::{ContextModal, IconName, Selectable, Sizable, StyledExt}; use crate::views::compose; -use i18n::t; mod element; @@ -80,7 +80,7 @@ impl Sidebar { InputState::new(window, cx).placeholder(t!("sidebar.find_or_start_conversation")) }); - let chats = ChatRegistry::global(cx); + let chats = Registry::global(cx); let mut subscriptions = smallvec![]; subscriptions.push(cx.subscribe_in( @@ -154,7 +154,7 @@ impl Sidebar { let query_cloned = query.clone(); let task: Task, Error>> = cx.background_spawn(async move { - let client = shared_state().client(); + let client = nostr_client(); let filter = Filter::new() .kind(Kind::Metadata) @@ -266,7 +266,7 @@ impl Sidebar { }; let task: Task> = cx.background_spawn(async move { - let client = shared_state().client(); + let client = nostr_client(); let signer = client.signer().await.unwrap(); let user_pubkey = signer.get_public_key().await.unwrap(); @@ -290,7 +290,7 @@ impl Sidebar { match task.await { Ok((profile, room)) => { this.update(cx, |this, cx| { - let chats = ChatRegistry::global(cx); + let chats = Registry::global(cx); let result = chats .read(cx) .search_by_public_key(profile.public_key(), cx); @@ -343,7 +343,7 @@ impl Sidebar { return; }; - let chats = ChatRegistry::global(cx); + let chats = Registry::global(cx); let result = chats.read(cx).search(&query, cx); if result.is_empty() { @@ -426,7 +426,7 @@ impl Sidebar { } fn open_room(&mut self, id: u64, window: &mut Window, cx: &mut Context) { - let room = if let Some(room) = ChatRegistry::get_global(cx).room(&id, cx) { + let room = if let Some(room) = Registry::read_global(cx).room(&id, cx) { room } else { let Some(result) = self.global_result.read(cx).as_ref() else { @@ -445,7 +445,7 @@ impl Sidebar { room }; - ChatRegistry::global(cx).update(cx, |this, cx| { + Registry::global(cx).update(cx, |this, cx| { this.push_room(room, cx); }); } @@ -508,15 +508,15 @@ impl Sidebar { .gap_2() .text_sm() .font_semibold() - .child(Avatar::new(profile.render_avatar(proxy)).size(rems(1.75))) - .child(profile.render_name()) + .child(Avatar::new(profile.avatar_url(proxy)).size(rems(1.75))) + .child(profile.display_name()) .on_click(cx.listener({ let Ok(public_key) = profile.public_key().to_bech32(); let item = ClipboardItem::new_string(public_key); move |_, _, window, cx| { cx.write_to_clipboard(item.clone()); - window.push_notification("User's NPUB is copied", cx); + window.push_notification(t!("common.copied"), cx); } })), ) @@ -616,7 +616,11 @@ impl Focusable for Sidebar { impl Render for Sidebar { fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { - let chats = ChatRegistry::get_global(cx); + let registry = Registry::read_global(cx); + + let profile = Identity::read_global(cx) + .public_key() + .map(|pk| registry.get_person(&pk, cx)); // Get rooms from either search results or the chat registry let rooms = if let Some(results) = self.local_result.read(cx) { @@ -624,9 +628,9 @@ impl Render for Sidebar { } else { #[allow(clippy::collapsible_else_if)] if self.active_filter.read(cx) == &RoomKind::Ongoing { - chats.ongoing_rooms(cx) + registry.ongoing_rooms(cx) } else { - chats.request_rooms(self.trusted_only, cx) + registry.request_rooms(self.trusted_only, cx) } }; @@ -638,7 +642,7 @@ impl Render for Sidebar { .flex_col() .gap_3() // Account - .when_some(Identity::get_global(cx).profile(), |this, profile| { + .when_some(profile, |this, profile| { this.child(self.account(&profile, cx)) }) // Search Input @@ -770,7 +774,7 @@ impl Render for Sidebar { ) }), ) - .when(chats.loading, |this| { + .when(registry.loading, |this| { this.child( div() .flex_1() @@ -791,7 +795,7 @@ impl Render for Sidebar { .h_full(), ), ) - .when(chats.loading, |this| { + .when(registry.loading, |this| { this.child( div().absolute().bottom_4().px_4().child( div() diff --git a/crates/coop/src/views/subject.rs b/crates/coop/src/views/subject.rs index a50522f..95c6e63 100644 --- a/crates/coop/src/views/subject.rs +++ b/crates/coop/src/views/subject.rs @@ -1,9 +1,9 @@ -use chats::ChatRegistry; use gpui::{ div, App, AppContext, Context, Entity, FocusHandle, InteractiveElement, IntoElement, ParentElement, Render, SharedString, Styled, Window, }; use i18n::t; +use registry::Registry; use theme::ActiveTheme; use ui::button::{Button, ButtonVariants}; use ui::input::{InputState, TextInput}; @@ -47,7 +47,7 @@ impl Subject { } pub fn update(&mut self, window: &mut Window, cx: &mut Context) { - let registry = ChatRegistry::global(cx).read(cx); + let registry = Registry::global(cx).read(cx); let subject = self.input.read(cx).value().clone(); if let Some(room) = registry.room(&self.id, cx) { diff --git a/crates/global/src/constants.rs b/crates/global/src/constants.rs index 414046a..da0811e 100644 --- a/crates/global/src/constants.rs +++ b/crates/global/src/constants.rs @@ -1,59 +1,57 @@ -pub const APP_NAME: &str = "Coop"; -pub const APP_ID: &str = "su.reya.coop"; -pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b"; -pub const KEYRING_URL: &str = "Coop Safe Storage"; - -pub const ACCOUNT_D: &str = "coop:account"; -pub const SETTINGS_D: &str = "coop:settings"; - -/// Bootstrap Relays. -pub const BOOTSTRAP_RELAYS: [&str; 4] = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://user.kindpag.es", - "wss://purplepag.es", -]; - -/// NIP65 Relays. Used for new account -pub const NIP65_RELAYS: [&str; 4] = [ - "wss://relay.damus.io", - "wss://relay.primal.net", - "wss://relay.nostr.net", - "wss://nos.lol", -]; - -/// Messaging Relays. Used for new account -pub const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"]; - -/// Search Relays. -pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.nostr.band"]; - -/// Default relay for Nostr Connect -pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; - -/// Default timeout (in seconds) for Nostr Connect -pub const NOSTR_CONNECT_TIMEOUT: u64 = 200; - -/// Unique ID for new message subscription. -pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps"; -/// Unique ID for all messages subscription. -pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps"; - -/// Total metadata requests will be grouped. -pub const METADATA_BATCH_LIMIT: usize = 100; -/// Maximum timeout for grouping metadata requests. -pub const METADATA_BATCH_TIMEOUT: u64 = 400; - -/// Default width for all modals. -pub const DEFAULT_MODAL_WIDTH: f32 = 420.; -/// Default width of the sidebar. -pub const DEFAULT_SIDEBAR_WIDTH: f32 = 280.; - -/// Image Resize Service -pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl"; - -/// Default NIP96 Media Server. -pub const NIP96_SERVER: &str = "https://nostrmedia.com"; - -pub(crate) const GLOBAL_CHANNEL_LIMIT: usize = 2048; -pub(crate) const BATCH_CHANNEL_LIMIT: usize = 2048; +pub const APP_NAME: &str = "Coop"; +pub const APP_ID: &str = "su.reya.coop"; +pub const APP_PUBKEY: &str = "b1813fb01274b32cc5db6d1198e7c79dda0fb430899f63c7064f651a41d44f2b"; +pub const KEYRING_URL: &str = "Coop Safe Storage"; + +pub const ACCOUNT_D: &str = "coop:account"; +pub const SETTINGS_D: &str = "coop:settings"; + +/// Bootstrap Relays. +pub const BOOTSTRAP_RELAYS: [&str; 5] = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://nostr.Wine", + "wss://user.kindpag.es", + "wss://purplepag.es", +]; + +/// Search Relays. +pub const SEARCH_RELAYS: [&str; 2] = ["wss://search.nos.today", "wss://relay.nostr.band"]; + +/// NIP65 Relays. Used for new account +pub const NIP65_RELAYS: [&str; 4] = [ + "wss://relay.damus.io", + "wss://relay.primal.net", + "wss://relay.nostr.net", + "wss://nos.lol", +]; + +/// Messaging Relays. Used for new account +pub const NIP17_RELAYS: [&str; 2] = ["wss://auth.nostr1.com", "wss://relay.0xchat.com"]; + +/// Default relay for Nostr Connect +pub const NOSTR_CONNECT_RELAY: &str = "wss://relay.nsec.app"; + +/// Default timeout (in seconds) for Nostr Connect +pub const NOSTR_CONNECT_TIMEOUT: u64 = 200; + +/// Unique ID for new message subscription. +pub const NEW_MESSAGE_SUB_ID: &str = "listen_new_giftwraps"; +/// Unique ID for all messages subscription. +pub const ALL_MESSAGES_SUB_ID: &str = "listen_all_giftwraps"; + +/// Total metadata requests will be grouped. +pub const METADATA_BATCH_LIMIT: usize = 100; +/// Maximum timeout for grouping metadata requests. +pub const METADATA_BATCH_TIMEOUT: u64 = 400; + +/// Default width for all modals. +pub const DEFAULT_MODAL_WIDTH: f32 = 420.; +/// Default width of the sidebar. +pub const DEFAULT_SIDEBAR_WIDTH: f32 = 280.; + +/// Image Resize Service +pub const IMAGE_RESIZE_SERVICE: &str = "https://wsrv.nl"; + +/// Default NIP96 Media Server. +pub const NIP96_SERVER: &str = "https://nostrmedia.com"; diff --git a/crates/global/src/lib.rs b/crates/global/src/lib.rs index 54c3099..0f84e36 100644 --- a/crates/global/src/lib.rs +++ b/crates/global/src/lib.rs @@ -1,643 +1,70 @@ -use std::collections::{BTreeMap, BTreeSet}; -use std::sync::OnceLock; -use std::time::Duration; -use std::{fs, mem}; - -use anyhow::{anyhow, Error}; -use constants::{ - ALL_MESSAGES_SUB_ID, APP_ID, APP_PUBKEY, BOOTSTRAP_RELAYS, METADATA_BATCH_LIMIT, - METADATA_BATCH_TIMEOUT, NEW_MESSAGE_SUB_ID, SEARCH_RELAYS, -}; -use nostr_connect::prelude::*; -use nostr_sdk::prelude::*; -use paths::nostr_file; -use smol::lock::RwLock; -use smol::Task; - -use crate::constants::{BATCH_CHANNEL_LIMIT, GLOBAL_CHANNEL_LIMIT}; -use crate::paths::support_dir; - -pub mod constants; -pub mod paths; - -/// Global singleton instance for application state -static GLOBALS: OnceLock = OnceLock::new(); - -/// Signals sent through the global event channel to notify UI components -#[derive(Debug)] -pub enum NostrSignal { - /// New gift wrap event received - Event(Event), - /// Finished processing all gift wrap events - Finish, - /// Partially finished processing all gift wrap events - PartialFinish, - /// Receives EOSE response from relay pool - Eose(SubscriptionId), - /// Notice from Relay Pool - Notice(String), - /// Application update event received - AppUpdate(Event), -} - -/// Global application state containing Nostr client and shared resources -pub struct Globals { - /// The Nostr SDK client - client: Client, - - /// Determines if this is the first time user run Coop - first_run: bool, - - /// Cache of user profiles mapped by their public keys - persons: RwLock>>, - - /// Channel sender for broadcasting global Nostr events to UI - global_sender: smol::channel::Sender, - - /// Channel receiver for handling global Nostr events - global_receiver: smol::channel::Receiver, - - batch_sender: smol::channel::Sender, - batch_receiver: smol::channel::Receiver, - - event_sender: smol::channel::Sender, - event_receiver: smol::channel::Receiver, -} - -/// Returns the global singleton instance, initializing it if necessary -pub fn shared_state() -> &'static Globals { - GLOBALS.get_or_init(|| { - // rustls uses the `aws_lc_rs` provider by default - // This only errors if the default provider has already - // been installed. We can ignore this `Result`. - rustls::crypto::aws_lc_rs::default_provider() - .install_default() - .ok(); - - let first_run = is_first_run().unwrap_or(true); - let opts = ClientOptions::new().gossip(true); - let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized"); - - let (global_sender, global_receiver) = - smol::channel::bounded::(GLOBAL_CHANNEL_LIMIT); - - let (batch_sender, batch_receiver) = - smol::channel::bounded::(BATCH_CHANNEL_LIMIT); - - let (event_sender, event_receiver) = smol::channel::unbounded::(); - - Globals { - client: ClientBuilder::default().database(lmdb).opts(opts).build(), - persons: RwLock::new(BTreeMap::new()), - first_run, - global_sender, - global_receiver, - batch_sender, - batch_receiver, - event_sender, - event_receiver, - } - }) -} - -impl Globals { - /// Starts the global event processing system and metadata batching - pub async fn start(&self) { - self.connect().await; - self.preload_metadata().await; - self.subscribe_for_app_updates().await; - self.batching_metadata().detach(); // .detach() to keep running in background - - let mut notifications = self.client.notifications(); - let mut processed_events: BTreeSet = BTreeSet::new(); - let new_messages_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); - - while let Ok(notification) = notifications.recv().await { - if let RelayPoolNotification::Message { message, .. } = notification { - match message { - RelayMessage::Event { - event, - subscription_id, - } => { - if processed_events.contains(&event.id) { - continue; - } - // Skip events that have already been processed - processed_events.insert(event.id); - - match event.kind { - Kind::GiftWrap => { - if *subscription_id == new_messages_sub_id - || self - .event_sender - .send(event.clone().into_owned()) - .await - .is_err() - { - self.unwrap_event(&event, true).await; - } - } - Kind::Metadata => { - self.insert_person_from_event(&event).await; - } - Kind::ContactList => { - self.extract_pubkeys_and_sync(&event).await; - } - Kind::ReleaseArtifactSet => { - self.notify_update(&event).await; - } - _ => {} - } - } - RelayMessage::EndOfStoredEvents(subscription_id) => { - self.send_signal(NostrSignal::Eose(subscription_id.into_owned())) - .await; - } - _ => {} - } - } - } - } - - /// Gets a reference to the Nostr Client instance - pub fn client(&'static self) -> &'static Client { - &self.client - } - - /// Returns whether this is the first time the application has been run - pub fn first_run(&self) -> bool { - self.first_run - } - - /// Gets the global signal receiver - pub fn signal(&self) -> smol::channel::Receiver { - self.global_receiver.clone() - } - - /// Sends a signal through the global channel to notify GPUI - /// - /// # Arguments - /// * `signal` - The [`NostrSignal`] to send to GPUI - /// - /// # Examples - /// ``` - /// shared_state().send_signal(NostrSignal::Finish).await; - /// ``` - pub async fn send_signal(&self, signal: NostrSignal) { - if let Err(e) = self.global_sender.send(signal).await { - log::error!("Failed to send signal: {e}") - } - } - - /// Batch metadata requests. Combine all requests from multiple authors into single filter - pub(crate) fn batching_metadata(&self) -> Task<()> { - smol::spawn(async move { - let duration = Duration::from_millis(METADATA_BATCH_TIMEOUT); - let mut batch: BTreeSet = BTreeSet::new(); - - loop { - let timeout = smol::Timer::after(duration); - /// Internal events for the metadata batching system - enum BatchEvent { - NewKeys(PublicKey), - Timeout, - Closed, - } - - let event = smol::future::or( - async { - if let Ok(public_key) = shared_state().batch_receiver.recv().await { - BatchEvent::NewKeys(public_key) - } else { - BatchEvent::Closed - } - }, - async { - timeout.await; - BatchEvent::Timeout - }, - ) - .await; - - match event { - BatchEvent::NewKeys(public_key) => { - batch.insert(public_key); - // Process immediately if batch limit reached - if batch.len() >= METADATA_BATCH_LIMIT { - shared_state() - .sync_data_for_pubkeys(mem::take(&mut batch)) - .await; - } - } - BatchEvent::Timeout => { - if !batch.is_empty() { - shared_state() - .sync_data_for_pubkeys(mem::take(&mut batch)) - .await; - } - } - BatchEvent::Closed => { - if !batch.is_empty() { - shared_state() - .sync_data_for_pubkeys(mem::take(&mut batch)) - .await; - } - break; - } - } - } - }) - } - - /// Process to unwrap the gift wrapped events - pub(crate) fn process_gift_wrap_events(&self) -> Task<()> { - smol::spawn(async move { - let timeout_duration = Duration::from_secs(75); // 75 secs - let mut counter = 0; - - loop { - // Signer is unset, probably user is not ready to retrieve gift wrap events - if shared_state().client.signer().await.is_err() { - continue; - } - - let timeout = smol::Timer::after(timeout_duration); - - // TODO: Find a way to make this code prettier - let event = smol::future::or( - async { (shared_state().event_receiver.recv().await).ok() }, - async { - timeout.await; - None - }, - ) - .await; - - match event { - Some(event) => { - // Process the gift wrap event unwrapping - let is_cached = shared_state().unwrap_event(&event, false).await; - - // Increment the total messages counter if message is not from cache - if !is_cached { - counter += 1; - } - - // Send partial finish signal to GPUI - if counter >= 20 { - shared_state().send_signal(NostrSignal::PartialFinish).await; - // Reset counter - counter = 0; - } - } - None => { - shared_state().send_signal(NostrSignal::Finish).await; - break; - } - } - } - - // Event channel is no longer needed when all gift wrap events have been processed - shared_state().event_receiver.close(); - }) - } - - pub async fn request_metadata(&self, public_key: PublicKey) { - if let Err(e) = self.batch_sender.send(public_key).await { - log::error!("Failed to request metadata: {e}") - } - } - - /// Gets a person's profile from cache or creates default (blocking) - pub fn person(&self, public_key: &PublicKey) -> Profile { - let metadata = if let Some(metadata) = self.persons.read_blocking().get(public_key) { - metadata.clone().unwrap_or_default() - } else { - Metadata::default() - }; - - Profile::new(*public_key, metadata) - } - - /// Gets a person's profile from cache or creates default (async) - pub async fn async_person(&self, public_key: &PublicKey) -> Profile { - let metadata = if let Some(metadata) = self.persons.read().await.get(public_key) { - metadata.clone().unwrap_or_default() - } else { - Metadata::default() - }; - - Profile::new(*public_key, metadata) - } - - /// Check if a person exists or not - pub async fn has_person(&self, public_key: &PublicKey) -> bool { - self.persons.read().await.contains_key(public_key) - } - - /// Inserts or updates a person's metadata - pub async fn insert_person(&self, public_key: PublicKey, metadata: Option) { - self.persons - .write() - .await - .entry(public_key) - .and_modify(|entry| { - if entry.is_none() { - *entry = metadata.clone(); - } - }) - .or_insert_with(|| metadata); - } - - /// Inserts or updates a person's metadata from a Kind::Metadata event - pub(crate) async fn insert_person_from_event(&self, event: &Event) { - let metadata = Metadata::from_json(&event.content).ok(); - - self.persons - .write() - .await - .entry(event.pubkey) - .and_modify(|entry| { - if entry.is_none() { - *entry = metadata.clone(); - } - }) - .or_insert_with(|| metadata); - } - - /// Connects to bootstrap and configured relays - pub(crate) async fn connect(&self) { - for relay in BOOTSTRAP_RELAYS.into_iter() { - if let Err(e) = self.client.add_relay(relay).await { - log::error!("Failed to add relay {relay}: {e}"); - } - } - - for relay in SEARCH_RELAYS.into_iter() { - if let Err(e) = self.client.add_relay(relay).await { - log::error!("Failed to add relay {relay}: {e}"); - } - } - - // Establish connection to relays - self.client.connect().await; - - log::info!("Connected to bootstrap relays"); - } - - /// Subscribes to user-specific data feeds (DMs, mentions, etc.) - pub async fn subscribe_for_user_data(&self, public_key: PublicKey) { - let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); - let new_messages_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - - self.client - .subscribe( - Filter::new() - .author(public_key) - .kinds(vec![ - Kind::Metadata, - Kind::ContactList, - Kind::MuteList, - Kind::SimpleGroups, - Kind::InboxRelays, - Kind::RelayList, - ]) - .since(Timestamp::now()), - None, - ) - .await - .ok(); - - self.client - .subscribe( - Filter::new() - .kinds(vec![ - Kind::Metadata, - Kind::ContactList, - Kind::InboxRelays, - Kind::MuteList, - Kind::SimpleGroups, - ]) - .author(public_key) - .limit(10), - Some(SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE)), - ) - .await - .ok(); - - self.client - .subscribe_with_id( - all_messages_sub_id, - Filter::new().kind(Kind::GiftWrap).pubkey(public_key), - Some(opts), - ) - .await - .ok(); - - self.client - .subscribe_with_id( - new_messages_sub_id, - Filter::new() - .kind(Kind::GiftWrap) - .pubkey(public_key) - .limit(0), - None, - ) - .await - .ok(); - - log::info!("Getting all user's metadata and messages..."); - // Process gift-wrapped events in the background - self.process_gift_wrap_events().detach(); - } - - /// Subscribes to application update notifications - pub(crate) async fn subscribe_for_app_updates(&self) { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let coordinate = Coordinate { - kind: Kind::Custom(32267), - public_key: PublicKey::from_hex(APP_PUBKEY).expect("App Pubkey is invalid"), - identifier: APP_ID.into(), - }; - let filter = Filter::new() - .kind(Kind::ReleaseArtifactSet) - .coordinate(&coordinate) - .limit(1); - - if let Err(e) = self - .client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await - { - log::error!("Failed to subscribe for app updates: {e}"); - } - - log::info!("Subscribed to app updates"); - } - - pub(crate) async fn preload_metadata(&self) { - let filter = Filter::new().kind(Kind::Metadata).limit(100); - if let Ok(events) = self.client.database().query(filter).await { - for event in events.into_iter() { - self.insert_person_from_event(&event).await; - } - } - } - - /// Stores an unwrapped event in local database with reference to original - pub(crate) async fn set_unwrapped( - &self, - root: EventId, - event: &Event, - keys: &Keys, - ) -> Result<(), Error> { - // Must be use the random generated keys to sign this event - let event = EventBuilder::new(Kind::ApplicationSpecificData, event.as_json()) - .tags(vec![Tag::identifier(root), Tag::event(root)]) - .sign(keys) - .await?; - - // Only save this event into the local database - self.client.database().save_event(&event).await?; - - Ok(()) - } - - /// Retrieves a previously unwrapped event from local database - pub(crate) async fn get_unwrapped(&self, target: EventId) -> Result { - let filter = Filter::new() - .kind(Kind::ApplicationSpecificData) - .identifier(target) - .event(target) - .limit(1); - - if let Some(event) = self.client.database().query(filter).await?.first_owned() { - Ok(Event::from_json(event.content)?) - } else { - Err(anyhow!("Event is not cached yet")) - } - } - - /// Unwraps a gift-wrapped event and processes its contents. - /// - /// # Arguments - /// * `event` - The gift-wrapped event to unwrap - /// * `incoming` - Whether this is a newly received event (true) or old - /// - /// # Returns - /// Returns `true` if the event was successfully loaded from cache or saved after unwrapping. - pub(crate) async fn unwrap_event(&self, event: &Event, incoming: bool) -> bool { - let mut is_cached = false; - - let event = match self.get_unwrapped(event.id).await { - Ok(event) => { - is_cached = true; - event - } - Err(_) => { - match self.client.unwrap_gift_wrap(event).await { - Ok(unwrap) => { - let keys = Keys::generate(); - let Ok(unwrapped) = unwrap.rumor.sign_with_keys(&keys) else { - return false; - }; - - // Save this event to the database for future use. - if let Err(e) = self.set_unwrapped(event.id, &unwrapped, &keys).await { - log::error!("Failed to save event: {e}") - } - - unwrapped - } - Err(_) => return false, - } - } - }; - - // Save the event to the database, use for query directly. - if let Err(e) = self.client.database().save_event(&event).await { - log::error!("Failed to save event: {e}") - } - - // Send all pubkeys to the batch to sync metadata - self.batch_sender.send(event.pubkey).await.ok(); - - for public_key in event.tags.public_keys().copied() { - self.batch_sender.send(public_key).await.ok(); - } - - // Send a notify to GPUI if this is a new message - if incoming { - self.send_signal(NostrSignal::Event(event)).await; - } - - is_cached - } - - /// Extracts public keys from contact list and queues metadata sync - pub(crate) async fn extract_pubkeys_and_sync(&self, event: &Event) { - if let Ok(signer) = self.client.signer().await { - if let Ok(public_key) = signer.get_public_key().await { - if public_key == event.pubkey { - for public_key in event.tags.public_keys().copied() { - self.batch_sender.send(public_key).await.ok(); - } - } - } - } - } - - /// Fetches metadata for a batch of public keys - pub(crate) async fn sync_data_for_pubkeys(&self, public_keys: BTreeSet) { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let kinds = vec![ - Kind::Metadata, - Kind::ContactList, - Kind::InboxRelays, - Kind::UserStatus, - ]; - let filter = Filter::new() - .limit(public_keys.len() * kinds.len()) - .authors(public_keys) - .kinds(kinds); - - if let Err(e) = shared_state() - .client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await - { - log::error!("Failed to sync metadata: {e}"); - } - } - - /// Notifies UI of application updates via global channel - pub(crate) async fn notify_update(&self, event: &Event) { - let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); - let filter = Filter::new() - .ids(event.tags.event_ids().copied()) - .kind(Kind::FileMetadata); - - if let Err(e) = self - .client - .subscribe_to(BOOTSTRAP_RELAYS, filter, Some(opts)) - .await - { - log::error!("Failed to subscribe for file metadata: {e}"); - } else { - self.send_signal(NostrSignal::AppUpdate(event.to_owned())) - .await; - } - } -} - -fn is_first_run() -> Result { - let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION"))); - - if !flag.exists() { - fs::write(&flag, "")?; - Ok(true) // First run - } else { - Ok(false) // Not first run - } -} +use std::fs; +use std::sync::OnceLock; + +use nostr_connect::prelude::*; +use nostr_sdk::prelude::*; +use paths::nostr_file; + +use crate::paths::support_dir; + +pub mod constants; +pub mod paths; + +/// Signals sent through the global event channel to notify UI components +#[derive(Debug, Clone)] +pub enum NostrSignal { + /// Received a new metadata event from Relay Pool + Metadata(Event), + + /// Received a new gift wrap event from Relay Pool + GiftWrap(Event), + + /// Finished processing all gift wrap events + Finish, + + /// Partially finished processing all gift wrap events + PartialFinish, + + /// Receives EOSE response from relay pool + Eose(SubscriptionId), + + /// Notice from Relay Pool + Notice(String), + + /// Application update event received + AppUpdate(Event), +} + +static NOSTR_CLIENT: OnceLock = OnceLock::new(); +static FIRST_RUN: OnceLock = OnceLock::new(); + +pub fn nostr_client() -> &'static Client { + NOSTR_CLIENT.get_or_init(|| { + // rustls uses the `aws_lc_rs` provider by default + // This only errors if the default provider has already + // been installed. We can ignore this `Result`. + rustls::crypto::aws_lc_rs::default_provider() + .install_default() + .ok(); + + let opts = ClientOptions::new().gossip(true); + let lmdb = NostrLMDB::open(nostr_file()).expect("Database is NOT initialized"); + + ClientBuilder::default().database(lmdb).opts(opts).build() + }) +} + +pub fn first_run() -> &'static bool { + FIRST_RUN.get_or_init(|| { + let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION"))); + + if !flag.exists() { + if fs::write(&flag, "").is_err() { + return false; + } + true // First run + } else { + false // Not first run + } + }) +} diff --git a/crates/identity/Cargo.toml b/crates/identity/Cargo.toml index 4650993..3dc950a 100644 --- a/crates/identity/Cargo.toml +++ b/crates/identity/Cargo.toml @@ -11,8 +11,6 @@ common = { path = "../common" } client_keys = { path = "../client_keys" } settings = { path = "../settings" } -rust-i18n.workspace = true -i18n.workspace = true nostr-sdk.workspace = true nostr-connect.workspace = true oneshot.workspace = true diff --git a/crates/identity/src/lib.rs b/crates/identity/src/lib.rs index 1fb2092..3d5c865 100644 --- a/crates/identity/src/lib.rs +++ b/crates/identity/src/lib.rs @@ -3,8 +3,11 @@ use std::time::Duration; use anyhow::{anyhow, Error}; use client_keys::ClientKeys; use common::handle_auth::CoopAuthUrlHandler; -use global::constants::{ACCOUNT_D, NIP17_RELAYS, NIP65_RELAYS, NOSTR_CONNECT_TIMEOUT}; -use global::shared_state; +use global::constants::{ + ACCOUNT_D, ALL_MESSAGES_SUB_ID, NEW_MESSAGE_SUB_ID, NIP17_RELAYS, NIP65_RELAYS, + NOSTR_CONNECT_TIMEOUT, +}; +use global::nostr_client; use gpui::prelude::FluentBuilder; use gpui::{ div, red, App, AppContext, Context, Entity, Global, ParentElement, SharedString, Styled, @@ -18,8 +21,6 @@ use ui::input::{InputState, TextInput}; use ui::notification::Notification; use ui::{ContextModal, Sizable}; -i18n::init!(); - pub fn init(window: &mut Window, cx: &mut App) { Identity::set_global(cx.new(|cx| Identity::new(window, cx)), cx); } @@ -29,7 +30,7 @@ struct GlobalIdentity(Entity); impl Global for GlobalIdentity {} pub struct Identity { - profile: Option, + public_key: Option, auto_logging_in_progress: bool, #[allow(dead_code)] subscriptions: SmallVec<[Subscription; 1]>, @@ -42,7 +43,7 @@ impl Identity { } /// Retrieve the Identity instance - pub fn get_global(cx: &App) -> &Self { + pub fn read_global(cx: &App) -> &Self { cx.global::().0.read(cx) } @@ -65,13 +66,13 @@ impl Identity { this.set_logging_in(true, cx); this.load(window, cx); } else { - this.set_profile(None, cx); + this.set_public_key(None, cx); } }), ); Self { - profile: None, + public_key: None, auto_logging_in_progress: false, subscriptions, } @@ -79,14 +80,12 @@ impl Identity { pub fn load(&mut self, window: &mut Window, cx: &mut Context) { let task = cx.background_spawn(async move { - let database = shared_state().client().database(); - let filter = Filter::new() .kind(Kind::ApplicationSpecificData) .identifier(ACCOUNT_D) .limit(1); - if let Some(event) = database.query(filter).await?.first_owned() { + if let Some(event) = nostr_client().database().query(filter).await?.first_owned() { let secret = event.content; let is_bunker = secret.starts_with("bunker://"); @@ -107,7 +106,7 @@ impl Identity { .ok(); } else { this.update(cx, |this, cx| { - this.set_profile(None, cx); + this.set_public_key(None, cx); }) .ok(); } @@ -116,24 +115,30 @@ impl Identity { } pub fn unload(&mut self, window: &mut Window, cx: &mut Context) { - let task = cx.background_spawn(async move { - let client = shared_state().client(); + let task: Task> = cx.background_spawn(async move { + let client = nostr_client(); let filter = Filter::new() .kind(Kind::ApplicationSpecificData) - .identifier(ACCOUNT_D) - .limit(1); + .identifier(ACCOUNT_D); // Unset signer client.unset_signer().await; - // Delete account - client.database().delete(filter).await.is_ok() + client.database().delete(filter).await?; + + Ok(()) }); - cx.spawn_in(window, async move |this, cx| { - if task.await { + cx.spawn_in(window, async move |this, cx| match task.await { + Ok(_) => { this.update(cx, |this, cx| { - this.set_profile(None, cx); + this.set_public_key(None, cx); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); }) .ok(); } @@ -153,13 +158,13 @@ impl Identity { self.login_with_bunker(uri, window, cx); } else { window.push_notification(Notification::error("Bunker URI is invalid"), cx); - self.set_profile(None, cx); + self.set_public_key(None, cx); } } else if let Ok(enc) = EncryptedSecretKey::from_bech32(secret) { self.login_with_keys(enc, window, cx); } else { window.push_notification(Notification::error("Secret Key is invalid"), cx); - self.set_profile(None, cx); + self.set_public_key(None, cx); } } @@ -177,7 +182,7 @@ impl Identity { Notification::error("Bunker URI is invalid").title("Nostr Connect"), cx, ); - self.set_profile(None, cx); + self.set_public_key(None, cx); return; }; // Automatically open auth url @@ -197,12 +202,9 @@ impl Identity { } Err(e) => { cx.update(|window, cx| { - window.push_notification( - Notification::error(e.to_string()).title("Nostr Connect"), - cx, - ); + window.push_notification(Notification::error(e.to_string()), cx); this.update(cx, |this, cx| { - this.set_profile(None, cx); + this.set_public_key(None, cx); }) .ok(); }) @@ -240,7 +242,7 @@ impl Identity { .on_cancel(move |_, _window, cx| { entity .update(cx, |this, cx| { - this.set_profile(None, cx); + this.set_public_key(None, cx); }) .ok(); // Close modal @@ -338,39 +340,32 @@ impl Identity { where S: NostrSigner + 'static, { - let task: Task> = cx.background_spawn(async move { - let client = shared_state().client(); + let task: Task> = cx.background_spawn(async move { + let client = nostr_client(); let public_key = signer.get_public_key().await?; - // Update signer client.set_signer(signer).await; + // Subscribe for user metadata + Self::subscribe(client, public_key).await?; - // Subscribe for user's data - shared_state().subscribe_for_user_data(public_key).await; - - // Fetch user's metadata - let metadata = client - .fetch_metadata(public_key, Duration::from_secs(3)) - .await? - .unwrap_or_default(); - - // Create user's profile with public key and metadata - Ok(Profile::new(public_key, metadata)) + Ok(public_key) }); - cx.spawn_in(window, async move |this, cx| match task.await { - Ok(profile) => { - this.update(cx, |this, cx| { - this.set_profile(Some(profile), cx); - }) - .ok(); - } - Err(e) => { - cx.update(|window, cx| { - window.push_notification(Notification::error(e.to_string()), cx); - }) - .ok(); - } + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(public_key) => { + this.update(cx, |this, cx| { + this.set_public_key(Some(public_key), cx); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + }) + .ok(); + } + }; }) .detach(); } @@ -378,25 +373,24 @@ impl Identity { /// Creates a new identity with the given keys and metadata pub fn new_identity( &mut self, - keys: Keys, password: String, metadata: Metadata, + window: &mut Window, cx: &mut Context, ) { - let profile = Profile::new(keys.public_key(), metadata.clone()); - // Save keys for further use - self.write_keys(&keys, password, cx); - - cx.background_spawn(async move { - let client = shared_state().client(); + let keys = Keys::generate(); + let async_keys = keys.clone(); + let task: Task> = cx.background_spawn(async move { + let client = nostr_client(); + let public_key = async_keys.public_key(); // Update signer - client.set_signer(keys).await; + client.set_signer(async_keys).await; // Set metadata - client.set_metadata(&metadata).await.ok(); + client.set_metadata(&metadata).await?; // Create relay list - let builder = EventBuilder::new(Kind::RelayList, "").tags( + let relay_list = EventBuilder::new(Kind::RelayList, "").tags( NIP65_RELAYS.into_iter().filter_map(|url| { if let Ok(url) = RelayUrl::parse(url) { Some(Tag::relay_metadata(url, None)) @@ -406,12 +400,8 @@ impl Identity { }), ); - if let Err(e) = client.send_event_builder(builder).await { - log::error!("Failed to send relay list event: {e}"); - }; - // Create messaging relay list - let builder = EventBuilder::new(Kind::InboxRelays, "").tags( + let dm_relay = EventBuilder::new(Kind::InboxRelays, "").tags( NIP17_RELAYS.into_iter().filter_map(|url| { if let Ok(url) = RelayUrl::parse(url) { Some(Tag::relay(url)) @@ -421,14 +411,31 @@ impl Identity { }), ); - if let Err(e) = client.send_event_builder(builder).await { - log::error!("Failed to send messaging relay list event: {e}"); - }; + client.send_event_builder(relay_list).await?; + client.send_event_builder(dm_relay).await?; - // Subscribe for user's data - shared_state() - .subscribe_for_user_data(profile.public_key()) - .await; + // Subscribe for user metadata + Self::subscribe(client, public_key).await?; + + Ok(public_key) + }); + + cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(public_key) => { + this.update(cx, |this, cx| { + this.write_keys(&keys, password, cx); + this.set_public_key(Some(public_key), cx); + }) + .ok(); + } + Err(e) => { + cx.update(|window, cx| { + window.push_notification(Notification::error(e.to_string()), cx); + }) + .ok(); + } + }; }) .detach(); } @@ -447,7 +454,7 @@ impl Identity { } cx.background_spawn(async move { - let client = shared_state().client(); + let client = nostr_client(); let keys = Keys::generate(); let builder = EventBuilder::new(Kind::ApplicationSpecificData, value).tags(vec![ @@ -472,17 +479,15 @@ impl Identity { if let Ok(enc_key) = EncryptedSecretKey::new(keys.secret_key(), &password, 8, KeySecurity::Unknown) { - let client = shared_state().client(); - let keys = Keys::generate(); + let client = nostr_client(); + let content = enc_key.to_bech32().unwrap(); - let builder = - EventBuilder::new(Kind::ApplicationSpecificData, enc_key.to_bech32().unwrap()) - .tags(vec![ - Tag::identifier(ACCOUNT_D), - Tag::public_key(public_key), - ]); + let builder = EventBuilder::new(Kind::ApplicationSpecificData, content).tags(vec![ + Tag::identifier(ACCOUNT_D), + Tag::public_key(public_key), + ]); - if let Ok(event) = builder.sign(&keys).await { + if let Ok(event) = builder.sign(&Keys::generate()).await { if let Err(e) = client.database().save_event(&event).await { log::error!("Failed to save event: {e}"); }; @@ -492,19 +497,19 @@ impl Identity { .detach(); } - pub(crate) fn set_profile(&mut self, profile: Option, cx: &mut Context) { - self.profile = profile; + pub(crate) fn set_public_key(&mut self, public_key: Option, cx: &mut Context) { + self.public_key = public_key; cx.notify(); } - /// Returns the current profile - pub fn profile(&self) -> Option { - self.profile.as_ref().cloned() + /// Returns the current identity's public key + pub fn public_key(&self) -> Option { + self.public_key } - /// Returns true if a profile is currently loaded - pub fn has_profile(&self) -> bool { - self.profile.is_some() + /// Returns true if a signer is currently set + pub fn has_signer(&self) -> bool { + self.public_key.is_some() } pub fn logging_in(&self) -> bool { @@ -515,4 +520,60 @@ impl Identity { self.auto_logging_in_progress = status; cx.notify(); } + + pub(crate) async fn subscribe(client: &Client, public_key: PublicKey) -> Result<(), Error> { + let all_messages_sub_id = SubscriptionId::new(ALL_MESSAGES_SUB_ID); + let new_messages_sub_id = SubscriptionId::new(NEW_MESSAGE_SUB_ID); + let opts = SubscribeAutoCloseOptions::default().exit_policy(ReqExitPolicy::ExitOnEOSE); + + client + .subscribe( + Filter::new() + .author(public_key) + .kinds(vec![ + Kind::Metadata, + Kind::ContactList, + Kind::MuteList, + Kind::SimpleGroups, + Kind::InboxRelays, + Kind::RelayList, + ]) + .since(Timestamp::now()), + None, + ) + .await?; + + client + .subscribe( + Filter::new() + .kinds(vec![Kind::Metadata, Kind::ContactList, Kind::RelayList]) + .author(public_key) + .limit(10), + Some(opts), + ) + .await?; + + client + .subscribe_with_id( + all_messages_sub_id, + Filter::new().kind(Kind::GiftWrap).pubkey(public_key), + Some(opts), + ) + .await?; + + client + .subscribe_with_id( + new_messages_sub_id, + Filter::new() + .kind(Kind::GiftWrap) + .pubkey(public_key) + .limit(0), + None, + ) + .await?; + + log::info!("Getting all user's metadata and messages..."); + + Ok(()) + } } diff --git a/crates/chats/Cargo.toml b/crates/registry/Cargo.toml similarity index 92% rename from crates/chats/Cargo.toml rename to crates/registry/Cargo.toml index a779d2f..6d10cfa 100644 --- a/crates/chats/Cargo.toml +++ b/crates/registry/Cargo.toml @@ -1,5 +1,5 @@ [package] -name = "chats" +name = "registry" version.workspace = true edition.workspace = true publish.workspace = true diff --git a/crates/chats/src/lib.rs b/crates/registry/src/lib.rs similarity index 66% rename from crates/chats/src/lib.rs rename to crates/registry/src/lib.rs index 4afa570..4dbfeb2 100644 --- a/crates/chats/src/lib.rs +++ b/crates/registry/src/lib.rs @@ -1,11 +1,11 @@ use std::cmp::Reverse; -use std::collections::{BTreeSet, HashMap}; +use std::collections::{BTreeMap, BTreeSet, HashMap}; use anyhow::Error; use common::room_hash; use fuzzy_matcher::skim::SkimMatcherV2; use fuzzy_matcher::FuzzyMatcher; -use global::shared_state; +use global::nostr_client; use gpui::{ App, AppContext, Context, Entity, EventEmitter, Global, Subscription, Task, WeakEntity, Window, }; @@ -20,17 +20,15 @@ use crate::room::Room; pub mod message; pub mod room; -mod constants; - i18n::init!(); pub fn init(cx: &mut App) { - ChatRegistry::set_global(cx.new(ChatRegistry::new), cx); + Registry::set_global(cx.new(Registry::new), cx); } -struct GlobalChatRegistry(Entity); +struct GlobalRegistry(Entity); -impl Global for GlobalChatRegistry {} +impl Global for GlobalRegistry {} #[derive(Debug)] pub enum RoomEmitter { @@ -39,16 +37,13 @@ pub enum RoomEmitter { } /// Main registry for managing chat rooms and user profiles -/// -/// The ChatRegistry is responsible for: -/// - Managing chat rooms and their states -/// - Tracking user profiles -/// - Loading room data from the lmdb -/// - Handling messages and room creation -pub struct ChatRegistry { +pub struct Registry { /// Collection of all chat rooms pub rooms: Vec>, + /// Collection of all persons (user profiles) + pub persons: BTreeMap>, + /// Indicates if rooms are currently being loaded /// /// Always equal to `true` when the app starts @@ -56,43 +51,126 @@ pub struct ChatRegistry { /// Subscriptions for observing changes #[allow(dead_code)] - subscriptions: SmallVec<[Subscription; 1]>, + subscriptions: SmallVec<[Subscription; 2]>, } -impl EventEmitter for ChatRegistry {} +impl EventEmitter for Registry {} -impl ChatRegistry { - /// Retrieve the Global ChatRegistry instance +impl Registry { + /// Retrieve the Global Registry state pub fn global(cx: &App) -> Entity { - cx.global::().0.clone() + cx.global::().0.clone() } - /// Retrieve the ChatRegistry instance - pub fn get_global(cx: &App) -> &Self { - cx.global::().0.read(cx) + /// Retrieve the Registry instance + pub fn read_global(cx: &App) -> &Self { + cx.global::().0.read(cx) } - /// Set the global ChatRegistry instance + /// Set the global Registry instance pub(crate) fn set_global(state: Entity, cx: &mut App) { - cx.set_global(GlobalChatRegistry(state)); + cx.set_global(GlobalRegistry(state)); } - /// Create a new ChatRegistry instance - fn new(cx: &mut Context) -> Self { + /// Create a new Registry instance + pub(crate) fn new(cx: &mut Context) -> Self { let mut subscriptions = smallvec![]; - // When any Room is created, load metadata for all members + // Load all user profiles from the database when the Registry is created + subscriptions.push(cx.observe_new::(|this, _window, cx| { + let task = this.load_local_person(cx); + this.set_persons_from_task(task, cx); + })); + + // When any Room is created, load members metadata subscriptions.push(cx.observe_new::(|this, _window, cx| { - this.load_metadata(cx).detach(); + let task = this.load_metadata(cx); + Self::global(cx).update(cx, |this, cx| { + this.set_persons_from_task(task, cx); + }); })); Self { rooms: vec![], + persons: BTreeMap::new(), loading: true, subscriptions, } } + pub(crate) fn set_persons_from_task( + &mut self, + task: Task, Error>>, + cx: &mut Context, + ) { + cx.spawn(async move |this, cx| { + if let Ok(profiles) = task.await { + this.update(cx, |this, cx| { + for profile in profiles { + this.persons + .insert(profile.public_key(), cx.new(|_| profile)); + } + cx.notify(); + }) + .ok(); + } + }) + .detach(); + } + + pub(crate) fn load_local_person(&self, cx: &App) -> Task, Error>> { + cx.background_spawn(async move { + let filter = Filter::new().kind(Kind::Metadata).limit(100); + let events = nostr_client().database().query(filter).await?; + let mut profiles = vec![]; + + for event in events.into_iter() { + let metadata = Metadata::from_json(event.content).unwrap_or_default(); + let profile = Profile::new(event.pubkey, metadata); + profiles.push(profile); + } + + Ok(profiles) + }) + } + + pub fn get_person(&self, public_key: &PublicKey, cx: &App) -> Profile { + self.persons + .get(public_key) + .map(|e| e.read(cx)) + .cloned() + .unwrap_or(Profile::new(public_key.to_owned(), Metadata::default())) + } + + pub fn get_group_person(&self, public_keys: &[PublicKey], cx: &App) -> Vec> { + let mut profiles = vec![]; + + for public_key in public_keys.iter() { + let profile = self.persons.get(public_key).map(|e| e.read(cx)).cloned(); + profiles.push(profile); + } + + profiles + } + + pub fn insert_or_update_person(&mut self, event: Event, cx: &mut App) { + let public_key = event.pubkey; + let Ok(metadata) = Metadata::from_json(event.content) else { + // Invalid metadata, no need to process further. + return; + }; + + if let Some(person) = self.persons.get(&public_key) { + person.update(cx, |this, cx| { + *this = Profile::new(public_key, metadata); + cx.notify(); + }); + } else { + self.persons + .insert(public_key, cx.new(|_| Profile::new(public_key, metadata))); + } + } + /// Get a room by its ID. pub fn room(&self, id: &u64, cx: &App) -> Option> { self.rooms @@ -125,6 +203,12 @@ impl ChatRegistry { .collect() } + /// Add a new room to the start of list. + pub fn add_room(&mut self, room: Entity, cx: &mut Context) { + self.rooms.insert(0, room); + cx.notify(); + } + /// Sort rooms by their created at. pub fn sort(&mut self, cx: &mut Context) { self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at)); @@ -155,6 +239,12 @@ impl ChatRegistry { .collect() } + /// Set the loading status of the registry. + pub fn set_loading(&mut self, status: bool, cx: &mut Context) { + self.loading = status; + cx.notify(); + } + /// Load all rooms from the lmdb. /// /// This method: @@ -166,7 +256,7 @@ impl ChatRegistry { log::info!("Starting to load rooms from database..."); let task: Task, Error>> = cx.background_spawn(async move { - let client = shared_state().client(); + let client = nostr_client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; @@ -278,18 +368,15 @@ impl ChatRegistry { /// Push a new Room to the global registry pub fn push_room(&mut self, room: Entity, cx: &mut Context) { - let weak_room = if let Some(room) = self - .rooms - .iter() - .find(|this| this.read(cx).id == room.read(cx).id) - { + let other_id = room.read(cx).id; + let find_room = self.rooms.iter().find(|this| this.read(cx).id == other_id); + + let weak_room = if let Some(room) = find_room { room.downgrade() } else { let weak_room = room.downgrade(); - - // Add this room to the global registry - self.rooms.insert(0, room); - cx.notify(); + // Add this room to the registry + self.add_room(room, cx); weak_room }; @@ -304,7 +391,8 @@ impl ChatRegistry { pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context) { let id = room_hash(&event); let author = event.pubkey; - let Some(public_key) = Identity::get_global(cx).profile().map(|i| i.public_key()) else { + + let Some(identity) = Identity::read_global(cx).public_key() else { return; }; @@ -314,7 +402,7 @@ impl ChatRegistry { this.created_at(event.created_at, cx); // Set this room is ongoing if the new message is from current user - if author == public_key { + if author == identity { this.set_ongoing(cx); } @@ -326,22 +414,17 @@ impl ChatRegistry { // Re-sort the rooms registry by their created at self.sort(cx); - - cx.notify(); } else { let room = Room::new(&event).kind(RoomKind::Unknown); let kind = room.kind; // Push the new room to the front of the list - self.rooms.insert(0, cx.new(|_| room)); + self.add_room(cx.new(|_| room), cx); - cx.emit(RoomEmitter::Request(kind)); - cx.notify(); + // Notify the UI about the new room + cx.defer_in(window, move |_this, _window, cx| { + cx.emit(RoomEmitter::Request(kind)); + }); } } - - pub fn set_loading(&mut self, status: bool, cx: &mut Context) { - self.loading = status; - cx.notify(); - } } diff --git a/crates/chats/src/message.rs b/crates/registry/src/message.rs similarity index 74% rename from crates/chats/src/message.rs rename to crates/registry/src/message.rs index 272ee42..3be1716 100644 --- a/crates/chats/src/message.rs +++ b/crates/registry/src/message.rs @@ -5,6 +5,7 @@ use std::rc::Rc; use chrono::{Local, TimeZone}; use gpui::SharedString; use nostr_sdk::prelude::*; +use smallvec::{smallvec, SmallVec}; use crate::room::SendError; @@ -15,54 +16,50 @@ use crate::room::SendError; #[derive(Debug, Clone, PartialEq, Eq)] pub struct Message { /// Unique identifier of the message (EventId from nostr_sdk) - pub id: Option, - /// Author profile information - pub author: Option, + pub id: EventId, + /// Author's public key + pub author: PublicKey, /// The content/text of the message pub content: SharedString, /// When the message was created pub created_at: Timestamp, - /// List of mentioned profiles in the message - pub mentions: Vec, + /// List of mentioned public keys in the message + pub mentions: SmallVec<[PublicKey; 2]>, /// List of EventIds this message is replying to - pub replies_to: Option>, + pub replies_to: Option>, /// Any errors that occurred while sending this message - pub errors: Option>, + pub errors: Option>, } /// Builder pattern implementation for constructing Message objects. -#[derive(Debug, Default)] +#[derive(Debug)] pub struct MessageBuilder { - id: Option, - author: Option, - content: Option, + id: EventId, + author: PublicKey, + content: Option, created_at: Option, - mentions: Vec, - replies_to: Option>, - errors: Option>, + mentions: SmallVec<[PublicKey; 2]>, + replies_to: Option>, + errors: Option>, } impl MessageBuilder { /// Creates a new MessageBuilder with default values - pub fn new() -> Self { - Self::default() - } - - /// Sets the message ID - pub fn id(mut self, id: EventId) -> Self { - self.id = Some(id); - self - } - - /// Sets the message author - pub fn author(mut self, author: Profile) -> Self { - self.author = Some(author); - self + pub fn new(id: EventId, author: PublicKey) -> Self { + Self { + id, + author, + content: None, + created_at: None, + mentions: smallvec![], + replies_to: None, + errors: None, + } } /// Sets the message content - pub fn content(mut self, content: String) -> Self { - self.content = Some(content); + pub fn content(mut self, content: impl Into) -> Self { + self.content = Some(content.into()); self } @@ -73,7 +70,7 @@ impl MessageBuilder { } /// Adds a single mention to the message - pub fn mention(mut self, mention: Profile) -> Self { + pub fn mention(mut self, mention: PublicKey) -> Self { self.mentions.push(mention); self } @@ -81,7 +78,7 @@ impl MessageBuilder { /// Adds multiple mentions to the message pub fn mentions(mut self, mentions: I) -> Self where - I: IntoIterator, + I: IntoIterator, { self.mentions.extend(mentions); self @@ -89,7 +86,7 @@ impl MessageBuilder { /// Sets a single message this is replying to pub fn reply_to(mut self, reply_to: EventId) -> Self { - self.replies_to = Some(vec![reply_to]); + self.replies_to = Some(smallvec![reply_to]); self } @@ -98,7 +95,7 @@ impl MessageBuilder { where I: IntoIterator, { - let replies: Vec = replies_to.into_iter().collect(); + let replies: SmallVec<[EventId; 1]> = replies_to.into_iter().collect(); if !replies.is_empty() { self.replies_to = Some(replies); } @@ -124,7 +121,7 @@ impl MessageBuilder { Ok(Message { id: self.id, author: self.author, - content: self.content.ok_or("Content is required")?.into(), + content: self.content.ok_or("Content is required")?, created_at: self.created_at.unwrap_or_else(Timestamp::now), mentions: self.mentions, replies_to: self.replies_to, @@ -135,8 +132,8 @@ impl MessageBuilder { impl Message { /// Creates a new MessageBuilder - pub fn builder() -> MessageBuilder { - MessageBuilder::new() + pub fn builder(id: EventId, author: PublicKey) -> MessageBuilder { + MessageBuilder::new(id, author) } /// Converts the message into an Rc> diff --git a/crates/chats/src/room.rs b/crates/registry/src/room.rs similarity index 72% rename from crates/chats/src/room.rs rename to crates/registry/src/room.rs index 5b5d3d8..8906756 100644 --- a/crates/chats/src/room.rs +++ b/crates/registry/src/room.rs @@ -1,18 +1,24 @@ use std::cmp::Ordering; -use std::sync::Arc; use anyhow::{anyhow, Error}; use chrono::{Local, TimeZone}; -use common::profile::RenderProfile; -use global::shared_state; +use common::display::DisplayProfile; +use global::nostr_client; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task, Window}; use identity::Identity; use itertools::Itertools; use nostr_sdk::prelude::*; use settings::AppSettings; +use smallvec::SmallVec; -use crate::constants::{DAYS_IN_MONTH, HOURS_IN_DAY, MINUTES_IN_HOUR, NOW, SECONDS_IN_MINUTE}; use crate::message::Message; +use crate::Registry; + +pub(crate) const NOW: &str = "now"; +pub(crate) const SECONDS_IN_MINUTE: i64 = 60; +pub(crate) const MINUTES_IN_HOUR: i64 = 60; +pub(crate) const HOURS_IN_DAY: i64 = 24; +pub(crate) const DAYS_IN_MONTH: i64 = 30; #[derive(Debug, Clone)] pub struct Incoming(pub Message); @@ -40,7 +46,7 @@ pub struct Room { /// Picture of the room pub picture: Option, /// All members of the room - pub members: Arc>, + pub members: SmallVec<[PublicKey; 2]>, /// Kind pub kind: RoomKind, } @@ -57,26 +63,17 @@ impl PartialOrd for Room { } } -impl Eq for Room {} - impl PartialEq for Room { fn eq(&self, other: &Self) -> bool { self.id == other.id } } +impl Eq for Room {} + impl EventEmitter for Room {} impl Room { - /// Creates a new Room instance from a Nostr event - /// - /// # Arguments - /// - /// * `event` - The Nostr event containing chat information - /// - /// # Returns - /// - /// A new Room instance with information extracted from the event pub fn new(event: &Event) -> Self { let id = common::room_hash(event); let created_at = event.created_at; @@ -87,7 +84,7 @@ impl Room { pubkeys.push(event.pubkey); // Convert pubkeys into members - let members = Arc::new(pubkeys.into_iter().unique().sorted().collect()); + let members = pubkeys.into_iter().unique().sorted().collect(); // Get the subject from the event's tags let subject = if let Some(tag) = event.tags.find(TagKind::Subject) { @@ -113,30 +110,88 @@ impl Room { } } - /// Sets the kind of the room + /// Sets the kind of the room and returns the modified room + /// + /// This is a builder-style method that allows chaining room modifications. /// /// # Arguments /// - /// * `kind` - The kind of room to set + /// * `kind` - The RoomKind to set for this room /// /// # Returns /// - /// The room with the updated kind + /// The modified Room instance with the new kind pub fn kind(mut self, kind: RoomKind) -> Self { self.kind = kind; self } - /// Calculates a human-readable representation of the time passed since room creation + /// Set the room kind to ongoing + /// + /// # Arguments + /// + /// * `cx` - The context to notify about the update + pub fn set_ongoing(&mut self, cx: &mut Context) { + if self.kind != RoomKind::Ongoing { + self.kind = RoomKind::Ongoing; + cx.notify(); + } + } + + /// Checks if the room is a group chat /// /// # Returns /// - /// A SharedString representing the relative time since room creation: - /// - "now" for less than a minute - /// - "Xm" for minutes - /// - "Xh" for hours - /// - "Xd" for days - /// - Month and day (e.g. "Jan 15") for older dates + /// true if the room has more than 2 members, false otherwise + pub fn is_group(&self) -> bool { + self.members.len() > 2 + } + + /// Updates the creation timestamp of the room + /// + /// # Arguments + /// + /// * `created_at` - The new Timestamp to set + /// * `cx` - The context to notify about the update + pub fn created_at(&mut self, created_at: impl Into, cx: &mut Context) { + self.created_at = created_at.into(); + cx.notify(); + } + + /// Updates the subject of the room + /// + /// # Arguments + /// + /// * `subject` - The new subject to set + /// * `cx` - The context to notify about the update + pub fn subject(&mut self, subject: impl Into, cx: &mut Context) { + self.subject = Some(subject.into()); + cx.notify(); + } + + /// Updates the picture of the room + /// + /// # Arguments + /// + /// * `picture` - The new subject to set + /// * `cx` - The context to notify about the update + pub fn picture(&mut self, picture: impl Into, cx: &mut Context) { + self.picture = Some(picture.into()); + cx.notify(); + } + + /// Returns a human-readable string representing how long ago the room was created + /// + /// The string will be formatted differently based on the time elapsed: + /// - Less than a minute: "now" + /// - Less than an hour: "Xm" (minutes) + /// - Less than a day: "Xh" (hours) + /// - Less than a month: "Xd" (days) + /// - More than a month: "MMM DD" (month abbreviation and day) + /// + /// # Returns + /// + /// A SharedString containing the formatted time representation pub fn ago(&self) -> SharedString { let input_time = match Local.timestamp_opt(self.created_at.as_u64() as i64, 0) { chrono::LocalResult::Single(time) => time, @@ -156,56 +211,86 @@ impl Room { .into() } - /// Gets the first member in the room that isn't the current user + /// Gets the display name for the room + /// + /// If the room has a subject set, that will be used as the display name. + /// Otherwise, it will generate a name based on the room members. /// /// # Arguments /// - /// * `cx` - The App context + /// * `cx` - The application context /// /// # Returns /// - /// The Profile of the first member in the room - pub fn first_member(&self, cx: &App) -> Profile { - let Some(account) = Identity::get_global(cx).profile() else { - return shared_state().person(&self.members[0]); - }; - - if let Some(public_key) = self - .members - .iter() - .filter(|&pubkey| pubkey != &account.public_key()) - .collect::>() - .first() - { - shared_state().person(public_key) + /// A SharedString containing the display name + pub fn display_name(&self, cx: &App) -> SharedString { + if let Some(subject) = self.subject.clone() { + subject } else { - account + self.merge_name(cx) } } - /// Gets a formatted string of member names + /// Gets the display image for the room + /// + /// The image is determined by: + /// - The room's picture if set + /// - The first member's avatar for 1:1 chats + /// - A default group image for group chats /// /// # Arguments /// - /// * `cx` - The App context + /// * `cx` - The application context /// /// # Returns /// - /// A SharedString containing formatted member names: - /// - For a group chat: "name1, name2, +X" where X is the number of additional members - /// - For a direct message: just the name of the other person - pub fn names(&self, cx: &App) -> SharedString { + /// A SharedString containing the image path or URL + pub fn display_image(&self, cx: &App) -> SharedString { + let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; + + if let Some(picture) = self.picture.as_ref() { + picture.clone() + } else if !self.is_group() { + self.first_member(cx).avatar_url(proxy) + } else { + "brand/group.png".into() + } + } + + /// Get the first member of the room. + /// + /// First member is always different from the current user. + pub(crate) fn first_member(&self, cx: &App) -> Profile { + let registry = Registry::read_global(cx); + + if let Some(identity) = Identity::read_global(cx).public_key().as_ref() { + self.members + .iter() + .filter(|&pubkey| pubkey != identity) + .collect::>() + .first() + .map(|public_key| registry.get_person(public_key, cx)) + .unwrap_or(registry.get_person(identity, cx)) + } else { + registry.get_person(&self.members[0], cx) + } + } + + /// Merge the names of the first two members of the room. + pub(crate) fn merge_name(&self, cx: &App) -> SharedString { + let registry = Registry::read_global(cx); + if self.is_group() { let profiles = self .members .iter() - .map(|public_key| shared_state().person(public_key)) + .map(|pk| registry.get_person(pk, cx)) .collect::>(); let mut name = profiles .iter() .take(2) - .map(|profile| profile.render_name()) + .map(|p| p.display_name()) .collect::>() .join(", "); @@ -215,11 +300,11 @@ impl Room { name.into() } else { - self.first_member(cx).render_name() + self.first_member(cx).display_name() } } - /// Gets the display name for the room + /// Loads all profiles for this room members from the database /// /// # Arguments /// @@ -227,148 +312,20 @@ impl Room { /// /// # Returns /// - /// A SharedString representing the display name: - /// - The subject of the room if it exists - /// - Otherwise, the formatted names of the members - pub fn display_name(&self, cx: &App) -> SharedString { - if let Some(subject) = self.subject.as_ref() { - subject.clone() - } else { - self.names(cx) - } - } - - /// Gets the display image for the room - /// - /// # Arguments - /// - /// * `cx` - The App context - /// - /// # Returns - /// - /// An Option containing the avatar: - /// - For a direct message: the other person's avatar - /// - For a group chat: None - pub fn display_image(&self, cx: &App) -> SharedString { - let proxy = AppSettings::get_global(cx).settings.proxy_user_avatars; - - if let Some(picture) = self.picture.as_ref() { - picture.clone() - } else if !self.is_group() { - self.first_member(cx).render_avatar(proxy) - } else { - "brand/group.png".into() - } - } - - /// Checks if the room is a group chat - /// - /// # Returns - /// - /// true if the room has more than 2 members, false otherwise - pub fn is_group(&self) -> bool { - self.members.len() > 2 - } - - /// Set the room kind to ongoing - /// - /// # Arguments - /// - /// * `cx` - The context to notify about the update - pub fn set_ongoing(&mut self, cx: &mut Context) { - if self.kind != RoomKind::Ongoing { - self.kind = RoomKind::Ongoing; - cx.notify(); - } - } - - /// Updates the creation timestamp of the room - /// - /// # Arguments - /// - /// * `created_at` - The new Timestamp to set - /// * `cx` - The context to notify about the update - pub fn created_at(&mut self, created_at: Timestamp, cx: &mut Context) { - self.created_at = created_at; - cx.notify(); - } - - /// Updates the subject of the room - /// - /// # Arguments - /// - /// * `subject` - The new subject to set - /// * `cx` - The context to notify about the update - pub fn subject(&mut self, subject: String, cx: &mut Context) { - self.subject = Some(subject.into()); - cx.notify(); - } - - /// Updates the picture of the room - /// - /// # Arguments - /// - /// * `picture` - The new subject to set - /// * `cx` - The context to notify about the update - pub fn picture(&mut self, picture: String, cx: &mut Context) { - self.picture = Some(picture.into()); - cx.notify(); - } - - /// Fetches metadata for all members in the room - /// - /// # Arguments - /// - /// * `cx` - The context for the background task - /// - /// # Returns - /// - /// A Task that resolves to Result<(), Error> - pub fn load_metadata(&self, cx: &mut Context) -> Task> { - let public_keys = Arc::clone(&self.members); + /// A Task that resolves to Result, Error> containing all profiles for this room + pub fn load_metadata(&self, cx: &mut Context) -> Task, Error>> { + let public_keys = self.members.clone(); cx.background_spawn(async move { - let database = shared_state().client().database(); + let database = nostr_client().database(); + let mut profiles = vec![]; - for public_key in public_keys.iter().cloned() { - if !shared_state().has_person(&public_key).await { - let metadata = database.metadata(public_key).await?; - shared_state().insert_person(public_key, metadata).await; - } + for public_key in public_keys.into_iter() { + let metadata = database.metadata(public_key).await?.unwrap_or_default(); + profiles.push(Profile::new(public_key, metadata)); } - Ok(()) - }) - } - - /// Checks which members have inbox relays set up - /// - /// # Arguments - /// - /// * `cx` - The App context - /// - /// # Returns - /// - /// A Task that resolves to Result, Error> where - /// the boolean indicates if the member has inbox relays configured - pub fn messaging_relays(&self, cx: &App) -> Task, Error>> { - let pubkeys = Arc::clone(&self.members); - - cx.background_spawn(async move { - let database = shared_state().client().database(); - let mut result = Vec::with_capacity(pubkeys.len()); - - for pubkey in pubkeys.iter() { - let filter = Filter::new() - .kind(Kind::InboxRelays) - .author(*pubkey) - .limit(1); - let is_ready = database.query(filter).await?.first().is_some(); - - result.push((*pubkey, is_ready)); - } - - Ok(result) + Ok(profiles) }) } @@ -380,19 +337,19 @@ impl Room { /// /// # Returns /// - /// A Task that resolves to Result, Error> containing - /// all messages for this room + /// A Task that resolves to Result, Error> containing all messages for this room pub fn load_messages(&self, cx: &App) -> Task, Error>> { - let pubkeys = Arc::clone(&self.members); + let pubkeys = self.members.clone(); + let filter = Filter::new() .kind(Kind::PrivateDirectMessage) - .authors(pubkeys.to_vec()) - .pubkeys(pubkeys.to_vec()); + .authors(self.members.clone()) + .pubkeys(self.members.clone()); cx.background_spawn(async move { let mut messages = vec![]; let parser = NostrParser::new(); - let database = shared_state().client().database(); + let database = nostr_client().database(); // Get all events from database let events = database @@ -403,7 +360,7 @@ impl Room { .filter(|ev| { let mut other_pubkeys = ev.tags.public_keys().copied().collect::>(); other_pubkeys.push(ev.pubkey); - // Check if the event is from a member of the room + // Check if the event is belong to a member of the current room common::compare(&other_pubkeys, &pubkeys) }) .collect::>(); @@ -411,7 +368,6 @@ impl Room { for event in events.into_iter() { let content = event.content.clone(); let tokens = parser.parse(&content); - let mut mentions = vec![]; let mut replies_to = vec![]; for tag in event.tags.filter(TagKind::e()) { @@ -430,7 +386,7 @@ impl Room { } } - let pubkey_tokens = tokens + let mentions = tokens .filter_map(|token| match token { Token::Nostr(nip21) => match nip21 { Nip21::Pubkey(pubkey) => Some(pubkey), @@ -441,16 +397,8 @@ impl Room { }) .collect::>(); - for pubkey in pubkey_tokens.iter() { - mentions.push(shared_state().async_person(pubkey).await); - } - - let author = shared_state().async_person(&event.pubkey).await; - - if let Ok(message) = Message::builder() - .id(event.id) + if let Ok(message) = Message::builder(event.id, event.pubkey) .content(content) - .author(author) .created_at(event.created_at) .replies_to(replies_to) .mentions(mentions) @@ -476,8 +424,6 @@ impl Room { /// /// Processes the event and emits an Incoming to the UI when complete pub fn emit_message(&self, event: Event, _window: &mut Window, cx: &mut Context) { - let author = shared_state().person(&event.pubkey); - // Extract all mentions from content let mentions = extract_mentions(&event.content); @@ -500,10 +446,8 @@ impl Room { } } - if let Ok(message) = Message::builder() - .id(event.id) + if let Ok(message) = Message::builder(event.id, event.pubkey) .content(event.content) - .author(author) .created_at(event.created_at) .replies_to(replies_to) .mentions(mentions) @@ -534,8 +478,7 @@ impl Room { replies: Option<&Vec>, cx: &App, ) -> Option { - let author = Identity::get_global(cx).profile()?; - let public_key = author.public_key(); + let public_key = Identity::read_global(cx).public_key()?; let builder = EventBuilder::private_msg_rumor(public_key, content); // Add event reference if it's present (replying to another event) @@ -543,10 +486,10 @@ impl Room { if let Some(replies) = replies { if replies.len() == 1 { - refs.push(Tag::event(replies[0].id.unwrap())) + refs.push(Tag::event(replies[0].id)) } else { for message in replies.iter() { - refs.push(Tag::custom(TagKind::q(), vec![message.id.unwrap()])) + refs.push(Tag::custom(TagKind::q(), vec![message.id])) } } } @@ -582,10 +525,8 @@ impl Room { } } - Message::builder() - .id(event.id.unwrap()) + Message::builder(event.id.unwrap(), public_key) .content(event.content) - .author(author) .created_at(event.created_at) .replies_to(replies_to) .mentions(mentions) @@ -614,11 +555,11 @@ impl Room { let replies = replies.cloned(); let subject = self.subject.clone(); let picture = self.picture.clone(); - let public_keys = Arc::clone(&self.members); + let public_keys = self.members.clone(); let backup = AppSettings::get_global(cx).settings.backup_messages; cx.background_spawn(async move { - let client = shared_state().client(); + let client = nostr_client(); let signer = client.signer().await?; let public_key = signer.get_public_key().await?; @@ -637,10 +578,10 @@ impl Room { // Add event reference if it's present (replying to another event) if let Some(replies) = replies { if replies.len() == 1 { - tags.push(Tag::event(replies[0].id.unwrap())) + tags.push(Tag::event(replies[0].id)) } else { for message in replies.iter() { - tags.push(Tag::custom(TagKind::q(), vec![message.id.unwrap()])) + tags.push(Tag::custom(TagKind::q(), vec![message.id])) } } } @@ -706,12 +647,11 @@ impl Room { } } -pub fn extract_mentions(content: &str) -> Vec { +pub(crate) fn extract_mentions(content: &str) -> Vec { let parser = NostrParser::new(); let tokens = parser.parse(content); - let mut mentions = vec![]; - let pubkey_tokens = tokens + tokens .filter_map(|token| match token { Token::Nostr(nip21) => match nip21 { Nip21::Pubkey(pubkey) => Some(pubkey), @@ -720,11 +660,5 @@ pub fn extract_mentions(content: &str) -> Vec { }, _ => None, }) - .collect::>(); - - for pubkey in pubkey_tokens.into_iter() { - mentions.push(shared_state().person(&pubkey)); - } - - mentions + .collect::>() } diff --git a/crates/settings/Cargo.toml b/crates/settings/Cargo.toml index 0ba753a..fbf466c 100644 --- a/crates/settings/Cargo.toml +++ b/crates/settings/Cargo.toml @@ -7,8 +7,6 @@ publish.workspace = true [dependencies] global = { path = "../global" } -rust-i18n.workspace = true -i18n.workspace = true nostr-sdk.workspace = true gpui.workspace = true anyhow.workspace = true diff --git a/crates/settings/src/lib.rs b/crates/settings/src/lib.rs index ec1886f..6887fb2 100644 --- a/crates/settings/src/lib.rs +++ b/crates/settings/src/lib.rs @@ -1,13 +1,10 @@ use anyhow::anyhow; -use global::constants::SETTINGS_D; -use global::shared_state; +use global::{constants::SETTINGS_D, nostr_client}; use gpui::{App, AppContext, Context, Entity, Global, Subscription, Task}; use nostr_sdk::prelude::*; use serde::{Deserialize, Serialize}; use smallvec::{smallvec, SmallVec}; -i18n::init!(); - pub fn init(cx: &mut App) { let state = cx.new(AppSettings::new); @@ -86,14 +83,12 @@ impl AppSettings { pub(crate) fn get_settings_from_db(&self, cx: &mut Context) { let task: Task> = cx.background_spawn(async move { - let database = shared_state().client().database(); - let filter = Filter::new() .kind(Kind::ApplicationSpecificData) .identifier(SETTINGS_D) .limit(1); - if let Some(event) = database.query(filter).await?.first_owned() { + if let Some(event) = nostr_client().database().query(filter).await?.first_owned() { log::info!("Successfully loaded settings from database"); Ok(serde_json::from_str(&event.content)?) } else { @@ -117,14 +112,13 @@ impl AppSettings { if let Ok(content) = serde_json::to_string(&self.settings) { cx.background_spawn(async move { let keys = Keys::generate(); - let database = shared_state().client().database(); if let Ok(event) = EventBuilder::new(Kind::ApplicationSpecificData, content) .tags(vec![Tag::identifier(SETTINGS_D)]) .sign(&keys) .await { - if let Err(e) = database.save_event(&event).await { + if let Err(e) = nostr_client().database().save_event(&event).await { log::error!("Failed to save user settings: {e}"); } else { log::info!("New settings have been saved successfully"); diff --git a/crates/ui/src/text.rs b/crates/ui/src/text.rs index c9eaee2..60f2e18 100644 --- a/crates/ui/src/text.rs +++ b/crates/ui/src/text.rs @@ -2,7 +2,7 @@ use std::collections::HashMap; use std::ops::Range; use std::sync::Arc; -use common::profile::RenderProfile; +use common::display::DisplayProfile; use gpui::{ AnyElement, AnyView, App, ElementId, FontWeight, HighlightStyle, InteractiveText, IntoElement, SharedString, StyledText, UnderlineStyle, Window, @@ -45,7 +45,7 @@ pub struct RichText { } impl RichText { - pub fn new(content: String, profiles: &[Profile]) -> Self { + pub fn new(content: String, profiles: &[Option]) -> Self { let mut text = String::new(); let mut highlights = Vec::new(); let mut link_ranges = Vec::new(); @@ -156,7 +156,7 @@ impl RichText { pub fn render_plain_text_mut( content: &str, - profiles: &[Profile], + profiles: &[Option], text: &mut String, highlights: &mut Vec<(Range, Highlight)>, link_ranges: &mut Vec>, @@ -168,7 +168,11 @@ pub fn render_plain_text_mut( // Create a profile lookup using PublicKey directly let profile_lookup: HashMap = profiles .iter() - .map(|profile| (profile.public_key(), profile.clone())) + .filter_map(|profile| { + profile + .as_ref() + .map(|profile| (profile.public_key(), profile.clone())) + }) .collect(); // Process regular URLs using linkify @@ -276,7 +280,7 @@ pub fn render_plain_text_mut( if let Some(profile) = profile_match { // Profile found - create a mention - let display_name = format!("@{}", profile.render_name()); + let display_name = format!("@{}", profile.display_name()); // Replace mention with profile name text.replace_range(range.clone(), &display_name); diff --git a/locales/app.yml b/locales/app.yml index d08f14d..5dd9828 100644 --- a/locales/app.yml +++ b/locales/app.yml @@ -252,15 +252,15 @@ login: pt: "Continuar com a chave privada ou Bunker URI" ko: "개인 키 또는 Bunker URI로 계속" approve_message: - en: "Approve connection request from your signer in {i} seconds" - zh-CN: "在 {i} 秒内批准来自您的 signer 的连接请求" - zh-TW: "在 {i} 秒內批准來自您的 signer 的連接請求" - ru: "Подтвердите запрос на подключение от вашего signer в течение {i} секунд" - vi: "Phê duyệt yêu cầu kết nối từ signer của bạn trong {i} giây" - ja: "{i} 秒以内にあなたの signer からの接続リクエストを承認してください" - es: "Aprueba la solicitud de conexión de tu signer en {i} segundos" - pt: "Aprove a solicitação de conexão do seu signer em {i} segundos" - ko: "{i}초 내에 signer의 연결 요청을 승인하세요" + en: "Approve connection request from your signer in %{i} seconds" + zh-CN: "在 %{i} 秒内批准来自您的 signer 的连接请求" + zh-TW: "在 %{i} 秒內批准來自您的 signer 的連接請求" + ru: "Подтвердите запрос на подключение от вашего signer в течение %{i} секунд" + vi: "Phê duyệt yêu cầu kết nối từ signer của bạn trong %{i} giây" + ja: "%{i} 秒以内にあなたの signer からの接続リクエストを承認してください" + es: "Aprueba la solicitud de conexión de tu signer en %{i} segundos" + pt: "Aprove a solicitação de conexão do seu signer em %{i} segundos" + ko: "%{i}초 내에 signer의 연결 요청을 승인하세요" nostr_connect: en: "Continue with Nostr Connect" zh-CN: "继续使用 Nostr Connect" @@ -824,15 +824,15 @@ compose: pt: "Seus contatos recentes aparecerão aqui." ko: "최근 연락처가 여기에 표시됩니다." contact_existed: - en: "Contact already added: {name}" - zh-CN: "联系人已添加:{name}" - zh-TW: "聯絡人已新增:{name}" - ru: "Контакт уже добавлен: {name}" - vi: "Danh bạ đã được thêm: {name}" - ja: "連絡先は既に追加されています: {name}" - es: "Contacto ya añadido: {name}" - pt: "Contato já adicionado: {name}" - ko: "이미 추가된 연락처: {name}" + en: "Contact already added" + zh-CN: "联系人已添加" + zh-TW: "聯絡人已新增" + ru: "Контакт уже добавлен" + vi: "Danh bạ đã được thêm" + ja: "連絡先は既に追加されています" + es: "Contacto ya añadido" + pt: "Contato já adicionado" + ko: "이미 추가된 연락처" receiver_required: en: "You need to add at least 1 receiver" zh-CN: "您需要添加至少1个收件人" @@ -958,15 +958,15 @@ sidebar: pt: "Pressione Enter para pesquisar" ko: "Enter 키를 눌러 검색" empty: - en: "There are no users matching query {query}" - zh-CN: "没有匹配查询 {query} 的用户" - zh-TW: "沒有匹配查詢 {query} 的用戶" - ru: "Нет пользователей, соответствующих запросу {query}" - vi: "Không có người dùng phù hợp với truy vấn {query}" - ja: "クエリ {query} に一致するユーザーがいません" - es: "No hay usuarios que coincidan con la consulta {query}" - pt: "Não há usuários correspondentes à consulta {query}" - ko: "쿼리 {query}와 일치하는 사용자가 없습니다" + en: "There are no users matching query %{query}" + zh-CN: "没有匹配查询 %{query} 的用户" + zh-TW: "沒有匹配查詢 %{query} 的用戶" + ru: "Нет пользователей, соответствующих запросу %{query}" + vi: "Không có người dùng phù hợp với truy vấn %{query}" + ja: "クエリ %{query} に一致するユーザーがいません" + es: "No hay usuarios que coincidan con la consulta %{query}" + pt: "Não há usuários correspondentes à consulta %{query}" + ko: "쿼리 %{query}와 일치하는 사용자가 없습니다" search_in_progress: en: "There is another search in progress" zh-CN: "正在进行另一个搜索"