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>
}
}