diff --git a/Cargo.lock b/Cargo.lock index a5710ca7..c897cc82 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -396,13 +396,13 @@ dependencies = [ [[package]] name = "async-recursion" -version = "1.1.1" +version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3b43422f69d8ff38f95f1b2bb76517c91589a924d1559a0e935d7c8ce0274c11" +checksum = "4f8abc12baad266b1c8cec146854c195b5864b4221d4b2ca7296a7ae82d9e451" dependencies = [ "proc-macro2", "quote", - "syn 2.0.119", + "syn 3.0.6", ] [[package]] @@ -839,16 +839,6 @@ dependencies = [ "piper", ] -[[package]] -name = "borsh" -version = "1.8.1" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "553c5d846a6ba5150c65e3b1b8ec073bcf1abc20f9b7220de384a4443ea4e20a" -dependencies = [ - "bytes", - "cfg_aliases", -] - [[package]] name = "browser-signer-proxy" version = "0.1.0" @@ -2690,9 +2680,9 @@ dependencies = [ [[package]] name = "gpui-base" -version = "0.7.0" +version = "0.7.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "64bb52b29a8dcdc8d3f595b8b0839c076f237572ab601ba4577d3da8ae085728" +checksum = "3cf698677b253118459f552fe15513858c669d79a25837a94e480db2ae330050" dependencies = [ "aho-corasick", "anyhow", @@ -2723,6 +2713,7 @@ dependencies = [ "smallvec", "smol", "tracing", + "unicode-linebreak", "unicode-segmentation", "web-time", "windows 0.58.0", @@ -2730,14 +2721,15 @@ dependencies = [ [[package]] name = "gpui-pre" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e87a42bb37c7cb4e76dd1ac0ce88851e46e976e0373a47ab3e0757abffee54d" +checksum = "d7f0bc3ebba070f8981b3f8e905a12f47d27794a30d029e59cdb9edf7a4b3360" dependencies = [ "accesskit", "anyhow", "async-channel", "async-task", + "backtrace", "bindgen", "bitflags 2.13.2", "chrono", @@ -2762,6 +2754,7 @@ dependencies = [ "image", "inventory", "itertools 0.14.0", + "libc", "log", "lyon", "num_cpus", @@ -2800,33 +2793,39 @@ dependencies = [ [[package]] name = "gpui-pre-apple" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "00053551517815ab169fda28fff78dbebf2712722593c7671c511785cb3f79d5" +checksum = "bfdd914311cd12894367c8b9908947bb0c27fd42f8b9a5ff7a0730551a83fa4c" dependencies = [ "anyhow", + "async-task", "block2 0.6.2", "cbindgen", - "cocoa 0.26.1", "core-foundation 0.10.1", + "core-graphics 0.24.0", "core-video", "derive_more", + "dispatch2", "etagere", "foreign-types", "gpui-pre", + "gpui-pre-util", "image", + "libc", "log", + "mach2", "metal", - "objc", "objc2 0.6.4", + "objc2-foundation 0.3.2", + "objc2-quartz-core 0.3.2", "parking_lot", ] [[package]] name = "gpui-pre-collections" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f79760925c31ed06924d28d68981b5b22b2ce25665f58f0a33dfea11170e6e28" +checksum = "2c060863025dc3b8e90febad2d1f4ffd23a5d0dcedba39d3f6b7820ea2fdd3a0" dependencies = [ "gpui-pre-util", "indexmap", @@ -2835,9 +2834,9 @@ dependencies = [ [[package]] name = "gpui-pre-derive-refineable" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1aea3f941581a6a9dd545ecd351ef2d336fe6e2a1a4555cb6414944a4984260" +checksum = "bc834f30f5f49791e214f1fde01a009c7de91c9cd0a9f31dabfed130ba39e6cf" dependencies = [ "proc-macro2", "quote", @@ -2846,9 +2845,9 @@ dependencies = [ [[package]] name = "gpui-pre-http-client" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ddce27e989e877a3a1a243f97d7ea4f1ade2532abec418d5a0be9cca802702c7" +checksum = "5d2935cf82333da3f41774e5757837756408f01356197b41c817ad2666034453" dependencies = [ "anyhow", "async-compression", @@ -2867,9 +2866,9 @@ dependencies = [ [[package]] name = "gpui-pre-http-client-tls" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0076f6cf87b87b5629d55692638df4edc04fab3da92f897c2294d520efac17d3" +checksum = "9bbac58d44e80c2bfdd736cacd334e5c9446d5847cd584193ee3c8634c729c2b" dependencies = [ "log", "rustls", @@ -2879,16 +2878,15 @@ dependencies = [ [[package]] name = "gpui-pre-linux" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0aac7022e347409454777701082201742710052813964a1e25160f7e22c965ad" +checksum = "b1131746fad5c87b5de74dc9c73105655b0c820a2dd4bb247586573e7cf5720e" dependencies = [ "accesskit", "accesskit_unix", "anyhow", "as-raw-xcb-connection", "ashpd", - "bitflags 2.13.2", "bytemuck", "calloop", "calloop-wayland-source", @@ -2917,7 +2915,6 @@ dependencies = [ "wayland-protocols", "wayland-protocols-plasma", "wayland-protocols-wlr", - "x11-clipboard", "x11rb", "xkbcommon", "zed-scap", @@ -2926,20 +2923,20 @@ dependencies = [ [[package]] name = "gpui-pre-macos" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5a43af845b260b09393e923c4f1e1a67a10fcfc847b4815192bbfd02ed9fe725" +checksum = "0d756813ff1cd741a502af60fd18869e0677795936d7349642b41c49f43120c0" dependencies = [ "accesskit", "accesskit_macos", "anyhow", - "async-task", "block2 0.6.2", "cocoa 0.26.1", "core-foundation 0.10.1", "core-foundation-sys", "core-graphics 0.24.0", "core-text", + "core-video", "ctor", "dispatch2", "foreign-types", @@ -2947,12 +2944,11 @@ dependencies = [ "gpui-pre", "gpui-pre-apple", "gpui-pre-collections", + "gpui-pre-scheduler", "gpui-pre-util", "image", "itertools 0.14.0", - "libc", "log", - "mach2", "objc", "objc2 0.6.4", "objc2-app-kit 0.3.2", @@ -2973,9 +2969,9 @@ dependencies = [ [[package]] name = "gpui-pre-macros" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e1627c5dd3c3351e39e0621d3f3e31dbbe7c7658df90b1d2ec12bfe95d54cd37" +checksum = "d2ad26ca759364694cb55487bebff4c1b7dd6accab49990ff3cda6f7a818a9f4" dependencies = [ "heck 0.5.0", "proc-macro-crate", @@ -2986,9 +2982,9 @@ dependencies = [ [[package]] name = "gpui-pre-perf" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01954497dd02ba96ab4c252a8668643b0cc3085e7a75dca62e7ba0e3dcd83b02" +checksum = "2fcb288a506a476b6cbf97e6ec1cc0118db43f0ca91087588afa1c2d8cd4f842" dependencies = [ "gpui-pre-collections", "serde", @@ -2997,9 +2993,9 @@ dependencies = [ [[package]] name = "gpui-pre-platform" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0112105c2ac6757fbf527c9c92a753be9bcbce9f4cf26959a11eaa1db6565aa5" +checksum = "e200b7b78836f1a0fc70767d971768e88f29d8b91b697285c15fb8e373635a3e" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3012,9 +3008,9 @@ dependencies = [ [[package]] name = "gpui-pre-refineable" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ffd14639e5dfab906bbaeabfe0f94289120dcf2e6ed37217deb13964867e89a5" +checksum = "4042345bb5bd3ecbc5d15b21ed128154f462a992058d7038cd397631f5462338" dependencies = [ "gpui-pre-derive-refineable", ] @@ -3071,9 +3067,9 @@ dependencies = [ [[package]] name = "gpui-pre-reqwest-client" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "53fa47d680f9e186b33af323f97345d67b6150c0e4c0bc894406bff822a4d9b4" +checksum = "894b8d8618c92cbd4da28a04dd3fcf3c0e0607074fa99416a864ac9b63b18aae" dependencies = [ "anyhow", "bytes", @@ -3089,9 +3085,9 @@ dependencies = [ [[package]] name = "gpui-pre-scheduler" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8049963b716df01b67b58f86000597f222d5b31b15b35069b23a3b07f3c68352" +checksum = "f4acd73345bc3c09f1b98c1b21eb4459ad8336f89bb2edcef619e11c5a08456e" dependencies = [ "async-task", "backtrace", @@ -3106,9 +3102,9 @@ dependencies = [ [[package]] name = "gpui-pre-shared-string" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8ef6314bdf5ab162c1913712ec4a91b013ce2a9cfb0b557ecdfdd5793c8b61d3" +checksum = "477529fd5aa90bff969785ee9a3938484d94aa5101cdf7b84719629c0345f98a" dependencies = [ "schemars", "serde", @@ -3117,9 +3113,9 @@ dependencies = [ [[package]] name = "gpui-pre-sum-tree" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5cca7e5fad9d53b2d265860fefdffc5f171966cb99c3fd99ecac15c033f19209" +checksum = "9ec0ec176d576b82aed716cf47185856b54ea9528caaf9d01787024689bfd824" dependencies = [ "gpui-pre-ztracing", "heapless 0.9.3", @@ -3130,9 +3126,9 @@ dependencies = [ [[package]] name = "gpui-pre-util" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d82e06359ae76f8bb07ed713c54adb80cea6a9a2d3dc715f41aec30cd2aadb4d" +checksum = "e1635ae7d0b05400bfa211ddef6c67dc8d9f223c463022c2b3bf2c1c14c6044f" dependencies = [ "anyhow", "log", @@ -3141,9 +3137,9 @@ dependencies = [ [[package]] name = "gpui-pre-util-macros" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8a0da4143bb0e7eb926eed7edd3a61c9d17344207543dcc25fb398f0cb96b2f" +checksum = "01486fce7722e5d7d7e7bb71c719dfdcc6ed917112bdc7c56d0556fdb60d10ab" dependencies = [ "gpui-pre-perf", "quote", @@ -3152,9 +3148,9 @@ dependencies = [ [[package]] name = "gpui-pre-web" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3870cc909471bb830c3d79496770c754401aad6cfa122147ccb13e8908ad08" +checksum = "184776d0763e6889dd2842dcb1204c15055945f2bdfe926b5ef57bdbd1952a65" dependencies = [ "anyhow", "console_error_panic_hook", @@ -3181,9 +3177,9 @@ dependencies = [ [[package]] name = "gpui-pre-wgpu" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f0b02657b56b09140ce542f5e4434fb96d9fdaba0fd0035ba7dba13c171ddb9b" +checksum = "439c5421cafe1a7cf68d0c0ae5cc82b26fad6b0e6345ac98d20acaf1f2463914" dependencies = [ "anyhow", "bytemuck", @@ -3208,9 +3204,9 @@ dependencies = [ [[package]] name = "gpui-pre-windows" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f05592a6f9e3e6bb7a9f2a0d4779271cf747a948db1c07b02448de777ffff8f3" +checksum = "371f9bb13f97e3bea2f8b8e5e81232bb74d735153c244ae67f7400e743925b1c" dependencies = [ "accesskit", "accesskit_windows", @@ -3220,6 +3216,7 @@ dependencies = [ "futures", "gpui-pre", "gpui-pre-collections", + "gpui-pre-scheduler", "gpui-pre-util", "image", "itertools 0.14.0", @@ -3237,9 +3234,9 @@ dependencies = [ [[package]] name = "gpui-pre-zlog" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a7506302091e66d7fb5e04a5b3480e90cff0bbe3157458d7df91a41c59238c48" +checksum = "3bbe5b98630ef4bfed5a7105ec1062e78d571912bc491388f07ea22f2457c18b" dependencies = [ "anyhow", "chrono", @@ -3249,9 +3246,9 @@ dependencies = [ [[package]] name = "gpui-pre-ztracing" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4ed1bc6d53b3ca93785a8573d1066d555b67bc475b344fb28871f9811eb6f218" +checksum = "f806400f6bda1fdd9fc627e158c0e44fb0c4b7767582a3621efcf92f4a850db7" dependencies = [ "gpui-pre-zlog", "gpui-pre-ztracing-macro", @@ -3261,9 +3258,9 @@ dependencies = [ [[package]] name = "gpui-pre-ztracing-macro" -version = "0.3.7" +version = "0.3.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "829258540ea51bb9ea4a71686beffe5c99586901ba9f39944d06e4a180ab63fe" +checksum = "d337c9acf058238e45ce0570d8a5958439242889492f91ae2d8e324211a61ccd" [[package]] name = "gpui-updater-core" @@ -4442,9 +4439,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.2.3" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4b18443e9c262bfe8fa82f51666e2642c53393f7e5c27b3e1aeab922cff5b9d8" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi", @@ -5756,9 +5753,9 @@ dependencies = [ [[package]] name = "powerfmt" -version = "0.2.0" +version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" +checksum = "4a6394b9e965e73d0a289ee54f589087e2c676aedf60885baf52c76b771e4958" [[package]] name = "ppv-lite86" @@ -7037,7 +7034,6 @@ version = "0.3.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4aaa7368fcf4852a4c2dd92df0cace6a71f2091ca0a23391ce7f3a31833f1523" dependencies = [ - "borsh", "serde_core", ] @@ -7617,9 +7613,9 @@ checksum = "fd3ca314f692efd6c868f8408f53fe444634a845f96c028b97d35f6a1f79f0ee" [[package]] name = "tokio" -version = "1.53.1" +version = "1.53.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "202caea871b69668250d242070849eb495be178ed697a3e98aebce5bc81a0bed" +checksum = "e95f91fcc7a621e8b030f6aa23c71fe9838ae2fb4d8118b75602a328f5144044" dependencies = [ "bytes", "libc", @@ -9472,16 +9468,6 @@ dependencies = [ "pkg-config", ] -[[package]] -name = "x11-clipboard" -version = "0.9.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "662d74b3d77e396b8e5beb00b9cad6a9eccf40b2ef68cc858784b14c41d535a3" -dependencies = [ - "libc", - "x11rb", -] - [[package]] name = "x11rb" version = "0.13.2" diff --git a/assets/icons/book.svg b/assets/icons/book.svg index 9f5eee0c..9f439a6f 100644 --- a/assets/icons/book.svg +++ b/assets/icons/book.svg @@ -1,3 +1 @@ - - - + diff --git a/assets/icons/plus-chat.svg b/assets/icons/plus-chat.svg new file mode 100644 index 00000000..e4649d28 --- /dev/null +++ b/assets/icons/plus-chat.svg @@ -0,0 +1 @@ + diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index 98c14cdd..c76c65dd 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -19,11 +19,15 @@ use state::{DEVICE_GIFTWRAP, NostrRegistry, USER_GIFTWRAP}; mod message; mod room; +mod search; pub use message::*; pub use room::*; +pub use search::SearchMessage; pub use state::FileAttachment; +use crate::search::MessageSearchIndex; + /// A static keypair used only for signing locally-cached rumor events. static LOCAL_KEYS: LazyLock = LazyLock::new(Keys::generate); @@ -82,6 +86,8 @@ pub struct ChatRegistry { seen: Arc>>>, /// Mapping of unwrapped event ids to their gift wrap event ids event_map: Arc>>, + /// In-memory message content index for search + search: Arc>, /// True while the initial event backlog is still loading tracking: Arc, /// Channel for sending signals to the UI. @@ -170,6 +176,7 @@ impl ChatRegistry { trash: cx.new(|_| BTreeSet::default()), seen: Arc::new(RwLock::new(HashMap::default())), event_map: Arc::new(RwLock::new(HashMap::default())), + search: Arc::default(), tracking: Arc::new(AtomicBool::new(true)), matcher: CachedMatcher(SkimMatcherV2::default()), signal_rx: rx, @@ -646,10 +653,16 @@ impl ChatRegistry { } } + /// Search the content of all indexed messages, newest first. + pub fn search_messages(&self, terms: &[String], limit: usize) -> Vec { + self.search.read().unwrap().search(terms, limit) + } + /// Reset the registry. pub fn reset(&mut self, cx: &mut Context) { self.rooms.clear(); self.room_index.clear(); + self.search.write().unwrap().clear(); self.trash.update(cx, |this, cx| { this.clear(); cx.notify(); @@ -715,6 +728,7 @@ impl ChatRegistry { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); let signer = nostr.read(cx).signer(); + let search = self.search.clone(); cx.background_spawn(async move { let public_key = signer.get_public_key_async().await?; @@ -746,6 +760,7 @@ impl ChatRegistry { if let Ok(rumor) = UnsignedEvent::from_json(&raw.content) && rumor.tags.public_keys().next().is_some() { + search.write().unwrap().insert(&rumor); if rumor.pubkey != public_key && !rumor.tags.public_keys().any(|k| k == public_key) { @@ -782,6 +797,8 @@ impl ChatRegistry { /// - If the room doesn't exist, it will be created. /// - Updates room ordering based on the most recent messages. pub fn new_message(&mut self, message: NewMessage, cx: &mut Context) { + self.search.write().unwrap().insert(&message.rumor); + let nostr = NostrRegistry::global(cx); let Some(public_key) = nostr.read(cx).current_user() else { diff --git a/crates/chat/src/search.rs b/crates/chat/src/search.rs new file mode 100644 index 00000000..320ceec8 --- /dev/null +++ b/crates/chat/src/search.rs @@ -0,0 +1,133 @@ +use std::cmp::Reverse; +use std::collections::BTreeMap; +use std::sync::Arc; + +use common::EventExt; +use nostr_sdk::prelude::*; + +/// A chat message kept in memory for content search. +#[derive(Debug, Clone)] +pub struct SearchMessage { + /// ID of the room the message belongs to + pub room: u64, + /// Author of the message + pub author: PublicKey, + /// The content of the message + pub content: Arc, + /// When the message was created + pub created_at: Timestamp, +} + +#[derive(Debug)] +struct IndexedMessage { + message: SearchMessage, + /// Lowercase content so repeated searches don't re-allocate per message + normalized: Arc, +} + +/// In-memory index of chat message rumors, keyed by room and rumor id. +#[derive(Debug, Default)] +pub struct MessageSearchIndex(BTreeMap>>); + +impl MessageSearchIndex { + /// Index a rumor for search, ignoring reactions and empty content. + pub fn insert(&mut self, rumor: &UnsignedEvent) { + if !is_chat(rumor.kind) || rumor.content.trim().is_empty() { + return; + } + + let Some(id) = rumor.id else { return }; + + let indexed = Arc::new(IndexedMessage { + message: SearchMessage { + room: rumor.uniq_id(), + author: rumor.pubkey, + content: rumor.content.as_str().into(), + created_at: rumor.created_at, + }, + normalized: rumor.content.to_lowercase().into(), + }); + + self.0 + .entry(rumor.uniq_id()) + .or_default() + .entry(id) + .or_insert(indexed); + } + + /// Find messages whose content contains all of the terms, newest first. + pub fn search(&self, terms: &[String], limit: usize) -> Vec { + let mut matches: Vec<&Arc> = self + .0 + .values() + .flat_map(|messages| messages.values()) + .filter(|indexed| terms.iter().all(|term| indexed.normalized.contains(term))) + .collect(); + + matches.sort_by_key(|indexed| Reverse(indexed.message.created_at)); + matches.truncate(limit); + + matches + .into_iter() + .map(|indexed| indexed.message.clone()) + .collect() + } + + /// Drop all indexed messages. + pub fn clear(&mut self) { + self.0.clear(); + } +} + +fn is_chat(kind: Kind) -> bool { + kind == Kind::PrivateDirectMessage || kind == Kind::Custom(15) +} + +#[cfg(test)] +mod tests { + use super::*; + + fn rumor(kind: Kind, content: &str, created_at: u64) -> UnsignedEvent { + let sender = Keys::generate(); + let peer = Keys::generate().public_key(); + + let mut rumor = EventBuilder::new(kind, content) + .tag(Tag::public_key(peer)) + .finalize_unsigned(sender.public_key()); + rumor.ensure_id(); + rumor.created_at = Timestamp::from(created_at); + rumor + } + + #[test] + fn search_matches_all_terms_newest_first_and_deduplicates() { + let mut index = MessageSearchIndex::default(); + let first = rumor(Kind::PrivateDirectMessage, "Hello ALICE", 10); + let second = rumor(Kind::PrivateDirectMessage, "hello again", 20); + let reaction = rumor(Kind::Reaction, "hello world", 30); + + index.insert(&first); + index.insert(&first); + index.insert(&second); + index.insert(&reaction); + + let hits = index.search(&["hello".to_owned()], 10); + assert_eq!(hits.len(), 2); + assert_eq!(&*hits[0].content, "hello again"); + assert_eq!(hits[0].created_at, Timestamp::from(20)); + + let hits = index.search(&["hello".to_owned(), "again".to_owned()], 10); + assert_eq!(hits.len(), 1); + + let hits = index.search(&["hello".to_owned()], 1); + assert_eq!(hits.len(), 1); + } + + #[test] + fn empty_content_is_not_indexed() { + let mut index = MessageSearchIndex::default(); + index.insert(&rumor(Kind::PrivateDirectMessage, " ", 10)); + + assert!(index.search(&["hello".to_owned()], 10).is_empty()); + } +} diff --git a/crates/nip29/src/group.rs b/crates/nip29/src/group.rs index dace77c6..3775d64d 100644 --- a/crates/nip29/src/group.rs +++ b/crates/nip29/src/group.rs @@ -88,6 +88,7 @@ pub struct Group { oldest: Option, loading: bool, loading_more: bool, + active: bool, refused: Option, tasks: SmallVec<[Task<()>; 2]>, } @@ -115,6 +116,7 @@ impl Group { oldest: None, loading: true, loading_more: false, + active: false, refused: None, tasks: smallvec![], } @@ -196,6 +198,14 @@ impl Group { self.loading_more } + pub(crate) fn is_active(&self) -> bool { + self.active + } + + pub(crate) fn activate(&mut self) { + self.active = true; + } + pub fn oldest(&self) -> Option { self.oldest } @@ -496,16 +506,25 @@ impl Group { }) } - pub(crate) fn load_envelopes(&self, cx: &App) -> Task>> { + pub(crate) fn load_envelopes(&self, cx: &App, state_only: bool) -> Task>> { let cache_tag = self.key.cache_tag(); let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); cx.background_spawn(async move { - let filter = Filter::new() + let mut filter = Filter::new() .kind(Kind::ApplicationSpecificData) .custom_tag(SingleLetterTag::LOWERCASE_R, cache_tag); + if state_only { + let kinds = STATE_KINDS + .iter() + .chain(MEMBERSHIP_KINDS.iter()) + .map(|kind| kind.to_string()); + + filter = filter.custom_tags(SingleLetterTag::LOWERCASE_K, kinds); + } + let mut events: Vec = client .database() .query(filter) diff --git a/crates/nip29/src/lib.rs b/crates/nip29/src/lib.rs index cb6df300..7fb8b75e 100644 --- a/crates/nip29/src/lib.rs +++ b/crates/nip29/src/lib.rs @@ -549,7 +549,7 @@ impl GroupsRegistry { ); self.subscribe(key.clone(), cx); - self.reload(key, None, cx); + self.load_state(key, cx); } fn subscribe(&mut self, key: GroupKey, cx: &mut Context) { @@ -679,7 +679,7 @@ impl GroupsRegistry { }; let logged = key.clone(); - let load = group.read(cx).load_envelopes(cx); + let load = group.read(cx).load_envelopes(cx, false); self.tasks.push(cx.spawn(async move |_this, cx| { if let Some(event) = event @@ -704,6 +704,71 @@ impl GroupsRegistry { })); } + /// Load only the group state (metadata, members, membership) from the cache. + fn load_state(&mut self, key: GroupKey, cx: &mut Context) { + let Some(group) = self.group(&key, cx) else { + return; + }; + + let Some(me) = NostrRegistry::global(cx).read(cx).current_user() else { + return; + }; + + let logged = key.clone(); + let load = group.read(cx).load_envelopes(cx, true); + + self.tasks + .push(cx.spawn(async move |_this, cx| match load.await { + Ok(events) => { + group.update(cx, |group, cx| { + group.hydrate(events, me, cx); + }); + } + Err(error) => { + log::warn!("nip29: loading {} state failed: {error}", logged.id()); + } + })); + } + + /// Cache and absorb a single event without rebuilding the group. + fn absorb(&mut self, key: GroupKey, event: Event, cx: &mut Context) { + let Some(group) = self.group(&key, cx) else { + return; + }; + + let Some(me) = NostrRegistry::global(cx).read(cx).current_user() else { + return; + }; + + self.tasks.push(cx.spawn(async move |_this, cx| { + if let Err(error) = group + .read_with(cx, |group, cx| group.save_envelope(event.clone(), cx)) + .await + { + log::warn!("nip29: caching failed: {error}"); + return; + } + + group.update(cx, |group, cx| { + group.hydrate(vec![event], me, cx); + }); + })); + } + + /// Load the full group content on first open. + pub fn activate(&mut self, key: &GroupKey, cx: &mut Context) { + let Some(group) = self.group(key, cx) else { + return; + }; + + if group.read(cx).is_active() { + return; + } + + group.update(cx, |group, _cx| group.activate()); + self.reload(key.clone(), None, cx); + } + fn forget_event(&mut self, group: Entity, event: Event, cx: &mut Context) { let Some(target) = event.tags.event_ids().next() else { return; @@ -761,7 +826,15 @@ impl GroupsRegistry { return; }; - self.reload(key, Some(event), cx); + let active = self + .group(&key, cx) + .is_some_and(|group| group.read(cx).is_active()); + + if active { + self.reload(key, Some(event), cx); + } else { + self.absorb(key, event, cx); + } } fn absorb_candidate(&mut self, id: &SubscriptionId, event: &Event, cx: &mut Context) { @@ -819,7 +892,7 @@ impl GroupsRegistry { } if id == fork_subscription_id() { - log::warn!("nip29: the admins' group lists were refused: {reason}"); + log::debug!("nip29: the admins' group lists were refused: {reason}"); return; } diff --git a/crates/nip29_ui/src/lib.rs b/crates/nip29_ui/src/lib.rs index 36d0411c..f020eef4 100644 --- a/crates/nip29_ui/src/lib.rs +++ b/crates/nip29_ui/src/lib.rs @@ -135,6 +135,13 @@ fn opens_run(rows: &[Row], index: usize) -> bool { } pub fn init(group: Entity, window: &mut Window, cx: &mut App) -> Entity { + let key = group.read(cx).key().clone(); + let groups = GroupsRegistry::global(cx); + + groups.update(cx, |registry, cx| { + registry.activate(&key, cx); + }); + cx.new(|cx| GroupPanel::new(group, window, cx)) } diff --git a/crates/ui/src/icon.rs b/crates/ui/src/icon.rs index 198f5977..b2642e6a 100644 --- a/crates/ui/src/icon.rs +++ b/crates/ui/src/icon.rs @@ -56,6 +56,7 @@ pub enum IconName { Message, Moon, Plus, + PlusChat, PlusCircle, Profile, Reset, @@ -139,6 +140,7 @@ impl IconNamed for IconName { Self::Message => "icons/message.svg", Self::Moon => "icons/moon.svg", Self::Plus => "icons/plus.svg", + Self::PlusChat => "icons/plus-chat.svg", Self::PlusCircle => "icons/plus-circle.svg", Self::Profile => "icons/profile.svg", Self::Reset => "icons/reset.svg", diff --git a/crates/ui/src/menu/popup_menu.rs b/crates/ui/src/menu/popup_menu.rs index c9325526..285f4eb3 100644 --- a/crates/ui/src/menu/popup_menu.rs +++ b/crates/ui/src/menu/popup_menu.rs @@ -990,7 +990,7 @@ impl PopupMenu { let this = MenuItemElement::new(ix, &group_name) .relative() - .text_sm() + .text_xs() .py_0() .px(INNER_PADDING) .rounded(radius) @@ -1063,7 +1063,7 @@ impl PopupMenu { }) .disabled(*disabled) .h(item_height) - .gap_x_1() + .gap_x_1p5() .children(Self::render_icon( has_left_icon, is_left_check, @@ -1110,7 +1110,7 @@ impl PopupMenu { .min_h(item_height) .size_full() .items_center() - .gap_x_1() + .gap_x_1p5() .children(Self::render_icon( has_left_icon, false, diff --git a/crates/ui/src/nav.rs b/crates/ui/src/nav.rs index 2193eb5d..e3c47774 100644 --- a/crates/ui/src/nav.rs +++ b/crates/ui/src/nav.rs @@ -105,7 +105,7 @@ impl RenderOnce for Nav { fn render(self, _window: &mut Window, cx: &mut App) -> impl IntoElement { let clickable = self.on_click.is_some() || self.clickable; - v_flex().w_full().h_10().child( + v_flex().h_10().w_full().items_center().child( h_flex() .id(self.id) .h_9() diff --git a/crates/workspace/src/sidebar/group_view.rs b/crates/workspace/src/sidebar/group_view.rs new file mode 100644 index 00000000..bde29a49 --- /dev/null +++ b/crates/workspace/src/sidebar/group_view.rs @@ -0,0 +1,310 @@ +use std::ops::Range; +use std::rc::Rc; + +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, ClipboardItem, Context, Entity, IntoElement, ParentElement, SharedString, Styled, + WeakEntity, div, px, relative, uniform_list, +}; +use nip29::Group; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use settings::AppSettings; +use state::NostrRegistry; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::menu::{DropdownMenu, PopupMenuItem}; +use ui::nav::Nav; +use ui::scroll::Scrollbar; +use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; + +use super::Sidebar; +use crate::dialogs::profile; + +/// A collapsible section of the group view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +enum GroupSection { + Admins, + Members, +} + +impl GroupSection { + fn label(self) -> &'static str { + match self { + Self::Admins => "Admins", + Self::Members => "Members", + } + } +} + +/// A row in the group view. +enum GroupRow { + Section(GroupSection), + Member { + public_key: PublicKey, + roles: Vec, + }, +} + +impl Sidebar { + /// Show the group view, replacing the main list. + pub(super) fn show_group(&mut self, group: &Entity, cx: &mut Context) { + self.group = Some(group.downgrade()); + self.group_open = true; + cx.notify(); + } + + /// Leave the group view, returning the sidebar to its main list. + pub(super) fn hide_group(&mut self, cx: &mut Context) { + if !self.group_open { + return; + } + + self.group_open = false; + cx.notify(); + } + + pub(super) fn render_group( + &mut self, + group: Entity, + cx: &mut Context, + ) -> AnyElement { + let admin = NostrRegistry::global(cx) + .read(cx) + .current_user() + .is_some_and(|me| group.read(cx).admins().contains(&me)); + + let (name, seed, picture, total_members, rows) = { + let group = group.read(cx); + let admins = group.admins(); + + let mut admin_rows = Vec::new(); + let mut member_rows = Vec::new(); + + for (public_key, roles) in admins.entries() { + admin_rows.push(GroupRow::Member { + public_key: *public_key, + roles: roles.clone(), + }); + } + + for public_key in group.members().iter() { + if admins.contains(public_key) { + continue; + } + + member_rows.push(GroupRow::Member { + public_key: *public_key, + roles: Vec::new(), + }); + } + + let mut rows = Vec::new(); + + if !admin_rows.is_empty() { + rows.push(GroupRow::Section(GroupSection::Admins)); + if self.admins_open { + rows.append(&mut admin_rows); + } + } + + if !member_rows.is_empty() { + rows.push(GroupRow::Section(GroupSection::Members)); + if self.members_open { + rows.append(&mut member_rows); + } + } + + ( + group.display_name(), + group.key().cache_tag(), + group.display_image(), + SharedString::from(format!("{} members", group.total_members())), + rows, + ) + }; + + let rows = Rc::new(rows); + let member_group = group.downgrade(); + + v_flex() + .flex_1() + .min_h_0() + .w_full() + .gap_2() + .child( + h_flex() + .px_2() + .flex_shrink_0() + .justify_between() + .child( + h_flex() + .pl_0p5() + .gap_1() + .flex_shrink_1() + .child(Avatar::from_source(picture).seed(seed).small()) + .child( + v_flex() + .flex_1() + .min_w_0() + .child( + div() + .text_xs() + .truncate() + .line_height(relative(1.2)) + .font_semibold() + .child(name), + ) + .child( + div() + .text_size(px(10.)) + .text_color(cx.theme().text_muted) + .child(total_members), + ), + ), + ) + .child( + h_flex() + .gap_1() + .child(nip29_ui::actions(group.downgrade(), cx)), + ), + ) + .child( + div() + .min_h_0() + .flex_1() + .child( + uniform_list( + "group-rows", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_group_rows( + range, + rows.as_slice(), + &member_group, + admin, + cx, + ) + }), + ) + .track_scroll(&self.group_scroll) + .h_full() + .px_2(), + ) + .child(Scrollbar::vertical(&self.group_scroll)), + ) + .into_any_element() + } + + fn render_group_rows( + &self, + range: Range, + rows: &[GroupRow], + group: &WeakEntity, + admin: bool, + cx: &mut Context, + ) -> Vec { + rows.get(range) + .into_iter() + .flatten() + .map(|row| match row { + GroupRow::Section(section) => self.section_row(*section, cx), + GroupRow::Member { public_key, roles } => { + self.member_row(public_key, roles, group, admin, cx) + } + }) + .collect() + } + + fn section_row(&self, section: GroupSection, cx: &mut Context) -> AnyElement { + let open = match section { + GroupSection::Admins => self.admins_open, + GroupSection::Members => self.members_open, + }; + let icon = if open { + IconName::CaretDown + } else { + IconName::CaretRight + }; + let label = section.label(); + + Nav::new(label) + .label(label) + .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted)) + .text_xs() + .font_semibold() + .text_color(cx.theme().text_placeholder) + .on_click(cx.listener(move |this, _event, _window, cx| { + match section { + GroupSection::Admins => this.admins_open = !this.admins_open, + GroupSection::Members => this.members_open = !this.members_open, + } + cx.notify(); + })) + .into_any_element() + } + + fn member_row( + &self, + public_key: &PublicKey, + roles: &[String], + group: &WeakEntity, + admin: bool, + cx: &mut Context, + ) -> AnyElement { + let hide_avatar = AppSettings::get_hide_avatar(cx); + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(public_key, cx); + let public_key = *public_key; + + let group = group.clone(); + + Nav::new(public_key.to_hex()) + .label(person.name()) + .text_sm() + .font_medium() + .clickable(true) + .when(!hide_avatar, |this| { + this.prefix( + Avatar::new(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .when(!roles.is_empty(), |this| { + this.suffix( + div() + .truncate() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(SharedString::from(roles.join(", "))), + ) + }) + .dropdown_menu(move |menu, _window, _cx| { + let menu = menu + .item( + PopupMenuItem::new("View profile").on_click(move |_, window, cx| { + profile::open(public_key, window, cx); + }), + ) + .item(PopupMenuItem::new("Copy").on_click(move |_, _, cx| { + let npub = public_key.to_bech32().unwrap_or_default(); + cx.write_to_clipboard(ClipboardItem::new_string(npub)); + })); + + if !admin { + return menu; + } + + menu.separator() + .item(PopupMenuItem::new("Remove").on_click({ + let group = group.clone(); + move |_event, window, cx| { + nip29_ui::remove_member(group.clone(), public_key, window, cx); + } + })) + }) + .into_any_element() + } +} diff --git a/crates/workspace/src/sidebar/header.rs b/crates/workspace/src/sidebar/header.rs new file mode 100644 index 00000000..bc7d8b51 --- /dev/null +++ b/crates/workspace/src/sidebar/header.rs @@ -0,0 +1,156 @@ +use auto_update::AutoUpdater; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, Context, Div, Entity, InteractiveElement, IntoElement, ParentElement, + Stateful, Styled, div, px, +}; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use theme::TABBAR_HEIGHT; +use ui::avatar::Avatar; +use ui::button::{Button, ButtonVariants}; +use ui::menu::DropdownMenu; +use ui::{Disableable, IconName, Selectable, Sizable, TRAFFIC_LIGHT_PADDING, h_flex}; + +use super::Sidebar; +use crate::Command; + +impl Sidebar { + pub(super) fn render_user( + &self, + current_user: &PublicKey, + cx: &mut Context, + ) -> Stateful
{ + let updater = AutoUpdater::try_global(cx); + let persons = PersonRegistry::global(cx); + let profile = persons.read(cx).get(current_user, cx); + let avatar = profile.avatar(); + let avatar_seed = profile.avatar_seed(); + + h_flex() + .id("sidebar-user") + .w_full() + .h(TABBAR_HEIGHT) + .flex_shrink_0() + .items_center() + .gap_2() + .px_2() + .when(cfg!(target_os = "macos"), |this| { + this.pl(px(TRAFFIC_LIGHT_PADDING)) + }) + .child( + Button::new("current-user") + .child( + Avatar::new(avatar.clone()) + .seed(avatar_seed.clone()) + .small(), + ) + .small() + .caret() + .compact() + .transparent() + .dropdown_menu(move |this, _window, _cx| { + this.min_w(px(256.)) + .menu_with_icon( + "Profile", + IconName::Profile, + Box::new(Command::ShowProfile), + ) + .menu_with_icon( + "Relays", + IconName::Relay, + Box::new(Command::ShowRelayList), + ) + .separator() + .menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme)) + .menu_with_icon( + "Settings", + IconName::Settings, + Box::new(Command::ShowSettings), + ) + }), + ) + .child(div().flex_1()) + .when_some(updater, |this, updater| { + this.child(self.render_updater(updater, cx)) + }) + .child( + Button::new("sidebar-search") + .icon(IconName::Search) + .tooltip("Search") + .selected(self.search_open) + .ghost() + .small() + .on_click(cx.listener(|this, _event, window, cx| { + this.toggle_search(window, cx); + })), + ) + .child( + Button::new("sidebar-new") + .icon(IconName::PlusChat) + .tooltip("New") + .ghost() + .small() + .dropdown_menu(|menu, _window, _cx| { + menu.menu_with_icon( + "New Group", + IconName::Group, + Box::new(Command::NewGroup), + ) + .menu_with_icon("Join Group", IconName::Door, Box::new(Command::JoinGroup)) + .separator() + .menu_with_icon( + "New Chat", + IconName::Message, + Box::new(Command::NewChat), + ) + }), + ) + .when(self.group_open, |this| { + this.child( + Button::new("sidebar-back") + .icon(IconName::ArrowLeft) + .tooltip("Back") + .ghost() + .small() + .on_click(cx.listener(|this, _event, _window, cx| { + this.hide_group(cx); + })), + ) + }) + } + + fn render_updater(&self, updater: Entity, cx: &mut App) -> AnyElement { + let status = updater.read(cx).status(); + let up_to_date = updater.read(cx).up_to_date(); + let staged = updater.read(cx).staged(); + + h_flex() + .gap_2() + .when(!up_to_date, |this| { + this.child( + Button::new("update") + .icon(IconName::ArrowDownCircle) + .tooltip(status) + .small() + .ghost() + .disabled(true), + ) + }) + .when(staged, |this| { + this.child( + Button::new("restart") + .icon(IconName::ArrowDownCircle) + .tooltip("Quit and relaunch into the installed update") + .small() + .ghost() + .on_click(move |_, _window, cx| { + updater.update(cx, |this, cx| { + this.restart(cx); + }); + }), + ) + }) + .into_any_element() + } +} diff --git a/crates/workspace/src/sidebar/inbox.rs b/crates/workspace/src/sidebar/inbox.rs new file mode 100644 index 00000000..649d9769 --- /dev/null +++ b/crates/workspace/src/sidebar/inbox.rs @@ -0,0 +1,222 @@ +use anyhow::Error; +use chat::{ChatRegistry, Room, RoomKind}; +use gpui::{ + AnyElement, App, AppContext, Context, IntoElement, ParentElement, SharedString, Styled, Task, + Window, div, +}; +use nostr_sdk::prelude::*; +use state::NostrRegistry; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::dock::{DockPlacement, PanelHandle}; +use ui::notification::Notification; +use ui::{StyledExt, WindowExtension, h_flex, v_flex}; + +use super::Sidebar; +use super::rows::SidebarRow; + +impl Sidebar { + /// Leave the contact picker and clear its selection. + fn close_contacts(&mut self) { + self.contacts_open = false; + self.selected_contacts.clear(); + } + + /// Show or hide the contact picker in the inbox list. + pub(super) fn toggle_contacts(&mut self, window: &mut Window, cx: &mut Context) { + self.contacts_open = !self.contacts_open; + + if !self.contacts_open { + self.selected_contacts.clear(); + } else { + self.close_requests(); + if self.contacts.is_none() { + self.load_contacts(window, cx); + } + } + + cx.notify(); + } + + /// Load the signed-in user's contact list into the contact picker. + fn load_contacts(&mut self, window: &mut Window, cx: &mut Context) { + let nostr = NostrRegistry::global(cx); + let client = nostr.read(cx).client(); + + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let task: Task, Error>> = cx.background_spawn(async move { + let filter = Filter::new() + .author(public_key) + .kind(Kind::ContactList) + .limit(1); + + let contacts: Vec = client + .database() + .query(filter) + .await? + .into_iter() + .next() + .map(|event| event.tags.public_keys().collect()) + .unwrap_or_default(); + + Ok(contacts) + }); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + match task.await { + Ok(contacts) => { + this.update(cx, |this, cx| { + this.contacts = Some(contacts); + cx.notify(); + })?; + } + Err(error) => { + cx.update(|window, cx| { + window.push_notification( + Notification::error(error.to_string()).autohide(false), + cx, + ); + })?; + } + } + + Ok(()) + })); + } + + /// Toggle a contact's selection in the contact picker. + pub(super) fn toggle_contact(&mut self, public_key: &PublicKey, cx: &mut Context) { + if !self.selected_contacts.remove(public_key) { + self.selected_contacts.insert(public_key.to_owned()); + } + cx.notify(); + } + + /// Start a chat with the contacts selected in the picker. + pub(super) fn create_chat(&mut self, window: &mut Window, cx: &mut Context) { + if self.selected_contacts.is_empty() { + return; + } + + let nostr = NostrRegistry::global(cx); + let Some(public_key) = nostr.read(cx).current_user() else { + return; + }; + + let chat = ChatRegistry::global(cx); + let async_chat = chat.downgrade(); + let dock = self.dock.clone(); + + let receivers: Vec = self.selected_contacts.iter().copied().collect(); + + self.tasks.push(cx.spawn_in(window, async move |this, cx| { + let room = async_chat.update_in(cx, |chat, _window, cx| { + let room = cx.new(|_| { + Room::new(public_key, receivers) + .organize(&public_key) + .kind(RoomKind::Ongoing) + }); + chat.track_room(&room, cx); + room + })?; + + cx.update(|window, cx| { + ui::dock::add_panel_to( + &dock, + PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), + DockPlacement::Center, + window, + cx, + ); + })?; + + // Leave the picker now that the chat is open + this.update(cx, |this, cx| { + this.close_contacts(); + cx.notify(); + })?; + + Ok(()) + })); + } + + /// Leave the requests list in the inbox. + fn close_requests(&mut self) { + self.requests_open = false; + } + + /// Show or hide the chat requests in the inbox list. + pub(super) fn toggle_requests(&mut self, cx: &mut Context) { + self.requests_open = !self.requests_open; + + if self.requests_open { + self.close_contacts(); + self.new_requests = false; + } + + cx.notify(); + } + + /// The signed-in user's contacts shown in the picker. + pub(super) fn contacts_rows(&self) -> Vec { + self.contacts + .clone() + .unwrap_or_default() + .into_iter() + .map(|public_key| SidebarRow::Contact { public_key }) + .collect() + } + + /// The chat requests awaiting a response. + pub(super) fn requests_rows(&self, cx: &App) -> Vec { + ChatRegistry::global(cx) + .read(cx) + .rooms(&RoomKind::Request, cx) + .into_iter() + .map(|room| SidebarRow::Room { room }) + .collect() + } + + /// The overlay with the create button and selection count. + pub(super) fn render_create_overlay(&self, cx: &mut Context) -> AnyElement { + v_flex() + .absolute() + .bottom_0() + .left_0() + .justify_center() + .h_20() + .w_full() + .px_4() + .border_t_1() + .border_color(cx.theme().border) + .bg(cx.theme().background) + .child( + Button::new("create-chat") + .label(if self.selected_contacts.len() > 1 { + "Create Group Chat" + } else { + "Create Chat" + }) + .primary() + .on_click(cx.listener(|this, _ev, window, cx| { + this.create_chat(window, cx); + })), + ) + .child( + h_flex() + .gap_1() + .text_xs() + .text_color(cx.theme().text_muted) + .child("Selected") + .child( + div() + .font_semibold() + .child(SharedString::from(self.selected_contacts.len().to_string())), + ), + ) + .into_any_element() + } +} diff --git a/crates/workspace/src/sidebar/list.rs b/crates/workspace/src/sidebar/list.rs new file mode 100644 index 00000000..d8c8acf2 --- /dev/null +++ b/crates/workspace/src/sidebar/list.rs @@ -0,0 +1,240 @@ +use std::rc::Rc; + +use chat::{ChatRegistry, RoomKind}; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, Context, IntoElement, ParentElement, SharedString, Styled, div, px, + uniform_list, +}; +use nip29::GroupsRegistry; +use settings::AppSettings; +use theme::ActiveTheme; +use ui::nav_item::NavItem; +use ui::scroll::Scrollbar; +use ui::{Icon, IconName, Sizable, StyledExt, h_flex, v_flex}; + +use super::Sidebar; +use super::rows::SidebarRow; +use crate::Command; + +/// A collapsible section of the sidebar's main list. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(super) enum ListSection { + Pins, + Groups, + Chats, +} + +impl ListSection { + pub(super) fn label(self) -> &'static str { + match self { + Self::Pins => "Pinned", + Self::Groups => "Groups", + Self::Chats => "Chats", + } + } +} + +impl Sidebar { + pub(super) fn render_list(&mut self, cx: &mut Context) -> AnyElement { + let requests_open = self.requests_open; + let contacts_open = self.contacts_open && !requests_open; + let rows = Rc::new(self.rows_for(cx)); + + // Whether to show the create button for the contact picker + let create_button = contacts_open && !self.selected_contacts.is_empty(); + + let (list_id, scroll_handle, list_title) = if contacts_open { + ("sidebar-contacts", &self.contacts_scroll, Some("Contacts")) + } else if requests_open { + ("sidebar-requests", &self.requests_scroll, Some("Requests")) + } else { + ("sidebar-list", &self.list_scroll, None) + }; + + let request_count = ChatRegistry::global(cx) + .read(cx) + .count(&RoomKind::Request, cx); + + v_flex() + .size_full() + .flex_1() + .min_h_0() + .relative() + .gap_2() + .child( + v_flex() + .px_2() + .gap_1() + .child( + NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small()) + .on_click(|_, window, cx| { + window.dispatch_action(Box::new(Command::ShowBrowse), cx) + }), + ) + .child( + NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small()) + .when(request_count > 0, |this| { + this.suffix( + h_flex() + .gap_1() + .items_center() + .child( + h_flex() + .py_0p5() + .px_1() + .min_w_6() + .justify_center() + .text_size(px(10.)) + .text_color(cx.theme().text_muted) + .text_center() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .child(SharedString::from( + request_count.to_string(), + )), + ) + .when(self.new_requests && !requests_open, |this| { + this.child( + div().size_1().rounded_full().bg(cx.theme().cursor), + ) + }), + ) + }) + .when(requests_open, |this| { + this.bg(cx.theme().ghost_element_active) + }) + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_requests(cx); + })), + ) + .child( + NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small()) + .when(contacts_open, |this| { + this.bg(cx.theme().ghost_element_active) + }) + .on_click(cx.listener(|this, _event, window, cx| { + this.toggle_contacts(window, cx); + })), + ), + ) + .when_some(list_title, |this, list_title| { + this.child( + div() + .px_4() + .text_xs() + .font_semibold() + .text_color(cx.theme().text_placeholder) + .child(list_title), + ) + }) + .child( + div() + .min_h_0() + .flex_1() + .child( + uniform_list( + list_id, + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_rows(range, rows.as_slice(), cx) + }), + ) + .track_scroll(scroll_handle) + .h_full() + .px_2(), + ) + .child(Scrollbar::vertical(scroll_handle)), + ) + .when(create_button, |this| { + this.child(self.render_create_overlay(cx)) + }) + .into_any_element() + } + + fn rows_for(&self, cx: &App) -> Vec { + if self.contacts_open { + return self.contacts_rows(); + } + + if self.requests_open { + return self.requests_rows(cx); + } + + let mut rows = Vec::new(); + + let chat = ChatRegistry::global(cx); + let pinned_rooms = AppSettings::get_pinned_rooms(cx); + + let (pinned_rooms, rooms): (Vec<_>, Vec<_>) = chat + .read(cx) + .rooms(&RoomKind::Ongoing, cx) + .into_iter() + .partition(|room| pinned_rooms.contains(&room.read(cx).id)); + + let registry = GroupsRegistry::global(cx); + let pinned_group_ids = AppSettings::get_pinned_groups(cx); + + let (pinned_groups, groups): (Vec<_>, Vec<_>) = registry + .read(cx) + .groups() + .to_vec() + .into_iter() + .partition(|group| { + let tag = group.read(cx).key().cache_tag(); + pinned_group_ids.iter().any(|pinned| pinned == &tag) + }); + + rows.push(SidebarRow::Section(ListSection::Pins)); + if self.pins_open { + if pinned_rooms.is_empty() && pinned_groups.is_empty() { + rows.push(SidebarRow::Empty("Nothing pinned yet")); + } else { + rows.extend( + pinned_groups + .into_iter() + .map(|group| SidebarRow::Group { group }), + ); + rows.extend( + pinned_rooms + .into_iter() + .map(|room| SidebarRow::Room { room }), + ); + } + } + + rows.push(SidebarRow::Section(ListSection::Groups)); + if self.is_groups_open(cx) { + if groups.is_empty() { + rows.push(SidebarRow::Empty("No groups yet")); + } else { + rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group })); + } + } + + rows.push(SidebarRow::Section(ListSection::Chats)); + if self.is_chats_open(cx) { + if rooms.is_empty() { + rows.push(SidebarRow::Empty("No chats yet")); + } else { + rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room })); + } + } + + rows + } + + /// Whether anything is pinned; drives the sections' default expansion. + fn pinned_any(cx: &App) -> bool { + !AppSettings::get_pinned_rooms(cx).is_empty() + || !AppSettings::get_pinned_groups(cx).is_empty() + } + + pub(super) fn is_chats_open(&self, cx: &App) -> bool { + self.chats_open.unwrap_or_else(|| !Self::pinned_any(cx)) + } + + pub(super) fn is_groups_open(&self, cx: &App) -> bool { + self.groups_open.unwrap_or_else(|| !Self::pinned_any(cx)) + } +} diff --git a/crates/workspace/src/sidebar/mod.rs b/crates/workspace/src/sidebar/mod.rs index 8a66c4f2..c5e8b5fc 100644 --- a/crates/workspace/src/sidebar/mod.rs +++ b/crates/workspace/src/sidebar/mod.rs @@ -1,97 +1,36 @@ use std::collections::HashSet; -use std::ops::Range; -use std::rc::Rc; use anyhow::Error; -use auto_update::AutoUpdater; -use chat::{ChatEvent, ChatRegistry, Room, RoomKind}; -use common::TimestampExt; +use chat::{ChatEvent, ChatRegistry}; +use common::DebouncedDelay; use gpui::prelude::FluentBuilder; use gpui::{ - AnyElement, App, AppContext, ClipboardItem, Context, Div, Entity, EventEmitter, FocusHandle, - Focusable, InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, - Stateful, Styled, StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, - div, img, px, relative, retain_all, uniform_list, + AnyElement, App, AppContext, Context, Entity, EventEmitter, FocusHandle, Focusable, + InteractiveElement, IntoElement, ObjectFit, ParentElement, Render, SharedString, Styled, + StyledImage, Subscription, Task, UniformListScrollHandle, WeakEntity, Window, div, img, + retain_all, }; +use instant::Duration; use nip29::{Group, GroupKey, GroupsEvent, GroupsRegistry}; use nostr_sdk::prelude::*; -use person::PersonRegistry; -use settings::AppSettings; use smallvec::{SmallVec, smallvec}; -use state::{NostrRegistry, StateEvent}; +use state::{FIND_DELAY, NostrRegistry, StateEvent}; use theme::{ActiveTheme, TABBAR_HEIGHT}; -use ui::avatar::Avatar; -use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; -use ui::dialog::DialogButtonProps; -use ui::dock::{ClosePanel, DockArea, DockPlacement, Panel, PanelEvent, PanelHandle}; +use ui::dock::{DockArea, Panel, PanelEvent}; use ui::indicator::Indicator; -use ui::menu::{ContextMenu, DropdownMenu, PopupMenuItem}; -use ui::nav::Nav; -use ui::nav_item::NavItem; +use ui::input::{InputEvent, InputState}; use ui::notification::Notification; -use ui::scroll::Scrollbar; -use ui::{ - Disableable, Icon, IconName, Selectable, Sizable, StyledExt, TRAFFIC_LIGHT_PADDING, - WindowExtension, h_flex, title_bar_drag_handlers, v_flex, -}; +use ui::{Sizable, StyledExt, WindowExtension, h_flex, title_bar_drag_handlers, v_flex}; -use crate::Command; -use crate::dialogs::{import, profile, screening}; +mod group_view; +mod header; +mod inbox; +mod list; +mod onboarding; +mod rows; +mod search; -mod utils; - -pub(crate) use utils::pick_banner; - -/// A collapsible section of the sidebar's main list. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum ListSection { - Pins, - Groups, - Chats, -} - -impl ListSection { - fn label(self) -> &'static str { - match self { - Self::Pins => "Pinned", - Self::Groups => "Groups", - Self::Chats => "Chats", - } - } -} - -pub enum SidebarRow { - Section(ListSection), - Room { room: Entity }, - Group { group: Entity }, - Contact { public_key: PublicKey }, - Empty(&'static str), -} - -/// A collapsible section of the group view. -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -enum GroupSection { - Admins, - Members, -} - -impl GroupSection { - fn label(self) -> &'static str { - match self { - Self::Admins => "Admins", - Self::Members => "Members", - } - } -} - -/// A row in the group view. -enum GroupRow { - Section(GroupSection), - Member { - public_key: PublicKey, - roles: Vec, - }, -} +use search::SearchResult; pub struct Sidebar { focus_handle: FocusHandle, @@ -132,6 +71,25 @@ pub struct Sidebar { new_requests: bool, requests_scroll: UniformListScrollHandle, + /// Whether the search bar is shown below the header. + search_open: bool, + /// Whether searches also cover global (relay) profiles. + global_open: bool, + /// Debounced delay for the global search while typing. + find_debouncer: DebouncedDelay, + /// Search input state. + find_input: Entity, + /// Search results: rooms and groups by name, then messages by content. + results: Vec, + /// Whether a global (relay) search is in progress. + global_finding: bool, + /// Profiles found by the last global search, if one was triggered. + global_results: Option>, + /// The async global profile search. + global_task: Option>, + /// Scroll state of the search results list. + search_scroll: UniformListScrollHandle, + tasks: SmallVec<[Task>; 1]>, _subscriptions: SmallVec<[Subscription; 4]>, } @@ -142,6 +100,12 @@ impl Sidebar { let groups = GroupsRegistry::global(cx); let nostr = NostrRegistry::global(cx); + let find_input = cx.new(|cx| { + InputState::new(window, cx) + .placeholder("Type to search") + .clean_on_escape() + }); + let mut subscriptions = smallvec![]; subscriptions.push( @@ -169,20 +133,53 @@ impl Sidebar { ); subscriptions.push( - cx.subscribe_in(&nostr, window, |this, _, event, _window, cx| { - // Re-pick the background each time the signed-out screen is shown. + cx.subscribe_in(&nostr, window, |this, _, event, window, cx| { if let StateEvent::NoSigner = event { - this.banner = pick_banner(); + this.banner = onboarding::pick_banner(); + if this.search_open { + this.toggle_search(window, cx); + } cx.notify(); } }), ); + subscriptions.push(cx.subscribe_in( + &find_input, + window, + |this, state, event, window, cx| match event { + InputEvent::PressEnter { .. } => { + this.search(cx); + + if this.global_open { + this.global_search(cx); + } + } + InputEvent::Change => { + if state.read(cx).value().is_empty() { + this.clear_results(cx); + } else { + this.search(cx); + + if this.global_open { + this.find_debouncer.fire_new( + Duration::from_millis(FIND_DELAY), + window, + cx, + |this, window, cx| this.debounced_global(window, cx), + ); + } + } + } + _ => {} + }, + )); + Self { focus_handle: cx.focus_handle(), list_scroll: UniformListScrollHandle::new(), dock, - banner: pick_banner(), + banner: onboarding::pick_banner(), group: None, group_open: false, group_scroll: UniformListScrollHandle::new(), @@ -198,18 +195,20 @@ impl Sidebar { requests_open: false, requests_scroll: UniformListScrollHandle::new(), new_requests: false, + search_open: false, + global_open: false, + find_debouncer: DebouncedDelay::new(), + find_input, + results: Vec::new(), + global_finding: false, + global_results: None, + global_task: None, + search_scroll: UniformListScrollHandle::new(), tasks: smallvec![], _subscriptions: subscriptions, } } - /// Show the group view, replacing the main list. - fn show_group(&mut self, group: &Entity, cx: &mut Context) { - self.group = Some(group.downgrade()); - self.group_open = true; - cx.notify(); - } - /// Show the group view without toggling it closed. pub(crate) fn show_group_meta(&mut self, key: &GroupKey, cx: &mut Context) { let groups = GroupsRegistry::global(cx); @@ -233,1069 +232,6 @@ impl Sidebar { self.hide_group(cx); } } - - /// Leave the group view, returning the sidebar to its main list. - fn hide_group(&mut self, cx: &mut Context) { - if !self.group_open { - return; - } - - self.group_open = false; - cx.notify(); - } - - /// Leave the contact picker and clear its selection. - fn close_contacts(&mut self) { - self.contacts_open = false; - self.selected_contacts.clear(); - } - - /// Show or hide the contact picker in the inbox list. - fn toggle_contacts(&mut self, window: &mut Window, cx: &mut Context) { - self.contacts_open = !self.contacts_open; - - if !self.contacts_open { - self.selected_contacts.clear(); - } else { - self.close_requests(); - if self.contacts.is_none() { - self.load_contacts(window, cx); - } - } - - cx.notify(); - } - - /// Leave the requests list in the inbox. - fn close_requests(&mut self) { - self.requests_open = false; - } - - /// Show or hide the chat requests in the inbox list. - fn toggle_requests(&mut self, cx: &mut Context) { - self.requests_open = !self.requests_open; - - if self.requests_open { - self.close_contacts(); - self.new_requests = false; - } - - cx.notify(); - } - - /// Load the signed-in user's contact list into the contact picker. - fn load_contacts(&mut self, window: &mut Window, cx: &mut Context) { - let nostr = NostrRegistry::global(cx); - let client = nostr.read(cx).client(); - - let Some(public_key) = nostr.read(cx).current_user() else { - return; - }; - - let task: Task, Error>> = cx.background_spawn(async move { - let filter = Filter::new() - .author(public_key) - .kind(Kind::ContactList) - .limit(1); - - let contacts: Vec = client - .database() - .query(filter) - .await? - .into_iter() - .next() - .map(|event| event.tags.public_keys().collect()) - .unwrap_or_default(); - - Ok(contacts) - }); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - match task.await { - Ok(contacts) => { - this.update(cx, |this, cx| { - this.contacts = Some(contacts); - cx.notify(); - })?; - } - Err(error) => { - cx.update(|window, cx| { - window.push_notification( - Notification::error(error.to_string()).autohide(false), - cx, - ); - })?; - } - } - - Ok(()) - })); - } - - /// Toggle a contact's selection in the contact picker. - fn toggle_contact(&mut self, public_key: &PublicKey, cx: &mut Context) { - if !self.selected_contacts.remove(public_key) { - self.selected_contacts.insert(public_key.to_owned()); - } - cx.notify(); - } - - /// Start a chat with the contacts selected in the picker. - fn create_chat(&mut self, window: &mut Window, cx: &mut Context) { - if self.selected_contacts.is_empty() { - return; - } - - let nostr = NostrRegistry::global(cx); - let Some(public_key) = nostr.read(cx).current_user() else { - return; - }; - - let chat = ChatRegistry::global(cx); - let async_chat = chat.downgrade(); - let dock = self.dock.clone(); - - let receivers: Vec = self.selected_contacts.iter().copied().collect(); - - self.tasks.push(cx.spawn_in(window, async move |this, cx| { - let room = async_chat.update_in(cx, |chat, _window, cx| { - let room = cx.new(|_| { - Room::new(public_key, receivers) - .organize(&public_key) - .kind(RoomKind::Ongoing) - }); - chat.track_room(&room, cx); - room - })?; - - cx.update(|window, cx| { - ui::dock::add_panel_to( - &dock, - PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), - DockPlacement::Center, - window, - cx, - ); - })?; - - // Leave the picker now that the chat is open - this.update(cx, |this, cx| { - this.close_contacts(); - cx.notify(); - })?; - - Ok(()) - })); - } - - fn render_user(&self, current_user: &PublicKey, cx: &mut Context) -> Stateful
{ - let updater = AutoUpdater::try_global(cx); - let persons = PersonRegistry::global(cx); - let profile = persons.read(cx).get(current_user, cx); - let avatar = profile.avatar(); - let avatar_seed = profile.avatar_seed(); - let name = profile.name(); - - h_flex() - .id("sidebar-user") - .w_full() - .h(TABBAR_HEIGHT) - .flex_shrink_0() - .items_center() - .gap_2() - .px_2() - .when(cfg!(target_os = "macos"), |this| { - this.pl(px(TRAFFIC_LIGHT_PADDING)) - }) - .child( - Button::new("current-user") - .child( - Avatar::new(avatar.clone()) - .seed(avatar_seed.clone()) - .small(), - ) - .small() - .caret() - .compact() - .transparent() - .dropdown_menu(move |this, _window, _cx| { - let avatar = avatar.clone(); - let avatar_seed = avatar_seed.clone(); - let name = name.clone(); - - this.min_w(px(256.)) - .item(PopupMenuItem::element(move |_window, cx| { - h_flex() - .gap_1p5() - .text_xs() - .text_color(cx.theme().text_muted) - .child( - Avatar::new(avatar.clone()) - .seed(avatar_seed.clone()) - .xsmall(), - ) - .child(name.clone()) - })) - .separator() - .menu_with_icon("Inbox", IconName::Inbox, Box::new(Command::ShowInbox)) - .menu_with_icon( - "Search", - IconName::Search, - Box::new(Command::ShowSearch), - ) - .menu_with_icon( - "Profile", - IconName::Profile, - Box::new(Command::ShowProfile), - ) - .menu_with_icon( - "Contact List", - IconName::Book, - Box::new(Command::ShowContactList), - ) - .menu_with_icon( - "Backup", - IconName::UserKey, - Box::new(Command::ShowBackup), - ) - .menu_with_icon("Themes", IconName::Sun, Box::new(Command::ToggleTheme)) - .separator() - .menu_with_icon( - "Settings", - IconName::Settings, - Box::new(Command::ShowSettings), - ) - }), - ) - .when_some(updater, |this, updater| { - this.child(self.render_updater(updater, cx)) - }) - .child( - Button::new("sidebar-new") - .icon(IconName::PlusCircle) - .tooltip("New") - .ghost() - .small() - .dropdown_menu(|menu, _window, _cx| { - menu.menu_with_icon( - "New Chat", - IconName::Message, - Box::new(Command::NewChat), - ) - .menu_with_icon("New Group", IconName::Group, Box::new(Command::NewGroup)) - .separator() - .menu_with_icon( - "Join Group", - IconName::Door, - Box::new(Command::JoinGroup), - ) - }), - ) - .when(self.group_open, |this| { - this.child( - Button::new("sidebar-back") - .icon(IconName::ArrowLeft) - .tooltip("Back") - .ghost() - .small() - .on_click(cx.listener(|this, _event, _window, cx| { - this.hide_group(cx); - })), - ) - }) - } - - fn render_updater(&self, updater: Entity, cx: &mut App) -> AnyElement { - let status = updater.read(cx).status(); - let up_to_date = updater.read(cx).up_to_date(); - let staged = updater.read(cx).staged(); - - h_flex() - .gap_2() - .when(!up_to_date, |this| { - this.child( - Button::new("update-status") - .icon(IconName::ArrowDownCircle) - .tooltip(status) - .small() - .warning() - .disabled(true), - ) - }) - .when(staged, |this| { - this.child( - Button::new("restart-to-update") - .icon(IconName::ArrowDownCircle) - .tooltip("Quit and relaunch into the installed update") - .small() - .ghost() - .on_click(move |_, _window, cx| { - updater.update(cx, |this, cx| { - this.restart(cx); - }); - }), - ) - }) - .into_any_element() - } - - fn render_list(&mut self, cx: &mut Context) -> AnyElement { - let requests_open = self.requests_open; - let contacts_open = self.contacts_open && !requests_open; - let rows = Rc::new(self.rows_for(cx)); - - // Whether to show the create button for the contact picker - let create_button = contacts_open && !self.selected_contacts.is_empty(); - - let (list_id, scroll_handle, list_title) = if contacts_open { - ("sidebar-contacts", &self.contacts_scroll, Some("Contacts")) - } else if requests_open { - ("sidebar-requests", &self.requests_scroll, Some("Requests")) - } else { - ("sidebar-list", &self.list_scroll, None) - }; - - let request_count = ChatRegistry::global(cx) - .read(cx) - .count(&RoomKind::Request, cx); - - v_flex() - .size_full() - .flex_1() - .min_h_0() - .relative() - .gap_2() - .child( - v_flex() - .px_2() - .gap_1() - .child( - NavItem::new("browse", "Browse", Icon::new(IconName::Compass).small()) - .on_click(|_, window, cx| { - window.dispatch_action(Box::new(Command::ShowBrowse), cx) - }), - ) - .child( - NavItem::new("reqs", "Requests", Icon::new(IconName::Invite).small()) - .when(request_count > 0, |this| { - this.suffix( - h_flex() - .gap_1() - .items_center() - .child( - h_flex() - .py_0p5() - .px_1() - .min_w_6() - .justify_center() - .text_size(px(10.)) - .text_color(cx.theme().text_muted) - .text_center() - .rounded(cx.theme().radius) - .bg(cx.theme().elevated_surface_background) - .child(SharedString::from( - request_count.to_string(), - )), - ) - .when(self.new_requests && !requests_open, |this| { - this.child( - div().size_1().rounded_full().bg(cx.theme().cursor), - ) - }), - ) - }) - .when(requests_open, |this| { - this.bg(cx.theme().ghost_element_active) - }) - .on_click(cx.listener(|this, _event, _window, cx| { - this.toggle_requests(cx); - })), - ) - .child( - NavItem::new("contacts", "Contacts", Icon::new(IconName::Book).small()) - .when(contacts_open, |this| { - this.bg(cx.theme().ghost_element_active) - }) - .on_click(cx.listener(|this, _event, window, cx| { - this.toggle_contacts(window, cx); - })), - ), - ) - .when_some(list_title, |this, list_title| { - this.child( - div() - .px_4() - .text_xs() - .font_semibold() - .text_color(cx.theme().text_placeholder) - .child(list_title), - ) - }) - .child( - div() - .min_h_0() - .flex_1() - .child( - uniform_list( - list_id, - rows.len(), - cx.processor(move |this, range, _window, cx| { - this.render_rows(range, rows.as_slice(), cx) - }), - ) - .track_scroll(scroll_handle) - .h_full() - .px_2(), - ) - .child(Scrollbar::vertical(scroll_handle)), - ) - .when(create_button, |this| { - this.child( - v_flex() - .absolute() - .bottom_0() - .left_0() - .justify_center() - .h_20() - .w_full() - .px_4() - .border_t_1() - .border_color(cx.theme().border) - .bg(cx.theme().background) - .child( - Button::new("create-chat") - .label(if self.selected_contacts.len() > 1 { - "Create Group Chat" - } else { - "Create Chat" - }) - .primary() - .on_click(cx.listener(|this, _ev, window, cx| { - this.create_chat(window, cx); - })), - ) - .child( - h_flex() - .gap_1() - .text_xs() - .text_color(cx.theme().text_muted) - .child("Selected") - .child(div().font_semibold().child(SharedString::from( - self.selected_contacts.len().to_string(), - ))), - ), - ) - }) - .into_any_element() - } - - fn rows_for(&self, cx: &App) -> Vec { - if self.contacts_open { - return self - .contacts - .clone() - .unwrap_or_default() - .into_iter() - .map(|public_key| SidebarRow::Contact { public_key }) - .collect(); - } - - if self.requests_open { - let chat = ChatRegistry::global(cx); - return chat - .read(cx) - .rooms(&RoomKind::Request, cx) - .into_iter() - .map(|room| SidebarRow::Room { room }) - .collect(); - } - - let mut rows = Vec::new(); - - let chat = ChatRegistry::global(cx); - let pinned_rooms = AppSettings::get_pinned_rooms(cx); - - let (pinned_rooms, rooms): (Vec<_>, Vec<_>) = chat - .read(cx) - .rooms(&RoomKind::Ongoing, cx) - .into_iter() - .partition(|room| pinned_rooms.contains(&room.read(cx).id)); - - let registry = GroupsRegistry::global(cx); - let pinned_group_ids = AppSettings::get_pinned_groups(cx); - - let (pinned_groups, groups): (Vec<_>, Vec<_>) = registry - .read(cx) - .groups() - .to_vec() - .into_iter() - .partition(|group| { - let tag = group.read(cx).key().cache_tag(); - pinned_group_ids.iter().any(|pinned| pinned == &tag) - }); - - rows.push(SidebarRow::Section(ListSection::Pins)); - if self.pins_open { - if pinned_rooms.is_empty() && pinned_groups.is_empty() { - rows.push(SidebarRow::Empty("Nothing pinned yet")); - } else { - rows.extend( - pinned_groups - .into_iter() - .map(|group| SidebarRow::Group { group }), - ); - rows.extend( - pinned_rooms - .into_iter() - .map(|room| SidebarRow::Room { room }), - ); - } - } - - rows.push(SidebarRow::Section(ListSection::Groups)); - if self.is_groups_open(cx) { - if groups.is_empty() { - rows.push(SidebarRow::Empty("No groups yet")); - } else { - rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group })); - } - } - - rows.push(SidebarRow::Section(ListSection::Chats)); - if self.is_chats_open(cx) { - if rooms.is_empty() { - rows.push(SidebarRow::Empty("No chats yet")); - } else { - rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room })); - } - } - - rows - } - - /// Whether anything is pinned; drives the sections' default expansion. - fn pinned_any(cx: &App) -> bool { - !AppSettings::get_pinned_rooms(cx).is_empty() - || !AppSettings::get_pinned_groups(cx).is_empty() - } - - fn is_chats_open(&self, cx: &App) -> bool { - self.chats_open.unwrap_or_else(|| !Self::pinned_any(cx)) - } - - fn is_groups_open(&self, cx: &App) -> bool { - self.groups_open.unwrap_or_else(|| !Self::pinned_any(cx)) - } - - fn render_rows( - &self, - range: Range, - rows: &[SidebarRow], - cx: &Context, - ) -> Vec { - let hide_avatar = AppSettings::get_hide_avatar(cx); - - rows.get(range.clone()) - .into_iter() - .flatten() - .enumerate() - .map(|(offset, row)| { - let index = range.start + offset; - - match row { - SidebarRow::Section(section) => { - let section = *section; - let open = match section { - ListSection::Pins => self.pins_open, - ListSection::Groups => self.is_groups_open(cx), - ListSection::Chats => self.is_chats_open(cx), - }; - let icon = if open { - IconName::CaretDown - } else { - IconName::CaretRight - }; - let label = section.label(); - - Nav::new(label) - .label(label) - .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted)) - .text_xs() - .font_semibold() - .text_color(cx.theme().text_placeholder) - .on_click(cx.listener(move |this, _event, _window, cx| { - match section { - ListSection::Pins => this.pins_open = !this.pins_open, - ListSection::Groups => { - this.groups_open = Some(!this.is_groups_open(cx)) - } - ListSection::Chats => { - this.chats_open = Some(!this.is_chats_open(cx)) - } - } - cx.notify(); - })) - .into_any_element() - } - SidebarRow::Empty(text) => h_flex() - .h_10() - .w_full() - .px_1p5() - .py_1() - .justify_center() - .border_1() - .border_dashed() - .border_color(cx.theme().border) - .rounded(cx.theme().radius) - .justify_center() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(*text) - .into_any_element(), - SidebarRow::Room { room } => { - let name = room.read(cx).display_name(cx); - let picture = room.read(cx).display_image(cx); - let seed = room.read(cx).display_image_seed(cx); - let created_at = room.read(cx).created_at.to_ago(); - let kind = room.read(cx).kind; - let peer = room.read(cx).display_member(cx).public_key(); - let id = room.read(cx).id; - let member = room.read(cx).members().first().copied(); - let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx); - let dock = self.dock.clone(); - let room = room.clone(); - - let nav = Nav::new(index) - .label(name) - .text_sm() - .font_medium() - .when(!hide_avatar, |this| { - this.prefix( - Avatar::from_source(picture) - .seed(seed) - .small() - .flex_shrink_0(), - ) - }) - .suffix( - div() - .font_normal() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(created_at), - ) - .on_click(move |_event, window, cx| { - ui::dock::add_panel_to( - &dock, - PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), - DockPlacement::Center, - window, - cx, - ); - if kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { - let screening = screening::init(peer, window, cx); - - window.open_dialog(cx, move |this, _window, _cx| { - this.confirm() - .child(screening.clone()) - .button_props( - DialogButtonProps::default() - .cancel_text("Ignore") - .ok_text("Response"), - ) - .on_cancel(move |_event, window, cx| { - window.dispatch_action(Box::new(ClosePanel), cx); - true - }) - }); - } - }); - - ContextMenu::new( - format!("room-menu-{index}"), - nav, - move |menu, _window, _cx| { - let menu = menu.item( - PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }) - .on_click(move |_, _window, cx| { - AppSettings::global(cx).update(cx, |settings, cx| { - settings.toggle_pinned_room(id, cx); - }); - }), - ); - - if let Some(member) = member { - menu.item(PopupMenuItem::new("View profile").on_click( - move |_, window, cx| { - profile::open(member, window, cx); - }, - )) - } else { - menu - } - }, - ) - .into_any_element() - } - SidebarRow::Contact { public_key } => { - let persons = PersonRegistry::global(cx); - let person = persons.read(cx).get(public_key, cx); - let selected = self.selected_contacts.contains(public_key); - let sidebar = cx.entity().downgrade(); - let public_key = public_key.to_owned(); - - Nav::new(index) - .label(person.name()) - .text_sm() - .font_medium() - .when(!hide_avatar, |this| { - this.prefix( - Avatar::from_source(person.avatar()) - .seed(person.avatar_seed()) - .small() - .flex_shrink_0(), - ) - }) - .selected(selected) - .on_click(move |_event, _window, cx| { - if let Err(error) = sidebar.update(cx, |this, cx| { - this.toggle_contact(&public_key, cx); - }) { - log::error!("Failed to select contact: {error}"); - } - }) - .into_any_element() - } - SidebarRow::Group { group } => { - let dock = self.dock.clone(); - let sidebar = cx.entity().downgrade(); - let group = group.clone(); - - let name = group.read(cx).display_name(); - let picture = group.read(cx).display_image(); - let seed = group.read(cx).key().cache_tag(); - let tag = seed.clone(); - let pinned = AppSettings::global(cx).read(cx).pinned_group(&tag, cx); - let weak_group = group.downgrade(); - - let suffix = group - .read(cx) - .membership() - .status() - .or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) - .map(SharedString::from); - - let nav = Nav::new(index) - .label(name) - .text_sm() - .font_medium() - .when(!hide_avatar, |this| { - this.prefix( - Avatar::from_source(picture) - .seed(seed) - .small() - .flex_shrink_0(), - ) - }) - .when_some(suffix, |this, suffix| { - this.suffix( - div() - .font_normal() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(suffix), - ) - }) - .on_click(move |_event, window, cx| { - ui::dock::add_panel_to( - &dock, - PanelHandle::new(nip29_ui::init(group.clone(), window, cx)), - DockPlacement::Center, - window, - cx, - ); - - if let Err(error) = sidebar.update(cx, |this, cx| { - this.show_group(&group, cx); - }) { - log::error!("Failed to show the group in the sidebar: {error}"); - } - }); - - ContextMenu::new( - format!("group-menu-{index}"), - nav, - move |menu, _window, _cx| { - menu.item( - PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }) - .on_click({ - let tag = tag.clone(); - move |_, _window, cx| { - AppSettings::global(cx).update( - cx, - |settings, cx| { - settings.toggle_pinned_group(&tag, cx); - }, - ); - } - }), - ) - .item( - PopupMenuItem::new("Leave").on_click({ - let group = weak_group.clone(); - move |_, window, cx| { - nip29_ui::confirm_leave(group.clone(), window, cx); - } - }), - ) - }, - ) - .into_any_element() - } - } - }) - .collect() - } - - fn render_group(&mut self, group: Entity, cx: &mut Context) -> AnyElement { - let admin = NostrRegistry::global(cx) - .read(cx) - .current_user() - .is_some_and(|me| group.read(cx).admins().contains(&me)); - - let (name, seed, picture, total_members, rows) = { - let group = group.read(cx); - let admins = group.admins(); - - let mut admin_rows = Vec::new(); - let mut member_rows = Vec::new(); - - for (public_key, roles) in admins.entries() { - admin_rows.push(GroupRow::Member { - public_key: *public_key, - roles: roles.clone(), - }); - } - - for public_key in group.members().iter() { - if admins.contains(public_key) { - continue; - } - - member_rows.push(GroupRow::Member { - public_key: *public_key, - roles: Vec::new(), - }); - } - - let mut rows = Vec::new(); - - if !admin_rows.is_empty() { - rows.push(GroupRow::Section(GroupSection::Admins)); - if self.admins_open { - rows.append(&mut admin_rows); - } - } - - if !member_rows.is_empty() { - rows.push(GroupRow::Section(GroupSection::Members)); - if self.members_open { - rows.append(&mut member_rows); - } - } - - ( - group.display_name(), - group.key().cache_tag(), - group.display_image(), - SharedString::from(format!("{} members", group.total_members())), - rows, - ) - }; - - let rows = Rc::new(rows); - let member_group = group.downgrade(); - - v_flex() - .flex_1() - .min_h_0() - .w_full() - .gap_2() - .child( - h_flex() - .px_2() - .flex_shrink_0() - .justify_between() - .child( - h_flex() - .pl_0p5() - .gap_1() - .flex_shrink_1() - .child(Avatar::from_source(picture).seed(seed).small()) - .child( - v_flex() - .flex_1() - .min_w_0() - .child( - div() - .text_xs() - .truncate() - .line_height(relative(1.2)) - .font_semibold() - .child(name), - ) - .child( - div() - .text_size(px(10.)) - .text_color(cx.theme().text_muted) - .child(total_members), - ), - ), - ) - .child( - h_flex() - .gap_1() - .child(nip29_ui::actions(group.downgrade(), cx)), - ), - ) - .child( - div() - .min_h_0() - .flex_1() - .child( - uniform_list( - "group-rows", - rows.len(), - cx.processor(move |this, range, _window, cx| { - this.render_group_rows( - range, - rows.as_slice(), - &member_group, - admin, - cx, - ) - }), - ) - .track_scroll(&self.group_scroll) - .h_full() - .px_2(), - ) - .child(Scrollbar::vertical(&self.group_scroll)), - ) - .into_any_element() - } - - fn render_group_rows( - &self, - range: Range, - rows: &[GroupRow], - group: &WeakEntity, - admin: bool, - cx: &mut Context, - ) -> Vec { - rows.get(range) - .into_iter() - .flatten() - .map(|row| match row { - GroupRow::Section(section) => self.section_row(*section, cx), - GroupRow::Member { public_key, roles } => { - self.member_row(public_key, roles, group, admin, cx) - } - }) - .collect() - } - - fn section_row(&self, section: GroupSection, cx: &mut Context) -> AnyElement { - let open = match section { - GroupSection::Admins => self.admins_open, - GroupSection::Members => self.members_open, - }; - let icon = if open { - IconName::CaretDown - } else { - IconName::CaretRight - }; - let label = section.label(); - - Nav::new(label) - .label(label) - .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted)) - .text_xs() - .font_semibold() - .text_color(cx.theme().text_placeholder) - .on_click(cx.listener(move |this, _event, _window, cx| { - match section { - GroupSection::Admins => this.admins_open = !this.admins_open, - GroupSection::Members => this.members_open = !this.members_open, - } - cx.notify(); - })) - .into_any_element() - } - - fn member_row( - &self, - public_key: &PublicKey, - roles: &[String], - group: &WeakEntity, - admin: bool, - cx: &mut Context, - ) -> AnyElement { - let hide_avatar = AppSettings::get_hide_avatar(cx); - let persons = PersonRegistry::global(cx); - let person = persons.read(cx).get(public_key, cx); - let public_key = *public_key; - - let group = group.clone(); - - Nav::new(public_key.to_hex()) - .label(person.name()) - .text_sm() - .font_medium() - .clickable(true) - .when(!hide_avatar, |this| { - this.prefix( - Avatar::new(person.avatar()) - .seed(person.avatar_seed()) - .small() - .flex_shrink_0(), - ) - }) - .when(!roles.is_empty(), |this| { - this.suffix( - div() - .truncate() - .text_xs() - .text_color(cx.theme().text_placeholder) - .child(SharedString::from(roles.join(", "))), - ) - }) - .dropdown_menu(move |menu, _window, _cx| { - let menu = menu - .item( - PopupMenuItem::new("View profile").on_click(move |_, window, cx| { - profile::open(public_key, window, cx); - }), - ) - .item(PopupMenuItem::new("Copy").on_click(move |_, _, cx| { - let npub = public_key.to_bech32().unwrap_or_default(); - cx.write_to_clipboard(ClipboardItem::new_string(npub)); - })); - - if !admin { - return menu; - } - - menu.separator() - .item(PopupMenuItem::new("Remove").on_click({ - let group = group.clone(); - move |_event, window, cx| { - nip29_ui::remove_member(group.clone(), public_key, window, cx); - } - })) - }) - .into_any_element() - } } impl Panel for Sidebar { @@ -1334,6 +270,7 @@ impl Render for Sidebar { let loading = chat.read(cx).loading(); let group = self.group.clone().and_then(|group| group.upgrade()); + let searching = self.search_open && !self.find_input.read(cx).value().is_empty(); v_flex() .image_cache(retain_all("sidebar")) @@ -1350,6 +287,7 @@ impl Render for Sidebar { cx, )) }) + .when(self.search_open, |this| this.child(self.render_find(cx))) .when(!logged_in, |this| { this.relative() .child(title_bar_drag_handlers( @@ -1370,33 +308,13 @@ impl Render for Sidebar { .object_fit(ObjectFit::Cover), ), ) - .child( - v_flex() - .size_full() - .justify_end() - .gap_4() - .p_4() - .child(img("brand/headline.png").max_w_48()) - .child( - Button::new("import") - .label("Import Identity") - .custom( - ButtonCustomVariant::new(window, cx) - .color(gpui::white()) - .foreground(gpui::black()) - .hover(gpui::white().opacity(0.9)) - .active(gpui::white().opacity(0.8)), - ) - .large() - .font_semibold() - .on_click(|_, window, cx| { - import::open(window, cx); - }), - ), - ) + .child(onboarding::render_signed_out(window, cx)) }) .map(|this| match group { - Some(group) if self.group_open => this.child(self.render_group(group, cx)), + Some(group) if self.group_open && !searching => { + this.child(self.render_group(group, cx)) + } + _ if searching => this.child(self.render_results(cx)), _ => this.child(self.render_list(cx)), }) .when(loading && logged_in, |this| { diff --git a/crates/workspace/src/sidebar/onboarding.rs b/crates/workspace/src/sidebar/onboarding.rs new file mode 100644 index 00000000..2a168c5f --- /dev/null +++ b/crates/workspace/src/sidebar/onboarding.rs @@ -0,0 +1,47 @@ +use std::time::{SystemTime, UNIX_EPOCH}; + +use gpui::{AnyElement, App, IntoElement, ParentElement, SharedString, Styled, Window, img}; +use ui::button::{Button, ButtonCustomVariant, ButtonVariants}; +use ui::{Sizable, StyledExt, v_flex}; + +use crate::dialogs::import; + +/// Brand backgrounds shown behind the signed-out screen; one is picked at random. +const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"]; + +/// Pick one of the signed-out backgrounds at random. +pub(super) fn pick_banner() -> SharedString { + let nanos = SystemTime::now() + .duration_since(UNIX_EPOCH) + .map(|elapsed| elapsed.subsec_nanos()) + .unwrap_or_default(); + let index = nanos as usize % SIGNED_OUT_BANNERS.len(); + SIGNED_OUT_BANNERS[index].into() +} + +/// The headline and identity import shown while signed out. +pub(super) fn render_signed_out(window: &mut Window, cx: &mut App) -> AnyElement { + v_flex() + .size_full() + .justify_end() + .gap_4() + .p_4() + .child(img("brand/headline.png").max_w_48()) + .child( + Button::new("import") + .label("Import Identity") + .custom( + ButtonCustomVariant::new(window, cx) + .color(gpui::white()) + .foreground(gpui::black()) + .hover(gpui::white().opacity(0.9)) + .active(gpui::white().opacity(0.8)), + ) + .large() + .font_semibold() + .on_click(|_, window, cx| { + import::open(window, cx); + }), + ) + .into_any_element() +} diff --git a/crates/workspace/src/sidebar/rows.rs b/crates/workspace/src/sidebar/rows.rs new file mode 100644 index 00000000..7fc64d65 --- /dev/null +++ b/crates/workspace/src/sidebar/rows.rs @@ -0,0 +1,392 @@ +use std::ops::Range; + +use chat::{Room, RoomKind}; +use common::TimestampExt; +use gpui::prelude::FluentBuilder; +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, WeakEntity, + Window, div, +}; +use nip29::Group; +use nostr_sdk::prelude::*; +use person::PersonRegistry; +use settings::AppSettings; +use theme::ActiveTheme; +use ui::avatar::Avatar; +use ui::dialog::DialogButtonProps; +use ui::dock::{ClosePanel, DockArea, DockPlacement, PanelHandle}; +use ui::menu::{ContextMenu, PopupMenuItem}; +use ui::nav::Nav; +use ui::{Icon, IconName, Selectable, Sizable, StyledExt, WindowExtension, h_flex, v_flex}; + +use super::Sidebar; +use super::list::ListSection; +use super::search::MessageHit; +use crate::dialogs::{profile, screening}; + +/// Open a room in the dock, screening new requests when enabled. +fn open_room(room: &Entity, dock: &WeakEntity, window: &mut Window, cx: &mut App) { + ui::dock::add_panel_to( + dock, + PanelHandle::new(chat_ui::init(room.downgrade(), window, cx)), + DockPlacement::Center, + window, + cx, + ); + + if room.read(cx).kind != RoomKind::Ongoing && AppSettings::get_screening(cx) { + let peer = room.read(cx).display_member(cx).public_key(); + let view = screening::init(peer, window, cx); + + window.open_dialog(cx, move |this, _window, _cx| { + this.confirm() + .child(view.clone()) + .button_props( + DialogButtonProps::default() + .cancel_text("Ignore") + .ok_text("Response"), + ) + .on_cancel(move |_event, window, cx| { + window.dispatch_action(Box::new(ClosePanel), cx); + true + }) + }); + } +} + +impl Sidebar { + pub(super) fn render_rows( + &self, + range: Range, + rows: &[SidebarRow], + cx: &Context, + ) -> Vec { + let hide_avatar = AppSettings::get_hide_avatar(cx); + + rows.get(range.clone()) + .into_iter() + .flatten() + .enumerate() + .map(|(offset, row)| { + let index = range.start + offset; + + match row { + SidebarRow::Section(section) => { + let section = *section; + let label = section.label(); + + let open = match section { + ListSection::Pins => self.pins_open, + ListSection::Groups => self.is_groups_open(cx), + ListSection::Chats => self.is_chats_open(cx), + }; + + let icon = if open { + IconName::CaretDown + } else { + IconName::CaretRight + }; + + Nav::new(label) + .label(label) + .suffix(Icon::new(icon).small().text_color(cx.theme().icon_muted)) + .text_xs() + .font_semibold() + .text_color(cx.theme().text_placeholder) + .on_click(cx.listener(move |this, _event, _window, cx| { + match section { + ListSection::Pins => this.pins_open = !this.pins_open, + ListSection::Groups => { + this.groups_open = Some(!this.is_groups_open(cx)) + } + ListSection::Chats => { + this.chats_open = Some(!this.is_chats_open(cx)) + } + } + cx.notify(); + })) + .into_any_element() + } + SidebarRow::Heading(label) => { + let label = *label; + + Nav::new(label) + .label(label) + .text_xs() + .font_semibold() + .text_color(cx.theme().text_placeholder) + .into_any_element() + } + SidebarRow::Empty(text) => v_flex() + .h_10() + .w_full() + .px_1p5() + .items_center() + .child( + h_flex() + .h_9() + .w_full() + .justify_center() + .border_1() + .border_dashed() + .border_color(cx.theme().border) + .rounded(cx.theme().radius) + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(*text), + ) + .into_any_element(), + SidebarRow::Room { room } => { + let name = room.read(cx).display_name(cx); + let picture = room.read(cx).display_image(cx); + let seed = room.read(cx).display_image_seed(cx); + let created_at = room.read(cx).created_at.to_ago(); + let id = room.read(cx).id; + let member = room.read(cx).members().first().copied(); + let pinned = AppSettings::global(cx).read(cx).pinned_room(id, cx); + let dock = self.dock.clone(); + let room = room.clone(); + + let nav = Nav::new(index) + .label(name) + .text_sm() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) + }) + .suffix( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(created_at), + ) + .on_click(move |_event, window, cx| { + open_room(&room, &dock, window, cx); + }); + + ContextMenu::new( + format!("room-menu-{index}"), + nav, + move |menu, _window, _cx| { + let menu = menu.item( + PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }) + .on_click(move |_, _window, cx| { + AppSettings::global(cx).update(cx, |settings, cx| { + settings.toggle_pinned_room(id, cx); + }); + }), + ); + + if let Some(member) = member { + menu.item(PopupMenuItem::new("View profile").on_click( + move |_, window, cx| { + profile::open(member, window, cx); + }, + )) + } else { + menu + } + }, + ) + .into_any_element() + } + SidebarRow::Contact { public_key } => { + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(public_key, cx); + let selected = self.selected_contacts.contains(public_key); + let sidebar = cx.entity().downgrade(); + let public_key = public_key.to_owned(); + + Nav::new(index) + .label(person.name()) + .text_sm() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .selected(selected) + .on_click(move |_event, _window, cx| { + if let Err(error) = sidebar.update(cx, |this, cx| { + this.toggle_contact(&public_key, cx); + }) { + log::error!("Failed to select contact: {error}"); + } + }) + .into_any_element() + } + SidebarRow::Profile { public_key } => { + let persons = PersonRegistry::global(cx); + let person = persons.read(cx).get(public_key, cx); + let public_key = public_key.to_owned(); + + Nav::new(index) + .label(person.name()) + .text_sm() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .on_click(move |_event, window, cx| { + profile::open(public_key, window, cx); + }) + .into_any_element() + } + SidebarRow::Group { group } => { + let dock = self.dock.clone(); + let sidebar = cx.entity().downgrade(); + let group = group.clone(); + + let name = group.read(cx).display_name(); + let picture = group.read(cx).display_image(); + let seed = group.read(cx).key().cache_tag(); + let tag = seed.clone(); + let pinned = AppSettings::global(cx).read(cx).pinned_group(&tag, cx); + let weak_group = group.downgrade(); + + let suffix = group + .read(cx) + .membership() + .status() + .or_else(|| group.read(cx).elsewhere().map(|_| "Moved")) + .map(SharedString::from); + + let nav = Nav::new(index) + .label(name) + .text_sm() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(picture) + .seed(seed) + .small() + .flex_shrink_0(), + ) + }) + .when_some(suffix, |this, suffix| { + this.suffix( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(suffix), + ) + }) + .on_click(move |_event, window, cx| { + ui::dock::add_panel_to( + &dock, + PanelHandle::new(nip29_ui::init(group.clone(), window, cx)), + DockPlacement::Center, + window, + cx, + ); + + if let Err(error) = sidebar.update(cx, |this, cx| { + this.show_group(&group, cx); + }) { + log::error!("Failed to show the group in the sidebar: {error}"); + } + }); + + ContextMenu::new( + format!("group-menu-{index}"), + nav, + move |menu, _window, _cx| { + menu.item( + PopupMenuItem::new(if pinned { "Unpin" } else { "Pin" }) + .on_click({ + let tag = tag.clone(); + move |_, _window, cx| { + AppSettings::global(cx).update( + cx, + |settings, cx| { + settings.toggle_pinned_group(&tag, cx); + }, + ); + } + }), + ) + .item( + PopupMenuItem::new("Leave").on_click({ + let group = weak_group.clone(); + move |_, window, cx| { + nip29_ui::confirm_leave(group.clone(), window, cx); + } + }), + ) + }, + ) + .into_any_element() + } + SidebarRow::Message { hit } => { + let person = PersonRegistry::global(cx).read(cx).get(&hit.author, cx); + let name = hit.room.read(cx).display_name(cx); + let detail = format!("{} ยท {}", name, hit.created_at.to_ago()); + let dock = self.dock.clone(); + let room = hit.room.clone(); + let content = hit.content.clone(); + + Nav::new(index) + .label(content) + .text_sm() + .font_medium() + .when(!hide_avatar, |this| { + this.prefix( + Avatar::from_source(person.avatar()) + .seed(person.avatar_seed()) + .small() + .flex_shrink_0(), + ) + }) + .suffix( + div() + .font_normal() + .text_xs() + .text_color(cx.theme().text_placeholder) + .child(detail), + ) + .on_click(move |_event, window, cx| { + open_room(&room, &dock, window, cx); + }) + .into_any_element() + } + } + }) + .collect() + } +} + +/// A row shown in the sidebar's lists. +pub(super) enum SidebarRow { + Section(ListSection), + Room { + room: Entity, + }, + Group { + group: Entity, + }, + Contact { + public_key: PublicKey, + }, + /// A profile found by global search. + Profile { + public_key: PublicKey, + }, + Message { + hit: MessageHit, + }, + /// A non-interactive label, such as a search results section. + Heading(&'static str), + Empty(&'static str), +} diff --git a/crates/workspace/src/sidebar/search.rs b/crates/workspace/src/sidebar/search.rs new file mode 100644 index 00000000..5724ce1d --- /dev/null +++ b/crates/workspace/src/sidebar/search.rs @@ -0,0 +1,315 @@ +use std::collections::HashSet; +use std::rc::Rc; + +use chat::{ChatRegistry, Room}; +use gpui::{ + AnyElement, App, Context, Entity, IntoElement, ParentElement, SharedString, Styled, Task, + Window, div, uniform_list, +}; +use nip29::{Group, GroupsRegistry}; +use nostr_sdk::prelude::*; +use state::{FIND_LIMIT, NostrRegistry}; +use theme::ActiveTheme; +use ui::button::{Button, ButtonVariants}; +use ui::input::Input; +use ui::scroll::Scrollbar; +use ui::{IconName, Selectable, Sizable, h_flex, v_flex}; + +use super::Sidebar; +use super::rows::SidebarRow; + +/// An entry in the sidebar's search results. +pub(super) enum SearchResult { + Room(Entity), + Group(Entity), + Message(MessageHit), +} + +/// A message found by searching the indexed chat history. +#[derive(Clone)] +pub(super) struct MessageHit { + pub(super) room: Entity, + pub(super) author: PublicKey, + pub(super) content: SharedString, + pub(super) created_at: Timestamp, +} + +/// Find groups whose display name contains all of the terms. +fn find_groups(terms: &[String], cx: &App) -> Vec> { + GroupsRegistry::global(cx) + .read(cx) + .groups() + .iter() + .filter(|group| { + let name = group.read(cx).display_name().to_lowercase(); + terms.iter().all(|term| name.contains(term.as_str())) + }) + .cloned() + .collect() +} + +impl Sidebar { + /// Show or hide the search bar below the header. + pub(super) fn toggle_search(&mut self, window: &mut Window, cx: &mut Context) { + self.search_open = !self.search_open; + + if self.search_open { + self.find_input + .update(cx, |input, cx| input.focus(window, cx)); + } else { + self.find_input.update(cx, |input, cx| { + input.set_value("", window, cx); + }); + self.global_open = false; + self.clear_results(cx); + } + + cx.notify(); + } + + /// Search rooms and groups by name, then indexed messages by content. + pub(super) fn search(&mut self, cx: &mut Context) { + let query = self.find_input.read(cx).value().to_string(); + let terms: Vec = query + .to_lowercase() + .split_whitespace() + .map(str::to_owned) + .collect(); + + if terms.is_empty() { + return; + } + + let chat = ChatRegistry::global(cx); + let rooms = chat.read(cx).find(&query, cx); + let groups = find_groups(&terms, cx); + + let messages = chat + .read(cx) + .search_messages(&terms, FIND_LIMIT) + .into_iter() + .filter_map(|matched| { + let room = chat.read(cx).room(&matched.room, cx)?.upgrade()?; + + Some(MessageHit { + room, + author: matched.author, + content: matched.content.into(), + created_at: matched.created_at, + }) + }) + .collect::>(); + + self.results = rooms + .into_iter() + .map(SearchResult::Room) + .chain(groups.into_iter().map(SearchResult::Group)) + .chain(messages.into_iter().map(SearchResult::Message)) + .collect(); + cx.notify(); + } + + /// Search profiles on the indexer relays for the current query. + pub(super) fn global_search(&mut self, cx: &mut Context) { + if !self.global_open { + return; + } + + let query = self.find_input.read(cx).value().trim().to_string(); + + if query.is_empty() { + return; + } + + let nostr = NostrRegistry::global(cx); + let find_profiles = nostr.read(cx).search(&query, cx); + + self.global_finding = true; + self.global_results = None; + cx.notify(); + + self.global_task = Some(cx.spawn(async move |this, cx| { + let result = find_profiles.await; + + this.update(cx, |this, cx| { + this.global_finding = false; + this.global_results = Some(match result { + Ok(profiles) => { + // Relays may return the same profile more than once + let mut seen = HashSet::new(); + profiles + .into_iter() + .filter(|key| seen.insert(*key)) + .collect() + } + Err(error) => { + log::debug!("Global search failed: {error}"); + Vec::new() + } + }); + cx.notify(); + }) + .ok(); + })); + } + + /// Trigger a global search once the input debounce delay has passed. + pub(super) fn debounced_global( + &mut self, + window: &mut Window, + cx: &mut Context, + ) -> Task<()> { + cx.spawn_in(window, async move |this, cx| { + this.update_in(cx, |this, _window, cx| { + this.global_search(cx); + }) + .ok(); + }) + } + + /// Toggle whether searches also cover global (relay) profiles. + pub(super) fn toggle_global(&mut self, cx: &mut Context) { + self.global_open = !self.global_open; + + if self.global_open { + self.global_search(cx); + } else { + self.global_finding = false; + self.global_results = None; + self.global_task = None; + } + + cx.notify(); + } + + /// Discard the search results. + pub(super) fn clear_results(&mut self, cx: &mut Context) { + self.results.clear(); + self.global_finding = false; + self.global_results = None; + self.global_task = None; + cx.notify(); + } + + pub(super) fn render_find(&self, cx: &mut Context) -> AnyElement { + h_flex() + .px_2() + .flex_shrink_0() + .w_full() + .child( + h_flex() + .px_1p5() + .gap_1() + .w_full() + .rounded(cx.theme().radius) + .bg(cx.theme().elevated_surface_background) + .child( + Input::new(&self.find_input) + .appearance(false) + .small() + .text_xs(), + ) + .child( + Button::new("sidebar-global") + .icon(IconName::Compass) + .tooltip(if self.global_open { + "Disable global search" + } else { + "Enable global search" + }) + .small() + .ghost() + .selected(self.global_open) + .loading(self.global_finding) + .on_click(cx.listener(|this, _event, _window, cx| { + this.toggle_global(cx); + })), + ), + ) + .into_any_element() + } + + pub(super) fn render_results(&self, cx: &mut Context) -> AnyElement { + let rows = Rc::new(self.results_for()); + + v_flex() + .flex_1() + .min_h_0() + .relative() + .child( + div() + .min_h_0() + .flex_1() + .child( + uniform_list( + "sidebar-search", + rows.len(), + cx.processor(move |this, range, _window, cx| { + this.render_rows(range, rows.as_slice(), cx) + }), + ) + .track_scroll(&self.search_scroll) + .h_full() + .px_2(), + ) + .child(Scrollbar::vertical(&self.search_scroll)), + ) + .into_any_element() + } + + fn results_for(&self) -> Vec { + let mut rooms = Vec::new(); + let mut groups = Vec::new(); + let mut messages = Vec::new(); + + for result in &self.results { + match result { + SearchResult::Room(room) => rooms.push(room.clone()), + SearchResult::Group(group) => groups.push(group.clone()), + SearchResult::Message(hit) => messages.push(hit.clone()), + } + } + + let mut rows = Vec::new(); + + rows.push(SidebarRow::Heading("Groups")); + if groups.is_empty() { + rows.push(SidebarRow::Empty("No groups found")); + } else { + rows.extend(groups.into_iter().map(|group| SidebarRow::Group { group })); + } + + rows.push(SidebarRow::Heading("Rooms")); + if rooms.is_empty() { + rows.push(SidebarRow::Empty("No rooms found")); + } else { + rows.extend(rooms.into_iter().map(|room| SidebarRow::Room { room })); + } + + rows.push(SidebarRow::Heading("Messages")); + if messages.is_empty() { + rows.push(SidebarRow::Empty("No messages found")); + } else { + rows.extend(messages.into_iter().map(|hit| SidebarRow::Message { hit })); + } + + // Global profiles only appear once a global search was triggered + if self.global_finding || self.global_results.is_some() { + rows.push(SidebarRow::Heading("Global")); + + match self.global_results.as_ref() { + Some(profiles) if profiles.is_empty() => { + rows.push(SidebarRow::Empty("No profiles found")); + } + Some(profiles) => { + rows.extend(profiles.iter().map(|public_key| SidebarRow::Profile { + public_key: *public_key, + })); + } + None => rows.push(SidebarRow::Empty("Searching...")), + } + } + + rows + } +} diff --git a/crates/workspace/src/sidebar/utils.rs b/crates/workspace/src/sidebar/utils.rs deleted file mode 100644 index 9781ba3f..00000000 --- a/crates/workspace/src/sidebar/utils.rs +++ /dev/null @@ -1,16 +0,0 @@ -use std::time::{SystemTime, UNIX_EPOCH}; - -use gpui::SharedString; - -/// Brand backgrounds shown behind the signed-out screen; one is picked at random. -const SIGNED_OUT_BANNERS: [&str; 2] = ["brand/bg1.jpg", "brand/bg2.jpg"]; - -/// Pick one of the signed-out backgrounds at random. -pub(crate) fn pick_banner() -> SharedString { - let nanos = SystemTime::now() - .duration_since(UNIX_EPOCH) - .map(|elapsed| elapsed.subsec_nanos()) - .unwrap_or_default(); - let index = nanos as usize % SIGNED_OUT_BANNERS.len(); - SIGNED_OUT_BANNERS[index].into() -} diff --git a/docs/TODO.md b/docs/TODO.md new file mode 100644 index 00000000..435b3697 --- /dev/null +++ b/docs/TODO.md @@ -0,0 +1,19 @@ +# TODO + +Open items from the search and lazy-loading work. Each entry notes the known +tradeoff and where the fix would land. + +## nip29 + +- [ ] **Incremental event handling for open groups.** Live events for an open + (active) group still trigger a full envelope reload and re-hydrate of the + whole group on every event (`GroupsRegistry::reload` in + `crates/nip29/src/lib.rs`). Closed groups already absorb events in O(1); + open groups could use the same incremental path, keeping the full reload + only as an occasional self-healing rebuild. +- [ ] **Fetch admins' group lists from NIP-29 relays.** The fork-detection + query (`GroupsRegistry::sync_forks`) is unscoped, so group relays refuse it + by policy (`must have 'h', 'e' or 'a' tag`). It still works via generic and + gossip relays, but the deeper fix is a per-relay scoped subscription using + an `a` tag with `10009:` coordinates. The exact coordinate format + each relay accepts needs to be tested first.