diff options
| author | 2025-06-01 16:10:26 +0100 | |
|---|---|---|
| committer | 2025-06-01 17:27:40 +0100 | |
| commit | 6ee4190a26f32bfa953302ee363ad3bb6c384ebb (patch) | |
| tree | 2c3182c29d5780a0ad9c9770b5e546312bea49b4 /src/views | |
| parent | f76c80c1d23177ab00c81240ee3a75d3bcda0e3b (diff) | |
| download | macaw-web-6ee4190a26f32bfa953302ee363ad3bb6c384ebb.tar.gz macaw-web-6ee4190a26f32bfa953302ee363ad3bb6c384ebb.tar.bz2 macaw-web-6ee4190a26f32bfa953302ee363ad3bb6c384ebb.zip  | |
refactor: reorganise code
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() +                } +            } +        }} +    } +} +  | 
