diff options
Diffstat (limited to 'src/views')
-rw-r--r-- | src/views/login_page.rs | 189 | ||||
-rw-r--r-- | src/views/macaw.rs | 162 | ||||
-rw-r--r-- | src/views/macaw/open_chats_panel.rs | 73 | ||||
-rw-r--r-- | src/views/macaw/settings.rs | 170 | ||||
-rw-r--r-- | src/views/mod.rs | 36 |
5 files changed, 630 insertions, 0 deletions
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<ConnectionError>), + #[error("OPFS: {0}")] + OPFS(#[from] OPFSError), +} + +#[component] +pub fn LoginPage( + set_app: WriteSignal<AppState>, + set_client: WriteSignal<Option<(Client, Receiver<UpdateMessage>)>>, +) -> 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::<LoginError>); + 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 (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::<String>); + 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! { + <div class="center fill"> + <div id="login-form" class="panel"> + <div id="hero"> + <img src="/assets/macaw-icon.png" /> + <h1>Macaw Instant Messenger</h1> + </div> + {error_message} + <form on:submit=move |ev| { + ev.prevent_default(); + login.dispatch(()); + }> + <label for="jid">JID</label> + <input + disabled=login_pending + placeholder="caw@macaw.chat" + type="text" + bind:value=jid + name="jid" + id="jid" + autofocus="true" + /> + <label for="password">Password</label> + <input + disabled=login_pending + placeholder="••••••••" + type="password" + bind:value=password + name="password" + id="password" + /> + <div> + <label for="remember_me">Remember me</label> + <input + disabled=login_pending + type="checkbox" + bind:checked=remember_me + name="remember_me" + id="remember_me" + /> + </div> + <div> + <label for="connect_on_login">Connect on login</label> + <input + disabled=login_pending + type="checkbox" + bind:checked=connect_on_login + name="connect_on_login" + id="connect_on_login" + /> + </div> + <input disabled=login_pending class="button" type="submit" value="Log In" /> + </form> + </div> + </div> + } +} 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<Option<essage>)>, LocalStorage>, + client: Client, + mut updates: Receiver<UpdateMessage>, + set_app: WriteSignal<AppState>, +) -> 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<Uuid, ArcStore<Message>> = StateStore::new(); + 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(); + provide_context(users_store); + + let open_chats = Store::new(OpenChatsPanel::default()); + provide_context(open_chats); + 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::new(move || { + async move { + let client = use_context::<Client>().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::<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 { + 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| { + <ArcStore<filamento::chat::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! { + <Sidebar /> + // <ChatsList /> + <OpenChatsPanelView /> + {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 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<OpenChatsPanel> = use_context().expect("no open chats panel in context"); + + // TODO: tabs + // view! { + // {move || { + // if open_chats.chats().read().len() > 1 { + // Some( + // view! { + // <For + // each=move || open_chats.chats().get() + // key=|(jid, _)| jid.clone() + // let(chat) + // ></For> + // }, + // ) + // } else { + // None + // } + // }} + // } + view! { + <div class="open-chat-views"> + {move || { + if let Some(open_chat) = open_chats.chat_view().get() { + if let Some(open_chat) = open_chats.chats().read().get(&open_chat) { + view! { <OpenChatView chat=open_chat.clone() /> }.into_any() + } else { + view! {}.into_any() + } + } else { + view! {}.into_any() + } + }} + </div> + } +} + +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<Chat> = + <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat).into(); + let chat_jid = move || chat_chat.correspondent().get(); + + view! { + <div class="open-chat-view"> + <ChatViewHeader chat=chat.clone() /> + <MessageHistoryBuffer chat=chat.clone() /> + <ChatViewMessageComposer chat=chat_jid() /> + </div> + } + } +} + 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<AvatarPublishError<Files>>), + } + + #[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::<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 (profile_save_pending, set_profile_save_pending) = signal(false); + + let profile_upload_data = RwSignal::new(None::<Vec<u8>>); + 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 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(); + async move {} + }); + + let new_nick= RwSignal::new("".to_string()); + + view! { + <div class="profile-settings"> + <form on:submit=move |ev| { + ev.prevent_default(); + save_profile.dispatch(()); + }> + {error_message} + <div class="change-avatar"> + <input type="file" id="client-user-avatar" on:change=from_input /> + </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" /> + </form> + </div> + <div class="profile-preview"> + <h2>Profile Preview</h2> + <div class="preview"> + <img /> + <div>nick</div> + </div> + </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>"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(), + } + } else { + view! {}.into_any() + }} + </div> + </div> + </div> + </Modal> + } +} + 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<UpdateMessage>)>); + + view! { + {move || match &*app.read() { + AppState::LoggedOut => view! { <LoginPage set_app set_client /> }.into_any(), + AppState::LoggedIn => { + if let Some((client, updates)) = set_client.write_untracked().take() { + view! { <Macaw client updates set_app /> }.into_any() + } else { + set_app.set(AppState::LoggedOut); + view! { <LoginPage set_app set_client /> }.into_any() + } + } + }} + } +} + |