diff options
Diffstat (limited to 'src')
39 files changed, 4179 insertions, 1393 deletions
diff --git a/src/chat.rs b/src/chat.rs new file mode 100644 index 0000000..e40119f --- /dev/null +++ b/src/chat.rs @@ -0,0 +1,90 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::ops::{Deref, DerefMut}; + +use filamento::{chat::Chat, user::User}; +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::ArcStore; + +use crate::{ + state_store::{StateListener, StateStore}, + user::{ArcMacawUser, MacawUser}, +}; + +#[derive(Clone, Copy)] +pub struct MacawChat { + pub chat: ArenaItem<StateListener<BareJID, ArcStore<Chat>>>, + pub user: MacawUser, + // user: StateListener<BareJID, ArcStore<User>>, +} + +impl MacawChat { + pub fn get(&self) -> ArcStore<Chat> { + self.try_get_value().unwrap().get() + } +} + +impl Deref for MacawChat { + type Target = ArenaItem<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 + } +} + +impl From<ArcMacawChat> for MacawChat { + fn from(value: ArcMacawChat) -> Self { + Self { + chat: ArenaItem::new_with_storage(value.chat), + user: value.user.into(), + } + } +} + +impl From<MacawChat> for ArcMacawChat { + fn from(value: MacawChat) -> Self { + Self { + chat: value.chat.try_get_value().unwrap(), + user: value.user.into(), + } + } +} + +#[derive(Clone)] +pub struct ArcMacawChat { + pub chat: StateListener<BareJID, ArcStore<Chat>>, + pub user: ArcMacawUser, +} + +impl ArcMacawChat { + pub async 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 = ArcMacawUser::got_user(user).await; + Self { chat, user } + } +} + +impl Deref for ArcMacawChat { + type Target = StateListener<BareJID, ArcStore<Chat>>; + + fn deref(&self) -> &Self::Target { + &self.chat + } +} + +impl DerefMut for ArcMacawChat { + 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..02ee537 --- /dev/null +++ b/src/client.rs @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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..11d2097 --- /dev/null +++ b/src/components/avatar.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use filamento::{presence::PresenceType, user::User}; +use leptos::prelude::*; +use reactive_stores::Store; + +use crate::{ + components::icon::{IconComponent, show_to_icon}, + icon::Icon, + user::{MacawUser, get_avatar}, + user_presences::UserPresences, +}; + +#[component] +pub fn AvatarWithPresence(user: MacawUser) -> impl IntoView { + let user_presences: Store<UserPresences> = use_context().expect("no user presences in context"); + let presence = move || { + user_presences + .write() + .get_user_presences(&user.get().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 || user.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..3fb5df8 --- /dev/null +++ b/src/components/chat_header.rs @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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 name = move || get_name(chat.user.get().into(), true); + let jid = move || chat.user.get().jid().read().to_string(); + + view! { + <div class="chat-view-header panel"> + {move || { + view! { <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..73ffdff --- /dev/null +++ b/src/components/chats_list.rs @@ -0,0 +1,156 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use chats_list_item::ChatsListItem; +use indexmap::IndexMap; +use jid::BareJID; +use js_sys::{wasm_bindgen::UnwrapThrowExt, Object, Reflect, JSON}; +use leptos::{html::Div, prelude::*}; +use overlay_scrollbars::OverlayScrollbars; +use tracing::debug; + +use crate::{ + chat::{ArcMacawChat, MacawChat}, + client::Client, + components::{icon::IconComponent, new_chat::NewChatWidget, overlay::Overlay}, + icon::Icon, + message::{ArcMacawMessage, 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 mut chats = IndexMap::new(); + for ((chat, chat_user), (message, message_user)) in c { + chats.insert( + chat.correspondent.clone(), + ( + ArcMacawChat::got_chat_and_user(chat, chat_user).await, + ArcMacawMessage::got_message_and_user(message, message_user).await, + ), + ); + } + 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.get().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 = ArcMacawChat::got_chat_and_user(chat, user).await; + 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_untracked() { + new_messages_signal.write().unsubscribe_all(sub_id); + } + }); + + let chats_list: NodeRef<Div> = NodeRef::new(); + let chats_list_viewport: NodeRef<Div> = NodeRef::new(); + + let _scrollbars = Effect::new(move |_| { + if let Some(buffer) = chats_list.get() { + if let Some(viewport) = chats_list_viewport.get() { + let scrollbars_obj = Object::new(); + // Reflect::set(&scrollbars_obj, &"theme".into(), &"os-macaw".into()).unwrap_throw(); + // Reflect::set(&scrollbars_obj, &"autoHide".into(), &"leave".into()).unwrap_throw(); + let options_obj = Object::new(); + // Reflect::set(&options_obj, &"scrollbars".into(), &scrollbars_obj).unwrap_throw(); + + let elements_obj = Object::new(); + Reflect::set(&elements_obj, &"viewport".into(), &viewport.into()).unwrap_throw(); + let element_obj = Object::new(); + Reflect::set(&elements_obj, &"elements".into(), &elements_obj).unwrap_throw(); + Reflect::set(&element_obj, &"target".into(), &buffer.into()).unwrap_throw(); + // let element = Object::define_property(&Object::define_property(&Object::new(), &"target".into(), &buffer.into()), &"elements".into(), &Object::define_property(&Object::new(), &"viewport".into(), &viewport.into())); + debug!("scrollable element: {}", JSON::stringify(&element_obj.clone().into()).unwrap_throw()); + OverlayScrollbars(element_obj, options_obj); + } + } + }); + + 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="overlay-scroll" node_ref=chats_list> + <div class="chats-list-chats" node_ref=chats_list_viewport> + <For + each=move || chats.get() + key=|chat| chat.1.1.message.get().read().id + let(chat) + > + <ChatsListItem chat=chat.1.0.into() message=chat.1.1.into() /> + </For> + </div> + </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..3e18dbe --- /dev/null +++ b/src/components/chats_list/chats_list_item.rs @@ -0,0 +1,93 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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 name = move || get_name(chat.user.get().into(), true); + + // TODO: store fine-grained reactivity + let latest_message_body = move || message.get().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.into())); + // open_chats.update(|open_chats| open_chats.open(chat.chat.try_get_value().unwrap().get().clone())); + }; + + let open = move || { + if let Some(open_chat) = &*open_chats.chat_view().read() { + debug!("got open chat: {:?}", open_chat); + if *open_chat == *chat.get().correspondent().read() { + return Open::Focused; + } + } + if let Some(_backgrounded_chat) = open_chats + .chats() + .read() + .get(chat.get().correspondent().read().deref()) + { + return Open::Open; + } + Open::Closed + }; + let focused = move || open().is_focused(); + let open = move || open().is_open(); + + let date = move || message.get().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 + > + {move || { + view! { <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..73b0f5d --- /dev/null +++ b/src/components/icon.rs @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use filamento::{chat::Delivery, presence::Show}; +use leptos::prelude::*; + +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..83a4bad --- /dev/null +++ b/src/components/message.rs @@ -0,0 +1,98 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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, new_day: bool) -> impl IntoView { + let name = move || get_name(message.user.get().into(), 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()} + view! { + {move || { + 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 || message.user.avatar().get() /> + </Transition> + </div> + <div class="middle"> + <div class="message-info"> + <div class="message-user-name">{name}</div> + <div class="message-timestamp"> + {move || { + message.get().timestamp().read().format("%H:%M").to_string() + }} + </div> + </div> + <div class="message-text"> + {move || message.get().body().read().body.clone()} + </div> + </div> + <div class="right message-delivery"> + {move || { + message + .get() + .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.get().timestamp().read().format("%H:%M").to_string()} + </div> + <div class="middle message-text"> + {move || message.get().body().read().body.clone()} + </div> + <div class="right message-delivery"> + {move || { + message + .get() + .delivery() + .get() + .map(|delivery| view! { <Delivery delivery /> }) + }} + </div> + </div> + } + .into_any() + } + }} + {move || { + if new_day { + view! { + <div class="new-day"> + {move || { + message.get().timestamp().read().format("%Y-%m-%d").to_string() + }} + </div> + } + .into_any() + } else { + view! {}.into_any() + } + }} + } +} diff --git a/src/components/message_composer.rs b/src/components/message_composer.rs new file mode 100644 index 0000000..fd4e59b --- /dev/null +++ b/src/components/message_composer.rs @@ -0,0 +1,134 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use filamento::chat::Body; +use jid::BareJID; +use js_sys::{wasm_bindgen::UnwrapThrowExt, Object, Reflect, JSON}; +use leptos::{html::Div, prelude::*, task::spawn_local}; +use overlay_scrollbars::OverlayScrollbars; +use tracing::debug; + +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"); + // }; + // + + let composer: NodeRef<Div> = NodeRef::new(); + + let _scrollbars = Effect::new(move |_| { + if let Some(buffer) = composer.get() { + if let Some(viewport) = message_input.get() { + let scrollbars_obj = Object::new(); + Reflect::set(&scrollbars_obj, &"theme".into(), &"os-macaw".into()).unwrap_throw(); + Reflect::set(&scrollbars_obj, &"autoHide".into(), &"leave".into()).unwrap_throw(); + let options_obj = Object::new(); + Reflect::set(&options_obj, &"scrollbars".into(), &scrollbars_obj).unwrap_throw(); + + let elements_obj = Object::new(); + Reflect::set(&elements_obj, &"viewport".into(), &viewport.into()).unwrap_throw(); + let element_obj = Object::new(); + Reflect::set(&elements_obj, &"elements".into(), &elements_obj).unwrap_throw(); + Reflect::set(&element_obj, &"target".into(), &buffer.into()).unwrap_throw(); + // let element = Object::define_property(&Object::define_property(&Object::new(), &"target".into(), &buffer.into()), &"elements".into(), &Object::define_property(&Object::new(), &"viewport".into(), &viewport.into())); + debug!( + "scrollable element: {}", + JSON::stringify(&element_obj.clone().into()).unwrap_throw() + ); + debug!( + "scrollable options: {}", + JSON::stringify(&options_obj.clone().into()).unwrap_throw() + ); + OverlayScrollbars(element_obj, options_obj); + } + } + }); + + // TODO: placeholder + view! { + <form class="new-message-composer panel"> + <div class="overlay-scroll" node_ref=composer> + <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(); + } + } + _ => {} + } + } + on:keyup=move |ev| { + match ev.key_code() { + 16 => set_shift_pressed.set(false), + _ => {} + } + } + ></div> + </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..c733700 --- /dev/null +++ b/src/components/message_history_buffer.rs @@ -0,0 +1,158 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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::{ArcMacawMessage, MacawMessage}, + message_subscriptions::MessageSubscriptions, +}; + +#[component] +pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView { + let (messages, set_messages) = arc_signal(IndexMap::new()); + + 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.get().correspondent().get()) + .await + .map_err(|e| e.to_string()); + match messages { + Ok(m) => { + let mut messages = IndexMap::new(); + for (message, message_user) in m { + messages.insert( + message.id, + ArcMacawMessage::got_message_and_user(message, message_user).await, + ); + } + 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.get().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 *last.get().timestamp().read() < *new_message.get().timestamp().read() { + messages.insert(new_message.get().id().get(), new_message); + debug!("set the new message in message buffer"); + } else { + let index = match messages.binary_search_by(|_, value| { + value + .get() + .timestamp() + .read() + .cmp(&new_message.get().timestamp().read()) + }) { + Ok(i) => i, + Err(i) => i, + }; + messages.insert_before( + // TODO: check if this logic is correct + index, + new_message.get().id().get(), + new_message, + ); + debug!("set the new message in message buffer"); + } + } else { + messages.insert(new_message.get().id().get(), new_message); + debug!("set the new message in message buffer"); + } + } + } + }); + on_cleanup(move || { + if let Some(sub_id) = sub_id.get_untracked() { + new_messages_signal + .write() + .unsubscribe_chat(sub_id, chat.get().correspondent().get_untracked()); + } + }); + + 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 = message.message.get().timestamp().read().naive_local(); + let mut major = if last_user.as_ref() != Some(&message.message.get().read().from) + || message_timestamp - last_timestamp > TimeDelta::minutes(3) + { + true + } else { + false + }; + let new_day = if message_timestamp.date() > last_timestamp.date() { + major = true; + true + } else { + false + }; + last_user = Some(message.get().from().get()); + last_timestamp = message_timestamp; + (id, (message, major, false, new_day)) + }) + .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, message.1.3) + let(message) + > + <Message + message=message.1.0.into() + major=message.1.1 + r#final=message.1.2 + new_day=message.1.3 + /> + </For> + </div> + } +} diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..d2fb6b5 --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,17 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +mod avatar; +pub mod chat_header; +mod chats_list; +pub mod icon; +mod message; +pub mod message_composer; +pub mod message_history_buffer; +pub mod modal; +mod new_chat; +mod overlay; +mod personal_status; +mod roster_list; +pub mod sidebar; diff --git a/src/components/modal.rs b/src/components/modal.rs new file mode 100644 index 0000000..e23fa5d --- /dev/null +++ b/src/components/modal.rs @@ -0,0 +1,25 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use leptos::ev::MouseEvent; +use leptos::prelude::*; + +#[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..925ec57 --- /dev/null +++ b/src/components/new_chat.rs @@ -0,0 +1,147 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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::{ArcMacawChat, MacawChat}, + client::Client, + open_chats::OpenChatsPanel, + state_store::StateStore, + user::{ArcMacawUser, MacawUser, fetch_avatar}, +}; + +#[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>, ArcRwSignal<String>)> = + 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 old_user = user_state_store.get_listener(user.jid.clone()); + let user = if let Some(old_user) = old_user { + old_user.update(|(old_user, _avatar)| { + old_user.set(user); + }); + old_user + } else { + let avatar = fetch_avatar(user.avatar.as_deref()).await; + let avatar = ArcRwSignal::new(avatar); + user_state_store.store(user.jid.clone(), (ArcStore::new(user), avatar)) + }; + let user = ArcMacawUser { user }; + let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat)); + ArcMacawChat { 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..d10f33a --- /dev/null +++ b/src/components/overlay.rs @@ -0,0 +1,22 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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..b74b366 --- /dev/null +++ b/src/components/personal_status.rs @@ -0,0 +1,249 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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::{MacawUser, get_name}, + user_presences::UserPresences, + views::{AppState, macaw::settings::SettingsPage}, +}; + +#[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() { + 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 /> + <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: MacawUser, 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.get().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.get().into(), false)}</div> + <div class="jid">{move || user.get().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 |_| { + 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..b018d45 --- /dev/null +++ b/src/components/roster_list.rs @@ -0,0 +1,133 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::collections::HashSet; + +use contact_request_manager::AddContact; +use jid::BareJID; +use js_sys::{wasm_bindgen::UnwrapThrowExt, Object, Reflect, JSON}; +use leptos::{html::Div, prelude::*}; +use overlay_scrollbars::OverlayScrollbars; +use reactive_stores::Store; +use roster_list_item::RosterListItem; +use tracing::debug; + +use crate::{ + components::icon::IconComponent, + icon::Icon, + open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}, + 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 open_chats: Store<OpenChatsPanel> = + use_context().expect("no open chats panel store in context"); + + let roster: Store<Roster> = use_context().expect("no roster in context"); + let (open_add_contact, set_open_add_contact) = signal(false); + let open_chat = Memo::new(move |_| open_chats.chat_view().get()); + provide_context(open_chat); + + let roster_list: NodeRef<Div> = NodeRef::new(); + let roster_list_viewport: NodeRef<Div> = NodeRef::new(); + + let _scrollbars = Effect::new(move |_| { + if let Some(buffer) = roster_list.get() { + if let Some(viewport) = roster_list_viewport.get() { + let elements_obj = Object::new(); + Reflect::set(&elements_obj, &"viewport".into(), &viewport.into()).unwrap_throw(); + let element_obj = Object::new(); + Reflect::set(&elements_obj, &"elements".into(), &elements_obj).unwrap_throw(); + Reflect::set(&element_obj, &"target".into(), &buffer.into()).unwrap_throw(); + // let element = Object::define_property(&Object::define_property(&Object::new(), &"target".into(), &buffer.into()), &"elements".into(), &Object::define_property(&Object::new(), &"viewport".into(), &viewport.into())); + debug!( + "scrollable element: {}", + JSON::stringify(&element_obj.clone().into()).unwrap_throw() + ); + OverlayScrollbars(element_obj, Object::new()); + } + } + }); + + let add_contact: NodeRef<Div> = NodeRef::new(); + let add_contact_viewport: NodeRef<Div> = NodeRef::new(); + + let _scrollbars = Effect::new(move |_| { + if let Some(buffer) = add_contact.get() { + if let Some(viewport) = add_contact_viewport.get() { + let scrollbars_obj = Object::new(); + Reflect::set(&scrollbars_obj, &"theme".into(), &"os-macaw".into()).unwrap_throw(); + Reflect::set(&scrollbars_obj, &"move".into(), &"leave".into()).unwrap_throw(); + let options_obj = Object::new(); + Reflect::set(&options_obj, &"scrollbars".into(), &scrollbars_obj).unwrap_throw(); + + let elements_obj = Object::new(); + Reflect::set(&elements_obj, &"viewport".into(), &viewport.into()).unwrap_throw(); + let element_obj = Object::new(); + Reflect::set(&elements_obj, &"elements".into(), &elements_obj).unwrap_throw(); + Reflect::set(&element_obj, &"target".into(), &buffer.into()).unwrap_throw(); + // let element = Object::define_property(&Object::define_property(&Object::new(), &"target".into(), &buffer.into()), &"elements".into(), &Object::define_property(&Object::new(), &"viewport".into(), &viewport.into())); + debug!( + "scrollable element: {}", + JSON::stringify(&element_obj.clone().into()).unwrap_throw() + ); + OverlayScrollbars(element_obj, options_obj); + } + } + }); + + // 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="overlay-scroll add-contact-panel" node_ref=add_contact> + <div class="roster-add-contact" node_ref=add_contact_viewport> + <AddContact /> + </div> + </div> + } + .into_any() + } else { + view! {}.into_any() + } + }} + <div class="overlay-scroll" node_ref=roster_list> + <div class="roster-list-roster" node_ref=roster_list_viewport> + <For + each=move || roster.contacts().get() + key=|contact| contact.0.clone() + let(contact) + > + <RosterListItem contact=contact.1 /> + </For> + </div> + </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..4c28142 --- /dev/null +++ b/src/components/roster_list/contact_request_manager.rs @@ -0,0 +1,284 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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 client5 = 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.clone()).await; + set_requests.write().remove(&jid); + } + }); + + let accept_subscription_request = Action::new_local(move |jid: &BareJID| { + let client = client5.clone(); + let jid = jid.clone(); + async move { + // TODO: error + client.accept_subscription_request(jid.clone()).await; + set_requests.write().remove(&jid); + } + }); + + 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 request4 = request.clone(); + let jid_string = move || request.to_string(); + view! { + <div class="jid-with-buttons"> + <div class="jid">{jid_string}</div> + <div class="buttons"> + <div + class="button" + on:click=move |_| { + reject_friend_request.dispatch(request3.clone()); + } + > + Reject + </div> + <div + class="button" + on:click=move |_| { + accept_subscription_request.dispatch(request4.clone()); + } + > + Sub-only + </div> + <div + class="button" + on:click=move |_| { + accept_friend_request.dispatch(request2.clone()); + } + > + Accept Buddy + </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..a6fd714 --- /dev/null +++ b/src/components/roster_list/roster_list_item.rs @@ -0,0 +1,122 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::ops::Deref; + +use filamento::{ + chat::Chat, + roster::{Contact, ContactStoreFields}, + user::{User, UserStoreFields}, +}; +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; +use tracing::debug; + +use crate::{ + chat::{ArcMacawChat, MacawChat}, + client::Client, + components::{avatar::AvatarWithPresence, sidebar::Open}, + contact::MacawContact, + open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}, + state_store::StateStore, + user::{ArcMacawUser, fetch_avatar, get_name}, +}; + +#[component] +pub fn RosterListItem(contact: MacawContact) -> impl IntoView { + let name = move || get_name(contact.user.get().into(), 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>, ArcRwSignal<String>)> = + use_context().expect("no user state store"); + + let open_chat = Action::new_local(move |_| { + let client = client.clone(); + async move { + let to = contact.user.get().jid().get(); + let (chat, user) = match client.get_chat_and_user(to).await { + Ok(c) => c, + Err(e) => { + // TODO: error + // 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 old_user = user_state_store.get_listener(user.jid.clone()); + let user = if let Some(old_user) = old_user { + old_user.update(|(old_user, _avatar)| { + old_user.set(user); + }); + old_user + } else { + let avatar = fetch_avatar(user.avatar.as_deref()).await; + let avatar = ArcRwSignal::new(avatar); + user_state_store.store(user.jid.clone(), (ArcStore::new(user), avatar)) + }; + let user = ArcMacawUser { user }; + let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat)); + ArcMacawChat { chat, user } + }; + open_chats.update(|open_chats| open_chats.open(chat.clone())); + } + }); + + let current_open_chat: Memo<Option<BareJID>> = + use_context().expect("no open chat memo in context"); + + let open = move || { + if let Some(open_chat) = &*current_open_chat.read() { + debug!("got open chat: {:?}", open_chat); + if *open_chat == *contact.user.get().jid().read() { + return Open::Focused; + } + } + if let Some(_backgrounded_chat) = open_chats + .chats() + .read() + .get(contact.user.get().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=move |_| { + open_chat.dispatch(()); + } + > + {move || { + view! { <AvatarWithPresence user=contact.user /> } + }} + <div class="item-info"> + <div class="main-info"> + <p class="name"> + {name} + <span class="jid">- {move || contact.user_jid().read().to_string()}</span> + </p> + </div> + <div class="sub-info">{move || 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..9f555b5 --- /dev/null +++ b/src/components/sidebar.rs @@ -0,0 +1,233 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::collections::{HashMap, HashSet}; + +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::Store; + +use crate::components::{ + chats_list::ChatsList, personal_status::PersonalStatus, roster_list::RosterList, +}; + +#[derive(PartialEq, Eq, Clone, Copy, Hash)] +pub enum SidebarOpen { + Roster, + Chats, +} + +#[derive(Store)] +pub struct Drawer { + open: SidebarOpen, + hovering: bool, +} + +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 open = Memo::new(move |_| open.get()); + let (hovered, set_hovered) = signal(None::<SidebarOpen>); + let hovered = Memo::new(move |_| hovered.get()); + let (just_closed, set_just_closed) = signal(false); + let just_closed = Memo::new(move |_| just_closed.get()); + + let pages = Memo::new(move |_| { + let mut pages = HashSet::new(); + if let Some(hovered) = *hovered.read() { + pages.insert(hovered); + } + if let Some(opened) = *open.read() { + pages.insert(opened); + } + pages + }); + + 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); + } else { + set_just_closed.set(false); + } + }) + } + > + <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); + } else { + set_just_closed.set(false); + } + }) + } + > + <div class="dock-pill"></div> + <img src="/assets/bubble.png" /> + </div> + </div> + <div class="pins"></div> + <div class="personal"> + <PersonalStatus /> + </div> + </div> + {move || { + if !just_closed.get() { + view! { + <For each=move || pages.get() key=|page| *page let(page)> + {move || match page { + SidebarOpen::Roster => { + view! { + {move || { + if *open.read() == None + && *hovered.read() == Some(SidebarOpen::Roster) + { + view! { <div class="sidebar-drawer behind-hovering"></div> } + .into_any() + } else { + view! {}.into_any() + } + }} + <div + class:sidebar-drawer=true + class:sidebar-hovering-drawer=move || { + !(*open.read() == Some(SidebarOpen::Roster)) + && (*hovered.read() == Some(SidebarOpen::Roster)) + } + > + <RosterList /> + </div> + } + .into_any() + } + SidebarOpen::Chats => { + view! { + {move || { + if *open.read() == None + && *hovered.read() == Some(SidebarOpen::Chats) + { + view! { <div class="sidebar-drawer behind-hovering"></div> } + .into_any() + } else { + view! {}.into_any() + } + }} + <div + class:sidebar-drawer=true + class:sidebar-hovering-drawer=move || { + !(*open.read() == Some(SidebarOpen::Chats)) + && (*hovered.read() == Some(SidebarOpen::Chats)) + } + > + <ChatsList /> + </div> + } + .into_any() + } + }} + </For> + } + .into_any() + } else { + view! {}.into_any() + } + }} + </div> + } +} diff --git a/src/contact.rs b/src/contact.rs new file mode 100644 index 0000000..b7f57fa --- /dev/null +++ b/src/contact.rs @@ -0,0 +1,67 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::ops::{Deref, DerefMut}; + +use filamento::{roster::Contact, user::User}; +use reactive_stores::Store; + +use crate::user::{ArcMacawUser, MacawUser}; + +#[derive(Clone, Copy)] +pub struct MacawContact { + pub contact: Store<Contact>, + pub user: MacawUser, +} + +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 + } +} + +impl From<ArcMacawContact> for MacawContact { + fn from(value: ArcMacawContact) -> Self { + Self { + contact: value.contact, + user: value.user.into(), + } + } +} + +#[derive(Clone)] +pub struct ArcMacawContact { + pub contact: Store<Contact>, + pub user: ArcMacawUser, +} + +impl ArcMacawContact { + pub async fn got_contact_and_user(contact: Contact, user: User) -> Self { + let contact = Store::new(contact); + let user = ArcMacawUser::got_user(user).await; + Self { contact, user } + } +} + +impl Deref for ArcMacawContact { + type Target = Store<Contact>; + + fn deref(&self) -> &Self::Target { + &self.contact + } +} + +impl DerefMut for ArcMacawContact { + 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..da660dc --- /dev/null +++ b/src/context.rs @@ -0,0 +1,3 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later diff --git a/src/files.rs b/src/files.rs new file mode 100644 index 0000000..760549f --- /dev/null +++ b/src/files.rs @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use base64::{Engine, prelude::BASE64_STANDARD}; +use filamento::files::{FileStore, FilesMem, FilesOPFS, opfs::OPFSError}; + +#[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..231ad2e --- /dev/null +++ b/src/icon.rs @@ -0,0 +1,114 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +#[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, + } + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..fcd632e --- /dev/null +++ b/src/lib.rs @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +pub use views::App; + +mod chat; +mod client; +mod components; +mod contact; +mod files; +mod icon; +mod message; +mod message_subscriptions; +mod open_chats; +mod roster; +mod state_store; +mod user; +mod user_presences; +mod views; diff --git a/src/login_modal.rs b/src/login_modal.rs deleted file mode 100644 index 5a63158..0000000 --- a/src/login_modal.rs +++ /dev/null @@ -1,115 +0,0 @@ -use iced::{ - futures::StreamExt, - widget::{button, checkbox, column, container, text, text_input}, - Element, Task, -}; -use jid::JID; -use keyring::Entry; -use luz::{ - presence::{Offline, Presence}, - LuzHandle, -}; -use serde::{Deserialize, Serialize}; -use tokio_stream::wrappers::ReceiverStream; -use tracing::info; - -use crate::Client; - -#[derive(Default)] -pub struct LoginModal { - jid: String, - password: String, - remember_me: bool, - error: Option<Error>, -} - -#[derive(Debug, Clone)] -pub enum Message { - JID(String), - Password(String), - RememberMe, - Submit, - Error(Error), -} - -#[derive(Debug, Clone)] -pub enum Error { - InvalidJID, -} - -pub enum Action { - None, - ClientCreated(Task<crate::Message>), - CreateClient(String, String, bool), -} - -#[derive(Serialize, Deserialize, Clone)] -pub struct Creds { - pub jid: String, - pub password: String, -} - -impl LoginModal { - pub fn update(&mut self, message: Message) -> Action { - match message { - Message::JID(j) => { - self.jid = j; - Action::None - } - Message::Password(p) => { - self.password = p; - Action::None - } - Message::RememberMe => { - self.remember_me = !self.remember_me; - Action::None - } - Message::Submit => { - info!("submitting login"); - let jid_str = self.jid.clone(); - let password = self.password.clone(); - let remember_me = self.remember_me.clone(); - Action::CreateClient(jid_str, password, remember_me) - } - Message::Error(error) => { - self.error = Some(error); - Action::None - } - } - } - - pub fn view(&self) -> Element<Message> { - container( - column![ - text("Log In").size(24), - column![ - column![ - text("JID").size(12), - text_input("berry@macaw.chat", &self.jid) - .on_input(|j| Message::JID(j)) - .on_submit(Message::Submit) - .padding(5), - ] - .spacing(5), - column![ - text("Password").size(12), - text_input("", &self.password) - .on_input(|p| Message::Password(p)) - .on_submit(Message::Submit) - .secure(true) - .padding(5), - ] - .spacing(5), - checkbox("remember me", self.remember_me).on_toggle(|_| Message::RememberMe), - button(text("Submit")).on_press(Message::Submit), - ] - .spacing(10) - ] - .spacing(20), - ) - .width(300) - .padding(10) - .style(container::rounded_box) - .into() - } -} diff --git a/src/main.rs b/src/main.rs index 37551c8..3db4c25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,1057 +1,13 @@ -use std::borrow::Cow; -use std::collections::{HashMap, HashSet}; -use std::fmt::Debug; -use std::ops::{Deref, DerefMut}; -use std::path::PathBuf; -use std::str::FromStr; -use std::sync::Arc; +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later -use chrono::{Local, Utc}; -use iced::alignment::Horizontal::Right; -use iced::futures::{SinkExt, Stream, StreamExt}; -use iced::keyboard::{on_key_press, on_key_release, Key, Modifiers}; -use iced::theme::palette::{ - Background, Danger, Extended, Pair, Primary, Secondary, Success, Warning, -}; -use iced::theme::{Custom, Palette}; -use iced::widget::button::Status; -use iced::widget::text::{Fragment, IntoFragment, Wrapping}; -use iced::widget::{ - button, center, checkbox, column, container, horizontal_space, mouse_area, opaque, row, - scrollable, stack, text, text_input, toggler, Column, Text, Toggler, -}; -use iced::Length::{self, Fill, Shrink}; -use iced::{color, stream, Color, Element, Subscription, Task, Theme}; -use indexmap::{indexmap, IndexMap}; -use jid::JID; -use keyring::Entry; -use login_modal::{Creds, LoginModal}; -use luz::chat::{Chat, Message as ChatMessage}; -use luz::error::CommandError; -use luz::presence::{Offline, Presence, PresenceType}; -use luz::CommandMessage; -use luz::{roster::Contact, user::User, LuzHandle, UpdateMessage}; -use message_view::MessageView; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use tokio::sync::{mpsc, oneshot}; -use tokio_stream::wrappers::ReceiverStream; -use tracing::{error, info}; -use uuid::Uuid; +use leptos::prelude::*; +use macaw_web::App; -mod login_modal; -mod message_view; +fn main() { + tracing_wasm::set_as_global_default(); + console_error_panic_hook::set_once(); -#[derive(Serialize, Deserialize, Clone)] -pub struct Config { - auto_connect: bool, - storage_dir: Option<String>, - dburl: Option<String>, - message_view_config: message_view::Config, -} - -impl Default for Config { - fn default() -> Self { - Self { - auto_connect: true, - storage_dir: None, - dburl: None, - message_view_config: message_view::Config::default(), - } - } -} - -pub struct Macaw { - client: Account, - config: Config, - roster: HashMap<JID, Contact>, - users: HashMap<JID, User>, - presences: HashMap<JID, Presence>, - chats: IndexMap<JID, (Chat, Option<ChatMessage>)>, - subscription_requests: HashSet<JID>, - open_chat: Option<MessageView>, - new_chat: Option<NewChat>, -} - -pub struct NewChat; - -impl Macaw { - pub fn new(client: Option<Client>, config: Config) -> Self { - let account; - if let Some(client) = client { - account = Account::LoggedIn(client); - } else { - account = Account::LoggedOut(LoginModal::default()); - } - - Self { - client: account, - config, - roster: HashMap::new(), - users: HashMap::new(), - presences: HashMap::new(), - chats: IndexMap::new(), - subscription_requests: HashSet::new(), - open_chat: None, - new_chat: None, - } - } -} - -pub enum Account { - LoggedIn(Client), - LoggedOut(LoginModal), -} - -impl Account { - pub fn is_connected(&self) -> bool { - match self { - Account::LoggedIn(client) => client.connection_state.is_connected(), - Account::LoggedOut(login_modal) => false, - } - } - - pub fn connection_status(&self) -> String { - match self { - Account::LoggedIn(client) => match client.connection_state { - ConnectionState::Online => "online".to_string(), - ConnectionState::Connecting => "connecting".to_string(), - ConnectionState::Offline => "offline".to_string(), - }, - Account::LoggedOut(login_modal) => "no account".to_string(), - } - } -} - -#[derive(Clone, Debug)] -pub struct Client { - client: LuzHandle, - jid: JID, - status: Presence, - connection_state: ConnectionState, -} - -impl Client { - pub fn is_connected(&self) -> bool { - self.connection_state.is_connected() - } -} - -#[derive(Clone, Debug)] -pub enum ConnectionState { - Online, - Connecting, - Offline, -} - -impl ConnectionState { - pub fn is_connected(&self) -> bool { - match self { - ConnectionState::Online => true, - ConnectionState::Connecting => false, - ConnectionState::Offline => false, - } - } -} - -impl DerefMut for Client { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.client - } -} - -impl Deref for Client { - type Target = LuzHandle; - - fn deref(&self) -> &Self::Target { - &self.client - } -} - -async fn luz(jid: &JID, creds: &Creds, cfg: &Config) -> (LuzHandle, mpsc::Receiver<UpdateMessage>) { - let luz; - if let Some(ref dburl) = cfg.dburl { - // TODO: have some sort of crash popup for this stuff - let db_path = dburl.strip_prefix("sqlite://").unwrap_or(&dburl); - let db_path = PathBuf::from_str(db_path).expect("invalid database path"); - let db = luz::db::Db::create_connect_and_migrate(db_path) - .await - .unwrap(); - luz = LuzHandle::new(jid.clone(), creds.password.to_string(), db); - } else if let Some(ref dir) = cfg.storage_dir { - let mut data_dir = PathBuf::from_str(&dir).expect("invalid storage directory path"); - data_dir.push(creds.jid.clone()); - data_dir.push(creds.jid.clone()); - data_dir.set_extension("db"); - let db = luz::db::Db::create_connect_and_migrate(data_dir) - .await - .unwrap(); - luz = LuzHandle::new(jid.clone(), creds.password.to_string(), db); - } else { - let mut data_dir = dirs::data_dir() - .expect("operating system does not support retreiving determining default data dir"); - data_dir.push("macaw"); - data_dir.push(creds.jid.clone()); - data_dir.push(creds.jid.clone()); - // TODO: better lol - data_dir.set_extension("db"); - info!("db_path: {:?}", data_dir); - let db = luz::db::Db::create_connect_and_migrate(data_dir) - .await - .unwrap(); - luz = LuzHandle::new(jid.clone(), creds.password.to_string(), db); - } - luz -} - -#[tokio::main] -async fn main() -> iced::Result { - tracing_subscriber::fmt::init(); - - let cfg: Config = confy::load("macaw", None).unwrap_or_default(); - let entry = Entry::new("macaw", "macaw"); - let mut client_creation_error: Option<Error> = None; - let mut creds: Option<Creds> = None; - - match entry { - Ok(e) => { - let result = e.get_password(); - match result { - Ok(c) => { - let result = toml::from_str(&c); - match result { - Ok(c) => creds = Some(c), - Err(e) => { - client_creation_error = - Some(Error::CredentialsLoad(CredentialsLoadError::Toml(e.into()))) - } - } - } - Err(e) => match e { - keyring::Error::NoEntry => {} - _ => { - client_creation_error = Some(Error::CredentialsLoad( - CredentialsLoadError::Keyring(e.into()), - )) - } - }, - } - } - Err(e) => { - client_creation_error = Some(Error::CredentialsLoad(CredentialsLoadError::Keyring( - e.into(), - ))) - } - } - - let mut client: Option<(JID, LuzHandle, mpsc::Receiver<UpdateMessage>)> = None; - if let Some(creds) = creds { - let jid = creds.jid.parse::<JID>(); - match jid { - Ok(jid) => { - let (handle, updates) = luz(&jid, &creds, &cfg).await; - client = Some((jid, handle, updates)); - } - Err(e) => client_creation_error = Some(Error::CredentialsLoad(e.into())), - } - } - - if let Some((jid, luz_handle, update_recv)) = client { - let stream = ReceiverStream::new(update_recv); - let stream = stream.map(|message| Message::Luz(message)); - let task = { - let luz_handle1 = luz_handle.clone(); - let luz_handle2 = luz_handle.clone(); - if cfg.auto_connect { - Task::batch( - [ - Task::batch([ - Task::perform( - async move { luz_handle1.get_roster().await }, - |result| { - let roster = result.unwrap(); - let mut macaw_roster = HashMap::new(); - for contact in roster { - macaw_roster.insert(contact.user_jid.clone(), contact); - } - Message::Roster(macaw_roster) - }, - ), - Task::perform( - async move { - luz_handle2.get_chats_ordered_with_latest_messages().await - }, - |chats| { - let chats = chats.unwrap(); - info!("got chats: {:?}", chats); - Message::GotChats(chats) - }, - ), - ]) - .chain(Task::done(Message::Connect)), - Task::stream(stream), - ], - ) - } else { - Task::batch([ - Task::perform(async move { luz_handle1.get_roster().await }, |result| { - let roster = result.unwrap(); - let mut macaw_roster = HashMap::new(); - for contact in roster { - macaw_roster.insert(contact.user_jid.clone(), contact); - } - Message::Roster(macaw_roster) - }), - Task::perform( - async move { luz_handle2.get_chats_ordered_with_latest_messages().await }, - |chats| { - let chats = chats.unwrap(); - info!("got chats: {:?}", chats); - Message::GotChats(chats) - }, - ), - Task::stream(stream), - ]) - } - }; - iced::application("Macaw", Macaw::update, Macaw::view) - .subscription(subscription) - .theme(Macaw::theme) - .run_with(|| { - ( - Macaw::new( - Some(Client { - client: luz_handle, - // TODO: - jid, - // TODO: store cached status - status: Presence { - timestamp: Utc::now(), - presence: PresenceType::Offline(Offline::default()), - }, - connection_state: ConnectionState::Offline, - }), - cfg, - ), - task, - ) - }) - } else { - if let Some(e) = client_creation_error { - iced::application("Macaw", Macaw::update, Macaw::view) - .run_with(|| (Macaw::new(None, cfg), Task::done(Message::Error(e)))) - } else { - iced::application("Macaw", Macaw::update, Macaw::view) - .run_with(|| (Macaw::new(None, cfg), Task::none())) - } - } -} - -fn subscription(state: &Macaw) -> Subscription<Message> { - Subscription::batch([press_subscription(state), release_subscription(state)]) -} - -fn press_subscription(_state: &Macaw) -> Subscription<Message> { - on_key_press(handle_key_press) -} - -fn handle_key_press(key: Key, r#mod: Modifiers) -> Option<Message> { - match key { - Key::Named(iced::keyboard::key::Named::Shift) => Some(Message::ShiftPressed), - _ => None, - } -} - -fn release_subscription(_state: &Macaw) -> Subscription<Message> { - on_key_release(handle_key_release) -} - -fn handle_key_release(key: Key, r#mod: Modifiers) -> Option<Message> { - match key { - Key::Named(iced::keyboard::key::Named::Shift) => Some(Message::ShiftReleased), - _ => None, - } -} - -#[derive(Debug, Clone)] -pub enum Message { - ShiftPressed, - ShiftReleased, - LoginModal(login_modal::Message), - ClientCreated(Client), - Luz(UpdateMessage), - Roster(HashMap<JID, Contact>), - Connect, - Disconnect, - GotChats(Vec<(Chat, ChatMessage)>), - GotMessageHistory(Chat, IndexMap<Uuid, ChatMessage>), - ToggleChat(JID), - SendMessage(JID, String), - Error(Error), - MessageView(message_view::Message), -} - -#[derive(Debug, Error, Clone)] -pub enum Error { - #[error("failed to create Luz client: {0}")] - ClientCreation(#[from] luz::error::DatabaseError), - #[error("failed to save credentials: {0}")] - CredentialsSave(CredentialsSaveError), - #[error("failed to load credentials: {0}")] - CredentialsLoad(CredentialsLoadError), - #[error("failed to retreive messages for chat {0}")] - MessageHistory(JID, CommandError<luz::error::DatabaseError>), -} - -#[derive(Debug, Error, Clone)] -pub enum CredentialsSaveError { - #[error("keyring: {0}")] - Keyring(Arc<keyring::Error>), - #[error("toml serialisation: {0}")] - Toml(#[from] toml::ser::Error), -} - -impl From<keyring::Error> for CredentialsSaveError { - fn from(e: keyring::Error) -> Self { - Self::Keyring(Arc::new(e)) - } -} - -#[derive(Debug, Error, Clone)] -pub enum CredentialsLoadError { - #[error("keyring: {0}")] - Keyring(Arc<keyring::Error>), - #[error("toml serialisation: {0}")] - Toml(#[from] toml::de::Error), - #[error("invalid jid: {0}")] - JID(#[from] jid::ParseError), -} - -impl From<keyring::Error> for CredentialsLoadError { - fn from(e: keyring::Error) -> Self { - Self::Keyring(Arc::new(e)) - } -} - -impl Macaw { - fn update(&mut self, message: Message) -> Task<Message> { - match message { - Message::Luz(update_message) => match update_message { - UpdateMessage::Error(error) => { - tracing::error!("Luz error: {:?}", error); - Task::none() - } - UpdateMessage::Online(online, vec) => match &mut self.client { - Account::LoggedIn(client) => { - client.status = Presence { - timestamp: Utc::now(), - presence: PresenceType::Online(online), - }; - client.connection_state = ConnectionState::Online; - let mut roster = HashMap::new(); - for contact in vec { - roster.insert(contact.user_jid.clone(), contact); - } - self.roster = roster; - Task::none() - } - Account::LoggedOut(login_modal) => Task::none(), - }, - UpdateMessage::Offline(offline) => { - // TODO: update all contacts' presences to unknown (offline) - match &mut self.client { - Account::LoggedIn(client) => { - client.status = Presence { - timestamp: Utc::now(), - presence: PresenceType::Offline(offline), - }; - client.connection_state = ConnectionState::Offline; - Task::none() - } - Account::LoggedOut(login_modal) => Task::none(), - } - } - UpdateMessage::FullRoster(vec) => { - let mut macaw_roster = HashMap::new(); - for contact in vec { - macaw_roster.insert(contact.user_jid.clone(), contact); - } - self.roster = macaw_roster; - Task::none() - } - UpdateMessage::RosterUpdate(contact) => { - self.roster.insert(contact.user_jid.clone(), contact); - Task::none() - } - UpdateMessage::RosterDelete(jid) => { - self.roster.remove(&jid); - Task::none() - } - UpdateMessage::Presence { from, presence } => { - self.presences.insert(from, presence); - Task::none() - } - UpdateMessage::Message { to, message } => { - if let Some((chat_jid, (chat, old_message))) = - self.chats.shift_remove_entry(&to) - { - self.chats - .insert_before(0, chat_jid, (chat, Some(message.clone()))); - if let Some(open_chat) = &mut self.open_chat { - if open_chat.jid == to { - open_chat.update(message_view::Message::Message(message)); - } - } - } else { - let chat = Chat { - correspondent: to.clone(), - }; - let message_history = indexmap! {message.id => message.clone()}; - self.chats.insert_before(0, to, (chat, Some(message))); - } - Task::none() - } - UpdateMessage::SubscriptionRequest(jid) => { - // TODO: subscription requests - Task::none() - } - }, - // TODO: NEXT - Message::ClientCreated(client) => { - self.client = Account::LoggedIn(client.clone()); - let client1 = client.clone(); - let client2 = client.clone(); - if self.config.auto_connect { - Task::batch([ - Task::perform(async move { client1.client.get_roster().await }, |result| { - let roster = result.unwrap(); - let mut macaw_roster = HashMap::new(); - for contact in roster { - macaw_roster.insert(contact.user_jid.clone(), contact); - } - Message::Roster(macaw_roster) - }), - Task::perform( - async move { - client2 - .client - .get_chats_ordered_with_latest_messages() - .await - }, - |chats| { - let chats = chats.unwrap(); - // let chats: HashMap<JID, (Chat, IndexMap<Uuid, ChatMessage>)> = chats - // .into_iter() - // .map(|chat| (chat.correspondent.clone(), (chat, IndexMap::new()))) - // .collect(); - info!("got chats: {:?}", chats); - Message::GotChats(chats) - }, - ), - ]) - .chain(Task::done(Message::Connect)) - } else { - Task::batch([ - Task::perform(async move { client1.client.get_roster().await }, |result| { - let roster = result.unwrap(); - let mut macaw_roster = HashMap::new(); - for contact in roster { - macaw_roster.insert(contact.user_jid.clone(), contact); - } - Message::Roster(macaw_roster) - }), - Task::perform( - async move { - client2 - .client - .get_chats_ordered_with_latest_messages() - .await - }, - |chats| { - let chats = chats.unwrap(); - // let chats: HashMap<JID, (Chat, IndexMap<Uuid, ChatMessage>)> = chats - // .into_iter() - // .map(|chat| (chat.correspondent.clone(), (chat, IndexMap::new()))) - // .collect(); - info!("got chats: {:?}", chats); - Message::GotChats(chats) - }, - ), - ]) - } - } - Message::Roster(hash_map) => { - self.roster = hash_map; - Task::none() - } - Message::Connect => match &mut self.client { - Account::LoggedIn(client) => { - client.connection_state = ConnectionState::Connecting; - let client = client.client.clone(); - Task::future(async move { - client.send(CommandMessage::Connect).await; - }) - .discard() - } - Account::LoggedOut(login_modal) => Task::none(), - }, - Message::Disconnect => match &self.client { - Account::LoggedIn(client) => { - let client = client.client.clone(); - Task::future(async move { - client - .send(CommandMessage::Disconnect(Offline::default())) - .await; - }) - .discard() - } - Account::LoggedOut(login_modal) => Task::none(), - }, - Message::ToggleChat(jid) => { - match &self.open_chat { - Some(message_view) => { - if message_view.jid == jid { - self.open_chat = None; - return Task::none(); - } - } - None => {} - } - self.open_chat = Some(MessageView::new(jid.clone(), &self.config)); - let jid1 = jid.clone(); - match &self.client { - Account::LoggedIn(client) => { - let client = client.clone(); - Task::perform( - async move { client.get_messages(jid1).await }, - move |result| match result { - Ok(h) => { - Message::MessageView(message_view::Message::MessageHistory(h)) - } - Err(e) => Message::Error(Error::MessageHistory(jid.clone(), e)), - }, - ) - } - Account::LoggedOut(login_modal) => Task::none(), - } - } - Message::LoginModal(login_modal_message) => match &mut self.client { - Account::LoggedIn(_client) => Task::none(), - Account::LoggedOut(login_modal) => { - let action = login_modal.update(login_modal_message); - match action { - login_modal::Action::None => Task::none(), - login_modal::Action::CreateClient(jid, password, remember_me) => { - let creds = Creds { jid, password }; - let jid = creds.jid.parse::<JID>(); - let config = self.config.clone(); - match jid { - Ok(jid) => { - Task::perform(async move { - let (jid, creds, config) = (jid, creds, config); - let (handle, recv) = luz(&jid, &creds, &config).await; - (handle, recv, jid, creds, config) - }, move |(handle, recv, jid, creds, config)| { - let creds = creds; - let mut tasks = Vec::new(); - tasks.push(Task::done(crate::Message::ClientCreated( - Client { - client: handle, - jid, - status: Presence { timestamp: Utc::now(), presence: PresenceType::Offline(Offline::default()) }, - connection_state: ConnectionState::Offline, - }, - ))); - let stream = ReceiverStream::new(recv); - let stream = - stream.map(|message| crate::Message::Luz(message)); - tasks.push(Task::stream(stream)); - - if remember_me { - let entry = Entry::new("macaw", "macaw"); - match entry { - Ok(e) => { - let creds = toml::to_string(&creds); - match creds { - Ok(c) => { - let result = e.set_password(&c); - if let Err(e) = result { - tasks.push(Task::done(crate::Message::Error( - crate::Error::CredentialsSave(e.into()), - ))); - } - } - Err(e) => tasks.push(Task::done( - crate::Message::Error( - crate::Error::CredentialsSave( - e.into(), - ), - ), - )), - } - } - Err(e) => { - tasks.push(Task::done(crate::Message::Error( - crate::Error::CredentialsSave(e.into()), - ))) - } - } - } - tasks - }).then(|tasks| Task::batch(tasks)) - } - Err(e) => Task::done(Message::LoginModal( - login_modal::Message::Error(login_modal::Error::InvalidJID), - )), - } - } - login_modal::Action::ClientCreated(task) => task, - } - } - }, - Message::GotChats(chats) => { - let mut tasks = Vec::new(); - let client = match &self.client { - Account::LoggedIn(client) => client, - Account::LoggedOut(_) => { - // TODO: error into event tracing subscriber - error!("no client, cannot retreive chat history for chats"); - return Task::none(); - } - }; - for chat in chats { - self.chats - // TODO: could have a chat with no messages, bad database state - .insert(chat.0.correspondent.clone(), (chat.0.clone(), Some(chat.1))); - // let client = client.clone(); - // let correspondent = chat.correspondent.clone(); - // tasks.push(Task::perform( - // // TODO: don't get the entire message history LOL - // async move { (chat, client.get_messages(correspondent).await) }, - // |result| { - // let messages: IndexMap<Uuid, ChatMessage> = result - // .1 - // .unwrap() - // .into_iter() - // .map(|message| (message.id.clone(), message)) - // .collect(); - // Message::GotMessageHistory(result.0, messages) - // }, - // )) - } - Task::batch(tasks) - // .then(|chats| { - // let tasks = Vec::new(); - // for key in chats.keys() { - // let client = client.client.clone(); - // tasks.push(Task::future(async { - // client.get_messages(key.clone()).await; - // })); - // } - // Task::batch(tasks) - // }), - } - Message::GotMessageHistory(chat, mut message_history) => { - // TODO: don't get the entire message history LOL - if let Some((_id, message)) = message_history.pop() { - self.chats - .insert(chat.correspondent.clone(), (chat, Some(message))); - } - Task::none() - } - Message::SendMessage(jid, body) => { - let client = match &self.client { - Account::LoggedIn(client) => client.clone(), - Account::LoggedOut(_) => { - error!("cannot send message when no client set up"); - return Task::none(); - } - }; - Task::future( - async move { client.send_message(jid, luz::chat::Body { body }).await }, - ) - .discard() - } - Message::Error(error) => { - error!("{}", error); - Task::none() - } - Message::MessageView(message) => { - if let Some(message_view) = &mut self.open_chat { - let action = message_view.update(message); - match action { - message_view::Action::None => Task::none(), - message_view::Action::SendMessage(m) => { - Task::done(Message::SendMessage(message_view.jid.clone(), m)) - } - } - } else { - Task::none() - } - } - Message::ShiftPressed => { - info!("shift pressed"); - if let Some(open_chat) = &mut self.open_chat { - open_chat.shift_pressed = true; - } - Task::none() - } - Message::ShiftReleased => { - info!("shift released"); - if let Some(open_chat) = &mut self.open_chat { - open_chat.shift_pressed = false; - } - Task::none() - } - } - } - - fn view(&self) -> Element<Message> { - let mut ui: Element<Message> = { - let mut chats_list: Column<Message> = column![]; - for (jid, (chat, latest_message)) in &self.chats { - let mut open = false; - if let Some(open_chat) = &self.open_chat { - if open_chat.jid == *jid { - open = true; - } - } - let chat_list_item = chat_list_item(chat, latest_message, open); - chats_list = chats_list.push(chat_list_item); - } - let chats_list = scrollable(chats_list.spacing(8).padding(8)) - .spacing(1) - .height(Fill); - - let connection_status = self.client.connection_status(); - let client_jid: Cow<'_, str> = match &self.client { - Account::LoggedIn(client) => (&client.jid).into(), - Account::LoggedOut(_) => Cow::from("no account"), - // map(|client| (&client.jid).into()); - }; - let connected = self.client.is_connected(); - - let account_view = container(row![ - text(client_jid), - horizontal_space(), - text(connection_status), - horizontal_space().width(8), - toggler(connected).on_toggle(|connect| { - if connect { - Message::Connect - } else { - Message::Disconnect - } - }) - ]) - .padding(8); - - // TODO: config width/resizing - let sidebar = column![chats_list, account_view].height(Fill).width(300); - - let message_view; - if let Some(open_chat) = &self.open_chat { - message_view = open_chat.view().map(Message::MessageView) - } else { - message_view = column![].into(); - } - - row![sidebar, container(message_view).width(Fill)] - } - .into(); - - if let Some(new_chat) = &self.new_chat { - // TODO: close new chat window - ui = modal(ui, text("new chat"), None); - } - // temporarily center to fill space - // let ui = center(ui).into(); - let ui = container(ui).center_x(Fill).center_y(Fill); - - match &self.client { - Account::LoggedIn(_client) => ui.into(), - Account::LoggedOut(login_modal) => { - let signup = login_modal.view().map(Message::LoginModal); - modal(ui, signup, None) - } - } - } - - fn theme(&self) -> Theme { - let extended = Extended { - background: Background { - base: Pair { - color: color!(0x392c25), - text: color!(0xdcdcdc), - }, - weakest: Pair { - color: color!(0xdcdcdc), - text: color!(0x392c25), - }, - weak: Pair { - color: color!(0xdcdcdc), - text: color!(0x392c25), - }, - strong: Pair { - color: color!(0x364b3b), - text: color!(0xdcdcdc), - }, - strongest: Pair { - color: color!(0x364b3b), - text: color!(0xdcdcdc), - }, - }, - primary: Primary { - base: Pair { - color: color!(0x2b33b4), - text: color!(0xdcdcdc), - }, - weak: Pair { - color: color!(0x4D4A5E), - text: color!(0xdcdcdc), - }, - strong: Pair { - color: color!(0x2b33b4), - text: color!(0xdcdcdc), - }, - }, - secondary: Secondary { - base: Pair { - color: color!(0xffce07), - text: color!(0x000000), - }, - weak: Pair { - color: color!(0xffce07), - text: color!(0x000000), - }, - strong: Pair { - color: color!(0xffce07), - text: color!(0x000000), - }, - }, - success: Success { - base: Pair { - color: color!(0x14802E), - text: color!(0xdcdcdc), - }, - weak: Pair { - color: color!(0x14802E), - text: color!(0xdcdcdc), - }, - strong: Pair { - color: color!(0x14802E), - text: color!(0xdcdcdc), - }, - }, - warning: Warning { - base: Pair { - color: color!(0xFF9D00), - text: color!(0x000000), - }, - weak: Pair { - color: color!(0xFF9D00), - text: color!(0x000000), - }, - strong: Pair { - color: color!(0xFF9D00), - text: color!(0x000000), - }, - }, - danger: Danger { - base: Pair { - color: color!(0xC1173C), - text: color!(0xdcdcdc), - }, - weak: Pair { - color: color!(0xC1173C), - text: color!(0xdcdcdc), - }, - strong: Pair { - color: color!(0xC1173C), - text: color!(0xdcdcdc), - }, - }, - is_dark: true, - }; - Theme::Custom(Arc::new(Custom::with_fn( - "macaw".to_string(), - Palette::DARK, - |_| extended, - ))) - // Theme::Custom(Arc::new(Custom::new( - // "macaw".to_string(), - // Palette { - // background: color!(0x392c25), - // text: color!(0xdcdcdc), - // primary: color!(0x2b33b4), - // success: color!(0x14802e), - // warning: color!(0xffce07), - // danger: color!(0xc1173c), - // }, - // ))) - } -} - -fn modal<'a, Message>( - base: impl Into<Element<'a, Message>>, - content: impl Into<Element<'a, Message>>, - on_blur: Option<Message>, -) -> Element<'a, Message> -where - Message: Clone + 'a, -{ - let mut mouse_area = mouse_area(center(opaque(content)).style(|_theme| { - container::Style { - background: Some( - Color { - a: 0.8, - ..Color::BLACK - } - .into(), - ), - ..container::Style::default() - } - })); // .on_press(on_blur) - if let Some(on_blur) = on_blur { - mouse_area = mouse_area.on_press(on_blur) - } - stack![base.into(), opaque(mouse_area)].into() -} - -fn chat_list_item<'a>( - chat: &'a Chat, - latest_message: &'a Option<ChatMessage>, - open: bool, -) -> Element<'a, Message> { - let mut content: Column<Message> = column![text(chat.correspondent.to_string())]; - if let Some(latest_message) = latest_message { - let message = latest_message.body.body.replace("\n", " "); - let date = latest_message.timestamp.naive_local(); - let now = Local::now().naive_local(); - let timeinfo; - if date.date() == now.date() { - // TODO: localisation/config - timeinfo = text(date.time().format("%H:%M").to_string()) - } else { - timeinfo = text(date.date().format("%d/%m").to_string()) - } - content = content.push( - row![ - container(text(message).wrapping(Wrapping::None)) - .clip(true) - .width(Fill), - timeinfo - ] - .spacing(8) - .width(Fill), - ); - } - let mut button = button(content).on_press(Message::ToggleChat(chat.correspondent.clone())); - if open { - button = button.style(|theme: &Theme, status| { - let palette = theme.extended_palette(); - button::Style::default().with_background(palette.primary.weak.color) - }); - } - button.width(Fill).into() + leptos::mount::mount_to_body(App) } diff --git a/src/message.rs b/src/message.rs new file mode 100644 index 0000000..20e37b9 --- /dev/null +++ b/src/message.rs @@ -0,0 +1,80 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::ops::{Deref, DerefMut}; + +use filamento::{chat::Message, user::User}; +use leptos::prelude::*; +use reactive_stores::ArcStore; +use uuid::Uuid; + +use crate::{ + state_store::{StateListener, StateStore}, + user::{ArcMacawUser, MacawUser}, +}; + +#[derive(Clone, Copy)] +pub struct MacawMessage { + pub message: ArenaItem<StateListener<Uuid, ArcStore<Message>>>, + pub user: MacawUser, +} + +impl MacawMessage { + pub fn get(&self) -> ArcStore<Message> { + self.try_get_value().unwrap().get() + } +} + +impl Deref for MacawMessage { + type Target = ArenaItem<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 + } +} + +impl From<ArcMacawMessage> for MacawMessage { + fn from(value: ArcMacawMessage) -> Self { + Self { + message: ArenaItem::new_with_storage(value.message), + user: value.user.into(), + } + } +} + +#[derive(Clone)] +pub struct ArcMacawMessage { + pub message: StateListener<Uuid, ArcStore<Message>>, + pub user: ArcMacawUser, +} + +impl ArcMacawMessage { + pub async 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 = ArcMacawUser::got_user(user).await; + Self { message, user } + } +} + +impl Deref for ArcMacawMessage { + type Target = StateListener<Uuid, ArcStore<Message>>; + + fn deref(&self) -> &Self::Target { + &self.message + } +} + +impl DerefMut for ArcMacawMessage { + 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..eebbef3 --- /dev/null +++ b/src/message_subscriptions.rs @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::collections::HashMap; + +use jid::BareJID; +use tokio::sync::mpsc::{self, Receiver}; +use uuid::Uuid; + +use crate::message::{ArcMacawMessage, MacawMessage}; + +pub struct MessageSubscriptions { + all: HashMap<Uuid, mpsc::Sender<(BareJID, ArcMacawMessage)>>, + subset: HashMap<BareJID, HashMap<Uuid, mpsc::Sender<ArcMacawMessage>>>, +} + +impl MessageSubscriptions { + pub fn new() -> Self { + Self { + all: HashMap::new(), + subset: HashMap::new(), + } + } + + pub async fn broadcast(&mut self, to: BareJID, message: ArcMacawMessage) { + // 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, ArcMacawMessage)>) { + 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<ArcMacawMessage>) { + 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/message_view.rs b/src/message_view.rs deleted file mode 100644 index 16e8ac1..0000000 --- a/src/message_view.rs +++ /dev/null @@ -1,225 +0,0 @@ -use std::borrow::Cow; - -use chrono::NaiveDate; -use iced::{ - alignment::Horizontal::{self, Right}, - border::Radius, - color, - theme::Palette, - widget::{ - button, column, container, horizontal_space, row, scrollable, text, text_editor, - text_editor::Content, text_input, Column, - }, - Border, Color, Element, - Length::{Fill, Shrink}, - Theme, -}; -use indexmap::IndexMap; -use jid::JID; -use luz::chat::Message as ChatMessage; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -pub struct MessageView { - pub config: Config, - pub jid: JID, - pub message_history: IndexMap<Uuid, ChatMessage>, - pub new_message: Content, - pub shift_pressed: bool, -} - -#[derive(Serialize, Deserialize, Clone)] -pub struct Config { - pub send_on_enter: bool, -} - -impl Default for Config { - fn default() -> Self { - Self { - send_on_enter: true, - } - } -} - -#[derive(Debug, Clone)] -pub enum Message { - MessageHistory(Vec<ChatMessage>), - Message(ChatMessage), - MessageCompose(text_editor::Action), - SendMessage(String), -} - -pub enum Action { - None, - SendMessage(String), -} - -impl MessageView { - pub fn new(jid: JID, config: &super::Config) -> Self { - Self { - jid, - // TODO: save position in message history - message_history: IndexMap::new(), - // TODO: save draft (as part of chat struct?) - new_message: Content::new(), - config: config.message_view_config.clone(), - // TODO: have centralised modifier state location? - shift_pressed: false, - } - } - - pub fn update(&mut self, message: Message) -> Action { - match message { - Message::MessageHistory(messages) => { - if self.message_history.is_empty() { - self.message_history = messages - .into_iter() - .map(|message| (message.id.clone(), message)) - .collect(); - } - Action::None - } - Message::Message(message) => { - let i = self - .message_history - .iter() - .position(|(_id, m)| m.timestamp > message.timestamp); - if let Some(i) = i { - self.message_history.insert_before(i, message.id, message); - } else { - self.message_history.insert(message.id, message); - } - Action::None - } - Message::MessageCompose(a) => { - match &a { - text_editor::Action::Edit(edit) => match edit { - text_editor::Edit::Enter => { - if self.config.send_on_enter { - if !self.shift_pressed { - let message = self.new_message.text(); - self.new_message = Content::new(); - return Action::SendMessage(message); - } - } else { - if self.shift_pressed { - let message = self.new_message.text(); - self.new_message = Content::new(); - return Action::SendMessage(message); - } - } - } - _ => {} - }, - _ => {} - } - self.new_message.perform(a); - Action::None - } - Message::SendMessage(m) => { - self.new_message = Content::new(); - Action::SendMessage(m) - } - } - } - - pub fn view(&self) -> Element<Message> { - let mut messages_view = column![].spacing(8).padding(8); - let mut latest_date = NaiveDate::MIN; - for (_id, message) in &self.message_history { - let message_date = message.timestamp.naive_local().date(); - if message_date > latest_date { - latest_date = message_date; - messages_view = messages_view.push(date(latest_date)); - } - messages_view = messages_view.push(self.message(message)); - } - let text_editor = text_editor(&self.new_message) - .placeholder("new message") - .on_action(Message::MessageCompose) - .wrapping(text::Wrapping::WordOrGlyph); - let message_send_input = row![ - text_editor, - button("send").on_press(Message::SendMessage(self.new_message.text())) - ] - .padding(8); - column![ - scrollable(messages_view) - .height(Fill) - .width(Fill) - .spacing(1) - .anchor_bottom(), - message_send_input - ] - .into() - } - - pub fn message<'a>(&'a self, message: &'a ChatMessage) -> Element<'a, Message> { - let timestamp = message.timestamp.naive_local(); - let timestamp = timestamp.time().format("%H:%M").to_string(); - - if self.jid == message.from.as_bare() { - container( - container( - column![ - text(message.body.body.as_str()).wrapping(text::Wrapping::WordOrGlyph), - container(text(timestamp).wrapping(text::Wrapping::None).size(12)) // .align_right(Fill) - ] - .width(Shrink) - .max_width(500), - ) - .padding(16) - .style(|theme: &Theme| { - let palette = theme.extended_palette(); - container::Style::default() - .background(palette.primary.weak.color) - .border(Border { - color: Color::BLACK, - width: 0., - // width: 4., - radius: Radius::new(16), - }) - }), - ) - .align_left(Fill) - .into() - } else { - let element: Element<Message> = container( - container( - column![ - text(message.body.body.as_str()).wrapping(text::Wrapping::WordOrGlyph), - container(text(timestamp).wrapping(text::Wrapping::None).size(12)) - .align_right(Fill) // row![ - // // horizontal_space(), - // // horizontal_space(), - // text(timestamp).wrapping(text::Wrapping::None).size(12) - // ] // container(text(timestamp).wrapping(text::Wrapping::None).size(12)) - // .align_right(Fill) - ] - .width(Shrink) - .max_width(500), - ) - .padding(16) - .style(|theme: &Theme| { - let palette = theme.extended_palette(); - container::Style::default() - .background(palette.primary.base.color) - .border(Border { - color: Color::BLACK, - width: 0., - // width: 4., - radius: Radius::new(16), - }) - }), - ) - .align_right(Fill) - .into(); - // element.explain(Color::BLACK) - element - } - } -} - -pub fn date(date: NaiveDate) -> Element<'static, Message> { - container(text(date.to_string())).center_x(Fill).into() -} diff --git a/src/open_chats.rs b/src/open_chats.rs new file mode 100644 index 0000000..bf2eb73 --- /dev/null +++ b/src/open_chats.rs @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use filamento::chat::ChatStoreFields; +use indexmap::IndexMap; +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; +use tracing::debug; + +use crate::chat::{ArcMacawChat, 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, ArcMacawChat>, +} + +pub fn open_chat(open_chats: Store<OpenChatsPanel>, chat: ArcMacawChat) { + 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 = chat.get().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 = chat.get().correspondent().read().clone(); + open_chats.chats().write().insert(new_jid.clone(), chat); + *open_chats.chat_view().write() = Some(new_jid); + } + } else { + let new_jid = chat.get().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: ArcMacawChat) { + 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 = chat.get().correspondent().read().clone(); + self.chats.insert_before(index, new_jid.clone(), chat); + *&mut self.chat_view = Some(new_jid); + } else { + let new_jid = chat.get().correspondent().read().clone(); + self.chats.insert(new_jid.clone(), chat); + *&mut self.chat_view = Some(new_jid); + } + } else { + let new_jid = chat.get().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..13aed19 --- /dev/null +++ b/src/roster.rs @@ -0,0 +1,24 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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..1e67f34 --- /dev/null +++ b/src/state_store.rs @@ -0,0 +1,270 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::{ + collections::HashMap, + ops::{Deref, DerefMut}, + sync::{Arc, RwLock}, +}; + +use leptos::prelude::*; +use tracing::debug; + +// TODO: get rid of this +// V has to be an arc signal +#[derive(Debug)] +pub struct ArcStateStore<K, V> { + store: Arc<RwLock<HashMap<K, (ArcRwSignal<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 + std::fmt::Debug, V: Clone + std::fmt::Debug> StateStore<K, V> +where + K: Send + Sync + 'static, + V: Send + Sync + 'static, +{ + pub fn store(&self, key: K, value: V) -> StateListener<K, V> { + let arc_store = self.inner.try_get_value().unwrap(); + let store = arc_store.store.clone(); + let mut store = store.write().unwrap(); + debug!("store state: {:?}", store); + if let Some((v, count)) = store.get_mut(&key) { + debug!("updating old value already in store"); + v.set(value); + *count += 1; + StateListener { + value: v.clone(), + cleaner: StateCleaner { + key, + state_store: arc_store, + }, + } + } else { + let v = ArcRwSignal::new(value); + store.insert(key.clone(), (v.clone(), 1)); + debug!("inserting new value: {:?}", store); + StateListener { + value: v.into(), + cleaner: StateCleaner { + key, + state_store: arc_store, + }, + } + } + } + + pub fn get_listener(&self, key: K) -> Option<StateListener<K, V>> { + let arc_store = self.inner.try_get_value().unwrap(); + let store = arc_store.store.clone(); + let mut store = store.write().unwrap(); + debug!("store state: {:?}", store); + if let Some((v, count)) = store.get_mut(&key) { + *count += 1; + Some(StateListener { + value: v.clone(), + cleaner: StateCleaner { + key, + state_store: arc_store, + }, + }) + } else { + None + } + } +} + +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.set(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) { + v.update(|v| modify(v)); + } + } +} + +#[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: ArcRwSignal<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 = ArcRwSignal<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: ArcStateStore<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 mut store = self.state_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) { + let mut store = self.state_store.store.write().unwrap(); + if let Some((_v, count)) = store.get_mut(&self.key) { + *count -= 1; + if *count == 0 { + store.remove(&self.key); + debug!("dropped item from store"); + } + } + } +} diff --git a/src/user.rs b/src/user.rs new file mode 100644 index 0000000..e277efd --- /dev/null +++ b/src/user.rs @@ -0,0 +1,153 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::ops::{Deref, DerefMut}; + +use filamento::user::{User, UserStoreFields}; +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; + +use crate::{ + client::Client, + roster::{Roster, RosterStoreFields}, + state_store::{StateListener, StateStore}, +}; + +#[derive(Clone, Copy)] +pub struct MacawUser { + pub user: ArenaItem<ArcMacawUser>, + // TODO: just store avatar src in user + // pub avatar: String, + // pub avatar: RwSignal<String>, +} + +impl MacawUser { + pub fn get(&self) -> ArcStore<User> { + self.try_get_value().unwrap().get().0 + } + + pub fn avatar(&self) -> ArcRwSignal<String> { + self.try_get_value().unwrap().get().1 + } +} + +impl Deref for MacawUser { + type Target = ArenaItem<ArcMacawUser>; + + fn deref(&self) -> &Self::Target { + &self.user + } +} + +impl DerefMut for MacawUser { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.user + } +} + +impl From<ArcMacawUser> for MacawUser { + fn from(value: ArcMacawUser) -> Self { + Self { + user: ArenaItem::new_with_storage(value), + // avatar: value.avatar.into(), + } + } +} + +impl From<MacawUser> for ArcMacawUser { + fn from(value: MacawUser) -> Self { + value.user.try_get_value().unwrap() + } +} + +#[derive(Clone)] +pub struct ArcMacawUser { + pub user: StateListener<BareJID, (ArcStore<User>, ArcRwSignal<String>)>, +} + +impl ArcMacawUser { + pub async fn got_user(user: User) -> Self { + let user_state_store: StateStore<BareJID, (ArcStore<User>, ArcRwSignal<String>)> = + use_context().expect("no user state store"); + let old_user = user_state_store.get_listener(user.jid.clone()); + let user = if let Some(old_user) = old_user { + old_user.update(|(old_user, _avatar)| { + old_user.set(user); + }); + old_user + } else { + let avatar = fetch_avatar(user.avatar.as_deref()).await; + let avatar = ArcRwSignal::new(avatar); + user_state_store.store(user.jid.clone(), (ArcStore::new(user), avatar)) + }; + let user = ArcMacawUser { user }; + user + } +} + +impl Deref for ArcMacawUser { + type Target = StateListener<BareJID, (ArcStore<User>, ArcRwSignal<String>)>; + + fn deref(&self) -> &Self::Target { + &self.user + } +} + +impl DerefMut for ArcMacawUser { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.user + } +} + +pub const NO_AVATAR: &str = "/assets/no-avatar.png"; + +pub async fn fetch_avatar(id: Option<&str>) -> String { + if let Some(avatar) = id { + 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() + } + } else { + NO_AVATAR.to_string() + } +} + +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() + } + } 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..87f9bdc --- /dev/null +++ b/src/user_presences.rs @@ -0,0 +1,173 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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..d1bb29a --- /dev/null +++ b/src/views/login_page.rs @@ -0,0 +1,202 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::{str::FromStr, sync::Arc}; + +use filamento::{ + db::Db, error::{CommandError, ConnectionError, DatabaseOpenError}, files::{opfs::OPFSError, FilesMem, FilesOPFS}, UpdateMessage +}; +use jid::JID; +use leptos::prelude::*; +use thiserror::Error; +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("Failed to open database: {0}")] + DatabaseOpen(#[from] DatabaseOpenError), + #[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 + } else { + debug!("creating db in memory"); + Db::create_connect_and_migrate_memory().await + }; + let db = match db { + Ok(db) => db, + Err(e) => { + set_error.set(Some(e.into())); + set_login_pending.set(false); + return; + } + }; + 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..e91e08a --- /dev/null +++ b/src/views/macaw.rs @@ -0,0 +1,199 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use std::collections::{HashMap, HashSet}; + +use filamento::{ + UpdateMessage, + chat::{Chat, Message, MessageStoreFields}, + user::User, +}; +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::{ArcMacawContact, MacawContact}, + message::{ArcMacawMessage, MacawMessage}, + message_subscriptions::MessageSubscriptions, + open_chats::OpenChatsPanel, + roster::{Roster, RosterStoreFields}, + state_store::StateStore, + user::{ArcMacawUser, MacawUser, fetch_avatar}, + user_presences::{Presences, UserPresences}, +}; + +use super::AppState; + +mod open_chats_panel; +pub mod settings; + +#[component] +pub fn Macaw( + // TODO: logout + // app_state: WriteSignal<Option<essage>)>, LocalStorage>, + client: Client, + mut updates: Receiver<UpdateMessage>, + set_app: WriteSignal<AppState>, +) -> impl IntoView { + let (updates, set_updates) = signal(Some(updates)); + 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>, ArcRwSignal<String>)> = 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<MacawUser> = 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(); + ArcMacawUser::got_user(user).await.into() + }); + 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 + + LocalResource::new(move || async move { + let mut updates = set_updates.write().take().expect("main loop ran twice"); + while let Some(update) = updates.recv().await { + match update { + UpdateMessage::Online(online, items) => { + let mut contacts = HashMap::new(); + for (contact, user) in items { + contacts.insert( + contact.user_jid.clone(), + ArcMacawContact::got_contact_and_user(contact, user) + .await + .into(), + ); + } + 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) => { + let new_contact = ArcMacawContact::got_contact_and_user(contact.clone(), user) + .await + .into(); + 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(); + roster.insert(jid, new_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_untracked() + .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 = ArcMacawMessage::got_message_and_user(message, from).await; + 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, _avatar)| { + user.update(|user| *&mut user.nick = nick.clone()) + }); + } + UpdateMessage::AvatarChanged { jid, id } => { + let new_avatar = fetch_avatar(id.as_deref()).await; + users_store.modify(&jid, |(user, avatar)| { + *&mut user.write().avatar = id.clone(); + *&mut avatar.set(new_avatar.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..375e8f3 --- /dev/null +++ b/src/views/macaw/open_chats_panel.rs @@ -0,0 +1,81 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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() /> }.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 { + view! { + <div class="open-chat-view"> + <ChatViewHeader chat=chat.clone() /> + <MessageHistoryBuffer chat=chat.clone() /> + {move || { + let chat_jid = chat.get().correspondent().get(); + view! { <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..7bdc2b9 --- /dev/null +++ b/src/views/macaw/settings.rs @@ -0,0 +1,391 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use leptos::prelude::*; +use profile_settings::ProfileSettings; + +use crate::{ + components::{icon::IconComponent, modal::Modal}, + icon::Icon, +}; + +mod profile_settings { + use filamento::{ + error::{AvatarPublishError, CommandError, NickError}, + user::User, + }; + use leptos::prelude::*; + use thiserror::Error; + use web_sys::{ + Event, FileReader, HtmlInputElement, ProgressEvent, Url, + js_sys::Uint8Array, + wasm_bindgen::{JsCast, UnwrapThrowExt, prelude::Closure}, + }; + + use crate::{ + client::Client, + files::Files, + user::{NO_AVATAR, fetch_avatar}, + }; + + #[derive(Debug, Clone, Error)] + pub enum ProfileSaveError { + #[error("avatar publish: {0}")] + Avatar(#[from] CommandError<AvatarPublishError<Files>>), + #[error("nick publish: {0}")] + Nick(#[from] CommandError<NickError>), + } + + #[component] + pub fn ProfileSettings() -> impl IntoView { + let client: Client = use_context().expect("no client in context"); + + let old_profile = LocalResource::new(move || { + let value = client.clone(); + async move { + // TODO: error + let jid = &*value.jid; + let old_profile = value.get_user(jid.clone()).await.unwrap(); + old_profile + } + }); + + view! { + {move || { + if let Some(old_profile) = old_profile.get() { + view! { <ProfileForm old_profile /> }.into_any() + } else { + view! {}.into_any() + } + }} + } + } + + #[component] + pub fn ProfileForm(old_profile: User) -> 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 (success_message, set_success_message) = signal(None::<String>); + let success_message = move || { + if let Some(message) = success_message.get() { + view! { <div class="success">{message}</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 new_nick = RwSignal::new(old_profile.nick.clone().unwrap_or_default().to_string()); + let has_avatar = RwSignal::new(old_profile.avatar.is_some()); + let new_avatar_preview_url = RwSignal::new(None::<String>); + let remove_avatar = RwSignal::new(false); + + 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 url = Url::create_object_url_with_blob(&file).unwrap_throw(); + + new_avatar_preview_url.set(Some(url)); + 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(); + let old_nick = old_profile.nick.clone(); + async move { + set_profile_save_pending.set(true); + + let new_nick = new_nick.get(); + let new_nick = if new_nick.is_empty() { + None + } else { + Some(new_nick) + }; + if new_nick != old_nick { + match client.change_nick(new_nick).await { + Ok(_) => {} + Err(e) => { + set_error.set(Some(ProfileSaveError::Nick(e))); + set_profile_save_pending.set(false); + return; + } + } + } + + if let Some(profile_data) = profile_upload_data.get() { + match client.change_avatar(Some(profile_data)).await { + Ok(_) => {} + Err(e) => { + set_error.set(Some(ProfileSaveError::Avatar(e))); + set_profile_save_pending.set(false); + return; + } + } + } else if remove_avatar.get() { + match client.change_avatar(None).await { + Ok(_) => {} + Err(e) => { + set_error.set(Some(ProfileSaveError::Avatar(e))); + set_profile_save_pending.set(false); + return; + } + } + } + + set_profile_save_pending.set(false); + set_error.set(None); + set_success_message.set(Some("Profile Updated!".to_string())); + } + }); + + let _old_account_avatar = LocalResource::new(move || { + let avatar = old_profile.avatar.clone(); + async move { + let url = fetch_avatar(avatar.as_deref()).await; + new_avatar_preview_url.set(Some(url)); + } + }); + + view! { + <div class="profile-settings"> + <div class="profile-preview"> + <h2>Profile Preview</h2> + <div class="preview"> + <img class="avatar" src=new_avatar_preview_url /> + <div class="nick"> + {move || { + let nick = new_nick.get(); + if nick.is_empty() { old_profile.jid.to_string() } else { nick } + }} + </div> + </div> + </div> + <form + class="profile-form" + on:submit=move |ev| { + ev.prevent_default(); + save_profile.dispatch(()); + } + > + {success_message} + {error_message} + <div> + <h3>Nick</h3> + <input + disabled=profile_save_pending + placeholder="Nick" + type="text" + id="client-user-nick" + bind:value=new_nick + name="client-user-nick" + /> + </div> + <div> + <h3>Avatar</h3> + <div class="change-avatar"> + <label for="client-user-avatar"> + <div class="button">Change Avatar</div> + </label> + <input + type="file" + id="client-user-avatar" + on:change=move |e| { + has_avatar.set(true); + remove_avatar.set(false); + from_input(e); + } + /> + {move || { + if has_avatar.get() { + view! { + <a + on:click=move |_| { + profile_upload_data.set(None); + remove_avatar.set(true); + has_avatar.set(false); + new_avatar_preview_url.set(Some(NO_AVATAR.to_string())); + } + style="cursor: pointer" + > + Remove Avatar + </a> + } + .into_any() + } else { + view! {}.into_any() + } + }} + </div> + </div> + <hr /> + <input + disabled=profile_save_pending + class="button" + type="submit" + value="Save Changes" + /> + </form> + </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 style="padding: 16px"> + "Account settings coming soon!" + </div> + } + .into_any() + } + SettingsPage::Chat => { + view! { + <div style="padding: 16px"> + "Chat settings coming soon!" + </div> + } + .into_any() + } + SettingsPage::Profile => { + view! { <ProfileSettings /> }.into_any() + } + SettingsPage::Privacy => { + view! { + <div style="padding: 16px"> + "Privacy settings coming soon!" + </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..69ba606 --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +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() + } + } + }} + } +} |
