summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/chat.rs90
-rw-r--r--src/client.rs36
-rw-r--r--src/components/avatar.rs53
-rw-r--r--src/components/chat_header.rs26
-rw-r--r--src/components/chats_list.rs156
-rw-r--r--src/components/chats_list/chats_list_item.rs93
-rw-r--r--src/components/icon.rs64
-rw-r--r--src/components/message.rs98
-rw-r--r--src/components/message_composer.rs134
-rw-r--r--src/components/message_history_buffer.rs158
-rw-r--r--src/components/mod.rs17
-rw-r--r--src/components/modal.rs25
-rw-r--r--src/components/new_chat.rs147
-rw-r--r--src/components/overlay.rs22
-rw-r--r--src/components/personal_status.rs249
-rw-r--r--src/components/roster_list.rs133
-rw-r--r--src/components/roster_list/contact_request_manager.rs284
-rw-r--r--src/components/roster_list/roster_list_item.rs122
-rw-r--r--src/components/sidebar.rs233
-rw-r--r--src/contact.rs67
-rw-r--r--src/context.rs3
-rw-r--r--src/files.rs53
-rw-r--r--src/icon.rs114
-rw-r--r--src/lib.rs20
-rw-r--r--src/login_modal.rs115
-rw-r--r--src/main.rs1062
-rw-r--r--src/message.rs80
-rw-r--r--src/message_subscriptions.rs89
-rw-r--r--src/message_view.rs225
-rw-r--r--src/open_chats.rs72
-rw-r--r--src/roster.rs24
-rw-r--r--src/state_store.rs270
-rw-r--r--src/user.rs153
-rw-r--r--src/user_presences.rs173
-rw-r--r--src/views/login_page.rs202
-rw-r--r--src/views/macaw.rs199
-rw-r--r--src/views/macaw/open_chats_panel.rs81
-rw-r--r--src/views/macaw/settings.rs391
-rw-r--r--src/views/mod.rs39
39 files changed, 4179 insertions, 1393 deletions
diff --git a/src/chat.rs b/src/chat.rs
new file mode 100644
index 0000000..e40119f
--- /dev/null
+++ b/src/chat.rs
@@ -0,0 +1,90 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::ops::{Deref, DerefMut};
+
+use filamento::{chat::Chat, user::User};
+use jid::BareJID;
+use leptos::prelude::*;
+use reactive_stores::ArcStore;
+
+use crate::{
+ state_store::{StateListener, StateStore},
+ user::{ArcMacawUser, MacawUser},
+};
+
+#[derive(Clone, Copy)]
+pub struct MacawChat {
+ pub chat: ArenaItem<StateListener<BareJID, ArcStore<Chat>>>,
+ pub user: MacawUser,
+ // user: StateListener<BareJID, ArcStore<User>>,
+}
+
+impl MacawChat {
+ pub fn get(&self) -> ArcStore<Chat> {
+ self.try_get_value().unwrap().get()
+ }
+}
+
+impl Deref for MacawChat {
+ type Target = ArenaItem<StateListener<BareJID, ArcStore<Chat>>>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.chat
+ }
+}
+
+impl DerefMut for MacawChat {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.chat
+ }
+}
+
+impl From<ArcMacawChat> for MacawChat {
+ fn from(value: ArcMacawChat) -> Self {
+ Self {
+ chat: ArenaItem::new_with_storage(value.chat),
+ user: value.user.into(),
+ }
+ }
+}
+
+impl From<MacawChat> for ArcMacawChat {
+ fn from(value: MacawChat) -> Self {
+ Self {
+ chat: value.chat.try_get_value().unwrap(),
+ user: value.user.into(),
+ }
+ }
+}
+
+#[derive(Clone)]
+pub struct ArcMacawChat {
+ pub chat: StateListener<BareJID, ArcStore<Chat>>,
+ pub user: ArcMacawUser,
+}
+
+impl ArcMacawChat {
+ pub async fn got_chat_and_user(chat: Chat, user: User) -> Self {
+ let chat_state_store: StateStore<BareJID, ArcStore<Chat>> =
+ use_context().expect("no chat state store");
+ let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat));
+ let user = ArcMacawUser::got_user(user).await;
+ Self { chat, user }
+ }
+}
+
+impl Deref for ArcMacawChat {
+ type Target = StateListener<BareJID, ArcStore<Chat>>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.chat
+ }
+}
+
+impl DerefMut for ArcMacawChat {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.chat
+ }
+}
diff --git a/src/client.rs b/src/client.rs
new file mode 100644
index 0000000..02ee537
--- /dev/null
+++ b/src/client.rs
@@ -0,0 +1,36 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::{
+ ops::{Deref, DerefMut},
+ sync::Arc,
+};
+
+use jid::BareJID;
+use leptos::prelude::*;
+
+use crate::files::Files;
+
+#[derive(Clone)]
+pub struct Client {
+ // TODO: not pub
+ pub client: filamento::Client<Files>,
+ pub resource: ArcRwSignal<Option<String>>,
+ pub jid: Arc<BareJID>,
+ pub file_store: Files,
+}
+
+impl Deref for Client {
+ type Target = filamento::Client<Files>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.client
+ }
+}
+
+impl DerefMut for Client {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.client
+ }
+}
diff --git a/src/components/avatar.rs b/src/components/avatar.rs
new file mode 100644
index 0000000..11d2097
--- /dev/null
+++ b/src/components/avatar.rs
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use filamento::{presence::PresenceType, user::User};
+use leptos::prelude::*;
+use reactive_stores::Store;
+
+use crate::{
+ components::icon::{IconComponent, show_to_icon},
+ icon::Icon,
+ user::{MacawUser, get_avatar},
+ user_presences::UserPresences,
+};
+
+#[component]
+pub fn AvatarWithPresence(user: MacawUser) -> impl IntoView {
+ let user_presences: Store<UserPresences> = use_context().expect("no user presences in context");
+ let presence = move || {
+ user_presences
+ .write()
+ .get_user_presences(&user.get().read().jid)
+ .read()
+ .presence()
+ };
+ let show_icon = move || {
+ presence()
+ .map(|(_, presence)| match presence.presence {
+ PresenceType::Online(online) => {
+ if let Some(show) = online.show {
+ Some(show_to_icon(show))
+ } else {
+ Some(Icon::Available16Color)
+ }
+ }
+ PresenceType::Offline(offline) => None,
+ })
+ .unwrap_or_default()
+ };
+
+ view! {
+ <div class="avatar-with-presence">
+ <img class="avatar" src=move || user.avatar().get() />
+ {move || {
+ if let Some(icon) = show_icon() {
+ view! { <IconComponent icon=icon class:presence-show-icon=true /> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ }
+}
diff --git a/src/components/chat_header.rs b/src/components/chat_header.rs
new file mode 100644
index 0000000..3fb5df8
--- /dev/null
+++ b/src/components/chat_header.rs
@@ -0,0 +1,26 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use filamento::user::UserStoreFields;
+use leptos::prelude::*;
+use reactive_stores::ArcStore;
+
+use crate::{chat::MacawChat, components::avatar::AvatarWithPresence, user::get_name};
+
+#[component]
+pub fn ChatViewHeader(chat: MacawChat) -> impl IntoView {
+ let name = move || get_name(chat.user.get().into(), true);
+ let jid = move || chat.user.get().jid().read().to_string();
+
+ view! {
+ <div class="chat-view-header panel">
+ {move || {
+ view! { <AvatarWithPresence user=chat.user /> }
+ }} <div class="user-info">
+ <h2 class="name">{name}</h2>
+ <h3>{jid}</h3>
+ </div>
+ </div>
+ }
+}
diff --git a/src/components/chats_list.rs b/src/components/chats_list.rs
new file mode 100644
index 0000000..73ffdff
--- /dev/null
+++ b/src/components/chats_list.rs
@@ -0,0 +1,156 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use chats_list_item::ChatsListItem;
+use indexmap::IndexMap;
+use jid::BareJID;
+use js_sys::{wasm_bindgen::UnwrapThrowExt, Object, Reflect, JSON};
+use leptos::{html::Div, prelude::*};
+use overlay_scrollbars::OverlayScrollbars;
+use tracing::debug;
+
+use crate::{
+ chat::{ArcMacawChat, MacawChat},
+ client::Client,
+ components::{icon::IconComponent, new_chat::NewChatWidget, overlay::Overlay},
+ icon::Icon,
+ message::{ArcMacawMessage, MacawMessage},
+ message_subscriptions::MessageSubscriptions,
+};
+
+mod chats_list_item;
+
+#[component]
+pub fn ChatsList() -> impl IntoView {
+ let (chats, set_chats) = signal(IndexMap::new());
+
+ let load_chats = LocalResource::new(move || async move {
+ let client = use_context::<Client>().expect("client not in context");
+ let chats = client
+ .get_chats_ordered_with_latest_messages_and_users()
+ .await
+ .map_err(|e| e.to_string());
+ match chats {
+ Ok(c) => {
+ let mut chats = IndexMap::new();
+ for ((chat, chat_user), (message, message_user)) in c {
+ chats.insert(
+ chat.correspondent.clone(),
+ (
+ ArcMacawChat::got_chat_and_user(chat, chat_user).await,
+ ArcMacawMessage::got_message_and_user(message, message_user).await,
+ ),
+ );
+ }
+ set_chats.set(chats);
+ }
+ Err(_) => {
+ // TODO: show error message at top of chats list
+ }
+ }
+ });
+
+ let (open_new_chat, set_open_new_chat) = signal(false);
+
+ // TODO: filter new messages signal
+ let new_messages_signal: RwSignal<MessageSubscriptions> = use_context().unwrap();
+ let (sub_id, set_sub_id) = signal(None);
+ let _load_new_messages = LocalResource::new(move || async move {
+ load_chats.await;
+ let (sub_id, mut new_messages) = new_messages_signal.write().subscribe_all();
+ set_sub_id.set(Some(sub_id));
+ while let Some((to, new_message)) = new_messages.recv().await {
+ debug!("got new message in let");
+ let mut chats = set_chats.write();
+ if let Some((chat, _latest_message)) = chats.shift_remove(&to) {
+ // TODO: check if new message is actually latest message
+ debug!("chat existed");
+ debug!(
+ "new message: {}",
+ new_message.message.get().read().body.body
+ );
+ chats.insert_before(0, to, (chat.clone(), new_message));
+ debug!("done setting");
+ } else {
+ debug!("the chat didn't exist");
+ let client = use_context::<Client>().expect("client not in context");
+ let chat = client.get_chat(to.clone()).await.unwrap();
+ let user = client.get_user(to.clone()).await.unwrap();
+ debug!("before got chat");
+ let chat = ArcMacawChat::got_chat_and_user(chat, user).await;
+ debug!("after got chat");
+ chats.insert_before(0, to, (chat, new_message));
+ debug!("done setting");
+ }
+ }
+ debug!("set the new message");
+ });
+ on_cleanup(move || {
+ if let Some(sub_id) = sub_id.get_untracked() {
+ new_messages_signal.write().unsubscribe_all(sub_id);
+ }
+ });
+
+ let chats_list: NodeRef<Div> = NodeRef::new();
+ let chats_list_viewport: NodeRef<Div> = NodeRef::new();
+
+ let _scrollbars = Effect::new(move |_| {
+ if let Some(buffer) = chats_list.get() {
+ if let Some(viewport) = chats_list_viewport.get() {
+ let scrollbars_obj = Object::new();
+ // Reflect::set(&scrollbars_obj, &"theme".into(), &"os-macaw".into()).unwrap_throw();
+ // Reflect::set(&scrollbars_obj, &"autoHide".into(), &"leave".into()).unwrap_throw();
+ let options_obj = Object::new();
+ // Reflect::set(&options_obj, &"scrollbars".into(), &scrollbars_obj).unwrap_throw();
+
+ let elements_obj = Object::new();
+ Reflect::set(&elements_obj, &"viewport".into(), &viewport.into()).unwrap_throw();
+ let element_obj = Object::new();
+ Reflect::set(&elements_obj, &"elements".into(), &elements_obj).unwrap_throw();
+ Reflect::set(&element_obj, &"target".into(), &buffer.into()).unwrap_throw();
+ // let element = Object::define_property(&Object::define_property(&Object::new(), &"target".into(), &buffer.into()), &"elements".into(), &Object::define_property(&Object::new(), &"viewport".into(), &viewport.into()));
+ debug!("scrollable element: {}", JSON::stringify(&element_obj.clone().into()).unwrap_throw());
+ OverlayScrollbars(element_obj, options_obj);
+ }
+ }
+ });
+
+ view! {
+ <div class="chats-list panel">
+ // TODO: update icon, tooltip on hover.
+ <div class="header">
+ <h2>Chats</h2>
+ <div class="new-chat header-icon" class:open=open_new_chat>
+ <IconComponent
+ icon=Icon::NewBubble24
+ on:click=move |_| set_open_new_chat.update(|state| *state = !*state)
+ />
+ {move || {
+ if *open_new_chat.read() {
+ view! {
+ <Overlay set_open=set_open_new_chat>
+ <NewChatWidget set_open_new_chat />
+ </Overlay>
+ }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ </div>
+ <div class="overlay-scroll" node_ref=chats_list>
+ <div class="chats-list-chats" node_ref=chats_list_viewport>
+ <For
+ each=move || chats.get()
+ key=|chat| chat.1.1.message.get().read().id
+ let(chat)
+ >
+ <ChatsListItem chat=chat.1.0.into() message=chat.1.1.into() />
+ </For>
+ </div>
+ </div>
+ </div>
+ }
+}
diff --git a/src/components/chats_list/chats_list_item.rs b/src/components/chats_list/chats_list_item.rs
new file mode 100644
index 0000000..3e18dbe
--- /dev/null
+++ b/src/components/chats_list/chats_list_item.rs
@@ -0,0 +1,93 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::ops::Deref;
+
+use chrono::Local;
+use filamento::{
+ chat::{Chat, ChatStoreFields, Message, MessageStoreFields},
+ user::User,
+};
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+use tracing::debug;
+
+use crate::{
+ chat::MacawChat,
+ components::{avatar::AvatarWithPresence, sidebar::Open},
+ message::MacawMessage,
+ open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields},
+ user::get_name,
+};
+
+#[component]
+pub fn ChatsListItem(chat: MacawChat, message: MacawMessage) -> impl IntoView {
+ let name = move || get_name(chat.user.get().into(), true);
+
+ // TODO: store fine-grained reactivity
+ let latest_message_body = move || message.get().body().get().body;
+ let open_chats: Store<OpenChatsPanel> =
+ use_context().expect("no open chats panel store in context");
+
+ let open_chat = move |_| {
+ debug!("opening chat");
+ open_chats.update(|open_chats| open_chats.open(chat.into()));
+ // open_chats.update(|open_chats| open_chats.open(chat.chat.try_get_value().unwrap().get().clone()));
+ };
+
+ let open = move || {
+ if let Some(open_chat) = &*open_chats.chat_view().read() {
+ debug!("got open chat: {:?}", open_chat);
+ if *open_chat == *chat.get().correspondent().read() {
+ return Open::Focused;
+ }
+ }
+ if let Some(_backgrounded_chat) = open_chats
+ .chats()
+ .read()
+ .get(chat.get().correspondent().read().deref())
+ {
+ return Open::Open;
+ }
+ Open::Closed
+ };
+ let focused = move || open().is_focused();
+ let open = move || open().is_open();
+
+ let date = move || message.get().timestamp().read().naive_local();
+ let now = move || Local::now().naive_local();
+ let timeinfo = move || {
+ if date().date() == now().date() {
+ // TODO: localisation/config
+ date().time().format("%H:%M").to_string()
+ } else {
+ date().date().format("%d/%m").to_string()
+ }
+ };
+
+ view! {
+ <div
+ class="chats-list-item"
+ class:open=move || open()
+ class:focused=move || focused()
+ on:click=open_chat
+ >
+ {move || {
+ view! { <AvatarWithPresence user=chat.user /> }
+ }}
+ <div class="item-info">
+ <div class="main-info">
+ <p class="name">{name}</p>
+ <p class="timestamp">{timeinfo}</p>
+ </div>
+ <div class="sub-info">
+ <p class="message-preview">{latest_message_body}</p>
+ <p>
+ <!-- "TODO: delivery or unread state" -->
+ </p>
+ </div>
+ </div>
+ </div>
+ }
+}
diff --git a/src/components/icon.rs b/src/components/icon.rs
new file mode 100644
index 0000000..73b0f5d
--- /dev/null
+++ b/src/components/icon.rs
@@ -0,0 +1,64 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use filamento::{chat::Delivery, presence::Show};
+use leptos::prelude::*;
+
+use crate::icon::Icon;
+
+// TODO: rename
+#[component]
+pub fn IconComponent(icon: Icon) -> impl IntoView {
+ view! {
+ <img
+ class:light=icon.light()
+ class:icon=true
+ style=move || format!("height: {}px; width: {}px", icon.size(), icon.size())
+ src=move || icon.src()
+ />
+ }
+}
+
+pub fn show_to_icon(show: Show) -> Icon {
+ match show {
+ Show::Away => Icon::Away16Color,
+ Show::Chat => Icon::Chat16Color,
+ Show::DoNotDisturb => Icon::Dnd16Color,
+ Show::ExtendedAway => Icon::Xa16Color,
+ }
+}
+
+#[component]
+pub fn Delivery(delivery: Delivery) -> impl IntoView {
+ match delivery {
+ // TODO: proper icon coloring/theming
+ Delivery::Sending => {
+ view! { <IconComponent class:visible=true class:light=true icon=Icon::Sending16 /> }
+ .into_any()
+ }
+ Delivery::Written => {
+ view! { <IconComponent class:light=true icon=Icon::Sent16 /> }.into_any()
+ }
+ // TODO: message receipts
+ // Delivery::Written => view! {}.into_any(),
+ Delivery::Sent => view! { <IconComponent class:light=true icon=Icon::Sent16 /> }.into_any(),
+ Delivery::Delivered => {
+ view! { <IconComponent class:light=true icon=Icon::Delivered16 /> }.into_any()
+ }
+ // TODO: check if there is also the icon class
+ Delivery::Read => {
+ view! { <IconComponent class:light=true class:read=true icon=Icon::Delivered16 /> }
+ .into_any()
+ }
+ Delivery::Failed => {
+ view! { <IconComponent class:visible=true class:light=true icon=Icon::Error16Color /> }
+ .into_any()
+ }
+ // TODO: queued icon
+ Delivery::Queued => {
+ view! { <IconComponent class:visible=true class:light=true icon=Icon::Sending16 /> }
+ .into_any()
+ }
+ }
+}
diff --git a/src/components/message.rs b/src/components/message.rs
new file mode 100644
index 0000000..83a4bad
--- /dev/null
+++ b/src/components/message.rs
@@ -0,0 +1,98 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use filamento::chat::MessageStoreFields;
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+
+use crate::{
+ message::MacawMessage,
+ user::{get_avatar, get_name, NO_AVATAR},
+};
+
+use super::icon::Delivery;
+
+#[component]
+pub fn Message(message: MacawMessage, major: bool, r#final: bool, new_day: bool) -> impl IntoView {
+ let name = move || get_name(message.user.get().into(), false);
+
+ // TODO: chrono-humanize?
+ // TODO: if final, show delivery not only on hover.
+ // {move || message_message.delivery().read().map(|delivery| delivery.to_string()).unwrap_or_default()}
+ view! {
+ {move || {
+ if major {
+ view! {
+ <div class:final=r#final class="chat-message major">
+ <div class="left">
+ <Transition fallback=|| view! { <img class="avatar" src=NO_AVATAR /> }>
+ <img class="avatar" src=move || message.user.avatar().get() />
+ </Transition>
+ </div>
+ <div class="middle">
+ <div class="message-info">
+ <div class="message-user-name">{name}</div>
+ <div class="message-timestamp">
+ {move || {
+ message.get().timestamp().read().format("%H:%M").to_string()
+ }}
+ </div>
+ </div>
+ <div class="message-text">
+ {move || message.get().body().read().body.clone()}
+ </div>
+ </div>
+ <div class="right message-delivery">
+ {move || {
+ message
+ .get()
+ .delivery()
+ .get()
+ .map(|delivery| {
+ view! { <Delivery class:light=true delivery /> }
+ })
+ }}
+ </div>
+ </div>
+ }
+ .into_any()
+ } else {
+ view! {
+ <div class:final=r#final class="chat-message minor">
+ <div class="left message-timestamp">
+ {move || message.get().timestamp().read().format("%H:%M").to_string()}
+ </div>
+ <div class="middle message-text">
+ {move || message.get().body().read().body.clone()}
+ </div>
+ <div class="right message-delivery">
+ {move || {
+ message
+ .get()
+ .delivery()
+ .get()
+ .map(|delivery| view! { <Delivery delivery /> })
+ }}
+ </div>
+ </div>
+ }
+ .into_any()
+ }
+ }}
+ {move || {
+ if new_day {
+ view! {
+ <div class="new-day">
+ {move || {
+ message.get().timestamp().read().format("%Y-%m-%d").to_string()
+ }}
+ </div>
+ }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ }
+}
diff --git a/src/components/message_composer.rs b/src/components/message_composer.rs
new file mode 100644
index 0000000..fd4e59b
--- /dev/null
+++ b/src/components/message_composer.rs
@@ -0,0 +1,134 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use filamento::chat::Body;
+use jid::BareJID;
+use js_sys::{wasm_bindgen::UnwrapThrowExt, Object, Reflect, JSON};
+use leptos::{html::Div, prelude::*, task::spawn_local};
+use overlay_scrollbars::OverlayScrollbars;
+use tracing::debug;
+
+use crate::client::Client;
+
+#[component]
+pub fn ChatViewMessageComposer(chat: BareJID) -> impl IntoView {
+ let message_input: NodeRef<Div> = NodeRef::new();
+
+ // TODO: load last message draft
+ let new_message = RwSignal::new("".to_string());
+ let client: Client = use_context().expect("no client in context");
+ let client = RwSignal::new(client);
+ let (shift_pressed, set_shift_pressed) = signal(false);
+
+ let send_message = move || {
+ let value = chat.clone();
+ spawn_local(async move {
+ match client
+ .read()
+ .send_message(
+ value,
+ Body {
+ body: new_message.get(),
+ },
+ )
+ .await
+ {
+ Ok(_) => {
+ new_message.set("".to_string());
+ message_input
+ .write()
+ .as_ref()
+ .expect("message input div not mounted")
+ .set_text_content(Some(""));
+ }
+ Err(e) => tracing::error!("message send error: {}", e),
+ }
+ })
+ };
+
+ let _focus = Effect::new(move |_| {
+ if let Some(input) = message_input.get() {
+ let _ = input.focus();
+ // TODO: set the last draft
+ input.set_text_content(Some(""));
+ // input.style("height: 0");
+ // let height = input.scroll_height();
+ // input.style(format!("height: {}px", height));
+ }
+ });
+
+ // let on_input = move |ev: Event| {
+ // // let keyboard_event: KeyboardEvent = ev.try_into().unwrap();
+ // debug!("got input event");
+ // let key= event_target_value(&ev);
+ // new_message.set(key);
+ // debug!("set new message");
+ // };
+ //
+
+ let composer: NodeRef<Div> = NodeRef::new();
+
+ let _scrollbars = Effect::new(move |_| {
+ if let Some(buffer) = composer.get() {
+ if let Some(viewport) = message_input.get() {
+ let scrollbars_obj = Object::new();
+ Reflect::set(&scrollbars_obj, &"theme".into(), &"os-macaw".into()).unwrap_throw();
+ Reflect::set(&scrollbars_obj, &"autoHide".into(), &"leave".into()).unwrap_throw();
+ let options_obj = Object::new();
+ Reflect::set(&options_obj, &"scrollbars".into(), &scrollbars_obj).unwrap_throw();
+
+ let elements_obj = Object::new();
+ Reflect::set(&elements_obj, &"viewport".into(), &viewport.into()).unwrap_throw();
+ let element_obj = Object::new();
+ Reflect::set(&elements_obj, &"elements".into(), &elements_obj).unwrap_throw();
+ Reflect::set(&element_obj, &"target".into(), &buffer.into()).unwrap_throw();
+ // let element = Object::define_property(&Object::define_property(&Object::new(), &"target".into(), &buffer.into()), &"elements".into(), &Object::define_property(&Object::new(), &"viewport".into(), &viewport.into()));
+ debug!(
+ "scrollable element: {}",
+ JSON::stringify(&element_obj.clone().into()).unwrap_throw()
+ );
+ debug!(
+ "scrollable options: {}",
+ JSON::stringify(&options_obj.clone().into()).unwrap_throw()
+ );
+ OverlayScrollbars(element_obj, options_obj);
+ }
+ }
+ });
+
+ // TODO: placeholder
+ view! {
+ <form class="new-message-composer panel">
+ <div class="overlay-scroll" node_ref=composer>
+ <div
+ class="text-box"
+ on:input:target=move |ev| {
+ new_message.set(ev.target().text_content().unwrap_or_default())
+ }
+ node_ref=message_input
+ contenteditable
+ on:keydown=move |ev| {
+ match ev.key_code() {
+ 16 => set_shift_pressed.set(true),
+ 13 => {
+ if !shift_pressed.get() {
+ ev.prevent_default();
+ send_message();
+ }
+ }
+ _ => {}
+ }
+ }
+ on:keyup=move |ev| {
+ match ev.key_code() {
+ 16 => set_shift_pressed.set(false),
+ _ => {}
+ }
+ }
+ ></div>
+ </div>
+ // <input hidden type="submit" />
+ </form>
+ }
+}
diff --git a/src/components/message_history_buffer.rs b/src/components/message_history_buffer.rs
new file mode 100644
index 0000000..c733700
--- /dev/null
+++ b/src/components/message_history_buffer.rs
@@ -0,0 +1,158 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use chrono::{NaiveDateTime, TimeDelta};
+use filamento::{
+ chat::{Chat, ChatStoreFields, MessageStoreFields},
+ user::User,
+};
+use indexmap::IndexMap;
+use jid::BareJID;
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+use tracing::{debug, error};
+use uuid::Uuid;
+
+use crate::{
+ chat::MacawChat,
+ client::Client,
+ components::message::Message,
+ message::{ArcMacawMessage, MacawMessage},
+ message_subscriptions::MessageSubscriptions,
+};
+
+#[component]
+pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView {
+ let (messages, set_messages) = arc_signal(IndexMap::new());
+
+ let load_set_messages = set_messages.clone();
+ let load_messages = LocalResource::new(move || {
+ let load_set_messages = load_set_messages.clone();
+ async move {
+ let client = use_context::<Client>().expect("client not in context");
+ let messages = client
+ .get_messages_with_users(chat.get().correspondent().get())
+ .await
+ .map_err(|e| e.to_string());
+ match messages {
+ Ok(m) => {
+ let mut messages = IndexMap::new();
+ for (message, message_user) in m {
+ messages.insert(
+ message.id,
+ ArcMacawMessage::got_message_and_user(message, message_user).await,
+ );
+ }
+ load_set_messages.set(messages);
+ }
+ Err(err) => {
+ error!("{err}")
+ // TODO: show error message at top of chats list
+ }
+ }
+ }
+ });
+
+ // TODO: filter new messages signal
+ let new_messages_signal: RwSignal<MessageSubscriptions> = use_context().unwrap();
+ let (sub_id, set_sub_id) = signal(None);
+ let load_new_messages_set = set_messages.clone();
+ let _load_new_messages = LocalResource::new(move || {
+ let load_new_messages_set = load_new_messages_set.clone();
+ async move {
+ load_messages.await;
+ let (sub_id, mut new_messages) = new_messages_signal
+ .write()
+ .subscribe_chat(chat.get().correspondent().get());
+ set_sub_id.set(Some(sub_id));
+ while let Some(new_message) = new_messages.recv().await {
+ debug!("got new message in let message buffer");
+ let mut messages = load_new_messages_set.write();
+ if let Some((_, last)) = messages.last() {
+ if *last.get().timestamp().read() < *new_message.get().timestamp().read() {
+ messages.insert(new_message.get().id().get(), new_message);
+ debug!("set the new message in message buffer");
+ } else {
+ let index = match messages.binary_search_by(|_, value| {
+ value
+ .get()
+ .timestamp()
+ .read()
+ .cmp(&new_message.get().timestamp().read())
+ }) {
+ Ok(i) => i,
+ Err(i) => i,
+ };
+ messages.insert_before(
+ // TODO: check if this logic is correct
+ index,
+ new_message.get().id().get(),
+ new_message,
+ );
+ debug!("set the new message in message buffer");
+ }
+ } else {
+ messages.insert(new_message.get().id().get(), new_message);
+ debug!("set the new message in message buffer");
+ }
+ }
+ }
+ });
+ on_cleanup(move || {
+ if let Some(sub_id) = sub_id.get_untracked() {
+ new_messages_signal
+ .write()
+ .unsubscribe_chat(sub_id, chat.get().correspondent().get_untracked());
+ }
+ });
+
+ let each = move || {
+ let mut last_timestamp = NaiveDateTime::MIN;
+ let mut last_user: Option<BareJID> = None;
+ let mut messages = messages
+ .get()
+ .into_iter()
+ .map(|(id, message)| {
+ let message_timestamp = message.message.get().timestamp().read().naive_local();
+ let mut major = if last_user.as_ref() != Some(&message.message.get().read().from)
+ || message_timestamp - last_timestamp > TimeDelta::minutes(3)
+ {
+ true
+ } else {
+ false
+ };
+ let new_day = if message_timestamp.date() > last_timestamp.date() {
+ major = true;
+ true
+ } else {
+ false
+ };
+ last_user = Some(message.get().from().get());
+ last_timestamp = message_timestamp;
+ (id, (message, major, false, new_day))
+ })
+ .collect::<Vec<_>>();
+ if let Some((_id, (_, _, last, _))) = messages.last_mut() {
+ *last = true
+ }
+ messages.into_iter().rev()
+ };
+
+ view! {
+ <div class="messages-buffer">
+ <For
+ each=each
+ key=|message| (message.0, message.1.1, message.1.2, message.1.3)
+ let(message)
+ >
+ <Message
+ message=message.1.0.into()
+ major=message.1.1
+ r#final=message.1.2
+ new_day=message.1.3
+ />
+ </For>
+ </div>
+ }
+}
diff --git a/src/components/mod.rs b/src/components/mod.rs
new file mode 100644
index 0000000..d2fb6b5
--- /dev/null
+++ b/src/components/mod.rs
@@ -0,0 +1,17 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+mod avatar;
+pub mod chat_header;
+mod chats_list;
+pub mod icon;
+mod message;
+pub mod message_composer;
+pub mod message_history_buffer;
+pub mod modal;
+mod new_chat;
+mod overlay;
+mod personal_status;
+mod roster_list;
+pub mod sidebar;
diff --git a/src/components/modal.rs b/src/components/modal.rs
new file mode 100644
index 0000000..e23fa5d
--- /dev/null
+++ b/src/components/modal.rs
@@ -0,0 +1,25 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use leptos::ev::MouseEvent;
+use leptos::prelude::*;
+
+#[component]
+pub fn Modal(
+ on_background_click: impl Fn(MouseEvent) + 'static,
+ children: Children,
+) -> impl IntoView {
+ view! {
+ <div
+ class="modal"
+ on:click=move |e| {
+ if e.current_target() == e.target() {
+ on_background_click(e)
+ }
+ }
+ >
+ {children()}
+ </div>
+ }
+}
diff --git a/src/components/new_chat.rs b/src/components/new_chat.rs
new file mode 100644
index 0000000..925ec57
--- /dev/null
+++ b/src/components/new_chat.rs
@@ -0,0 +1,147 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::str::FromStr;
+
+use filamento::{
+ chat::Chat,
+ error::{CommandError, DatabaseError},
+ user::User,
+};
+use jid::{BareJID, JID};
+use leptos::{html::Input, prelude::*};
+use reactive_stores::{ArcStore, Store};
+use thiserror::Error;
+
+use crate::{
+ chat::{ArcMacawChat, MacawChat},
+ client::Client,
+ open_chats::OpenChatsPanel,
+ state_store::StateStore,
+ user::{ArcMacawUser, MacawUser, fetch_avatar},
+};
+
+#[derive(Clone, Debug, Error)]
+pub enum NewChatError {
+ #[error("Missing JID")]
+ MissingJID,
+ #[error("Invalid JID: {0}")]
+ InvalidJID(#[from] jid::ParseError),
+ #[error("Database: {0}")]
+ Db(#[from] CommandError<DatabaseError>),
+}
+
+#[component]
+pub fn NewChatWidget(set_open_new_chat: WriteSignal<bool>) -> impl IntoView {
+ let jid = RwSignal::new("".to_string());
+
+ // TODO: compartmentalise into error component, form component...
+ let (error, set_error) = signal(None::<NewChatError>);
+ let error_message = move || {
+ error.with(|error| {
+ if let Some(error) = error {
+ view! { <div class="error">{error.to_string()}</div> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ })
+ };
+ let (new_chat_pending, set_new_chat_pending) = signal(false);
+
+ let open_chats: Store<OpenChatsPanel> =
+ use_context().expect("no open chats panel store in context");
+ let client = use_context::<Client>().expect("client not in context");
+
+ let chat_state_store: StateStore<BareJID, ArcStore<Chat>> =
+ use_context().expect("no chat state store");
+ let user_state_store: StateStore<BareJID, (ArcStore<User>, ArcRwSignal<String>)> =
+ use_context().expect("no user state store");
+
+ let open_chat = Action::new_local(move |_| {
+ let client = client.clone();
+ async move {
+ set_new_chat_pending.set(true);
+
+ if jid.read_untracked().is_empty() {
+ set_error.set(Some(NewChatError::MissingJID));
+ set_new_chat_pending.set(false);
+ return;
+ }
+
+ let jid = match JID::from_str(&jid.read_untracked()) {
+ // TODO: ability to direct address a resource?
+ Ok(j) => j.to_bare(),
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_new_chat_pending.set(false);
+ return;
+ }
+ };
+
+ let chat_jid = jid;
+ let (chat, user) = match client.get_chat_and_user(chat_jid).await {
+ Ok(c) => c,
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_new_chat_pending.set(false);
+ return;
+ }
+ };
+
+ let chat = {
+ // let user = MacawUser::got_user(user);
+ // let user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
+ let old_user = user_state_store.get_listener(user.jid.clone());
+ let user = if let Some(old_user) = old_user {
+ old_user.update(|(old_user, _avatar)| {
+ old_user.set(user);
+ });
+ old_user
+ } else {
+ let avatar = fetch_avatar(user.avatar.as_deref()).await;
+ let avatar = ArcRwSignal::new(avatar);
+ user_state_store.store(user.jid.clone(), (ArcStore::new(user), avatar))
+ };
+ let user = ArcMacawUser { user };
+ let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat));
+ ArcMacawChat { chat, user }
+ };
+ open_chats.update(|open_chats| open_chats.open(chat.clone()));
+ set_open_new_chat.set(false);
+ }
+ });
+
+ let jid_input = NodeRef::<Input>::new();
+ let _focus = Effect::new(move |_| {
+ if let Some(input) = jid_input.get() {
+ let _ = input.focus();
+ input.set_text_content(Some(""));
+ // input.style("height: 0");
+ // let height = input.scroll_height();
+ // input.style(format!("height: {}px", height));
+ }
+ });
+
+ view! {
+ <div class="new-chat-widget">
+ <form on:submit=move |ev| {
+ ev.prevent_default();
+ open_chat.dispatch(());
+ }>
+ {error_message}
+ <input
+ disabled=new_chat_pending
+ placeholder="JID"
+ type="text"
+ node_ref=jid_input
+ bind:value=jid
+ name="jid"
+ id="jid"
+ autofocus="true"
+ />
+ <input disabled=new_chat_pending class="button" type="submit" value="Start Chat" />
+ </form>
+ </div>
+ }
+}
diff --git a/src/components/overlay.rs b/src/components/overlay.rs
new file mode 100644
index 0000000..d10f33a
--- /dev/null
+++ b/src/components/overlay.rs
@@ -0,0 +1,22 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use leptos::prelude::*;
+use tracing::debug;
+
+#[component]
+pub fn Overlay(set_open: WriteSignal<bool>, children: Children) -> impl IntoView {
+ view! {
+ <div class="overlay">
+ <div
+ class="overlay-background"
+ on:click=move |_| {
+ debug!("set open to false");
+ set_open.update(|state| *state = false)
+ }
+ ></div>
+ <div class="overlay-content">{children()}</div>
+ </div>
+ }
+}
diff --git a/src/components/personal_status.rs b/src/components/personal_status.rs
new file mode 100644
index 0000000..b74b366
--- /dev/null
+++ b/src/components/personal_status.rs
@@ -0,0 +1,249 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use filamento::{
+ presence::{Offline, Online, PresenceType, Show},
+ user::{User, UserStoreFields},
+};
+use leptos::{html, prelude::*};
+use reactive_stores::{ArcStore, Store};
+use tracing::{debug, error};
+
+use crate::{
+ client::Client,
+ components::{avatar::AvatarWithPresence, overlay::Overlay},
+ user::{MacawUser, get_name},
+ user_presences::UserPresences,
+ views::{AppState, macaw::settings::SettingsPage},
+};
+
+#[component]
+pub fn PersonalStatus() -> impl IntoView {
+ let user: LocalResource<MacawUser> = use_context().expect("no local user in context");
+
+ let (open, set_open) = signal(false);
+ move || {
+ if let Some(user) = user.get() {
+ view! {
+ <div
+ class="dock-item"
+ class:focused=move || *open.read()
+ on:click=move |_| {
+ debug!("set open to true");
+ set_open.update(|state| *state = !*state)
+ }
+ >
+ <AvatarWithPresence user />
+ <div class="dock-pill"></div>
+ </div>
+ {move || {
+ let open = open.get();
+ debug!("open = {:?}", open);
+ if open {
+ view! {
+ <Overlay set_open>
+ <PersonalStatusMenu user set_open />
+ </Overlay>
+ }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }
+}
+
+#[component]
+pub fn PersonalStatusMenu(user: MacawUser, set_open: WriteSignal<bool>) -> impl IntoView {
+ let set_app: WriteSignal<AppState> = use_context().unwrap();
+ let show_settings: RwSignal<Option<SettingsPage>> = use_context().unwrap();
+ let user_presences: Store<UserPresences> = use_context().expect("no user presence store");
+
+ let client = use_context::<Client>().expect("client not in context");
+ let client1 = client.clone();
+ let (show_value, set_show_value) = signal({
+ let show = match user_presences
+ .write()
+ .get_user_presences(&user.get().jid().read())
+ .write()
+ .resource_presence(client.resource.read().clone().unwrap_or_default())
+ .presence
+ {
+ PresenceType::Online(online) => match online.show {
+ Some(s) => match s {
+ Show::Away => 3,
+ Show::Chat => 0,
+ Show::DoNotDisturb => 2,
+ Show::ExtendedAway => 4,
+ },
+ None => 1,
+ },
+ PresenceType::Offline(_offline) => 5,
+ };
+ debug!("initial show = {show}");
+ show
+ });
+
+ let show_select: NodeRef<html::Select> = NodeRef::new();
+
+ let disconnect = Action::new_local(move |()| {
+ let client = client.clone();
+ async move {
+ client.disconnect(Offline::default()).await;
+ }
+ });
+ let set_status = Action::new_local(move |show_value: &i32| {
+ let show_value = show_value.to_owned();
+ let client = client1.clone();
+ async move {
+ if let Err(e) = match show_value {
+ 0 => {
+ if let Ok(r) = client.connect().await {
+ client.resource.set(Some(r))
+ };
+ client
+ .set_status(Online {
+ show: Some(Show::Chat),
+ ..Default::default()
+ })
+ .await
+ }
+ 1 => {
+ if let Ok(r) = client.connect().await {
+ client.resource.set(Some(r))
+ };
+ client
+ .set_status(Online {
+ show: None,
+ ..Default::default()
+ })
+ .await
+ }
+ 2 => {
+ if let Ok(r) = client.connect().await {
+ client.resource.set(Some(r))
+ };
+ client
+ .set_status(Online {
+ show: Some(Show::DoNotDisturb),
+ ..Default::default()
+ })
+ .await
+ }
+ 3 => {
+ if let Ok(r) = client.connect().await {
+ client.resource.set(Some(r))
+ };
+ client
+ .set_status(Online {
+ show: Some(Show::Away),
+ ..Default::default()
+ })
+ .await
+ }
+ 4 => {
+ if let Ok(r) = client.connect().await {
+ client.resource.set(Some(r))
+ };
+ client
+ .set_status(Online {
+ show: Some(Show::ExtendedAway),
+ ..Default::default()
+ })
+ .await
+ }
+ 5 => {
+ if let Ok(_) = client.disconnect(Offline::default()).await {
+ client.resource.set(None)
+ }
+ set_show_value.set(5);
+ return;
+ }
+ _ => {
+ error!("invalid availability select");
+ return;
+ }
+ } {
+ error!("show set error: {e}");
+ return;
+ }
+ set_show_value.set(show_value);
+ }
+ });
+
+ view! {
+ <div class="personal-status-menu menu">
+ <div class="user">
+ <AvatarWithPresence user=user />
+ <div class="user-info">
+ <div class="nick">{move || get_name(user.get().into(), false)}</div>
+ <div class="jid">{move || user.get().jid().with(|jid| jid.to_string())}</div>
+ </div>
+ </div>
+ <div class="status-edit">
+ <select
+ node_ref=show_select
+ on:change:target=move |ev| {
+ let show_value = ev.target().value().parse().unwrap();
+ set_status.dispatch(show_value);
+ }
+ prop:show_value=move || show_value.get().to_string()
+ >
+ <option value="0" selected=move || show_value.get_untracked() == 0>
+ Available to Chat
+ </option>
+ <option value="1" selected=move || show_value.get_untracked() == 1>
+ Online
+ </option>
+ <option value="2" selected=move || show_value.get_untracked() == 2>
+ Do not disturb
+ </option>
+ <option value="3" selected=move || show_value.get_untracked() == 3>
+ Away
+ </option>
+ <option value="4" selected=move || show_value.get_untracked() == 4>
+ Extended Away
+ </option>
+ <option value="5" selected=move || show_value.get_untracked() == 5>
+ Offline
+ </option>
+ </select>
+ </div>
+ <hr />
+ <div
+ class="menu-item"
+ on:click=move |_| {
+ show_settings.set(Some(SettingsPage::Profile));
+ set_open.set(false);
+ }
+ >
+ Profile
+ </div>
+ <div
+ class="menu-item"
+ on:click=move |_| {
+ show_settings.set(Some(SettingsPage::Account));
+ set_open.set(false);
+ }
+ >
+ Settings
+ </div>
+ <hr />
+ <div
+ class="menu-item"
+ on:click=move |_| {
+ disconnect.dispatch(());
+ set_app.set(AppState::LoggedOut)
+ }
+ >
+ Log out
+ </div>
+ </div>
+ }
+}
diff --git a/src/components/roster_list.rs b/src/components/roster_list.rs
new file mode 100644
index 0000000..b018d45
--- /dev/null
+++ b/src/components/roster_list.rs
@@ -0,0 +1,133 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::collections::HashSet;
+
+use contact_request_manager::AddContact;
+use jid::BareJID;
+use js_sys::{wasm_bindgen::UnwrapThrowExt, Object, Reflect, JSON};
+use leptos::{html::Div, prelude::*};
+use overlay_scrollbars::OverlayScrollbars;
+use reactive_stores::Store;
+use roster_list_item::RosterListItem;
+use tracing::debug;
+
+use crate::{
+ components::icon::IconComponent,
+ icon::Icon,
+ open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields},
+ roster::{Roster, RosterStoreFields},
+};
+
+mod contact_request_manager;
+mod roster_list_item;
+
+#[component]
+pub fn RosterList() -> impl IntoView {
+ let requests: ReadSignal<HashSet<BareJID>> =
+ use_context().expect("no pending subscriptions in context");
+
+ let open_chats: Store<OpenChatsPanel> =
+ use_context().expect("no open chats panel store in context");
+
+ let roster: Store<Roster> = use_context().expect("no roster in context");
+ let (open_add_contact, set_open_add_contact) = signal(false);
+ let open_chat = Memo::new(move |_| open_chats.chat_view().get());
+ provide_context(open_chat);
+
+ let roster_list: NodeRef<Div> = NodeRef::new();
+ let roster_list_viewport: NodeRef<Div> = NodeRef::new();
+
+ let _scrollbars = Effect::new(move |_| {
+ if let Some(buffer) = roster_list.get() {
+ if let Some(viewport) = roster_list_viewport.get() {
+ let elements_obj = Object::new();
+ Reflect::set(&elements_obj, &"viewport".into(), &viewport.into()).unwrap_throw();
+ let element_obj = Object::new();
+ Reflect::set(&elements_obj, &"elements".into(), &elements_obj).unwrap_throw();
+ Reflect::set(&element_obj, &"target".into(), &buffer.into()).unwrap_throw();
+ // let element = Object::define_property(&Object::define_property(&Object::new(), &"target".into(), &buffer.into()), &"elements".into(), &Object::define_property(&Object::new(), &"viewport".into(), &viewport.into()));
+ debug!(
+ "scrollable element: {}",
+ JSON::stringify(&element_obj.clone().into()).unwrap_throw()
+ );
+ OverlayScrollbars(element_obj, Object::new());
+ }
+ }
+ });
+
+ let add_contact: NodeRef<Div> = NodeRef::new();
+ let add_contact_viewport: NodeRef<Div> = NodeRef::new();
+
+ let _scrollbars = Effect::new(move |_| {
+ if let Some(buffer) = add_contact.get() {
+ if let Some(viewport) = add_contact_viewport.get() {
+ let scrollbars_obj = Object::new();
+ Reflect::set(&scrollbars_obj, &"theme".into(), &"os-macaw".into()).unwrap_throw();
+ Reflect::set(&scrollbars_obj, &"move".into(), &"leave".into()).unwrap_throw();
+ let options_obj = Object::new();
+ Reflect::set(&options_obj, &"scrollbars".into(), &scrollbars_obj).unwrap_throw();
+
+ let elements_obj = Object::new();
+ Reflect::set(&elements_obj, &"viewport".into(), &viewport.into()).unwrap_throw();
+ let element_obj = Object::new();
+ Reflect::set(&elements_obj, &"elements".into(), &elements_obj).unwrap_throw();
+ Reflect::set(&element_obj, &"target".into(), &buffer.into()).unwrap_throw();
+ // let element = Object::define_property(&Object::define_property(&Object::new(), &"target".into(), &buffer.into()), &"elements".into(), &Object::define_property(&Object::new(), &"viewport".into(), &viewport.into()));
+ debug!(
+ "scrollable element: {}",
+ JSON::stringify(&element_obj.clone().into()).unwrap_throw()
+ );
+ OverlayScrollbars(element_obj, options_obj);
+ }
+ }
+ });
+
+ // TODO: filter new messages signal
+ view! {
+ <div class="roster-list panel">
+ <div class="header">
+ <h2>Roster</h2>
+ <div class="add-contact header-icon" class:open=open_add_contact>
+ <IconComponent
+ icon=Icon::AddContact24
+ on:click=move |_| set_open_add_contact.update(|state| *state = !*state)
+ />
+ {move || {
+ if !requests.read().is_empty() {
+ view! { <div class="badge"></div> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ </div>
+ {move || {
+ if *open_add_contact.read() {
+ view! {
+ <div class="overlay-scroll add-contact-panel" node_ref=add_contact>
+ <div class="roster-add-contact" node_ref=add_contact_viewport>
+ <AddContact />
+ </div>
+ </div>
+ }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ <div class="overlay-scroll" node_ref=roster_list>
+ <div class="roster-list-roster" node_ref=roster_list_viewport>
+ <For
+ each=move || roster.contacts().get()
+ key=|contact| contact.0.clone()
+ let(contact)
+ >
+ <RosterListItem contact=contact.1 />
+ </For>
+ </div>
+ </div>
+ </div>
+ }
+}
diff --git a/src/components/roster_list/contact_request_manager.rs b/src/components/roster_list/contact_request_manager.rs
new file mode 100644
index 0000000..4c28142
--- /dev/null
+++ b/src/components/roster_list/contact_request_manager.rs
@@ -0,0 +1,284 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::{collections::HashSet, str::FromStr};
+
+use filamento::{
+ error::{CommandError, SubscribeError},
+ roster::ContactStoreFields,
+};
+use jid::{BareJID, JID};
+use leptos::{html::Input, prelude::*};
+use reactive_stores::Store;
+use thiserror::Error;
+
+use crate::{
+ client::Client,
+ roster::{Roster, RosterStoreFields},
+};
+
+#[derive(Clone, Debug, Error)]
+pub enum AddContactError {
+ #[error("Missing JID")]
+ MissingJID,
+ #[error("Invalid JID: {0}")]
+ InvalidJID(#[from] jid::ParseError),
+ #[error("Subscription: {0}")]
+ Db(#[from] CommandError<SubscribeError>),
+}
+
+#[component]
+// TODO: rename
+pub fn AddContact() -> impl IntoView {
+ let requests: ReadSignal<HashSet<BareJID>> =
+ use_context().expect("no pending subscriptions in context");
+ let set_requests: WriteSignal<HashSet<BareJID>> =
+ use_context().expect("no pending subscriptions write signal in context");
+ let roster: Store<Roster> = use_context().expect("no roster in context");
+
+ let jid = RwSignal::new("".to_string());
+ // TODO: compartmentalise into error component, form component...
+ let (error, set_error) = signal(None::<AddContactError>);
+ let error_message = move || {
+ error.with(|error| {
+ if let Some(error) = error {
+ view! { <div class="error">{error.to_string()}</div> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ })
+ };
+ let (add_contact_pending, set_add_contact_pending) = signal(false);
+
+ let client = use_context::<Client>().expect("client not in context");
+ let client2 = client.clone();
+ let client3 = client.clone();
+ let client4 = client.clone();
+ let client5 = client.clone();
+
+ let add_contact = Action::new_local(move |_| {
+ let client = client.clone();
+ async move {
+ set_add_contact_pending.set(true);
+
+ if jid.read_untracked().is_empty() {
+ set_error.set(Some(AddContactError::MissingJID));
+ set_add_contact_pending.set(false);
+ return;
+ }
+
+ let jid = match JID::from_str(&jid.read_untracked()) {
+ Ok(j) => j.to_bare(),
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_add_contact_pending.set(false);
+ return;
+ }
+ };
+
+ let chat_jid = jid;
+ // TODO: more options?
+ match client.buddy_request(chat_jid).await {
+ Ok(c) => c,
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_add_contact_pending.set(false);
+ return;
+ }
+ };
+
+ set_add_contact_pending.set(false);
+ }
+ });
+
+ let jid_input = NodeRef::<Input>::new();
+ let _focus = Effect::new(move |_| {
+ if let Some(input) = jid_input.get() {
+ let _ = input.focus();
+ input.set_text_content(Some(""));
+ // input.style("height: 0");
+ // let height = input.scroll_height();
+ // input.style(format!("height: {}px", height));
+ }
+ });
+
+ let outgoing = move || {
+ roster
+ .contacts()
+ .get()
+ .into_iter()
+ .filter(
+ |(jid, contact)| match *contact.contact.subscription().read() {
+ filamento::roster::Subscription::None => false,
+ filamento::roster::Subscription::PendingOut => true,
+ filamento::roster::Subscription::PendingIn => false,
+ filamento::roster::Subscription::PendingInPendingOut => true,
+ filamento::roster::Subscription::OnlyOut => false,
+ filamento::roster::Subscription::OnlyIn => false,
+ filamento::roster::Subscription::OutPendingIn => false,
+ filamento::roster::Subscription::InPendingOut => true,
+ filamento::roster::Subscription::Buddy => false,
+ },
+ )
+ .collect::<Vec<_>>()
+ };
+
+ let accept_friend_request = Action::new_local(move |jid: &BareJID| {
+ let client = client2.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.accept_buddy_request(jid.clone()).await;
+ set_requests.write().remove(&jid);
+ }
+ });
+
+ let accept_subscription_request = Action::new_local(move |jid: &BareJID| {
+ let client = client5.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.accept_subscription_request(jid.clone()).await;
+ set_requests.write().remove(&jid);
+ }
+ });
+
+ let reject_friend_request = Action::new_local(move |jid: &BareJID| {
+ let client = client3.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.unsubscribe_contact(jid.clone()).await;
+ set_requests.write().remove(&jid);
+ }
+ });
+
+ let cancel_subscription_request = Action::new_local(move |jid: &BareJID| {
+ let client = client4.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.unsubscribe_from_contact(jid).await;
+ }
+ });
+
+ view! {
+ <div class="add-contact-menu">
+ <div>
+ {error_message}
+ <form on:submit=move |ev| {
+ ev.prevent_default();
+ add_contact.dispatch(());
+ }>
+ <input
+ disabled=add_contact_pending
+ placeholder="JID"
+ type="text"
+ node_ref=jid_input
+ bind:value=jid
+ name="jid"
+ id="jid"
+ autofocus="true"
+ />
+ <input
+ disabled=add_contact_pending
+ class="button"
+ type="submit"
+ value="Send Friend Request"
+ />
+ </form>
+ </div>
+ {move || {
+ if !requests.read().is_empty() {
+ view! {
+ <div>
+ <h3>Incoming Subscription Requests</h3>
+ <For
+ each=move || requests.get()
+ key=|request| request.clone()
+ let(request)
+ >
+ {
+ let request2 = request.clone();
+ let request3 = request.clone();
+ let request4 = request.clone();
+ let jid_string = move || request.to_string();
+ view! {
+ <div class="jid-with-buttons">
+ <div class="jid">{jid_string}</div>
+ <div class="buttons">
+ <div
+ class="button"
+ on:click=move |_| {
+ reject_friend_request.dispatch(request3.clone());
+ }
+ >
+ Reject
+ </div>
+ <div
+ class="button"
+ on:click=move |_| {
+ accept_subscription_request.dispatch(request4.clone());
+ }
+ >
+ Sub-only
+ </div>
+ <div
+ class="button"
+ on:click=move |_| {
+ accept_friend_request.dispatch(request2.clone());
+ }
+ >
+ Accept Buddy
+ </div>
+ </div>
+ </div>
+ }
+ }
+ </For>
+ </div>
+ }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ {move || {
+ if !outgoing().is_empty() {
+ view! {
+ <div>
+ <h3>Pending Outgoing Subscription Requests</h3>
+ <For
+ each=move || outgoing()
+ key=|(jid, _contact)| jid.clone()
+ let((jid, contact))
+ >
+ {
+ let jid2 = jid.clone();
+ let jid_string = move || jid.to_string();
+ view! {
+ <div class="jid-with-button">
+ <div class="jid">{jid_string}</div>
+ <div
+ class="button"
+ on:click=move |_| {
+ cancel_subscription_request.dispatch(jid2.clone());
+ }
+ >
+ Cancel
+ </div>
+ </div>
+ }
+ }
+ </For>
+ </div>
+ }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ }
+}
diff --git a/src/components/roster_list/roster_list_item.rs b/src/components/roster_list/roster_list_item.rs
new file mode 100644
index 0000000..a6fd714
--- /dev/null
+++ b/src/components/roster_list/roster_list_item.rs
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::ops::Deref;
+
+use filamento::{
+ chat::Chat,
+ roster::{Contact, ContactStoreFields},
+ user::{User, UserStoreFields},
+};
+use jid::BareJID;
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+use tracing::debug;
+
+use crate::{
+ chat::{ArcMacawChat, MacawChat},
+ client::Client,
+ components::{avatar::AvatarWithPresence, sidebar::Open},
+ contact::MacawContact,
+ open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields},
+ state_store::StateStore,
+ user::{ArcMacawUser, fetch_avatar, get_name},
+};
+
+#[component]
+pub fn RosterListItem(contact: MacawContact) -> impl IntoView {
+ let name = move || get_name(contact.user.get().into(), false);
+
+ let open_chats: Store<OpenChatsPanel> =
+ use_context().expect("no open chats panel store in context");
+
+ let client = use_context::<Client>().expect("client not in context");
+
+ let chat_state_store: StateStore<BareJID, ArcStore<Chat>> =
+ use_context().expect("no chat state store");
+ let user_state_store: StateStore<BareJID, (ArcStore<User>, ArcRwSignal<String>)> =
+ use_context().expect("no user state store");
+
+ let open_chat = Action::new_local(move |_| {
+ let client = client.clone();
+ async move {
+ let to = contact.user.get().jid().get();
+ let (chat, user) = match client.get_chat_and_user(to).await {
+ Ok(c) => c,
+ Err(e) => {
+ // TODO: error
+ // set_error.set(Some(e.into()));
+ // set_new_chat_pending.set(false);
+ return;
+ }
+ };
+
+ let chat = {
+ // let user = MacawUser::got_user(user);
+ // let user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
+ let old_user = user_state_store.get_listener(user.jid.clone());
+ let user = if let Some(old_user) = old_user {
+ old_user.update(|(old_user, _avatar)| {
+ old_user.set(user);
+ });
+ old_user
+ } else {
+ let avatar = fetch_avatar(user.avatar.as_deref()).await;
+ let avatar = ArcRwSignal::new(avatar);
+ user_state_store.store(user.jid.clone(), (ArcStore::new(user), avatar))
+ };
+ let user = ArcMacawUser { user };
+ let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat));
+ ArcMacawChat { chat, user }
+ };
+ open_chats.update(|open_chats| open_chats.open(chat.clone()));
+ }
+ });
+
+ let current_open_chat: Memo<Option<BareJID>> =
+ use_context().expect("no open chat memo in context");
+
+ let open = move || {
+ if let Some(open_chat) = &*current_open_chat.read() {
+ debug!("got open chat: {:?}", open_chat);
+ if *open_chat == *contact.user.get().jid().read() {
+ return Open::Focused;
+ }
+ }
+ if let Some(_backgrounded_chat) = open_chats
+ .chats()
+ .read()
+ .get(contact.user.get().jid().read().deref())
+ {
+ return Open::Open;
+ }
+ Open::Closed
+ };
+ let focused = move || open().is_focused();
+ let open = move || open().is_open();
+
+ view! {
+ <div
+ class="roster-list-item"
+ class:open=move || open()
+ class:focused=move || focused()
+ on:click=move |_| {
+ open_chat.dispatch(());
+ }
+ >
+ {move || {
+ view! { <AvatarWithPresence user=contact.user /> }
+ }}
+ <div class="item-info">
+ <div class="main-info">
+ <p class="name">
+ {name}
+ <span class="jid">- {move || contact.user_jid().read().to_string()}</span>
+ </p>
+ </div>
+ <div class="sub-info">{move || contact.subscription().read().to_string()}</div>
+ </div>
+ </div>
+ }
+}
diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs
new file mode 100644
index 0000000..9f555b5
--- /dev/null
+++ b/src/components/sidebar.rs
@@ -0,0 +1,233 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::collections::{HashMap, HashSet};
+
+use jid::BareJID;
+use leptos::prelude::*;
+use reactive_stores::Store;
+
+use crate::components::{
+ chats_list::ChatsList, personal_status::PersonalStatus, roster_list::RosterList,
+};
+
+#[derive(PartialEq, Eq, Clone, Copy, Hash)]
+pub enum SidebarOpen {
+ Roster,
+ Chats,
+}
+
+#[derive(Store)]
+pub struct Drawer {
+ open: SidebarOpen,
+ hovering: bool,
+}
+
+pub enum Open {
+ /// Currently on screen
+ Focused,
+ /// Open in background somewhere (e.g. in another chat tab)
+ Open,
+ /// Closed
+ Closed,
+}
+
+impl Open {
+ pub fn is_focused(&self) -> bool {
+ match self {
+ Open::Focused => true,
+ Open::Open => false,
+ Open::Closed => false,
+ }
+ }
+
+ pub fn is_open(&self) -> bool {
+ match self {
+ Open::Focused => true,
+ Open::Open => true,
+ Open::Closed => false,
+ }
+ }
+}
+
+/// returns whether the state was changed to open (true) or closed (false)
+pub fn toggle_open(state: &mut Option<SidebarOpen>, open: SidebarOpen) -> bool {
+ match state {
+ Some(opened) => {
+ if *opened == open {
+ *state = None;
+ false
+ } else {
+ *state = Some(open);
+ true
+ }
+ }
+ None => {
+ *state = Some(open);
+ true
+ }
+ }
+}
+
+#[component]
+pub fn Sidebar() -> impl IntoView {
+ let requests: ReadSignal<HashSet<BareJID>> =
+ use_context().expect("no pending subscriptions in context");
+
+ // for what has been clicked open (in the background)
+ let (open, set_open) = signal(None::<SidebarOpen>);
+ // for what is just in the hovered state (not clicked to be pinned open yet necessarily)
+ let open = Memo::new(move |_| open.get());
+ let (hovered, set_hovered) = signal(None::<SidebarOpen>);
+ let hovered = Memo::new(move |_| hovered.get());
+ let (just_closed, set_just_closed) = signal(false);
+ let just_closed = Memo::new(move |_| just_closed.get());
+
+ let pages = Memo::new(move |_| {
+ let mut pages = HashSet::new();
+ if let Some(hovered) = *hovered.read() {
+ pages.insert(hovered);
+ }
+ if let Some(opened) = *open.read() {
+ pages.insert(opened);
+ }
+ pages
+ });
+
+ view! {
+ <div
+ class="sidebar"
+ on:mouseleave=move |_| {
+ set_hovered.set(None);
+ set_just_closed.set(false);
+ }
+ >
+ <div class="dock panel">
+ <div class="shortcuts">
+ <div
+ class="roster-tab dock-item"
+ class:focused=move || *open.read() == Some(SidebarOpen::Roster)
+ class:hovering=move || *hovered.read() == Some(SidebarOpen::Roster)
+ on:mouseenter=move |_| {
+ set_just_closed.set(false);
+ set_hovered.set(Some(SidebarOpen::Roster))
+ }
+ on:click=move |_| {
+ set_open
+ .update(|state| {
+ if !toggle_open(state, SidebarOpen::Roster) {
+ set_just_closed.set(true);
+ } else {
+ set_just_closed.set(false);
+ }
+ })
+ }
+ >
+ <div class="dock-pill"></div>
+ <div class="dock-icon">
+ <div class="icon-with-badge">
+ <img src="/assets/caw.png" />
+ {move || {
+ let len = requests.read().len();
+ if len > 0 {
+ view! { <div class="badge">{len}</div> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ </div>
+ </div>
+ <div
+ class="chats-tab dock-item"
+ class:focused=move || *open.read() == Some(SidebarOpen::Chats)
+ class:hovering=move || *hovered.read() == Some(SidebarOpen::Chats)
+ on:mouseenter=move |_| {
+ set_just_closed.set(false);
+ set_hovered.set(Some(SidebarOpen::Chats))
+ }
+ on:click=move |_| {
+ set_open
+ .update(|state| {
+ if !toggle_open(state, SidebarOpen::Chats) {
+ set_just_closed.set(true);
+ } else {
+ set_just_closed.set(false);
+ }
+ })
+ }
+ >
+ <div class="dock-pill"></div>
+ <img src="/assets/bubble.png" />
+ </div>
+ </div>
+ <div class="pins"></div>
+ <div class="personal">
+ <PersonalStatus />
+ </div>
+ </div>
+ {move || {
+ if !just_closed.get() {
+ view! {
+ <For each=move || pages.get() key=|page| *page let(page)>
+ {move || match page {
+ SidebarOpen::Roster => {
+ view! {
+ {move || {
+ if *open.read() == None
+ && *hovered.read() == Some(SidebarOpen::Roster)
+ {
+ view! { <div class="sidebar-drawer behind-hovering"></div> }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ <div
+ class:sidebar-drawer=true
+ class:sidebar-hovering-drawer=move || {
+ !(*open.read() == Some(SidebarOpen::Roster))
+ && (*hovered.read() == Some(SidebarOpen::Roster))
+ }
+ >
+ <RosterList />
+ </div>
+ }
+ .into_any()
+ }
+ SidebarOpen::Chats => {
+ view! {
+ {move || {
+ if *open.read() == None
+ && *hovered.read() == Some(SidebarOpen::Chats)
+ {
+ view! { <div class="sidebar-drawer behind-hovering"></div> }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ <div
+ class:sidebar-drawer=true
+ class:sidebar-hovering-drawer=move || {
+ !(*open.read() == Some(SidebarOpen::Chats))
+ && (*hovered.read() == Some(SidebarOpen::Chats))
+ }
+ >
+ <ChatsList />
+ </div>
+ }
+ .into_any()
+ }
+ }}
+ </For>
+ }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ }
+}
diff --git a/src/contact.rs b/src/contact.rs
new file mode 100644
index 0000000..b7f57fa
--- /dev/null
+++ b/src/contact.rs
@@ -0,0 +1,67 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::ops::{Deref, DerefMut};
+
+use filamento::{roster::Contact, user::User};
+use reactive_stores::Store;
+
+use crate::user::{ArcMacawUser, MacawUser};
+
+#[derive(Clone, Copy)]
+pub struct MacawContact {
+ pub contact: Store<Contact>,
+ pub user: MacawUser,
+}
+
+impl Deref for MacawContact {
+ type Target = Store<Contact>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.contact
+ }
+}
+
+impl DerefMut for MacawContact {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.contact
+ }
+}
+
+impl From<ArcMacawContact> for MacawContact {
+ fn from(value: ArcMacawContact) -> Self {
+ Self {
+ contact: value.contact,
+ user: value.user.into(),
+ }
+ }
+}
+
+#[derive(Clone)]
+pub struct ArcMacawContact {
+ pub contact: Store<Contact>,
+ pub user: ArcMacawUser,
+}
+
+impl ArcMacawContact {
+ pub async fn got_contact_and_user(contact: Contact, user: User) -> Self {
+ let contact = Store::new(contact);
+ let user = ArcMacawUser::got_user(user).await;
+ Self { contact, user }
+ }
+}
+
+impl Deref for ArcMacawContact {
+ type Target = Store<Contact>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.contact
+ }
+}
+
+impl DerefMut for ArcMacawContact {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.contact
+ }
+}
diff --git a/src/context.rs b/src/context.rs
new file mode 100644
index 0000000..da660dc
--- /dev/null
+++ b/src/context.rs
@@ -0,0 +1,3 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
diff --git a/src/files.rs b/src/files.rs
new file mode 100644
index 0000000..760549f
--- /dev/null
+++ b/src/files.rs
@@ -0,0 +1,53 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use base64::{Engine, prelude::BASE64_STANDARD};
+use filamento::files::{FileStore, FilesMem, FilesOPFS, opfs::OPFSError};
+
+#[derive(Clone, Debug)]
+pub enum Files {
+ Mem(FilesMem),
+ Opfs(FilesOPFS),
+}
+
+impl FileStore for Files {
+ type Err = OPFSError;
+
+ async fn is_stored(&self, name: &str) -> Result<bool, Self::Err> {
+ match self {
+ Files::Mem(files_mem) => Ok(files_mem.is_stored(name).await.unwrap()),
+ Files::Opfs(files_opfs) => Ok(files_opfs.is_stored(name).await?),
+ }
+ }
+
+ async fn store(&self, name: &str, data: &[u8]) -> Result<(), Self::Err> {
+ match self {
+ Files::Mem(files_mem) => Ok(files_mem.store(name, data).await.unwrap()),
+ Files::Opfs(files_opfs) => Ok(files_opfs.store(name, data).await?),
+ }
+ }
+
+ async fn delete(&self, name: &str) -> Result<(), Self::Err> {
+ match self {
+ Files::Mem(files_mem) => Ok(files_mem.delete(name).await.unwrap()),
+ Files::Opfs(files_opfs) => Ok(files_opfs.delete(name).await?),
+ }
+ }
+}
+
+impl Files {
+ pub async fn get_src(&self, file_name: &str) -> Option<String> {
+ match self {
+ Files::Mem(files_mem) => {
+ if let Some(data) = files_mem.get_file(file_name).await {
+ let data = BASE64_STANDARD.encode(data);
+ Some(format!("data:image/jpg;base64, {}", data))
+ } else {
+ None
+ }
+ }
+ Files::Opfs(files_opfs) => files_opfs.get_src(file_name).await.ok(),
+ }
+ }
+}
diff --git a/src/icon.rs b/src/icon.rs
new file mode 100644
index 0000000..231ad2e
--- /dev/null
+++ b/src/icon.rs
@@ -0,0 +1,114 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+#[derive(Copy, Clone)]
+pub enum Icon {
+ AddContact24,
+ Attachment24,
+ Away16,
+ Away16Color,
+ Bubble16,
+ Bubble16Color,
+ Bubble24,
+ Close24,
+ Contact24,
+ Delivered16,
+ Dnd16,
+ Dnd16Color,
+ Error16Color,
+ Forward24,
+ Heart24,
+ NewBubble24,
+ Reply24,
+ Sending16,
+ Sent16,
+ Chat16Color,
+ Xa16Color,
+ Available16Color,
+}
+
+pub const ICONS_SRC: &str = "/assets/icons/";
+
+impl Icon {
+ pub fn src(&self) -> String {
+ match self {
+ Icon::AddContact24 => format!("{}addcontact24.svg", ICONS_SRC),
+ Icon::Attachment24 => format!("{}attachment24.svg", ICONS_SRC),
+ Icon::Away16 => format!("{}away16.svg", ICONS_SRC),
+ Icon::Away16Color => format!("{}away16color.svg", ICONS_SRC),
+ Icon::Bubble16 => format!("{}bubble16.svg", ICONS_SRC),
+ Icon::Bubble16Color => format!("{}bubble16color.svg", ICONS_SRC),
+ Icon::Bubble24 => format!("{}bubble24.svg", ICONS_SRC),
+ Icon::Close24 => format!("{}close24.svg", ICONS_SRC),
+ Icon::Contact24 => format!("{}contact24.svg", ICONS_SRC),
+ Icon::Delivered16 => format!("{}delivered16.svg", ICONS_SRC),
+ Icon::Dnd16 => format!("{}dnd16.svg", ICONS_SRC),
+ Icon::Dnd16Color => format!("{}dnd16color.svg", ICONS_SRC),
+ Icon::Error16Color => format!("{}error16color.svg", ICONS_SRC),
+ Icon::Forward24 => format!("{}forward24.svg", ICONS_SRC),
+ Icon::Heart24 => format!("{}heart24.svg", ICONS_SRC),
+ Icon::NewBubble24 => format!("{}newbubble24.svg", ICONS_SRC),
+ Icon::Reply24 => format!("{}reply24.svg", ICONS_SRC),
+ Icon::Sending16 => format!("{}sending16.svg", ICONS_SRC),
+ Icon::Sent16 => format!("{}sent16.svg", ICONS_SRC),
+ Icon::Chat16Color => format!("{}chat16color.svg", ICONS_SRC),
+ Icon::Xa16Color => format!("{}xa16color.svg", ICONS_SRC),
+ Icon::Available16Color => format!("{}available16color.svg", ICONS_SRC),
+ }
+ }
+
+ pub fn size(&self) -> isize {
+ match self {
+ Icon::AddContact24 => 24,
+ Icon::Attachment24 => 24,
+ Icon::Away16 => 16,
+ Icon::Away16Color => 16,
+ Icon::Bubble16 => 16,
+ Icon::Bubble16Color => 16,
+ Icon::Bubble24 => 24,
+ Icon::Close24 => 24,
+ Icon::Contact24 => 24,
+ Icon::Delivered16 => 16,
+ Icon::Dnd16 => 16,
+ Icon::Dnd16Color => 16,
+ Icon::Error16Color => 16,
+ Icon::Forward24 => 24,
+ Icon::Heart24 => 24,
+ Icon::NewBubble24 => 24,
+ Icon::Reply24 => 24,
+ Icon::Sending16 => 16,
+ Icon::Sent16 => 16,
+ Icon::Chat16Color => 16,
+ Icon::Xa16Color => 16,
+ Icon::Available16Color => 16,
+ }
+ }
+
+ pub fn light(&self) -> bool {
+ match self {
+ Icon::AddContact24 => true,
+ Icon::Attachment24 => true,
+ Icon::Away16 => true,
+ Icon::Away16Color => false,
+ Icon::Bubble16 => true,
+ Icon::Bubble16Color => false,
+ Icon::Bubble24 => true,
+ Icon::Close24 => true,
+ Icon::Contact24 => true,
+ Icon::Delivered16 => true,
+ Icon::Dnd16 => true,
+ Icon::Dnd16Color => false,
+ Icon::Error16Color => false,
+ Icon::Forward24 => true,
+ Icon::Heart24 => true,
+ Icon::NewBubble24 => true,
+ Icon::Reply24 => true,
+ Icon::Sending16 => true,
+ Icon::Sent16 => true,
+ Icon::Chat16Color => false,
+ Icon::Xa16Color => false,
+ Icon::Available16Color => false,
+ }
+ }
+}
diff --git a/src/lib.rs b/src/lib.rs
new file mode 100644
index 0000000..fcd632e
--- /dev/null
+++ b/src/lib.rs
@@ -0,0 +1,20 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+pub use views::App;
+
+mod chat;
+mod client;
+mod components;
+mod contact;
+mod files;
+mod icon;
+mod message;
+mod message_subscriptions;
+mod open_chats;
+mod roster;
+mod state_store;
+mod user;
+mod user_presences;
+mod views;
diff --git a/src/login_modal.rs b/src/login_modal.rs
deleted file mode 100644
index 5a63158..0000000
--- a/src/login_modal.rs
+++ /dev/null
@@ -1,115 +0,0 @@
-use iced::{
- futures::StreamExt,
- widget::{button, checkbox, column, container, text, text_input},
- Element, Task,
-};
-use jid::JID;
-use keyring::Entry;
-use luz::{
- presence::{Offline, Presence},
- LuzHandle,
-};
-use serde::{Deserialize, Serialize};
-use tokio_stream::wrappers::ReceiverStream;
-use tracing::info;
-
-use crate::Client;
-
-#[derive(Default)]
-pub struct LoginModal {
- jid: String,
- password: String,
- remember_me: bool,
- error: Option<Error>,
-}
-
-#[derive(Debug, Clone)]
-pub enum Message {
- JID(String),
- Password(String),
- RememberMe,
- Submit,
- Error(Error),
-}
-
-#[derive(Debug, Clone)]
-pub enum Error {
- InvalidJID,
-}
-
-pub enum Action {
- None,
- ClientCreated(Task<crate::Message>),
- CreateClient(String, String, bool),
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-pub struct Creds {
- pub jid: String,
- pub password: String,
-}
-
-impl LoginModal {
- pub fn update(&mut self, message: Message) -> Action {
- match message {
- Message::JID(j) => {
- self.jid = j;
- Action::None
- }
- Message::Password(p) => {
- self.password = p;
- Action::None
- }
- Message::RememberMe => {
- self.remember_me = !self.remember_me;
- Action::None
- }
- Message::Submit => {
- info!("submitting login");
- let jid_str = self.jid.clone();
- let password = self.password.clone();
- let remember_me = self.remember_me.clone();
- Action::CreateClient(jid_str, password, remember_me)
- }
- Message::Error(error) => {
- self.error = Some(error);
- Action::None
- }
- }
- }
-
- pub fn view(&self) -> Element<Message> {
- container(
- column![
- text("Log In").size(24),
- column![
- column![
- text("JID").size(12),
- text_input("berry@macaw.chat", &self.jid)
- .on_input(|j| Message::JID(j))
- .on_submit(Message::Submit)
- .padding(5),
- ]
- .spacing(5),
- column![
- text("Password").size(12),
- text_input("", &self.password)
- .on_input(|p| Message::Password(p))
- .on_submit(Message::Submit)
- .secure(true)
- .padding(5),
- ]
- .spacing(5),
- checkbox("remember me", self.remember_me).on_toggle(|_| Message::RememberMe),
- button(text("Submit")).on_press(Message::Submit),
- ]
- .spacing(10)
- ]
- .spacing(20),
- )
- .width(300)
- .padding(10)
- .style(container::rounded_box)
- .into()
- }
-}
diff --git a/src/main.rs b/src/main.rs
index 37551c8..3db4c25 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,1057 +1,13 @@
-use std::borrow::Cow;
-use std::collections::{HashMap, HashSet};
-use std::fmt::Debug;
-use std::ops::{Deref, DerefMut};
-use std::path::PathBuf;
-use std::str::FromStr;
-use std::sync::Arc;
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
-use chrono::{Local, Utc};
-use iced::alignment::Horizontal::Right;
-use iced::futures::{SinkExt, Stream, StreamExt};
-use iced::keyboard::{on_key_press, on_key_release, Key, Modifiers};
-use iced::theme::palette::{
- Background, Danger, Extended, Pair, Primary, Secondary, Success, Warning,
-};
-use iced::theme::{Custom, Palette};
-use iced::widget::button::Status;
-use iced::widget::text::{Fragment, IntoFragment, Wrapping};
-use iced::widget::{
- button, center, checkbox, column, container, horizontal_space, mouse_area, opaque, row,
- scrollable, stack, text, text_input, toggler, Column, Text, Toggler,
-};
-use iced::Length::{self, Fill, Shrink};
-use iced::{color, stream, Color, Element, Subscription, Task, Theme};
-use indexmap::{indexmap, IndexMap};
-use jid::JID;
-use keyring::Entry;
-use login_modal::{Creds, LoginModal};
-use luz::chat::{Chat, Message as ChatMessage};
-use luz::error::CommandError;
-use luz::presence::{Offline, Presence, PresenceType};
-use luz::CommandMessage;
-use luz::{roster::Contact, user::User, LuzHandle, UpdateMessage};
-use message_view::MessageView;
-use serde::{Deserialize, Serialize};
-use thiserror::Error;
-use tokio::sync::{mpsc, oneshot};
-use tokio_stream::wrappers::ReceiverStream;
-use tracing::{error, info};
-use uuid::Uuid;
+use leptos::prelude::*;
+use macaw_web::App;
-mod login_modal;
-mod message_view;
+fn main() {
+ tracing_wasm::set_as_global_default();
+ console_error_panic_hook::set_once();
-#[derive(Serialize, Deserialize, Clone)]
-pub struct Config {
- auto_connect: bool,
- storage_dir: Option<String>,
- dburl: Option<String>,
- message_view_config: message_view::Config,
-}
-
-impl Default for Config {
- fn default() -> Self {
- Self {
- auto_connect: true,
- storage_dir: None,
- dburl: None,
- message_view_config: message_view::Config::default(),
- }
- }
-}
-
-pub struct Macaw {
- client: Account,
- config: Config,
- roster: HashMap<JID, Contact>,
- users: HashMap<JID, User>,
- presences: HashMap<JID, Presence>,
- chats: IndexMap<JID, (Chat, Option<ChatMessage>)>,
- subscription_requests: HashSet<JID>,
- open_chat: Option<MessageView>,
- new_chat: Option<NewChat>,
-}
-
-pub struct NewChat;
-
-impl Macaw {
- pub fn new(client: Option<Client>, config: Config) -> Self {
- let account;
- if let Some(client) = client {
- account = Account::LoggedIn(client);
- } else {
- account = Account::LoggedOut(LoginModal::default());
- }
-
- Self {
- client: account,
- config,
- roster: HashMap::new(),
- users: HashMap::new(),
- presences: HashMap::new(),
- chats: IndexMap::new(),
- subscription_requests: HashSet::new(),
- open_chat: None,
- new_chat: None,
- }
- }
-}
-
-pub enum Account {
- LoggedIn(Client),
- LoggedOut(LoginModal),
-}
-
-impl Account {
- pub fn is_connected(&self) -> bool {
- match self {
- Account::LoggedIn(client) => client.connection_state.is_connected(),
- Account::LoggedOut(login_modal) => false,
- }
- }
-
- pub fn connection_status(&self) -> String {
- match self {
- Account::LoggedIn(client) => match client.connection_state {
- ConnectionState::Online => "online".to_string(),
- ConnectionState::Connecting => "connecting".to_string(),
- ConnectionState::Offline => "offline".to_string(),
- },
- Account::LoggedOut(login_modal) => "no account".to_string(),
- }
- }
-}
-
-#[derive(Clone, Debug)]
-pub struct Client {
- client: LuzHandle,
- jid: JID,
- status: Presence,
- connection_state: ConnectionState,
-}
-
-impl Client {
- pub fn is_connected(&self) -> bool {
- self.connection_state.is_connected()
- }
-}
-
-#[derive(Clone, Debug)]
-pub enum ConnectionState {
- Online,
- Connecting,
- Offline,
-}
-
-impl ConnectionState {
- pub fn is_connected(&self) -> bool {
- match self {
- ConnectionState::Online => true,
- ConnectionState::Connecting => false,
- ConnectionState::Offline => false,
- }
- }
-}
-
-impl DerefMut for Client {
- fn deref_mut(&mut self) -> &mut Self::Target {
- &mut self.client
- }
-}
-
-impl Deref for Client {
- type Target = LuzHandle;
-
- fn deref(&self) -> &Self::Target {
- &self.client
- }
-}
-
-async fn luz(jid: &JID, creds: &Creds, cfg: &Config) -> (LuzHandle, mpsc::Receiver<UpdateMessage>) {
- let luz;
- if let Some(ref dburl) = cfg.dburl {
- // TODO: have some sort of crash popup for this stuff
- let db_path = dburl.strip_prefix("sqlite://").unwrap_or(&dburl);
- let db_path = PathBuf::from_str(db_path).expect("invalid database path");
- let db = luz::db::Db::create_connect_and_migrate(db_path)
- .await
- .unwrap();
- luz = LuzHandle::new(jid.clone(), creds.password.to_string(), db);
- } else if let Some(ref dir) = cfg.storage_dir {
- let mut data_dir = PathBuf::from_str(&dir).expect("invalid storage directory path");
- data_dir.push(creds.jid.clone());
- data_dir.push(creds.jid.clone());
- data_dir.set_extension("db");
- let db = luz::db::Db::create_connect_and_migrate(data_dir)
- .await
- .unwrap();
- luz = LuzHandle::new(jid.clone(), creds.password.to_string(), db);
- } else {
- let mut data_dir = dirs::data_dir()
- .expect("operating system does not support retreiving determining default data dir");
- data_dir.push("macaw");
- data_dir.push(creds.jid.clone());
- data_dir.push(creds.jid.clone());
- // TODO: better lol
- data_dir.set_extension("db");
- info!("db_path: {:?}", data_dir);
- let db = luz::db::Db::create_connect_and_migrate(data_dir)
- .await
- .unwrap();
- luz = LuzHandle::new(jid.clone(), creds.password.to_string(), db);
- }
- luz
-}
-
-#[tokio::main]
-async fn main() -> iced::Result {
- tracing_subscriber::fmt::init();
-
- let cfg: Config = confy::load("macaw", None).unwrap_or_default();
- let entry = Entry::new("macaw", "macaw");
- let mut client_creation_error: Option<Error> = None;
- let mut creds: Option<Creds> = None;
-
- match entry {
- Ok(e) => {
- let result = e.get_password();
- match result {
- Ok(c) => {
- let result = toml::from_str(&c);
- match result {
- Ok(c) => creds = Some(c),
- Err(e) => {
- client_creation_error =
- Some(Error::CredentialsLoad(CredentialsLoadError::Toml(e.into())))
- }
- }
- }
- Err(e) => match e {
- keyring::Error::NoEntry => {}
- _ => {
- client_creation_error = Some(Error::CredentialsLoad(
- CredentialsLoadError::Keyring(e.into()),
- ))
- }
- },
- }
- }
- Err(e) => {
- client_creation_error = Some(Error::CredentialsLoad(CredentialsLoadError::Keyring(
- e.into(),
- )))
- }
- }
-
- let mut client: Option<(JID, LuzHandle, mpsc::Receiver<UpdateMessage>)> = None;
- if let Some(creds) = creds {
- let jid = creds.jid.parse::<JID>();
- match jid {
- Ok(jid) => {
- let (handle, updates) = luz(&jid, &creds, &cfg).await;
- client = Some((jid, handle, updates));
- }
- Err(e) => client_creation_error = Some(Error::CredentialsLoad(e.into())),
- }
- }
-
- if let Some((jid, luz_handle, update_recv)) = client {
- let stream = ReceiverStream::new(update_recv);
- let stream = stream.map(|message| Message::Luz(message));
- let task = {
- let luz_handle1 = luz_handle.clone();
- let luz_handle2 = luz_handle.clone();
- if cfg.auto_connect {
- Task::batch(
- [
- Task::batch([
- Task::perform(
- async move { luz_handle1.get_roster().await },
- |result| {
- let roster = result.unwrap();
- let mut macaw_roster = HashMap::new();
- for contact in roster {
- macaw_roster.insert(contact.user_jid.clone(), contact);
- }
- Message::Roster(macaw_roster)
- },
- ),
- Task::perform(
- async move {
- luz_handle2.get_chats_ordered_with_latest_messages().await
- },
- |chats| {
- let chats = chats.unwrap();
- info!("got chats: {:?}", chats);
- Message::GotChats(chats)
- },
- ),
- ])
- .chain(Task::done(Message::Connect)),
- Task::stream(stream),
- ],
- )
- } else {
- Task::batch([
- Task::perform(async move { luz_handle1.get_roster().await }, |result| {
- let roster = result.unwrap();
- let mut macaw_roster = HashMap::new();
- for contact in roster {
- macaw_roster.insert(contact.user_jid.clone(), contact);
- }
- Message::Roster(macaw_roster)
- }),
- Task::perform(
- async move { luz_handle2.get_chats_ordered_with_latest_messages().await },
- |chats| {
- let chats = chats.unwrap();
- info!("got chats: {:?}", chats);
- Message::GotChats(chats)
- },
- ),
- Task::stream(stream),
- ])
- }
- };
- iced::application("Macaw", Macaw::update, Macaw::view)
- .subscription(subscription)
- .theme(Macaw::theme)
- .run_with(|| {
- (
- Macaw::new(
- Some(Client {
- client: luz_handle,
- // TODO:
- jid,
- // TODO: store cached status
- status: Presence {
- timestamp: Utc::now(),
- presence: PresenceType::Offline(Offline::default()),
- },
- connection_state: ConnectionState::Offline,
- }),
- cfg,
- ),
- task,
- )
- })
- } else {
- if let Some(e) = client_creation_error {
- iced::application("Macaw", Macaw::update, Macaw::view)
- .run_with(|| (Macaw::new(None, cfg), Task::done(Message::Error(e))))
- } else {
- iced::application("Macaw", Macaw::update, Macaw::view)
- .run_with(|| (Macaw::new(None, cfg), Task::none()))
- }
- }
-}
-
-fn subscription(state: &Macaw) -> Subscription<Message> {
- Subscription::batch([press_subscription(state), release_subscription(state)])
-}
-
-fn press_subscription(_state: &Macaw) -> Subscription<Message> {
- on_key_press(handle_key_press)
-}
-
-fn handle_key_press(key: Key, r#mod: Modifiers) -> Option<Message> {
- match key {
- Key::Named(iced::keyboard::key::Named::Shift) => Some(Message::ShiftPressed),
- _ => None,
- }
-}
-
-fn release_subscription(_state: &Macaw) -> Subscription<Message> {
- on_key_release(handle_key_release)
-}
-
-fn handle_key_release(key: Key, r#mod: Modifiers) -> Option<Message> {
- match key {
- Key::Named(iced::keyboard::key::Named::Shift) => Some(Message::ShiftReleased),
- _ => None,
- }
-}
-
-#[derive(Debug, Clone)]
-pub enum Message {
- ShiftPressed,
- ShiftReleased,
- LoginModal(login_modal::Message),
- ClientCreated(Client),
- Luz(UpdateMessage),
- Roster(HashMap<JID, Contact>),
- Connect,
- Disconnect,
- GotChats(Vec<(Chat, ChatMessage)>),
- GotMessageHistory(Chat, IndexMap<Uuid, ChatMessage>),
- ToggleChat(JID),
- SendMessage(JID, String),
- Error(Error),
- MessageView(message_view::Message),
-}
-
-#[derive(Debug, Error, Clone)]
-pub enum Error {
- #[error("failed to create Luz client: {0}")]
- ClientCreation(#[from] luz::error::DatabaseError),
- #[error("failed to save credentials: {0}")]
- CredentialsSave(CredentialsSaveError),
- #[error("failed to load credentials: {0}")]
- CredentialsLoad(CredentialsLoadError),
- #[error("failed to retreive messages for chat {0}")]
- MessageHistory(JID, CommandError<luz::error::DatabaseError>),
-}
-
-#[derive(Debug, Error, Clone)]
-pub enum CredentialsSaveError {
- #[error("keyring: {0}")]
- Keyring(Arc<keyring::Error>),
- #[error("toml serialisation: {0}")]
- Toml(#[from] toml::ser::Error),
-}
-
-impl From<keyring::Error> for CredentialsSaveError {
- fn from(e: keyring::Error) -> Self {
- Self::Keyring(Arc::new(e))
- }
-}
-
-#[derive(Debug, Error, Clone)]
-pub enum CredentialsLoadError {
- #[error("keyring: {0}")]
- Keyring(Arc<keyring::Error>),
- #[error("toml serialisation: {0}")]
- Toml(#[from] toml::de::Error),
- #[error("invalid jid: {0}")]
- JID(#[from] jid::ParseError),
-}
-
-impl From<keyring::Error> for CredentialsLoadError {
- fn from(e: keyring::Error) -> Self {
- Self::Keyring(Arc::new(e))
- }
-}
-
-impl Macaw {
- fn update(&mut self, message: Message) -> Task<Message> {
- match message {
- Message::Luz(update_message) => match update_message {
- UpdateMessage::Error(error) => {
- tracing::error!("Luz error: {:?}", error);
- Task::none()
- }
- UpdateMessage::Online(online, vec) => match &mut self.client {
- Account::LoggedIn(client) => {
- client.status = Presence {
- timestamp: Utc::now(),
- presence: PresenceType::Online(online),
- };
- client.connection_state = ConnectionState::Online;
- let mut roster = HashMap::new();
- for contact in vec {
- roster.insert(contact.user_jid.clone(), contact);
- }
- self.roster = roster;
- Task::none()
- }
- Account::LoggedOut(login_modal) => Task::none(),
- },
- UpdateMessage::Offline(offline) => {
- // TODO: update all contacts' presences to unknown (offline)
- match &mut self.client {
- Account::LoggedIn(client) => {
- client.status = Presence {
- timestamp: Utc::now(),
- presence: PresenceType::Offline(offline),
- };
- client.connection_state = ConnectionState::Offline;
- Task::none()
- }
- Account::LoggedOut(login_modal) => Task::none(),
- }
- }
- UpdateMessage::FullRoster(vec) => {
- let mut macaw_roster = HashMap::new();
- for contact in vec {
- macaw_roster.insert(contact.user_jid.clone(), contact);
- }
- self.roster = macaw_roster;
- Task::none()
- }
- UpdateMessage::RosterUpdate(contact) => {
- self.roster.insert(contact.user_jid.clone(), contact);
- Task::none()
- }
- UpdateMessage::RosterDelete(jid) => {
- self.roster.remove(&jid);
- Task::none()
- }
- UpdateMessage::Presence { from, presence } => {
- self.presences.insert(from, presence);
- Task::none()
- }
- UpdateMessage::Message { to, message } => {
- if let Some((chat_jid, (chat, old_message))) =
- self.chats.shift_remove_entry(&to)
- {
- self.chats
- .insert_before(0, chat_jid, (chat, Some(message.clone())));
- if let Some(open_chat) = &mut self.open_chat {
- if open_chat.jid == to {
- open_chat.update(message_view::Message::Message(message));
- }
- }
- } else {
- let chat = Chat {
- correspondent: to.clone(),
- };
- let message_history = indexmap! {message.id => message.clone()};
- self.chats.insert_before(0, to, (chat, Some(message)));
- }
- Task::none()
- }
- UpdateMessage::SubscriptionRequest(jid) => {
- // TODO: subscription requests
- Task::none()
- }
- },
- // TODO: NEXT
- Message::ClientCreated(client) => {
- self.client = Account::LoggedIn(client.clone());
- let client1 = client.clone();
- let client2 = client.clone();
- if self.config.auto_connect {
- Task::batch([
- Task::perform(async move { client1.client.get_roster().await }, |result| {
- let roster = result.unwrap();
- let mut macaw_roster = HashMap::new();
- for contact in roster {
- macaw_roster.insert(contact.user_jid.clone(), contact);
- }
- Message::Roster(macaw_roster)
- }),
- Task::perform(
- async move {
- client2
- .client
- .get_chats_ordered_with_latest_messages()
- .await
- },
- |chats| {
- let chats = chats.unwrap();
- // let chats: HashMap<JID, (Chat, IndexMap<Uuid, ChatMessage>)> = chats
- // .into_iter()
- // .map(|chat| (chat.correspondent.clone(), (chat, IndexMap::new())))
- // .collect();
- info!("got chats: {:?}", chats);
- Message::GotChats(chats)
- },
- ),
- ])
- .chain(Task::done(Message::Connect))
- } else {
- Task::batch([
- Task::perform(async move { client1.client.get_roster().await }, |result| {
- let roster = result.unwrap();
- let mut macaw_roster = HashMap::new();
- for contact in roster {
- macaw_roster.insert(contact.user_jid.clone(), contact);
- }
- Message::Roster(macaw_roster)
- }),
- Task::perform(
- async move {
- client2
- .client
- .get_chats_ordered_with_latest_messages()
- .await
- },
- |chats| {
- let chats = chats.unwrap();
- // let chats: HashMap<JID, (Chat, IndexMap<Uuid, ChatMessage>)> = chats
- // .into_iter()
- // .map(|chat| (chat.correspondent.clone(), (chat, IndexMap::new())))
- // .collect();
- info!("got chats: {:?}", chats);
- Message::GotChats(chats)
- },
- ),
- ])
- }
- }
- Message::Roster(hash_map) => {
- self.roster = hash_map;
- Task::none()
- }
- Message::Connect => match &mut self.client {
- Account::LoggedIn(client) => {
- client.connection_state = ConnectionState::Connecting;
- let client = client.client.clone();
- Task::future(async move {
- client.send(CommandMessage::Connect).await;
- })
- .discard()
- }
- Account::LoggedOut(login_modal) => Task::none(),
- },
- Message::Disconnect => match &self.client {
- Account::LoggedIn(client) => {
- let client = client.client.clone();
- Task::future(async move {
- client
- .send(CommandMessage::Disconnect(Offline::default()))
- .await;
- })
- .discard()
- }
- Account::LoggedOut(login_modal) => Task::none(),
- },
- Message::ToggleChat(jid) => {
- match &self.open_chat {
- Some(message_view) => {
- if message_view.jid == jid {
- self.open_chat = None;
- return Task::none();
- }
- }
- None => {}
- }
- self.open_chat = Some(MessageView::new(jid.clone(), &self.config));
- let jid1 = jid.clone();
- match &self.client {
- Account::LoggedIn(client) => {
- let client = client.clone();
- Task::perform(
- async move { client.get_messages(jid1).await },
- move |result| match result {
- Ok(h) => {
- Message::MessageView(message_view::Message::MessageHistory(h))
- }
- Err(e) => Message::Error(Error::MessageHistory(jid.clone(), e)),
- },
- )
- }
- Account::LoggedOut(login_modal) => Task::none(),
- }
- }
- Message::LoginModal(login_modal_message) => match &mut self.client {
- Account::LoggedIn(_client) => Task::none(),
- Account::LoggedOut(login_modal) => {
- let action = login_modal.update(login_modal_message);
- match action {
- login_modal::Action::None => Task::none(),
- login_modal::Action::CreateClient(jid, password, remember_me) => {
- let creds = Creds { jid, password };
- let jid = creds.jid.parse::<JID>();
- let config = self.config.clone();
- match jid {
- Ok(jid) => {
- Task::perform(async move {
- let (jid, creds, config) = (jid, creds, config);
- let (handle, recv) = luz(&jid, &creds, &config).await;
- (handle, recv, jid, creds, config)
- }, move |(handle, recv, jid, creds, config)| {
- let creds = creds;
- let mut tasks = Vec::new();
- tasks.push(Task::done(crate::Message::ClientCreated(
- Client {
- client: handle,
- jid,
- status: Presence { timestamp: Utc::now(), presence: PresenceType::Offline(Offline::default()) },
- connection_state: ConnectionState::Offline,
- },
- )));
- let stream = ReceiverStream::new(recv);
- let stream =
- stream.map(|message| crate::Message::Luz(message));
- tasks.push(Task::stream(stream));
-
- if remember_me {
- let entry = Entry::new("macaw", "macaw");
- match entry {
- Ok(e) => {
- let creds = toml::to_string(&creds);
- match creds {
- Ok(c) => {
- let result = e.set_password(&c);
- if let Err(e) = result {
- tasks.push(Task::done(crate::Message::Error(
- crate::Error::CredentialsSave(e.into()),
- )));
- }
- }
- Err(e) => tasks.push(Task::done(
- crate::Message::Error(
- crate::Error::CredentialsSave(
- e.into(),
- ),
- ),
- )),
- }
- }
- Err(e) => {
- tasks.push(Task::done(crate::Message::Error(
- crate::Error::CredentialsSave(e.into()),
- )))
- }
- }
- }
- tasks
- }).then(|tasks| Task::batch(tasks))
- }
- Err(e) => Task::done(Message::LoginModal(
- login_modal::Message::Error(login_modal::Error::InvalidJID),
- )),
- }
- }
- login_modal::Action::ClientCreated(task) => task,
- }
- }
- },
- Message::GotChats(chats) => {
- let mut tasks = Vec::new();
- let client = match &self.client {
- Account::LoggedIn(client) => client,
- Account::LoggedOut(_) => {
- // TODO: error into event tracing subscriber
- error!("no client, cannot retreive chat history for chats");
- return Task::none();
- }
- };
- for chat in chats {
- self.chats
- // TODO: could have a chat with no messages, bad database state
- .insert(chat.0.correspondent.clone(), (chat.0.clone(), Some(chat.1)));
- // let client = client.clone();
- // let correspondent = chat.correspondent.clone();
- // tasks.push(Task::perform(
- // // TODO: don't get the entire message history LOL
- // async move { (chat, client.get_messages(correspondent).await) },
- // |result| {
- // let messages: IndexMap<Uuid, ChatMessage> = result
- // .1
- // .unwrap()
- // .into_iter()
- // .map(|message| (message.id.clone(), message))
- // .collect();
- // Message::GotMessageHistory(result.0, messages)
- // },
- // ))
- }
- Task::batch(tasks)
- // .then(|chats| {
- // let tasks = Vec::new();
- // for key in chats.keys() {
- // let client = client.client.clone();
- // tasks.push(Task::future(async {
- // client.get_messages(key.clone()).await;
- // }));
- // }
- // Task::batch(tasks)
- // }),
- }
- Message::GotMessageHistory(chat, mut message_history) => {
- // TODO: don't get the entire message history LOL
- if let Some((_id, message)) = message_history.pop() {
- self.chats
- .insert(chat.correspondent.clone(), (chat, Some(message)));
- }
- Task::none()
- }
- Message::SendMessage(jid, body) => {
- let client = match &self.client {
- Account::LoggedIn(client) => client.clone(),
- Account::LoggedOut(_) => {
- error!("cannot send message when no client set up");
- return Task::none();
- }
- };
- Task::future(
- async move { client.send_message(jid, luz::chat::Body { body }).await },
- )
- .discard()
- }
- Message::Error(error) => {
- error!("{}", error);
- Task::none()
- }
- Message::MessageView(message) => {
- if let Some(message_view) = &mut self.open_chat {
- let action = message_view.update(message);
- match action {
- message_view::Action::None => Task::none(),
- message_view::Action::SendMessage(m) => {
- Task::done(Message::SendMessage(message_view.jid.clone(), m))
- }
- }
- } else {
- Task::none()
- }
- }
- Message::ShiftPressed => {
- info!("shift pressed");
- if let Some(open_chat) = &mut self.open_chat {
- open_chat.shift_pressed = true;
- }
- Task::none()
- }
- Message::ShiftReleased => {
- info!("shift released");
- if let Some(open_chat) = &mut self.open_chat {
- open_chat.shift_pressed = false;
- }
- Task::none()
- }
- }
- }
-
- fn view(&self) -> Element<Message> {
- let mut ui: Element<Message> = {
- let mut chats_list: Column<Message> = column![];
- for (jid, (chat, latest_message)) in &self.chats {
- let mut open = false;
- if let Some(open_chat) = &self.open_chat {
- if open_chat.jid == *jid {
- open = true;
- }
- }
- let chat_list_item = chat_list_item(chat, latest_message, open);
- chats_list = chats_list.push(chat_list_item);
- }
- let chats_list = scrollable(chats_list.spacing(8).padding(8))
- .spacing(1)
- .height(Fill);
-
- let connection_status = self.client.connection_status();
- let client_jid: Cow<'_, str> = match &self.client {
- Account::LoggedIn(client) => (&client.jid).into(),
- Account::LoggedOut(_) => Cow::from("no account"),
- // map(|client| (&client.jid).into());
- };
- let connected = self.client.is_connected();
-
- let account_view = container(row![
- text(client_jid),
- horizontal_space(),
- text(connection_status),
- horizontal_space().width(8),
- toggler(connected).on_toggle(|connect| {
- if connect {
- Message::Connect
- } else {
- Message::Disconnect
- }
- })
- ])
- .padding(8);
-
- // TODO: config width/resizing
- let sidebar = column![chats_list, account_view].height(Fill).width(300);
-
- let message_view;
- if let Some(open_chat) = &self.open_chat {
- message_view = open_chat.view().map(Message::MessageView)
- } else {
- message_view = column![].into();
- }
-
- row![sidebar, container(message_view).width(Fill)]
- }
- .into();
-
- if let Some(new_chat) = &self.new_chat {
- // TODO: close new chat window
- ui = modal(ui, text("new chat"), None);
- }
- // temporarily center to fill space
- // let ui = center(ui).into();
- let ui = container(ui).center_x(Fill).center_y(Fill);
-
- match &self.client {
- Account::LoggedIn(_client) => ui.into(),
- Account::LoggedOut(login_modal) => {
- let signup = login_modal.view().map(Message::LoginModal);
- modal(ui, signup, None)
- }
- }
- }
-
- fn theme(&self) -> Theme {
- let extended = Extended {
- background: Background {
- base: Pair {
- color: color!(0x392c25),
- text: color!(0xdcdcdc),
- },
- weakest: Pair {
- color: color!(0xdcdcdc),
- text: color!(0x392c25),
- },
- weak: Pair {
- color: color!(0xdcdcdc),
- text: color!(0x392c25),
- },
- strong: Pair {
- color: color!(0x364b3b),
- text: color!(0xdcdcdc),
- },
- strongest: Pair {
- color: color!(0x364b3b),
- text: color!(0xdcdcdc),
- },
- },
- primary: Primary {
- base: Pair {
- color: color!(0x2b33b4),
- text: color!(0xdcdcdc),
- },
- weak: Pair {
- color: color!(0x4D4A5E),
- text: color!(0xdcdcdc),
- },
- strong: Pair {
- color: color!(0x2b33b4),
- text: color!(0xdcdcdc),
- },
- },
- secondary: Secondary {
- base: Pair {
- color: color!(0xffce07),
- text: color!(0x000000),
- },
- weak: Pair {
- color: color!(0xffce07),
- text: color!(0x000000),
- },
- strong: Pair {
- color: color!(0xffce07),
- text: color!(0x000000),
- },
- },
- success: Success {
- base: Pair {
- color: color!(0x14802E),
- text: color!(0xdcdcdc),
- },
- weak: Pair {
- color: color!(0x14802E),
- text: color!(0xdcdcdc),
- },
- strong: Pair {
- color: color!(0x14802E),
- text: color!(0xdcdcdc),
- },
- },
- warning: Warning {
- base: Pair {
- color: color!(0xFF9D00),
- text: color!(0x000000),
- },
- weak: Pair {
- color: color!(0xFF9D00),
- text: color!(0x000000),
- },
- strong: Pair {
- color: color!(0xFF9D00),
- text: color!(0x000000),
- },
- },
- danger: Danger {
- base: Pair {
- color: color!(0xC1173C),
- text: color!(0xdcdcdc),
- },
- weak: Pair {
- color: color!(0xC1173C),
- text: color!(0xdcdcdc),
- },
- strong: Pair {
- color: color!(0xC1173C),
- text: color!(0xdcdcdc),
- },
- },
- is_dark: true,
- };
- Theme::Custom(Arc::new(Custom::with_fn(
- "macaw".to_string(),
- Palette::DARK,
- |_| extended,
- )))
- // Theme::Custom(Arc::new(Custom::new(
- // "macaw".to_string(),
- // Palette {
- // background: color!(0x392c25),
- // text: color!(0xdcdcdc),
- // primary: color!(0x2b33b4),
- // success: color!(0x14802e),
- // warning: color!(0xffce07),
- // danger: color!(0xc1173c),
- // },
- // )))
- }
-}
-
-fn modal<'a, Message>(
- base: impl Into<Element<'a, Message>>,
- content: impl Into<Element<'a, Message>>,
- on_blur: Option<Message>,
-) -> Element<'a, Message>
-where
- Message: Clone + 'a,
-{
- let mut mouse_area = mouse_area(center(opaque(content)).style(|_theme| {
- container::Style {
- background: Some(
- Color {
- a: 0.8,
- ..Color::BLACK
- }
- .into(),
- ),
- ..container::Style::default()
- }
- })); // .on_press(on_blur)
- if let Some(on_blur) = on_blur {
- mouse_area = mouse_area.on_press(on_blur)
- }
- stack![base.into(), opaque(mouse_area)].into()
-}
-
-fn chat_list_item<'a>(
- chat: &'a Chat,
- latest_message: &'a Option<ChatMessage>,
- open: bool,
-) -> Element<'a, Message> {
- let mut content: Column<Message> = column![text(chat.correspondent.to_string())];
- if let Some(latest_message) = latest_message {
- let message = latest_message.body.body.replace("\n", " ");
- let date = latest_message.timestamp.naive_local();
- let now = Local::now().naive_local();
- let timeinfo;
- if date.date() == now.date() {
- // TODO: localisation/config
- timeinfo = text(date.time().format("%H:%M").to_string())
- } else {
- timeinfo = text(date.date().format("%d/%m").to_string())
- }
- content = content.push(
- row![
- container(text(message).wrapping(Wrapping::None))
- .clip(true)
- .width(Fill),
- timeinfo
- ]
- .spacing(8)
- .width(Fill),
- );
- }
- let mut button = button(content).on_press(Message::ToggleChat(chat.correspondent.clone()));
- if open {
- button = button.style(|theme: &Theme, status| {
- let palette = theme.extended_palette();
- button::Style::default().with_background(palette.primary.weak.color)
- });
- }
- button.width(Fill).into()
+ leptos::mount::mount_to_body(App)
}
diff --git a/src/message.rs b/src/message.rs
new file mode 100644
index 0000000..20e37b9
--- /dev/null
+++ b/src/message.rs
@@ -0,0 +1,80 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::ops::{Deref, DerefMut};
+
+use filamento::{chat::Message, user::User};
+use leptos::prelude::*;
+use reactive_stores::ArcStore;
+use uuid::Uuid;
+
+use crate::{
+ state_store::{StateListener, StateStore},
+ user::{ArcMacawUser, MacawUser},
+};
+
+#[derive(Clone, Copy)]
+pub struct MacawMessage {
+ pub message: ArenaItem<StateListener<Uuid, ArcStore<Message>>>,
+ pub user: MacawUser,
+}
+
+impl MacawMessage {
+ pub fn get(&self) -> ArcStore<Message> {
+ self.try_get_value().unwrap().get()
+ }
+}
+
+impl Deref for MacawMessage {
+ type Target = ArenaItem<StateListener<Uuid, ArcStore<Message>>>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.message
+ }
+}
+
+impl DerefMut for MacawMessage {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.message
+ }
+}
+
+impl From<ArcMacawMessage> for MacawMessage {
+ fn from(value: ArcMacawMessage) -> Self {
+ Self {
+ message: ArenaItem::new_with_storage(value.message),
+ user: value.user.into(),
+ }
+ }
+}
+
+#[derive(Clone)]
+pub struct ArcMacawMessage {
+ pub message: StateListener<Uuid, ArcStore<Message>>,
+ pub user: ArcMacawUser,
+}
+
+impl ArcMacawMessage {
+ pub async fn got_message_and_user(message: Message, user: User) -> Self {
+ let message_state_store: StateStore<Uuid, ArcStore<Message>> =
+ use_context().expect("no message state store");
+ let message = message_state_store.store(message.id, ArcStore::new(message));
+ let user = ArcMacawUser::got_user(user).await;
+ Self { message, user }
+ }
+}
+
+impl Deref for ArcMacawMessage {
+ type Target = StateListener<Uuid, ArcStore<Message>>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.message
+ }
+}
+
+impl DerefMut for ArcMacawMessage {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.message
+ }
+}
diff --git a/src/message_subscriptions.rs b/src/message_subscriptions.rs
new file mode 100644
index 0000000..eebbef3
--- /dev/null
+++ b/src/message_subscriptions.rs
@@ -0,0 +1,89 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::collections::HashMap;
+
+use jid::BareJID;
+use tokio::sync::mpsc::{self, Receiver};
+use uuid::Uuid;
+
+use crate::message::{ArcMacawMessage, MacawMessage};
+
+pub struct MessageSubscriptions {
+ all: HashMap<Uuid, mpsc::Sender<(BareJID, ArcMacawMessage)>>,
+ subset: HashMap<BareJID, HashMap<Uuid, mpsc::Sender<ArcMacawMessage>>>,
+}
+
+impl MessageSubscriptions {
+ pub fn new() -> Self {
+ Self {
+ all: HashMap::new(),
+ subset: HashMap::new(),
+ }
+ }
+
+ pub async fn broadcast(&mut self, to: BareJID, message: ArcMacawMessage) {
+ // subscriptions to all
+ let mut removals = Vec::new();
+ for (id, sender) in &self.all {
+ match sender.send((to.clone(), message.clone())).await {
+ Ok(_) => {}
+ Err(_) => {
+ removals.push(*id);
+ }
+ }
+ }
+ for removal in removals {
+ self.all.remove(&removal);
+ }
+
+ // subscriptions to specific chat
+ if let Some(subscribers) = self.subset.get_mut(&to) {
+ let mut removals = Vec::new();
+ for (id, sender) in &*subscribers {
+ match sender.send(message.clone()).await {
+ Ok(_) => {}
+ Err(_) => {
+ removals.push(*id);
+ }
+ }
+ }
+ for removal in removals {
+ subscribers.remove(&removal);
+ }
+ if subscribers.is_empty() {
+ self.subset.remove(&to);
+ }
+ }
+ }
+
+ pub fn subscribe_all(&mut self) -> (Uuid, Receiver<(BareJID, ArcMacawMessage)>) {
+ let (send, recv) = mpsc::channel(10);
+ let id = Uuid::new_v4();
+ self.all.insert(id, send);
+ (id, recv)
+ }
+
+ pub fn subscribe_chat(&mut self, chat: BareJID) -> (Uuid, Receiver<ArcMacawMessage>) {
+ let (send, recv) = mpsc::channel(10);
+ let id = Uuid::new_v4();
+ if let Some(chat_subscribers) = self.subset.get_mut(&chat) {
+ chat_subscribers.insert(id, send);
+ } else {
+ let hash_map = HashMap::from([(id, send)]);
+ self.subset.insert(chat, hash_map);
+ }
+ (id, recv)
+ }
+
+ pub fn unsubscribe_all(&mut self, sub_id: Uuid) {
+ self.all.remove(&sub_id);
+ }
+
+ pub fn unsubscribe_chat(&mut self, sub_id: Uuid, chat: BareJID) {
+ if let Some(chat_subs) = self.subset.get_mut(&chat) {
+ chat_subs.remove(&sub_id);
+ }
+ }
+}
diff --git a/src/message_view.rs b/src/message_view.rs
deleted file mode 100644
index 16e8ac1..0000000
--- a/src/message_view.rs
+++ /dev/null
@@ -1,225 +0,0 @@
-use std::borrow::Cow;
-
-use chrono::NaiveDate;
-use iced::{
- alignment::Horizontal::{self, Right},
- border::Radius,
- color,
- theme::Palette,
- widget::{
- button, column, container, horizontal_space, row, scrollable, text, text_editor,
- text_editor::Content, text_input, Column,
- },
- Border, Color, Element,
- Length::{Fill, Shrink},
- Theme,
-};
-use indexmap::IndexMap;
-use jid::JID;
-use luz::chat::Message as ChatMessage;
-use serde::{Deserialize, Serialize};
-use uuid::Uuid;
-
-pub struct MessageView {
- pub config: Config,
- pub jid: JID,
- pub message_history: IndexMap<Uuid, ChatMessage>,
- pub new_message: Content,
- pub shift_pressed: bool,
-}
-
-#[derive(Serialize, Deserialize, Clone)]
-pub struct Config {
- pub send_on_enter: bool,
-}
-
-impl Default for Config {
- fn default() -> Self {
- Self {
- send_on_enter: true,
- }
- }
-}
-
-#[derive(Debug, Clone)]
-pub enum Message {
- MessageHistory(Vec<ChatMessage>),
- Message(ChatMessage),
- MessageCompose(text_editor::Action),
- SendMessage(String),
-}
-
-pub enum Action {
- None,
- SendMessage(String),
-}
-
-impl MessageView {
- pub fn new(jid: JID, config: &super::Config) -> Self {
- Self {
- jid,
- // TODO: save position in message history
- message_history: IndexMap::new(),
- // TODO: save draft (as part of chat struct?)
- new_message: Content::new(),
- config: config.message_view_config.clone(),
- // TODO: have centralised modifier state location?
- shift_pressed: false,
- }
- }
-
- pub fn update(&mut self, message: Message) -> Action {
- match message {
- Message::MessageHistory(messages) => {
- if self.message_history.is_empty() {
- self.message_history = messages
- .into_iter()
- .map(|message| (message.id.clone(), message))
- .collect();
- }
- Action::None
- }
- Message::Message(message) => {
- let i = self
- .message_history
- .iter()
- .position(|(_id, m)| m.timestamp > message.timestamp);
- if let Some(i) = i {
- self.message_history.insert_before(i, message.id, message);
- } else {
- self.message_history.insert(message.id, message);
- }
- Action::None
- }
- Message::MessageCompose(a) => {
- match &a {
- text_editor::Action::Edit(edit) => match edit {
- text_editor::Edit::Enter => {
- if self.config.send_on_enter {
- if !self.shift_pressed {
- let message = self.new_message.text();
- self.new_message = Content::new();
- return Action::SendMessage(message);
- }
- } else {
- if self.shift_pressed {
- let message = self.new_message.text();
- self.new_message = Content::new();
- return Action::SendMessage(message);
- }
- }
- }
- _ => {}
- },
- _ => {}
- }
- self.new_message.perform(a);
- Action::None
- }
- Message::SendMessage(m) => {
- self.new_message = Content::new();
- Action::SendMessage(m)
- }
- }
- }
-
- pub fn view(&self) -> Element<Message> {
- let mut messages_view = column![].spacing(8).padding(8);
- let mut latest_date = NaiveDate::MIN;
- for (_id, message) in &self.message_history {
- let message_date = message.timestamp.naive_local().date();
- if message_date > latest_date {
- latest_date = message_date;
- messages_view = messages_view.push(date(latest_date));
- }
- messages_view = messages_view.push(self.message(message));
- }
- let text_editor = text_editor(&self.new_message)
- .placeholder("new message")
- .on_action(Message::MessageCompose)
- .wrapping(text::Wrapping::WordOrGlyph);
- let message_send_input = row![
- text_editor,
- button("send").on_press(Message::SendMessage(self.new_message.text()))
- ]
- .padding(8);
- column![
- scrollable(messages_view)
- .height(Fill)
- .width(Fill)
- .spacing(1)
- .anchor_bottom(),
- message_send_input
- ]
- .into()
- }
-
- pub fn message<'a>(&'a self, message: &'a ChatMessage) -> Element<'a, Message> {
- let timestamp = message.timestamp.naive_local();
- let timestamp = timestamp.time().format("%H:%M").to_string();
-
- if self.jid == message.from.as_bare() {
- container(
- container(
- column![
- text(message.body.body.as_str()).wrapping(text::Wrapping::WordOrGlyph),
- container(text(timestamp).wrapping(text::Wrapping::None).size(12)) // .align_right(Fill)
- ]
- .width(Shrink)
- .max_width(500),
- )
- .padding(16)
- .style(|theme: &Theme| {
- let palette = theme.extended_palette();
- container::Style::default()
- .background(palette.primary.weak.color)
- .border(Border {
- color: Color::BLACK,
- width: 0.,
- // width: 4.,
- radius: Radius::new(16),
- })
- }),
- )
- .align_left(Fill)
- .into()
- } else {
- let element: Element<Message> = container(
- container(
- column![
- text(message.body.body.as_str()).wrapping(text::Wrapping::WordOrGlyph),
- container(text(timestamp).wrapping(text::Wrapping::None).size(12))
- .align_right(Fill) // row![
- // // horizontal_space(),
- // // horizontal_space(),
- // text(timestamp).wrapping(text::Wrapping::None).size(12)
- // ] // container(text(timestamp).wrapping(text::Wrapping::None).size(12))
- // .align_right(Fill)
- ]
- .width(Shrink)
- .max_width(500),
- )
- .padding(16)
- .style(|theme: &Theme| {
- let palette = theme.extended_palette();
- container::Style::default()
- .background(palette.primary.base.color)
- .border(Border {
- color: Color::BLACK,
- width: 0.,
- // width: 4.,
- radius: Radius::new(16),
- })
- }),
- )
- .align_right(Fill)
- .into();
- // element.explain(Color::BLACK)
- element
- }
- }
-}
-
-pub fn date(date: NaiveDate) -> Element<'static, Message> {
- container(text(date.to_string())).center_x(Fill).into()
-}
diff --git a/src/open_chats.rs b/src/open_chats.rs
new file mode 100644
index 0000000..bf2eb73
--- /dev/null
+++ b/src/open_chats.rs
@@ -0,0 +1,72 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use filamento::chat::ChatStoreFields;
+use indexmap::IndexMap;
+use jid::BareJID;
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+use tracing::debug;
+
+use crate::chat::{ArcMacawChat, MacawChat};
+
+#[derive(Store, Default)]
+pub struct OpenChatsPanel {
+ // jid must be a chat in the chats map
+ chat_view: Option<BareJID>,
+ #[store(key: BareJID = |(jid, _)| jid.clone())]
+ chats: IndexMap<BareJID, ArcMacawChat>,
+}
+
+pub fn open_chat(open_chats: Store<OpenChatsPanel>, chat: ArcMacawChat) {
+ if let Some(jid) = &*open_chats.chat_view().read() {
+ if let Some((index, _jid, entry)) = open_chats.chats().write().shift_remove_full(jid) {
+ let new_jid = chat.get().correspondent().read().clone();
+ open_chats
+ .chats()
+ .write()
+ .insert_before(index, new_jid.clone(), chat);
+ *open_chats.chat_view().write() = Some(new_jid);
+ } else {
+ let new_jid = chat.get().correspondent().read().clone();
+ open_chats.chats().write().insert(new_jid.clone(), chat);
+ *open_chats.chat_view().write() = Some(new_jid);
+ }
+ } else {
+ let new_jid = chat.get().correspondent().read().clone();
+ open_chats.chats().write().insert(new_jid.clone(), chat);
+ *open_chats.chat_view().write() = Some(new_jid);
+ }
+}
+
+impl OpenChatsPanel {
+ pub fn open(&mut self, chat: ArcMacawChat) {
+ if let Some(jid) = &mut self.chat_view {
+ debug!("a chat was already open");
+ if let Some((index, _jid, entry)) = self.chats.shift_remove_full(jid) {
+ let new_jid = chat.get().correspondent().read().clone();
+ self.chats.insert_before(index, new_jid.clone(), chat);
+ *&mut self.chat_view = Some(new_jid);
+ } else {
+ let new_jid = chat.get().correspondent().read().clone();
+ self.chats.insert(new_jid.clone(), chat);
+ *&mut self.chat_view = Some(new_jid);
+ }
+ } else {
+ let new_jid = chat.get().correspondent().read().clone();
+ self.chats.insert(new_jid.clone(), chat);
+ *&mut self.chat_view = Some(new_jid);
+ }
+ debug!("opened chat");
+ }
+
+ // TODO:
+ // pub fn open_in_new_tab_unfocused(&mut self) {
+
+ // }
+
+ // pub fn open_in_new_tab_focus(&mut self) {
+
+ // }
+}
diff --git a/src/roster.rs b/src/roster.rs
new file mode 100644
index 0000000..13aed19
--- /dev/null
+++ b/src/roster.rs
@@ -0,0 +1,24 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::collections::HashMap;
+
+use jid::BareJID;
+use reactive_stores::Store;
+
+use crate::contact::MacawContact;
+
+#[derive(Store, Clone)]
+pub struct Roster {
+ #[store(key: BareJID = |(jid, _)| jid.clone())]
+ contacts: HashMap<BareJID, MacawContact>,
+}
+
+impl Roster {
+ pub fn new() -> Self {
+ Self {
+ contacts: HashMap::new(),
+ }
+ }
+}
diff --git a/src/state_store.rs b/src/state_store.rs
new file mode 100644
index 0000000..1e67f34
--- /dev/null
+++ b/src/state_store.rs
@@ -0,0 +1,270 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::{
+ collections::HashMap,
+ ops::{Deref, DerefMut},
+ sync::{Arc, RwLock},
+};
+
+use leptos::prelude::*;
+use tracing::debug;
+
+// TODO: get rid of this
+// V has to be an arc signal
+#[derive(Debug)]
+pub struct ArcStateStore<K, V> {
+ store: Arc<RwLock<HashMap<K, (ArcRwSignal<V>, usize)>>>,
+}
+
+impl<K, V> PartialEq for ArcStateStore<K, V> {
+ fn eq(&self, other: &Self) -> bool {
+ Arc::ptr_eq(&self.store, &other.store)
+ }
+}
+
+impl<K, V> Clone for ArcStateStore<K, V> {
+ fn clone(&self) -> Self {
+ Self {
+ store: Arc::clone(&self.store),
+ }
+ }
+}
+
+impl<K, V> Eq for ArcStateStore<K, V> {}
+
+impl<K, V> ArcStateStore<K, V> {
+ pub fn new() -> Self {
+ Self {
+ store: Arc::new(RwLock::new(HashMap::new())),
+ }
+ }
+}
+
+#[derive(Debug)]
+pub struct StateStore<K, V, S = SyncStorage> {
+ inner: ArenaItem<ArcStateStore<K, V>, S>,
+}
+
+impl<K, V, S> Dispose for StateStore<K, V, S> {
+ fn dispose(self) {
+ self.inner.dispose()
+ }
+}
+
+impl<K, V> StateStore<K, V>
+where
+ K: Send + Sync + 'static,
+ V: Send + Sync + 'static,
+{
+ pub fn new() -> Self {
+ Self::new_with_storage()
+ }
+}
+
+impl<K, V, S> StateStore<K, V, S>
+where
+ K: 'static,
+ V: 'static,
+ S: Storage<ArcStateStore<K, V>>,
+{
+ pub fn new_with_storage() -> Self {
+ Self {
+ inner: ArenaItem::new_with_storage(ArcStateStore::new()),
+ }
+ }
+}
+
+impl<K, V> StateStore<K, V, LocalStorage>
+where
+ K: 'static,
+ V: 'static,
+{
+ pub fn new_local() -> Self {
+ Self::new_with_storage()
+ }
+}
+
+impl<
+ K: std::marker::Send + std::marker::Sync + 'static,
+ V: std::marker::Send + std::marker::Sync + 'static,
+> From<ArcStateStore<K, V>> for StateStore<K, V>
+{
+ fn from(value: ArcStateStore<K, V>) -> Self {
+ Self {
+ inner: ArenaItem::new_with_storage(value),
+ }
+ }
+}
+
+impl<K: 'static, V: 'static> FromLocal<ArcStateStore<K, V>> for StateStore<K, V, LocalStorage> {
+ fn from_local(value: ArcStateStore<K, V>) -> Self {
+ Self {
+ inner: ArenaItem::new_with_storage(value),
+ }
+ }
+}
+
+impl<K, V, S> Copy for StateStore<K, V, S> {}
+
+impl<K, V, S> Clone for StateStore<K, V, S> {
+ fn clone(&self) -> Self {
+ *self
+ }
+}
+
+impl<K: Eq + std::hash::Hash + Clone + std::fmt::Debug, V: Clone + std::fmt::Debug> StateStore<K, V>
+where
+ K: Send + Sync + 'static,
+ V: Send + Sync + 'static,
+{
+ pub fn store(&self, key: K, value: V) -> StateListener<K, V> {
+ let arc_store = self.inner.try_get_value().unwrap();
+ let store = arc_store.store.clone();
+ let mut store = store.write().unwrap();
+ debug!("store state: {:?}", store);
+ if let Some((v, count)) = store.get_mut(&key) {
+ debug!("updating old value already in store");
+ v.set(value);
+ *count += 1;
+ StateListener {
+ value: v.clone(),
+ cleaner: StateCleaner {
+ key,
+ state_store: arc_store,
+ },
+ }
+ } else {
+ let v = ArcRwSignal::new(value);
+ store.insert(key.clone(), (v.clone(), 1));
+ debug!("inserting new value: {:?}", store);
+ StateListener {
+ value: v.into(),
+ cleaner: StateCleaner {
+ key,
+ state_store: arc_store,
+ },
+ }
+ }
+ }
+
+ pub fn get_listener(&self, key: K) -> Option<StateListener<K, V>> {
+ let arc_store = self.inner.try_get_value().unwrap();
+ let store = arc_store.store.clone();
+ let mut store = store.write().unwrap();
+ debug!("store state: {:?}", store);
+ if let Some((v, count)) = store.get_mut(&key) {
+ *count += 1;
+ Some(StateListener {
+ value: v.clone(),
+ cleaner: StateCleaner {
+ key,
+ state_store: arc_store,
+ },
+ })
+ } else {
+ None
+ }
+ }
+}
+
+impl<K, V> StateStore<K, V>
+where
+ K: Eq + std::hash::Hash + Send + Sync + 'static,
+ V: Send + Sync + 'static,
+{
+ pub fn update(&self, key: &K, value: V) {
+ let store = self.inner.try_get_value().unwrap();
+ let mut store = store.store.write().unwrap();
+ if let Some((v, _)) = store.get_mut(key) {
+ v.set(value)
+ }
+ }
+
+ pub fn modify(&self, key: &K, modify: impl Fn(&mut V)) {
+ let store = self.inner.try_get_value().unwrap();
+ let mut store = store.store.write().unwrap();
+ if let Some((v, _)) = store.get_mut(key) {
+ v.update(|v| modify(v));
+ }
+ }
+}
+
+#[derive(Clone)]
+pub struct StateListener<K, V>
+where
+ K: Eq + std::hash::Hash + 'static + std::marker::Send + std::marker::Sync,
+ V: 'static + std::marker::Send + std::marker::Sync,
+{
+ value: ArcRwSignal<V>,
+ cleaner: StateCleaner<K, V>,
+}
+
+impl<
+ K: std::cmp::Eq + std::hash::Hash + std::marker::Send + std::marker::Sync,
+ V: std::marker::Send + std::marker::Sync,
+> Deref for StateListener<K, V>
+{
+ type Target = ArcRwSignal<V>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.value
+ }
+}
+
+impl<K: std::cmp::Eq + std::hash::Hash + Send + Sync, V: Send + Sync> DerefMut
+ for StateListener<K, V>
+{
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.value
+ }
+}
+
+struct ArcStateCleaner<K, V> {
+ key: K,
+ state_store: ArcStateStore<K, V>,
+}
+
+struct StateCleaner<K, V>
+where
+ K: Eq + std::hash::Hash + Send + Sync + 'static,
+ V: Send + Sync + 'static,
+{
+ key: K,
+ state_store: ArcStateStore<K, V>,
+}
+
+impl<K, V> Clone for StateCleaner<K, V>
+where
+ K: Eq + std::hash::Hash + Clone + Send + Sync,
+ V: Send + Sync,
+{
+ fn clone(&self) -> Self {
+ {
+ let mut store = self.state_store.store.write().unwrap();
+ if let Some((_v, count)) = store.get_mut(&self.key) {
+ *count += 1;
+ }
+ }
+ Self {
+ key: self.key.clone(),
+ state_store: self.state_store.clone(),
+ }
+ }
+}
+
+impl<K: Eq + std::hash::Hash + Send + Sync + 'static, V: Send + Sync + 'static> Drop
+ for StateCleaner<K, V>
+{
+ fn drop(&mut self) {
+ let mut store = self.state_store.store.write().unwrap();
+ if let Some((_v, count)) = store.get_mut(&self.key) {
+ *count -= 1;
+ if *count == 0 {
+ store.remove(&self.key);
+ debug!("dropped item from store");
+ }
+ }
+ }
+}
diff --git a/src/user.rs b/src/user.rs
new file mode 100644
index 0000000..e277efd
--- /dev/null
+++ b/src/user.rs
@@ -0,0 +1,153 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::ops::{Deref, DerefMut};
+
+use filamento::user::{User, UserStoreFields};
+use jid::BareJID;
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+
+use crate::{
+ client::Client,
+ roster::{Roster, RosterStoreFields},
+ state_store::{StateListener, StateStore},
+};
+
+#[derive(Clone, Copy)]
+pub struct MacawUser {
+ pub user: ArenaItem<ArcMacawUser>,
+ // TODO: just store avatar src in user
+ // pub avatar: String,
+ // pub avatar: RwSignal<String>,
+}
+
+impl MacawUser {
+ pub fn get(&self) -> ArcStore<User> {
+ self.try_get_value().unwrap().get().0
+ }
+
+ pub fn avatar(&self) -> ArcRwSignal<String> {
+ self.try_get_value().unwrap().get().1
+ }
+}
+
+impl Deref for MacawUser {
+ type Target = ArenaItem<ArcMacawUser>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.user
+ }
+}
+
+impl DerefMut for MacawUser {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.user
+ }
+}
+
+impl From<ArcMacawUser> for MacawUser {
+ fn from(value: ArcMacawUser) -> Self {
+ Self {
+ user: ArenaItem::new_with_storage(value),
+ // avatar: value.avatar.into(),
+ }
+ }
+}
+
+impl From<MacawUser> for ArcMacawUser {
+ fn from(value: MacawUser) -> Self {
+ value.user.try_get_value().unwrap()
+ }
+}
+
+#[derive(Clone)]
+pub struct ArcMacawUser {
+ pub user: StateListener<BareJID, (ArcStore<User>, ArcRwSignal<String>)>,
+}
+
+impl ArcMacawUser {
+ pub async fn got_user(user: User) -> Self {
+ let user_state_store: StateStore<BareJID, (ArcStore<User>, ArcRwSignal<String>)> =
+ use_context().expect("no user state store");
+ let old_user = user_state_store.get_listener(user.jid.clone());
+ let user = if let Some(old_user) = old_user {
+ old_user.update(|(old_user, _avatar)| {
+ old_user.set(user);
+ });
+ old_user
+ } else {
+ let avatar = fetch_avatar(user.avatar.as_deref()).await;
+ let avatar = ArcRwSignal::new(avatar);
+ user_state_store.store(user.jid.clone(), (ArcStore::new(user), avatar))
+ };
+ let user = ArcMacawUser { user };
+ user
+ }
+}
+
+impl Deref for ArcMacawUser {
+ type Target = StateListener<BareJID, (ArcStore<User>, ArcRwSignal<String>)>;
+
+ fn deref(&self) -> &Self::Target {
+ &self.user
+ }
+}
+
+impl DerefMut for ArcMacawUser {
+ fn deref_mut(&mut self) -> &mut Self::Target {
+ &mut self.user
+ }
+}
+
+pub const NO_AVATAR: &str = "/assets/no-avatar.png";
+
+pub async fn fetch_avatar(id: Option<&str>) -> String {
+ if let Some(avatar) = id {
+ let client = use_context::<Client>().expect("client not in context");
+ if let Some(data) = client.file_store.get_src(avatar).await {
+ data
+ } else {
+ NO_AVATAR.to_string()
+ }
+ } else {
+ NO_AVATAR.to_string()
+ }
+}
+
+pub async fn get_avatar(user: Store<User>) -> String {
+ if let Some(avatar) = &user.read().avatar {
+ let client = use_context::<Client>().expect("client not in context");
+ if let Some(data) = client.file_store.get_src(avatar).await {
+ data
+ } else {
+ NO_AVATAR.to_string()
+ }
+ } else {
+ NO_AVATAR.to_string()
+ }
+}
+
+pub fn get_name(user: Store<User>, note_to_self: bool) -> String {
+ let roster: Store<Roster> = use_context().expect("no roster in context");
+ if note_to_self {
+ let client: Client = use_context().expect("no client in context");
+ if *client.jid == *user.jid().read() {
+ return "Note to self".to_string();
+ }
+ }
+ if let Some(name) = roster
+ .contacts()
+ .read()
+ .get(&user.read().jid)
+ .map(|contact| contact.read().name.clone())
+ .unwrap_or_default()
+ {
+ name.to_string()
+ } else if let Some(nick) = &user.read().nick {
+ nick.to_string()
+ } else {
+ user.read().jid.to_string()
+ }
+}
diff --git a/src/user_presences.rs b/src/user_presences.rs
new file mode 100644
index 0000000..87f9bdc
--- /dev/null
+++ b/src/user_presences.rs
@@ -0,0 +1,173 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::collections::HashMap;
+
+use chrono::Utc;
+use filamento::presence::{Offline, Presence, PresenceType, Show};
+use indexmap::IndexMap;
+use jid::BareJID;
+use leptos::prelude::*;
+use reactive_stores::Store;
+
+#[derive(Store)]
+pub struct UserPresences {
+ #[store(key: BareJID = |(jid, _)| jid.clone())]
+ pub user_presences: HashMap<BareJID, ArcRwSignal<Presences>>,
+}
+
+impl UserPresences {
+ pub fn clear(&mut self) {
+ for (_user, presences) in &mut self.user_presences {
+ presences.set(Presences::new())
+ }
+ }
+
+ // TODO: should be a bare jid
+ pub fn get_user_presences(&mut self, user: &BareJID) -> ArcRwSignal<Presences> {
+ if let Some(presences) = self.user_presences.get(user) {
+ presences.clone()
+ } else {
+ let presences = Presences::new();
+ let signal = ArcRwSignal::new(presences);
+ self.user_presences.insert(user.clone(), signal.clone());
+ signal
+ }
+ }
+}
+
+impl UserPresences {
+ pub fn new() -> Self {
+ Self {
+ user_presences: HashMap::new(),
+ }
+ }
+}
+
+pub struct Presences {
+ /// presences are sorted by time, first by type, then by last activity.
+ presences: IndexMap<String, Presence>,
+}
+
+impl Presences {
+ pub fn new() -> Self {
+ Self {
+ presences: IndexMap::new(),
+ }
+ }
+
+ /// gets the highest priority presence
+ pub fn presence(&self) -> Option<(String, Presence)> {
+ if let Some((resource, presence)) = self
+ .presences
+ .iter()
+ .filter(|(_resource, presence)| {
+ if let PresenceType::Online(online) = &presence.presence {
+ online.show == Some(Show::DoNotDisturb)
+ } else {
+ false
+ }
+ })
+ .next()
+ {
+ return Some((resource.clone(), presence.clone()));
+ }
+ if let Some((resource, presence)) = self
+ .presences
+ .iter()
+ .filter(|(_resource, presence)| {
+ if let PresenceType::Online(online) = &presence.presence {
+ online.show == Some(Show::Chat)
+ } else {
+ false
+ }
+ })
+ .next()
+ {
+ return Some((resource.clone(), presence.clone()));
+ }
+ if let Some((resource, presence)) = self
+ .presences
+ .iter()
+ .filter(|(_resource, presence)| {
+ if let PresenceType::Online(online) = &presence.presence {
+ online.show == None
+ } else {
+ false
+ }
+ })
+ .next()
+ {
+ return Some((resource.clone(), presence.clone()));
+ }
+ if let Some((resource, presence)) = self
+ .presences
+ .iter()
+ .filter(|(_resource, presence)| {
+ if let PresenceType::Online(online) = &presence.presence {
+ online.show == Some(Show::Away)
+ } else {
+ false
+ }
+ })
+ .next()
+ {
+ return Some((resource.clone(), presence.clone()));
+ }
+ if let Some((resource, presence)) = self
+ .presences
+ .iter()
+ .filter(|(_resource, presence)| {
+ if let PresenceType::Online(online) = &presence.presence {
+ online.show == Some(Show::ExtendedAway)
+ } else {
+ false
+ }
+ })
+ .next()
+ {
+ return Some((resource.clone(), presence.clone()));
+ }
+ if let Some((resource, presence)) = self
+ .presences
+ .iter()
+ .filter(|(_resource, presence)| {
+ if let PresenceType::Offline(_offline) = &presence.presence {
+ true
+ } else {
+ false
+ }
+ })
+ .next()
+ {
+ return Some((resource.clone(), presence.clone()));
+ } else {
+ None
+ }
+ }
+
+ pub fn update_presence(&mut self, resource: String, presence: Presence) {
+ let index = match self.presences.binary_search_by(|_, existing_presence| {
+ presence.timestamp.cmp(&existing_presence.timestamp)
+ }) {
+ Ok(i) => i,
+ Err(i) => i,
+ };
+ self.presences.insert_before(
+ // TODO: check if this logic is correct
+ index, resource, presence,
+ );
+ }
+
+ pub fn resource_presence(&mut self, resource: String) -> Presence {
+ if let Some(presence) = self.presences.get(&resource) {
+ presence.clone()
+ } else {
+ Presence {
+ timestamp: Utc::now(),
+ presence: PresenceType::Offline(Offline::default()),
+ }
+ }
+ }
+}
diff --git a/src/views/login_page.rs b/src/views/login_page.rs
new file mode 100644
index 0000000..d1bb29a
--- /dev/null
+++ b/src/views/login_page.rs
@@ -0,0 +1,202 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::{str::FromStr, sync::Arc};
+
+use filamento::{
+ db::Db, error::{CommandError, ConnectionError, DatabaseOpenError}, files::{opfs::OPFSError, FilesMem, FilesOPFS}, UpdateMessage
+};
+use jid::JID;
+use leptos::prelude::*;
+use thiserror::Error;
+use tokio::sync::mpsc::Receiver;
+use tracing::debug;
+
+use crate::{client::Client, files::Files};
+
+use super::AppState;
+
+#[derive(Clone, Debug, Error)]
+pub enum LoginError {
+ #[error("Missing Password")]
+ MissingPassword,
+ #[error("Missing JID")]
+ MissingJID,
+ #[error("Invalid JID: {0}")]
+ InvalidJID(#[from] jid::ParseError),
+ #[error("Connection Error: {0}")]
+ ConnectionError(#[from] CommandError<ConnectionError>),
+ #[error("Failed to open database: {0}")]
+ DatabaseOpen(#[from] DatabaseOpenError),
+ #[error("OPFS: {0}")]
+ OPFS(#[from] OPFSError),
+}
+
+#[component]
+pub fn LoginPage(
+ set_app: WriteSignal<AppState>,
+ set_client: WriteSignal<Option<(Client, Receiver<UpdateMessage>)>>,
+) -> impl IntoView {
+ let jid = RwSignal::new("".to_string());
+ let password = RwSignal::new("".to_string());
+ let remember_me = RwSignal::new(false);
+ let connect_on_login = RwSignal::new(true);
+
+ let (error, set_error) = signal(None::<LoginError>);
+ let error_message = move || {
+ error.with(|error| {
+ if let Some(error) = error {
+ view! { <div class="error">{error.to_string()}</div> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ })
+ };
+
+ let (login_pending, set_login_pending) = signal(false);
+
+ let login = Action::new_local(move |_| {
+ async move {
+ set_login_pending.set(true);
+
+ if jid.read_untracked().is_empty() {
+ set_error.set(Some(LoginError::MissingJID));
+ set_login_pending.set(false);
+ return;
+ }
+
+ if password.read_untracked().is_empty() {
+ set_error.set(Some(LoginError::MissingPassword));
+ set_login_pending.set(false);
+ return;
+ }
+
+ let jid = match JID::from_str(&jid.read_untracked()) {
+ Ok(j) => j,
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_login_pending.set(false);
+ return;
+ }
+ };
+
+ let remember_me = remember_me.get_untracked();
+ // initialise the client
+ let db = if remember_me {
+ debug!("creating db in opfs");
+ Db::create_connect_and_migrate(jid.as_bare().to_string())
+ .await
+ } else {
+ debug!("creating db in memory");
+ Db::create_connect_and_migrate_memory().await
+ };
+ let db = match db {
+ Ok(db) => db,
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_login_pending.set(false);
+ return;
+ }
+ };
+ let files = if remember_me {
+ let opfs = FilesOPFS::new(jid.as_bare().to_string()).await;
+ match opfs {
+ Ok(f) => Files::Opfs(f),
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_login_pending.set(false);
+ return;
+ }
+ }
+ } else {
+ Files::Mem(FilesMem::new())
+ };
+ let (client, updates) = filamento::Client::new(
+ jid.clone(),
+ password.read_untracked().clone(),
+ db,
+ files.clone(),
+ );
+ let resource = ArcRwSignal::new(None::<String>);
+ let client = Client {
+ client,
+ resource: resource.clone(),
+ jid: Arc::new(jid.to_bare()),
+ file_store: files,
+ };
+
+ if *connect_on_login.read_untracked() {
+ match client.connect().await {
+ Ok(r) => resource.set(Some(r)),
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_login_pending.set(false);
+ return;
+ }
+ }
+ }
+
+ // debug!("before setting app state");
+ set_client.set(Some((client, updates)));
+ set_app.set(AppState::LoggedIn);
+ }
+ });
+
+ view! {
+ <div class="center fill">
+ <div id="login-form" class="panel">
+ <div id="hero">
+ <img src="/assets/macaw-icon.png" />
+ <h1>Macaw Instant Messenger</h1>
+ </div>
+ {error_message}
+ <form on:submit=move |ev| {
+ ev.prevent_default();
+ login.dispatch(());
+ }>
+ <label for="jid">JID</label>
+ <input
+ disabled=login_pending
+ placeholder="caw@macaw.chat"
+ type="text"
+ bind:value=jid
+ name="jid"
+ id="jid"
+ autofocus="true"
+ />
+ <label for="password">Password</label>
+ <input
+ disabled=login_pending
+ placeholder="••••••••"
+ type="password"
+ bind:value=password
+ name="password"
+ id="password"
+ />
+ <div>
+ <label for="remember_me">Remember me</label>
+ <input
+ disabled=login_pending
+ type="checkbox"
+ bind:checked=remember_me
+ name="remember_me"
+ id="remember_me"
+ />
+ </div>
+ <div>
+ <label for="connect_on_login">Connect on login</label>
+ <input
+ disabled=login_pending
+ type="checkbox"
+ bind:checked=connect_on_login
+ name="connect_on_login"
+ id="connect_on_login"
+ />
+ </div>
+ <input disabled=login_pending class="button" type="submit" value="Log In" />
+ </form>
+ </div>
+ </div>
+ }
+}
diff --git a/src/views/macaw.rs b/src/views/macaw.rs
new file mode 100644
index 0000000..e91e08a
--- /dev/null
+++ b/src/views/macaw.rs
@@ -0,0 +1,199 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::collections::{HashMap, HashSet};
+
+use filamento::{
+ UpdateMessage,
+ chat::{Chat, Message, MessageStoreFields},
+ user::User,
+};
+use jid::BareJID;
+use leptos::{prelude::*, task::spawn_local};
+use open_chats_panel::OpenChatsPanelView;
+use reactive_stores::{ArcStore, Store};
+use settings::{Settings, SettingsPage};
+use tokio::sync::mpsc::Receiver;
+use tracing::debug;
+use uuid::Uuid;
+
+use crate::{
+ client::Client,
+ components::sidebar::Sidebar,
+ contact::{ArcMacawContact, MacawContact},
+ message::{ArcMacawMessage, MacawMessage},
+ message_subscriptions::MessageSubscriptions,
+ open_chats::OpenChatsPanel,
+ roster::{Roster, RosterStoreFields},
+ state_store::StateStore,
+ user::{ArcMacawUser, MacawUser, fetch_avatar},
+ user_presences::{Presences, UserPresences},
+};
+
+use super::AppState;
+
+mod open_chats_panel;
+pub mod settings;
+
+#[component]
+pub fn Macaw(
+ // TODO: logout
+ // app_state: WriteSignal<Option<essage>)>, LocalStorage>,
+ client: Client,
+ mut updates: Receiver<UpdateMessage>,
+ set_app: WriteSignal<AppState>,
+) -> impl IntoView {
+ let (updates, set_updates) = signal(Some(updates));
+ provide_context(set_app);
+ provide_context(client);
+
+ let roster = Store::new(Roster::new());
+ provide_context(roster);
+
+ let message_subscriptions = RwSignal::new(MessageSubscriptions::new());
+ provide_context(message_subscriptions);
+
+ let messages_store: StateStore<Uuid, ArcStore<Message>> = StateStore::new();
+ provide_context(messages_store);
+ let chats_store: StateStore<BareJID, ArcStore<Chat>> = StateStore::new();
+ provide_context(chats_store);
+ let users_store: StateStore<BareJID, (ArcStore<User>, ArcRwSignal<String>)> = StateStore::new();
+ provide_context(users_store);
+
+ let open_chats = Store::new(OpenChatsPanel::default());
+ provide_context(open_chats);
+ let show_settings = RwSignal::new(None::<SettingsPage>);
+ provide_context(show_settings);
+
+ let user_presences = Store::new(UserPresences::new());
+ provide_context(user_presences);
+
+ let client_user: LocalResource<MacawUser> = LocalResource::new(move || async move {
+ let client = use_context::<Client>().expect("client not in context");
+ let user = client.get_user((*client.jid).clone()).await.unwrap();
+ ArcMacawUser::got_user(user).await.into()
+ });
+ provide_context(client_user);
+
+ // TODO: timestamp incoming/outgoing subscription requests
+ let (subscription_requests, set_subscription_requests) = signal(HashSet::<BareJID>::new());
+ provide_context(subscription_requests);
+ provide_context(set_subscription_requests);
+
+ // TODO: get cached contacts on login before getting the updated contacts
+
+ LocalResource::new(move || async move {
+ let mut updates = set_updates.write().take().expect("main loop ran twice");
+ while let Some(update) = updates.recv().await {
+ match update {
+ UpdateMessage::Online(online, items) => {
+ let mut contacts = HashMap::new();
+ for (contact, user) in items {
+ contacts.insert(
+ contact.user_jid.clone(),
+ ArcMacawContact::got_contact_and_user(contact, user)
+ .await
+ .into(),
+ );
+ }
+ roster.contacts().set(contacts);
+ }
+ UpdateMessage::Offline(offline) => {
+ // when offline, will no longer receive updated user presences, consider everybody offline.
+ user_presences.write().clear();
+ }
+ UpdateMessage::RosterUpdate(contact, user) => {
+ let new_contact = ArcMacawContact::got_contact_and_user(contact.clone(), user)
+ .await
+ .into();
+ roster.contacts().update(|roster| {
+ if let Some(macaw_contact) = roster.get_mut(&contact.user_jid) {
+ macaw_contact.set(contact);
+ } else {
+ let jid = contact.user_jid.clone();
+ roster.insert(jid, new_contact);
+ }
+ });
+ }
+ UpdateMessage::RosterDelete(jid) => {
+ roster.contacts().update(|roster| {
+ roster.remove(&jid);
+ });
+ }
+ UpdateMessage::Presence { from, presence } => {
+ let bare_jid = from.to_bare();
+ if let Some(presences) = user_presences
+ .read_untracked()
+ .user_presences
+ .get(&bare_jid)
+ {
+ if let Some(resource) = from.resourcepart() {
+ presences
+ .write()
+ .update_presence(resource.clone(), presence);
+ }
+ } else {
+ if let Some(resource) = from.resourcepart() {
+ let mut presences = Presences::new();
+ presences.update_presence(resource.clone(), presence);
+ user_presences
+ .write()
+ .user_presences
+ .insert(bare_jid, ArcRwSignal::new(presences));
+ }
+ }
+ }
+ UpdateMessage::Message { to, from, message } => {
+ debug!("before got message");
+ let new_message = ArcMacawMessage::got_message_and_user(message, from).await;
+ debug!("after got message");
+ spawn_local(async move {
+ message_subscriptions
+ .write()
+ .broadcast(to, new_message)
+ .await
+ });
+ debug!("after set message");
+ }
+ UpdateMessage::MessageDelivery { id, chat, delivery } => {
+ messages_store.modify(&id, |message| {
+ <ArcStore<filamento::chat::Message> as Clone>::clone(&message)
+ .delivery()
+ .set(Some(delivery))
+ });
+ }
+ UpdateMessage::SubscriptionRequest(jid) => {
+ set_subscription_requests.update(|req| {
+ req.insert(jid);
+ });
+ }
+ UpdateMessage::NickChanged { jid, nick } => {
+ users_store.modify(&jid, |(user, _avatar)| {
+ user.update(|user| *&mut user.nick = nick.clone())
+ });
+ }
+ UpdateMessage::AvatarChanged { jid, id } => {
+ let new_avatar = fetch_avatar(id.as_deref()).await;
+ users_store.modify(&jid, |(user, avatar)| {
+ *&mut user.write().avatar = id.clone();
+ *&mut avatar.set(new_avatar.clone())
+ });
+ }
+ }
+ }
+ });
+
+ view! {
+ <Sidebar />
+ // <ChatsList />
+ <OpenChatsPanelView />
+ {move || {
+ if let Some(_) = *show_settings.read() {
+ view! { <Settings /> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ }
+}
diff --git a/src/views/macaw/open_chats_panel.rs b/src/views/macaw/open_chats_panel.rs
new file mode 100644
index 0000000..375e8f3
--- /dev/null
+++ b/src/views/macaw/open_chats_panel.rs
@@ -0,0 +1,81 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use leptos::prelude::*;
+use open_chat::OpenChatView;
+use reactive_stores::{ArcStore, Store};
+
+use crate::open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields};
+
+// TODO: multiple panels
+// pub struct OpenChats {
+// panels:
+// }
+
+#[component]
+pub fn OpenChatsPanelView() -> impl IntoView {
+ let open_chats: Store<OpenChatsPanel> = use_context().expect("no open chats panel in context");
+
+ // TODO: tabs
+ // view! {
+ // {move || {
+ // if open_chats.chats().read().len() > 1 {
+ // Some(
+ // view! {
+ // <For
+ // each=move || open_chats.chats().get()
+ // key=|(jid, _)| jid.clone()
+ // let(chat)
+ // ></For>
+ // },
+ // )
+ // } else {
+ // None
+ // }
+ // }}
+ // }
+ view! {
+ <div class="open-chat-views">
+ {move || {
+ if let Some(open_chat) = open_chats.chat_view().get() {
+ if let Some(open_chat) = open_chats.chats().read().get(&open_chat) {
+ view! { <OpenChatView chat=open_chat.clone().into() /> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ }
+}
+
+mod open_chat {
+ use filamento::chat::{Chat, ChatStoreFields};
+ use leptos::prelude::*;
+ use reactive_stores::{ArcStore, Store};
+
+ use crate::{
+ chat::MacawChat,
+ components::{
+ chat_header::ChatViewHeader, message_composer::ChatViewMessageComposer,
+ message_history_buffer::MessageHistoryBuffer,
+ },
+ };
+
+ #[component]
+ pub fn OpenChatView(chat: MacawChat) -> impl IntoView {
+ view! {
+ <div class="open-chat-view">
+ <ChatViewHeader chat=chat.clone() />
+ <MessageHistoryBuffer chat=chat.clone() />
+ {move || {
+ let chat_jid = chat.get().correspondent().get();
+ view! { <ChatViewMessageComposer chat=chat_jid /> }
+ }}
+ </div>
+ }
+ }
+}
diff --git a/src/views/macaw/settings.rs b/src/views/macaw/settings.rs
new file mode 100644
index 0000000..7bdc2b9
--- /dev/null
+++ b/src/views/macaw/settings.rs
@@ -0,0 +1,391 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use leptos::prelude::*;
+use profile_settings::ProfileSettings;
+
+use crate::{
+ components::{icon::IconComponent, modal::Modal},
+ icon::Icon,
+};
+
+mod profile_settings {
+ use filamento::{
+ error::{AvatarPublishError, CommandError, NickError},
+ user::User,
+ };
+ use leptos::prelude::*;
+ use thiserror::Error;
+ use web_sys::{
+ Event, FileReader, HtmlInputElement, ProgressEvent, Url,
+ js_sys::Uint8Array,
+ wasm_bindgen::{JsCast, UnwrapThrowExt, prelude::Closure},
+ };
+
+ use crate::{
+ client::Client,
+ files::Files,
+ user::{NO_AVATAR, fetch_avatar},
+ };
+
+ #[derive(Debug, Clone, Error)]
+ pub enum ProfileSaveError {
+ #[error("avatar publish: {0}")]
+ Avatar(#[from] CommandError<AvatarPublishError<Files>>),
+ #[error("nick publish: {0}")]
+ Nick(#[from] CommandError<NickError>),
+ }
+
+ #[component]
+ pub fn ProfileSettings() -> impl IntoView {
+ let client: Client = use_context().expect("no client in context");
+
+ let old_profile = LocalResource::new(move || {
+ let value = client.clone();
+ async move {
+ // TODO: error
+ let jid = &*value.jid;
+ let old_profile = value.get_user(jid.clone()).await.unwrap();
+ old_profile
+ }
+ });
+
+ view! {
+ {move || {
+ if let Some(old_profile) = old_profile.get() {
+ view! { <ProfileForm old_profile /> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ }
+ }
+
+ #[component]
+ pub fn ProfileForm(old_profile: User) -> impl IntoView {
+ let client: Client = use_context().expect("no client in context");
+
+ // TODO: compartmentalise into error component, form component...
+ let (error, set_error) = signal(None::<ProfileSaveError>);
+ let error_message = move || {
+ error.with(|error| {
+ if let Some(error) = error {
+ view! { <div class="error">{error.to_string()}</div> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ })
+ };
+
+ let (success_message, set_success_message) = signal(None::<String>);
+ let success_message = move || {
+ if let Some(message) = success_message.get() {
+ view! { <div class="success">{message}</div> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ };
+
+ let (profile_save_pending, set_profile_save_pending) = signal(false);
+
+ let profile_upload_data = RwSignal::new(None::<Vec<u8>>);
+ let new_nick = RwSignal::new(old_profile.nick.clone().unwrap_or_default().to_string());
+ let has_avatar = RwSignal::new(old_profile.avatar.is_some());
+ let new_avatar_preview_url = RwSignal::new(None::<String>);
+ let remove_avatar = RwSignal::new(false);
+
+ let from_input = move |ev: Event| {
+ let elem = ev.target().unwrap().unchecked_into::<HtmlInputElement>();
+
+ // let UploadSignal(file_signal) = expect_context();
+ // file_signal.update(Vec::clear); // Clear list from previous change
+ let files = elem.files().unwrap_throw();
+
+ if let Some(file) = files.get(0) {
+ let url = Url::create_object_url_with_blob(&file).unwrap_throw();
+
+ new_avatar_preview_url.set(Some(url));
+ let reader = FileReader::new().unwrap_throw();
+ // let name = file.name();
+
+ // This closure only needs to be called a single time as we will just
+ // remake it on each loop
+ // * web_sys drops it for us when using this specific constructor
+ let read_file = {
+ // FileReader is cloned prior to moving into the closure
+ let reader = reader.to_owned();
+ Closure::once_into_js(move |_: ProgressEvent| {
+ // `.result` valid after the `read_*` completes on FileReader
+ // https://developer.mozilla.org/en-US/docs/Web/API/FileReader/result
+ let result = reader.result().unwrap_throw();
+ let data = Uint8Array::new(&result).to_vec();
+ // Do whatever you want with the Vec<u8>
+ profile_upload_data.set(Some(data));
+ })
+ };
+ reader.set_onloadend(Some(read_file.as_ref().unchecked_ref()));
+
+ // read_as_array_buffer takes a &Blob
+ //
+ // Per https://w3c.github.io/FileAPI/#file-section
+ // > A File object is a Blob object with a name attribute..
+ //
+ // File is a subclass (inherits) from the Blob interface, so a File
+ // can be used anywhere a Blob is required.
+ reader.read_as_array_buffer(&file).unwrap_throw();
+
+ // You can also use `.read_as_text(&file)` instead if you just want a string.
+ // This example shows how to extract an array buffer as it is more flexible
+ //
+ // If you use `.read_as_text` change the closure from ...
+ //
+ // let result = reader.result().unwrap_throw();
+ // let vec_of_u8_bytes = Uint8Array::new(&result).to_vec();
+ // let content = String::from_utf8(vec_of_u8_bytes).unwrap_throw();
+ //
+ // to ...
+ //
+ // let result = reader.result().unwrap_throw();
+ // let content = result.as_string().unwrap_throw();
+ } else {
+ profile_upload_data.set(None);
+ }
+ };
+
+ let save_profile = Action::new_local(move |_| {
+ let client = client.clone();
+ let old_nick = old_profile.nick.clone();
+ async move {
+ set_profile_save_pending.set(true);
+
+ let new_nick = new_nick.get();
+ let new_nick = if new_nick.is_empty() {
+ None
+ } else {
+ Some(new_nick)
+ };
+ if new_nick != old_nick {
+ match client.change_nick(new_nick).await {
+ Ok(_) => {}
+ Err(e) => {
+ set_error.set(Some(ProfileSaveError::Nick(e)));
+ set_profile_save_pending.set(false);
+ return;
+ }
+ }
+ }
+
+ if let Some(profile_data) = profile_upload_data.get() {
+ match client.change_avatar(Some(profile_data)).await {
+ Ok(_) => {}
+ Err(e) => {
+ set_error.set(Some(ProfileSaveError::Avatar(e)));
+ set_profile_save_pending.set(false);
+ return;
+ }
+ }
+ } else if remove_avatar.get() {
+ match client.change_avatar(None).await {
+ Ok(_) => {}
+ Err(e) => {
+ set_error.set(Some(ProfileSaveError::Avatar(e)));
+ set_profile_save_pending.set(false);
+ return;
+ }
+ }
+ }
+
+ set_profile_save_pending.set(false);
+ set_error.set(None);
+ set_success_message.set(Some("Profile Updated!".to_string()));
+ }
+ });
+
+ let _old_account_avatar = LocalResource::new(move || {
+ let avatar = old_profile.avatar.clone();
+ async move {
+ let url = fetch_avatar(avatar.as_deref()).await;
+ new_avatar_preview_url.set(Some(url));
+ }
+ });
+
+ view! {
+ <div class="profile-settings">
+ <div class="profile-preview">
+ <h2>Profile Preview</h2>
+ <div class="preview">
+ <img class="avatar" src=new_avatar_preview_url />
+ <div class="nick">
+ {move || {
+ let nick = new_nick.get();
+ if nick.is_empty() { old_profile.jid.to_string() } else { nick }
+ }}
+ </div>
+ </div>
+ </div>
+ <form
+ class="profile-form"
+ on:submit=move |ev| {
+ ev.prevent_default();
+ save_profile.dispatch(());
+ }
+ >
+ {success_message}
+ {error_message}
+ <div>
+ <h3>Nick</h3>
+ <input
+ disabled=profile_save_pending
+ placeholder="Nick"
+ type="text"
+ id="client-user-nick"
+ bind:value=new_nick
+ name="client-user-nick"
+ />
+ </div>
+ <div>
+ <h3>Avatar</h3>
+ <div class="change-avatar">
+ <label for="client-user-avatar">
+ <div class="button">Change Avatar</div>
+ </label>
+ <input
+ type="file"
+ id="client-user-avatar"
+ on:change=move |e| {
+ has_avatar.set(true);
+ remove_avatar.set(false);
+ from_input(e);
+ }
+ />
+ {move || {
+ if has_avatar.get() {
+ view! {
+ <a
+ on:click=move |_| {
+ profile_upload_data.set(None);
+ remove_avatar.set(true);
+ has_avatar.set(false);
+ new_avatar_preview_url.set(Some(NO_AVATAR.to_string()));
+ }
+ style="cursor: pointer"
+ >
+ Remove Avatar
+ </a>
+ }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ </div>
+ <hr />
+ <input
+ disabled=profile_save_pending
+ class="button"
+ type="submit"
+ value="Save Changes"
+ />
+ </form>
+ </div>
+ }
+ }
+}
+
+#[derive(Clone, Copy, Debug, PartialEq, Eq)]
+pub enum SettingsPage {
+ Account,
+ Chat,
+ Profile,
+ Privacy,
+}
+
+#[component]
+pub fn Settings() -> impl IntoView {
+ let show_settings: RwSignal<Option<SettingsPage>> = use_context().unwrap();
+
+ view! {
+ <Modal on_background_click=move |_| {
+ show_settings.set(None);
+ }>
+ <div class="settings panel">
+ <div class="header">
+ <h2>Settings</h2>
+ <div class="header-icon close">
+ <IconComponent
+ icon=Icon::Close24
+ on:click=move |_| show_settings.set(None)
+ />
+ </div>
+ </div>
+ <div class="settings-main">
+ <div class="settings-sidebar">
+ <div
+ class:open=move || *show_settings.read() == Some(SettingsPage::Account)
+ on:click=move |_| show_settings.set(Some(SettingsPage::Account))
+ >
+ Account
+ </div>
+ <div
+ class:open=move || *show_settings.read() == Some(SettingsPage::Chat)
+ on:click=move |_| show_settings.set(Some(SettingsPage::Chat))
+ >
+ Chat
+ </div>
+ <div
+ class:open=move || *show_settings.read() == Some(SettingsPage::Privacy)
+ on:click=move |_| show_settings.set(Some(SettingsPage::Privacy))
+ >
+ Privacy
+ </div>
+ <div
+ class:open=move || *show_settings.read() == Some(SettingsPage::Profile)
+ on:click=move |_| show_settings.set(Some(SettingsPage::Profile))
+ >
+ Profile
+ </div>
+ </div>
+ <div class="settings-page">
+ {move || {
+ if let Some(page) = show_settings.get() {
+ match page {
+ SettingsPage::Account => {
+ view! {
+ <div style="padding: 16px">
+ "Account settings coming soon!"
+ </div>
+ }
+ .into_any()
+ }
+ SettingsPage::Chat => {
+ view! {
+ <div style="padding: 16px">
+ "Chat settings coming soon!"
+ </div>
+ }
+ .into_any()
+ }
+ SettingsPage::Profile => {
+ view! { <ProfileSettings /> }.into_any()
+ }
+ SettingsPage::Privacy => {
+ view! {
+ <div style="padding: 16px">
+ "Privacy settings coming soon!"
+ </div>
+ }
+ .into_any()
+ }
+ }
+ } else {
+ view! {}.into_any()
+ }
+ }}
+ </div>
+ </div>
+ </div>
+ </Modal>
+ }
+}
diff --git a/src/views/mod.rs b/src/views/mod.rs
new file mode 100644
index 0000000..69ba606
--- /dev/null
+++ b/src/views/mod.rs
@@ -0,0 +1,39 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use filamento::UpdateMessage;
+use leptos::prelude::*;
+use login_page::LoginPage;
+use macaw::Macaw;
+use tokio::sync::mpsc::Receiver;
+
+use crate::client::Client;
+
+pub mod login_page;
+pub mod macaw;
+
+pub enum AppState {
+ LoggedOut,
+ LoggedIn,
+}
+
+#[component]
+pub fn App() -> impl IntoView {
+ let (app, set_app) = signal(AppState::LoggedOut);
+ let (client, set_client) = signal(None::<(Client, Receiver<UpdateMessage>)>);
+
+ view! {
+ {move || match &*app.read() {
+ AppState::LoggedOut => view! { <LoginPage set_app set_client /> }.into_any(),
+ AppState::LoggedIn => {
+ if let Some((client, updates)) = set_client.write_untracked().take() {
+ view! { <Macaw client updates set_app /> }.into_any()
+ } else {
+ set_app.set(AppState::LoggedOut);
+ view! { <LoginPage set_app set_client /> }.into_any()
+ }
+ }
+ }}
+ }
+}