From c4573ef1da771e08b99118b8cba6bf2da15b04fc Mon Sep 17 00:00:00 2001 From: reya Date: Mon, 10 Feb 2025 09:03:29 +0700 Subject: [PATCH] feat: improve chat state --- Cargo.lock | 2 +- crates/app/Cargo.toml | 1 - crates/app/src/main.rs | 2 +- crates/app/src/views/chat/mod.rs | 10 +- crates/app/src/views/relays.rs | 12 +-- crates/app/src/views/sidebar/compose.rs | 7 +- crates/app/src/views/sidebar/inbox.rs | 16 +-- crates/chat_state/Cargo.toml | 1 + crates/chat_state/src/inbox.rs | 36 +------ crates/chat_state/src/registry.rs | 125 ++++++++++++++---------- crates/chat_state/src/room.rs | 74 ++++++++++++-- crates/common/src/profile.rs | 9 +- crates/common/src/utils.rs | 57 +---------- 13 files changed, 171 insertions(+), 181 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 65cba5b..db55e06 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -882,6 +882,7 @@ name = "chat_state" version = "0.1.0" dependencies = [ "anyhow", + "chrono", "common", "gpui", "itertools 0.13.0", @@ -1111,7 +1112,6 @@ dependencies = [ "anyhow", "app_state", "chat_state", - "chrono", "common", "dirs 5.0.1", "gpui", diff --git a/crates/app/Cargo.toml b/crates/app/Cargo.toml index 63b2a95..22b220a 100644 --- a/crates/app/Cargo.toml +++ b/crates/app/Cargo.toml @@ -26,7 +26,6 @@ anyhow.workspace = true serde.workspace = true serde_json.workspace = true itertools.workspace = true -chrono.workspace = true dirs.workspace = true rust-embed.workspace = true smol.workspace = true diff --git a/crates/app/src/main.rs b/crates/app/src/main.rs index 8ae0cc3..c38250b 100644 --- a/crates/app/src/main.rs +++ b/crates/app/src/main.rs @@ -75,7 +75,7 @@ fn main() { async fn process_batch(client: &Client, events: &[Cow<'_, Event>]) { let sig = Signature::from_str(FAKE_SIG).unwrap(); - let mut buffer: HashSet = HashSet::with_capacity(100); + let mut buffer: HashSet = HashSet::with_capacity(20); for event in events.iter() { if let Ok(UnwrappedGift { mut rumor, sender }) = diff --git a/crates/app/src/views/chat/mod.rs b/crates/app/src/views/chat/mod.rs index 221f1c1..456a232 100644 --- a/crates/app/src/views/chat/mod.rs +++ b/crates/app/src/views/chat/mod.rs @@ -1,9 +1,9 @@ use async_utility::task::spawn; -use chat_state::room::Room; +use chat_state::room::{LastSeen, Room}; use common::{ constants::IMAGE_SERVICE, profile::NostrProfile, - utils::{compare, message_time, nip96_upload}, + utils::{compare, nip96_upload}, }; use gpui::{ div, img, list, prelude::FluentBuilder, px, white, AnyElement, App, AppContext, Context, @@ -207,7 +207,7 @@ impl Chat { Some(Message::new( member, ev.content.into(), - message_time(ev.created_at).into(), + LastSeen(ev.created_at).human_readable(), )) } else { None @@ -237,7 +237,7 @@ impl Chat { Message::new( member, event.content.clone().into(), - message_time(event.created_at).into(), + LastSeen(event.created_at).human_readable(), ) }) }) @@ -334,7 +334,7 @@ impl Chat { let message = Message::new( this.owner.clone(), content.to_string().into(), - message_time(Timestamp::now()).into(), + LastSeen(Timestamp::now()).human_readable(), ); // Update message list diff --git a/crates/app/src/views/relays.rs b/crates/app/src/views/relays.rs index be89acf..f2882b6 100644 --- a/crates/app/src/views/relays.rs +++ b/crates/app/src/views/relays.rs @@ -22,12 +22,10 @@ pub struct Relays { impl Relays { pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self { let relays = cx.new(|_| { - let mut list = Vec::with_capacity(10); - - list.push(Url::parse("wss://auth.nostr1.com").unwrap()); - list.push(Url::parse("wss://relay.0xchat.com").unwrap()); - - list + vec![ + Url::parse("wss://auth.nostr1.com").unwrap(), + Url::parse("wss://relay.0xchat.com").unwrap(), + ] }); let input = cx.new(|cx| { @@ -186,7 +184,7 @@ impl Render for Relays { "relays", total, move |_, range, _window, cx| { - let mut items = Vec::with_capacity(total); + let mut items = Vec::new(); for ix in range { let item = relays.get(ix).unwrap().clone().to_string(); diff --git a/crates/app/src/views/sidebar/compose.rs b/crates/app/src/views/sidebar/compose.rs index 2dc5cc4..9655ef0 100644 --- a/crates/app/src/views/sidebar/compose.rs +++ b/crates/app/src/views/sidebar/compose.rs @@ -40,7 +40,7 @@ pub struct Compose { impl Compose { pub fn new(window: &mut Window, cx: &mut Context<'_, Self>) -> Self { - let contacts = cx.new(|_| Vec::with_capacity(200)); + let contacts = cx.new(|_| Vec::new()); let selected = cx.new(|_| HashSet::new()); let user_input = cx.new(|cx| { @@ -157,7 +157,10 @@ impl Compose { let content = message.to_string(); // Get room title from user's input - let title = Tag::title(self.title_input.read(cx).text().to_string()); + let title = Tag::custom( + TagKind::Subject, + vec![self.title_input.read(cx).text().to_string()], + ); // Get all pubkeys let current_user = current_user.public_key(); diff --git a/crates/app/src/views/sidebar/inbox.rs b/crates/app/src/views/sidebar/inbox.rs index a326a06..4041cc6 100644 --- a/crates/app/src/views/sidebar/inbox.rs +++ b/crates/app/src/views/sidebar/inbox.rs @@ -1,6 +1,5 @@ use crate::views::app::{AddPanel, PanelKind}; use chat_state::registry::ChatRegistry; -use common::utils::message_ago; use gpui::{ div, img, percentage, prelude::FluentBuilder, px, relative, Context, InteractiveElement, IntoElement, ParentElement, Render, SharedString, StatefulInteractiveElement, Styled, @@ -75,9 +74,7 @@ impl Inbox { } else { this.children(inbox.rooms.iter().map(|model| { let room = model.read(cx); - let id = room.id; - let room_id: SharedString = id.to_string().into(); - let ago: SharedString = message_ago(room.last_seen).into(); + let room_id: SharedString = room.id.to_string().into(); div() .id(room_id) @@ -115,11 +112,14 @@ impl Inbox { div() .flex_shrink_0() .text_color(cx.theme().base.step(cx, ColorScaleStep::ELEVEN)) - .child(ago), + .child(room.last_seen.ago()), ) - .on_click(cx.listener(move |this, _, window, cx| { - this.action(id, window, cx); - })) + .on_click({ + let id = room.id; + cx.listener(move |this, _, window, cx| { + this.action(id, window, cx); + }) + }) })) } }) diff --git a/crates/chat_state/Cargo.toml b/crates/chat_state/Cargo.toml index 11780ff..9834173 100644 --- a/crates/chat_state/Cargo.toml +++ b/crates/chat_state/Cargo.toml @@ -13,3 +13,4 @@ nostr-sdk.workspace = true anyhow.workspace = true itertools.workspace = true smol.workspace = true +chrono.workspace = true diff --git a/crates/chat_state/src/inbox.rs b/crates/chat_state/src/inbox.rs index 51ba83d..246b1f6 100644 --- a/crates/chat_state/src/inbox.rs +++ b/crates/chat_state/src/inbox.rs @@ -1,9 +1,4 @@ -use common::utils::room_hash; -use gpui::{AsyncApp, Context, Entity, Task}; -use itertools::Itertools; -use nostr_sdk::prelude::*; -use state::get_client; -use std::cmp::Reverse; +use gpui::{Context, Entity}; use crate::room::Room; @@ -20,36 +15,9 @@ impl Inbox { } } - pub fn get_room_ids(&self, cx: &Context) -> Vec { + pub fn ids(&self, cx: &Context) -> Vec { self.rooms.iter().map(|room| room.read(cx).id).collect() } - - pub fn load(&mut self, cx: AsyncApp) -> Task, Error>> { - cx.background_executor().spawn(async move { - let client = get_client(); - let signer = client.signer().await?; - let public_key = signer.get_public_key().await?; - - let filter = Filter::new() - .kind(Kind::PrivateDirectMessage) - .author(public_key); - - // Get all DM events from database - let events = client.database().query(filter).await?; - - // Filter result - // - Get unique rooms only - // - Sorted by created_at - let result = events - .into_iter() - .filter(|ev| ev.tags.public_keys().peekable().peek().is_some()) - .unique_by(|ev| room_hash(&ev.tags)) - .sorted_by_key(|ev| Reverse(ev.created_at)) - .collect::>(); - - Ok(result) - }) - } } impl Default for Inbox { diff --git a/crates/chat_state/src/registry.rs b/crates/chat_state/src/registry.rs index 9febb08..42e0494 100644 --- a/crates/chat_state/src/registry.rs +++ b/crates/chat_state/src/registry.rs @@ -1,8 +1,10 @@ -use anyhow::Error; -use common::utils::{compare, room_hash}; +use async_utility::tokio::sync::oneshot; +use common::utils::{compare, room_hash, signer_public_key}; use gpui::{App, AppContext, Entity, Global, WeakEntity, Window}; +use itertools::Itertools; use nostr_sdk::prelude::*; use state::get_client; +use std::cmp::Reverse; use crate::{inbox::Inbox, room::Room}; @@ -18,34 +20,30 @@ impl ChatRegistry { cx.observe_new::(|this, _window, cx| { // Get all pubkeys to load metadata - let pubkeys = this.get_pubkeys(); + let pubkeys = this.pubkeys(); - cx.spawn(|weak_model, mut async_cx| async move { - let query: Result, Error> = async_cx - .background_executor() - .spawn(async move { - let client = get_client(); - let mut profiles = Vec::new(); + cx.spawn(|this, mut cx| async move { + let (tx, rx) = oneshot::channel::>(); - for public_key in pubkeys.into_iter() { - let metadata = client - .database() - .metadata(public_key) - .await? - .unwrap_or_default(); + cx.background_spawn(async move { + let client = get_client(); + let mut profiles = Vec::new(); - profiles.push((public_key, metadata)); + for public_key in pubkeys.into_iter() { + if let Ok(metadata) = client.database().metadata(public_key).await { + profiles.push((public_key, metadata.unwrap_or_default())); } + } - Ok(profiles) - }) - .await; + _ = tx.send(profiles); + }) + .detach(); - if let Ok(profiles) = query { - if let Some(model) = weak_model.upgrade() { - _ = async_cx.update_entity(&model, |model, cx| { + if let Ok(profiles) = rx.await { + if let Some(room) = this.upgrade() { + _ = cx.update_entity(&room, |this, cx| { for profile in profiles.into_iter() { - model.set_metadata(profile.0, profile.1); + this.set_metadata(profile.0, profile.1); } cx.notify(); }); @@ -61,38 +59,60 @@ impl ChatRegistry { pub fn load(&mut self, window: &mut Window, cx: &mut App) { let window_handle = window.window_handle(); + let inbox = self.inbox.downgrade(); - self.inbox.update(cx, |this, cx| { - let task = this.load(cx.to_async()); + cx.spawn(|mut cx| async move { + let (tx, rx) = oneshot::channel::>(); - cx.spawn(|this, mut cx| async move { - if let Ok(events) = task.await { - _ = cx.update_window(window_handle, |_, _, cx| { - _ = this.update(cx, |this, cx| { - let current_rooms = this.get_room_ids(cx); - let items: Vec> = events - .into_iter() - .filter_map(|ev| { - let id = room_hash(&ev.tags); - // Filter all seen events - if !current_rooms.iter().any(|h| h == &id) { - Some(cx.new(|_| Room::parse(&ev))) - } else { - None - } - }) - .collect(); + cx.background_spawn(async move { + let client = get_client(); - this.rooms.extend(items); - this.is_loading = false; + if let Ok(public_key) = signer_public_key(client).await { + let filter = Filter::new() + .kind(Kind::PrivateDirectMessage) + .author(public_key); - cx.notify(); - }); - }); + // Get all DM events from database + if let Ok(events) = client.database().query(filter).await { + let result: Vec = events + .into_iter() + .filter(|ev| ev.tags.public_keys().peekable().peek().is_some()) + .unique_by(room_hash) + .sorted_by_key(|ev| Reverse(ev.created_at)) + .collect(); + + _ = tx.send(result); + } } }) .detach(); - }); + + if let Ok(events) = rx.await { + _ = cx.update_window(window_handle, |_, _, cx| { + _ = inbox.update(cx, |this, cx| { + let current_rooms = this.ids(cx); + let items: Vec> = events + .into_iter() + .filter_map(|ev| { + let new = room_hash(&ev); + // Filter all seen events + if !current_rooms.iter().any(|this| this == &new) { + Some(cx.new(|_| Room::parse(&ev))) + } else { + None + } + }) + .collect(); + + this.rooms.extend(items); + this.is_loading = false; + + cx.notify(); + }); + }); + } + }) + .detach(); } pub fn inbox(&self) -> WeakEntity { @@ -121,7 +141,6 @@ impl ChatRegistry { pub fn new_room_message(&mut self, event: Event, window: &mut Window, cx: &mut App) { let window_handle = window.window_handle(); - // Get all pubkeys from event's tags for comparision let mut pubkeys: Vec<_> = event.tags.public_keys().copied().collect(); pubkeys.push(event.pubkey); @@ -131,14 +150,14 @@ impl ChatRegistry { .read(cx) .rooms .iter() - .find(|room| compare(&room.read(cx).get_pubkeys(), &pubkeys)) + .find(|room| compare(&room.read(cx).pubkeys(), &pubkeys)) { - let weak_room = room.downgrade(); + let this = room.downgrade(); cx.spawn(|mut cx| async move { if let Err(e) = cx.update_window(window_handle, |_, _, cx| { - _ = weak_room.update(cx, |this, cx| { - this.last_seen = event.created_at; + _ = this.update(cx, |this, cx| { + this.last_seen.set(event.created_at); this.new_messages.push(event); cx.notify(); diff --git a/crates/chat_state/src/room.rs b/crates/chat_state/src/room.rs index 3c10d9f..6e6574d 100644 --- a/crates/chat_state/src/room.rs +++ b/crates/chat_state/src/room.rs @@ -1,3 +1,4 @@ +use chrono::{Datelike, Local, TimeZone}; use common::{ profile::NostrProfile, utils::{compare, random_name, room_hash}, @@ -6,13 +7,66 @@ use gpui::SharedString; use nostr_sdk::prelude::*; use std::collections::HashSet; -#[derive(Debug)] +pub struct LastSeen(pub Timestamp); + +impl LastSeen { + pub fn ago(&self) -> SharedString { + let now = Local::now(); + let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap(); + let diff = (now - input_time).num_hours(); + + if diff < 24 { + let duration = now.signed_duration_since(input_time); + + if duration.num_seconds() < 60 { + "now".to_string().into() + } else if duration.num_minutes() == 1 { + "1m".to_string().into() + } else if duration.num_minutes() < 60 { + format!("{}m", duration.num_minutes()).into() + } else if duration.num_hours() == 1 { + "1h".to_string().into() + } else if duration.num_hours() < 24 { + format!("{}h", duration.num_hours()).into() + } else if duration.num_days() == 1 { + "1d".to_string().into() + } else { + format!("{}d", duration.num_days()).into() + } + } else { + input_time.format("%b %d").to_string().into() + } + } + + pub fn human_readable(&self) -> SharedString { + let now = Local::now(); + let input_time = Local.timestamp_opt(self.0.as_u64() as i64, 0).unwrap(); + + if input_time.day() == now.day() { + format!("Today at {}", input_time.format("%H:%M %p")).into() + } else if input_time.day() == now.day() - 1 { + format!("Yesterday at {}", input_time.format("%H:%M %p")).into() + } else { + format!( + "{}, {}", + input_time.format("%d/%m/%y"), + input_time.format("%H:%M %p") + ) + .into() + } + } + + pub fn set(&mut self, created_at: Timestamp) { + self.0 = created_at + } +} + pub struct Room { pub id: u64, pub title: Option, pub owner: NostrProfile, // Owner always match current user pub members: Vec, // Extract from event's tags - pub last_seen: Timestamp, + pub last_seen: LastSeen, pub is_group: bool, pub new_messages: Vec, // Hold all new messages } @@ -35,7 +89,7 @@ impl Room { owner: NostrProfile, members: Vec, title: Option, - last_seen: Timestamp, + last_seen: LastSeen, ) -> Self { let is_group = members.len() > 1; let title = if title.is_none() { @@ -57,8 +111,8 @@ impl Room { /// Convert nostr event to room pub fn parse(event: &Event) -> Room { - let id = room_hash(&event.tags); - let last_seen = event.created_at; + let id = room_hash(event); + let last_seen = LastSeen(event.created_at); // Always equal to current user let owner = NostrProfile::new(event.pubkey, Metadata::default()); @@ -73,7 +127,7 @@ impl Room { .collect(); // Get title from event's tags - let title = if let Some(tag) = event.tags.find(TagKind::Title) { + let title = if let Some(tag) = event.tags.find(TagKind::Subject) { tag.content().map(|s| s.to_owned().into()) } else { None @@ -113,7 +167,7 @@ impl Room { self.members .iter() .map(|profile| profile.name()) - .collect::>() + .collect::>() .join(", ") } else { let name = self @@ -121,15 +175,15 @@ impl Room { .iter() .take(2) .map(|profile| profile.name()) - .collect::>() + .collect::>() .join(", "); format!("{}, +{}", name, self.members.len() - 2) } } - /// Get all public keys from room's contacts - pub fn get_pubkeys(&self) -> Vec { + /// Get all public keys from current room + pub fn pubkeys(&self) -> Vec { let mut pubkeys: Vec<_> = self.members.iter().map(|m| m.public_key()).collect(); pubkeys.push(self.owner.public_key()); diff --git a/crates/common/src/profile.rs b/crates/common/src/profile.rs index 34d81eb..e80640a 100644 --- a/crates/common/src/profile.rs +++ b/crates/common/src/profile.rs @@ -1,4 +1,4 @@ -use crate::{constants::IMAGE_SERVICE, utils::shorted_public_key}; +use crate::constants::IMAGE_SERVICE; use nostr_sdk::prelude::*; #[derive(Debug, Clone)] @@ -58,17 +58,18 @@ impl NostrProfile { pub fn name(&self) -> String { if let Some(display_name) = &self.metadata.display_name { if !display_name.is_empty() { - return display_name.clone(); + return display_name.to_owned(); } } if let Some(name) = &self.metadata.name { if !name.is_empty() { - return name.clone(); + return name.to_owned(); } } - shorted_public_key(self.public_key) + let pubkey = self.public_key.to_string(); + format!("{}:{}", &pubkey[0..4], &pubkey[pubkey.len() - 4..]) } /// Get contact's metadata diff --git a/crates/common/src/utils.rs b/crates/common/src/utils.rs index c64aebc..46a11e5 100644 --- a/crates/common/src/utils.rs +++ b/crates/common/src/utils.rs @@ -1,5 +1,4 @@ use crate::constants::NIP96_SERVER; -use chrono::{Datelike, Local, TimeZone}; use itertools::Itertools; use nostr_sdk::prelude::*; use rnglib::{Language, RNG}; @@ -25,13 +24,11 @@ pub async fn nip96_upload(client: &Client, file: Vec) -> anyhow::Result u64 { - let pubkeys: Vec<&PublicKey> = tags.public_keys().unique_by(|&pubkey| pubkey).collect(); +pub fn room_hash(event: &Event) -> u64 { + let pubkeys: Vec<&PublicKey> = event.tags.public_keys().unique().collect(); let mut hasher = DefaultHasher::new(); - // Generate unique hash pubkeys.hash(&mut hasher); - hasher.finish() } @@ -49,53 +46,3 @@ where a == b } - -pub fn shorted_public_key(public_key: PublicKey) -> String { - let pk = public_key.to_string(); - format!("{}:{}", &pk[0..4], &pk[pk.len() - 4..]) -} - -pub fn message_ago(time: Timestamp) -> String { - let now = Local::now(); - let input_time = Local.timestamp_opt(time.as_u64() as i64, 0).unwrap(); - let diff = (now - input_time).num_hours(); - - if diff < 24 { - let duration = now.signed_duration_since(input_time); - - if duration.num_seconds() < 60 { - "now".to_string() - } else if duration.num_minutes() == 1 { - "1m".to_string() - } else if duration.num_minutes() < 60 { - format!("{}m", duration.num_minutes()) - } else if duration.num_hours() == 1 { - "1h".to_string() - } else if duration.num_hours() < 24 { - format!("{}h", duration.num_hours()) - } else if duration.num_days() == 1 { - "1d".to_string() - } else { - format!("{}d", duration.num_days()) - } - } else { - input_time.format("%b %d").to_string() - } -} - -pub fn message_time(time: Timestamp) -> String { - let now = Local::now(); - let input_time = Local.timestamp_opt(time.as_u64() as i64, 0).unwrap(); - - if input_time.day() == now.day() { - format!("Today at {}", input_time.format("%H:%M %p")) - } else if input_time.day() == now.day() - 1 { - format!("Yesterday at {}", input_time.format("%H:%M %p")) - } else { - format!( - "{}, {}", - input_time.format("%d/%m/%y"), - input_time.format("%H:%M %p") - ) - } -}