diff options
Diffstat (limited to '')
| -rw-r--r-- | src/views/macaw/settings.rs | 391 |
1 files changed, 391 insertions, 0 deletions
diff --git a/src/views/macaw/settings.rs b/src/views/macaw/settings.rs new file mode 100644 index 0000000..7bdc2b9 --- /dev/null +++ b/src/views/macaw/settings.rs @@ -0,0 +1,391 @@ +// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden> +// +// SPDX-License-Identifier: AGPL-3.0-or-later + +use leptos::prelude::*; +use profile_settings::ProfileSettings; + +use crate::{ + components::{icon::IconComponent, modal::Modal}, + icon::Icon, +}; + +mod profile_settings { + use filamento::{ + error::{AvatarPublishError, CommandError, NickError}, + user::User, + }; + use leptos::prelude::*; + use thiserror::Error; + use web_sys::{ + Event, FileReader, HtmlInputElement, ProgressEvent, Url, + js_sys::Uint8Array, + wasm_bindgen::{JsCast, UnwrapThrowExt, prelude::Closure}, + }; + + use crate::{ + client::Client, + files::Files, + user::{NO_AVATAR, fetch_avatar}, + }; + + #[derive(Debug, Clone, Error)] + pub enum ProfileSaveError { + #[error("avatar publish: {0}")] + Avatar(#[from] CommandError<AvatarPublishError<Files>>), + #[error("nick publish: {0}")] + Nick(#[from] CommandError<NickError>), + } + + #[component] + pub fn ProfileSettings() -> impl IntoView { + let client: Client = use_context().expect("no client in context"); + + let old_profile = LocalResource::new(move || { + let value = client.clone(); + async move { + // TODO: error + let jid = &*value.jid; + let old_profile = value.get_user(jid.clone()).await.unwrap(); + old_profile + } + }); + + view! { + {move || { + if let Some(old_profile) = old_profile.get() { + view! { <ProfileForm old_profile /> }.into_any() + } else { + view! {}.into_any() + } + }} + } + } + + #[component] + pub fn ProfileForm(old_profile: User) -> impl IntoView { + let client: Client = use_context().expect("no client in context"); + + // TODO: compartmentalise into error component, form component... + let (error, set_error) = signal(None::<ProfileSaveError>); + let error_message = move || { + error.with(|error| { + if let Some(error) = error { + view! { <div class="error">{error.to_string()}</div> }.into_any() + } else { + view! {}.into_any() + } + }) + }; + + let (success_message, set_success_message) = signal(None::<String>); + let success_message = move || { + if let Some(message) = success_message.get() { + view! { <div class="success">{message}</div> }.into_any() + } else { + view! {}.into_any() + } + }; + + let (profile_save_pending, set_profile_save_pending) = signal(false); + + let profile_upload_data = RwSignal::new(None::<Vec<u8>>); + let new_nick = RwSignal::new(old_profile.nick.clone().unwrap_or_default().to_string()); + let has_avatar = RwSignal::new(old_profile.avatar.is_some()); + let new_avatar_preview_url = RwSignal::new(None::<String>); + let remove_avatar = RwSignal::new(false); + + let from_input = move |ev: Event| { + let elem = ev.target().unwrap().unchecked_into::<HtmlInputElement>(); + + // let UploadSignal(file_signal) = expect_context(); + // file_signal.update(Vec::clear); // Clear list from previous change + let files = elem.files().unwrap_throw(); + + if let Some(file) = files.get(0) { + let url = Url::create_object_url_with_blob(&file).unwrap_throw(); + + new_avatar_preview_url.set(Some(url)); + let reader = FileReader::new().unwrap_throw(); + // let name = file.name(); + + // This closure only needs to be called a single time as we will just + // remake it on each loop + // * web_sys drops it for us when using this specific constructor + let read_file = { + // FileReader is cloned prior to moving into the closure + let reader = reader.to_owned(); + Closure::once_into_js(move |_: ProgressEvent| { + // `.result` valid after the `read_*` completes on FileReader + // https://developer.mozilla.org/en-US/docs/Web/API/FileReader/result + let result = reader.result().unwrap_throw(); + let data = Uint8Array::new(&result).to_vec(); + // Do whatever you want with the Vec<u8> + profile_upload_data.set(Some(data)); + }) + }; + reader.set_onloadend(Some(read_file.as_ref().unchecked_ref())); + + // read_as_array_buffer takes a &Blob + // + // Per https://w3c.github.io/FileAPI/#file-section + // > A File object is a Blob object with a name attribute.. + // + // File is a subclass (inherits) from the Blob interface, so a File + // can be used anywhere a Blob is required. + reader.read_as_array_buffer(&file).unwrap_throw(); + + // You can also use `.read_as_text(&file)` instead if you just want a string. + // This example shows how to extract an array buffer as it is more flexible + // + // If you use `.read_as_text` change the closure from ... + // + // let result = reader.result().unwrap_throw(); + // let vec_of_u8_bytes = Uint8Array::new(&result).to_vec(); + // let content = String::from_utf8(vec_of_u8_bytes).unwrap_throw(); + // + // to ... + // + // let result = reader.result().unwrap_throw(); + // let content = result.as_string().unwrap_throw(); + } else { + profile_upload_data.set(None); + } + }; + + let save_profile = Action::new_local(move |_| { + let client = client.clone(); + let old_nick = old_profile.nick.clone(); + async move { + set_profile_save_pending.set(true); + + let new_nick = new_nick.get(); + let new_nick = if new_nick.is_empty() { + None + } else { + Some(new_nick) + }; + if new_nick != old_nick { + match client.change_nick(new_nick).await { + Ok(_) => {} + Err(e) => { + set_error.set(Some(ProfileSaveError::Nick(e))); + set_profile_save_pending.set(false); + return; + } + } + } + + if let Some(profile_data) = profile_upload_data.get() { + match client.change_avatar(Some(profile_data)).await { + Ok(_) => {} + Err(e) => { + set_error.set(Some(ProfileSaveError::Avatar(e))); + set_profile_save_pending.set(false); + return; + } + } + } else if remove_avatar.get() { + match client.change_avatar(None).await { + Ok(_) => {} + Err(e) => { + set_error.set(Some(ProfileSaveError::Avatar(e))); + set_profile_save_pending.set(false); + return; + } + } + } + + set_profile_save_pending.set(false); + set_error.set(None); + set_success_message.set(Some("Profile Updated!".to_string())); + } + }); + + let _old_account_avatar = LocalResource::new(move || { + let avatar = old_profile.avatar.clone(); + async move { + let url = fetch_avatar(avatar.as_deref()).await; + new_avatar_preview_url.set(Some(url)); + } + }); + + view! { + <div class="profile-settings"> + <div class="profile-preview"> + <h2>Profile Preview</h2> + <div class="preview"> + <img class="avatar" src=new_avatar_preview_url /> + <div class="nick"> + {move || { + let nick = new_nick.get(); + if nick.is_empty() { old_profile.jid.to_string() } else { nick } + }} + </div> + </div> + </div> + <form + class="profile-form" + on:submit=move |ev| { + ev.prevent_default(); + save_profile.dispatch(()); + } + > + {success_message} + {error_message} + <div> + <h3>Nick</h3> + <input + disabled=profile_save_pending + placeholder="Nick" + type="text" + id="client-user-nick" + bind:value=new_nick + name="client-user-nick" + /> + </div> + <div> + <h3>Avatar</h3> + <div class="change-avatar"> + <label for="client-user-avatar"> + <div class="button">Change Avatar</div> + </label> + <input + type="file" + id="client-user-avatar" + on:change=move |e| { + has_avatar.set(true); + remove_avatar.set(false); + from_input(e); + } + /> + {move || { + if has_avatar.get() { + view! { + <a + on:click=move |_| { + profile_upload_data.set(None); + remove_avatar.set(true); + has_avatar.set(false); + new_avatar_preview_url.set(Some(NO_AVATAR.to_string())); + } + style="cursor: pointer" + > + Remove Avatar + </a> + } + .into_any() + } else { + view! {}.into_any() + } + }} + </div> + </div> + <hr /> + <input + disabled=profile_save_pending + class="button" + type="submit" + value="Save Changes" + /> + </form> + </div> + } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SettingsPage { + Account, + Chat, + Profile, + Privacy, +} + +#[component] +pub fn Settings() -> impl IntoView { + let show_settings: RwSignal<Option<SettingsPage>> = use_context().unwrap(); + + view! { + <Modal on_background_click=move |_| { + show_settings.set(None); + }> + <div class="settings panel"> + <div class="header"> + <h2>Settings</h2> + <div class="header-icon close"> + <IconComponent + icon=Icon::Close24 + on:click=move |_| show_settings.set(None) + /> + </div> + </div> + <div class="settings-main"> + <div class="settings-sidebar"> + <div + class:open=move || *show_settings.read() == Some(SettingsPage::Account) + on:click=move |_| show_settings.set(Some(SettingsPage::Account)) + > + Account + </div> + <div + class:open=move || *show_settings.read() == Some(SettingsPage::Chat) + on:click=move |_| show_settings.set(Some(SettingsPage::Chat)) + > + Chat + </div> + <div + class:open=move || *show_settings.read() == Some(SettingsPage::Privacy) + on:click=move |_| show_settings.set(Some(SettingsPage::Privacy)) + > + Privacy + </div> + <div + class:open=move || *show_settings.read() == Some(SettingsPage::Profile) + on:click=move |_| show_settings.set(Some(SettingsPage::Profile)) + > + Profile + </div> + </div> + <div class="settings-page"> + {move || { + if let Some(page) = show_settings.get() { + match page { + SettingsPage::Account => { + view! { + <div style="padding: 16px"> + "Account settings coming soon!" + </div> + } + .into_any() + } + SettingsPage::Chat => { + view! { + <div style="padding: 16px"> + "Chat settings coming soon!" + </div> + } + .into_any() + } + SettingsPage::Profile => { + view! { <ProfileSettings /> }.into_any() + } + SettingsPage::Privacy => { + view! { + <div style="padding: 16px"> + "Privacy settings coming soon!" + </div> + } + .into_any() + } + } + } else { + view! {}.into_any() + } + }} + </div> + </div> + </div> + </Modal> + } +} |
