summaryrefslogtreecommitdiffstats
path: root/src/views
diff options
context:
space:
mode:
Diffstat (limited to 'src/views')
-rw-r--r--src/views/login_page.rs189
-rw-r--r--src/views/macaw.rs162
-rw-r--r--src/views/macaw/open_chats_panel.rs73
-rw-r--r--src/views/macaw/settings.rs170
-rw-r--r--src/views/mod.rs36
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()
+ }
+ }
+ }}
+ }
+}
+