chore: improve data requests (#81)
* refactor * refactor * add documents * clean up * refactor * clean up * refactor identity * . * . * rename
This commit is contained in:
138
Cargo.lock
generated
138
Cargo.lock
generated
@@ -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",
|
||||
|
||||
@@ -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<Result<(TempDir, PathBuf), Error>> = 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")?;
|
||||
|
||||
@@ -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;
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
48
crates/common/src/display.rs
Normal file
48
crates/common/src/display.rs
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -8,10 +8,10 @@ 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 mod profile;
|
||||
|
||||
pub fn room_hash(event: &Event) -> u64 {
|
||||
let mut hasher = DefaultHasher::new();
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -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<Result<bool, 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()
|
||||
|
||||
@@ -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::<NostrSignal>(2048);
|
||||
let (mta_tx, mta_rx) = channel::bounded::<PublicKey>(1024);
|
||||
let (event_tx, event_rx) = channel::unbounded::<Event>();
|
||||
|
||||
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<PublicKey> = 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<NostrSignal>,
|
||||
mta_tx: &Sender<PublicKey>,
|
||||
event_tx: &Sender<Event>,
|
||||
) -> 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<EventId> = BTreeSet::new();
|
||||
let mut processed_dm_relays: BTreeSet<PublicKey> = 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<bool, Error> {
|
||||
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<PublicKey>) {
|
||||
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<Event, Error> {
|
||||
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<NostrSignal>,
|
||||
mta_tx: &Sender<PublicKey>,
|
||||
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
|
||||
}
|
||||
|
||||
@@ -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<Room>, window: &mut Window, cx: &mut App) -> Entity<Self> {
|
||||
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<Self>) -> 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>) {
|
||||
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<Self>) -> 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<SendError>, 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<SendError>, 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)
|
||||
}))
|
||||
|
||||
@@ -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<Compose> {
|
||||
cx.new(|cx| Compose::new(window, cx))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
#[derive(Debug)]
|
||||
struct Contact {
|
||||
profile: Profile,
|
||||
public_key: PublicKey,
|
||||
select: bool,
|
||||
}
|
||||
|
||||
impl AsRef<Profile> for Contact {
|
||||
fn as_ref(&self) -> &Profile {
|
||||
&self.profile
|
||||
impl AsRef<PublicKey> 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<Result<Vec<Contact>, 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<PublicKey, Error> {
|
||||
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<Self>) {
|
||||
let public_keys: Vec<PublicKey> = self.selected(cx);
|
||||
|
||||
@@ -158,7 +180,7 @@ impl Compose {
|
||||
}
|
||||
|
||||
let event: Task<Result<Room, anyhow::Error>> = 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<Item = Contact>, cx: &mut Context<Self>) {
|
||||
fn extend_contacts<I>(&mut self, contacts: I, cx: &mut Context<Self>)
|
||||
where
|
||||
I: IntoIterator<Item = Contact>,
|
||||
{
|
||||
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<Result<Contact, anyhow::Error>> = if content.contains("@") {
|
||||
let task: Task<Result<Contact, Error>> = if content.contains("@") {
|
||||
cx.background_spawn(async move {
|
||||
let (tx, rx) = oneshot::channel::<Option<Nip05Profile>>();
|
||||
|
||||
@@ -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<usize>, cx: &Context<Self>) -> Vec<impl IntoElement> {
|
||||
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.)),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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| {
|
||||
|
||||
@@ -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::<Url>();
|
||||
|
||||
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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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()),
|
||||
),
|
||||
),
|
||||
)
|
||||
|
||||
@@ -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<Self>) -> 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()
|
||||
|
||||
@@ -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<Result<Option<Metadata>, 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<Self>) {
|
||||
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::<Url>();
|
||||
|
||||
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<Result<(), Error>> = 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();
|
||||
}
|
||||
|
||||
@@ -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<Result<Vec<RelayUrl>, 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<Result<EventId, 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?;
|
||||
|
||||
|
||||
@@ -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<Result<BTreeSet<Room>, 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<Result<(Profile, Room), Error>> = 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<Self>) {
|
||||
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<Self>) -> 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()
|
||||
|
||||
@@ -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<Self>) {
|
||||
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) {
|
||||
|
||||
@@ -7,13 +7,17 @@ pub const ACCOUNT_D: &str = "coop:account";
|
||||
pub const SETTINGS_D: &str = "coop:settings";
|
||||
|
||||
/// Bootstrap Relays.
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 4] = [
|
||||
pub const BOOTSTRAP_RELAYS: [&str; 5] = [
|
||||
"wss://relay.damus.io",
|
||||
"wss://relay.primal.net",
|
||||
"wss://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",
|
||||
@@ -25,9 +29,6 @@ pub const NIP65_RELAYS: [&str; 4] = [
|
||||
/// 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";
|
||||
|
||||
@@ -54,6 +55,3 @@ 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;
|
||||
|
||||
@@ -1,72 +1,45 @@
|
||||
use std::collections::{BTreeMap, BTreeSet};
|
||||
use std::fs;
|
||||
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<Globals> = OnceLock::new();
|
||||
|
||||
/// Signals sent through the global event channel to notify UI components
|
||||
#[derive(Debug)]
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum NostrSignal {
|
||||
/// New gift wrap event received
|
||||
Event(Event),
|
||||
/// 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),
|
||||
}
|
||||
|
||||
/// Global application state containing Nostr client and shared resources
|
||||
pub struct Globals {
|
||||
/// The Nostr SDK client
|
||||
client: Client,
|
||||
static NOSTR_CLIENT: OnceLock<Client> = OnceLock::new();
|
||||
static FIRST_RUN: OnceLock<bool> = OnceLock::new();
|
||||
|
||||
/// Determines if this is the first time user run Coop
|
||||
first_run: bool,
|
||||
|
||||
/// Cache of user profiles mapped by their public keys
|
||||
persons: RwLock<BTreeMap<PublicKey, Option<Metadata>>>,
|
||||
|
||||
/// Channel sender for broadcasting global Nostr events to UI
|
||||
global_sender: smol::channel::Sender<NostrSignal>,
|
||||
|
||||
/// Channel receiver for handling global Nostr events
|
||||
global_receiver: smol::channel::Receiver<NostrSignal>,
|
||||
|
||||
batch_sender: smol::channel::Sender<PublicKey>,
|
||||
batch_receiver: smol::channel::Receiver<PublicKey>,
|
||||
|
||||
event_sender: smol::channel::Sender<Event>,
|
||||
event_receiver: smol::channel::Receiver<Event>,
|
||||
}
|
||||
|
||||
/// Returns the global singleton instance, initializing it if necessary
|
||||
pub fn shared_state() -> &'static Globals {
|
||||
GLOBALS.get_or_init(|| {
|
||||
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`.
|
||||
@@ -74,570 +47,24 @@ pub fn shared_state() -> &'static Globals {
|
||||
.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::<NostrSignal>(GLOBAL_CHANNEL_LIMIT);
|
||||
|
||||
let (batch_sender, batch_receiver) =
|
||||
smol::channel::bounded::<PublicKey>(BATCH_CHANNEL_LIMIT);
|
||||
|
||||
let (event_sender, event_receiver) = smol::channel::unbounded::<Event>();
|
||||
|
||||
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,
|
||||
}
|
||||
ClientBuilder::default().database(lmdb).opts(opts).build()
|
||||
})
|
||||
}
|
||||
|
||||
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
|
||||
pub fn first_run() -> &'static bool {
|
||||
FIRST_RUN.get_or_init(|| {
|
||||
let flag = support_dir().join(format!(".{}-first_run", env!("CARGO_PKG_VERSION")));
|
||||
|
||||
let mut notifications = self.client.notifications();
|
||||
let mut processed_events: BTreeSet<EventId> = 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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
if !flag.exists() {
|
||||
if fs::write(&flag, "").is_err() {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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<NostrSignal> {
|
||||
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<PublicKey> = 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()
|
||||
true // First run
|
||||
} 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<Metadata>) {
|
||||
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}");
|
||||
}
|
||||
false // Not first run
|
||||
}
|
||||
|
||||
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<Event, Error> {
|
||||
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<PublicKey>) {
|
||||
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<bool, anyhow::Error> {
|
||||
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
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<Identity>);
|
||||
impl Global for GlobalIdentity {}
|
||||
|
||||
pub struct Identity {
|
||||
profile: Option<Profile>,
|
||||
public_key: Option<PublicKey>,
|
||||
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::<GlobalIdentity>().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<Self>) {
|
||||
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<Self>) {
|
||||
let task = cx.background_spawn(async move {
|
||||
let client = shared_state().client();
|
||||
let task: Task<Result<(), Error>> = 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<Result<Profile, Error>> = cx.background_spawn(async move {
|
||||
let client = shared_state().client();
|
||||
let task: Task<Result<PublicKey, Error>> = 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<Self>,
|
||||
) {
|
||||
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<Result<PublicKey, Error>> = 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<Profile>, cx: &mut Context<Self>) {
|
||||
self.profile = profile;
|
||||
pub(crate) fn set_public_key(&mut self, public_key: Option<PublicKey>, cx: &mut Context<Self>) {
|
||||
self.public_key = public_key;
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Returns the current profile
|
||||
pub fn profile(&self) -> Option<Profile> {
|
||||
self.profile.as_ref().cloned()
|
||||
/// Returns the current identity's public key
|
||||
pub fn public_key(&self) -> Option<PublicKey> {
|
||||
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(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
[package]
|
||||
name = "chats"
|
||||
name = "registry"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
publish.workspace = true
|
||||
@@ -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<ChatRegistry>);
|
||||
struct GlobalRegistry(Entity<Registry>);
|
||||
|
||||
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<Entity<Room>>,
|
||||
|
||||
/// Collection of all persons (user profiles)
|
||||
pub persons: BTreeMap<PublicKey, Entity<Profile>>,
|
||||
|
||||
/// 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<RoomEmitter> for ChatRegistry {}
|
||||
impl EventEmitter<RoomEmitter> for Registry {}
|
||||
|
||||
impl ChatRegistry {
|
||||
/// Retrieve the Global ChatRegistry instance
|
||||
impl Registry {
|
||||
/// Retrieve the Global Registry state
|
||||
pub fn global(cx: &App) -> Entity<Self> {
|
||||
cx.global::<GlobalChatRegistry>().0.clone()
|
||||
cx.global::<GlobalRegistry>().0.clone()
|
||||
}
|
||||
|
||||
/// Retrieve the ChatRegistry instance
|
||||
pub fn get_global(cx: &App) -> &Self {
|
||||
cx.global::<GlobalChatRegistry>().0.read(cx)
|
||||
/// Retrieve the Registry instance
|
||||
pub fn read_global(cx: &App) -> &Self {
|
||||
cx.global::<GlobalRegistry>().0.read(cx)
|
||||
}
|
||||
|
||||
/// Set the global ChatRegistry instance
|
||||
/// Set the global Registry instance
|
||||
pub(crate) fn set_global(state: Entity<Self>, cx: &mut App) {
|
||||
cx.set_global(GlobalChatRegistry(state));
|
||||
cx.set_global(GlobalRegistry(state));
|
||||
}
|
||||
|
||||
/// Create a new ChatRegistry instance
|
||||
fn new(cx: &mut Context<Self>) -> Self {
|
||||
/// Create a new Registry instance
|
||||
pub(crate) fn new(cx: &mut Context<Self>) -> 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::<Self>(|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::<Room>(|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<Result<Vec<Profile>, Error>>,
|
||||
cx: &mut Context<Self>,
|
||||
) {
|
||||
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<Result<Vec<Profile>, 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<Option<Profile>> {
|
||||
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<Entity<Room>> {
|
||||
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<Room>, cx: &mut Context<Self>) {
|
||||
self.rooms.insert(0, room);
|
||||
cx.notify();
|
||||
}
|
||||
|
||||
/// Sort rooms by their created at.
|
||||
pub fn sort(&mut self, cx: &mut Context<Self>) {
|
||||
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>) {
|
||||
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<Result<BTreeSet<Room>, 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<Room>, cx: &mut Context<Self>) {
|
||||
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<Self>) {
|
||||
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>) {
|
||||
self.loading = status;
|
||||
cx.notify();
|
||||
}
|
||||
}
|
||||
@@ -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<EventId>,
|
||||
/// Author profile information
|
||||
pub author: Option<Profile>,
|
||||
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<Profile>,
|
||||
/// 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<Vec<EventId>>,
|
||||
pub replies_to: Option<SmallVec<[EventId; 1]>>,
|
||||
/// Any errors that occurred while sending this message
|
||||
pub errors: Option<Vec<SendError>>,
|
||||
pub errors: Option<SmallVec<[SendError; 1]>>,
|
||||
}
|
||||
|
||||
/// Builder pattern implementation for constructing Message objects.
|
||||
#[derive(Debug, Default)]
|
||||
#[derive(Debug)]
|
||||
pub struct MessageBuilder {
|
||||
id: Option<EventId>,
|
||||
author: Option<Profile>,
|
||||
content: Option<String>,
|
||||
id: EventId,
|
||||
author: PublicKey,
|
||||
content: Option<SharedString>,
|
||||
created_at: Option<Timestamp>,
|
||||
mentions: Vec<Profile>,
|
||||
replies_to: Option<Vec<EventId>>,
|
||||
errors: Option<Vec<SendError>>,
|
||||
mentions: SmallVec<[PublicKey; 2]>,
|
||||
replies_to: Option<SmallVec<[EventId; 1]>>,
|
||||
errors: Option<SmallVec<[SendError; 1]>>,
|
||||
}
|
||||
|
||||
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<SharedString>) -> 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<I>(mut self, mentions: I) -> Self
|
||||
where
|
||||
I: IntoIterator<Item = Profile>,
|
||||
I: IntoIterator<Item = PublicKey>,
|
||||
{
|
||||
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<Item = EventId>,
|
||||
{
|
||||
let replies: Vec<EventId> = 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<RefCell<Message>>
|
||||
@@ -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<SharedString>,
|
||||
/// All members of the room
|
||||
pub members: Arc<Vec<PublicKey>>,
|
||||
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<Incoming> 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<Self>) {
|
||||
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<Timestamp>, cx: &mut Context<Self>) {
|
||||
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<SharedString>, cx: &mut Context<Self>) {
|
||||
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<SharedString>, cx: &mut Context<Self>) {
|
||||
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::<Vec<_>>()
|
||||
.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::<Vec<_>>()
|
||||
.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::<Vec<_>>();
|
||||
|
||||
let mut name = profiles
|
||||
.iter()
|
||||
.take(2)
|
||||
.map(|profile| profile.render_name())
|
||||
.map(|p| p.display_name())
|
||||
.collect::<Vec<_>>()
|
||||
.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<SharedString> 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<Self>) {
|
||||
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>) {
|
||||
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>) {
|
||||
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>) {
|
||||
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<Self>) -> Task<Result<(), Error>> {
|
||||
let public_keys = Arc::clone(&self.members);
|
||||
/// A Task that resolves to Result<Vec<Profile>, Error> containing all profiles for this room
|
||||
pub fn load_metadata(&self, cx: &mut Context<Self>) -> Task<Result<Vec<Profile>, 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<Vec<(PublicKey, bool)>, Error> where
|
||||
/// the boolean indicates if the member has inbox relays configured
|
||||
pub fn messaging_relays(&self, cx: &App) -> Task<Result<Vec<(PublicKey, bool)>, 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<Vec<RoomMessage>, Error> containing
|
||||
/// all messages for this room
|
||||
/// A Task that resolves to Result<Vec<RoomMessage>, Error> containing all messages for this room
|
||||
pub fn load_messages(&self, cx: &App) -> Task<Result<Vec<Message>, 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::<Vec<_>>();
|
||||
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::<Vec<_>>();
|
||||
@@ -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::<Vec<_>>();
|
||||
|
||||
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<Self>) {
|
||||
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<Message>>,
|
||||
cx: &App,
|
||||
) -> Option<Message> {
|
||||
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<Profile> {
|
||||
pub(crate) fn extract_mentions(content: &str) -> Vec<PublicKey> {
|
||||
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<Profile> {
|
||||
},
|
||||
_ => None,
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
for pubkey in pubkey_tokens.into_iter() {
|
||||
mentions.push(shared_state().person(&pubkey));
|
||||
}
|
||||
|
||||
mentions
|
||||
.collect::<Vec<_>>()
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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<Self>) {
|
||||
let task: Task<Result<Settings, anyhow::Error>> = 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");
|
||||
|
||||
@@ -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<Profile>]) -> 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<Profile>],
|
||||
text: &mut String,
|
||||
highlights: &mut Vec<(Range<usize>, Highlight)>,
|
||||
link_ranges: &mut Vec<Range<usize>>,
|
||||
@@ -168,7 +168,11 @@ pub fn render_plain_text_mut(
|
||||
// Create a profile lookup using PublicKey directly
|
||||
let profile_lookup: HashMap<PublicKey, Profile> = 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);
|
||||
|
||||
@@ -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: "正在进行另一个搜索"
|
||||
|
||||
Reference in New Issue
Block a user