diff options
author | 2025-06-01 16:10:26 +0100 | |
---|---|---|
committer | 2025-06-01 17:27:40 +0100 | |
commit | 6ee4190a26f32bfa953302ee363ad3bb6c384ebb (patch) | |
tree | 2c3182c29d5780a0ad9c9770b5e546312bea49b4 | |
parent | f76c80c1d23177ab00c81240ee3a75d3bcda0e3b (diff) | |
download | macaw-web-6ee4190a26f32bfa953302ee363ad3bb6c384ebb.tar.gz macaw-web-6ee4190a26f32bfa953302ee363ad3bb6c384ebb.tar.bz2 macaw-web-6ee4190a26f32bfa953302ee363ad3bb6c384ebb.zip |
refactor: reorganise code
36 files changed, 3051 insertions, 2779 deletions
diff --git a/src/chat.rs b/src/chat.rs new file mode 100644 index 0000000..6785b06 --- /dev/null +++ b/src/chat.rs @@ -0,0 +1,40 @@ +use std::ops::{Deref, DerefMut}; + +use filamento::{chat::Chat, user::User}; +use jid::BareJID; +use reactive_stores::ArcStore; +use leptos::prelude::*; + +use crate::{state_store::{StateListener, StateStore}, user::MacawUser}; + +#[derive(Clone)] +pub struct MacawChat { + pub chat: StateListener<BareJID, ArcStore<Chat>>, + pub user: MacawUser, + // user: StateListener<BareJID, ArcStore<User>>, +} + +impl MacawChat { + pub fn got_chat_and_user(chat: Chat, user: User) -> Self { + let chat_state_store: StateStore<BareJID, ArcStore<Chat>> = + use_context().expect("no chat state store"); + let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat)); + let user = MacawUser::got_user(user); + Self { chat, user } + } +} + +impl Deref for MacawChat { + type Target = StateListener<BareJID, ArcStore<Chat>>; + + fn deref(&self) -> &Self::Target { + &self.chat + } +} + +impl DerefMut for MacawChat { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.chat + } +} + diff --git a/src/client.rs b/src/client.rs new file mode 100644 index 0000000..b3ff6bb --- /dev/null +++ b/src/client.rs @@ -0,0 +1,30 @@ +use std::{ops::{Deref, DerefMut}, sync::Arc}; + +use jid::BareJID; +use leptos::prelude::*; + +use crate::files::Files; + +#[derive(Clone)] +pub struct Client { + // TODO: not pub + pub client: filamento::Client<Files>, + pub resource: ArcRwSignal<Option<String>>, + pub jid: Arc<BareJID>, + pub file_store: Files, +} + +impl Deref for Client { + type Target = filamento::Client<Files>; + + fn deref(&self) -> &Self::Target { + &self.client + } +} + +impl DerefMut for Client { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.client + } +} + 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> + } +} + diff --git a/src/contact.rs b/src/contact.rs new file mode 100644 index 0000000..e9ab21f --- /dev/null +++ b/src/contact.rs @@ -0,0 +1,35 @@ +use std::ops::{Deref, DerefMut}; + +use filamento::{roster::Contact, user::User}; +use reactive_stores::Store; + +use crate::user::MacawUser; + +#[derive(Clone)] +pub struct MacawContact { + pub contact: Store<Contact>, + pub user: MacawUser, +} + +impl MacawContact { + pub fn got_contact_and_user(contact: Contact, user: User) -> Self { + let contact = Store::new(contact); + let user = MacawUser::got_user(user); + Self { contact, user } + } +} + +impl Deref for MacawContact { + type Target = Store<Contact>; + + fn deref(&self) -> &Self::Target { + &self.contact + } +} + +impl DerefMut for MacawContact { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.contact + } +} + diff --git a/src/context.rs b/src/context.rs new file mode 100644 index 0000000..e69de29 --- /dev/null +++ b/src/context.rs diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..b9e50df --- /dev/null +++ b/src/files.rs @@ -0,0 +1,50 @@ +use base64::{prelude::BASE64_STANDARD, Engine}; +use filamento::files::{opfs::OPFSError, FileStore, FilesMem, FilesOPFS}; + +#[derive(Clone, Debug)] +pub enum Files { + Mem(FilesMem), + Opfs(FilesOPFS), +} + +impl FileStore for Files { + type Err = OPFSError; + + async fn is_stored(&self, name: &str) -> Result<bool, Self::Err> { + match self { + Files::Mem(files_mem) => Ok(files_mem.is_stored(name).await.unwrap()), + Files::Opfs(files_opfs) => Ok(files_opfs.is_stored(name).await?), + } + } + + async fn store(&self, name: &str, data: &[u8]) -> Result<(), Self::Err> { + match self { + Files::Mem(files_mem) => Ok(files_mem.store(name, data).await.unwrap()), + Files::Opfs(files_opfs) => Ok(files_opfs.store(name, data).await?), + } + } + + async fn delete(&self, name: &str) -> Result<(), Self::Err> { + match self { + Files::Mem(files_mem) => Ok(files_mem.delete(name).await.unwrap()), + Files::Opfs(files_opfs) => Ok(files_opfs.delete(name).await?), + } + } +} + +impl Files { + pub async fn get_src(&self, file_name: &str) -> Option<String> { + match self { + Files::Mem(files_mem) => { + if let Some(data) = files_mem.get_file(file_name).await { + let data = BASE64_STANDARD.encode(data); + Some(format!("data:image/jpg;base64, {}", data)) + } else { + None + } + } + Files::Opfs(files_opfs) => files_opfs.get_src(file_name).await.ok(), + } + } +} + diff --git a/src/icon.rs b/src/icon.rs new file mode 100644 index 0000000..b0ef60e --- /dev/null +++ b/src/icon.rs @@ -0,0 +1,111 @@ +#[derive(Copy, Clone)] +pub enum Icon { + AddContact24, + Attachment24, + Away16, + Away16Color, + Bubble16, + Bubble16Color, + Bubble24, + Close24, + Contact24, + Delivered16, + Dnd16, + Dnd16Color, + Error16Color, + Forward24, + Heart24, + NewBubble24, + Reply24, + Sending16, + Sent16, + Chat16Color, + Xa16Color, + Available16Color, +} + +pub const ICONS_SRC: &str = "/assets/icons/"; + +impl Icon { + pub fn src(&self) -> String { + match self { + Icon::AddContact24 => format!("{}addcontact24.svg", ICONS_SRC), + Icon::Attachment24 => format!("{}attachment24.svg", ICONS_SRC), + Icon::Away16 => format!("{}away16.svg", ICONS_SRC), + Icon::Away16Color => format!("{}away16color.svg", ICONS_SRC), + Icon::Bubble16 => format!("{}bubble16.svg", ICONS_SRC), + Icon::Bubble16Color => format!("{}bubble16color.svg", ICONS_SRC), + Icon::Bubble24 => format!("{}bubble24.svg", ICONS_SRC), + Icon::Close24 => format!("{}close24.svg", ICONS_SRC), + Icon::Contact24 => format!("{}contact24.svg", ICONS_SRC), + Icon::Delivered16 => format!("{}delivered16.svg", ICONS_SRC), + Icon::Dnd16 => format!("{}dnd16.svg", ICONS_SRC), + Icon::Dnd16Color => format!("{}dnd16color.svg", ICONS_SRC), + Icon::Error16Color => format!("{}error16color.svg", ICONS_SRC), + Icon::Forward24 => format!("{}forward24.svg", ICONS_SRC), + Icon::Heart24 => format!("{}heart24.svg", ICONS_SRC), + Icon::NewBubble24 => format!("{}newbubble24.svg", ICONS_SRC), + 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), + } + } + + pub fn size(&self) -> isize { + match self { + Icon::AddContact24 => 24, + Icon::Attachment24 => 24, + Icon::Away16 => 16, + Icon::Away16Color => 16, + Icon::Bubble16 => 16, + Icon::Bubble16Color => 16, + Icon::Bubble24 => 24, + Icon::Close24 => 24, + Icon::Contact24 => 24, + Icon::Delivered16 => 16, + Icon::Dnd16 => 16, + Icon::Dnd16Color => 16, + Icon::Error16Color => 16, + Icon::Forward24 => 24, + Icon::Heart24 => 24, + Icon::NewBubble24 => 24, + Icon::Reply24 => 24, + Icon::Sending16 => 16, + Icon::Sent16 => 16, + Icon::Chat16Color => 16, + Icon::Xa16Color => 16, + Icon::Available16Color => 16, + } + } + + pub fn light(&self) -> bool { + match self { + Icon::AddContact24 => true, + Icon::Attachment24 => true, + Icon::Away16 => true, + Icon::Away16Color => false, + Icon::Bubble16 => true, + Icon::Bubble16Color => false, + Icon::Bubble24 => true, + Icon::Close24 => true, + Icon::Contact24 => true, + Icon::Delivered16 => true, + Icon::Dnd16 => true, + Icon::Dnd16Color => false, + Icon::Error16Color => false, + Icon::Forward24 => true, + Icon::Heart24 => true, + Icon::NewBubble24 => true, + Icon::Reply24 => true, + Icon::Sending16 => true, + Icon::Sent16 => true, + Icon::Chat16Color => false, + Icon::Xa16Color => false, + Icon::Available16Color => false, + } + } +} + @@ -1,2780 +1,17 @@ -use std::{ - borrow::Borrow, - cell::RefCell, - collections::{HashMap, HashSet}, - marker::PhantomData, - ops::{Deref, DerefMut}, - rc::Rc, - str::FromStr, - sync::{atomic::AtomicUsize, Arc, RwLock}, - thread::sleep, - time::{self, Duration}, -}; +pub use views::App; + +mod state_store; +mod icon; +mod user; +mod chat; +mod open_chats; +mod components; +mod views; +mod files; +mod client; +mod roster; +mod contact; +mod message; +mod message_subscriptions; +mod user_presences; -use base64::{Engine, prelude::BASE64_STANDARD}; -use chrono::{Local, NaiveDateTime, TimeDelta, Utc}; -use filamento::{ - chat::{Body, Chat, ChatStoreFields, Delivery, Message, MessageStoreFields}, db::Db, error::{AvatarPublishError, CommandError, ConnectionError, DatabaseError, SubscribeError}, files::{opfs::OPFSError, FileStore, FilesMem, FilesOPFS}, presence::{Offline, Online, Presence, PresenceType, Show}, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}, UpdateMessage -}; -use futures::stream::StreamExt; -use indexmap::IndexMap; -use jid::{JID, BareJID}; -use leptos::{ - ev::{Event, KeyboardEvent, MouseEvent, SubmitEvent}, - html::{self, Div, Input, Pre, Textarea}, - prelude::*, - tachys::{dom::document, reactive_graph::bind::GetValue, renderer::dom::Element}, - task::{spawn, spawn_local}, -}; -use reactive_stores::{ArcStore, Store, StoreField}; -use stylance::import_style; -use thiserror::Error; -use tokio::sync::{ - Mutex, - mpsc::{self, Receiver}, -}; -use tracing::{debug, error}; -use uuid::Uuid; -use web_sys::{js_sys::Uint8Array, wasm_bindgen::{prelude::Closure, JsCast, UnwrapThrowExt}, FileReader, HtmlInputElement, ProgressEvent}; - -const NO_AVATAR: &str = "/assets/no-avatar.png"; - -pub enum AppState { - LoggedOut, - LoggedIn, -} - -#[derive(Clone)] -pub struct Client { - client: filamento::Client<Files>, - resource: ArcRwSignal<Option<String>>, - jid: Arc<BareJID>, - file_store: Files, -} - -#[derive(Clone, Debug)] -pub enum Files { - Mem(FilesMem), - Opfs(FilesOPFS), -} - -impl FileStore for Files { - type Err = OPFSError; - - async fn is_stored(&self, name: &str) -> Result<bool, Self::Err> { - match self { - Files::Mem(files_mem) => Ok(files_mem.is_stored(name).await.unwrap()), - Files::Opfs(files_opfs) => Ok(files_opfs.is_stored(name).await?), - } - } - - async fn store(&self, name: &str, data: &[u8]) -> Result<(), Self::Err> { - match self { - Files::Mem(files_mem) => Ok(files_mem.store(name, data).await.unwrap()), - Files::Opfs(files_opfs) => Ok(files_opfs.store(name, data).await?), - } - } - - async fn delete(&self, name: &str) -> Result<(), Self::Err> { - match self { - Files::Mem(files_mem) => Ok(files_mem.delete(name).await.unwrap()), - Files::Opfs(files_opfs) => Ok(files_opfs.delete(name).await?), - } - } -} - -impl Files { - pub async fn get_src(&self, file_name: &str) -> Option<String> { - match self { - Files::Mem(files_mem) => { - if let Some(data) = files_mem.get_file(file_name).await { - let data = BASE64_STANDARD.encode(data); - Some(format!("data:image/jpg;base64, {}", data)) - } else { - None - } - } - Files::Opfs(files_opfs) => files_opfs.get_src(file_name).await.ok(), - } - } -} - -impl Deref for Client { - type Target = filamento::Client<Files>; - - fn deref(&self) -> &Self::Target { - &self.client - } -} - -impl DerefMut for Client { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.client - } -} - -#[component] -pub fn App() -> impl IntoView { - let (app, set_app) = signal(AppState::LoggedOut); - let (client, set_client) = signal(None::<(Client, Receiver<UpdateMessage>)>); - - view! { - {move || match &*app.read() { - AppState::LoggedOut => view! { <LoginPage set_app set_client /> }.into_any(), - AppState::LoggedIn => { - if let Some((client, updates)) = set_client.write_untracked().take() { - view! { <Macaw client updates set_app /> }.into_any() - } else { - set_app.set(AppState::LoggedOut); - view! { <LoginPage set_app set_client /> }.into_any() - } - } - }} - } -} - -#[derive(Clone, Debug, Error)] -pub enum LoginError { - #[error("Missing Password")] - MissingPassword, - #[error("Missing JID")] - MissingJID, - #[error("Invalid JID: {0}")] - InvalidJID(#[from] jid::ParseError), - #[error("Connection Error: {0}")] - ConnectionError(#[from] CommandError<ConnectionError>), - #[error("OPFS: {0}")] - OPFS(#[from] OPFSError), -} - -#[component] -fn LoginPage( - set_app: WriteSignal<AppState>, - set_client: WriteSignal<Option<(Client, Receiver<UpdateMessage>)>>, -) -> impl IntoView { - let jid = RwSignal::new("".to_string()); - let password = RwSignal::new("".to_string()); - let remember_me = RwSignal::new(false); - let connect_on_login = RwSignal::new(true); - - let (error, set_error) = signal(None::<LoginError>); - 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 (login_pending, set_login_pending) = signal(false); - - let login = Action::new_local(move |_| { - async move { - set_login_pending.set(true); - - if jid.read_untracked().is_empty() { - set_error.set(Some(LoginError::MissingJID)); - set_login_pending.set(false); - return; - } - - if password.read_untracked().is_empty() { - set_error.set(Some(LoginError::MissingPassword)); - set_login_pending.set(false); - return; - } - - let jid = match JID::from_str(&jid.read_untracked()) { - Ok(j) => j, - Err(e) => { - set_error.set(Some(e.into())); - set_login_pending.set(false); - return; - } - }; - - let remember_me = remember_me.get_untracked(); - // initialise the client - let db = if remember_me { - debug!("creating db in opfs"); - Db::create_connect_and_migrate(jid.as_bare().to_string()) - .await - .unwrap() - } else { - debug!("creating db in memory"); - Db::create_connect_and_migrate_memory().await.unwrap() - }; - let files = if remember_me { - let opfs = FilesOPFS::new(jid.as_bare().to_string()).await; - match opfs { - Ok(f) => Files::Opfs(f), - Err(e) => { - set_error.set(Some(e.into())); - set_login_pending.set(false); - return; - } - } - } else { - Files::Mem(FilesMem::new()) - }; - let (client, updates) = filamento::Client::new( - jid.clone(), - password.read_untracked().clone(), - db, - files.clone(), - ); - // TODO: remember_me - let resource = ArcRwSignal::new(None::<String>); - let client = Client { - client, - resource: resource.clone(), - jid: Arc::new(jid.to_bare()), - file_store: files, - }; - - if *connect_on_login.read_untracked() { - match client.connect().await { - Ok(r) => { - resource.set(Some(r)) - } - Err(e) => { - set_error.set(Some(e.into())); - set_login_pending.set(false); - return; - } - } - } - - // debug!("before setting app state"); - set_client.set(Some((client, updates))); - set_app.set(AppState::LoggedIn); - } - }); - - view! { - <div class="center fill"> - <div id="login-form" class="panel"> - <div id="hero"> - <img src="/assets/macaw-icon.png" /> - <h1>Macaw Instant Messenger</h1> - </div> - {error_message} - <form on:submit=move |ev| { - ev.prevent_default(); - login.dispatch(()); - }> - <label for="jid">JID</label> - <input - disabled=login_pending - placeholder="caw@macaw.chat" - type="text" - bind:value=jid - name="jid" - id="jid" - autofocus="true" - /> - <label for="password">Password</label> - <input - disabled=login_pending - placeholder="••••••••" - type="password" - bind:value=password - name="password" - id="password" - /> - <div> - <label for="remember_me">Remember me</label> - <input - disabled=login_pending - type="checkbox" - bind:checked=remember_me - name="remember_me" - id="remember_me" - /> - </div> - <div> - <label for="connect_on_login">Connect on login</label> - <input - disabled=login_pending - type="checkbox" - bind:checked=connect_on_login - name="connect_on_login" - id="connect_on_login" - /> - </div> - <input disabled=login_pending class="button" type="submit" value="Log In" /> - </form> - </div> - </div> - } -} - -pub struct MessageSubscriptions { - all: HashMap<Uuid, mpsc::Sender<(BareJID, MacawMessage)>>, - subset: HashMap<BareJID, HashMap<Uuid, mpsc::Sender<MacawMessage>>>, -} - -impl MessageSubscriptions { - pub fn new() -> Self { - Self { - all: HashMap::new(), - subset: HashMap::new(), - } - } - - pub async fn broadcast(&mut self, to: BareJID, message: MacawMessage) { - // subscriptions to all - let mut removals = Vec::new(); - for (id, sender) in &self.all { - match sender.send((to.clone(), message.clone())).await { - Ok(_) => {} - Err(_) => { - removals.push(*id); - } - } - } - for removal in removals { - self.all.remove(&removal); - } - - // subscriptions to specific chat - if let Some(subscribers) = self.subset.get_mut(&to) { - let mut removals = Vec::new(); - for (id, sender) in &*subscribers { - match sender.send(message.clone()).await { - Ok(_) => {} - Err(_) => { - removals.push(*id); - } - } - } - for removal in removals { - subscribers.remove(&removal); - } - if subscribers.is_empty() { - self.subset.remove(&to); - } - } - } - - pub fn subscribe_all(&mut self) -> (Uuid, Receiver<(BareJID, MacawMessage)>) { - let (send, recv) = mpsc::channel(10); - let id = Uuid::new_v4(); - self.all.insert(id, send); - (id, recv) - } - - pub fn subscribe_chat(&mut self, chat: BareJID) -> (Uuid, Receiver<MacawMessage>) { - let (send, recv) = mpsc::channel(10); - let id = Uuid::new_v4(); - if let Some(chat_subscribers) = self.subset.get_mut(&chat) { - chat_subscribers.insert(id, send); - } else { - let hash_map = HashMap::from([(id, send)]); - self.subset.insert(chat, hash_map); - } - (id, recv) - } - - pub fn unsubscribe_all(&mut self, sub_id: Uuid) { - self.all.remove(&sub_id); - } - - pub fn unsubscribe_chat(&mut self, sub_id: Uuid, chat: BareJID) { - if let Some(chat_subs) = self.subset.get_mut(&chat) { - chat_subs.remove(&sub_id); - } - } -} - -#[derive(Store, Clone)] -pub struct Roster { - #[store(key: BareJID = |(jid, _)| jid.clone())] - contacts: HashMap<BareJID, MacawContact>, -} - -impl Roster { - pub fn new() -> Self { - Self { - contacts: HashMap::new(), - } - } -} - -// TODO: multiple panels -// pub struct OpenChats { -// panels: -// } - -#[derive(Store, Default)] -pub struct OpenChatsPanel { - // jid must be a chat in the chats map - chat_view: Option<BareJID>, - #[store(key: BareJID = |(jid, _)| jid.clone())] - chats: IndexMap<BareJID, MacawChat>, -} - -pub fn open_chat(open_chats: Store<OpenChatsPanel>, chat: MacawChat) { - if let Some(jid) = &*open_chats.chat_view().read() { - if let Some((index, _jid, entry)) = open_chats.chats().write().shift_remove_full(jid) { - let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) - .correspondent() - .read() - .clone(); - open_chats - .chats() - .write() - .insert_before(index, new_jid.clone(), chat); - *open_chats.chat_view().write() = Some(new_jid); - } else { - let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) - .correspondent() - .read() - .clone(); - open_chats.chats().write().insert(new_jid.clone(), chat); - *open_chats.chat_view().write() = Some(new_jid); - } - } else { - let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) - .correspondent() - .read() - .clone(); - open_chats.chats().write().insert(new_jid.clone(), chat); - *open_chats.chat_view().write() = Some(new_jid); - } -} - -impl OpenChatsPanel { - pub fn open(&mut self, chat: MacawChat) { - if let Some(jid) = &mut self.chat_view { - debug!("a chat was already open"); - if let Some((index, _jid, entry)) = self.chats.shift_remove_full(jid) { - let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) - .correspondent() - .read() - .clone(); - self.chats.insert_before(index, new_jid.clone(), chat); - *&mut self.chat_view = Some(new_jid); - } else { - let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) - .correspondent() - .read() - .clone(); - self.chats.insert(new_jid.clone(), chat); - *&mut self.chat_view = Some(new_jid); - } - } else { - let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) - .correspondent() - .read() - .clone(); - self.chats.insert(new_jid.clone(), chat); - *&mut self.chat_view = Some(new_jid); - } - debug!("opened chat"); - } - - // TODO: - // pub fn open_in_new_tab_unfocused(&mut self) { - - // } - - // pub fn open_in_new_tab_focus(&mut self) { - - // } -} - -#[derive(Store)] -pub struct UserPresences { - #[store(key: BareJID = |(jid, _)| jid.clone())] - user_presences: HashMap<BareJID, ArcRwSignal<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: &BareJID) -> ArcRwSignal<Presences> { - if let Some(presences) = self.user_presences.get(user) { - presences.clone() - } else { - let presences = Presences::new(); - let signal = ArcRwSignal::new(presences); - self.user_presences.insert(user.clone(), signal.clone()); - 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, - ); - } - - pub fn resource_presence(&mut self, resource: String) -> Presence { - if let Some(presence) = self.presences.get(&resource) { - presence.clone() - } else { - Presence { - timestamp: Utc::now(), - presence: PresenceType::Offline(Offline::default()), - } - } - } -} - -#[component] -fn Macaw( - // TODO: logout - // app_state: WriteSignal<Option<essage>)>, LocalStorage>, - client: Client, - mut updates: Receiver<UpdateMessage>, - set_app: WriteSignal<AppState>, -) -> impl IntoView { - provide_context(set_app); - provide_context(client); - - let roster = Store::new(Roster::new()); - provide_context(roster); - - let message_subscriptions = RwSignal::new(MessageSubscriptions::new()); - provide_context(message_subscriptions); - - let messages_store: StateStore<Uuid, ArcStore<Message>> = StateStore::new(); - provide_context(messages_store); - let chats_store: StateStore<BareJID, ArcStore<Chat>> = StateStore::new(); - provide_context(chats_store); - let users_store: StateStore<BareJID, ArcStore<User>> = StateStore::new(); - provide_context(users_store); - - let open_chats = Store::new(OpenChatsPanel::default()); - provide_context(open_chats); - let show_settings = RwSignal::new(None::<SettingsPage>); - provide_context(show_settings); - - let user_presences = Store::new(UserPresences::new()); - provide_context(user_presences); - - let client_user = LocalResource::new(move || { - async move { - let client = use_context::<Client>().expect("client not in context"); - let user = client.get_user((*client.jid).clone()).await.unwrap(); - MacawUser::got_user(user) - } - }); - provide_context(client_user); - - // TODO: timestamp incoming/outgoing subscription requests - let (subscription_requests, set_subscription_requests)= signal(HashSet::<BareJID>::new()); - provide_context(subscription_requests); - provide_context(set_subscription_requests); - - // TODO: get cached contacts on login before getting the updated contacts - - OnceResource::new(async move { - while let Some(update) = updates.recv().await { - match update { - UpdateMessage::Online(online, items) => { - let contacts = items - .into_iter() - .map(|(contact, user)| { - ( - contact.user_jid.clone(), - MacawContact::got_contact_and_user(contact, user), - ) - }) - .collect(); - roster.contacts().set(contacts); - } - 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) { - macaw_contact.set(contact); - } else { - let jid = contact.user_jid.clone(); - let contact = MacawContact::got_contact_and_user(contact, user); - roster.insert(jid, contact); - } - }); - } - UpdateMessage::RosterDelete(jid) => { - roster.contacts().update(|roster| { - roster.remove(&jid); - }); - } - UpdateMessage::Presence { from, presence } => { - let bare_jid = from.to_bare(); - if let Some(presences) = user_presences.read().user_presences.get(&bare_jid) { - if let Some(resource) = from.resourcepart() { - presences.write().update_presence(resource.clone(), presence); - } - } else { - if let Some(resource) = from.resourcepart() { - let mut presences = Presences::new(); - presences.update_presence(resource.clone(), presence); - user_presences.write().user_presences.insert(bare_jid, ArcRwSignal::new(presences)); - } - } - } - UpdateMessage::Message { to, from, message } => { - debug!("before got message"); - let new_message = MacawMessage::got_message_and_user(message, from); - debug!("after got message"); - spawn_local(async move { - message_subscriptions - .write() - .broadcast(to, new_message) - .await - }); - debug!("after set message"); - } - UpdateMessage::MessageDelivery { id, chat, delivery } => { - messages_store.modify(&id, |message| { - <ArcStore<filamento::chat::Message> as Clone>::clone(&message) - .delivery() - .set(Some(delivery)) - }); - } - UpdateMessage::SubscriptionRequest(jid) => { - set_subscription_requests.update(|req| { req.insert(jid); }); - } - UpdateMessage::NickChanged { jid, nick } => { - users_store.modify(&jid, |user| { - user.update(|user| *&mut user.nick = nick.clone()) - }); - } - UpdateMessage::AvatarChanged { jid, id } => { - users_store.modify(&jid, |user| *&mut user.write().avatar = id.clone()); - } - } - } - }); - - view! { - <Sidebar /> - // <ChatsList /> - <OpenChatsPanelView /> - {move || if let Some(_) = *show_settings.read() { - view! { <Settings /> }.into_any() - } else { - view! {}.into_any() - }} - } -} - -#[derive(PartialEq, Eq, Clone, Copy)] -pub enum SidebarOpen { - Roster, - Chats, -} - -/// 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> - } -} - -#[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> - } -} - -#[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> - } -} - -#[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> - } -} - -#[derive(Clone, Copy, Debug, PartialEq, Eq)] -pub enum SettingsPage { - Account, - Chat, - Profile, - Privacy, -} - -#[component] -pub fn Settings() -> impl IntoView { - let show_settings: RwSignal<Option<SettingsPage>> = use_context().unwrap(); - - view! { - <Modal on_background_click=move |_| { show_settings.set(None); }> - <div class="settings panel"> - <div class="header"> - <h2>Settings</h2> - <div class="header-icon close"> - <IconComponent icon=Icon::Close24 on:click=move |_| show_settings.set(None)/> - </div> - </div> - <div class="settings-main"> - <div class="settings-sidebar"> - <div class:open=move || *show_settings.read() == Some(SettingsPage::Account) on:click=move |_| show_settings.set(Some(SettingsPage::Account))>Account</div> - <div class:open=move || *show_settings.read() == Some(SettingsPage::Chat) on:click=move |_| show_settings.set(Some(SettingsPage::Chat))>Chat</div> - <div class:open=move || *show_settings.read() == Some(SettingsPage::Privacy) on:click=move |_| show_settings.set(Some(SettingsPage::Privacy))>Privacy</div> - <div class:open=move || *show_settings.read() == Some(SettingsPage::Profile) on:click=move |_| show_settings.set(Some(SettingsPage::Profile))>Profile</div> - </div> - <div class="settings-page"> - {move || if let Some(page) = show_settings.get() { - match page { - SettingsPage::Account => view! { <div>"account"</div> }.into_any(), - SettingsPage::Chat => view! { <div>"chat"</div> }.into_any(), - SettingsPage::Profile => view! { <ProfileSettings /> }.into_any(), - SettingsPage::Privacy => view! { <div>"privacy"</div> }.into_any(), - } - } else { - view! {}.into_any() - }} - </div> - </div> - </div> - </Modal> - } -} - -#[derive(Debug, Clone, Error)] -pub enum ProfileSaveError { - #[error("avatar publish: {0}")] - Avatar(#[from] CommandError<AvatarPublishError<Files>>), -} - -#[component] -pub fn ProfileSettings() -> impl IntoView { - let client: Client = use_context().expect("no client in context"); - - // TODO: compartmentalise into error component, form component... - let (error, set_error) = signal(None::<ProfileSaveError>); - 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 (profile_save_pending, set_profile_save_pending) = signal(false); - - let profile_upload_data = RwSignal::new(None::<Vec<u8>>); - let from_input = move |ev: Event| { - let elem = ev.target().unwrap().unchecked_into::<HtmlInputElement>(); - - // let UploadSignal(file_signal) = expect_context(); - // file_signal.update(Vec::clear); // Clear list from previous change - let files = elem.files().unwrap_throw(); - - if let Some(file) = files.get(0) { - let reader = FileReader::new().unwrap_throw(); - // let name = file.name(); - - // This closure only needs to be called a single time as we will just - // remake it on each loop - // * web_sys drops it for us when using this specific constructor - let read_file = { - // FileReader is cloned prior to moving into the closure - let reader = reader.to_owned(); - Closure::once_into_js(move |_: ProgressEvent| { - // `.result` valid after the `read_*` completes on FileReader - // https://developer.mozilla.org/en-US/docs/Web/API/FileReader/result - let result = reader.result().unwrap_throw(); - let data= Uint8Array::new(&result).to_vec(); - // Do whatever you want with the Vec<u8> - profile_upload_data.set(Some(data)); - }) - }; - reader.set_onloadend(Some(read_file.as_ref().unchecked_ref())); - - // read_as_array_buffer takes a &Blob - // - // Per https://w3c.github.io/FileAPI/#file-section - // > A File object is a Blob object with a name attribute.. - // - // File is a subclass (inherits) from the Blob interface, so a File - // can be used anywhere a Blob is required. - reader.read_as_array_buffer(&file).unwrap_throw(); - - // You can also use `.read_as_text(&file)` instead if you just want a string. - // This example shows how to extract an array buffer as it is more flexible - // - // If you use `.read_as_text` change the closure from ... - // - // let result = reader.result().unwrap_throw(); - // let vec_of_u8_bytes = Uint8Array::new(&result).to_vec(); - // let content = String::from_utf8(vec_of_u8_bytes).unwrap_throw(); - // - // to ... - // - // let result = reader.result().unwrap_throw(); - // let content = result.as_string().unwrap_throw(); - } else { - profile_upload_data.set(None); - } - }; - - let save_profile = Action::new_local(move |_| { - let client = client.clone(); - async move {} - }); - - let new_nick= RwSignal::new("".to_string()); - - view! { - <div class="profile-settings"> - <form on:submit=move |ev| { - ev.prevent_default(); - save_profile.dispatch(()); - }> - {error_message} - <div class="change-avatar"> - <input type="file" id="client-user-avatar" on:change=from_input /> - </div> - <input disabled=profile_save_pending placeholder="Nickname" type="text" id="client-user-nick" bind:value=new_nick name="client-user-nick" /> - <input disabled=profile_save_pending class="button" type="submit" value="Save Changes" /> - </form> - </div> - <div class="profile-preview"> - <h2>Profile Preview</h2> - <div class="preview"> - <img /> - <div>nick</div> - </div> - </div> - } -} - -#[component] -pub fn OpenChatsPanelView() -> impl IntoView { - let open_chats: Store<OpenChatsPanel> = use_context().expect("no open chats panel in context"); - - // TODO: tabs - // view! { - // {move || { - // if open_chats.chats().read().len() > 1 { - // Some( - // view! { - // <For - // each=move || open_chats.chats().get() - // key=|(jid, _)| jid.clone() - // let(chat) - // ></For> - // }, - // ) - // } else { - // None - // } - // }} - // } - view! { - <div class="open-chat-views"> - {move || { - if let Some(open_chat) = open_chats.chat_view().get() { - if let Some(open_chat) = open_chats.chats().read().get(&open_chat) { - view! { <OpenChatView chat=open_chat.clone() /> }.into_any() - } else { - view! {}.into_any() - } - } else { - view! {}.into_any() - } - }} - </div> - } -} - -#[component] -pub fn OpenChatView(chat: MacawChat) -> impl IntoView { - let chat_chat: Store<Chat> = - <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat).into(); - let chat_jid = move || chat_chat.correspondent().get(); - - view! { - <div class="open-chat-view"> - <ChatViewHeader chat=chat.clone() /> - <MessageHistoryBuffer chat=chat.clone() /> - <ChatViewMessageComposer chat=chat_jid() /> - </div> - } -} - -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! { - <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> - } -} - -#[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> - } -} - -#[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) - .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> - } -} - -#[derive(Copy, Clone)] -pub enum Icon { - AddContact24, - Attachment24, - Away16, - Away16Color, - Bubble16, - Bubble16Color, - Bubble24, - Close24, - Contact24, - Delivered16, - Dnd16, - Dnd16Color, - Error16Color, - Forward24, - Heart24, - NewBubble24, - Reply24, - Sending16, - Sent16, - Chat16Color, - Xa16Color, - Available16Color, -} - -pub const ICONS_SRC: &str = "/assets/icons/"; - -impl Icon { - pub fn src(&self) -> String { - match self { - Icon::AddContact24 => format!("{}addcontact24.svg", ICONS_SRC), - Icon::Attachment24 => format!("{}attachment24.svg", ICONS_SRC), - Icon::Away16 => format!("{}away16.svg", ICONS_SRC), - Icon::Away16Color => format!("{}away16color.svg", ICONS_SRC), - Icon::Bubble16 => format!("{}bubble16.svg", ICONS_SRC), - Icon::Bubble16Color => format!("{}bubble16color.svg", ICONS_SRC), - Icon::Bubble24 => format!("{}bubble24.svg", ICONS_SRC), - Icon::Close24 => format!("{}close24.svg", ICONS_SRC), - Icon::Contact24 => format!("{}contact24.svg", ICONS_SRC), - Icon::Delivered16 => format!("{}delivered16.svg", ICONS_SRC), - Icon::Dnd16 => format!("{}dnd16.svg", ICONS_SRC), - Icon::Dnd16Color => format!("{}dnd16color.svg", ICONS_SRC), - Icon::Error16Color => format!("{}error16color.svg", ICONS_SRC), - Icon::Forward24 => format!("{}forward24.svg", ICONS_SRC), - Icon::Heart24 => format!("{}heart24.svg", ICONS_SRC), - Icon::NewBubble24 => format!("{}newbubble24.svg", ICONS_SRC), - 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), - } - } - - pub fn size(&self) -> isize { - match self { - Icon::AddContact24 => 24, - Icon::Attachment24 => 24, - Icon::Away16 => 16, - Icon::Away16Color => 16, - Icon::Bubble16 => 16, - Icon::Bubble16Color => 16, - Icon::Bubble24 => 24, - Icon::Close24 => 24, - Icon::Contact24 => 24, - Icon::Delivered16 => 16, - Icon::Dnd16 => 16, - Icon::Dnd16Color => 16, - Icon::Error16Color => 16, - Icon::Forward24 => 24, - Icon::Heart24 => 24, - Icon::NewBubble24 => 24, - Icon::Reply24 => 24, - Icon::Sending16 => 16, - Icon::Sent16 => 16, - Icon::Chat16Color => 16, - Icon::Xa16Color => 16, - Icon::Available16Color => 16, - } - } - - pub fn light(&self) -> bool { - match self { - Icon::AddContact24 => true, - Icon::Attachment24 => true, - Icon::Away16 => true, - Icon::Away16Color => false, - Icon::Bubble16 => true, - Icon::Bubble16Color => false, - Icon::Bubble24 => true, - Icon::Close24 => true, - Icon::Contact24 => true, - Icon::Delivered16 => true, - Icon::Dnd16 => true, - Icon::Dnd16Color => false, - Icon::Error16Color => false, - Icon::Forward24 => true, - Icon::Heart24 => true, - Icon::NewBubble24 => true, - Icon::Reply24 => true, - Icon::Sending16 => true, - Icon::Sent16 => true, - Icon::Chat16Color => false, - Icon::Xa16Color => false, - Icon::Available16Color => false, - } - } -} - -#[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() /> - } -} - -#[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() - } - } -} - -#[component] -pub fn Message(message: MacawMessage, major: bool, r#final: bool) -> impl IntoView { - let message_message: Store<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() - } -} - -#[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> - } -} - -// V has to be an arc signal -#[derive(Debug)] -struct ArcStateStore<K, V> { - store: Arc<RwLock<HashMap<K, (V, usize)>>>, -} - -impl<K, V> PartialEq for ArcStateStore<K, V> { - fn eq(&self, other: &Self) -> bool { - Arc::ptr_eq(&self.store, &other.store) - } -} - -impl<K, V> Clone for ArcStateStore<K, V> { - fn clone(&self) -> Self { - Self { - store: Arc::clone(&self.store), - } - } -} - -impl<K, V> Eq for ArcStateStore<K, V> {} - -impl<K, V> ArcStateStore<K, V> { - pub fn new() -> Self { - Self { - store: Arc::new(RwLock::new(HashMap::new())), - } - } -} - -#[derive(Debug)] -struct StateStore<K, V, S = SyncStorage> { - inner: ArenaItem<ArcStateStore<K, V>, S>, -} - -impl<K, V, S> Dispose for StateStore<K, V, S> { - fn dispose(self) { - self.inner.dispose() - } -} - -impl<K, V> StateStore<K, V> -where - K: Send + Sync + 'static, - V: Send + Sync + 'static, -{ - pub fn new() -> Self { - Self::new_with_storage() - } -} - -impl<K, V, S> StateStore<K, V, S> -where - K: 'static, - V: 'static, - S: Storage<ArcStateStore<K, V>>, -{ - pub fn new_with_storage() -> Self { - Self { - inner: ArenaItem::new_with_storage(ArcStateStore::new()), - } - } -} - -impl<K, V> StateStore<K, V, LocalStorage> -where - K: 'static, - V: 'static, -{ - pub fn new_local() -> Self { - Self::new_with_storage() - } -} - -impl< - K: std::marker::Send + std::marker::Sync + 'static, - V: std::marker::Send + std::marker::Sync + 'static, -> From<ArcStateStore<K, V>> for StateStore<K, V> -{ - fn from(value: ArcStateStore<K, V>) -> Self { - Self { - inner: ArenaItem::new_with_storage(value), - } - } -} - -impl<K: 'static, V: 'static> FromLocal<ArcStateStore<K, V>> for StateStore<K, V, LocalStorage> { - fn from_local(value: ArcStateStore<K, V>) -> Self { - Self { - inner: ArenaItem::new_with_storage(value), - } - } -} - -impl<K, V, S> Copy for StateStore<K, V, S> {} - -impl<K, V, S> Clone for StateStore<K, V, S> { - fn clone(&self) -> Self { - *self - } -} - -impl<K: Eq + std::hash::Hash + Clone, V: Clone> StateStore<K, V> -where - K: Send + Sync + 'static, - V: Send + Sync + 'static, -{ - pub fn store(&self, key: K, value: V) -> StateListener<K, V> { - { - let store = self.inner.try_get_value().unwrap(); - let mut store = store.store.write().unwrap(); - if let Some((v, count)) = store.get_mut(&key) { - *v = value.clone(); - *count += 1; - } else { - store.insert(key.clone(), (value.clone(), 1)); - } - }; - StateListener { - value, - cleaner: StateCleaner { - key, - state_store: self.clone(), - }, - } - } -} - -impl<K, V> StateStore<K, V> -where - K: Eq + std::hash::Hash + Send + Sync + 'static, - V: Send + Sync + 'static, -{ - pub fn update(&self, key: &K, value: V) { - let store = self.inner.try_get_value().unwrap(); - let mut store = store.store.write().unwrap(); - if let Some((v, _)) = store.get_mut(key) { - *v = value; - } - } - - pub fn modify(&self, key: &K, modify: impl Fn(&mut V)) { - let store = self.inner.try_get_value().unwrap(); - let mut store = store.store.write().unwrap(); - if let Some((v, _)) = store.get_mut(key) { - modify(v); - } - } - - fn remove(&self, key: &K) { - // let store = self.inner.try_get_value().unwrap(); - // let mut store = store.store.write().unwrap(); - // if let Some((_v, count)) = store.get_mut(key) { - // *count -= 1; - // if *count == 0 { - // store.remove(key); - // debug!("dropped item from store"); - // } - // } - } -} - -#[derive(Clone)] -struct StateListener<K, V> -where - K: Eq + std::hash::Hash + 'static + std::marker::Send + std::marker::Sync, - V: 'static + std::marker::Send + std::marker::Sync, -{ - value: V, - cleaner: StateCleaner<K, V>, -} - -impl< - K: std::cmp::Eq + std::hash::Hash + std::marker::Send + std::marker::Sync, - V: std::marker::Send + std::marker::Sync, -> Deref for StateListener<K, V> -{ - type Target = V; - - fn deref(&self) -> &Self::Target { - &self.value - } -} - -impl<K: std::cmp::Eq + std::hash::Hash + Send + Sync, V: Send + Sync> DerefMut - for StateListener<K, V> -{ - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.value - } -} - -struct ArcStateCleaner<K, V> { - key: K, - state_store: ArcStateStore<K, V>, -} - -struct StateCleaner<K, V> -where - K: Eq + std::hash::Hash + Send + Sync + 'static, - V: Send + Sync + 'static, -{ - key: K, - state_store: StateStore<K, V>, -} - -impl<K, V> Clone for StateCleaner<K, V> -where - K: Eq + std::hash::Hash + Clone + Send + Sync, - V: Send + Sync, -{ - fn clone(&self) -> Self { - { - let store = self.state_store.inner.try_get_value().unwrap(); - let mut store = store.store.write().unwrap(); - if let Some((_v, count)) = store.get_mut(&self.key) { - *count += 1; - } - } - Self { - key: self.key.clone(), - state_store: self.state_store.clone(), - } - } -} - -impl<K: Eq + std::hash::Hash + Send + Sync + 'static, V: Send + Sync + 'static> Drop - for StateCleaner<K, V> -{ - fn drop(&mut self) { - self.state_store.remove(&self.key); - } -} - -#[derive(Clone)] -struct MacawChat { - chat: StateListener<BareJID, ArcStore<Chat>>, - user: StateListener<BareJID, ArcStore<User>>, -} - -impl MacawChat { - fn got_chat_and_user(chat: Chat, user: User) -> Self { - 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 user = user_state_store.store(user.jid.clone(), ArcStore::new(user)); - let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat)); - Self { chat, user } - } -} - -impl Deref for MacawChat { - type Target = StateListener<BareJID, ArcStore<Chat>>; - - fn deref(&self) -> &Self::Target { - &self.chat - } -} - -impl DerefMut for MacawChat { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.chat - } -} - -#[derive(Clone)] -struct MacawMessage { - message: StateListener<Uuid, ArcStore<Message>>, - user: StateListener<BareJID, ArcStore<User>>, -} - -impl MacawMessage { - fn got_message_and_user(message: Message, user: User) -> Self { - let message_state_store: StateStore<Uuid, ArcStore<Message>> = - use_context().expect("no message state store"); - let user_state_store: StateStore<BareJID, ArcStore<User>> = - use_context().expect("no user state store"); - let message = message_state_store.store(message.id, ArcStore::new(message)); - let user = user_state_store.store(user.jid.clone(), ArcStore::new(user)); - Self { message, user } - } -} - -impl Deref for MacawMessage { - type Target = StateListener<Uuid, ArcStore<Message>>; - - fn deref(&self) -> &Self::Target { - &self.message - } -} - -impl DerefMut for MacawMessage { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.message - } -} - -#[derive(Clone)] -struct MacawUser { - user: StateListener<BareJID, ArcStore<User>>, -} - -impl MacawUser { - fn got_user(user: User) -> Self { - - let user_state_store: StateStore<BareJID, ArcStore<User>> = - use_context().expect("no user state store"); - let user = user_state_store.store(user.jid.clone(), ArcStore::new(user)); - Self { user } - } -} - -impl Deref for MacawUser { - type Target = StateListener<BareJID, ArcStore<User>>; - - fn deref(&self) -> &Self::Target { - &self.user - } -} - -impl DerefMut for MacawUser { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.user - } -} - -#[derive(Clone)] -struct MacawContact { - contact: Store<Contact>, - user: StateListener<BareJID, ArcStore<User>>, -} - -impl MacawContact { - fn got_contact_and_user(contact: Contact, user: User) -> Self { - let contact = Store::new(contact); - let user_state_store: StateStore<BareJID, ArcStore<User>> = - use_context().expect("no user state store"); - let user = user_state_store.store(user.jid.clone(), ArcStore::new(user)); - Self { contact, user } - } -} - -impl Deref for MacawContact { - type Target = Store<Contact>; - - fn deref(&self) -> &Self::Target { - &self.contact - } -} - -impl DerefMut for MacawContact { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.contact - } -} - -#[component] -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.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> - } -} - -#[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] -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 = user_state_store.store(user.jid.clone(), ArcStore::new(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> - } -} - -#[component] -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> - } -} - -#[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] -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> - } -} - -#[component] -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> - } -} - -pub async fn get_avatar(user: Store<User>) -> String { - if let Some(avatar) = &user.read().avatar { - let client = use_context::<Client>().expect("client not in context"); - if let Some(data) = client.file_store.get_src(avatar).await { - data - } else { - NO_AVATAR.to_string() - } - // TODO: enable avatar fetching - // format!("/files/{}", avatar) - } else { - NO_AVATAR.to_string() - } -} - -pub fn get_name(user: Store<User>, note_to_self: bool) -> String { - let roster: Store<Roster> = use_context().expect("no roster in context"); - if note_to_self { - let client: Client = use_context().expect("no client in context"); - if *client.jid == *user.jid().read() { - return "Note to self".to_string() - } - } - if let Some(name) = roster - .contacts() - .read() - .get(&user.read().jid) - .map(|contact| contact.read().name.clone()) - .unwrap_or_default() - { - name.to_string() - } else if let Some(nick) = &user.read().nick { - nick.to_string() - } else { - user.read().jid.to_string() - } -} - -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, - } - } -} - -#[component] -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/message.rs b/src/message.rs new file mode 100644 index 0000000..e5caed1 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,39 @@ +use std::ops::{Deref, DerefMut}; + +use filamento::{chat::Message, user::User}; +use reactive_stores::ArcStore; +use uuid::Uuid; +use leptos::prelude::*; + +use crate::{state_store::{StateListener, StateStore}, user::MacawUser}; + +#[derive(Clone)] +pub struct MacawMessage { + pub message: StateListener<Uuid, ArcStore<Message>>, + pub user: MacawUser, +} + +impl MacawMessage { + pub fn got_message_and_user(message: Message, user: User) -> Self { + let message_state_store: StateStore<Uuid, ArcStore<Message>> = + use_context().expect("no message state store"); + let message = message_state_store.store(message.id, ArcStore::new(message)); + let user = MacawUser::got_user(user); + Self { message, user } + } +} + +impl Deref for MacawMessage { + type Target = StateListener<Uuid, ArcStore<Message>>; + + fn deref(&self) -> &Self::Target { + &self.message + } +} + +impl DerefMut for MacawMessage { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.message + } +} + diff --git a/src/message_subscriptions.rs b/src/message_subscriptions.rs new file mode 100644 index 0000000..5b1d276 --- /dev/null +++ b/src/message_subscriptions.rs @@ -0,0 +1,86 @@ +use std::collections::HashMap; + +use jid::BareJID; +use tokio::sync::mpsc::{self, Receiver}; +use uuid::Uuid; + +use crate::message::MacawMessage; + +pub struct MessageSubscriptions { + all: HashMap<Uuid, mpsc::Sender<(BareJID, MacawMessage)>>, + subset: HashMap<BareJID, HashMap<Uuid, mpsc::Sender<MacawMessage>>>, +} + +impl MessageSubscriptions { + pub fn new() -> Self { + Self { + all: HashMap::new(), + subset: HashMap::new(), + } + } + + pub async fn broadcast(&mut self, to: BareJID, message: MacawMessage) { + // subscriptions to all + let mut removals = Vec::new(); + for (id, sender) in &self.all { + match sender.send((to.clone(), message.clone())).await { + Ok(_) => {} + Err(_) => { + removals.push(*id); + } + } + } + for removal in removals { + self.all.remove(&removal); + } + + // subscriptions to specific chat + if let Some(subscribers) = self.subset.get_mut(&to) { + let mut removals = Vec::new(); + for (id, sender) in &*subscribers { + match sender.send(message.clone()).await { + Ok(_) => {} + Err(_) => { + removals.push(*id); + } + } + } + for removal in removals { + subscribers.remove(&removal); + } + if subscribers.is_empty() { + self.subset.remove(&to); + } + } + } + + pub fn subscribe_all(&mut self) -> (Uuid, Receiver<(BareJID, MacawMessage)>) { + let (send, recv) = mpsc::channel(10); + let id = Uuid::new_v4(); + self.all.insert(id, send); + (id, recv) + } + + pub fn subscribe_chat(&mut self, chat: BareJID) -> (Uuid, Receiver<MacawMessage>) { + let (send, recv) = mpsc::channel(10); + let id = Uuid::new_v4(); + if let Some(chat_subscribers) = self.subset.get_mut(&chat) { + chat_subscribers.insert(id, send); + } else { + let hash_map = HashMap::from([(id, send)]); + self.subset.insert(chat, hash_map); + } + (id, recv) + } + + pub fn unsubscribe_all(&mut self, sub_id: Uuid) { + self.all.remove(&sub_id); + } + + pub fn unsubscribe_chat(&mut self, sub_id: Uuid, chat: BareJID) { + if let Some(chat_subs) = self.subset.get_mut(&chat) { + chat_subs.remove(&sub_id); + } + } +} + diff --git a/src/open_chats.rs b/src/open_chats.rs new file mode 100644 index 0000000..ed89537 --- /dev/null +++ b/src/open_chats.rs @@ -0,0 +1,88 @@ +use filamento::chat::ChatStoreFields; +use indexmap::IndexMap; +use jid::BareJID; +use reactive_stores::{ArcStore, Store}; +use tracing::debug; +use leptos::prelude::*; + +use crate::chat::MacawChat; + +#[derive(Store, Default)] +pub struct OpenChatsPanel { + // jid must be a chat in the chats map + chat_view: Option<BareJID>, + #[store(key: BareJID = |(jid, _)| jid.clone())] + chats: IndexMap<BareJID, MacawChat>, +} + +pub fn open_chat(open_chats: Store<OpenChatsPanel>, chat: MacawChat) { + if let Some(jid) = &*open_chats.chat_view().read() { + if let Some((index, _jid, entry)) = open_chats.chats().write().shift_remove_full(jid) { + let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) + .correspondent() + .read() + .clone(); + open_chats + .chats() + .write() + .insert_before(index, new_jid.clone(), chat); + *open_chats.chat_view().write() = Some(new_jid); + } else { + let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) + .correspondent() + .read() + .clone(); + open_chats.chats().write().insert(new_jid.clone(), chat); + *open_chats.chat_view().write() = Some(new_jid); + } + } else { + let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) + .correspondent() + .read() + .clone(); + open_chats.chats().write().insert(new_jid.clone(), chat); + *open_chats.chat_view().write() = Some(new_jid); + } +} + +impl OpenChatsPanel { + pub fn open(&mut self, chat: MacawChat) { + if let Some(jid) = &mut self.chat_view { + debug!("a chat was already open"); + if let Some((index, _jid, entry)) = self.chats.shift_remove_full(jid) { + let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) + .correspondent() + .read() + .clone(); + self.chats.insert_before(index, new_jid.clone(), chat); + *&mut self.chat_view = Some(new_jid); + } else { + let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) + .correspondent() + .read() + .clone(); + self.chats.insert(new_jid.clone(), chat); + *&mut self.chat_view = Some(new_jid); + } + } else { + let new_jid = <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat) + .correspondent() + .read() + .clone(); + self.chats.insert(new_jid.clone(), chat); + *&mut self.chat_view = Some(new_jid); + } + debug!("opened chat"); + } + + // TODO: + // pub fn open_in_new_tab_unfocused(&mut self) { + + // } + + // pub fn open_in_new_tab_focus(&mut self) { + + // } +} + + diff --git a/src/roster.rs b/src/roster.rs new file mode 100644 index 0000000..75f4c3b --- /dev/null +++ b/src/roster.rs @@ -0,0 +1,21 @@ +use std::collections::HashMap; + +use jid::BareJID; +use reactive_stores::Store; + +use crate::contact::MacawContact; + +#[derive(Store, Clone)] +pub struct Roster { + #[store(key: BareJID = |(jid, _)| jid.clone())] + contacts: HashMap<BareJID, MacawContact>, +} + +impl Roster { + pub fn new() -> Self { + Self { + contacts: HashMap::new(), + } + } +} + diff --git a/src/state_store.rs b/src/state_store.rs new file mode 100644 index 0000000..2536cda --- /dev/null +++ b/src/state_store.rs @@ -0,0 +1,238 @@ +use std::{collections::HashMap, ops::{Deref, DerefMut}, sync::{Arc, RwLock}}; + +use leptos::prelude::*; + +// TODO: get rid of this +// V has to be an arc signal +#[derive(Debug)] +pub struct ArcStateStore<K, V> { + store: Arc<RwLock<HashMap<K, (V, usize)>>>, +} + +impl<K, V> PartialEq for ArcStateStore<K, V> { + fn eq(&self, other: &Self) -> bool { + Arc::ptr_eq(&self.store, &other.store) + } +} + +impl<K, V> Clone for ArcStateStore<K, V> { + fn clone(&self) -> Self { + Self { + store: Arc::clone(&self.store), + } + } +} + +impl<K, V> Eq for ArcStateStore<K, V> {} + +impl<K, V> ArcStateStore<K, V> { + pub fn new() -> Self { + Self { + store: Arc::new(RwLock::new(HashMap::new())), + } + } +} + +#[derive(Debug)] +pub struct StateStore<K, V, S = SyncStorage> { + inner: ArenaItem<ArcStateStore<K, V>, S>, +} + +impl<K, V, S> Dispose for StateStore<K, V, S> { + fn dispose(self) { + self.inner.dispose() + } +} + +impl<K, V> StateStore<K, V> +where + K: Send + Sync + 'static, + V: Send + Sync + 'static, +{ + pub fn new() -> Self { + Self::new_with_storage() + } +} + +impl<K, V, S> StateStore<K, V, S> +where + K: 'static, + V: 'static, + S: Storage<ArcStateStore<K, V>>, +{ + pub fn new_with_storage() -> Self { + Self { + inner: ArenaItem::new_with_storage(ArcStateStore::new()), + } + } +} + +impl<K, V> StateStore<K, V, LocalStorage> +where + K: 'static, + V: 'static, +{ + pub fn new_local() -> Self { + Self::new_with_storage() + } +} + +impl< + K: std::marker::Send + std::marker::Sync + 'static, + V: std::marker::Send + std::marker::Sync + 'static, +> From<ArcStateStore<K, V>> for StateStore<K, V> +{ + fn from(value: ArcStateStore<K, V>) -> Self { + Self { + inner: ArenaItem::new_with_storage(value), + } + } +} + +impl<K: 'static, V: 'static> FromLocal<ArcStateStore<K, V>> for StateStore<K, V, LocalStorage> { + fn from_local(value: ArcStateStore<K, V>) -> Self { + Self { + inner: ArenaItem::new_with_storage(value), + } + } +} + +impl<K, V, S> Copy for StateStore<K, V, S> {} + +impl<K, V, S> Clone for StateStore<K, V, S> { + fn clone(&self) -> Self { + *self + } +} + +impl<K: Eq + std::hash::Hash + Clone, V: Clone> StateStore<K, V> +where + K: Send + Sync + 'static, + V: Send + Sync + 'static, +{ + pub fn store(&self, key: K, value: V) -> StateListener<K, V> { + { + let store = self.inner.try_get_value().unwrap(); + let mut store = store.store.write().unwrap(); + if let Some((v, count)) = store.get_mut(&key) { + *v = value.clone(); + *count += 1; + } else { + store.insert(key.clone(), (value.clone(), 1)); + } + }; + StateListener { + value, + cleaner: StateCleaner { + key, + state_store: self.clone(), + }, + } + } +} + +impl<K, V> StateStore<K, V> +where + K: Eq + std::hash::Hash + Send + Sync + 'static, + V: Send + Sync + 'static, +{ + pub fn update(&self, key: &K, value: V) { + let store = self.inner.try_get_value().unwrap(); + let mut store = store.store.write().unwrap(); + if let Some((v, _)) = store.get_mut(key) { + *v = value; + } + } + + pub fn modify(&self, key: &K, modify: impl Fn(&mut V)) { + let store = self.inner.try_get_value().unwrap(); + let mut store = store.store.write().unwrap(); + if let Some((v, _)) = store.get_mut(key) { + modify(v); + } + } + + fn remove(&self, key: &K) { + // let store = self.inner.try_get_value().unwrap(); + // let mut store = store.store.write().unwrap(); + // if let Some((_v, count)) = store.get_mut(key) { + // *count -= 1; + // if *count == 0 { + // store.remove(key); + // debug!("dropped item from store"); + // } + // } + } +} + +#[derive(Clone)] +pub struct StateListener<K, V> +where + K: Eq + std::hash::Hash + 'static + std::marker::Send + std::marker::Sync, + V: 'static + std::marker::Send + std::marker::Sync, +{ + value: V, + cleaner: StateCleaner<K, V>, +} + +impl< + K: std::cmp::Eq + std::hash::Hash + std::marker::Send + std::marker::Sync, + V: std::marker::Send + std::marker::Sync, +> Deref for StateListener<K, V> +{ + type Target = V; + + fn deref(&self) -> &Self::Target { + &self.value + } +} + +impl<K: std::cmp::Eq + std::hash::Hash + Send + Sync, V: Send + Sync> DerefMut + for StateListener<K, V> +{ + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.value + } +} + +struct ArcStateCleaner<K, V> { + key: K, + state_store: ArcStateStore<K, V>, +} + +struct StateCleaner<K, V> +where + K: Eq + std::hash::Hash + Send + Sync + 'static, + V: Send + Sync + 'static, +{ + key: K, + state_store: StateStore<K, V>, +} + +impl<K, V> Clone for StateCleaner<K, V> +where + K: Eq + std::hash::Hash + Clone + Send + Sync, + V: Send + Sync, +{ + fn clone(&self) -> Self { + { + let store = self.state_store.inner.try_get_value().unwrap(); + let mut store = store.store.write().unwrap(); + if let Some((_v, count)) = store.get_mut(&self.key) { + *count += 1; + } + } + Self { + key: self.key.clone(), + state_store: self.state_store.clone(), + } + } +} + +impl<K: Eq + std::hash::Hash + Send + Sync + 'static, V: Send + Sync + 'static> Drop + for StateCleaner<K, V> +{ + fn drop(&mut self) { + self.state_store.remove(&self.key); + } +} diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..f55c0dd --- /dev/null +++ b/src/user.rs @@ -0,0 +1,78 @@ +use std::ops::{Deref, DerefMut}; + +use filamento::user::{User, UserStoreFields}; +use jid::BareJID; +use reactive_stores::{ArcStore, Store}; +use leptos::prelude::*; + +use crate::{client::Client, roster::{Roster, RosterStoreFields}, state_store::{StateListener, StateStore}}; + +#[derive(Clone)] +pub struct MacawUser { + pub user: StateListener<BareJID, ArcStore<User>>, +} + +impl MacawUser { + pub fn got_user(user: User) -> Self { + + let user_state_store: StateStore<BareJID, ArcStore<User>> = + use_context().expect("no user state store"); + let user = user_state_store.store(user.jid.clone(), ArcStore::new(user)); + Self { user } + } +} + +impl Deref for MacawUser { + type Target = StateListener<BareJID, ArcStore<User>>; + + fn deref(&self) -> &Self::Target { + &self.user + } +} + +impl DerefMut for MacawUser { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.user + } +} + +pub const NO_AVATAR: &str = "/assets/no-avatar.png"; + +pub async fn get_avatar(user: Store<User>) -> String { + if let Some(avatar) = &user.read().avatar { + let client = use_context::<Client>().expect("client not in context"); + if let Some(data) = client.file_store.get_src(avatar).await { + data + } else { + NO_AVATAR.to_string() + } + // TODO: enable avatar fetching + // format!("/files/{}", avatar) + } else { + NO_AVATAR.to_string() + } +} + +pub fn get_name(user: Store<User>, note_to_self: bool) -> String { + let roster: Store<Roster> = use_context().expect("no roster in context"); + if note_to_self { + let client: Client = use_context().expect("no client in context"); + if *client.jid == *user.jid().read() { + return "Note to self".to_string() + } + } + if let Some(name) = roster + .contacts() + .read() + .get(&user.read().jid) + .map(|contact| contact.read().name.clone()) + .unwrap_or_default() + { + name.to_string() + } else if let Some(nick) = &user.read().nick { + nick.to_string() + } else { + user.read().jid.to_string() + } +} + diff --git a/src/user_presences.rs b/src/user_presences.rs new file mode 100644 index 0000000..1a719a2 --- /dev/null +++ b/src/user_presences.rs @@ -0,0 +1,133 @@ +use std::collections::HashMap; + +use chrono::Utc; +use filamento::presence::{Offline, Presence, PresenceType, Show}; +use indexmap::IndexMap; +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::Store; + +#[derive(Store)] +pub struct UserPresences { + #[store(key: BareJID = |(jid, _)| jid.clone())] + pub user_presences: HashMap<BareJID, ArcRwSignal<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: &BareJID) -> ArcRwSignal<Presences> { + if let Some(presences) = self.user_presences.get(user) { + presences.clone() + } else { + let presences = Presences::new(); + let signal = ArcRwSignal::new(presences); + self.user_presences.insert(user.clone(), signal.clone()); + 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, + ); + } + + pub fn resource_presence(&mut self, resource: String) -> Presence { + if let Some(presence) = self.presences.get(&resource) { + presence.clone() + } else { + Presence { + timestamp: Utc::now(), + presence: PresenceType::Offline(Offline::default()), + } + } + } +} + diff --git a/src/views/login_page.rs b/src/views/login_page.rs new file mode 100644 index 0000000..2edd4b5 --- /dev/null +++ b/src/views/login_page.rs @@ -0,0 +1,189 @@ +use std::{str::FromStr, sync::Arc}; + +use filamento::{db::Db, error::{CommandError, ConnectionError}, files::{opfs::OPFSError, FilesMem, FilesOPFS}, UpdateMessage}; +use jid::JID; +use thiserror::Error; +use leptos::prelude::*; +use tokio::sync::mpsc::Receiver; +use tracing::debug; + +use crate::{client::Client, files::Files}; + +use super::AppState; + +#[derive(Clone, Debug, Error)] +pub enum LoginError { + #[error("Missing Password")] + MissingPassword, + #[error("Missing JID")] + MissingJID, + #[error("Invalid JID: {0}")] + InvalidJID(#[from] jid::ParseError), + #[error("Connection Error: {0}")] + ConnectionError(#[from] CommandError<ConnectionError>), + #[error("OPFS: {0}")] + OPFS(#[from] OPFSError), +} + +#[component] +pub fn LoginPage( + set_app: WriteSignal<AppState>, + set_client: WriteSignal<Option<(Client, Receiver<UpdateMessage>)>>, +) -> impl IntoView { + let jid = RwSignal::new("".to_string()); + let password = RwSignal::new("".to_string()); + let remember_me = RwSignal::new(false); + let connect_on_login = RwSignal::new(true); + + let (error, set_error) = signal(None::<LoginError>); + 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 (login_pending, set_login_pending) = signal(false); + + let login = Action::new_local(move |_| { + async move { + set_login_pending.set(true); + + if jid.read_untracked().is_empty() { + set_error.set(Some(LoginError::MissingJID)); + set_login_pending.set(false); + return; + } + + if password.read_untracked().is_empty() { + set_error.set(Some(LoginError::MissingPassword)); + set_login_pending.set(false); + return; + } + + let jid = match JID::from_str(&jid.read_untracked()) { + Ok(j) => j, + Err(e) => { + set_error.set(Some(e.into())); + set_login_pending.set(false); + return; + } + }; + + let remember_me = remember_me.get_untracked(); + // initialise the client + let db = if remember_me { + debug!("creating db in opfs"); + Db::create_connect_and_migrate(jid.as_bare().to_string()) + .await + .unwrap() + } else { + debug!("creating db in memory"); + Db::create_connect_and_migrate_memory().await.unwrap() + }; + let files = if remember_me { + let opfs = FilesOPFS::new(jid.as_bare().to_string()).await; + match opfs { + Ok(f) => Files::Opfs(f), + Err(e) => { + set_error.set(Some(e.into())); + set_login_pending.set(false); + return; + } + } + } else { + Files::Mem(FilesMem::new()) + }; + let (client, updates) = filamento::Client::new( + jid.clone(), + password.read_untracked().clone(), + db, + files.clone(), + ); + let resource = ArcRwSignal::new(None::<String>); + let client = Client { + client, + resource: resource.clone(), + jid: Arc::new(jid.to_bare()), + file_store: files, + }; + + if *connect_on_login.read_untracked() { + match client.connect().await { + Ok(r) => { + resource.set(Some(r)) + } + Err(e) => { + set_error.set(Some(e.into())); + set_login_pending.set(false); + return; + } + } + } + + // debug!("before setting app state"); + set_client.set(Some((client, updates))); + set_app.set(AppState::LoggedIn); + } + }); + + view! { + <div class="center fill"> + <div id="login-form" class="panel"> + <div id="hero"> + <img src="/assets/macaw-icon.png" /> + <h1>Macaw Instant Messenger</h1> + </div> + {error_message} + <form on:submit=move |ev| { + ev.prevent_default(); + login.dispatch(()); + }> + <label for="jid">JID</label> + <input + disabled=login_pending + placeholder="caw@macaw.chat" + type="text" + bind:value=jid + name="jid" + id="jid" + autofocus="true" + /> + <label for="password">Password</label> + <input + disabled=login_pending + placeholder="••••••••" + type="password" + bind:value=password + name="password" + id="password" + /> + <div> + <label for="remember_me">Remember me</label> + <input + disabled=login_pending + type="checkbox" + bind:checked=remember_me + name="remember_me" + id="remember_me" + /> + </div> + <div> + <label for="connect_on_login">Connect on login</label> + <input + disabled=login_pending + type="checkbox" + bind:checked=connect_on_login + name="connect_on_login" + id="connect_on_login" + /> + </div> + <input disabled=login_pending class="button" type="submit" value="Log In" /> + </form> + </div> + </div> + } +} diff --git a/src/views/macaw.rs b/src/views/macaw.rs new file mode 100644 index 0000000..18e0ad3 --- /dev/null +++ b/src/views/macaw.rs @@ -0,0 +1,162 @@ +use std::collections::HashSet; + +use filamento::{chat::{Chat, Message, MessageStoreFields}, user::User, UpdateMessage}; +use jid::BareJID; +use leptos::{prelude::*, task::spawn_local}; +use open_chats_panel::OpenChatsPanelView; +use reactive_stores::{ArcStore, Store}; +use settings::{Settings, SettingsPage}; +use tokio::sync::mpsc::Receiver; +use tracing::debug; +use uuid::Uuid; + +use crate::{client::Client, components::sidebar::Sidebar, contact::MacawContact, message::MacawMessage, message_subscriptions::MessageSubscriptions, open_chats::OpenChatsPanel, roster::{Roster, RosterStoreFields}, state_store::StateStore, user::MacawUser, user_presences::{Presences, UserPresences}}; + +use super::AppState; + +pub mod settings; +mod open_chats_panel; + +#[component] +pub fn Macaw( + // TODO: logout + // app_state: WriteSignal<Option<essage>)>, LocalStorage>, + client: Client, + mut updates: Receiver<UpdateMessage>, + set_app: WriteSignal<AppState>, +) -> impl IntoView { + provide_context(set_app); + provide_context(client); + + let roster = Store::new(Roster::new()); + provide_context(roster); + + let message_subscriptions = RwSignal::new(MessageSubscriptions::new()); + provide_context(message_subscriptions); + + let messages_store: StateStore<Uuid, ArcStore<Message>> = StateStore::new(); + provide_context(messages_store); + let chats_store: StateStore<BareJID, ArcStore<Chat>> = StateStore::new(); + provide_context(chats_store); + let users_store: StateStore<BareJID, ArcStore<User>> = StateStore::new(); + provide_context(users_store); + + let open_chats = Store::new(OpenChatsPanel::default()); + provide_context(open_chats); + let show_settings = RwSignal::new(None::<SettingsPage>); + provide_context(show_settings); + + let user_presences = Store::new(UserPresences::new()); + provide_context(user_presences); + + let client_user = LocalResource::new(move || { + async move { + let client = use_context::<Client>().expect("client not in context"); + let user = client.get_user((*client.jid).clone()).await.unwrap(); + MacawUser::got_user(user) + } + }); + provide_context(client_user); + + // TODO: timestamp incoming/outgoing subscription requests + let (subscription_requests, set_subscription_requests)= signal(HashSet::<BareJID>::new()); + provide_context(subscription_requests); + provide_context(set_subscription_requests); + + // TODO: get cached contacts on login before getting the updated contacts + + OnceResource::new(async move { + while let Some(update) = updates.recv().await { + match update { + UpdateMessage::Online(online, items) => { + let contacts = items + .into_iter() + .map(|(contact, user)| { + ( + contact.user_jid.clone(), + MacawContact::got_contact_and_user(contact, user), + ) + }) + .collect(); + roster.contacts().set(contacts); + } + 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) { + macaw_contact.set(contact); + } else { + let jid = contact.user_jid.clone(); + let contact = MacawContact::got_contact_and_user(contact, user); + roster.insert(jid, contact); + } + }); + } + UpdateMessage::RosterDelete(jid) => { + roster.contacts().update(|roster| { + roster.remove(&jid); + }); + } + UpdateMessage::Presence { from, presence } => { + let bare_jid = from.to_bare(); + if let Some(presences) = user_presences.read().user_presences.get(&bare_jid) { + if let Some(resource) = from.resourcepart() { + presences.write().update_presence(resource.clone(), presence); + } + } else { + if let Some(resource) = from.resourcepart() { + let mut presences = Presences::new(); + presences.update_presence(resource.clone(), presence); + user_presences.write().user_presences.insert(bare_jid, ArcRwSignal::new(presences)); + } + } + } + UpdateMessage::Message { to, from, message } => { + debug!("before got message"); + let new_message = MacawMessage::got_message_and_user(message, from); + debug!("after got message"); + spawn_local(async move { + message_subscriptions + .write() + .broadcast(to, new_message) + .await + }); + debug!("after set message"); + } + UpdateMessage::MessageDelivery { id, chat, delivery } => { + messages_store.modify(&id, |message| { + <ArcStore<filamento::chat::Message> as Clone>::clone(&message) + .delivery() + .set(Some(delivery)) + }); + } + UpdateMessage::SubscriptionRequest(jid) => { + set_subscription_requests.update(|req| { req.insert(jid); }); + } + UpdateMessage::NickChanged { jid, nick } => { + users_store.modify(&jid, |user| { + user.update(|user| *&mut user.nick = nick.clone()) + }); + } + UpdateMessage::AvatarChanged { jid, id } => { + users_store.modify(&jid, |user| *&mut user.write().avatar = id.clone()); + } + } + } + }); + + view! { + <Sidebar /> + // <ChatsList /> + <OpenChatsPanelView /> + {move || if let Some(_) = *show_settings.read() { + view! { <Settings /> }.into_any() + } else { + view! {}.into_any() + }} + } +} + diff --git a/src/views/macaw/open_chats_panel.rs b/src/views/macaw/open_chats_panel.rs new file mode 100644 index 0000000..062c786 --- /dev/null +++ b/src/views/macaw/open_chats_panel.rs @@ -0,0 +1,73 @@ +use leptos::prelude::*; +use open_chat::OpenChatView; +use reactive_stores::{ArcStore, Store}; + +use crate::open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}; + +// TODO: multiple panels +// pub struct OpenChats { +// panels: +// } + +#[component] +pub fn OpenChatsPanelView() -> impl IntoView { + let open_chats: Store<OpenChatsPanel> = use_context().expect("no open chats panel in context"); + + // TODO: tabs + // view! { + // {move || { + // if open_chats.chats().read().len() > 1 { + // Some( + // view! { + // <For + // each=move || open_chats.chats().get() + // key=|(jid, _)| jid.clone() + // let(chat) + // ></For> + // }, + // ) + // } else { + // None + // } + // }} + // } + view! { + <div class="open-chat-views"> + {move || { + if let Some(open_chat) = open_chats.chat_view().get() { + if let Some(open_chat) = open_chats.chats().read().get(&open_chat) { + view! { <OpenChatView chat=open_chat.clone() /> }.into_any() + } else { + view! {}.into_any() + } + } else { + view! {}.into_any() + } + }} + </div> + } +} + +mod open_chat { + use filamento::chat::{Chat, ChatStoreFields}; + use leptos::prelude::*; + use reactive_stores::{ArcStore, Store}; + + use crate::{chat::MacawChat, components::{chat_header::ChatViewHeader, message_composer::ChatViewMessageComposer, message_history_buffer::MessageHistoryBuffer}}; + + #[component] + pub fn OpenChatView(chat: MacawChat) -> impl IntoView { + let chat_chat: Store<Chat> = + <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat).into(); + let chat_jid = move || chat_chat.correspondent().get(); + + view! { + <div class="open-chat-view"> + <ChatViewHeader chat=chat.clone() /> + <MessageHistoryBuffer chat=chat.clone() /> + <ChatViewMessageComposer chat=chat_jid() /> + </div> + } + } +} + diff --git a/src/views/macaw/settings.rs b/src/views/macaw/settings.rs new file mode 100644 index 0000000..11a3fc3 --- /dev/null +++ b/src/views/macaw/settings.rs @@ -0,0 +1,170 @@ +use leptos::prelude::*; +use profile_settings::ProfileSettings; + +use crate::{components::{icon::IconComponent, modal::Modal}, icon::Icon}; + +mod profile_settings { + use filamento::error::{AvatarPublishError, CommandError}; + use thiserror::Error; + use leptos::prelude::*; + use web_sys::{js_sys::Uint8Array, wasm_bindgen::{prelude::Closure, JsCast, UnwrapThrowExt}, Event, FileReader, HtmlInputElement, ProgressEvent}; + + use crate::{client::Client, files::Files}; + + #[derive(Debug, Clone, Error)] + pub enum ProfileSaveError { + #[error("avatar publish: {0}")] + Avatar(#[from] CommandError<AvatarPublishError<Files>>), + } + + #[component] + pub fn ProfileSettings() -> impl IntoView { + let client: Client = use_context().expect("no client in context"); + + // TODO: compartmentalise into error component, form component... + let (error, set_error) = signal(None::<ProfileSaveError>); + 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 (profile_save_pending, set_profile_save_pending) = signal(false); + + let profile_upload_data = RwSignal::new(None::<Vec<u8>>); + let from_input = move |ev: Event| { + let elem = ev.target().unwrap().unchecked_into::<HtmlInputElement>(); + + // let UploadSignal(file_signal) = expect_context(); + // file_signal.update(Vec::clear); // Clear list from previous change + let files = elem.files().unwrap_throw(); + + if let Some(file) = files.get(0) { + let reader = FileReader::new().unwrap_throw(); + // let name = file.name(); + + // This closure only needs to be called a single time as we will just + // remake it on each loop + // * web_sys drops it for us when using this specific constructor + let read_file = { + // FileReader is cloned prior to moving into the closure + let reader = reader.to_owned(); + Closure::once_into_js(move |_: ProgressEvent| { + // `.result` valid after the `read_*` completes on FileReader + // https://developer.mozilla.org/en-US/docs/Web/API/FileReader/result + let result = reader.result().unwrap_throw(); + let data= Uint8Array::new(&result).to_vec(); + // Do whatever you want with the Vec<u8> + profile_upload_data.set(Some(data)); + }) + }; + reader.set_onloadend(Some(read_file.as_ref().unchecked_ref())); + + // read_as_array_buffer takes a &Blob + // + // Per https://w3c.github.io/FileAPI/#file-section + // > A File object is a Blob object with a name attribute.. + // + // File is a subclass (inherits) from the Blob interface, so a File + // can be used anywhere a Blob is required. + reader.read_as_array_buffer(&file).unwrap_throw(); + + // You can also use `.read_as_text(&file)` instead if you just want a string. + // This example shows how to extract an array buffer as it is more flexible + // + // If you use `.read_as_text` change the closure from ... + // + // let result = reader.result().unwrap_throw(); + // let vec_of_u8_bytes = Uint8Array::new(&result).to_vec(); + // let content = String::from_utf8(vec_of_u8_bytes).unwrap_throw(); + // + // to ... + // + // let result = reader.result().unwrap_throw(); + // let content = result.as_string().unwrap_throw(); + } else { + profile_upload_data.set(None); + } + }; + + let save_profile = Action::new_local(move |_| { + let client = client.clone(); + async move {} + }); + + let new_nick= RwSignal::new("".to_string()); + + view! { + <div class="profile-settings"> + <form on:submit=move |ev| { + ev.prevent_default(); + save_profile.dispatch(()); + }> + {error_message} + <div class="change-avatar"> + <input type="file" id="client-user-avatar" on:change=from_input /> + </div> + <input disabled=profile_save_pending placeholder="Nickname" type="text" id="client-user-nick" bind:value=new_nick name="client-user-nick" /> + <input disabled=profile_save_pending class="button" type="submit" value="Save Changes" /> + </form> + </div> + <div class="profile-preview"> + <h2>Profile Preview</h2> + <div class="preview"> + <img /> + <div>nick</div> + </div> + </div> + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SettingsPage { + Account, + Chat, + Profile, + Privacy, +} + +#[component] +pub fn Settings() -> impl IntoView { + let show_settings: RwSignal<Option<SettingsPage>> = use_context().unwrap(); + + view! { + <Modal on_background_click=move |_| { show_settings.set(None); }> + <div class="settings panel"> + <div class="header"> + <h2>Settings</h2> + <div class="header-icon close"> + <IconComponent icon=Icon::Close24 on:click=move |_| show_settings.set(None)/> + </div> + </div> + <div class="settings-main"> + <div class="settings-sidebar"> + <div class:open=move || *show_settings.read() == Some(SettingsPage::Account) on:click=move |_| show_settings.set(Some(SettingsPage::Account))>Account</div> + <div class:open=move || *show_settings.read() == Some(SettingsPage::Chat) on:click=move |_| show_settings.set(Some(SettingsPage::Chat))>Chat</div> + <div class:open=move || *show_settings.read() == Some(SettingsPage::Privacy) on:click=move |_| show_settings.set(Some(SettingsPage::Privacy))>Privacy</div> + <div class:open=move || *show_settings.read() == Some(SettingsPage::Profile) on:click=move |_| show_settings.set(Some(SettingsPage::Profile))>Profile</div> + </div> + <div class="settings-page"> + {move || if let Some(page) = show_settings.get() { + match page { + SettingsPage::Account => view! { <div>"account"</div> }.into_any(), + SettingsPage::Chat => view! { <div>"chat"</div> }.into_any(), + SettingsPage::Profile => view! { <ProfileSettings /> }.into_any(), + SettingsPage::Privacy => view! { <div>"privacy"</div> }.into_any(), + } + } else { + view! {}.into_any() + }} + </div> + </div> + </div> + </Modal> + } +} + diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..112f930 --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,36 @@ +use filamento::UpdateMessage; +use leptos::prelude::*; +use login_page::LoginPage; +use macaw::Macaw; +use tokio::sync::mpsc::Receiver; + +use crate::client::Client; + +pub mod login_page; +pub mod macaw; + +pub enum AppState { + LoggedOut, + LoggedIn, +} + +#[component] +pub fn App() -> impl IntoView { + let (app, set_app) = signal(AppState::LoggedOut); + let (client, set_client) = signal(None::<(Client, Receiver<UpdateMessage>)>); + + view! { + {move || match &*app.read() { + AppState::LoggedOut => view! { <LoginPage set_app set_client /> }.into_any(), + AppState::LoggedIn => { + if let Some((client, updates)) = set_client.write_untracked().take() { + view! { <Macaw client updates set_app /> }.into_any() + } else { + set_app.set(AppState::LoggedOut); + view! { <LoginPage set_app set_client /> }.into_any() + } + } + }} + } +} + |