From 557ff18714ef2b9c140d246afc33e3ca7579cf0a Mon Sep 17 00:00:00 2001 From: reya <123083837+reyamir@users.noreply.github.com> Date: Thu, 29 May 2025 09:05:08 +0700 Subject: [PATCH] chore: improve room kind handling (#48) * chore: improve room kind handling * . * add some tooltips * . * fix button hovered style * . * improve prevent duplicate message * . --- crates/chats/src/lib.rs | 139 +++++++++++++++------------ crates/chats/src/room.rs | 12 +++ crates/coop/src/chatspace.rs | 19 ++-- crates/coop/src/views/chat.rs | 50 ++++++++-- crates/coop/src/views/sidebar/mod.rs | 56 ++++++++++- crates/ui/src/button.rs | 2 +- 6 files changed, 197 insertions(+), 81 deletions(-) diff --git a/crates/chats/src/lib.rs b/crates/chats/src/lib.rs index 55dd6f5..0fcc9af 100644 --- a/crates/chats/src/lib.rs +++ b/crates/chats/src/lib.rs @@ -1,7 +1,4 @@ -use std::{ - cmp::Reverse, - collections::{HashMap, HashSet}, -}; +use std::{cmp::Reverse, collections::BTreeSet}; use account::Account; use anyhow::Error; @@ -32,7 +29,10 @@ struct GlobalChatRegistry(Entity); impl Global for GlobalChatRegistry {} #[derive(Debug)] -pub struct NewRoom(pub WeakEntity); +pub enum RoomEmitter { + Open(WeakEntity), + Request(RoomKind), +} /// Main registry for managing chat rooms and user profiles /// @@ -53,7 +53,7 @@ pub struct ChatRegistry { subscriptions: SmallVec<[Subscription; 2]>, } -impl EventEmitter for ChatRegistry {} +impl EventEmitter for ChatRegistry {} impl ChatRegistry { /// Retrieve the Global ChatRegistry instance @@ -131,9 +131,10 @@ impl ChatRegistry { .collect() } - /// Get the IDs of all rooms. - pub fn room_ids(&self, cx: &mut Context) -> Vec { - self.rooms.iter().map(|room| room.read(cx).id).collect() + /// Sort rooms by their created at. + pub fn sort(&mut self, cx: &mut Context) { + self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at)); + cx.notify(); } /// Search rooms by their name. @@ -159,20 +160,15 @@ impl ChatRegistry { /// 3. Determines each room's type based on message frequency and trust status /// 4. Creates Room entities for each unique room pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context) { - // [event] is the Nostr Event - // [usize] is the total number of messages, used to determine an ongoing conversation - // [bool] is used to determine if the room is trusted - type Rooms = Vec<(Event, usize, bool)>; - // If the user is not logged in, do nothing - let Some(user) = Account::get_global(cx).profile_ref() else { + let Some(current_user) = Account::get_global(cx).profile_ref() else { return; }; let client = get_client(); - let public_key = user.public_key(); + let public_key = current_user.public_key(); - let task: Task> = cx.background_spawn(async move { + let task: Task, Error>> = cx.background_spawn(async move { // Get messages sent by the user let send = Filter::new() .kind(Kind::PrivateDirectMessage) @@ -187,15 +183,24 @@ impl ChatRegistry { let recv_events = client.database().query(recv).await?; let events = send_events.merge(recv_events); - let mut room_map: HashMap = HashMap::new(); - let mut trusted_keys: HashSet = HashSet::new(); + let mut rooms: BTreeSet = BTreeSet::new(); + let mut trusted_keys: BTreeSet = BTreeSet::new(); // Process each event and group by room hash for event in events .into_iter() + .sorted_by_key(|event| Reverse(event.created_at)) .filter(|ev| ev.tags.public_keys().peekable().peek().is_some()) { let hash = room_hash(&event); + + if rooms.iter().any(|room| room.id == hash) { + continue; + } + + let mut public_keys = event.tags.public_keys().copied().collect_vec(); + public_keys.push(event.pubkey); + let mut is_trust = trusted_keys.contains(&event.pubkey); if !is_trust { @@ -209,55 +214,50 @@ impl ChatRegistry { } } - room_map - .entry(hash) - .and_modify(|(_, count, trusted)| { - *count += 1; - *trusted = is_trust; - }) - .or_insert((event, 1, is_trust)); + // Check if current_user has sent a message to this room at least once + let filter = Filter::new() + .kind(Kind::PrivateDirectMessage) + .author(public_key) + .pubkeys(public_keys); + // If current user has sent a message at least once, mark as ongoing + let is_ongoing = client.database().count(filter).await? >= 1; + + if is_ongoing { + rooms.insert(Room::new(&event).kind(RoomKind::Ongoing)); + } else if is_trust { + rooms.insert(Room::new(&event).kind(RoomKind::Trusted)); + } else { + rooms.insert(Room::new(&event)); + } } - // Sort rooms by creation date (newest first) - let result: Vec<(Event, usize, bool)> = room_map - .into_values() - .sorted_by_key(|(ev, _, _)| Reverse(ev.created_at)) - .collect(); - - Ok(result) + Ok(rooms) }); cx.spawn_in(window, async move |this, cx| { - if let Ok(events) = task.await { - this.update(cx, |this, cx| { - let ids = this.room_ids(cx); - let rooms: Vec> = events + let rooms = task + .await + .expect("Failed to load chat rooms. Please restart the application."); + + this.update(cx, |this, cx| { + this.wait_for_eose = false; + this.rooms.extend( + rooms .into_iter() - .filter_map(|(event, count, trusted)| { - let hash = room_hash(&event); - if !ids.iter().any(|this| this == &hash) { - let kind = if count > 2 { - // If frequency count is greater than 2, mark this room as ongoing - RoomKind::Ongoing - } else if trusted { - RoomKind::Trusted - } else { - RoomKind::Unknown - }; - Some(cx.new(|_| Room::new(&event).kind(kind))) + .sorted_by_key(|room| Reverse(room.created_at)) + .filter_map(|room| { + if !this.rooms.iter().any(|this| this.read(cx).id == room.id) { + Some(cx.new(|_| room)) } else { None } }) - .collect(); + .collect_vec(), + ); - this.rooms.extend(rooms); - this.wait_for_eose = false; - - cx.notify(); - }) - .ok(); - } + cx.notify(); + }) + .ok(); }) .detach(); } @@ -280,7 +280,7 @@ impl ChatRegistry { weak_room }; - cx.emit(NewRoom(weak_room)); + cx.emit(RoomEmitter::Open(weak_room)); } /// Parse a Nostr event into a Coop Message and push it to the belonging room @@ -289,19 +289,40 @@ impl ChatRegistry { /// Updates room ordering based on the most recent messages. pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context) { let id = room_hash(&event); + let author = event.pubkey; + + let Some(profile) = Account::get_global(cx).profile.to_owned() else { + return; + }; if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) { + // Update room room.update(cx, |this, cx| { this.created_at(event.created_at, cx); + + // Set this room is ongoing if the new message is from current user + if author == profile.public_key() { + this.set_ongoing(cx); + } + // Emit the new message to the room cx.defer_in(window, |this, window, cx| { this.emit_message(event, window, cx); }); }); + + // Re-sort the rooms registry by their created at + self.sort(cx); + cx.notify(); } else { + let room = Room::new(&event).kind(RoomKind::Unknown); + let kind = room.kind; + // Push the new room to the front of the list - self.rooms.insert(0, cx.new(|_| Room::new(&event))); + self.rooms.insert(0, cx.new(|_| room)); + + cx.emit(RoomEmitter::Request(kind)); cx.notify(); } } diff --git a/crates/chats/src/room.rs b/crates/chats/src/room.rs index d7d1a65..c2b688d 100644 --- a/crates/chats/src/room.rs +++ b/crates/chats/src/room.rs @@ -269,6 +269,18 @@ impl Room { self.members.len() > 2 } + /// Set the room kind to ongoing + /// + /// # Arguments + /// + /// * `cx` - The context to notify about the update + pub fn set_ongoing(&mut self, cx: &mut Context) { + if self.kind != RoomKind::Ongoing { + self.kind = RoomKind::Ongoing; + cx.notify(); + } + } + /// Updates the creation timestamp of the room /// /// # Arguments diff --git a/crates/coop/src/chatspace.rs b/crates/coop/src/chatspace.rs index fc19896..0467cc5 100644 --- a/crates/coop/src/chatspace.rs +++ b/crates/coop/src/chatspace.rs @@ -2,7 +2,7 @@ use std::sync::Arc; use account::Account; use anyhow::Error; -use chats::ChatRegistry; +use chats::{ChatRegistry, RoomEmitter}; use global::{ constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH}, get_client, @@ -101,13 +101,16 @@ impl ChatSpace { &chats, window, |this, _state, event, window, cx| { - if let Some(room) = event.0.upgrade() { - this.dock.update(cx, |this, cx| { - let panel = chat::init(room, window, cx); - this.add_panel(panel, DockPlacement::Center, window, cx); - }); - } else { - window.push_notification("Failed to open room. Please retry later.", cx); + if let RoomEmitter::Open(room) = event { + if let Some(room) = room.upgrade() { + this.dock.update(cx, |this, cx| { + let panel = chat::init(room, window, cx); + this.add_panel(panel, DockPlacement::Center, window, cx); + }); + } else { + window + .push_notification("Failed to open room. Please retry later.", cx); + } } }, )); diff --git a/crates/coop/src/views/chat.rs b/crates/coop/src/views/chat.rs index f0873e4..d7b3f33 100644 --- a/crates/coop/src/views/chat.rs +++ b/crates/coop/src/views/chat.rs @@ -1,9 +1,10 @@ use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc}; +use account::Account; use async_utility::task::spawn; use chats::{ message::Message, - room::{Room, SendError}, + room::{Room, RoomKind, SendError}, }; use common::{nip96_upload, profile::RenderProfile}; use global::get_client; @@ -214,17 +215,39 @@ impl Chat { content } + // TODO: find a better way to prevent duplicate messages during optimistic updates fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context) -> bool { - let min_timestamp = new_msg.created_at.as_u64().saturating_sub(2); + let Some(current_user) = Account::get_global(cx).profile_ref() else { + return false; + }; - self.messages.read(cx).iter().any(|existing| { - let existing = existing.borrow(); - // Check if messages are within the time window - (existing.created_at.as_u64() >= min_timestamp) && - // Compare content and author - (existing.content == new_msg.content) && - (existing.author == new_msg.author) - }) + let Some(author) = new_msg.author.as_ref() else { + return false; + }; + + if current_user.public_key() != author.public_key() { + return false; + } + + let min_timestamp = new_msg.created_at.as_u64().saturating_sub(10); + + self.messages + .read(cx) + .iter() + .filter(|m| { + m.borrow() + .author + .as_ref() + .is_some_and(|p| p.public_key() == current_user.public_key()) + }) + .any(|existing| { + let existing = existing.borrow(); + // Check if messages are within the time window + (existing.created_at.as_u64() >= min_timestamp) && + // Compare content and author + (existing.content == new_msg.content) && + (existing.author == new_msg.author) + }) } fn send_message(&mut self, window: &mut Window, cx: &mut Context) { @@ -263,6 +286,13 @@ impl Chat { if let Ok(reports) = send_message.await { if !reports.is_empty() { this.update(cx, |this, cx| { + this.room.update(cx, |this, cx| { + if this.kind != RoomKind::Ongoing { + this.kind = RoomKind::Ongoing; + cx.notify(); + } + }); + this.messages.update(cx, |this, cx| { if let Some(msg) = id.and_then(|id| { this.iter().find(|msg| msg.borrow().id == Some(id)).cloned() diff --git a/crates/coop/src/views/sidebar/mod.rs b/crates/coop/src/views/sidebar/mod.rs index d01aa8e..9e7705b 100644 --- a/crates/coop/src/views/sidebar/mod.rs +++ b/crates/coop/src/views/sidebar/mod.rs @@ -4,7 +4,7 @@ use account::Account; use async_utility::task::spawn; use chats::{ room::{Room, RoomKind}, - ChatRegistry, + ChatRegistry, RoomEmitter, }; use common::{debounced_delay::DebouncedDelay, profile::RenderProfile}; @@ -18,6 +18,7 @@ use gpui::{ use itertools::Itertools; use nostr_sdk::prelude::*; use smallvec::{smallvec, SmallVec}; +use theme::ActiveTheme; use ui::{ avatar::Avatar, button::{Button, ButtonRounded, ButtonVariants}, @@ -48,13 +49,14 @@ pub struct Sidebar { local_result: Entity>>>, global_result: Entity>>>, // Rooms + indicator: Entity>, active_filter: Entity, trusted_only: bool, // GPUI focus_handle: FocusHandle, image_cache: Entity, #[allow(dead_code)] - subscriptions: SmallVec<[Subscription; 1]>, + subscriptions: SmallVec<[Subscription; 2]>, } impl Sidebar { @@ -64,14 +66,29 @@ impl Sidebar { fn view(window: &mut Window, cx: &mut Context) -> Self { let active_filter = cx.new(|_| RoomKind::Ongoing); + let indicator = cx.new(|_| None); let local_result = cx.new(|_| None); let global_result = cx.new(|_| None); let find_input = cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation")); + let chats = ChatRegistry::global(cx); let mut subscriptions = smallvec![]; + subscriptions.push(cx.subscribe_in( + &chats, + window, + move |this, _chats, event, _window, cx| { + if let RoomEmitter::Request(kind) = event { + this.indicator.update(cx, |this, cx| { + *this = Some(kind.to_owned()); + cx.notify(); + }); + } + }, + )); + subscriptions.push(cx.subscribe_in( &find_input, window, @@ -103,6 +120,7 @@ impl Sidebar { find_debouncer: DebouncedDelay::new(), finding: false, trusted_only: false, + indicator, active_filter, find_input, local_result, @@ -275,10 +293,14 @@ impl Sidebar { } fn set_filter(&mut self, kind: RoomKind, cx: &mut Context) { + self.indicator.update(cx, |this, cx| { + *this = None; + cx.notify(); + }); self.active_filter.update(cx, |this, cx| { *this = kind; cx.notify(); - }) + }); } fn set_trusted_only(&mut self, cx: &mut Context) { @@ -532,6 +554,20 @@ impl Render for Sidebar { .child( Button::new("all") .label("All") + .tooltip("All ongoing conversations") + .when_some( + self.indicator.read(cx).as_ref(), + |this, kind| { + this.when(kind == &RoomKind::Ongoing, |this| { + this.child( + div() + .size_1() + .rounded_full() + .bg(cx.theme().cursor), + ) + }) + }, + ) .small() .bold() .secondary() @@ -544,6 +580,20 @@ impl Render for Sidebar { .child( Button::new("requests") .label("Requests") + .tooltip("Incoming new conversations") + .when_some( + self.indicator.read(cx).as_ref(), + |this, kind| { + this.when(kind != &RoomKind::Ongoing, |this| { + this.child( + div() + .size_1() + .rounded_full() + .bg(cx.theme().cursor), + ) + }) + }, + ) .small() .bold() .secondary() diff --git a/crates/ui/src/button.rs b/crates/ui/src/button.rs index db349e4..d6e7ae3 100644 --- a/crates/ui/src/button.rs +++ b/crates/ui/src/button.rs @@ -316,7 +316,7 @@ impl RenderOnce for Button { this.bg(normal_style.bg) .hover(|this| { let hover_style = style.hovered(window, cx); - this.bg(hover_style.bg) + this.bg(hover_style.bg).text_color(hover_style.fg) }) .active(|this| { let active_style = style.active(window, cx);