summaryrefslogtreecommitdiffstats
path: root/src/views/macaw
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/views/macaw.rs114
-rw-r--r--src/views/macaw/open_chats_panel.rs17
-rw-r--r--src/views/macaw/settings.rs295
3 files changed, 346 insertions, 80 deletions
diff --git a/src/views/macaw.rs b/src/views/macaw.rs
index 328e3d2..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_untracked().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
@@ -128,20 +158,27 @@ pub fn Macaw(
}
UpdateMessage::MessageDelivery { id, chat, delivery } => {
messages_store.modify(&id, |message| {
- <ArcStore<filamento::chat::Message> as Clone>::clone(&message).delivery()
+ <ArcStore<filamento::chat::Message> as Clone>::clone(&message)
+ .delivery()
.set(Some(delivery))
});
}
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())
+ });
}
}
}
@@ -151,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 bdb0084..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,7 +57,13 @@ 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 {
@@ -63,12 +73,9 @@ mod open_chat {
<MessageHistoryBuffer chat=chat.clone() />
{move || {
let chat_jid = chat.get().correspondent().get();
- view! {
- <ChatViewMessageComposer chat=chat_jid />
- }
+ 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>
}
}
-