From 6ee4190a26f32bfa953302ee363ad3bb6c384ebb Mon Sep 17 00:00:00 2001 From: cel 🌸 Date: Sun, 1 Jun 2025 16:10:26 +0100 Subject: refactor: reorganise code --- src/views/login_page.rs | 189 ++++++++++++++++++++++++++++++++++++ src/views/macaw.rs | 162 +++++++++++++++++++++++++++++++ src/views/macaw/open_chats_panel.rs | 73 ++++++++++++++ src/views/macaw/settings.rs | 170 ++++++++++++++++++++++++++++++++ src/views/mod.rs | 36 +++++++ 5 files changed, 630 insertions(+) create mode 100644 src/views/login_page.rs create mode 100644 src/views/macaw.rs create mode 100644 src/views/macaw/open_chats_panel.rs create mode 100644 src/views/macaw/settings.rs create mode 100644 src/views/mod.rs (limited to 'src/views') diff --git a/src/views/login_page.rs b/src/views/login_page.rs new file mode 100644 index 0000000..2edd4b5 --- /dev/null +++ b/src/views/login_page.rs @@ -0,0 +1,189 @@ +use std::{str::FromStr, sync::Arc}; + +use filamento::{db::Db, error::{CommandError, ConnectionError}, files::{opfs::OPFSError, FilesMem, FilesOPFS}, UpdateMessage}; +use jid::JID; +use thiserror::Error; +use leptos::prelude::*; +use tokio::sync::mpsc::Receiver; +use tracing::debug; + +use crate::{client::Client, files::Files}; + +use super::AppState; + +#[derive(Clone, Debug, Error)] +pub enum LoginError { + #[error("Missing Password")] + MissingPassword, + #[error("Missing JID")] + MissingJID, + #[error("Invalid JID: {0}")] + InvalidJID(#[from] jid::ParseError), + #[error("Connection Error: {0}")] + ConnectionError(#[from] CommandError), + #[error("OPFS: {0}")] + OPFS(#[from] OPFSError), +} + +#[component] +pub fn LoginPage( + set_app: WriteSignal, + set_client: WriteSignal)>>, +) -> impl IntoView { + let jid = RwSignal::new("".to_string()); + let password = RwSignal::new("".to_string()); + let remember_me = RwSignal::new(false); + let connect_on_login = RwSignal::new(true); + + let (error, set_error) = signal(None::); + let error_message = move || { + error.with(|error| { + if let Some(error) = error { + view! {
{error.to_string()}
}.into_any() + } else { + view! {}.into_any() + } + }) + }; + + let (login_pending, set_login_pending) = signal(false); + + let login = Action::new_local(move |_| { + async move { + set_login_pending.set(true); + + if jid.read_untracked().is_empty() { + set_error.set(Some(LoginError::MissingJID)); + set_login_pending.set(false); + return; + } + + if password.read_untracked().is_empty() { + set_error.set(Some(LoginError::MissingPassword)); + set_login_pending.set(false); + return; + } + + let jid = match JID::from_str(&jid.read_untracked()) { + Ok(j) => j, + Err(e) => { + set_error.set(Some(e.into())); + set_login_pending.set(false); + return; + } + }; + + let remember_me = remember_me.get_untracked(); + // initialise the client + let db = if remember_me { + 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() + }; + let files = if remember_me { + let opfs = FilesOPFS::new(jid.as_bare().to_string()).await; + match opfs { + Ok(f) => Files::Opfs(f), + Err(e) => { + set_error.set(Some(e.into())); + set_login_pending.set(false); + return; + } + } + } else { + Files::Mem(FilesMem::new()) + }; + let (client, updates) = filamento::Client::new( + jid.clone(), + password.read_untracked().clone(), + db, + files.clone(), + ); + let resource = ArcRwSignal::new(None::); + let client = Client { + client, + resource: resource.clone(), + jid: Arc::new(jid.to_bare()), + file_store: files, + }; + + if *connect_on_login.read_untracked() { + match client.connect().await { + Ok(r) => { + resource.set(Some(r)) + } + Err(e) => { + set_error.set(Some(e.into())); + set_login_pending.set(false); + return; + } + } + } + + // debug!("before setting app state"); + set_client.set(Some((client, updates))); + set_app.set(AppState::LoggedIn); + } + }); + + view! { +
+
+
+ +

Macaw Instant Messenger

+
+ {error_message} +
+ + + + +
+ + +
+
+ + +
+ +
+
+
+ } +} diff --git a/src/views/macaw.rs b/src/views/macaw.rs new file mode 100644 index 0000000..18e0ad3 --- /dev/null +++ b/src/views/macaw.rs @@ -0,0 +1,162 @@ +use std::collections::HashSet; + +use filamento::{chat::{Chat, Message, MessageStoreFields}, user::User, UpdateMessage}; +use jid::BareJID; +use leptos::{prelude::*, task::spawn_local}; +use open_chats_panel::OpenChatsPanelView; +use reactive_stores::{ArcStore, Store}; +use settings::{Settings, SettingsPage}; +use tokio::sync::mpsc::Receiver; +use tracing::debug; +use uuid::Uuid; + +use crate::{client::Client, components::sidebar::Sidebar, contact::MacawContact, message::MacawMessage, message_subscriptions::MessageSubscriptions, open_chats::OpenChatsPanel, roster::{Roster, RosterStoreFields}, state_store::StateStore, user::MacawUser, user_presences::{Presences, UserPresences}}; + +use super::AppState; + +pub mod settings; +mod open_chats_panel; + +#[component] +pub fn Macaw( + // TODO: logout + // app_state: WriteSignal)>, LocalStorage>, + client: Client, + mut updates: Receiver, + set_app: WriteSignal, +) -> impl IntoView { + provide_context(set_app); + provide_context(client); + + let roster = Store::new(Roster::new()); + provide_context(roster); + + let message_subscriptions = RwSignal::new(MessageSubscriptions::new()); + provide_context(message_subscriptions); + + let messages_store: StateStore> = StateStore::new(); + provide_context(messages_store); + let chats_store: StateStore> = StateStore::new(); + provide_context(chats_store); + let users_store: StateStore> = StateStore::new(); + provide_context(users_store); + + let open_chats = Store::new(OpenChatsPanel::default()); + provide_context(open_chats); + let show_settings = RwSignal::new(None::); + provide_context(show_settings); + + let user_presences = Store::new(UserPresences::new()); + provide_context(user_presences); + + let client_user = LocalResource::new(move || { + async move { + let client = use_context::().expect("client not in context"); + let user = client.get_user((*client.jid).clone()).await.unwrap(); + MacawUser::got_user(user) + } + }); + provide_context(client_user); + + // TODO: timestamp incoming/outgoing subscription requests + let (subscription_requests, set_subscription_requests)= signal(HashSet::::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 { + 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(), + MacawContact::got_contact_and_user(contact, user), + ) + }) + .collect(); + roster.contacts().set(contacts); + } + UpdateMessage::Offline(offline) => { + // when offline, will no longer receive updated user presences, consider everybody offline. + user_presences.write().clear(); + } + UpdateMessage::RosterUpdate(contact, user) => { + 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 = MacawContact::got_contact_and_user(contact, user); + roster.insert(jid, contact); + } + }); + } + UpdateMessage::RosterDelete(jid) => { + roster.contacts().update(|roster| { + roster.remove(&jid); + }); + } + 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(resource) = from.resourcepart() { + 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)); + } + } + } + UpdateMessage::Message { to, from, message } => { + debug!("before got message"); + let new_message = MacawMessage::got_message_and_user(message, from); + debug!("after got message"); + spawn_local(async move { + message_subscriptions + .write() + .broadcast(to, new_message) + .await + }); + debug!("after set message"); + } + UpdateMessage::MessageDelivery { id, chat, delivery } => { + messages_store.modify(&id, |message| { + as Clone>::clone(&message) + .delivery() + .set(Some(delivery)) + }); + } + UpdateMessage::SubscriptionRequest(jid) => { + set_subscription_requests.update(|req| { req.insert(jid); }); + } + UpdateMessage::NickChanged { jid, nick } => { + users_store.modify(&jid, |user| { + user.update(|user| *&mut user.nick = nick.clone()) + }); + } + UpdateMessage::AvatarChanged { jid, id } => { + users_store.modify(&jid, |user| *&mut user.write().avatar = id.clone()); + } + } + } + }); + + view! { + + // + + {move || if let Some(_) = *show_settings.read() { + view! { }.into_any() + } else { + view! {}.into_any() + }} + } +} + diff --git a/src/views/macaw/open_chats_panel.rs b/src/views/macaw/open_chats_panel.rs new file mode 100644 index 0000000..062c786 --- /dev/null +++ b/src/views/macaw/open_chats_panel.rs @@ -0,0 +1,73 @@ +use leptos::prelude::*; +use open_chat::OpenChatView; +use reactive_stores::{ArcStore, Store}; + +use crate::open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}; + +// TODO: multiple panels +// pub struct OpenChats { +// panels: +// } + +#[component] +pub fn OpenChatsPanelView() -> impl IntoView { + let open_chats: Store = use_context().expect("no open chats panel in context"); + + // TODO: tabs + // view! { + // {move || { + // if open_chats.chats().read().len() > 1 { + // Some( + // view! { + // + // }, + // ) + // } else { + // None + // } + // }} + // } + view! { +
+ {move || { + if let Some(open_chat) = open_chats.chat_view().get() { + if let Some(open_chat) = open_chats.chats().read().get(&open_chat) { + view! { }.into_any() + } else { + view! {}.into_any() + } + } else { + view! {}.into_any() + } + }} +
+ } +} + +mod open_chat { + use filamento::chat::{Chat, ChatStoreFields}; + use leptos::prelude::*; + use reactive_stores::{ArcStore, Store}; + + 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_chat: Store = + as Clone>::clone(&chat.chat).into(); + let chat_jid = move || chat_chat.correspondent().get(); + + view! { +
+ + + +
+ } + } +} + diff --git a/src/views/macaw/settings.rs b/src/views/macaw/settings.rs new file mode 100644 index 0000000..11a3fc3 --- /dev/null +++ b/src/views/macaw/settings.rs @@ -0,0 +1,170 @@ +use leptos::prelude::*; +use profile_settings::ProfileSettings; + +use crate::{components::{icon::IconComponent, modal::Modal}, icon::Icon}; + +mod profile_settings { + use filamento::error::{AvatarPublishError, CommandError}; + use thiserror::Error; + use leptos::prelude::*; + use web_sys::{js_sys::Uint8Array, wasm_bindgen::{prelude::Closure, JsCast, UnwrapThrowExt}, Event, FileReader, HtmlInputElement, ProgressEvent}; + + use crate::{client::Client, files::Files}; + + #[derive(Debug, Clone, Error)] + pub enum ProfileSaveError { + #[error("avatar publish: {0}")] + Avatar(#[from] CommandError>), + } + + #[component] + pub fn ProfileSettings() -> 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::); + let error_message = move || { + error.with(|error| { + if let Some(error) = error { + view! {
{error.to_string()}
}.into_any() + } else { + view! {}.into_any() + } + }) + }; + let (profile_save_pending, set_profile_save_pending) = signal(false); + + let profile_upload_data = RwSignal::new(None::>); + let from_input = move |ev: Event| { + let elem = ev.target().unwrap().unchecked_into::(); + + // 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 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 + 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(); + async move {} + }); + + let new_nick= RwSignal::new("".to_string()); + + view! { +
+
+ {error_message} +
+ +
+ + +
+
+
+

Profile Preview

+
+ +
nick
+
+
+ } + } +} + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum SettingsPage { + Account, + Chat, + Profile, + Privacy, +} + +#[component] +pub fn Settings() -> impl IntoView { + let show_settings: RwSignal> = use_context().unwrap(); + + view! { + +
+
+

