From cf2ffc5ecb2eefbb425a4d05583d3e4d9645bf2b Mon Sep 17 00:00:00 2001 From: cel 🌸 Date: Sat, 10 May 2025 02:31:40 +0100 Subject: feat: presences and presence icons --- assets/icons/available16color.svg | 3 + assets/icons/away16color.svg | 2 +- assets/icons/chat16color.svg | 10 ++ assets/icons/dnd16color.svg | 2 +- assets/icons/xa16color.svg | 10 ++ assets/style.scss | 15 +++ src/lib.rs | 217 +++++++++++++++++++++++++++++++++----- 7 files changed, 231 insertions(+), 28 deletions(-) create mode 100644 assets/icons/available16color.svg create mode 100644 assets/icons/chat16color.svg create mode 100644 assets/icons/xa16color.svg diff --git a/assets/icons/available16color.svg b/assets/icons/available16color.svg new file mode 100644 index 0000000..e54c2f7 --- /dev/null +++ b/assets/icons/available16color.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/icons/away16color.svg b/assets/icons/away16color.svg index e35da57..8803c51 100644 --- a/assets/icons/away16color.svg +++ b/assets/icons/away16color.svg @@ -1,6 +1,6 @@ - + diff --git a/assets/icons/chat16color.svg b/assets/icons/chat16color.svg new file mode 100644 index 0000000..d4a2479 --- /dev/null +++ b/assets/icons/chat16color.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/icons/dnd16color.svg b/assets/icons/dnd16color.svg index e69cbe3..8e18fae 100644 --- a/assets/icons/dnd16color.svg +++ b/assets/icons/dnd16color.svg @@ -1,3 +1,3 @@ - + diff --git a/assets/icons/xa16color.svg b/assets/icons/xa16color.svg new file mode 100644 index 0000000..1e3643e --- /dev/null +++ b/assets/icons/xa16color.svg @@ -0,0 +1,10 @@ + + + + + + + + + + diff --git a/assets/style.scss b/assets/style.scss index 14909c5..b03393c 100644 --- a/assets/style.scss +++ b/assets/style.scss @@ -1,4 +1,8 @@ /* App-wide styling */ +img { + display: block; +} + body { max-width: 100vw; max-height: 100vh; @@ -163,6 +167,7 @@ p { .chats-list-item, .roster-list-item, { display: flex; + align-items: start; gap: 8px; border-radius: 1em; padding: 0.5em; @@ -456,6 +461,16 @@ p { padding: 4px 8px; } +.avatar-with-presence { + position: relative; +} + +.avatar-with-presence>.presence-show-icon { + position: absolute; + bottom: 0; + right: 0; +} + /* font-families */ /* thai */ diff --git a/src/lib.rs b/src/lib.rs index 92cbe1a..505a97d 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -14,16 +14,7 @@ use std::{ use base64::{Engine, prelude::BASE64_STANDARD}; use chrono::{NaiveDateTime, TimeDelta}; use filamento::{ - UpdateMessage, - chat::{Body, Chat, ChatStoreFields, Delivery, Message, MessageStoreFields}, - db::Db, - error::{CommandError, ConnectionError, DatabaseError}, - files::FileStore, - files::FilesMem, - files::FilesOPFS, - files::opfs::OPFSError, - roster::{Contact, ContactStoreFields}, - user::{User, UserStoreFields}, + chat::{Body, Chat, ChatStoreFields, Delivery, Message, MessageStoreFields}, db::Db, error::{CommandError, ConnectionError, DatabaseError}, files::{opfs::OPFSError, FileStore, FilesMem, FilesOPFS}, presence::{Presence, PresenceType, Show}, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}, UpdateMessage }; use futures::stream::StreamExt; use indexmap::IndexMap; @@ -204,8 +195,9 @@ fn LoginPage( } }; + let remember_me = remember_me.get_untracked(); // initialise the client - let db = if remember_me.get_untracked() { + let db = if remember_me { debug!("creating db in opfs"); Db::create_connect_and_migrate(jid.as_bare().to_string()) .await @@ -214,7 +206,7 @@ fn LoginPage( debug!("creating db in memory"); Db::create_connect_and_migrate_memory().await.unwrap() }; - let files = if remember_me.get_untracked() { + let files = if remember_me { let opfs = FilesOPFS::new(jid.as_bare().to_string()).await; match opfs { Ok(f) => Files::Opfs(f), @@ -490,6 +482,119 @@ impl OpenChatsPanel { // } } +#[derive(Store)] +pub struct UserPresences { + #[store(key: JID = |(jid, _)| jid.clone())] + user_presences: HashMap>, +} + +impl UserPresences { + pub fn clear(&mut self) { + for (_user, presences) in &mut self.user_presences { + presences.set(Presences::new()) + } + } + + // TODO: should be a bare jid + pub fn get_user_presences(&mut self, user: &JID) -> RwSignal { + if let Some(presences) = self.user_presences.get(user) { + *presences + } else { + let presences = Presences::new(); + let signal = RwSignal::new(presences); + self.user_presences.insert(user.clone(), signal); + signal + } + } +} + +impl UserPresences { + pub fn new() -> Self { + Self { + user_presences: HashMap::new(), + } + } +} + +pub struct Presences { + /// presences are sorted by time, first by type, then by last activity. + presences: IndexMap +} + +impl Presences { + pub fn new() -> Self { + Self { + presences: IndexMap::new(), + } + } + + /// gets the highest priority presence + pub fn presence(&self) -> Option<(String, Presence)> { + if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Online(online) = &presence.presence { + online.show == Some(Show::DoNotDisturb) + } else { + false + }).next() { + return Some((resource.clone(), presence.clone())) + } + if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Online(online) = &presence.presence { + online.show == Some(Show::Chat) + } else { + false + }).next() { + return Some((resource.clone(), presence.clone())) + } + if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Online(online) = &presence.presence { + online.show == None + } else { + false + }).next() { + return Some((resource.clone(), presence.clone())) + } + if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Online(online) = &presence.presence { + online.show == Some(Show::Away) + } else { + false + }).next() { + return Some((resource.clone(), presence.clone())) + } + if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Online(online) = &presence.presence { + online.show == Some(Show::ExtendedAway) + } else { + false + }).next() { + return Some((resource.clone(), presence.clone())) + } + if let Some((resource, presence)) = self.presences.iter().filter(|(_resource, presence)| if let PresenceType::Offline(_offline) = &presence.presence { + true + } else { + false + }).next() { + return Some((resource.clone(), presence.clone())) + } else { + None + } + } + + pub fn update_presence(&mut self, resource: String, presence: Presence) { + let index = match self.presences.binary_search_by(|_, existing_presence| { + presence.timestamp + .cmp( + &existing_presence.timestamp + ) + }) { + Ok(i) => i, + Err(i) => i, + }; + self.presences.insert_before( + // TODO: check if this logic is correct + index, + resource, + presence, + ); + } +} + #[component] fn Macaw( // TODO: logout @@ -515,6 +620,9 @@ fn Macaw( let open_chats = Store::new(OpenChatsPanel::default()); provide_context(open_chats); + let user_presences = Store::new(UserPresences::new()); + provide_context(user_presences); + // TODO: get cached contacts on login before getting the updated contacts OnceResource::new(async move { @@ -532,7 +640,10 @@ fn Macaw( .collect(); roster.contacts().set(contacts); } - UpdateMessage::Offline(offline) => {} + UpdateMessage::Offline(offline) => { + // when offline, will no longer receive updated user presences, consider everybody offline. + user_presences.write().clear(); + } UpdateMessage::RosterUpdate(contact, user) => { roster.contacts().update(|roster| { if let Some(macaw_contact) = roster.get_mut(&contact.user_jid) { @@ -549,7 +660,20 @@ fn Macaw( roster.remove(&jid); }); } - UpdateMessage::Presence { from, presence } => {} + UpdateMessage::Presence { from, presence } => { + let bare_jid = from.as_bare(); + if let Some(presences) = user_presences.read().user_presences.get(&bare_jid) { + if let Some(resource) = from.resourcepart { + presences.write().update_presence(resource, presence); + } + } else { + if let Some(resource) = from.resourcepart { + let mut presences = Presences::new(); + presences.update_presence(resource, presence); + user_presences.write().user_presences.insert(bare_jid, RwSignal::new(presences)); + } + } + } UpdateMessage::Message { to, from, message } => { debug!("before got message"); let new_message = MacawMessage::got_message_and_user(message, from); @@ -707,18 +831,56 @@ pub fn OpenChatView(chat: MacawChat) -> impl IntoView { } } +pub fn show_to_icon(show: Show) -> Icon { + match show { + Show::Away => Icon::Away16Color, + Show::Chat => Icon::Chat16Color, + Show::DoNotDisturb => Icon::Dnd16Color, + Show::ExtendedAway => Icon::Xa16Color, + } +} + +#[component] +pub fn AvatarWithPresence(user: Store) -> impl IntoView { + let avatar = LocalResource::new(move || get_avatar(user)); + let user_presences: Store = use_context().expect("no user presences in context"); + let presence = move || user_presences.write().get_user_presences(&user.read().jid).read().presence(); + let show_icon = move || presence().map(|(_, presence)| { + match presence.presence { + PresenceType::Online(online) => if let Some(show) = online.show { + Some(show_to_icon(show)) + } else { + Some(Icon::Available16Color) + }, + PresenceType::Offline(offline) => None, + } + }).unwrap_or_default(); + + view! { + } > +
+ + {move || if let Some(icon) = show_icon() { + view!{ + + }.into_any() + } else { + view! {}.into_any() + }} +
+ + } +} + #[component] pub fn ChatViewHeader(chat: MacawChat) -> impl IntoView { let chat_user = as Clone>::clone(&chat.user).into(); - let avatar = LocalResource::new(move || get_avatar(chat_user)); let name = move || get_name(chat_user); let jid = move || chat_user.jid().read().to_string(); view! {
- } > - - +