diff options
| author | 2025-05-10 02:31:40 +0100 | |
|---|---|---|
| committer | 2025-05-10 02:31:40 +0100 | |
| commit | cf2ffc5ecb2eefbb425a4d05583d3e4d9645bf2b (patch) | |
| tree | ad945d8311750cb773d289ddfacf0dd67b962304 /src | |
| 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 'src')
| -rw-r--r-- | src/lib.rs | 217 | 
1 files changed, 191 insertions, 26 deletions
@@ -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>  | 
