chore: improve room kind handling (#48)

* chore: improve room kind handling

* .

* add some tooltips

* .

* fix button hovered style

* .

* improve prevent duplicate message

* .
This commit is contained in:
reya
2025-05-29 09:05:08 +07:00
committed by GitHub
parent 7a447da447
commit 557ff18714
6 changed files with 197 additions and 81 deletions

View File

@@ -1,7 +1,4 @@
use std::{ use std::{cmp::Reverse, collections::BTreeSet};
cmp::Reverse,
collections::{HashMap, HashSet},
};
use account::Account; use account::Account;
use anyhow::Error; use anyhow::Error;
@@ -32,7 +29,10 @@ struct GlobalChatRegistry(Entity<ChatRegistry>);
impl Global for GlobalChatRegistry {} impl Global for GlobalChatRegistry {}
#[derive(Debug)] #[derive(Debug)]
pub struct NewRoom(pub WeakEntity<Room>); pub enum RoomEmitter {
Open(WeakEntity<Room>),
Request(RoomKind),
}
/// Main registry for managing chat rooms and user profiles /// Main registry for managing chat rooms and user profiles
/// ///
@@ -53,7 +53,7 @@ pub struct ChatRegistry {
subscriptions: SmallVec<[Subscription; 2]>, subscriptions: SmallVec<[Subscription; 2]>,
} }
impl EventEmitter<NewRoom> for ChatRegistry {} impl EventEmitter<RoomEmitter> for ChatRegistry {}
impl ChatRegistry { impl ChatRegistry {
/// Retrieve the Global ChatRegistry instance /// Retrieve the Global ChatRegistry instance
@@ -131,9 +131,10 @@ impl ChatRegistry {
.collect() .collect()
} }
/// Get the IDs of all rooms. /// Sort rooms by their created at.
pub fn room_ids(&self, cx: &mut Context<Self>) -> Vec<u64> { pub fn sort(&mut self, cx: &mut Context<Self>) {
self.rooms.iter().map(|room| room.read(cx).id).collect() self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at));
cx.notify();
} }
/// Search rooms by their name. /// Search rooms by their name.
@@ -159,20 +160,15 @@ impl ChatRegistry {
/// 3. Determines each room's type based on message frequency and trust status /// 3. Determines each room's type based on message frequency and trust status
/// 4. Creates Room entities for each unique room /// 4. Creates Room entities for each unique room
pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) { pub fn load_rooms(&mut self, window: &mut Window, cx: &mut Context<Self>) {
// [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 // 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; return;
}; };
let client = get_client(); let client = get_client();
let public_key = user.public_key(); let public_key = current_user.public_key();
let task: Task<Result<Rooms, Error>> = cx.background_spawn(async move { let task: Task<Result<BTreeSet<Room>, Error>> = cx.background_spawn(async move {
// Get messages sent by the user // Get messages sent by the user
let send = Filter::new() let send = Filter::new()
.kind(Kind::PrivateDirectMessage) .kind(Kind::PrivateDirectMessage)
@@ -187,15 +183,24 @@ impl ChatRegistry {
let recv_events = client.database().query(recv).await?; let recv_events = client.database().query(recv).await?;
let events = send_events.merge(recv_events); let events = send_events.merge(recv_events);
let mut room_map: HashMap<u64, (Event, usize, bool)> = HashMap::new(); let mut rooms: BTreeSet<Room> = BTreeSet::new();
let mut trusted_keys: HashSet<PublicKey> = HashSet::new(); let mut trusted_keys: BTreeSet<PublicKey> = BTreeSet::new();
// Process each event and group by room hash // Process each event and group by room hash
for event in events for event in events
.into_iter() .into_iter()
.sorted_by_key(|event| Reverse(event.created_at))
.filter(|ev| ev.tags.public_keys().peekable().peek().is_some()) .filter(|ev| ev.tags.public_keys().peekable().peek().is_some())
{ {
let hash = room_hash(&event); 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); let mut is_trust = trusted_keys.contains(&event.pubkey);
if !is_trust { if !is_trust {
@@ -209,55 +214,50 @@ impl ChatRegistry {
} }
} }
room_map // Check if current_user has sent a message to this room at least once
.entry(hash) let filter = Filter::new()
.and_modify(|(_, count, trusted)| { .kind(Kind::PrivateDirectMessage)
*count += 1; .author(public_key)
*trusted = is_trust; .pubkeys(public_keys);
}) // If current user has sent a message at least once, mark as ongoing
.or_insert((event, 1, is_trust)); 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) Ok(rooms)
let result: Vec<(Event, usize, bool)> = room_map
.into_values()
.sorted_by_key(|(ev, _, _)| Reverse(ev.created_at))
.collect();
Ok(result)
}); });
cx.spawn_in(window, async move |this, cx| { cx.spawn_in(window, async move |this, cx| {
if let Ok(events) = task.await { let rooms = task
this.update(cx, |this, cx| { .await
let ids = this.room_ids(cx); .expect("Failed to load chat rooms. Please restart the application.");
let rooms: Vec<Entity<Room>> = events
this.update(cx, |this, cx| {
this.wait_for_eose = false;
this.rooms.extend(
rooms
.into_iter() .into_iter()
.filter_map(|(event, count, trusted)| { .sorted_by_key(|room| Reverse(room.created_at))
let hash = room_hash(&event); .filter_map(|room| {
if !ids.iter().any(|this| this == &hash) { if !this.rooms.iter().any(|this| this.read(cx).id == room.id) {
let kind = if count > 2 { Some(cx.new(|_| room))
// 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)))
} else { } else {
None None
} }
}) })
.collect(); .collect_vec(),
);
this.rooms.extend(rooms); cx.notify();
this.wait_for_eose = false; })
.ok();
cx.notify();
})
.ok();
}
}) })
.detach(); .detach();
} }
@@ -280,7 +280,7 @@ impl ChatRegistry {
weak_room 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 /// 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. /// Updates room ordering based on the most recent messages.
pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) { pub fn event_to_message(&mut self, event: Event, window: &mut Window, cx: &mut Context<Self>) {
let id = room_hash(&event); 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) { if let Some(room) = self.rooms.iter().find(|room| room.read(cx).id == id) {
// Update room
room.update(cx, |this, cx| { room.update(cx, |this, cx| {
this.created_at(event.created_at, 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 // Emit the new message to the room
cx.defer_in(window, |this, window, cx| { cx.defer_in(window, |this, window, cx| {
this.emit_message(event, window, cx); this.emit_message(event, window, cx);
}); });
}); });
// Re-sort the rooms registry by their created at
self.sort(cx);
cx.notify(); cx.notify();
} else { } else {
let room = Room::new(&event).kind(RoomKind::Unknown);
let kind = room.kind;
// Push the new room to the front of the list // 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(); cx.notify();
} }
} }

