summaryrefslogtreecommitdiffstats
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
parentedd38ee4a7164170e9e456a92be286609062424c (diff)
downloadmacaw-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.svg3
-rw-r--r--assets/icons/away16color.svg2
-rw-r--r--assets/icons/chat16color.svg10
-rw-r--r--assets/icons/dnd16color.svg2
-rw-r--r--assets/icons/xa16color.svg10
-rw-r--r--assets/style.scss15
-rw-r--r--src/lib.rs217
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 */
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>