chore: improve data requests (#81)

* refactor

* refactor

* add documents

* clean up

* refactor

* clean up

* refactor identity

* .

* .

* rename
This commit is contained in:
reya
2025-07-08 15:23:35 +07:00
committed by GitHub
parent 122dbaf693
commit 8bfad30a99
32 changed files with 1478 additions and 1594 deletions

138
Cargo.lock generated
View File

@@ -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",

View File

@@ -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")?;

View File

@@ -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;

View File

@@ -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

View File

@@ -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);

View 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()
}
}

View File

@@ -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();

View File

@@ -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" }

View File

@@ -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,
&registry,
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()

View File

@@ -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
}

View File

@@ -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)
}))

View File

@@ -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.)),
)
}

View File

@@ -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| {

View File

@@ -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);
}
});

View File

@@ -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()),
),
),
)

View File

@@ -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()

View File

@@ -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();
}

View File

@@ -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?;

View File

@@ -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()

View File

@@ -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) {

View File

@@ -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;

View File

@@ -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
}
})
}

View File

@@ -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

View File

@@ -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(())
}
}

View File

@@ -1,5 +1,5 @@
[package]
name = "chats"
name = "registry"
version.workspace = true
edition.workspace = true
publish.workspace = true

View File

@@ -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();
}
}

View File

@@ -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>>

View File

@@ -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<_>>()
}

View File

@@ -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

View File

@@ -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");

View File

@@ -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);

View File

@@ -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: "正在进行另一个搜索"