diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index a6c9ec2..30a7117 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -1,5 +1,5 @@ use std::cmp::Reverse; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet, hash_map}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, LazyLock, RwLock}; @@ -80,6 +80,9 @@ pub struct ChatRegistry { /// Chat rooms rooms: Vec>, + /// O(1) room lookup by room ID + room_index: HashMap>, + /// Events that failed to unwrap for any reason trash: Entity>, @@ -170,6 +173,7 @@ impl ChatRegistry { Self { rooms: vec![], + room_index: HashMap::new(), trash: cx.new(|_| BTreeSet::default()), seen: Arc::new(RwLock::new(HashMap::default())), event_map: Arc::new(RwLock::new(HashMap::default())), @@ -436,12 +440,9 @@ impl ChatRegistry { self.tracking.load(Ordering::Acquire) } - /// Get a weak reference to a room by its ID. - pub fn room(&self, id: &u64, cx: &App) -> Option> { - self.rooms - .iter() - .find(|this| &this.read(cx).id == id) - .map(|this| this.downgrade()) + /// Get a weak reference to a room by its ID + pub fn room(&self, id: &u64, _cx: &App) -> Option> { + self.room_index.get(id).map(|room| room.downgrade()) } /// Get all rooms based on the filter. @@ -511,7 +512,11 @@ impl ChatRegistry { }; let room: Room = room.into().organize(&public_key); - self.rooms.insert(0, cx.new(|_| room)); + let room_id = room.id; + let entity = cx.new(|_| room); + + self.room_index.insert(room_id, entity.clone()); + self.rooms.insert(0, entity); cx.emit(ChatEvent::Ping); cx.notify(); @@ -524,9 +529,11 @@ impl ChatRegistry { // Get the room's ID. let id = room.read(cx).id; - // If the room is new, add it to the registry. - if !self.rooms.iter().any(|r| r.read(cx).id == id) { - self.rooms.insert(0, room.to_owned()); + // If the room is new, add it to the registry and index. + if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) { + let entity = room.to_owned(); + e.insert(entity.clone()); + self.rooms.insert(0, entity); } // Emit the open room event deferred to avoid re-entrant reads @@ -537,17 +544,23 @@ impl ChatRegistry { /// Close a room. pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context) { - if self.rooms.iter().any(|r| r.read(cx).id == id) { + if self.room_index.contains_key(&id) { + self.room_index.remove(&id); + self.rooms.retain(|r| r.read(cx).id != id); cx.defer_in(window, move |_this, _window, cx| { cx.emit(ChatEvent::CloseRoom(id)); }); } } - /// Sort rooms by their created at. + /// Sort rooms by their created at. Only notifies if order changed. pub fn sort(&mut self, cx: &mut Context) { + let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect(); self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at)); - cx.notify(); + let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect(); + if before != after { + cx.notify(); + } } /// Finding rooms based on a query. @@ -574,6 +587,7 @@ impl ChatRegistry { /// Reset the registry. pub fn reset(&mut self, cx: &mut Context) { self.rooms.clear(); + self.room_index.clear(); self.trash.update(cx, |this, cx| { this.clear(); cx.notify(); @@ -601,7 +615,9 @@ impl ChatRegistry { }); } else { let new_room_id = new_room.id; - self.rooms.push(cx.new(|_| new_room)); + let entity = cx.new(|_| new_room); + self.room_index.insert(new_room_id, entity.clone()); + self.rooms.push(entity); let new_index = self.rooms.len(); room_map.insert(new_room_id, new_index); @@ -713,7 +729,7 @@ impl ChatRegistry { return; }; - match self.rooms.iter().find(|e| e.read(cx).id == message.room) { + match self.room_index.get(&message.room).cloned() { Some(room) => { room.update(cx, |this, cx| { if this.kind == RoomKind::Request && message.rumor.pubkey == public_key { diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 8f09b4b..23fc2ac 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -271,8 +271,8 @@ impl Room { } /// Returns the members of the room - pub fn members(&self) -> Vec { - self.members.clone() + pub fn members(&self) -> &[PublicKey] { + &self.members } /// Checks if the room has more than two members (group) @@ -356,7 +356,7 @@ impl Room { pub fn connect(&self, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let members = self.members(); + let members = self.members().to_vec(); cx.background_spawn(async move { let opts = SubscribeAutoCloseOptions::default() diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 4a52698..2b14ab7 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; pub use actions::*; @@ -59,6 +59,9 @@ pub struct ChatPanel { /// All messages (sorted by created_at) messages: Vec, + /// O(1) message lookup by EventId + message_index: HashMap, + /// All reactions reactions: BTreeMap>, @@ -166,6 +169,7 @@ impl ChatPanel { focus_handle: cx.focus_handle(), id, messages, + message_index: HashMap::new(), reactions: BTreeMap::new(), room, list_state, @@ -231,23 +235,29 @@ impl ChatPanel { while let Ok(status) = rx.recv_async().await { { let mut map = reports.write().unwrap(); - for reports in map.values_mut() { - for report in reports.iter_mut() { + let status_id = match &*status { + SendStatus::Ok { id, .. } => *id, + SendStatus::Failed { id, .. } => *id, + }; + + // Find the matching report and update it (exit early on first match) + 'outer: for reports_list in map.values_mut() { + for report in reports_list.iter_mut() { let Some(output) = report.output.as_mut() else { continue; }; + if *output.id() != status_id { + continue; + } match &*status { - SendStatus::Ok { id, relay } => { - if output.id() == id { - output.success.insert(relay.clone(), EventSendStatus::Sent); - } + SendStatus::Ok { relay, .. } => { + output.success.insert(relay.clone(), EventSendStatus::Sent); } - SendStatus::Failed { id, relay, message } => { - if output.id() == id { - output.failed.insert(relay.clone(), message.clone()); - } + SendStatus::Failed { relay, message, .. } => { + output.failed.insert(relay.clone(), message.clone()); } } + break 'outer; } } } @@ -472,6 +482,10 @@ impl ChatPanel { if let Err(pos) = self.messages.binary_search(&msg) { self.messages.insert(pos, msg); + // Rebuild message index after insertion (indices from pos to end shift) + for (i, message) in self.messages.iter().enumerate().skip(pos) { + self.message_index.insert(message.id, i); + } self.list_state.splice(old_len..old_len, 1); if scroll { @@ -514,22 +528,25 @@ impl ChatPanel { } /// Check if a message has any reports - fn has_reports(&self, id: &EventId, _cx: &App) -> bool { - self.reports_by_id.read().unwrap().get(id).is_some() + fn has_reports(&self, id: &EventId) -> bool { + self.reports_by_id.read().unwrap().contains_key(id) } - fn sent_reports(&self, id: &EventId, _cx: &App) -> Option> { + /// Clone reports for a message (used for modal display, not called during render) + fn sent_reports(&self, id: &EventId) -> Option> { self.reports_by_id.read().unwrap().get(id).cloned() } - /// Get a message by its ID + /// Get a message by its ID (O(1) lookup) fn message(&self, id: &EventId) -> Option<&Message> { - self.messages.iter().find(|msg| &msg.id == id) + self.message_index + .get(id) + .and_then(|&ix| self.messages.get(ix)) } - /// Get a reaction by its target ID - fn reaction(&self, id: &EventId) -> Vec<(SharedString, PublicKey)> { - self.reactions.get(id).cloned().unwrap_or_default() + /// Get a reaction by its target ID (returns reference, no allocation) + fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] { + self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[]) } /// Check if a message has any reactions @@ -912,7 +929,7 @@ impl ChatPanel { let replies = message.replies_to.as_slice(); let has_replies = !replies.is_empty(); let has_reactions = self.has_reaction(&id); - let has_reports = self.has_reports(&id, cx); + let has_reports = self.has_reports(&id); // Hide avatar setting let hide_avatar = AppSettings::get_hide_avatar(cx); @@ -1095,7 +1112,7 @@ impl ChatPanel { // Group reactions by emoji and collect authors for each let mut grouped: BTreeMap> = BTreeMap::new(); - for (emoji, author) in &reactions { + for (emoji, author) in reactions { grouped.entry(emoji.clone()).or_default().push(*author); } @@ -1127,7 +1144,7 @@ impl ChatPanel { } fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement { - let reports = self.sent_reports(id, cx); + let reports = self.sent_reports(id); let pending = reports .as_ref()