summaryrefslogtreecommitdiffstats
path: root/src/lib.rs
diff options
context:
space:
mode:
authorLibravatar cel 🌸 <cel@bunny.garden>2025-05-10 02:31:40 +0100
committerLibravatar cel 🌸 <cel@bunny.garden>2025-05-10 02:31:40 +0100
commitcf2ffc5ecb2eefbb425a4d05583d3e4d9645bf2b (patch)
treead945d8311750cb773d289ddfacf0dd67b962304 /src/lib.rs
parentedd38ee4a7164170e9e456a92be286609062424c (diff)
downloadmacaw-web-cf2ffc5ecb2eefbb425a4d05583d3e4d9645bf2b.tar.gz
macaw-web-cf2ffc5ecb2eefbb425a4d05583d3e4d9645bf2b.tar.bz2
macaw-web-cf2ffc5ecb2eefbb425a4d05583d3e4d9645bf2b.zip
feat: presences and presence icons
Diffstat (limited to 'src/lib.rs')
-rw-r--r--src/lib.rs217
1 files changed, 191 insertions, 26 deletions
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<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>