diff options
author | 2025-05-10 02:31:40 +0100 | |
---|---|---|
committer | 2025-05-10 02:31:40 +0100 | |
commit | cf2ffc5ecb2eefbb425a4d05583d3e4d9645bf2b (patch) | |
tree | ad945d8311750cb773d289ddfacf0dd67b962304 | |
parent | edd38ee4a7164170e9e456a92be286609062424c (diff) | |
download | macaw-web-cf2ffc5ecb2eefbb425a4d05583d3e4d9645bf2b.tar.gz macaw-web-cf2ffc5ecb2eefbb425a4d05583d3e4d9645bf2b.tar.bz2 macaw-web-cf2ffc5ecb2eefbb425a4d05583d3e4d9645bf2b.zip |
feat: presences and presence icons
Diffstat (limited to '')
-rw-r--r-- | assets/icons/available16color.svg | 3 | ||||
-rw-r--r-- | assets/icons/away16color.svg | 2 | ||||
-rw-r--r-- | assets/icons/chat16color.svg | 10 | ||||
-rw-r--r-- | assets/icons/dnd16color.svg | 2 | ||||
-rw-r--r-- | assets/icons/xa16color.svg | 10 | ||||
-rw-r--r-- | assets/style.scss | 15 | ||||
-rw-r--r-- | src/lib.rs | 217 |
7 files changed, 231 insertions, 28 deletions
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 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<circle cx="8" cy="8" r="6" fill="#87EE23" stroke="#66BF10" stroke-width="2"/> +</svg> 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 @@ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> <g clip-path="url(#clip0_204_583)"> -<path d="M12.3949 3.68876C14.4905 5.78437 14.3985 9.52573 11.8703 12.0539C9.3422 14.582 5.60084 14.674 3.50523 12.5784C2.7029 11.7761 2.22277 10.747 2.07449 9.63325C2.10824 9.63849 2.14189 9.64327 2.17541 9.64762C2.83698 9.73336 3.54898 9.66529 4.21793 9.51478C5.52363 9.22102 6.93329 8.5485 7.73555 7.67748C8.50721 6.83967 9.11302 5.50754 9.35486 4.24412C9.47772 3.60222 9.51846 2.91991 9.40702 2.28141C9.4053 2.27156 9.40354 2.2617 9.40175 2.25184C10.5339 2.39262 11.5812 2.87506 12.3949 3.68876Z" fill="#FFCE07" stroke="black" stroke-width="2"/> +<path d="M3.50535 12.5786C2.70294 11.7762 2.22417 10.7466 2.07594 9.63274C2.10924 9.6379 2.1423 9.64365 2.17538 9.64793C2.83692 9.73367 3.54906 9.66584 4.21798 9.51535C5.52368 9.22158 6.9333 8.54817 7.73556 7.67715C8.50707 6.83941 9.11299 5.50773 9.35486 4.2445C9.47773 3.60261 9.51808 2.91981 9.40665 2.28132C9.40504 2.27208 9.40281 2.26294 9.40112 2.25369C10.5335 2.39442 11.5815 2.87482 12.3953 3.68862C14.4907 5.78422 14.3984 9.52565 11.8705 12.0538C9.34237 14.5819 5.60098 14.674 3.50535 12.5786Z" fill="#FFCE07" stroke="#D0A700" stroke-width="2"/> </g> <defs> <clipPath id="clip0_204_583"> 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 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_214_198)"> +<path d="M2.32171 6.75399C2.68825 5.71497 3.48076 4.5717 5.03364 3.5225L5.3547 3.31455C6.12876 2.83032 7.54957 2.29008 9.04407 2.20066C10.4342 2.11758 11.7523 2.42625 12.7153 3.37165L12.9029 3.56876C13.9254 4.72163 14.072 6.07691 13.6105 7.33885C13.1668 8.55207 12.1506 9.68103 10.7797 10.3505L10.5014 10.4785C8.79928 11.2069 7.43339 11.1579 6.19304 11.2025L5.12138 11.2413L5.23485 12.3081C5.2644 12.5851 5.38057 12.8882 5.48855 13.128C5.52044 13.1988 5.5553 13.2722 5.59296 13.3471C5.19706 13.1688 4.81599 12.9476 4.45863 12.6893C3.44189 11.9544 2.68078 10.9727 2.32417 9.99369L2.25848 9.79823C1.99751 8.95405 1.92678 7.87353 2.32171 6.75399Z" fill="#87EE23" stroke="#66BF10" stroke-width="2"/> +</g> +<defs> +<clipPath id="clip0_214_198"> +<rect width="16" height="16" fill="white"/> +</clipPath> +</defs> +</svg> 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 @@ <svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> -<path d="M6 14L2 10V6L6 2H10L14 6V10L10 14H6Z" fill="#C1173C" stroke="black" stroke-width="2"/> +<path d="M6 14L2 10V6L6 2H10L14 6V10L10 14H6Z" fill="#C1173C" stroke="#8B0C28" stroke-width="2"/> </svg> 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 @@ +<svg width="16" height="16" viewBox="0 0 16 16" fill="none" xmlns="http://www.w3.org/2000/svg"> +<g clip-path="url(#clip0_214_392)"> +<path d="M3.50535 12.5786C2.70294 11.7762 2.22417 10.7466 2.07594 9.63274C2.10924 9.6379 2.1423 9.64365 2.17538 9.64793C2.83692 9.73367 3.54906 9.66584 4.21798 9.51535C5.52368 9.22158 6.9333 8.54817 7.73556 7.67715C8.50707 6.83941 9.11299 5.50773 9.35486 4.2445C9.47773 3.60261 9.51808 2.91981 9.40665 2.28132C9.40504 2.27208 9.40281 2.26294 9.40112 2.25369C10.5335 2.39442 11.5815 2.87482 12.3953 3.68862C14.4907 5.78422 14.3984 9.52565 11.8705 12.0538C9.34237 14.5819 5.60098 14.674 3.50535 12.5786Z" fill="#F99E36" stroke="#D06F00" stroke-width="2"/> +</g> +<defs> +<clipPath id="clip0_214_392"> +<rect width="16" height="16" fill="white"/> +</clipPath> +</defs> +</svg> 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 */ @@ -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<JID, RwSignal<Presences>>, +} + +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<Presences> { + 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<String, Presence> +} + +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<User>) -> impl IntoView { + let avatar = LocalResource::new(move || get_avatar(user)); + let user_presences: Store<UserPresences> = 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! { + <Transition fallback=|| view! { <img class="avatar" src=NO_AVATAR /> } > + <div class="avatar-with-presence"> + <img class="avatar" src=move || avatar.read().as_deref().map(|avatar| avatar.clone()).unwrap_or_default() /> + {move || if let Some(icon) = show_icon() { + view!{ + <IconComponent icon=icon class:presence-show-icon=true /> + }.into_any() + } else { + view! {}.into_any() + }} + </div> + </Transition> + } +} + #[component] pub fn ChatViewHeader(chat: MacawChat) -> impl IntoView { let chat_user = <ArcStore<filamento::user::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! { <div class="chat-view-header panel"> - <Transition fallback=|| view! { <img class="avatar" src=NO_AVATAR /> } > - <img class="avatar" src=move || avatar.read().as_deref().map(|avatar| avatar.clone()).unwrap_or_default() /> - </Transition> + <AvatarWithPresence user=chat_user /> <div class="user-info"> <h2 class="name">{name}</h2> <h3>{jid}</h3> @@ -911,6 +1073,9 @@ pub enum Icon { Reply24, Sending16, Sent16, + Chat16Color, + Xa16Color, + Available16Color, } pub const ICONS_SRC: &str = "/assets/icons/"; @@ -936,6 +1101,9 @@ impl Icon { Icon::Reply24 => format!("{}reply24.svg", ICONS_SRC), Icon::Sending16 => format!("{}sending16.svg", ICONS_SRC), Icon::Sent16 => format!("{}sent16.svg", ICONS_SRC), + Icon::Chat16Color => format!("{}chat16color.svg", ICONS_SRC), + Icon::Xa16Color => format!("{}xa16color.svg", ICONS_SRC), + Icon::Available16Color => format!("{}available16color.svg", ICONS_SRC), } } @@ -959,6 +1127,9 @@ impl Icon { Icon::Reply24 => 24, Icon::Sending16 => 16, Icon::Sent16 => 16, + Icon::Chat16Color => 16, + Icon::Xa16Color => 16, + Icon::Available16Color => 16, } } } @@ -1588,7 +1759,6 @@ fn RosterListItem(contact: MacawContact) -> impl IntoView { let contact_contact: Store<Contact> = contact.contact; let contact_user: Store<User> = <ArcStore<filamento::user::User> as Clone>::clone(&contact.user).into(); - let avatar = LocalResource::new(move || get_avatar(contact_user)); let name = move || get_name(contact_user); let open_chats: Store<OpenChatsPanel> = @@ -1628,9 +1798,7 @@ fn RosterListItem(contact: MacawContact) -> impl IntoView { view! { <div class="roster-list-item" class:open=move || open() class:focused=move || focused() on:click=open_chat> - <Transition fallback=|| view! { <img class="avatar" src=NO_AVATAR /> } > - <img class="avatar" src=move || avatar.read().as_deref().map(|avatar| avatar.clone()).unwrap_or_default() /> - </Transition> + <AvatarWithPresence user=contact_user /> <div class="item-info"> <h3>{name}</h3> </div> @@ -1702,7 +1870,6 @@ fn ChatsListItem(chat: MacawChat, message: MacawMessage) -> impl IntoView { let chat_chat: Store<Chat> = <ArcStore<Chat> as Clone>::clone(&chat.chat).into(); let chat_user: Store<User> = <ArcStore<filamento::user::User> as Clone>::clone(&chat.user).into(); - let avatar = LocalResource::new(move || get_avatar(chat_user)); let name = move || get_name(chat_user); // TODO: store fine-grained reactivity @@ -1736,9 +1903,7 @@ fn ChatsListItem(chat: MacawChat, message: MacawMessage) -> impl IntoView { view! { <div class="chats-list-item" class:open=move || open() class:focused=move || focused() on:click=open_chat> - <Transition fallback=|| view! { <img class="avatar" src=NO_AVATAR /> } > - <img class="avatar" src=move || avatar.read().as_deref().map(|avatar| avatar.clone()).unwrap_or_default() /> - </Transition> + <AvatarWithPresence user=chat_user /> <div class="item-info"> <h3>{name}</h3> <p>{latest_message_body}</p> |