Settings

+
+ +
+
+
+
+
Account
+
Chat
+
Privacy
+
Profile
+
+
+ {move || if let Some(page) = show_settings.get() { + match page { + SettingsPage::Account => view! {
"account"
}.into_any(), + SettingsPage::Chat => view! {
"chat"
}.into_any(), + SettingsPage::Profile => view! { }.into_any(), + SettingsPage::Privacy => view! {
"privacy"
}.into_any(), + } + } else { + view! {}.into_any() + }} +
+
+
+
+ } +} + diff --git a/src/views/mod.rs b/src/views/mod.rs new file mode 100644 index 0000000..112f930 --- /dev/null +++ b/src/views/mod.rs @@ -0,0 +1,36 @@ +use filamento::UpdateMessage; +use leptos::prelude::*; +use login_page::LoginPage; +use macaw::Macaw; +use tokio::sync::mpsc::Receiver; + +use crate::client::Client; + +pub mod login_page; +pub mod macaw; + +pub enum AppState { + LoggedOut, + LoggedIn, +} + +#[component] +pub fn App() -> impl IntoView { + let (app, set_app) = signal(AppState::LoggedOut); + let (client, set_client) = signal(None::<(Client, Receiver)>); + + view! { + {move || match &*app.read() { + AppState::LoggedOut => view! { }.into_any(), + AppState::LoggedIn => { + if let Some((client, updates)) = set_client.write_untracked().take() { + view! { }.into_any() + } else { + set_app.set(AppState::LoggedOut); + view! { }.into_any() + } + } + }} + } +} + -- cgit