summaryrefslogtreecommitdiffstats
path: root/src/components
diff options
context:
space:
mode:
Diffstat (limited to 'src/components')
-rw-r--r--src/components/avatar.rs36
-rw-r--r--src/components/chat_header.rs23
-rw-r--r--src/components/chats_list.rs109
-rw-r--r--src/components/chats_list/chats_list_item.rs66
-rw-r--r--src/components/icon.rs57
-rw-r--r--src/components/message.rs52
-rw-r--r--src/components/message_composer.rs97
-rw-r--r--src/components/message_history_buffer.rs176
-rw-r--r--src/components/mod.rs13
-rw-r--r--src/components/modal.rs16
-rw-r--r--src/components/new_chat.rs123
-rw-r--r--src/components/overlay.rs16
-rw-r--r--src/components/personal_status.rs178
-rw-r--r--src/components/roster_list.rs58
-rw-r--r--src/components/roster_list/contact_request_manager.rs198
-rw-r--r--src/components/roster_list/roster_list_item.rs62
-rw-r--r--src/components/sidebar.rs176
17 files changed, 1456 insertions, 0 deletions
diff --git a/src/components/avatar.rs b/src/components/avatar.rs
new file mode 100644
index 0000000..9265ef7
--- /dev/null
+++ b/src/components/avatar.rs
@@ -0,0 +1,36 @@
+use filamento::{presence::PresenceType, user::User};
+use leptos::prelude::*;
+use reactive_stores::Store;
+
+use crate::{components::icon::{show_to_icon, IconComponent}, icon::Icon, user::get_avatar, user_presences::UserPresences};
+
+#[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! {
+ <div class="avatar-with-presence">
+ <img class="avatar" src=move || avatar.get() />
+ {move || if let Some(icon) = show_icon() {
+ view!{
+ <IconComponent icon=icon class:presence-show-icon=true />
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }}
+ </div>
+ }
+}
+
diff --git a/src/components/chat_header.rs b/src/components/chat_header.rs
new file mode 100644
index 0000000..208e7f6
--- /dev/null
+++ b/src/components/chat_header.rs
@@ -0,0 +1,23 @@
+use filamento::user::UserStoreFields;
+use leptos::prelude::*;
+use reactive_stores::ArcStore;
+
+use crate::{chat::MacawChat, components::avatar::AvatarWithPresence, user::get_name};
+
+#[component]
+pub fn ChatViewHeader(chat: MacawChat) -> impl IntoView {
+ let chat_user = <ArcStore<filamento::user::User> as Clone>::clone(&chat.user).into();
+ let name = move || get_name(chat_user, true);
+ let jid = move || chat_user.jid().read().to_string();
+
+ view! {
+ <div class="chat-view-header panel">
+ <AvatarWithPresence user=chat_user />
+ <div class="user-info">
+ <h2 class="name">{name}</h2>
+ <h3>{jid}</h3>
+ </div>
+ </div>
+ }
+}
+
diff --git a/src/components/chats_list.rs b/src/components/chats_list.rs
new file mode 100644
index 0000000..b8cf34c
--- /dev/null
+++ b/src/components/chats_list.rs
@@ -0,0 +1,109 @@
+use chats_list_item::ChatsListItem;
+use indexmap::IndexMap;
+use jid::BareJID;
+use leptos::prelude::*;
+use tracing::debug;
+
+use crate::{chat::MacawChat, client::Client, components::{icon::IconComponent, new_chat::NewChatWidget, overlay::Overlay}, icon::Icon, message::MacawMessage, message_subscriptions::MessageSubscriptions};
+
+mod chats_list_item;
+
+#[component]
+pub fn ChatsList() -> impl IntoView {
+ let (chats, set_chats) = signal(IndexMap::new());
+
+ let load_chats = LocalResource::new(move || async move {
+ let client = use_context::<Client>().expect("client not in context");
+ let chats = client
+ .get_chats_ordered_with_latest_messages_and_users()
+ .await
+ .map_err(|e| e.to_string());
+ match chats {
+ Ok(c) => {
+ let chats = c
+ .into_iter()
+ .map(|((chat, chat_user), (message, message_user))| {
+ (
+ chat.correspondent.clone(),
+ (
+ MacawChat::got_chat_and_user(chat, chat_user),
+ MacawMessage::got_message_and_user(message, message_user),
+ ),
+ )
+ })
+ .collect::<IndexMap<BareJID, _>>();
+ set_chats.set(chats);
+ }
+ Err(_) => {
+ // TODO: show error message at top of chats list
+ }
+ }
+ });
+
+ let (open_new_chat, set_open_new_chat) = signal(false);
+
+ // TODO: filter new messages signal
+ let new_messages_signal: RwSignal<MessageSubscriptions> = use_context().unwrap();
+ let (sub_id, set_sub_id) = signal(None);
+ let _load_new_messages = LocalResource::new(move || async move {
+ load_chats.await;
+ let (sub_id, mut new_messages) = new_messages_signal.write().subscribe_all();
+ set_sub_id.set(Some(sub_id));
+ while let Some((to, new_message)) = new_messages.recv().await {
+ debug!("got new message in let");
+ let mut chats = set_chats.write();
+ if let Some((chat, _latest_message)) = chats.shift_remove(&to) {
+ // TODO: check if new message is actually latest message
+ debug!("chat existed");
+ debug!("new message: {}", new_message.message.read().body.body);
+ chats.insert_before(0, to, (chat.clone(), new_message));
+ debug!("done setting");
+ } else {
+ debug!("the chat didn't exist");
+ let client = use_context::<Client>().expect("client not in context");
+ let chat = client.get_chat(to.clone()).await.unwrap();
+ let user = client.get_user(to.clone()).await.unwrap();
+ debug!("before got chat");
+ let chat = MacawChat::got_chat_and_user(chat, user);
+ debug!("after got chat");
+ chats.insert_before(0, to, (chat, new_message));
+ debug!("done setting");
+ }
+ }
+ debug!("set the new message");
+ });
+ on_cleanup(move || {
+ if let Some(sub_id) = sub_id.get() {
+ new_messages_signal.write().unsubscribe_all(sub_id);
+ }
+ });
+
+ view! {
+ <div class="chats-list panel">
+ // TODO: update icon, tooltip on hover.
+ <div class="header">
+ <h2>Chats</h2>
+ <div class="new-chat header-icon" class:open=open_new_chat >
+ <IconComponent icon=Icon::NewBubble24 on:click=move |_| set_open_new_chat.update(|state| *state = !*state)/>
+ {move || {
+ if *open_new_chat.read() {
+ view! {
+ <Overlay set_open=set_open_new_chat>
+ <NewChatWidget set_open_new_chat />
+ </Overlay>
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ </div>
+ <div class="chats-list-chats">
+ <For each=move || chats.get() key=|chat| chat.1.1.message.read().id let(chat)>
+ <ChatsListItem chat=chat.1.0 message=chat.1.1 />
+ </For>
+ </div>
+ </div>
+ }
+}
+
diff --git a/src/components/chats_list/chats_list_item.rs b/src/components/chats_list/chats_list_item.rs
new file mode 100644
index 0000000..191f163
--- /dev/null
+++ b/src/components/chats_list/chats_list_item.rs
@@ -0,0 +1,66 @@
+use std::ops::Deref;
+
+use chrono::Local;
+use filamento::{chat::{Chat, ChatStoreFields, Message, MessageStoreFields}, user::User};
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+use tracing::debug;
+
+use crate::{chat::MacawChat, components::{avatar::AvatarWithPresence, sidebar::Open}, message::MacawMessage, open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}, user::get_name};
+
+#[component]
+pub 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 message_message: Store<Message> = <ArcStore<Message> as Clone>::clone(&message.message).into();
+ let name = move || get_name(chat_user, true);
+
+ // TODO: store fine-grained reactivity
+ let latest_message_body = move || message_message.body().get().body;
+ let open_chats: Store<OpenChatsPanel> =
+ use_context().expect("no open chats panel store in context");
+
+ let open_chat = move |_| {
+ debug!("opening chat");
+ open_chats.update(|open_chats| open_chats.open(chat.clone()));
+ };
+
+ let open = move || {
+ if let Some(open_chat) = &*open_chats.chat_view().read() {
+ debug!("got open chat: {:?}", open_chat);
+ if *open_chat == *chat_chat.correspondent().read() {
+ return Open::Focused;
+ }
+ }
+ if let Some(_backgrounded_chat) = open_chats
+ .chats()
+ .read()
+ .get(chat_chat.correspondent().read().deref())
+ {
+ return Open::Open;
+ }
+ Open::Closed
+ };
+ let focused = move || open().is_focused();
+ let open = move || open().is_open();
+
+ let date = move || message_message.timestamp().read().naive_local();
+ let now = move || Local::now().naive_local();
+ let timeinfo = move || if date().date() == now().date() {
+ // TODO: localisation/config
+ date().time().format("%H:%M").to_string()
+ } else {
+ date().date().format("%d/%m").to_string()
+ };
+
+ view! {
+ <div class="chats-list-item" class:open=move || open() class:focused=move || focused() on:click=open_chat>
+ <AvatarWithPresence user=chat_user />
+ <div class="item-info">
+ <div class="main-info"><p class="name">{name}</p><p class="timestamp">{timeinfo}</p></div>
+ <div class="sub-info"><p class="message-preview">{latest_message_body}</p><p><!-- "TODO: delivery or unread state" --></p></div>
+ </div>
+ </div>
+ }
+}
diff --git a/src/components/icon.rs b/src/components/icon.rs
new file mode 100644
index 0000000..7eaa52f
--- /dev/null
+++ b/src/components/icon.rs
@@ -0,0 +1,57 @@
+use leptos::prelude::*;
+use filamento::{chat::Delivery, presence::Show};
+
+use crate::icon::Icon;
+
+// TODO: rename
+#[component]
+pub fn IconComponent(icon: Icon) -> impl IntoView {
+ view! {
+ <img class:light=icon.light() class:icon=true style=move || format!("height: {}px; width: {}px", icon.size(), icon.size()) src=move || icon.src() />
+ }
+}
+
+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 Delivery(delivery: Delivery) -> impl IntoView {
+ match delivery {
+ // TODO: proper icon coloring/theming
+ Delivery::Sending => {
+ view! { <IconComponent class:visible=true class:light=true icon=Icon::Sending16 /> }
+ .into_any()
+ }
+ Delivery::Written => {
+ view! { <IconComponent class:light=true icon=Icon::Sent16 /> }.into_any()
+ }
+ // TODO: message receipts
+ // Delivery::Written => view! {}.into_any(),
+ Delivery::Sent => view! { <IconComponent class:light=true icon=Icon::Sent16 /> }.into_any(),
+ Delivery::Delivered => {
+ view! { <IconComponent class:light=true icon=Icon::Delivered16 /> }.into_any()
+ }
+ // TODO: check if there is also the icon class
+ Delivery::Read => {
+ view! { <IconComponent class:light=true class:read=true icon=Icon::Delivered16 /> }
+ .into_any()
+ }
+ Delivery::Failed => {
+ view! { <IconComponent class:visible=true class:light=true icon=Icon::Error16Color /> }
+ .into_any()
+ }
+ // TODO: queued icon
+ Delivery::Queued => {
+ view! { <IconComponent class:visible=true class:light=true icon=Icon::Sending16 /> }
+ .into_any()
+ }
+ }
+}
+
+
diff --git a/src/components/message.rs b/src/components/message.rs
new file mode 100644
index 0000000..2ae2ef0
--- /dev/null
+++ b/src/components/message.rs
@@ -0,0 +1,52 @@
+use filamento::chat::MessageStoreFields;
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+
+use crate::{message::MacawMessage, user::{get_avatar, get_name, NO_AVATAR}};
+
+use super::icon::Delivery;
+
+#[component]
+pub fn Message(message: MacawMessage, major: bool, r#final: bool) -> impl IntoView {
+ let message_message: Store<filamento::chat::Message> =
+ <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message).into();
+ let message_user = <ArcStore<filamento::user::User> as Clone>::clone(&message.user).into();
+ let avatar = LocalResource::new(move || get_avatar(message_user));
+ let name = move || get_name(message_user, false);
+
+ // TODO: chrono-humanize?
+ // TODO: if final, show delivery not only on hover.
+ // {move || message_message.delivery().read().map(|delivery| delivery.to_string()).unwrap_or_default()}
+ if major {
+ view! {
+ <div class:final=r#final class="chat-message major">
+ <div class="left">
+ <Transition fallback=|| view! { <img class="avatar" src=NO_AVATAR /> } >
+ <img class="avatar" src=move || avatar.get() />
+ </Transition>
+ </div>
+ <div class="middle">
+ <div class="message-info">
+ <div class="message-user-name">{name}</div>
+ <div class="message-timestamp">{move || message_message.timestamp().read().format("%H:%M").to_string()}</div>
+ </div>
+ <div class="message-text">
+ {move || message_message.body().read().body.clone()}
+ </div>
+ </div>
+ <div class="right message-delivery">{move || message_message.delivery().get().map(|delivery| view! { <Delivery class:light=true delivery /> } ) }</div>
+ </div>
+ }.into_any()
+ } else {
+ view! {
+ <div class:final=r#final class="chat-message minor">
+ <div class="left message-timestamp">
+ {move || message_message.timestamp().read().format("%H:%M").to_string()}
+ </div>
+ <div class="middle message-text">{move || message_message.body().read().body.clone()}</div>
+ <div class="right message-delivery">{move || message_message.delivery().get().map(|delivery| view! { <Delivery delivery /> } ) }</div>
+ </div>
+ }.into_any()
+ }
+}
+
diff --git a/src/components/message_composer.rs b/src/components/message_composer.rs
new file mode 100644
index 0000000..3876a5a
--- /dev/null
+++ b/src/components/message_composer.rs
@@ -0,0 +1,97 @@
+use filamento::chat::Body;
+use jid::BareJID;
+use leptos::{html::Div, prelude::*, task::spawn_local};
+
+use crate::client::Client;
+
+#[component]
+pub fn ChatViewMessageComposer(chat: BareJID) -> impl IntoView {
+ let message_input: NodeRef<Div> = NodeRef::new();
+
+ // TODO: load last message draft
+ let new_message = RwSignal::new("".to_string());
+ let client: Client = use_context().expect("no client in context");
+ let client = RwSignal::new(client);
+ let (shift_pressed, set_shift_pressed) = signal(false);
+
+ let send_message = move || {
+ let value = chat.clone();
+ spawn_local(async move {
+ match client
+ .read()
+ .send_message(
+ value,
+ Body {
+ body: new_message.get(),
+ },
+ )
+ .await
+ {
+ Ok(_) => {
+ new_message.set("".to_string());
+ message_input
+ .write()
+ .as_ref()
+ .expect("message input div not mounted")
+ .set_text_content(Some(""));
+ }
+ Err(e) => tracing::error!("message send error: {}", e),
+ }
+ })
+ };
+
+ let _focus = Effect::new(move |_| {
+ if let Some(input) = message_input.get() {
+ let _ = input.focus();
+ // TODO: set the last draft
+ input.set_text_content(Some(""));
+ // input.style("height: 0");
+ // let height = input.scroll_height();
+ // input.style(format!("height: {}px", height));
+ }
+ });
+
+ // let on_input = move |ev: Event| {
+ // // let keyboard_event: KeyboardEvent = ev.try_into().unwrap();
+ // debug!("got input event");
+ // let key= event_target_value(&ev);
+ // new_message.set(key);
+ // debug!("set new message");
+ // };
+ //
+
+ // TODO: placeholder
+ view! {
+ <form
+ class="new-message-composer panel"
+ >
+ <div
+ class="text-box"
+ on:input:target=move |ev| new_message.set(ev.target().text_content().unwrap_or_default())
+ node_ref=message_input
+ contenteditable
+ on:keydown=move |ev| {
+ match ev.key_code() {
+ 16 => set_shift_pressed.set(true),
+ 13 => if !shift_pressed.get() {
+ ev.prevent_default();
+ send_message();
+ }
+ _ => {}
+ // debug!("shift pressed down");
+ }
+ }
+ on:keyup=move |ev| {
+ match ev.key_code() {
+ 16 => set_shift_pressed.set(false),
+ _ => {}
+ // debug!("shift released");
+ }
+ }
+ ></div>
+ // <input hidden type="submit" />
+ </form>
+ }
+}
+
+
diff --git a/src/components/message_history_buffer.rs b/src/components/message_history_buffer.rs
new file mode 100644
index 0000000..36439a8
--- /dev/null
+++ b/src/components/message_history_buffer.rs
@@ -0,0 +1,176 @@
+use chrono::{NaiveDateTime, TimeDelta};
+use filamento::{chat::{Chat, ChatStoreFields, MessageStoreFields}, user::User};
+use indexmap::IndexMap;
+use jid::BareJID;
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+use tracing::{debug, error};
+use uuid::Uuid;
+
+use crate::{chat::MacawChat, client::Client, components::message::Message, message::MacawMessage, message_subscriptions::MessageSubscriptions};
+
+#[component]
+pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView {
+ let (messages, set_messages) = arc_signal(IndexMap::new());
+ let chat_chat: Store<Chat> =
+ <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat).into();
+ let chat_user: Store<User> =
+ <ArcStore<filamento::user::User> as Clone>::clone(&chat.user).into();
+
+ let load_set_messages = set_messages.clone();
+ let load_messages = LocalResource::new(move || {
+ let load_set_messages = load_set_messages.clone();
+ async move {
+ let client = use_context::<Client>().expect("client not in context");
+ let messages = client
+ .get_messages_with_users(chat_chat.correspondent().get())
+ .await
+ .map_err(|e| e.to_string());
+ match messages {
+ Ok(m) => {
+ let messages = m
+ .into_iter()
+ .map(|(message, message_user)| {
+ (
+ message.id,
+ MacawMessage::got_message_and_user(message, message_user),
+ )
+ })
+ .collect::<IndexMap<Uuid, _>>();
+ load_set_messages.set(messages);
+ }
+ Err(err) => {
+ error!("{err}")
+ // TODO: show error message at top of chats list
+ }
+ }
+ }
+ });
+
+ // TODO: filter new messages signal
+ let new_messages_signal: RwSignal<MessageSubscriptions> = use_context().unwrap();
+ let (sub_id, set_sub_id) = signal(None);
+ let load_new_messages_set = set_messages.clone();
+ let _load_new_messages = LocalResource::new(move || {
+ let load_new_messages_set = load_new_messages_set.clone();
+ async move {
+ load_messages.await;
+ let (sub_id, mut new_messages) = new_messages_signal
+ .write()
+ .subscribe_chat(chat_chat.correspondent().get());
+ set_sub_id.set(Some(sub_id));
+ while let Some(new_message) = new_messages.recv().await {
+ debug!("got new message in let message buffer");
+ let mut messages = load_new_messages_set.write();
+ if let Some((_, last)) = messages.last() {
+ if *<ArcStore<filamento::chat::Message> as Clone>::clone(&last.message)
+ .timestamp()
+ .read()
+ < *<ArcStore<filamento::chat::Message> as Clone>::clone(&new_message.message)
+ .timestamp()
+ .read()
+ {
+ messages.insert(
+ <ArcStore<filamento::chat::Message> as Clone>::clone(
+ &new_message.message,
+ )
+ .id()
+ .get(),
+ new_message,
+ );
+ debug!("set the new message in message buffer");
+ } else {
+ let index = match messages.binary_search_by(|_, value| {
+ <ArcStore<filamento::chat::Message> as Clone>::clone(&value.message)
+ .timestamp()
+ .read()
+ .cmp(
+ &<ArcStore<filamento::chat::Message> as Clone>::clone(
+ &new_message.message,
+ )
+ .timestamp()
+ .read(),
+ )
+ }) {
+ Ok(i) => i,
+ Err(i) => i,
+ };
+ messages.insert_before(
+ // TODO: check if this logic is correct
+ index,
+ <ArcStore<filamento::chat::Message> as Clone>::clone(
+ &new_message.message,
+ )
+ .id()
+ .get(),
+ new_message,
+ );
+ debug!("set the new message in message buffer");
+ }
+ } else {
+ messages.insert(
+ <ArcStore<filamento::chat::Message> as Clone>::clone(&new_message.message)
+ .id()
+ .get(),
+ new_message,
+ );
+ debug!("set the new message in message buffer");
+ }
+ }
+ }
+ });
+ on_cleanup(move || {
+ if let Some(sub_id) = sub_id.get() {
+ new_messages_signal
+ .write()
+ .unsubscribe_chat(sub_id, chat_chat.correspondent().get());
+ }
+ });
+
+ let each = move || {
+ let mut last_timestamp = NaiveDateTime::MIN;
+ let mut last_user: Option<BareJID> = None;
+ let mut messages = messages
+ .get()
+ .into_iter()
+ .map(|(id, message)| {
+ let message_timestamp =
+ <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message)
+ .timestamp()
+ .read()
+ .naive_local();
+ // TODO: mark new day
+ // if message_timestamp.date() > last_timestamp.date() {
+ // messages_view = messages_view.push(date(message_timestamp.date()));
+ // }
+ let major = if last_user.as_ref() != Some(&message.message.read().from)
+ || message_timestamp - last_timestamp > TimeDelta::minutes(3)
+ {
+ true
+ } else {
+ false
+ };
+ last_user = Some(
+ <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message)
+ .from()
+ .get(),
+ );
+ last_timestamp = message_timestamp;
+ (id, (message, major, false))
+ })
+ .collect::<Vec<_>>();
+ if let Some((_id, (_, _, last))) = messages.last_mut() {
+ *last = true
+ }
+ messages.into_iter().rev()
+ };
+
+ view! {
+ <div class="messages-buffer">
+ <For each=each key=|message| (message.0, message.1.1, message.1.2) let(message)>
+ <Message message=message.1.0 major=message.1.1 r#final=message.1.2 />
+ </For>
+ </div>
+ }
+}
+
diff --git a/src/components/mod.rs b/src/components/mod.rs
new file mode 100644
index 0000000..879f99e
--- /dev/null
+++ b/src/components/mod.rs
@@ -0,0 +1,13 @@
+pub mod sidebar;
+mod chats_list;
+mod new_chat;
+mod roster_list;
+mod avatar;
+mod message;
+pub mod message_history_buffer;
+pub mod message_composer;
+pub mod chat_header;
+mod overlay;
+pub mod modal;
+pub mod icon;
+mod personal_status;
diff --git a/src/components/modal.rs b/src/components/modal.rs
new file mode 100644
index 0000000..62e1fac
--- /dev/null
+++ b/src/components/modal.rs
@@ -0,0 +1,16 @@
+use leptos::prelude::*;
+use leptos::ev::MouseEvent;
+
+#[component]
+pub fn Modal(on_background_click: impl Fn(MouseEvent) + 'static, children: Children) -> impl IntoView {
+ view! {
+ <div class="modal" on:click=move |e| {
+ if e.current_target() == e.target() {
+ on_background_click(e)
+ }
+ }>
+ {children()}
+ </div>
+ }
+}
+
diff --git a/src/components/new_chat.rs b/src/components/new_chat.rs
new file mode 100644
index 0000000..8047afb
--- /dev/null
+++ b/src/components/new_chat.rs
@@ -0,0 +1,123 @@
+use std::str::FromStr;
+
+use filamento::{chat::Chat, error::{CommandError, DatabaseError}, user::User};
+use jid::{BareJID, JID};
+use leptos::{html::Input, prelude::*};
+use reactive_stores::{ArcStore, Store};
+use thiserror::Error;
+
+use crate::{chat::MacawChat, client::Client, open_chats::OpenChatsPanel, state_store::StateStore, user::MacawUser};
+
+#[derive(Clone, Debug, Error)]
+pub enum NewChatError {
+ #[error("Missing JID")]
+ MissingJID,
+ #[error("Invalid JID: {0}")]
+ InvalidJID(#[from] jid::ParseError),
+ #[error("Database: {0}")]
+ Db(#[from] CommandError<DatabaseError>),
+}
+
+#[component]
+pub fn NewChatWidget(set_open_new_chat: WriteSignal<bool>) -> impl IntoView {
+ let jid = RwSignal::new("".to_string());
+
+ // TODO: compartmentalise into error component, form component...
+ let (error, set_error) = signal(None::<NewChatError>);
+ let error_message = move || {
+ error.with(|error| {
+ if let Some(error) = error {
+ view! { <div class="error">{error.to_string()}</div> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ })
+ };
+ let (new_chat_pending, set_new_chat_pending) = signal(false);
+
+ let open_chats: Store<OpenChatsPanel> =
+ use_context().expect("no open chats panel store in context");
+ let client = use_context::<Client>().expect("client not in context");
+
+ let chat_state_store: StateStore<BareJID, ArcStore<Chat>> =
+ use_context().expect("no chat state store");
+ let user_state_store: StateStore<BareJID, ArcStore<User>> =
+ use_context().expect("no user state store");
+
+ let open_chat = Action::new_local(move |_| {
+ let client = client.clone();
+ async move {
+ set_new_chat_pending.set(true);
+
+ if jid.read_untracked().is_empty() {
+ set_error.set(Some(NewChatError::MissingJID));
+ set_new_chat_pending.set(false);
+ return;
+ }
+
+ let jid = match JID::from_str(&jid.read_untracked()) {
+ // TODO: ability to direct address a resource?
+ Ok(j) => j.to_bare(),
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_new_chat_pending.set(false);
+ return;
+ }
+ };
+
+ let chat_jid = jid;
+ let (chat, user) = match client.get_chat_and_user(chat_jid).await {
+ Ok(c) => c,
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_new_chat_pending.set(false);
+ return;
+ },
+ };
+
+ let chat = {
+ // let user = MacawUser::got_user(user);
+ let user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
+ let user = MacawUser { user };
+ let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat));
+ MacawChat { chat, user }
+ };
+ open_chats.update(|open_chats| open_chats.open(chat.clone()));
+ set_open_new_chat.set(false);
+ }
+ });
+
+ let jid_input = NodeRef::<Input>::new();
+ let _focus = Effect::new(move |_| {
+ if let Some(input) = jid_input.get() {
+ let _ = input.focus();
+ input.set_text_content(Some(""));
+ // input.style("height: 0");
+ // let height = input.scroll_height();
+ // input.style(format!("height: {}px", height));
+ }
+ });
+
+ view! {
+ <div class="new-chat-widget">
+ <form on:submit=move |ev| {
+ ev.prevent_default();
+ open_chat.dispatch(());
+ }>
+ {error_message}
+ <input
+ disabled=new_chat_pending
+ placeholder="JID"
+ type="text"
+ node_ref=jid_input
+ bind:value=jid
+ name="jid"
+ id="jid"
+ autofocus="true"
+ />
+ <input disabled=new_chat_pending class="button" type="submit" value="Start Chat" />
+ </form>
+ </div>
+ }
+}
+
diff --git a/src/components/overlay.rs b/src/components/overlay.rs
new file mode 100644
index 0000000..d4ff1bf
--- /dev/null
+++ b/src/components/overlay.rs
@@ -0,0 +1,16 @@
+use leptos::prelude::*;
+use tracing::debug;
+
+#[component]
+pub fn Overlay(set_open: WriteSignal<bool>, children: Children) -> impl IntoView {
+ view! {
+ <div class="overlay">
+ <div class="overlay-background" on:click=move |_| {
+ debug!("set open to false");
+ set_open.update(|state| *state = false)
+ }></div>
+ <div class="overlay-content">{children()}</div>
+ </div>
+ }
+}
+
diff --git a/src/components/personal_status.rs b/src/components/personal_status.rs
new file mode 100644
index 0000000..f830a1b
--- /dev/null
+++ b/src/components/personal_status.rs
@@ -0,0 +1,178 @@
+use filamento::{presence::{Offline, Online, PresenceType, Show}, user::{User, UserStoreFields}};
+use leptos::{html, prelude::*};
+use reactive_stores::{ArcStore, Store};
+use tracing::{debug, error};
+
+use crate::{client::Client, components::{avatar::AvatarWithPresence, overlay::Overlay}, user::{get_name, MacawUser}, user_presences::UserPresences, views::{macaw::settings::SettingsPage, AppState}};
+
+#[component]
+pub fn PersonalStatus() -> impl IntoView {
+ let user: LocalResource<MacawUser> = use_context().expect("no local user in context");
+
+ let (open, set_open) = signal(false);
+ move || if let Some(user) = user.get() {
+ let user: Store<User> = <ArcStore<filamento::user::User> as Clone>::clone(&(*user.user)).into();
+ view! {
+ <div class="dock-item" class:focused=move || *open.read() on:click=move |_| {
+ debug!("set open to true");
+ set_open.update(|state| *state = !*state)
+ }>
+ <AvatarWithPresence user=user />
+ <div class="dock-pill"></div>
+ </div>
+ {move || {
+ let open = open.get();
+ debug!("open = {:?}", open);
+ if open {
+ view! {
+ <Overlay set_open>
+ <PersonalStatusMenu user set_open/>
+ </Overlay>
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }}}
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+}
+
+#[component]
+pub fn PersonalStatusMenu(user: Store<User>, set_open: WriteSignal<bool>) -> impl IntoView {
+ let set_app: WriteSignal<AppState> = use_context().unwrap();
+ let show_settings: RwSignal<Option<SettingsPage>> = use_context().unwrap();
+ let user_presences: Store<UserPresences> = use_context().expect("no user presence store");
+
+ let client = use_context::<Client>().expect("client not in context");
+ let client1 = client.clone();
+ let (show_value, set_show_value) = signal({
+ let show = match user_presences.write().get_user_presences(&user.jid().read()).write().resource_presence(client.resource.read().clone().unwrap_or_default()).presence {
+ PresenceType::Online(online) => match online.show {
+ Some(s) => match s {
+ Show::Away => 3,
+ Show::Chat => 0,
+ Show::DoNotDisturb => 2,
+ Show::ExtendedAway => 4,
+ },
+ None => 1,
+ },
+ PresenceType::Offline(_offline) => 5,
+ };
+ debug!("initial show = {show}");
+ show
+ });
+
+ let show_select: NodeRef<html::Select> = NodeRef::new();
+
+ let disconnect = Action::new_local(move |()| {
+ let client = client.clone();
+ async move {
+ client.disconnect(Offline::default()).await;
+ }
+ });
+ let set_status = Action::new_local(move |show_value: &i32| {
+ let show_value = show_value.to_owned();
+ let client = client1.clone();
+ async move {
+ if let Err(e) = match show_value {
+ 0 => {
+ if let Ok(r) = client.connect().await {
+ client.resource.set(Some(r))
+ };
+ client.set_status(Online { show: Some(Show::Chat), ..Default::default() }).await
+ },
+ 1 => {
+ if let Ok(r) = client.connect().await {
+ client.resource.set(Some(r))
+ };
+ client.set_status(Online { show: None, ..Default::default() }).await
+ },
+ 2 => {
+ if let Ok(r) = client.connect().await {
+ client.resource.set(Some(r))
+ };
+ client.set_status(Online { show: Some(Show::DoNotDisturb), ..Default::default() }).await
+ },
+ 3 => {
+ if let Ok(r) = client.connect().await {
+ client.resource.set(Some(r))
+ };
+ client.set_status(Online { show: Some(Show::Away), ..Default::default() }).await
+ },
+ 4 => {
+ if let Ok(r) = client.connect().await {
+ client.resource.set(Some(r))
+ };
+ client.set_status(Online { show: Some(Show::ExtendedAway), ..Default::default() }).await
+ },
+ 5 => {
+ if let Ok(_) = client.disconnect(Offline::default()).await {
+ client.resource.set(None)
+ }
+ set_show_value.set(5);
+ return
+ }
+ _ => {
+ error!("invalid availability select");
+ return
+ }
+ } {
+ error!("show set error: {e}");
+ return
+ }
+ set_show_value.set(show_value);
+ }
+ });
+
+ view! {
+ <div class="personal-status-menu menu">
+ <div class="user">
+ <AvatarWithPresence user=user />
+ <div class="user-info">
+ <div class="nick">{move || get_name(user, false)}</div>
+ <div class="jid">{move || user.jid().with(|jid| jid.to_string())}</div>
+ </div>
+ </div>
+ <div class="status-edit">
+ <select
+ node_ref=show_select
+ on:change:target=move |ev| {
+ let show_value = ev.target().value().parse().unwrap();
+ set_status.dispatch(show_value);
+ }
+ prop:show_value=move || show_value.get().to_string()
+ >
+ <option value="0" selected=move || show_value.get_untracked() == 0>Available to Chat</option>
+ <option value="1" selected=move || show_value.get_untracked() == 1>Online</option>
+ <option value="2" selected=move || show_value.get_untracked() == 2>Do not disturb</option>
+ <option value="3" selected=move || show_value.get_untracked() == 3>Away</option>
+ <option value="4" selected=move || show_value.get_untracked() == 4>Extended Away</option>
+ <option value="5" selected=move || show_value.get_untracked() == 5>Offline</option>
+ </select>
+ </div>
+ <hr />
+ <div class="menu-item" on:click=move |_| {
+ show_settings.set(Some(SettingsPage::Profile));
+ set_open.set(false);
+ }>
+ Profile
+ </div>
+ <div class="menu-item" on:click=move |_| {
+ show_settings.set(Some(SettingsPage::Account));
+ set_open.set(false);
+ }>
+ Settings
+ </div>
+ <hr />
+ <div class="menu-item" on:click=move |_| {
+ // TODO: check if client is actually dropped/shutdown eventually
+ disconnect.dispatch(());
+ set_app.set(AppState::LoggedOut)
+ }>
+ Log out
+ </div>
+ </div>
+ }
+}
+
diff --git a/src/components/roster_list.rs b/src/components/roster_list.rs
new file mode 100644
index 0000000..a398ffe
--- /dev/null
+++ b/src/components/roster_list.rs
@@ -0,0 +1,58 @@
+use std::collections::HashSet;
+
+use contact_request_manager::AddContact;
+use jid::BareJID;
+use leptos::prelude::*;
+use reactive_stores::Store;
+use roster_list_item::RosterListItem;
+
+use crate::{components::icon::IconComponent, icon::Icon, roster::{Roster, RosterStoreFields}};
+
+mod contact_request_manager;
+mod roster_list_item;
+
+#[component]
+pub fn RosterList() -> impl IntoView {
+ let requests: ReadSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions in context");
+
+ let roster: Store<Roster> = use_context().expect("no roster in context");
+ let (open_add_contact, set_open_add_contact) = signal(false);
+
+ // TODO: filter new messages signal
+ view! {
+ <div class="roster-list panel">
+ <div class="header">
+ <h2>Roster</h2>
+ <div class="add-contact header-icon" class:open=open_add_contact>
+ <IconComponent icon=Icon::AddContact24 on:click=move |_| set_open_add_contact.update(|state| *state = !*state)/>
+ {move || {
+ if !requests.read().is_empty() {
+ view! {
+ <div class="badge"></div>
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ </div>
+ {move || {
+ if *open_add_contact.read() {
+ view! {
+ <div class="roster-add-contact">
+ <AddContact />
+ </div>
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ <div class="roster-list-roster">
+ <For each=move || roster.contacts().get() key=|contact| contact.0.clone() let(contact)>
+ <RosterListItem contact=contact.1 />
+ </For>
+ </div>
+ </div>
+ }
+}
+
diff --git a/src/components/roster_list/contact_request_manager.rs b/src/components/roster_list/contact_request_manager.rs
new file mode 100644
index 0000000..174e677
--- /dev/null
+++ b/src/components/roster_list/contact_request_manager.rs
@@ -0,0 +1,198 @@
+use std::{collections::HashSet, str::FromStr};
+
+use filamento::{error::{CommandError, SubscribeError}, roster::ContactStoreFields};
+use jid::{BareJID, JID};
+use leptos::{html::Input, prelude::*};
+use reactive_stores::Store;
+use thiserror::Error;
+
+use crate::{client::Client, roster::{Roster, RosterStoreFields}};
+
+#[derive(Clone, Debug, Error)]
+pub enum AddContactError {
+ #[error("Missing JID")]
+ MissingJID,
+ #[error("Invalid JID: {0}")]
+ InvalidJID(#[from] jid::ParseError),
+ #[error("Subscription: {0}")]
+ Db(#[from] CommandError<SubscribeError>),
+}
+
+#[component]
+// TODO: rename
+pub fn AddContact() -> impl IntoView {
+ let requests: ReadSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions in context");
+ let set_requests: WriteSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions write signal in context");
+ let roster: Store<Roster> = use_context().expect("no roster in context");
+
+ let jid = RwSignal::new("".to_string());
+ // TODO: compartmentalise into error component, form component...
+ let (error, set_error) = signal(None::<AddContactError>);
+ let error_message = move || {
+ error.with(|error| {
+ if let Some(error) = error {
+ view! { <div class="error">{error.to_string()}</div> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ })
+ };
+ let (add_contact_pending, set_add_contact_pending) = signal(false);
+
+ let client = use_context::<Client>().expect("client not in context");
+ let client2 = client.clone();
+ let client3 = client.clone();
+ let client4 = client.clone();
+
+ let add_contact= Action::new_local(move |_| {
+ let client = client.clone();
+ async move {
+ set_add_contact_pending.set(true);
+
+ if jid.read_untracked().is_empty() {
+ set_error.set(Some(AddContactError::MissingJID));
+ set_add_contact_pending.set(false);
+ return;
+ }
+
+ let jid = match JID::from_str(&jid.read_untracked()) {
+ Ok(j) => j.to_bare(),
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_add_contact_pending.set(false);
+ return;
+ }
+ };
+
+ let chat_jid = jid;
+ // TODO: more options?
+ match client.buddy_request(chat_jid).await {
+ Ok(c) => c,
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_add_contact_pending.set(false);
+ return;
+ },
+ };
+
+ set_add_contact_pending.set(false);
+ }
+ });
+
+ let jid_input = NodeRef::<Input>::new();
+ let _focus = Effect::new(move |_| {
+ if let Some(input) = jid_input.get() {
+ let _ = input.focus();
+ input.set_text_content(Some(""));
+ // input.style("height: 0");
+ // let height = input.scroll_height();
+ // input.style(format!("height: {}px", height));
+ }
+ });
+
+ let outgoing = move || roster.contacts().get().into_iter().filter(|(jid, contact)| {
+ match *contact.contact.subscription().read() {
+ filamento::roster::Subscription::None => false,
+ filamento::roster::Subscription::PendingOut => true,
+ filamento::roster::Subscription::PendingIn => false,
+ filamento::roster::Subscription::PendingInPendingOut => true,
+ filamento::roster::Subscription::OnlyOut => false,
+ filamento::roster::Subscription::OnlyIn => false,
+ filamento::roster::Subscription::OutPendingIn => false,
+ filamento::roster::Subscription::InPendingOut => true,
+ filamento::roster::Subscription::Buddy => false,
+ }
+ }).collect::<Vec<_>>();
+
+ let accept_friend_request = Action::new_local(move |jid: &BareJID| {
+ let client = client2.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.accept_buddy_request(jid).await;
+ }
+ });
+
+ let reject_friend_request = Action::new_local(move |jid: &BareJID| {
+ let client = client3.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.unsubscribe_contact(jid.clone()).await;
+ set_requests.write().remove(&jid);
+ }
+ });
+
+ let cancel_subscription_request = Action::new_local(move |jid: &BareJID| {
+ let client = client4.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.unsubscribe_from_contact(jid).await;
+
+ }
+ });
+
+ view! {
+ <div class="add-contact-menu">
+ <div>
+ {error_message}
+ <form on:submit=move |ev| {
+ ev.prevent_default();
+ add_contact.dispatch(());
+ }>
+ <input
+ disabled=add_contact_pending
+ placeholder="JID"
+ type="text"
+ node_ref=jid_input
+ bind:value=jid
+ name="jid"
+ id="jid"
+ autofocus="true"
+ />
+ <input disabled=add_contact_pending class="button" type="submit" value="Send Friend Request" />
+ </form>
+ </div>
+ {move || if !requests.read().is_empty() {
+ view! {
+ <div>
+ <h3>Incoming Subscription Requests</h3>
+ <For each=move || requests.get() key=|request| request.clone() let(request)>
+ {
+ let request2 = request.clone();
+ let request3 = request.clone();
+ let jid_string = move || request.to_string();
+ view! {
+ <div class="jid-with-button"><div class="jid">{jid_string}</div>
+ <div><div class="button" on:click=move |_| { accept_friend_request.dispatch(request2.clone()); } >Accept</div><div class="button" on:click=move |_| { reject_friend_request.dispatch(request3.clone()); } >Reject</div></div></div>
+ }
+ }
+ </For>
+ </div>
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }}
+ {move || if !outgoing().is_empty() {
+ view! {
+ <div>
+ <h3>Pending Outgoing Subscription Requests</h3>
+ <For each=move || outgoing() key=|(jid, _contact)| jid.clone() let((jid, contact))>
+ {
+ let jid2 = jid.clone();
+ let jid_string = move || jid.to_string();
+ view! {
+ <div class="jid-with-button"><div class="jid">{jid_string}</div><div class="button" on:click=move |_| { cancel_subscription_request.dispatch(jid2.clone()); } >Cancel</div></div>
+ }
+ }
+ </For>
+ </div>
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }}
+ </div>
+ }
+}
+
diff --git a/src/components/roster_list/roster_list_item.rs b/src/components/roster_list/roster_list_item.rs
new file mode 100644
index 0000000..46ac1cc
--- /dev/null
+++ b/src/components/roster_list/roster_list_item.rs
@@ -0,0 +1,62 @@
+use std::ops::Deref;
+
+use filamento::{chat::Chat, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}};
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+use tracing::debug;
+
+use crate::{chat::MacawChat, components::{avatar::AvatarWithPresence, sidebar::Open}, contact::MacawContact, open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}, user::get_name};
+
+#[component]
+pub 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 name = move || get_name(contact_user, false);
+
+ let open_chats: Store<OpenChatsPanel> =
+ use_context().expect("no open chats panel store in context");
+
+ // TODO: why can this not be in the closure?????
+ // TODO: not good, as overwrites preexisting chat state with possibly incorrect one...
+ let chat = Chat {
+ correspondent: contact_user.jid().get(),
+ have_chatted: false,
+ };
+ let chat = MacawChat::got_chat_and_user(chat, contact_user.get());
+
+ let open_chat = move |_| {
+ debug!("opening chat");
+ open_chats.update(|open_chats| open_chats.open(chat.clone()));
+ };
+
+ let open = move || {
+ if let Some(open_chat) = &*open_chats.chat_view().read() {
+ debug!("got open chat: {:?}", open_chat);
+ if *open_chat == *contact_user.jid().read() {
+ return Open::Focused;
+ }
+ }
+ if let Some(_backgrounded_chat) = open_chats
+ .chats()
+ .read()
+ .get(contact_user.jid().read().deref())
+ {
+ return Open::Open;
+ }
+ Open::Closed
+ };
+ let focused = move || open().is_focused();
+ let open = move || open().is_open();
+
+ view! {
+ <div class="roster-list-item" class:open=move || open() class:focused=move || focused() on:click=open_chat>
+ <AvatarWithPresence user=contact_user />
+ <div class="item-info">
+ <div class="main-info"><p class="name">{name}<span class="jid"> - {move || contact_contact.user_jid().read().to_string()}</span></p></div>
+ <div class="sub-info">{move || contact_contact.subscription().read().to_string()}</div>
+ </div>
+ </div>
+ }
+}
+
diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs
new file mode 100644
index 0000000..ca753ef
--- /dev/null
+++ b/src/components/sidebar.rs
@@ -0,0 +1,176 @@
+use std::collections::HashSet;
+
+use jid::BareJID;
+use leptos::prelude::*;
+
+use crate::components::{
+ personal_status::PersonalStatus,
+ chats_list::ChatsList,
+ roster_list::RosterList,
+};
+
+#[derive(PartialEq, Eq, Clone, Copy)]
+pub enum SidebarOpen {
+ Roster,
+ Chats,
+}
+
+pub enum Open {
+ /// Currently on screen
+ Focused,
+ /// Open in background somewhere (e.g. in another chat tab)
+ Open,
+ /// Closed
+ Closed,
+}
+
+impl Open {
+ pub fn is_focused(&self) -> bool {
+ match self {
+ Open::Focused => true,
+ Open::Open => false,
+ Open::Closed => false,
+ }
+ }
+
+ pub fn is_open(&self) -> bool {
+ match self {
+ Open::Focused => true,
+ Open::Open => true,
+ Open::Closed => false,
+ }
+ }
+}
+
+/// returns whether the state was changed to open (true) or closed (false)
+pub fn toggle_open(state: &mut Option<SidebarOpen>, open: SidebarOpen) -> bool {
+ match state {
+ Some(opened) => {
+ if *opened == open {
+ *state = None;
+ false
+ } else {
+ *state = Some(open);
+ true
+ }
+ }
+ None => {
+ *state = Some(open);
+ true
+ },
+ }
+}
+
+#[component]
+pub fn Sidebar() -> impl IntoView {
+ let requests: ReadSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions in context");
+
+ // for what has been clicked open (in the background)
+ let (open, set_open) = signal(None::<SidebarOpen>);
+ // for what is just in the hovered state (not clicked to be pinned open yet necessarily)
+ let (hovered, set_hovered) = signal(None::<SidebarOpen>);
+ let (just_closed, set_just_closed) = signal(false);
+
+ view! {
+ <div class="sidebar" on:mouseleave=move |_| {
+ set_hovered.set(None);
+ set_just_closed.set(false);
+ }>
+ <div class="dock panel">
+ <div class="shortcuts">
+ <div class="roster-tab dock-item" class:focused=move || *open.read() == Some(SidebarOpen::Roster) class:hovering=move || *hovered.read() == Some(SidebarOpen::Roster)
+ on:mouseenter=move |_| {
+ set_just_closed.set(false);
+ set_hovered.set(Some(SidebarOpen::Roster))
+ }
+ on:click=move |_| {
+ set_open.update(|state| {
+ if !toggle_open(state, SidebarOpen::Roster) {
+ set_just_closed.set(true);
+ }
+ })
+ }>
+ <div class="dock-pill"></div>
+ <div class="dock-icon">
+ <div class="icon-with-badge">
+ <img src="/assets/caw.png" />
+ {move || {
+ let len = requests.read().len();
+ if len > 0 {
+ view! {
+ <div class="badge">{len}</div>
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ </div>
+ </div>
+ <div class="chats-tab dock-item" class:focused=move || *open.read() == Some(SidebarOpen::Chats) class:hovering=move || *hovered.read() == Some(SidebarOpen::Chats)
+ on:mouseenter=move |_| {
+ set_just_closed.set(false);
+ set_hovered.set(Some(SidebarOpen::Chats))
+ }
+ on:click=move |_| {
+ set_open.update(|state| {
+ if !toggle_open(state, SidebarOpen::Chats) {
+ set_just_closed.set(true);
+ }
+ })
+ }>
+ <div class="dock-pill"></div>
+ <img src="/assets/bubble.png" />
+ </div>
+ </div>
+ <div class="pins">
+ </div>
+ <div class="personal">
+ <PersonalStatus />
+ </div>
+ </div>
+ {move || if let Some(hovered) = *hovered.read() {
+ if Some(hovered) != *open.read() {
+ if !just_closed.get() {
+ match hovered {
+ SidebarOpen::Roster => view! {
+ <div class="sidebar-drawer sidebar-hovering-drawer">
+ <RosterList />
+ </div>
+ }.into_any(),
+ SidebarOpen::Chats => view! {
+ <div class="sidebar-drawer sidebar-hovering-drawer">
+ <ChatsList />
+ </div>
+ }.into_any(),
+ }
+ } else {
+
+ view! {}.into_any()
+ }
+ } else {
+ view! {}.into_any()
+ }
+ } else {
+ view! {}.into_any()
+ }}
+ {move || if let Some(opened) = *open.read() {
+ match opened {
+ SidebarOpen::Roster => view! {
+ <div class="sidebar-drawer">
+ <RosterList />
+ </div>
+ }.into_any(),
+ SidebarOpen::Chats => view! {
+ <div class="sidebar-drawer">
+ <ChatsList />
+ </div>
+ }.into_any(),
+ }
+ } else {
+ view! {}.into_any()
+ }}
+ </div>
+ }
+}
+