diff options
Diffstat (limited to '')
40 files changed, 4179 insertions, 1992 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/icons.rs b/src/icons.rs deleted file mode 100644 index 934a0c8..0000000 --- a/src/icons.rs +++ /dev/null @@ -1,127 +0,0 @@ -use iced::widget::svg; -use iced::widget::{svg::Handle, Svg}; -use iced::Element; - -pub enum Icon { - AddContact24, - Attachment24, - Away16, - Away16Color, - Bubble16, - Bubble16Color, - Bubble24, - Contact24, - Delivered16, - Dnd16, - Dnd16Color, - Error16Color, - Forward24, - Heart24, - NewBubble24, - Reply24, - Sending16, - Sent16, -} - -impl From<Icon> for Svg<'_> { - fn from(value: Icon) -> Self { - match value { - Icon::AddContact24 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/addcontact24.svg" - ))) - .width(24) - .height(24), - Icon::Attachment24 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/attachment24.svg" - ))) - .width(24) - .height(24), - Icon::Away16 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/away16.svg" - ))) - .width(16) - .height(16), - Icon::Away16Color => svg(Handle::from_memory(include_bytes!( - "../assets/icons/away16color.svg" - ))) - .width(16) - .height(16), - Icon::Bubble16 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/bubble16.svg" - ))) - .width(16) - .height(16), - Icon::Bubble16Color => svg(Handle::from_memory(include_bytes!( - "../assets/icons/bubble16color.svg" - ))) - .width(16) - .height(16), - Icon::Bubble24 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/bubble24.svg" - ))) - .width(24) - .height(24), - Icon::Contact24 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/contact24.svg" - ))) - .width(24) - .height(24), - Icon::Delivered16 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/delivered16.svg" - ))) - .width(16) - .height(16), - Icon::Dnd16 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/dnd16.svg" - ))) - .width(16) - .height(16), - Icon::Dnd16Color => svg(Handle::from_memory(include_bytes!( - "../assets/icons/dnd16color.svg" - ))) - .width(16) - .height(16), - Icon::Error16Color => svg(Handle::from_memory(include_bytes!( - "../assets/icons/error16color.svg" - ))) - .width(16) - .height(16), - Icon::Forward24 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/forward24.svg" - ))) - .width(24) - .height(24), - Icon::Heart24 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/heart24.svg" - ))) - .width(24) - .height(24), - Icon::NewBubble24 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/newbubble24.svg" - ))) - .width(24) - .height(24), - Icon::Reply24 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/reply24.svg" - ))) - .width(24) - .height(24), - Icon::Sending16 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/sending16.svg" - ))) - .width(16) - .height(16), - Icon::Sent16 => svg(Handle::from_memory(include_bytes!( - "../assets/icons/sent16.svg" - ))) - .width(16) - .height(16), - } - } -} - -impl<Message> From<Icon> for Element<'_, Message> { - fn from(value: Icon) -> Self { - Into::<Svg>::into(value).into() - } -} 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 f61c204..0000000 --- a/src/login_modal.rs +++ /dev/null @@ -1,112 +0,0 @@ -use filamento::presence::{Offline, Presence}; -use iced::{ - futures::StreamExt, - widget::{button, checkbox, column, container, text, text_input}, - Element, Task, -}; -use jid::JID; -use keyring::Entry; -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 b785562..3db4c25 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,1477 +1,13 @@ -use std::borrow::{Borrow, Cow}; -use std::cell::{Ref, RefCell}; -use std::collections::{HashMap, HashSet}; -use std::fmt::Debug; -use std::ops::{Deref, DerefMut}; -use std::path::{Path, PathBuf}; -use std::rc::{Rc, Weak}; -use std::str::FromStr; -use std::sync::{Arc, Mutex}; +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later -use chrono::{Local, Utc}; -use filamento::chat::{Chat, Message as ChatMessage}; -use filamento::error::{CommandError, DatabaseError}; -use filamento::files::Files; -use filamento::presence::{Offline, Presence, PresenceType}; -use filamento::{roster::Contact, user::User, UpdateMessage}; -use iced::alignment::Horizontal::Right; -use iced::font::{Stretch, Weight}; -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}; -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, image, mouse_area, opaque, row, - scrollable, stack, text, text_input, toggler, Column, Svg, Text, Toggler, -}; -use iced::Length::{self, Fill, Shrink}; -use iced::{color, stream, Color, Element, Font, Subscription, Task, Theme}; -use icons::Icon; -use indexmap::{indexmap, IndexMap}; -use jid::JID; -use keyring::Entry; -use login_modal::{Creds, LoginModal}; -use message_view::MessageView; -use serde::{Deserialize, Serialize}; -use thiserror::Error; -use tokio::sync::mpsc::Sender; -use tokio::sync::{mpsc, oneshot}; -use tokio_stream::wrappers::ReceiverStream; -use tracing::{debug, error, info}; -use uuid::Uuid; +use leptos::prelude::*; +use macaw_web::App; -mod icons; -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(), - } - } -} - -// any object that references another contains an arc to that object, so that items can be garbage-collected by checking reference count -// maybe have a cache which is a set of an enum of reference counted objects, so that when an object is needed it's first cloned from the set, otherwise it is added then cloned. then once an object is no longer needed, it is automatically garbage collected. -// or maybe have the cache items automatically drop themselves at 1 reference? some kind of custom pointer. items in the cache must be easily addressable and updateable. -pub struct Macaw { - client: Account, - config: Config, - presences: HashMap<JID, Presence>, - subscription_requests: HashSet<MacawUser>, - new_chat: Option<NewChat>, - // references chats, users, messages - open_chat: Option<MessageView>, - // references users, contacts - roster: HashMap<JID, MacawContact>, - // references chats, users, messages - chats_list: IndexMap<JID, ChatListItem>, -} - -#[derive(Debug, Clone)] -pub struct MacawMessage { - inner: ChatMessage, - from: MacawUser, -} - -impl Deref for MacawMessage { - type Target = ChatMessage; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl DerefMut for MacawMessage { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner - } -} - -#[derive(Debug, Clone)] -pub struct MacawUser { - inner: User, - // contact not needed, as can always query the roster store to get this option. - // contact: Option<Contact>, -} - -impl Deref for MacawUser { - type Target = User; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl DerefMut for MacawUser { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner - } -} - -#[derive(Debug, Clone)] -pub struct MacawContact { - inner: Contact, - user: User, -} - -impl Deref for MacawContact { - type Target = Contact; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl DerefMut for MacawContact { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner - } -} - -#[derive(Debug, Clone)] -pub struct MacawChat { - inner: Chat, - user: MacawUser, -} - -pub struct ChatListItem { - // references chats - inner: MacawChat, - // references users, messages - latest_message: Option<MacawMessage>, -} - -impl Deref for ChatListItem { - type Target = MacawChat; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl DerefMut for ChatListItem { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner - } -} - -impl Deref for MacawChat { - type Target = Chat; - - fn deref(&self) -> &Self::Target { - &self.inner - } -} - -impl DerefMut for MacawChat { - fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.inner - } -} - -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, - presences: HashMap::new(), - subscription_requests: HashSet::new(), - new_chat: None, - open_chat: None, - roster: HashMap::new(), - chats_list: IndexMap::new(), - } - } -} - -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: filamento::Client<Files>, - files_root: PathBuf, - jid: JID, - status: Presence, - connection_state: ConnectionState, -} - -impl Client { - pub fn is_connected(&self) -> bool { - self.connection_state.is_connected() - } - - pub fn files_root(&self) -> &Path { - &self.files_root - } -} - -#[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 = filamento::Client<Files>; - - fn deref(&self) -> &Self::Target { - &self.client - } -} - -async fn filamento( - jid: &JID, - creds: &Creds, - cfg: &Config, -) -> ( - (filamento::Client<Files>, mpsc::Receiver<UpdateMessage>), - PathBuf, -) { - let filamento; - 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 = filamento::db::Db::create_connect_and_migrate(db_path) - .await - .unwrap(); - let files; - 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()); - let files_dir = data_dir.join("files"); - files = Files::new(&files_dir); - if !tokio::fs::try_exists(&files_dir) - .await - .expect("could not read storage directory") - { - tokio::fs::create_dir_all(&files_dir) - .await - .expect("could not create file storage directory") - } - filamento = ( - filamento::Client::new(jid.clone(), creds.password.to_string(), db, files), - files_dir, - ); - } 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("files"); - let files_dir = data_dir; - files = Files::new(&files_dir); - if !tokio::fs::try_exists(&files_dir) - .await - .expect("could not read storage directory") - { - tokio::fs::create_dir_all(&files_dir) - .await - .expect("could not create file storage directory") - } - filamento = ( - filamento::Client::new(jid.clone(), creds.password.to_string(), db, files), - files_dir, - ); - } - } 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()); - let files_dir = data_dir.join("files"); - let files = Files::new(&files_dir); - data_dir.push(format!("{}.db", creds.jid.clone())); - let db = filamento::db::Db::create_connect_and_migrate(data_dir) - .await - .unwrap(); - if !tokio::fs::try_exists(&files_dir) - .await - .expect("could not read storage directory") - { - tokio::fs::create_dir_all(&files_dir) - .await - .expect("could not create file storage directory") - } - filamento = ( - filamento::Client::new(jid.clone(), creds.password.to_string(), db, files), - files_dir, - ); - } 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()); - let files_dir = data_dir.join("files"); - let files = Files::new(&files_dir); - data_dir.push(format!("{}.db", creds.jid.clone())); - info!("db_path: {:?}", data_dir); - let db = filamento::db::Db::create_connect_and_migrate(data_dir) - .await - .unwrap(); - if !tokio::fs::try_exists(&files_dir) - .await - .expect("could not read storage directory") - { - tokio::fs::create_dir_all(&files_dir) - .await - .expect("could not create file storage directory") - } - filamento = ( - filamento::Client::new(jid.clone(), creds.password.to_string(), db, files), - files_dir, - ); - } - } - filamento -} - -#[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, - filamento::Client<Files>, - mpsc::Receiver<UpdateMessage>, - PathBuf, - )> = None; - if let Some(creds) = creds { - let jid = creds.jid.parse::<JID>(); - match jid { - Ok(jid) => { - let ((handle, updates), files_dir) = filamento(&jid, &creds, &cfg).await; - client = Some((jid, handle, updates, files_dir)); - } - Err(e) => client_creation_error = Some(Error::CredentialsLoad(e.into())), - } - } - - if let Some((jid, luz_handle, update_recv, files_root)) = 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_with_users().await }, - |result| { - let roster = result.unwrap(); - let mut macaw_roster = HashMap::new(); - for (contact, user) in roster { - macaw_roster.insert( - contact.user_jid.clone(), - MacawContact { - inner: contact, - user, - }, - ); - } - Message::Roster(macaw_roster) - }, - ), - Task::perform( - async move { - luz_handle2 - .get_chats_ordered_with_latest_messages_and_users() - .await - }, - |chats| { - let chats = chats.unwrap(); - info!("got chats: {:?}", chats); - Message::GotChats(chats) - }, - ), - ]) - .chain(Task::done(Message::Connect)), - Task::stream(stream), - ]) - } else { - debug!("no auto connect"); - Task::batch([ - Task::perform( - async move { luz_handle1.get_roster_with_users().await }, - |result| { - let roster = result.unwrap(); - let mut macaw_roster = HashMap::new(); - for (contact, user) in roster { - macaw_roster.insert( - contact.user_jid.clone(), - MacawContact { - inner: contact, - user, - }, - ); - } - Message::Roster(macaw_roster) - }, - ), - Task::perform( - async move { - luz_handle2 - .get_chats_ordered_with_latest_messages_and_users() - .await - }, - |chats| { - let chats = chats.unwrap(); - info!("got chats: {:?}", chats); - Message::GotChats(chats) - }, - ), - Task::stream(stream), - ]) - } - }; - let mut font = Font::with_name("K2D"); - font.weight = Weight::Medium; - // font.stretch = Stretch::Condensed; - iced::application("Macaw", Macaw::update, Macaw::view) - .font(include_bytes!("../assets/fonts/Diolce-Regular.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Italic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Thin.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraBold.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraLightItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraLight.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Light.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Light.ttf")) - .font(include_bytes!("../assets/fonts/K2D-BoldItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-MediumItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ThinItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Medium.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Bold.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Regular.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraBoldItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-LightItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-SemiBoldItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-SemiBold.ttf")) - .default_font(font) - .subscription(subscription) - .theme(Macaw::theme) - .run_with(|| { - ( - Macaw::new( - Some(Client { - client: luz_handle, - jid, - status: Presence { - timestamp: Utc::now(), - presence: PresenceType::Offline(Offline::default()), - }, - connection_state: ConnectionState::Offline, - files_root, - }), - cfg, - ), - task, - ) - }) - } else { - if let Some(e) = client_creation_error { - iced::application("Macaw", Macaw::update, Macaw::view) - .font(include_bytes!("../assets/fonts/Diolce-Regular.otf")) - .font(include_bytes!("../assets/fonts/K2D-Italic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Thin.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraBold.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraLightItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraLight.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Light.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Light.ttf")) - .font(include_bytes!("../assets/fonts/K2D-BoldItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-MediumItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ThinItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Medium.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Bold.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Regular.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraBoldItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-LightItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-SemiBoldItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-SemiBold.ttf")) - .default_font(Font::with_name("K2D")) - .theme(Macaw::theme) - .run_with(|| (Macaw::new(None, cfg), Task::done(Message::Error(e)))) - } else { - iced::application("Macaw", Macaw::update, Macaw::view) - .font(include_bytes!("../assets/fonts/Diolce-Regular.otf")) - .font(include_bytes!("../assets/fonts/K2D-Italic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Thin.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraBold.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraLightItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraLight.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Light.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Light.ttf")) - .font(include_bytes!("../assets/fonts/K2D-BoldItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-MediumItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ThinItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Medium.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Bold.ttf")) - .font(include_bytes!("../assets/fonts/K2D-Regular.ttf")) - .font(include_bytes!("../assets/fonts/K2D-ExtraBoldItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-LightItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-SemiBoldItalic.ttf")) - .font(include_bytes!("../assets/fonts/K2D-SemiBold.ttf")) - .default_font(Font::with_name("K2D")) - .theme(Macaw::theme) - .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, MacawContact>), - Connect, - Disconnect, - GotChats(Vec<((Chat, User), (ChatMessage, User))>), - 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] filamento::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<filamento::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::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 roster = vec - .into_iter() - .map(|(contact, user)| { - ( - contact.user_jid.clone(), - MacawContact { - inner: contact, - user, - }, - ) - }) - .collect(); - // no need to also update users as any user updates will come in separately - 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::RosterUpdate(contact, user) => { - self.roster.insert( - contact.user_jid.clone(), - MacawContact { - inner: contact, - user, - }, - ); - Task::none() - } - UpdateMessage::RosterDelete(jid) => { - self.roster.remove(&jid); - Task::none() - } - UpdateMessage::Presence { from, presence } => { - // TODO: presence handling - info!("got presence from {:?} {:?}", from, presence); - self.presences.insert(from.as_bare(), presence); - Task::none() - } - UpdateMessage::Message { to, message, from } => { - let message = MacawMessage { - inner: message, - from: MacawUser { inner: from }, - }; - if let Some((chat_jid, mut chat_list_item)) = - self.chats_list.shift_remove_entry(&to) - { - chat_list_item.latest_message = Some(message.clone()); - self.chats_list.insert_before(0, chat_jid, chat_list_item); - if let Some(open_chat) = &mut self.open_chat { - if open_chat.chat.user.jid == to { - open_chat.update(message_view::Message::Message(message)); - } - } - } else { - // TODO: get the actual chat from the thing, or send the chat first, from the client side. - let chat = Chat { - correspondent: to.clone(), - have_chatted: false, - }; - let chat_list_item = ChatListItem { - inner: MacawChat { - inner: chat, - user: message.from.clone(), - }, - latest_message: Some(message), - }; - self.chats_list.insert_before(0, to, chat_list_item); - } - Task::none() - } - UpdateMessage::SubscriptionRequest(jid) => { - // TODO: subscription requests - Task::none() - } - UpdateMessage::MessageDelivery { chat, id, delivery } => { - if let Some(chat_list_item) = self.chats_list.get_mut(&chat) { - if let Some(latest_message) = &mut chat_list_item.latest_message { - if latest_message.id == id { - latest_message.delivery = Some(delivery) - } - } - } - if let Some(open_chat) = &mut self.open_chat { - if let Some(message) = open_chat.messages.get_mut(&id) { - message.delivery = Some(delivery) - } - } - Task::none() - } - UpdateMessage::NickChanged { jid, nick } => { - // roster, chats_list, open chat - if let Some(contact) = self.roster.get_mut(&jid) { - contact.user.nick = nick.clone(); - } - if let Some(chats_list_item) = self.chats_list.get_mut(&jid) { - chats_list_item.user.nick = nick.clone() - } - if let Some(open_chat) = &mut self.open_chat { - for (_, message) in &mut open_chat.messages { - if message.from.jid == jid { - message.from.nick = nick.clone() - } - } - if open_chat.chat.user.jid == jid { - open_chat.chat.user.nick = nick - } - } - Task::none() - } - UpdateMessage::AvatarChanged { jid, id } => { - // roster, chats_list, open chat - if let Some(contact) = self.roster.get_mut(&jid) { - contact.user.avatar = id.clone(); - } - if let Some(chats_list_item) = self.chats_list.get_mut(&jid) { - chats_list_item.user.avatar = id.clone() - } - if let Some(open_chat) = &mut self.open_chat { - // TODO: consider using an indexmap with two keys for speeding this up? - for (_, message) in &mut open_chat.messages { - if message.from.jid == jid { - message.from.avatar = id.clone() - } - } - if open_chat.chat.user.jid == jid { - open_chat.chat.user.avatar = id - } - } - 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_with_users().await }, - |result| { - let roster = result.unwrap(); - let mut macaw_roster = HashMap::new(); - for (contact, user) in roster { - macaw_roster.insert( - contact.user_jid.clone(), - MacawContact { - inner: contact, - user, - }, - ); - } - // TODO: clean this up - Message::Roster(macaw_roster) - }, - ), - Task::perform( - async move { - client2 - .client - .get_chats_ordered_with_latest_messages_and_users() - .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_with_users().await }, - |result| { - let roster = result.unwrap(); - let mut macaw_roster = HashMap::new(); - for (contact, user) in roster { - macaw_roster.insert( - contact.user_jid.clone(), - MacawContact { - inner: contact, - user, - }, - ); - } - Message::Roster(macaw_roster) - }, - ), - Task::perform( - async move { - client2 - .client - .get_chats_ordered_with_latest_messages_and_users() - .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.connect().await.unwrap(); - }) - .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.disconnect(Offline::default()).await.unwrap(); - }) - .discard() - } - Account::LoggedOut(login_modal) => Task::none(), - }, - Message::ToggleChat(jid) => { - match &self.open_chat { - Some(message_view) => { - if message_view.chat.user.jid == jid { - self.open_chat = None; - return Task::none(); - } - } - None => {} - } - if let Some(chat) = self.chats_list.get(&jid) { - match &self.client { - Account::LoggedIn(client) => { - let client = client.clone(); - self.open_chat = Some(MessageView::new( - (*chat).clone(), - &self.config, - client.files_root.clone(), - )); - Task::perform( - async move { client.get_messages_with_users(jid).await }, - move |result| { - let message_history = result.unwrap(); - let messages = message_history - .into_iter() - .map(|(message, user)| MacawMessage { - inner: message, - from: MacawUser { inner: user }, - }) - .collect(); - Message::MessageView(message_view::Message::MessageHistory( - messages, - )) - }, - ) - } - Account::LoggedOut(login_modal) => Task::none(), - } - } else { - 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), files_root) = filamento(&jid, &creds, &config).await; - (handle, recv, jid, creds, config, files_root) - }, move |(handle, recv, jid, creds, config, files_root)| { - 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, - files_root, - }, - ))); - 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, chat_user), (message, message_user)) in chats { - let chat = MacawChat { - inner: chat, - user: MacawUser { inner: chat_user }, - }; - let latest_message = MacawMessage { - inner: message, - from: MacawUser { - inner: message_user, - }, - }; - let chat_list_item = ChatListItem { - inner: chat.clone(), - latest_message: Some(latest_message), - }; - self.chats_list - .insert(chat.correspondent.clone(), chat_list_item); - } - Task::batch(tasks) - } - 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, filamento::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.chat.user.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![]; - if let Account::LoggedIn(client) = &self.client { - for (jid, chat) in &self.chats_list { - let mut open = false; - if let Some(open_chat) = &self.open_chat { - if open_chat.chat.user.jid == *jid { - open = true; - } - } - let chat_list_item = chat_list_item( - &self.presences, - &self.roster, - client.files_root(), - chat, - 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!(0x503e34), - text: color!(0xdcdcdc), - }, - weak: Pair { - color: color!(0x392c25), - text: color!(0xdcdcdc), - }, - strong: Pair { - color: color!(0x293f2e), - 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!(0xdcdcdc), - }, - weak: Pair { - color: color!(0xffce07), - text: color!(0xdcdcdc), - }, - strong: Pair { - color: color!(0xffce07), - text: color!(0xdcdcdc), - }, - }, - 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), - }, - }, - 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>( - presences: &HashMap<JID, Presence>, - roster: &HashMap<JID, MacawContact>, - file_root: &'a Path, - chat_list_item: &'a ChatListItem, - open: bool, -) -> Element<'a, Message> { - let name: String; - if let Some(Some(contact_name)) = roster - .get(chat_list_item.correspondent()) - .map(|contact| &contact.name) - { - name = contact_name.clone() - } else if let Some(nick) = &chat_list_item.user.nick { - name = nick.clone() - } else { - name = chat_list_item.correspondent().to_string(); - } - - let avatar: Option<String>; - if let Some(user_avatar) = &chat_list_item.user.avatar { - avatar = Some(user_avatar.clone()) - } else { - avatar = None - } - - let latest_message_text: Option<(String, String)>; - if let Some(latest_message) = &chat_list_item.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 = date.time().format("%H:%M").to_string() - } else { - timeinfo = date.date().format("%d/%m").to_string() - } - latest_message_text = Some((message, timeinfo)); - // content = content.push( - // row![ - // container(text(message).wrapping(Wrapping::None)) - // .clip(true) - // .width(Fill), - // timeinfo - // ] - // .spacing(8) - // .width(Fill), - // ); - } else { - latest_message_text = None; - } - - let mut avatar_stack = stack([]); - if let Some(avatar) = avatar { - let mut path = file_root.join(avatar); - path.set_extension("jpg"); - info!("got avatar: {:?}", path); - avatar_stack = avatar_stack.push(image(path).width(48).height(48)); - } - let mut status_icon: Option<Icon> = None; - if let Some(presence) = presences.get(&chat_list_item.user.jid) { - debug!("found a presence"); - match &presence.presence { - PresenceType::Online(online) => match online.show { - Some(s) => match s { - filamento::presence::Show::Away => status_icon = Some(Icon::Away16Color), - filamento::presence::Show::Chat => status_icon = Some(Icon::Bubble16Color), - filamento::presence::Show::DoNotDisturb => status_icon = Some(Icon::Dnd16Color), - filamento::presence::Show::ExtendedAway => { - status_icon = Some(Icon::Away16Color) - } - }, - None => status_icon = Some(Icon::Bubble16Color), - }, - PresenceType::Offline(offline) => {} - } - } - if let Some(status_icon) = status_icon { - avatar_stack = avatar_stack.push(Into::<Svg>::into(status_icon)); - } - let content: Element<Message> = if let Some((message, time)) = latest_message_text { - row![ - avatar_stack, - column![ - text(name), - row![ - container(text(message).wrapping(Wrapping::None)) - .clip(true) - .width(Fill), - text(time) - ] - .spacing(8) - .width(Fill) - ] - .spacing(8) - ] - .spacing(8) - .into() - } else { - row![avatar_stack, text(name)].spacing(8).into() - }; - let mut button = - button(content).on_press(Message::ToggleChat(chat_list_item.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 f5319bd..0000000 --- a/src/message_view.rs +++ /dev/null @@ -1,280 +0,0 @@ -use std::{path::PathBuf, time::Duration}; - -use chrono::{NaiveDate, NaiveDateTime, TimeDelta}; -use iced::color; -use iced::widget::text_editor; -use iced::{ - border::Radius, - font::{Style, Weight}, - widget::{button, column, container, image, row, scrollable, text, text_editor::Content}, - Border, Color, Element, Font, - Length::{Fill, Shrink}, - Theme, -}; -use indexmap::IndexMap; -use jid::JID; -use serde::{Deserialize, Serialize}; -use uuid::Uuid; - -use crate::{icons::Icon, MacawChat, MacawMessage}; - -pub struct MessageView { - pub file_root: PathBuf, - // references chats, users - pub chat: MacawChat, - // references users, messages - pub messages: IndexMap<Uuid, MacawMessage>, - pub config: Config, - 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<MacawMessage>), - Message(MacawMessage), - MessageCompose(text_editor::Action), - SendMessage(String), -} - -pub enum Action { - None, - SendMessage(String), -} - -impl MessageView { - pub fn new(chat: MacawChat, config: &super::Config, file_root: PathBuf) -> Self { - Self { - chat, - // TODO: save position in message history - messages: 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, - file_root, - } - } - - pub fn update(&mut self, message: Message) -> Action { - match message { - 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) - } - Message::MessageHistory(macaw_messages) => { - if self.messages.is_empty() { - self.messages = macaw_messages - .into_iter() - .map(|message| (message.id, message)) - .collect() - } else { - for message in macaw_messages { - let index = match self - .messages - .binary_search_by(|_, value| value.timestamp.cmp(&message.timestamp)) - { - Ok(i) => i, - Err(i) => i, - }; - self.messages.insert_before(index, message.id, message); - } - } - Action::None - } - Message::Message(macaw_message) => { - if let Some((_, last)) = self.messages.last() { - if last.timestamp < macaw_message.timestamp { - self.messages.insert(macaw_message.id, macaw_message); - } else { - let index = match self.messages.binary_search_by(|_, value| { - value.timestamp.cmp(&macaw_message.timestamp) - }) { - Ok(i) => i, - Err(i) => i, - }; - self.messages - .insert_before(index, macaw_message.id, macaw_message); - } - } else { - self.messages.insert(macaw_message.id, macaw_message); - } - Action::None - } - } - } - - pub fn view(&self) -> Element<Message> { - let mut messages_view = column![].spacing(8).padding(8); - let mut last_timestamp = NaiveDateTime::MIN; - let mut last_user: Option<JID> = None; - for (_id, message) in &self.messages { - let message_timestamp = message.timestamp.naive_local(); - if message_timestamp.date() > last_timestamp.date() { - messages_view = messages_view.push(date(message_timestamp.date())); - } - if last_user.as_ref() != Some(&message.from.jid) - || message_timestamp - last_timestamp > TimeDelta::minutes(3) - { - messages_view = messages_view.push(self.message(message, true)); - } else { - messages_view = messages_view.push(self.message(message, false)); - } - last_user = Some(message.from.jid.clone()); - last_timestamp = message_timestamp; - } - let text_editor = text_editor(&self.new_message) - .placeholder("new message") - .on_action(Message::MessageCompose) - .wrapping(text::Wrapping::WordOrGlyph) - .style(|theme, status| text_editor::Style { - background: color!(0xdcdcdc).into(), - border: Border { - color: Color::BLACK, - width: 0.0, - radius: 0.into(), - }, - icon: color!(0x00000000), - placeholder: color!(0xacacac), - value: color!(0x000000), - selection: color!(0xffce07), - }); - let message_send_input = row![ - text_editor, - // button(Icon::NewBubble24).on_press(Message::SendMessage(self.new_message.text())) - ] - .padding(8); - column![ - self.header(), - scrollable(messages_view) - .height(Fill) - .width(Fill) - .spacing(1) - .anchor_bottom(), - message_send_input - ] - .into() - } - - pub fn header(&self) -> Element<'_, Message> { - // TODO: contact stored here for name - let mut bold = Font::with_name("K2D"); - bold.weight = Weight::Bold; - let mut sweet = Font::with_name("Diolce"); - sweet.style = Style::Italic; - let mut name_and_jid = column![]; - if let Some(nick) = &self.chat.user.nick { - name_and_jid = name_and_jid.push(text(nick).font(bold).size(20)); - } - let jid = self.chat.user.jid.as_bare().to_string(); - name_and_jid = name_and_jid.push(text(jid).font(sweet)); - let mut header = row![]; - if let Some(avatar) = &self.chat.user.avatar { - let mut path = self.file_root.join(avatar); - path.set_extension("jpg"); - header = header.push(container(image(path).width(48).height(48))); - } - header = header.push(name_and_jid); - container( - container(header.spacing(8).padding(8)) - .style(|theme: &Theme| { - container::Style::default() - .background(theme.extended_palette().background.strong.color) - }) - .width(Fill), - ) - .padding(8) - .width(Fill) - .into() - } - - pub fn message<'a>(&'a self, message: &'a MacawMessage, major: bool) -> Element<'a, Message> { - let timestamp = message.timestamp.naive_local(); - let timestamp = timestamp.time().format("%H:%M").to_string(); - - if major { - let nick: String = if let Some(nick) = &message.from.nick { - nick.to_string() - } else { - message.from.jid.as_bare().to_string() - }; - let mut bold = Font::with_name("K2D"); - bold.weight = Weight::Bold; - let mut header = row![text(nick).font(bold), text(timestamp)].spacing(8); - if let Some(delivery) = message.delivery { - let icon = match delivery { - filamento::chat::Delivery::Sending => Some(Icon::Sending16), - filamento::chat::Delivery::Written => None, - filamento::chat::Delivery::Sent => Some(Icon::Sent16), - filamento::chat::Delivery::Delivered => Some(Icon::Delivered16), - filamento::chat::Delivery::Read => Some(Icon::Delivered16), - filamento::chat::Delivery::Failed => Some(Icon::Error16Color), - filamento::chat::Delivery::Queued => Some(Icon::Sending16), - }; - if let Some(icon) = icon { - header = header.push(icon); - } - } - let message_right = column![header, text(&message.body.body)].spacing(8); - let mut major_message = row([]); - if let Some(avatar) = &message.from.avatar { - let mut path = self.file_root.join(avatar); - path.set_extension("jpg"); - // info!("got avatar: {:?}", path); - major_message = major_message.push(container(image(path).width(48).height(48))); - } - major_message = major_message.push(message_right); - major_message.spacing(8).into() - } else { - row![ - container(text(timestamp)).width(48), - text(&message.body.body) - ] - .spacing(8) - .into() - } - } -} - -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() + } + } + }} + } +} |
