summaryrefslogtreecommitdiffstats
path: root/src/views/macaw/settings.rs
diff options
context:
space:
mode:
Diffstat (limited to '')
-rw-r--r--src/views/macaw/settings.rs391
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>
+ }
+}