summaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/chat.rs16
-rw-r--r--src/client.rs10
-rw-r--r--src/components/avatar.rs63
-rw-r--r--src/components/chat_header.rs10
-rw-r--r--src/components/chats_list.rs93
-rw-r--r--src/components/chats_list/chats_list_item.rs52
-rw-r--r--src/components/icon.rs15
-rw-r--r--src/components/message.rs116
-rw-r--r--src/components/message_composer.rs91
-rw-r--r--src/components/message_history_buffer.rs126
-rw-r--r--src/components/mod.rs20
-rw-r--r--src/components/modal.rs23
-rw-r--r--src/components/new_chat.rs38
-rw-r--r--src/components/overlay.rs16
-rw-r--r--src/components/personal_status.rs226
-rw-r--r--src/components/roster_list.rs105
-rw-r--r--src/components/roster_list/contact_request_manager.rs248
-rw-r--r--src/components/roster_list/roster_list_item.rs110
-rw-r--r--src/components/sidebar.rs231
-rw-r--r--src/contact.rs9
-rw-r--r--src/context.rs3
-rw-r--r--src/files.rs9
-rw-r--r--src/icon.rs5
-rw-r--r--src/lib.rs21
-rw-r--r--src/main.rs4
-rw-r--r--src/message.rs16
-rw-r--r--src/message_subscriptions.rs5
-rw-r--r--src/open_chats.rs38
-rw-r--r--src/roster.rs5
-rw-r--r--src/state_store.rs106
-rw-r--r--src/user.rs62
-rw-r--r--src/user_presences.rs130
-rw-r--r--src/views/login_page.rs27
-rw-r--r--src/views/macaw.rs111
-rw-r--r--src/views/macaw/open_chats_panel.rs20
-rw-r--r--src/views/macaw/settings.rs295
-rw-r--r--src/views/mod.rs5
37 files changed, 1726 insertions, 754 deletions
diff --git a/src/chat.rs b/src/chat.rs
index 29e2641..e40119f 100644
--- a/src/chat.rs
+++ b/src/chat.rs
@@ -1,11 +1,18 @@
+// 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 reactive_stores::ArcStore;
use leptos::prelude::*;
+use reactive_stores::ArcStore;
-use crate::{state_store::{StateListener, StateStore}, user::{ArcMacawUser, MacawUser}};
+use crate::{
+ state_store::{StateListener, StateStore},
+ user::{ArcMacawUser, MacawUser},
+};
#[derive(Clone, Copy)]
pub struct MacawChat {
@@ -59,11 +66,11 @@ pub struct ArcMacawChat {
}
impl ArcMacawChat {
- pub fn got_chat_and_user(chat: Chat, user: User) -> Self {
+ 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);
+ let user = ArcMacawUser::got_user(user).await;
Self { chat, user }
}
}
@@ -81,4 +88,3 @@ impl DerefMut for ArcMacawChat {
&mut self.chat
}
}
-
diff --git a/src/client.rs b/src/client.rs
index b3ff6bb..02ee537 100644
--- a/src/client.rs
+++ b/src/client.rs
@@ -1,4 +1,11 @@
-use std::{ops::{Deref, DerefMut}, sync::Arc};
+// 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::*;
@@ -27,4 +34,3 @@ impl DerefMut for Client {
&mut self.client
}
}
-
diff --git a/src/components/avatar.rs b/src/components/avatar.rs
index 9265ef7..11d2097 100644
--- a/src/components/avatar.rs
+++ b/src/components/avatar.rs
@@ -1,36 +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::{show_to_icon, IconComponent}, icon::Icon, user::get_avatar, user_presences::UserPresences};
+use crate::{
+ components::icon::{IconComponent, show_to_icon},
+ icon::Icon,
+ user::{MacawUser, get_avatar},
+ user_presences::UserPresences,
+};
#[component]
-pub fn AvatarWithPresence(user: Store<User>) -> impl IntoView {
- let avatar = LocalResource::new(move || get_avatar(user));
+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.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();
+ 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 || avatar.get() />
- {move || if let Some(icon) = show_icon() {
- view!{
- <IconComponent icon=icon class:presence-show-icon=true />
- }.into_any()
- } else {
- view! {}.into_any()
- }}
+ <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
index ab33d2b..3fb5df8 100644
--- a/src/components/chat_header.rs
+++ b/src/components/chat_header.rs
@@ -1,3 +1,7 @@
+// 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;
@@ -11,12 +15,12 @@ pub fn ChatViewHeader(chat: MacawChat) -> impl IntoView {
view! {
<div class="chat-view-header panel">
- <AvatarWithPresence user=chat.user.get().into() />
- <div class="user-info">
+ {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
index a5ecc9b..73ffdff 100644
--- a/src/components/chats_list.rs
+++ b/src/components/chats_list.rs
@@ -1,10 +1,23 @@
+// 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 leptos::prelude::*;
+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};
+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;
@@ -20,18 +33,16 @@ pub fn ChatsList() -> impl IntoView {
.map_err(|e| e.to_string());
match chats {
Ok(c) => {
- let chats = c
- .into_iter()
- .map(|((chat, chat_user), (message, message_user))| {
+ let mut chats = IndexMap::new();
+ for ((chat, chat_user), (message, message_user)) in c {
+ chats.insert(
+ chat.correspondent.clone(),
(
- chat.correspondent.clone(),
- (
- ArcMacawChat::got_chat_and_user(chat, chat_user),
- ArcMacawMessage::got_message_and_user(message, message_user),
- ),
- )
- })
- .collect::<IndexMap<BareJID, _>>();
+ ArcMacawChat::got_chat_and_user(chat, chat_user).await,
+ ArcMacawMessage::got_message_and_user(message, message_user).await,
+ ),
+ );
+ }
set_chats.set(chats);
}
Err(_) => {
@@ -55,7 +66,10 @@ pub fn ChatsList() -> impl IntoView {
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);
+ debug!(
+ "new message: {}",
+ new_message.message.get().read().body.body
+ );
chats.insert_before(0, to, (chat.clone(), new_message));
debug!("done setting");
} else {
@@ -64,7 +78,7 @@ pub fn ChatsList() -> impl IntoView {
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);
+ 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");
@@ -78,32 +92,65 @@ pub fn ChatsList() -> impl IntoView {
}
});
+ 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)/>
+ <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()
+ }
+ .into_any()
} else {
view! {}.into_any()
}
}}
</div>
</div>
- <div class="chats-list-chats">
- <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 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
index d1da727..3e18dbe 100644
--- a/src/components/chats_list/chats_list_item.rs
+++ b/src/components/chats_list/chats_list_item.rs
@@ -1,12 +1,25 @@
+// 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 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};
+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 {
@@ -44,19 +57,36 @@ pub fn ChatsListItem(chat: MacawChat, message: MacawMessage) -> impl IntoView {
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()
+ let timeinfo = move || {
+ if date().date() == now().date() {
+ // TODO: localisation/config
+ date().time().format("%H:%M").to_string()
+ } else {
+ date().date().format("%d/%m").to_string()
+ }
};
view! {
- <div class="chats-list-item" class:open=move || open() class:focused=move || focused() on:click=open_chat>
- <AvatarWithPresence user=chat.user.get().into() />
+ <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 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
index 7eaa52f..73b0f5d 100644
--- a/src/components/icon.rs
+++ b/src/components/icon.rs
@@ -1,5 +1,9 @@
-use leptos::prelude::*;
+// 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;
@@ -7,7 +11,12 @@ use crate::icon::Icon;
#[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() />
+ <img
+ class:light=icon.light()
+ class:icon=true
+ style=move || format!("height: {}px; width: {}px", icon.size(), icon.size())
+ src=move || icon.src()
+ />
}
}
@@ -53,5 +62,3 @@ pub fn Delivery(delivery: Delivery) -> impl IntoView {
}
}
}
-
-
diff --git a/src/components/message.rs b/src/components/message.rs
index 5deccdd..83a4bad 100644
--- a/src/components/message.rs
+++ b/src/components/message.rs
@@ -1,52 +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 crate::{
+ message::MacawMessage,
+ user::{get_avatar, get_name, NO_AVATAR},
+};
use super::icon::Delivery;
#[component]
-pub fn Message(message: MacawMessage, major: bool, r#final: bool) -> impl IntoView {
- let message_message: Store<filamento::chat::Message> =
- <ArcStore<filamento::chat::Message> as Clone>::clone(&message.get()).into();
- let message_user = <ArcStore<filamento::user::User> as Clone>::clone(&message.user.get()).into();
- let avatar = LocalResource::new(move || get_avatar(message_user));
- let name = move || get_name(message_user, false);
+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()}
- if major {
- view! {
- <div class:final=r#final class="chat-message major">
- <div class="left">
- <Transition fallback=|| view! { <img class="avatar" src=NO_AVATAR /> } >
- <img class="avatar" src=move || avatar.get() />
- </Transition>
+ 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>
- <div class="middle">
- <div class="message-info">
- <div class="message-user-name">{name}</div>
- <div class="message-timestamp">{move || message_message.timestamp().read().format("%H:%M").to_string()}</div>
+ }
+ .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>
- <div class="message-text">
- {move || message_message.body().read().body.clone()}
+ }
+ .into_any()
+ }
+ }}
+ {move || {
+ if new_day {
+ view! {
+ <div class="new-day">
+ {move || {
+ message.get().timestamp().read().format("%Y-%m-%d").to_string()
+ }}
</div>
- </div>
- <div class="right message-delivery">{move || message_message.delivery().get().map(|delivery| view! { <Delivery class:light=true delivery /> } ) }</div>
- </div>
- }.into_any()
- } else {
- view! {
- <div class:final=r#final class="chat-message minor">
- <div class="left message-timestamp">
- {move || message_message.timestamp().read().format("%H:%M").to_string()}
- </div>
- <div class="middle message-text">{move || message_message.body().read().body.clone()}</div>
- <div class="right message-delivery">{move || message_message.delivery().get().map(|delivery| view! { <Delivery delivery /> } ) }</div>
- </div>
- }.into_any()
+ }
+ .into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
}
}
-
diff --git a/src/components/message_composer.rs b/src/components/message_composer.rs
index 3876a5a..fd4e59b 100644
--- a/src/components/message_composer.rs
+++ b/src/components/message_composer.rs
@@ -1,6 +1,13 @@
+// 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;
@@ -60,38 +67,68 @@ pub fn ChatViewMessageComposer(chat: BareJID) -> impl IntoView {
// };
//
+ 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="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();
+ <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();
+ }
+ }
+ _ => {}
}
- _ => {}
- // debug!("shift pressed down");
}
- }
- on:keyup=move |ev| {
- match ev.key_code() {
- 16 => set_shift_pressed.set(false),
- _ => {}
- // debug!("shift released");
+ on:keyup=move |ev| {
+ match ev.key_code() {
+ 16 => set_shift_pressed.set(false),
+ _ => {}
+ }
}
- }
- ></div>
- // <input hidden type="submit" />
+ ></div>
+ </div>
+ // <input hidden type="submit" />
</form>
}
}
-
-
diff --git a/src/components/message_history_buffer.rs b/src/components/message_history_buffer.rs
index ecdba5b..c733700 100644
--- a/src/components/message_history_buffer.rs
+++ b/src/components/message_history_buffer.rs
@@ -1,5 +1,12 @@
+// 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 filamento::{
+ chat::{Chat, ChatStoreFields, MessageStoreFields},
+ user::User,
+};
use indexmap::IndexMap;
use jid::BareJID;
use leptos::prelude::*;
@@ -7,15 +14,17 @@ 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};
+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 chat_chat: Store<Chat> =
- <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.get()).into();
- let chat_user: Store<User> =
- <ArcStore<filamento::user::User> as Clone>::clone(&chat.user.get()).into();
let load_set_messages = set_messages.clone();
let load_messages = LocalResource::new(move || {
@@ -23,20 +32,18 @@ pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView {
async move {
let client = use_context::<Client>().expect("client not in context");
let messages = client
- .get_messages_with_users(chat_chat.correspondent().get())
+ .get_messages_with_users(chat.get().correspondent().get())
.await
.map_err(|e| e.to_string());
match messages {
Ok(m) => {
- let messages = m
- .into_iter()
- .map(|(message, message_user)| {
- (
- message.id,
- ArcMacawMessage::got_message_and_user(message, message_user),
- )
- })
- .collect::<IndexMap<Uuid, _>>();
+ 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) => {
@@ -57,40 +64,22 @@ pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView {
load_messages.await;
let (sub_id, mut new_messages) = new_messages_signal
.write()
- .subscribe_chat(chat_chat.correspondent().get());
+ .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 *<ArcStore<filamento::chat::Message> as Clone>::clone(&last.message.get())
- .timestamp()
- .read()
- < *<ArcStore<filamento::chat::Message> as Clone>::clone(&new_message.message.get())
- .timestamp()
- .read()
- {
- messages.insert(
- <ArcStore<filamento::chat::Message> as Clone>::clone(
- &new_message.message.get(),
- )
- .id()
- .get(),
- new_message,
- );
+ 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| {
- <ArcStore<filamento::chat::Message> as Clone>::clone(&value.message.get())
+ value
+ .get()
.timestamp()
.read()
- .cmp(
- &<ArcStore<filamento::chat::Message> as Clone>::clone(
- &new_message.message.get(),
- )
- .timestamp()
- .read(),
- )
+ .cmp(&new_message.get().timestamp().read())
}) {
Ok(i) => i,
Err(i) => i,
@@ -98,22 +87,13 @@ pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView {
messages.insert_before(
// TODO: check if this logic is correct
index,
- <ArcStore<filamento::chat::Message> as Clone>::clone(
- &new_message.message.get(),
- )
- .id()
- .get(),
+ new_message.get().id().get(),
new_message,
);
debug!("set the new message in message buffer");
}
} else {
- messages.insert(
- <ArcStore<filamento::chat::Message> as Clone>::clone(&new_message.message.get())
- .id()
- .get(),
- new_message,
- );
+ messages.insert(new_message.get().id().get(), new_message);
debug!("set the new message in message buffer");
}
}
@@ -123,7 +103,7 @@ pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView {
if let Some(sub_id) = sub_id.get_untracked() {
new_messages_signal
.write()
- .unsubscribe_chat(sub_id, chat_chat.correspondent().get_untracked());
+ .unsubscribe_chat(sub_id, chat.get().correspondent().get_untracked());
}
});
@@ -134,32 +114,26 @@ pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView {
.get()
.into_iter()
.map(|(id, message)| {
- let message_timestamp =
- <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message.get())
- .timestamp()
- .read()
- .naive_local();
- // TODO: mark new day
- // if message_timestamp.date() > last_timestamp.date() {
- // messages_view = messages_view.push(date(message_timestamp.date()));
- // }
- let major = if last_user.as_ref() != Some(&message.message.get().read().from)
+ 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
};
- last_user = Some(
- <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message.get())
- .from()
- .get(),
- );
+ 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))
+ (id, (message, major, false, new_day))
})
.collect::<Vec<_>>();
- if let Some((_id, (_, _, last))) = messages.last_mut() {
+ if let Some((_id, (_, _, last, _))) = messages.last_mut() {
*last = true
}
messages.into_iter().rev()
@@ -167,10 +141,18 @@ pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView {
view! {
<div class="messages-buffer">
- <For each=each key=|message| (message.0, message.1.1, message.1.2) let(message)>
- <Message message=message.1.0.into() major=message.1.1 r#final=message.1.2 />
+ <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
index 879f99e..d2fb6b5 100644
--- a/src/components/mod.rs
+++ b/src/components/mod.rs
@@ -1,13 +1,17 @@
-pub mod sidebar;
-mod chats_list;
-mod new_chat;
-mod roster_list;
+// 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_history_buffer;
pub mod message_composer;
-pub mod chat_header;
-mod overlay;
+pub mod message_history_buffer;
pub mod modal;
-pub mod icon;
+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
index 62e1fac..e23fa5d 100644
--- a/src/components/modal.rs
+++ b/src/components/modal.rs
@@ -1,16 +1,25 @@
-use leptos::prelude::*;
+// 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 {
+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)
+ <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
index b706ca9..925ec57 100644
--- a/src/components/new_chat.rs
+++ b/src/components/new_chat.rs
@@ -1,12 +1,26 @@
+// 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 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}};
+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 {
@@ -34,14 +48,14 @@ pub fn NewChatWidget(set_open_new_chat: WriteSignal<bool>) -> impl IntoView {
})
};
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>> =
+ let user_state_store: StateStore<BareJID, (ArcStore<User>, ArcRwSignal<String>)> =
use_context().expect("no user state store");
let open_chat = Action::new_local(move |_| {
@@ -72,12 +86,23 @@ pub fn NewChatWidget(set_open_new_chat: WriteSignal<bool>) -> impl IntoView {
set_error.set(Some(e.into()));
set_new_chat_pending.set(false);
return;
- },
+ }
};
let chat = {
// let user = MacawUser::got_user(user);
- let user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
+ // let user = 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 }
@@ -120,4 +145,3 @@ pub fn NewChatWidget(set_open_new_chat: WriteSignal<bool>) -> impl IntoView {
</div>
}
}
-
diff --git a/src/components/overlay.rs b/src/components/overlay.rs
index d4ff1bf..d10f33a 100644
--- a/src/components/overlay.rs
+++ b/src/components/overlay.rs
@@ -1,3 +1,7 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
use leptos::prelude::*;
use tracing::debug;
@@ -5,12 +9,14 @@ use tracing::debug;
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-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
index 8439756..b74b366 100644
--- a/src/components/personal_status.rs
+++ b/src/components/personal_status.rs
@@ -1,65 +1,93 @@
-use filamento::{presence::{Offline, Online, PresenceType, Show}, user::{User, UserStoreFields}};
+// 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::{get_name, MacawUser}, user_presences::UserPresences, views::{macaw::settings::SettingsPage, AppState}};
+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=user.get().into() />
- <div class="dock-pill"></div>
- </div>
- {move || {
- let open = open.get();
- debug!("open = {:?}", open);
- if open {
- view! {
- <Overlay set_open>
- <PersonalStatusMenu user=user.get().into() set_open/>
- </Overlay>
- }.into_any()
- } else {
- view! {}.into_any()
- }}}
- }.into_any()
- } else {
- view! {}.into_any()
+ 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: Store<User>, set_open: WriteSignal<bool>) -> impl IntoView {
+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.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,
+ 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,
},
- None => 1,
- },
- PresenceType::Offline(_offline) => 5,
- };
- debug!("initial show = {show}");
- show
+ PresenceType::Offline(_offline) => 5,
+ };
+ debug!("initial show = {show}");
+ show
});
let show_select: NodeRef<html::Select> = NodeRef::new();
@@ -71,7 +99,7 @@ pub fn PersonalStatusMenu(user: Store<User>, set_open: WriteSignal<bool>) -> imp
}
});
let set_status = Action::new_local(move |show_value: &i32| {
- let show_value = show_value.to_owned();
+ let show_value = show_value.to_owned();
let client = client1.clone();
async move {
if let Err(e) = match show_value {
@@ -79,46 +107,71 @@ pub fn PersonalStatusMenu(user: Store<User>, set_open: WriteSignal<bool>) -> imp
if let Ok(r) = client.connect().await {
client.resource.set(Some(r))
};
- client.set_status(Online { show: Some(Show::Chat), ..Default::default() }).await
- },
+ 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
- },
+ 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
- },
+ 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
- },
+ 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
- },
+ 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
+ return;
}
_ => {
error!("invalid availability select");
- return
+ return;
}
} {
error!("show set error: {e}");
- return
+ return;
}
set_show_value.set(show_value);
}
@@ -129,8 +182,8 @@ pub fn PersonalStatusMenu(user: Store<User>, set_open: WriteSignal<bool>) -> imp
<div class="user">
<AvatarWithPresence user=user />
<div class="user-info">
- <div class="nick">{move || get_name(user, false)}</div>
- <div class="jid">{move || user.jid().with(|jid| jid.to_string())}</div>
+ <div 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">
@@ -142,36 +195,55 @@ pub fn PersonalStatusMenu(user: Store<User>, set_open: WriteSignal<bool>) -> imp
}
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>
+ <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);
- }>
+ <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);
- }>
+ <div
+ class="menu-item"
+ on:click=move |_| {
+ show_settings.set(Some(SettingsPage::Account));
+ set_open.set(false);
+ }
+ >
Settings
</div>
<hr />
- <div class="menu-item" on:click=move |_| {
- // TODO: check if client is actually dropped/shutdown eventually
- disconnect.dispatch(());
- set_app.set(AppState::LoggedOut)
- }>
+ <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
index a398ffe..b018d45 100644
--- a/src/components/roster_list.rs
+++ b/src/components/roster_list.rs
@@ -1,22 +1,88 @@
+// 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 leptos::prelude::*;
+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, roster::{Roster, RosterStoreFields}};
+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 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! {
@@ -24,12 +90,13 @@ pub fn RosterList() -> impl IntoView {
<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)/>
+ <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()
+ view! { <div class="badge"></div> }.into_any()
} else {
view! {}.into_any()
}
@@ -39,20 +106,28 @@ pub fn RosterList() -> impl IntoView {
{move || {
if *open_add_contact.read() {
view! {
- <div class="roster-add-contact">
- <AddContact />
+ <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()
+ }
+ .into_any()
} else {
view! {}.into_any()
}
}}
- <div class="roster-list-roster">
- <For each=move || roster.contacts().get() key=|contact| contact.0.clone() let(contact)>
- <RosterListItem contact=contact.1 />
- </For>
+ <div 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
index 174e677..4c28142 100644
--- a/src/components/roster_list/contact_request_manager.rs
+++ b/src/components/roster_list/contact_request_manager.rs
@@ -1,12 +1,22 @@
+// 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 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}};
+use crate::{
+ client::Client,
+ roster::{Roster, RosterStoreFields},
+};
#[derive(Clone, Debug, Error)]
pub enum AddContactError {
@@ -21,9 +31,11 @@ pub enum AddContactError {
#[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 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...
@@ -43,8 +55,9 @@ pub fn AddContact() -> impl IntoView {
let client2 = client.clone();
let client3 = client.clone();
let client4 = client.clone();
+ let client5 = client.clone();
- let add_contact= Action::new_local(move |_| {
+ let add_contact = Action::new_local(move |_| {
let client = client.clone();
async move {
set_add_contact_pending.set(true);
@@ -71,8 +84,8 @@ pub fn AddContact() -> impl IntoView {
Err(e) => {
set_error.set(Some(e.into()));
set_add_contact_pending.set(false);
- return;
- },
+ return;
+ }
};
set_add_contact_pending.set(false);
@@ -90,26 +103,44 @@ pub fn AddContact() -> impl IntoView {
}
});
- 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 outgoing = move || {
+ roster
+ .contacts()
+ .get()
+ .into_iter()
+ .filter(
+ |(jid, contact)| match *contact.contact.subscription().read() {
+ filamento::roster::Subscription::None => false,
+ filamento::roster::Subscription::PendingOut => true,
+ filamento::roster::Subscription::PendingIn => false,
+ filamento::roster::Subscription::PendingInPendingOut => true,
+ filamento::roster::Subscription::OnlyOut => false,
+ filamento::roster::Subscription::OnlyIn => false,
+ filamento::roster::Subscription::OutPendingIn => false,
+ filamento::roster::Subscription::InPendingOut => true,
+ filamento::roster::Subscription::Buddy => false,
+ },
+ )
+ .collect::<Vec<_>>()
+ };
let accept_friend_request = Action::new_local(move |jid: &BareJID| {
let client = client2.clone();
let jid = jid.clone();
async move {
// TODO: error
- client.accept_buddy_request(jid).await;
+ 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);
}
});
@@ -129,70 +160,125 @@ pub fn AddContact() -> impl IntoView {
async move {
// TODO: error
client.unsubscribe_from_contact(jid).await;
-
}
});
view! {
<div class="add-contact-menu">
- <div>
- {error_message}
- <form on:submit=move |ev| {
- ev.prevent_default();
- add_contact.dispatch(());
- }>
- <input
- disabled=add_contact_pending
- placeholder="JID"
- type="text"
- node_ref=jid_input
- bind:value=jid
- name="jid"
- id="jid"
- autofocus="true"
- />
- <input disabled=add_contact_pending class="button" type="submit" value="Send Friend Request" />
- </form>
- </div>
- {move || if !requests.read().is_empty() {
- view! {
- <div>
- <h3>Incoming Subscription Requests</h3>
- <For each=move || requests.get() key=|request| request.clone() let(request)>
- {
- let request2 = request.clone();
- let request3 = request.clone();
- let jid_string = move || request.to_string();
- view! {
- <div class="jid-with-button"><div class="jid">{jid_string}</div>
- <div><div class="button" on:click=move |_| { accept_friend_request.dispatch(request2.clone()); } >Accept</div><div class="button" on:click=move |_| { reject_friend_request.dispatch(request3.clone()); } >Reject</div></div></div>
- }
- }
- </For>
- </div>
- }.into_any()
- } else {
- view! {}.into_any()
- }}
- {move || if !outgoing().is_empty() {
- view! {
- <div>
- <h3>Pending Outgoing Subscription Requests</h3>
- <For each=move || outgoing() key=|(jid, _contact)| jid.clone() let((jid, contact))>
- {
- let jid2 = jid.clone();
- let jid_string = move || jid.to_string();
- view! {
- <div class="jid-with-button"><div class="jid">{jid_string}</div><div class="button" on:click=move |_| { cancel_subscription_request.dispatch(jid2.clone()); } >Cancel</div></div>
- }
- }
- </For>
- </div>
- }.into_any()
- } else {
- view! {}.into_any()
- }}
+ <div>
+ {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
index 9ebc26a..a6fd714 100644
--- a/src/components/roster_list/roster_list_item.rs
+++ b/src/components/roster_list/roster_list_item.rs
@@ -1,46 +1,93 @@
+// 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 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}, components::{avatar::AvatarWithPresence, sidebar::Open}, contact::MacawContact, open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}, user::get_name};
+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 contact_contact: Store<Contact> = contact.contact;
- let contact_user: Store<User> =
- <ArcStore<filamento::user::User> as Clone>::clone(&contact.user.get()).into();
- let name = move || get_name(contact_user, false);
+ 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");
- // TODO: why can this not be in the closure?????
- // TODO: not good, as overwrites preexisting chat state with possibly incorrect one...
- let chat = Chat {
- correspondent: contact_user.jid().get(),
- have_chatted: false,
- };
- let chat = ArcMacawChat::got_chat_and_user(chat, contact_user.get());
+ let client = use_context::<Client>().expect("client not in context");
- let open_chat = move |_| {
- debug!("opening chat");
- open_chats.update(|open_chats| open_chats.open(Clone::clone(&chat)));
- };
+ 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) = &*open_chats.chat_view().read() {
+ if let Some(open_chat) = &*current_open_chat.read() {
debug!("got open chat: {:?}", open_chat);
- if *open_chat == *contact_user.jid().read() {
+ if *open_chat == *contact.user.get().jid().read() {
return Open::Focused;
}
}
if let Some(_backgrounded_chat) = open_chats
.chats()
.read()
- .get(contact_user.jid().read().deref())
+ .get(contact.user.get().jid().read().deref())
{
return Open::Open;
}
@@ -50,13 +97,26 @@ pub fn RosterListItem(contact: MacawContact) -> impl IntoView {
let open = move || open().is_open();
view! {
- <div class="roster-list-item" class:open=move || open() class:focused=move || focused() on:click=open_chat>
- <AvatarWithPresence user=contact_user />
+ <div
+ class="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_contact.user_jid().read().to_string()}</span></p></div>
- <div class="sub-info">{move || contact_contact.subscription().read().to_string()}</div>
+ <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
index ca753ef..9f555b5 100644
--- a/src/components/sidebar.rs
+++ b/src/components/sidebar.rs
@@ -1,20 +1,29 @@
-use std::collections::HashSet;
+// 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::{
- personal_status::PersonalStatus,
- chats_list::ChatsList,
- roster_list::RosterList,
+ chats_list::ChatsList, personal_status::PersonalStatus, roster_list::RosterList,
};
-#[derive(PartialEq, Eq, Clone, Copy)]
+#[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,
@@ -55,122 +64,170 @@ pub fn toggle_open(state: &mut Option<SidebarOpen>, open: SidebarOpen) -> bool {
}
}
None => {
- *state = Some(open);
- true
- },
+ *state = Some(open);
+ true
+ }
}
}
#[component]
pub fn Sidebar() -> impl IntoView {
- let requests: ReadSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions in context");
+ 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="sidebar"
+ on:mouseleave=move |_| {
+ set_hovered.set(None);
+ set_just_closed.set(false);
+ }
+ >
<div class="dock panel">
<div class="shortcuts">
- <div class="roster-tab dock-item" class:focused=move || *open.read() == Some(SidebarOpen::Roster) class:hovering=move || *hovered.read() == Some(SidebarOpen::Roster)
- on:mouseenter=move |_| {
- set_just_closed.set(false);
- set_hovered.set(Some(SidebarOpen::Roster))
- }
- on:click=move |_| {
- set_open.update(|state| {
- if !toggle_open(state, SidebarOpen::Roster) {
- set_just_closed.set(true);
- }
- })
- }>
+ <div
+ class="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()
- }
- }}
+ <img src="/assets/caw.png" />
+ {move || {
+ let len = requests.read().len();
+ if len > 0 {
+ view! { <div class="badge">{len}</div> }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
</div>
</div>
</div>
- <div class="chats-tab dock-item" class:focused=move || *open.read() == Some(SidebarOpen::Chats) class:hovering=move || *hovered.read() == Some(SidebarOpen::Chats)
- on:mouseenter=move |_| {
- set_just_closed.set(false);
- set_hovered.set(Some(SidebarOpen::Chats))
- }
- on:click=move |_| {
- set_open.update(|state| {
- if !toggle_open(state, SidebarOpen::Chats) {
- set_just_closed.set(true);
- }
- })
- }>
+ <div
+ class="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="pins"></div>
<div class="personal">
<PersonalStatus />
</div>
</div>
- {move || if let Some(hovered) = *hovered.read() {
- if Some(hovered) != *open.read() {
- if !just_closed.get() {
- match hovered {
- SidebarOpen::Roster => view! {
- <div class="sidebar-drawer sidebar-hovering-drawer">
- <RosterList />
- </div>
- }.into_any(),
- SidebarOpen::Chats => view! {
- <div class="sidebar-drawer sidebar-hovering-drawer">
- <ChatsList />
- </div>
- }.into_any(),
- }
- } else {
-
- view! {}.into_any()
+ {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()
}
- } else {
- view! {}.into_any()
- }}
- {move || if let Some(opened) = *open.read() {
- match opened {
- SidebarOpen::Roster => view! {
- <div class="sidebar-drawer">
- <RosterList />
- </div>
- }.into_any(),
- SidebarOpen::Chats => view! {
- <div class="sidebar-drawer">
- <ChatsList />
- </div>
- }.into_any(),
- }
- } else {
- view! {}.into_any()
}}
</div>
}
}
-
diff --git a/src/contact.rs b/src/contact.rs
index 9adec16..b7f57fa 100644
--- a/src/contact.rs
+++ b/src/contact.rs
@@ -1,3 +1,7 @@
+// 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};
@@ -41,9 +45,9 @@ pub struct ArcMacawContact {
}
impl ArcMacawContact {
- pub fn got_contact_and_user(contact: Contact, user: User) -> Self {
+ pub async fn got_contact_and_user(contact: Contact, user: User) -> Self {
let contact = Store::new(contact);
- let user = ArcMacawUser::got_user(user);
+ let user = ArcMacawUser::got_user(user).await;
Self { contact, user }
}
}
@@ -61,4 +65,3 @@ impl DerefMut for ArcMacawContact {
&mut self.contact
}
}
-
diff --git a/src/context.rs b/src/context.rs
index e69de29..da660dc 100644
--- a/src/context.rs
+++ 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
index b9e50df..760549f 100644
--- a/src/files.rs
+++ b/src/files.rs
@@ -1,5 +1,9 @@
-use base64::{prelude::BASE64_STANDARD, Engine};
-use filamento::files::{opfs::OPFSError, FileStore, FilesMem, FilesOPFS};
+// 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 {
@@ -47,4 +51,3 @@ impl Files {
}
}
}
-
diff --git a/src/icon.rs b/src/icon.rs
index b0ef60e..231ad2e 100644
--- a/src/icon.rs
+++ b/src/icon.rs
@@ -1,3 +1,7 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
#[derive(Copy, Clone)]
pub enum Icon {
AddContact24,
@@ -108,4 +112,3 @@ impl Icon {
}
}
}
-
diff --git a/src/lib.rs b/src/lib.rs
index 4c66911..fcd632e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,17 +1,20 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
pub use views::App;
-mod state_store;
-mod icon;
-mod user;
mod chat;
-mod open_chats;
-mod components;
-mod views;
-mod files;
mod client;
-mod roster;
+mod 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/main.rs b/src/main.rs
index fd4a1de..3db4c25 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -1,3 +1,7 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
use leptos::prelude::*;
use macaw_web::App;
diff --git a/src/message.rs b/src/message.rs
index 878085e..20e37b9 100644
--- a/src/message.rs
+++ b/src/message.rs
@@ -1,11 +1,18 @@
+// 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 leptos::prelude::*;
-use crate::{state_store::{StateListener, StateStore}, user::{ArcMacawUser, MacawUser}};
+use crate::{
+ state_store::{StateListener, StateStore},
+ user::{ArcMacawUser, MacawUser},
+};
#[derive(Clone, Copy)]
pub struct MacawMessage {
@@ -49,11 +56,11 @@ pub struct ArcMacawMessage {
}
impl ArcMacawMessage {
- pub fn got_message_and_user(message: Message, user: User) -> Self {
+ 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);
+ let user = ArcMacawUser::got_user(user).await;
Self { message, user }
}
}
@@ -71,4 +78,3 @@ impl DerefMut for ArcMacawMessage {
&mut self.message
}
}
-
diff --git a/src/message_subscriptions.rs b/src/message_subscriptions.rs
index 17d924f..eebbef3 100644
--- a/src/message_subscriptions.rs
+++ b/src/message_subscriptions.rs
@@ -1,3 +1,7 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
use std::collections::HashMap;
use jid::BareJID;
@@ -83,4 +87,3 @@ impl MessageSubscriptions {
}
}
}
-
diff --git a/src/open_chats.rs b/src/open_chats.rs
index 6d08c5d..bf2eb73 100644
--- a/src/open_chats.rs
+++ b/src/open_chats.rs
@@ -1,9 +1,13 @@
+// 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 leptos::prelude::*;
use crate::chat::{ArcMacawChat, MacawChat};
@@ -18,28 +22,19 @@ pub struct OpenChatsPanel {
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();
+ 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();
+ 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();
+ 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);
}
@@ -50,25 +45,16 @@ impl OpenChatsPanel {
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();
+ 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();
+ 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();
+ let new_jid = chat.get().correspondent().read().clone();
self.chats.insert(new_jid.clone(), chat);
*&mut self.chat_view = Some(new_jid);
}
@@ -84,5 +70,3 @@ impl OpenChatsPanel {
// }
}
-
-
diff --git a/src/roster.rs b/src/roster.rs
index 75f4c3b..13aed19 100644
--- a/src/roster.rs
+++ b/src/roster.rs
@@ -1,3 +1,7 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
use std::collections::HashMap;
use jid::BareJID;
@@ -18,4 +22,3 @@ impl Roster {
}
}
}
-
diff --git a/src/state_store.rs b/src/state_store.rs
index 8d80b3f..1e67f34 100644
--- a/src/state_store.rs
+++ b/src/state_store.rs
@@ -1,6 +1,15 @@
-use std::{collections::HashMap, ops::{Deref, DerefMut}, sync::{Arc, RwLock}};
+// 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
@@ -105,36 +114,57 @@ impl<K, V, S> Clone for StateStore<K, V, S> {
}
}
-impl<K: Eq + std::hash::Hash + Clone, V: Clone> StateStore<K, V>
+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 store = self.inner.try_get_value().unwrap();
- let mut store = store.store.write().unwrap();
- if let Some((v, count)) = store.get_mut(&key) {
- v.set(value);
- *count += 1;
- StateListener {
- value: v.clone(),
- cleaner: StateCleaner {
- key,
- state_store: self.clone(),
- },
- }
- } else {
- let v = ArcRwSignal::new(value);
- store.insert(key.clone(), (v.clone(), 1));
- StateListener {
- value: v.into(),
- cleaner: StateCleaner {
- key,
- state_store: self.clone(),
- },
- }
+ 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
}
}
}
@@ -159,18 +189,6 @@ where
v.update(|v| modify(v));
}
}
-
- fn remove(&self, key: &K) {
- // let store = self.inner.try_get_value().unwrap();
- // let mut store = store.store.write().unwrap();
- // if let Some((_v, count)) = store.get_mut(key) {
- // *count -= 1;
- // if *count == 0 {
- // store.remove(key);
- // debug!("dropped item from store");
- // }
- // }
- }
}
#[derive(Clone)]
@@ -214,7 +232,7 @@ where
V: Send + Sync + 'static,
{
key: K,
- state_store: StateStore<K, V>,
+ state_store: ArcStateStore<K, V>,
}
impl<K, V> Clone for StateCleaner<K, V>
@@ -224,8 +242,7 @@ where
{
fn clone(&self) -> Self {
{
- let store = self.state_store.inner.try_get_value().unwrap();
- let mut store = store.store.write().unwrap();
+ let mut store = self.state_store.store.write().unwrap();
if let Some((_v, count)) = store.get_mut(&self.key) {
*count += 1;
}
@@ -241,6 +258,13 @@ impl<K: Eq + std::hash::Hash + Send + Sync + 'static, V: Send + Sync + 'static>
for StateCleaner<K, V>
{
fn drop(&mut self) {
- self.state_store.remove(&self.key);
+ 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
index e62ebea..e277efd 100644
--- a/src/user.rs
+++ b/src/user.rs
@@ -1,22 +1,35 @@
+// 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 reactive_stores::{ArcStore, Store};
use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
-use crate::{client::Client, roster::{Roster, RosterStoreFields}, state_store::{StateListener, StateStore}};
+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()
+ self.try_get_value().unwrap().get().0
+ }
+
+ pub fn avatar(&self) -> ArcRwSignal<String> {
+ self.try_get_value().unwrap().get().1
}
}
@@ -38,6 +51,7 @@ impl From<ArcMacawUser> for MacawUser {
fn from(value: ArcMacawUser) -> Self {
Self {
user: ArenaItem::new_with_storage(value),
+ // avatar: value.avatar.into(),
}
}
}
@@ -50,21 +64,31 @@ impl From<MacawUser> for ArcMacawUser {
#[derive(Clone)]
pub struct ArcMacawUser {
- pub user: StateListener<BareJID, ArcStore<User>>,
+ pub user: StateListener<BareJID, (ArcStore<User>, ArcRwSignal<String>)>,
}
impl ArcMacawUser {
- pub fn got_user(user: User) -> Self {
-
- let user_state_store: StateStore<BareJID, ArcStore<User>> =
+ 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 user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
- Self { 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 };
+ user
}
}
impl Deref for ArcMacawUser {
- type Target = StateListener<BareJID, ArcStore<User>>;
+ type Target = StateListener<BareJID, (ArcStore<User>, ArcRwSignal<String>)>;
fn deref(&self) -> &Self::Target {
&self.user
@@ -79,6 +103,19 @@ impl DerefMut for ArcMacawUser {
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");
@@ -87,8 +124,6 @@ pub async fn get_avatar(user: Store<User>) -> String {
} else {
NO_AVATAR.to_string()
}
- // TODO: enable avatar fetching
- // format!("/files/{}", avatar)
} else {
NO_AVATAR.to_string()
}
@@ -99,7 +134,7 @@ pub fn get_name(user: Store<User>, note_to_self: bool) -> String {
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()
+ return "Note to self".to_string();
}
}
if let Some(name) = roster
@@ -116,4 +151,3 @@ pub fn get_name(user: Store<User>, note_to_self: bool) -> String {
user.read().jid.to_string()
}
}
-
diff --git a/src/user_presences.rs b/src/user_presences.rs
index 1a719a2..87f9bdc 100644
--- a/src/user_presences.rs
+++ b/src/user_presences.rs
@@ -1,3 +1,7 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
use std::collections::HashMap;
use chrono::Utc;
@@ -43,7 +47,7 @@ impl UserPresences {
pub struct Presences {
/// presences are sorted by time, first by type, then by last activity.
- presences: IndexMap<String, Presence>
+ presences: IndexMap<String, Presence>,
}
impl Presences {
@@ -55,47 +59,89 @@ impl Presences {
/// 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::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 == 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 == 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::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::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()))
+ 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
}
@@ -103,19 +149,14 @@ impl Presences {
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
- )
+ 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,
+ index, resource, presence,
);
}
@@ -130,4 +171,3 @@ impl Presences {
}
}
}
-
diff --git a/src/views/login_page.rs b/src/views/login_page.rs
index 2edd4b5..d1bb29a 100644
--- a/src/views/login_page.rs
+++ b/src/views/login_page.rs
@@ -1,9 +1,15 @@
+// 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}, files::{opfs::OPFSError, FilesMem, FilesOPFS}, UpdateMessage};
+use filamento::{
+ db::Db, error::{CommandError, ConnectionError, DatabaseOpenError}, files::{opfs::OPFSError, FilesMem, FilesOPFS}, UpdateMessage
+};
use jid::JID;
-use thiserror::Error;
use leptos::prelude::*;
+use thiserror::Error;
use tokio::sync::mpsc::Receiver;
use tracing::debug;
@@ -21,6 +27,8 @@ pub enum LoginError {
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),
}
@@ -79,10 +87,17 @@ pub fn LoginPage(
debug!("creating db in opfs");
Db::create_connect_and_migrate(jid.as_bare().to_string())
.await
- .unwrap()
} else {
debug!("creating db in memory");
- Db::create_connect_and_migrate_memory().await.unwrap()
+ 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;
@@ -113,9 +128,7 @@ pub fn LoginPage(
if *connect_on_login.read_untracked() {
match client.connect().await {
- Ok(r) => {
- resource.set(Some(r))
- }
+ Ok(r) => resource.set(Some(r)),
Err(e) => {
set_error.set(Some(e.into()));
set_login_pending.set(false);
diff --git a/src/views/macaw.rs b/src/views/macaw.rs
index 1b7051f..e91e08a 100644
--- a/src/views/macaw.rs
+++ b/src/views/macaw.rs
@@ -1,6 +1,14 @@
-use std::collections::HashSet;
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
-use filamento::{chat::{Chat, Message, MessageStoreFields}, user::User, UpdateMessage};
+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;
@@ -10,12 +18,23 @@ 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}, user_presences::{Presences, UserPresences}};
+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;
-pub mod settings;
mod open_chats_panel;
+pub mod settings;
#[component]
pub fn Macaw(
@@ -25,6 +44,7 @@ pub fn Macaw(
mut updates: Receiver<UpdateMessage>,
set_app: WriteSignal<AppState>,
) -> impl IntoView {
+ let (updates, set_updates) = signal(Some(updates));
provide_context(set_app);
provide_context(client);
@@ -38,46 +58,45 @@ pub fn Macaw(
provide_context(messages_store);
let chats_store: StateStore<BareJID, ArcStore<Chat>> = StateStore::new();
provide_context(chats_store);
- let users_store: StateStore<BareJID, ArcStore<User>> = StateStore::new();
+ 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>);
+ 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).into()
- }
+ 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());
+ let (subscription_requests, set_subscription_requests) = signal(HashSet::<BareJID>::new());
provide_context(subscription_requests);
provide_context(set_subscription_requests);
// TODO: get cached contacts on login before getting the updated contacts
- OnceResource::new(async move {
+ 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 contacts = items
- .into_iter()
- .map(|(contact, user)| {
- (
- contact.user_jid.clone(),
- ArcMacawContact::got_contact_and_user(contact, user).into(),
- )
- })
- .collect();
+ 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) => {
@@ -85,13 +104,15 @@ pub fn Macaw(
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();
- let contact = ArcMacawContact::got_contact_and_user(contact, user).into();
- roster.insert(jid, contact);
+ roster.insert(jid, new_contact);
}
});
}
@@ -102,21 +123,30 @@ pub fn Macaw(
}
UpdateMessage::Presence { from, presence } => {
let bare_jid = from.to_bare();
- if let Some(presences) = user_presences.read().user_presences.get(&bare_jid) {
+ if let Some(presences) = user_presences
+ .read_untracked()
+ .user_presences
+ .get(&bare_jid)
+ {
if let Some(resource) = from.resourcepart() {
- presences.write().update_presence(resource.clone(), presence);
+ 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));
+ 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);
+ let new_message = ArcMacawMessage::got_message_and_user(message, from).await;
debug!("after got message");
spawn_local(async move {
message_subscriptions
@@ -134,15 +164,21 @@ pub fn Macaw(
});
}
UpdateMessage::SubscriptionRequest(jid) => {
- set_subscription_requests.update(|req| { req.insert(jid); });
+ set_subscription_requests.update(|req| {
+ req.insert(jid);
+ });
}
UpdateMessage::NickChanged { jid, nick } => {
- users_store.modify(&jid, |user| {
+ users_store.modify(&jid, |(user, _avatar)| {
user.update(|user| *&mut user.nick = nick.clone())
});
}
UpdateMessage::AvatarChanged { jid, id } => {
- users_store.modify(&jid, |user| *&mut user.write().avatar = id.clone());
+ 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())
+ });
}
}
}
@@ -152,11 +188,12 @@ pub fn Macaw(
<Sidebar />
// <ChatsList />
<OpenChatsPanelView />
- {move || if let Some(_) = *show_settings.read() {
- view! { <Settings /> }.into_any()
- } else {
- view! {}.into_any()
+ {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
index ddc9ec9..375e8f3 100644
--- a/src/views/macaw/open_chats_panel.rs
+++ b/src/views/macaw/open_chats_panel.rs
@@ -1,3 +1,7 @@
+// 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};
@@ -53,19 +57,25 @@ mod open_chat {
use leptos::prelude::*;
use reactive_stores::{ArcStore, Store};
- use crate::{chat::MacawChat, components::{chat_header::ChatViewHeader, message_composer::ChatViewMessageComposer, message_history_buffer::MessageHistoryBuffer}};
+ use crate::{
+ chat::MacawChat,
+ components::{
+ chat_header::ChatViewHeader, message_composer::ChatViewMessageComposer,
+ message_history_buffer::MessageHistoryBuffer,
+ },
+ };
#[component]
pub fn OpenChatView(chat: MacawChat) -> impl IntoView {
- let chat_jid = move || chat.chat.try_get_value().unwrap().get().correspondent().get();
-
view! {
<div class="open-chat-view">
<ChatViewHeader chat=chat.clone() />
<MessageHistoryBuffer chat=chat.clone() />
- <ChatViewMessageComposer chat=chat_jid() />
+ {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
index 11a3fc3..7bdc2b9 100644
--- a/src/views/macaw/settings.rs
+++ b/src/views/macaw/settings.rs
@@ -1,26 +1,71 @@
+// 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};
+use crate::{
+ components::{icon::IconComponent, modal::Modal},
+ icon::Icon,
+};
mod profile_settings {
- use filamento::error::{AvatarPublishError, CommandError};
- use thiserror::Error;
+ use filamento::{
+ error::{AvatarPublishError, CommandError, NickError},
+ user::User,
+ };
use leptos::prelude::*;
- use web_sys::{js_sys::Uint8Array, wasm_bindgen::{prelude::Closure, JsCast, UnwrapThrowExt}, Event, FileReader, HtmlInputElement, ProgressEvent};
+ 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};
+ 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 || {
@@ -32,17 +77,35 @@ mod profile_settings {
}
})
};
+
+ 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();
@@ -56,7 +119,7 @@ mod profile_settings {
// `.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();
+ let data = Uint8Array::new(&result).to_vec();
// Do whatever you want with the Vec<u8>
profile_upload_data.set(Some(data));
})
@@ -92,32 +155,141 @@ mod profile_settings {
let save_profile = Action::new_local(move |_| {
let client = client.clone();
- async move {}
+ 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 new_nick= RwSignal::new("".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">
- <form on:submit=move |ev| {
+ <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 class="change-avatar">
- <input type="file" id="client-user-avatar" on:change=from_input />
+ <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>
- <input disabled=profile_save_pending placeholder="Nickname" type="text" id="client-user-nick" bind:value=new_nick name="client-user-nick" />
- <input disabled=profile_save_pending class="button" type="submit" value="Save Changes" />
+ <hr />
+ <input
+ disabled=profile_save_pending
+ class="button"
+ type="submit"
+ value="Save Changes"
+ />
</form>
</div>
- <div class="profile-preview">
- <h2>Profile Preview</h2>
- <div class="preview">
- <img />
- <div>nick</div>
- </div>
- </div>
}
}
}
@@ -135,31 +307,81 @@ pub fn Settings() -> impl IntoView {
let show_settings: RwSignal<Option<SettingsPage>> = use_context().unwrap();
view! {
- <Modal on_background_click=move |_| { show_settings.set(None); }>
+ <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)/>
+ <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
+ class:open=move || *show_settings.read() == Some(SettingsPage::Account)
+ on:click=move |_| show_settings.set(Some(SettingsPage::Account))
+ >
+ Account
+ </div>
+ <div
+ class:open=move || *show_settings.read() == Some(SettingsPage::Chat)
+ on:click=move |_| show_settings.set(Some(SettingsPage::Chat))
+ >
+ Chat
+ </div>
+ <div
+ class:open=move || *show_settings.read() == Some(SettingsPage::Privacy)
+ on:click=move |_| show_settings.set(Some(SettingsPage::Privacy))
+ >
+ Privacy
+ </div>
+ <div
+ class:open=move || *show_settings.read() == Some(SettingsPage::Profile)
+ on:click=move |_| show_settings.set(Some(SettingsPage::Profile))
+ >
+ Profile
+ </div>
</div>
<div class="settings-page">
- {move || if let Some(page) = show_settings.get() {
- match page {
- SettingsPage::Account => view! { <div>"account"</div> }.into_any(),
- SettingsPage::Chat => view! { <div>"chat"</div> }.into_any(),
- SettingsPage::Profile => view! { <ProfileSettings /> }.into_any(),
- SettingsPage::Privacy => view! { <div>"privacy"</div> }.into_any(),
+ {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()
}
- } else {
- view! {}.into_any()
}}
</div>
</div>
@@ -167,4 +389,3 @@ pub fn Settings() -> impl IntoView {
</Modal>
}
}
-
diff --git a/src/views/mod.rs b/src/views/mod.rs
index 112f930..69ba606 100644
--- a/src/views/mod.rs
+++ b/src/views/mod.rs
@@ -1,3 +1,7 @@
+// 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;
@@ -33,4 +37,3 @@ pub fn App() -> impl IntoView {
}}
}
}
-