View File

@@ -269,6 +269,18 @@ impl Room {
self.members.len() > 2 self.members.len() > 2
} }
/// Set the room kind to ongoing
///
/// # Arguments
///
/// * `cx` - The context to notify about the update
pub fn set_ongoing(&mut self, cx: &mut Context<Self>) {
if self.kind != RoomKind::Ongoing {
self.kind = RoomKind::Ongoing;
cx.notify();
}
}
/// Updates the creation timestamp of the room /// Updates the creation timestamp of the room
/// ///
/// # Arguments /// # Arguments

View File

@@ -2,7 +2,7 @@ use std::sync::Arc;
use account::Account; use account::Account;
use anyhow::Error; use anyhow::Error;
use chats::ChatRegistry; use chats::{ChatRegistry, RoomEmitter};
use global::{ use global::{
constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH}, constants::{DEFAULT_MODAL_WIDTH, DEFAULT_SIDEBAR_WIDTH},
get_client, get_client,
@@ -101,13 +101,16 @@ impl ChatSpace {
&chats, &chats,
window, window,
|this, _state, event, window, cx| { |this, _state, event, window, cx| {
if let Some(room) = event.0.upgrade() { if let RoomEmitter::Open(room) = event {
this.dock.update(cx, |this, cx| { if let Some(room) = room.upgrade() {
let panel = chat::init(room, window, cx); this.dock.update(cx, |this, cx| {
this.add_panel(panel, DockPlacement::Center, window, 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); } else {
window
.push_notification("Failed to open room. Please retry later.", cx);
}
} }
}, },
)); ));

View File

@@ -1,9 +1,10 @@
use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc}; use std::{cell::RefCell, collections::HashMap, rc::Rc, sync::Arc};
use account::Account;
use async_utility::task::spawn; use async_utility::task::spawn;
use chats::{ use chats::{
message::Message, message::Message,
room::{Room, SendError}, room::{Room, RoomKind, SendError},
}; };
use common::{nip96_upload, profile::RenderProfile}; use common::{nip96_upload, profile::RenderProfile};
use global::get_client; use global::get_client;
@@ -214,17 +215,39 @@ impl Chat {
content content
} }
// TODO: find a better way to prevent duplicate messages during optimistic updates
fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context<Self>) -> bool { fn prevent_duplicate_message(&self, new_msg: &Message, cx: &Context<Self>) -> 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 Some(author) = new_msg.author.as_ref() else {
let existing = existing.borrow(); return false;
// Check if messages are within the time window };
(existing.created_at.as_u64() >= min_timestamp) &&
// Compare content and author if current_user.public_key() != author.public_key() {
(existing.content == new_msg.content) && return false;
(existing.author == new_msg.author) }
})
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<Self>) { fn send_message(&mut self, window: &mut Window, cx: &mut Context<Self>) {
@@ -263,6 +286,13 @@ impl Chat {
if let Ok(reports) = send_message.await { if let Ok(reports) = send_message.await {
if !reports.is_empty() { if !reports.is_empty() {
this.update(cx, |this, cx| { 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| { this.messages.update(cx, |this, cx| {
if let Some(msg) = id.and_then(|id| { if let Some(msg) = id.and_then(|id| {
this.iter().find(|msg| msg.borrow().id == Some(id)).cloned() this.iter().find(|msg| msg.borrow().id == Some(id)).cloned()

View File

@@ -4,7 +4,7 @@ use account::Account;
use async_utility::task::spawn; use async_utility::task::spawn;
use chats::{ use chats::{
room::{Room, RoomKind}, room::{Room, RoomKind},
ChatRegistry, ChatRegistry, RoomEmitter,
}; };
use common::{debounced_delay::DebouncedDelay, profile::RenderProfile}; use common::{debounced_delay::DebouncedDelay, profile::RenderProfile};
@@ -18,6 +18,7 @@ use gpui::{
use itertools::Itertools; use itertools::Itertools;
use nostr_sdk::prelude::*; use nostr_sdk::prelude::*;
use smallvec::{smallvec, SmallVec}; use smallvec::{smallvec, SmallVec};
use theme::ActiveTheme;
use ui::{ use ui::{
avatar::Avatar, avatar::Avatar,
button::{Button, ButtonRounded, ButtonVariants}, button::{Button, ButtonRounded, ButtonVariants},
@@ -48,13 +49,14 @@ pub struct Sidebar {
local_result: Entity<Option<Vec<Entity<Room>>>>, local_result: Entity<Option<Vec<Entity<Room>>>>,
global_result: Entity<Option<Vec<Entity<Room>>>>, global_result: Entity<Option<Vec<Entity<Room>>>>,
// Rooms // Rooms
indicator: Entity<Option<RoomKind>>,
active_filter: Entity<RoomKind>, active_filter: Entity<RoomKind>,
trusted_only: bool, trusted_only: bool,
// GPUI // GPUI
focus_handle: FocusHandle, focus_handle: FocusHandle,
image_cache: Entity<RetainAllImageCache>, image_cache: Entity<RetainAllImageCache>,
#[allow(dead_code)] #[allow(dead_code)]
subscriptions: SmallVec<[Subscription; 1]>, subscriptions: SmallVec<[Subscription; 2]>,
} }
impl Sidebar { impl Sidebar {
@@ -64,14 +66,29 @@ impl Sidebar {
fn view(window: &mut Window, cx: &mut Context<Self>) -> Self { fn view(window: &mut Window, cx: &mut Context<Self>) -> Self {
let active_filter = cx.new(|_| RoomKind::Ongoing); let active_filter = cx.new(|_| RoomKind::Ongoing);
let indicator = cx.new(|_| None);
let local_result = cx.new(|_| None); let local_result = cx.new(|_| None);
let global_result = cx.new(|_| None); let global_result = cx.new(|_| None);
let find_input = let find_input =
cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation")); cx.new(|cx| InputState::new(window, cx).placeholder("Find or start a conversation"));
let chats = ChatRegistry::global(cx);
let mut subscriptions = smallvec![]; 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( subscriptions.push(cx.subscribe_in(
&find_input, &find_input,
window, window,
@@ -103,6 +120,7 @@ impl Sidebar {
find_debouncer: DebouncedDelay::new(), find_debouncer: DebouncedDelay::new(),
finding: false, finding: false,
trusted_only: false, trusted_only: false,
indicator,
active_filter, active_filter,
find_input, find_input,
local_result, local_result,
@@ -275,10 +293,14 @@ impl Sidebar {
} }
fn set_filter(&mut self, kind: RoomKind, cx: &mut Context<Self>) { fn set_filter(&mut self, kind: RoomKind, cx: &mut Context<Self>) {
self.indicator.update(cx, |this, cx| {
*this = None;
cx.notify();
});
self.active_filter.update(cx, |this, cx| { self.active_filter.update(cx, |this, cx| {
*this = kind; *this = kind;
cx.notify(); cx.notify();
}) });
} }
fn set_trusted_only(&mut self, cx: &mut Context<Self>) { fn set_trusted_only(&mut self, cx: &mut Context<Self>) {
@@ -532,6 +554,20 @@ impl Render for Sidebar {
.child( .child(
Button::new("all") Button::new("all")
.label("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() .small()
.bold() .bold()
.secondary() .secondary()
@@ -544,6 +580,20 @@ impl Render for Sidebar {
.child( .child(
Button::new("requests") Button::new("requests")
.label("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() .small()
.bold() .bold()
.secondary() .secondary()

View File

@@ -316,7 +316,7 @@ impl RenderOnce for Button {
this.bg(normal_style.bg) this.bg(normal_style.bg)
.hover(|this| { .hover(|this| {
let hover_style = style.hovered(window, cx); let hover_style = style.hovered(window, cx);
this.bg(hover_style.bg) this.bg(hover_style.bg).text_color(hover_style.fg)
}) })
.active(|this| { .active(|this| {
let active_style = style.active(window, cx); let active_style = style.active(window, cx);