diff options
author | 2025-05-08 10:17:25 +0100 | |
---|---|---|
committer | 2025-05-08 10:17:25 +0100 | |
commit | e8aa5b71df378669ff732444a5f8c57098052e5e (patch) | |
tree | 66516c48fe7d9531274b68b2b21aa8366913d87d | |
parent | 0d8855bee79cc493f40b5092434bce724a3adb55 (diff) | |
download | macaw-web-e8aa5b71df378669ff732444a5f8c57098052e5e.tar.gz macaw-web-e8aa5b71df378669ff732444a5f8c57098052e5e.tar.bz2 macaw-web-e8aa5b71df378669ff732444a5f8c57098052e5e.zip |
feat: sidebar, files in opfs
-rw-r--r-- | .helix/languages.toml | 2 | ||||
-rw-r--r-- | Cargo.lock | 1 | ||||
-rw-r--r-- | Cargo.toml | 2 | ||||
-rw-r--r-- | Trunk.toml | 2 | ||||
-rw-r--r-- | assets/style.scss | 68 | ||||
-rw-r--r-- | index.html | 2 | ||||
-rw-r--r-- | src/lib.rs | 220 |
7 files changed, 258 insertions, 39 deletions
diff --git a/.helix/languages.toml b/.helix/languages.toml index 75f513c..80cd6e7 100644 --- a/.helix/languages.toml +++ b/.helix/languages.toml @@ -1,5 +1,5 @@ [language-server.rust-analyzer] command = "rust-analyzer" -config = { rustfmt.overrideCommand = ["leptosfmt", "--stdin", "--rustfmt"], cargo.features = ["filamento/reactive_stores"] } +config = { rustfmt.overrideCommand = ["leptosfmt", "--stdin", "--rustfmt"], cargo.features = ["filamento/reactive_stores"], cargo.target = "wasm32-unknown-unknown" } # environment = { "DATABASE_URL" = "sqlite://filamento.db" } # config = { cargo.features = ["stanza/rfc_6121", "stanza/xep_0203", "stanza/xep_0030", "stanza/xep_0060", "stanza/xep_0172", "stanza/xep_0390", "stanza/xep_0128", "stanza/xep_0115", "stanza/xep_0084", "sqlx/sqlite", "sqlx/runtime-tokio", "sqlx/uuid", "sqlx/chrono", "jid/sqlx", "uuid/v4", "tokio/full", "rsasl/provider_base64", "rsasl/plain", "rsasl/config_builder", "rsasl/scram-sha-1"] } @@ -834,6 +834,7 @@ dependencies = [ "uuid", "wasm-bindgen", "wasm-bindgen-futures", + "web-sys", ] [[package]] @@ -8,7 +8,7 @@ base64 = "0.22.1" chrono = "0.4.41" chrono-humanize = "0.2.3" console_error_panic_hook = "0.1.7" -filamento = { path = "../luz/filamento", features = ["serde", "reactive_stores"] } +filamento = { path = "../luz/filamento", features = ["serde", "reactive_stores", "opfs"] } futures = "0.3.31" indexmap = "2.9.0" jid = { path = "../luz/jid" } @@ -43,6 +43,8 @@ port = 3000 # disable_address_lookup = false # Open a browser tab once the initial build is complete. open = false +headers = { "Cross-Origin-Embedder-Policy" = "require-corp", "Cross-Origin-Opener-Policy" = "same-origin" } + # Whether to disable fallback to index.html for missing files. # no_spa = false # Disable auto-reload of the web app. diff --git a/assets/style.scss b/assets/style.scss index 8020885..14909c5 100644 --- a/assets/style.scss +++ b/assets/style.scss @@ -139,7 +139,7 @@ p { margin: 0; } -.chats-list { +.chats-list, .roster-list { border-width: 0 2px 0 0; border-color: black; border-style: solid; @@ -147,18 +147,21 @@ p { width: 400px; flex: 0 0 auto; align-self: stretch; + display: flex; + flex-direction: column; } -.chats-list>* { +.chats-list>*, .roster-list>* { padding: 8px 16px; } -.chats-list h2 { +.chats-list h2, .roster-list h2 { margin-top: 0.4rem; border-bottom: 2px solid black; + flex: 0 0 auto; } -.chats-list-item { +.chats-list-item, .roster-list-item, { display: flex; gap: 8px; border-radius: 1em; @@ -168,7 +171,9 @@ p { } .chats-list-item:hover, -.chats-list-item.open { +.chats-list-item.open, +.roster-list-item:hover, +.roster-list-item.open, { background: #00000060; /* color: black; */ /* border: 2px solid black; */ @@ -183,13 +188,14 @@ p { height: 48px; } -.chats-list-item .avatar { +.chats-list-item .avatar, .roster-list-item .avatar { width: 48px; height: 48px; } -.chats-list-chats { - height: auto; +.chats-list-chats, .roster-list-roster { + flex-grow: 1; + overflow: scroll; } .open-chat-views { @@ -392,22 +398,62 @@ p { opacity: 0; } +.sidebar { + display: flex; +} + .dock { display: flex; flex-direction: column; border-width: 0 2px 0 0; } -.dock-icon { - margin: 8px; +.shortcuts { + padding: 8px 0; +} + +.dock-item { + display: flex; + justify-content: center; + position: relative; +} + +.dock-pill { + position: absolute; + border-radius: 50%; + left: -4px; + top: calc(50% - 4px); + width: 8px; + height: 8px; + opacity: 0; + background-color: #dcdcdc; + transition-duration: 250ms; +} + +.dock-item.open .dock-pill { + opacity: 100%; +} + +.dock-item:hover .dock-pill, .dock-item.hovering .dock-pill { + top: calc(50% - 14px); + height: 28px; + opacity: 100%; +} + +.dock-item.focused .dock-pill { + top: calc(50% - 24px); + height: 48px; + opacity: 100%; } .shortcuts { border-bottom: 2px solid black; } -.dock-icon img { +.dock-item img { width: 64px; + height: 64px; + padding: 4px 8px; } /* font-families */ @@ -8,7 +8,7 @@ <link data-trunk rel="scss" href="assets/style.scss" /> <!-- Include favicon in dist output: see https://trunkrs.dev/assets/#icon --> - <link data-trunk rel="icon" href="assets/icon.png" /> + <link data-trunk rel="icon" href="assets/bubble.png" /> <!-- include support for `wasm-bindgen --weak-refs` - see: https://rustwasm.github.io/docs/wasm-bindgen/reference/weak-references.html --> <link data-trunk rel="rust" data-wasm-opt="z" data-weak-refs /> @@ -14,7 +14,7 @@ use std::{ use base64::{prelude::BASE64_STANDARD, Engine}; use chrono::{NaiveDateTime, TimeDelta}; use filamento::{ - chat::{Body, Chat, ChatStoreFields, Delivery, Message, MessageStoreFields}, db::Db, error::{CommandError, ConnectionError, DatabaseError}, files::FilesMem, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}, UpdateMessage + chat::{Body, Chat, ChatStoreFields, Delivery, Message, MessageStoreFields}, db::Db, error::{CommandError, ConnectionError, DatabaseError}, files::FileStore, files::opfs::OPFSError, files::FilesMem, files::FilesOPFS, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}, UpdateMessage }; use futures::stream::StreamExt; use indexmap::IndexMap; @@ -28,7 +28,7 @@ use reactive_stores::{ArcStore, Store, StoreField}; use stylance::import_style; use thiserror::Error; use tokio::sync::{mpsc::{self, Receiver}, Mutex}; -use tracing::debug; +use tracing::{debug, error}; use uuid::Uuid; const NO_AVATAR: &str = "/assets/no-avatar.png"; @@ -40,13 +40,64 @@ pub enum AppState { #[derive(Clone)] pub struct Client { - client: filamento::Client<FilesMem>, + client: filamento::Client<Files>, jid: Arc<JID>, - file_store: FilesMem, + file_store: Files, +} + +#[derive(Clone)] +pub enum Files { + Mem(FilesMem), + Opfs(FilesOPFS), +} + +impl FileStore for Files { + type Err = OPFSError; + + async fn is_stored(&self, name: &str) -> Result<bool, Self::Err> { + match self { + Files::Mem(files_mem) => Ok(files_mem.is_stored(name).await.unwrap()), + Files::Opfs(files_opfs) => Ok(files_opfs.is_stored(name).await?), + } + } + + async fn store( + &self, + name: &str, + data: &[u8], + ) -> Result<(), Self::Err> { + match self { + Files::Mem(files_mem) => Ok(files_mem.store(name, data).await.unwrap()), + Files::Opfs(files_opfs) => Ok(files_opfs.store(name, data).await?), + } + } + + async fn delete(&self, name: &str) -> Result<(), Self::Err> { + match self { + Files::Mem(files_mem) => Ok(files_mem.delete(name).await.unwrap()), + Files::Opfs(files_opfs) => Ok(files_opfs.delete(name).await?), + } + } +} + +impl Files { + pub async fn get_src(&self, file_name: &str) -> Option<String> { + match self { + Files::Mem(files_mem) => { + if let Some(data) = files_mem.get_file(file_name).await { + let data = BASE64_STANDARD.encode(data); + Some(format!("data:image/jpg;base64, {}", data)) + } else { + None + } + }, + Files::Opfs(files_opfs) => files_opfs.get_src(file_name).await.ok(), + } + } } impl Deref for Client { - type Target = filamento::Client<FilesMem>; + type Target = filamento::Client<Files>; fn deref(&self) -> &Self::Target { &self.client @@ -89,6 +140,8 @@ pub enum LoginError { InvalidJID(#[from] jid::ParseError), #[error("Connection Error: {0}")] ConnectionError(#[from] CommandError<ConnectionError>), + #[error("OPFS: {0}")] + OPFS(#[from] OPFSError), } #[component] @@ -138,14 +191,26 @@ fn LoginPage(set_app: WriteSignal<AppState>, set_client: RwSignal<Option<(Client // initialise the client let db = Db::create_connect_and_migrate("mem.db").await.unwrap(); - let files_mem = FilesMem::new(); + let files = if remember_me.get_untracked() { + 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_mem.clone()); + filamento::Client::new(jid.clone(), password.read_untracked().clone(), db, files.clone()); // TODO: remember_me let client = Client { client, jid: Arc::new(jid), - file_store: files_mem, + file_store: files, }; if *connect_on_login.read_untracked() { @@ -312,7 +377,7 @@ impl MessageSubscriptions { } } -#[derive(Store)] +#[derive(Store, Clone)] pub struct Roster { #[store(key: JID = |(jid, _)| jid.clone())] contacts: HashMap<JID, MacawContact>, @@ -464,28 +529,70 @@ fn Macaw( }); view! { - <Dock /> - <ChatsList /> + <Sidebar /> + // <ChatsList /> <OpenChatsPanelView /> } } +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum SidebarOpen { + Roster, + Chats, +} + +pub fn toggle_open(state: &mut Option<SidebarOpen>, open: SidebarOpen) { + match state { + Some(opened) => if *opened == open { + *state = None + } else { + *state = Some(open) + }, + None => *state = Some(open), + } +} + #[component] -pub fn Dock() -> impl IntoView { +pub fn Sidebar() -> impl IntoView { + // for what has been clicked open (in the background) + let (open, set_open) = signal(None::<SidebarOpen>); + // for what is just in the hovered state (not clicked to be pinned open yet necessarily) + let (hovered, set_hovered) = signal(None::<SidebarOpen>); + view! { - <div class="dock panel"> - <div class="shortcuts"> - <div class="roster-tab dock-icon"> - <img src="/assets/caw.png" /> + <div class="sidebar"> + <div class="dock panel"> + <div class="shortcuts"> + <div class="roster-tab dock-item" class:focused=move || *open.read() == Some(SidebarOpen::Roster) class:hovering=move || *hovered.read() == Some(SidebarOpen::Chats) on:click=move |_| { + set_open.update(|state| toggle_open(state, SidebarOpen::Roster)) + }> + <div class="dock-pill"></div> + <img src="/assets/caw.png" /> + </div> + <div class="chats-tab dock-item" class:focused=move || *open.read() == Some(SidebarOpen::Chats) class:hovering=move || *hovered.read() == Some(SidebarOpen::Chats) on:click=move |_| { + set_open.update(|state| toggle_open(state, SidebarOpen::Chats)) + }> + <div class="dock-pill"></div> + <img src="/assets/bubble.png" /> + </div> </div> - <div class="chats-tab dock-icon"> - <img src="/assets/bubble.png" /> + <div class="pins"> + </div> + <div class="personal"> </div> </div> - <div class="pins"> - </div> - <div class="personal"> - </div> + {move || if let Some(opened) = *open.read() { + match opened { + SidebarOpen::Roster => view! { + <RosterList /> + }.into_any(), + SidebarOpen::Chats => view! { + <ChatsList /> + }.into_any(), + } + } else { + view! {}.into_any() + }} </div> } } @@ -1149,6 +1256,7 @@ impl DerefMut for MacawUser { } } +#[derive(Clone)] struct MacawContact { contact: Store<Contact>, user: StateListener<JID, ArcStore<User>>, @@ -1248,12 +1356,74 @@ fn ChatsList() -> impl IntoView { } } +#[component] +fn RosterList() -> impl IntoView { + let roster: Store<Roster> = use_context().expect("no roster in context"); + + // TODO: filter new messages signal + view! { + <div class="roster-list panel"> + <h2>Roster</h2> + <div class="roster-list-roster"> + <For each=move || roster.contacts().get() key=|contact| contact.0.clone() let(contact)> + <RosterListItem contact=contact.1 /> + </For> + </div> + </div> + } +} + +#[component] +fn RosterListItem(contact: MacawContact) -> impl IntoView { + let contact_contact: Store<Contact> = contact.contact; + let contact_user: Store<User> = <ArcStore<filamento::user::User> as Clone>::clone(&contact.user).into(); + let avatar = LocalResource::new(move || get_avatar(contact_user)); + let name = move || get_name(contact_user); + + let open_chats: Store<OpenChatsPanel> = use_context().expect("no open chats panel store in context"); + + // TODO: why can this not be in the closure????? + // TODO: not good, as overwrites preexisting chat state with possibly incorrect one... + let chat = Chat { correspondent: contact_user.jid().get(), have_chatted: false }; + let chat = MacawChat::got_chat_and_user(chat, contact_user.get()); + + let open_chat = move |_| { + debug!("opening chat"); + open_chats.update(|open_chats| open_chats.open(chat.clone())); + }; + + let open =move || { + if let Some(open_chat) = &*open_chats.chat_view().read() { + debug!("got open chat: {:?}", open_chat); + if *open_chat == *contact_user.jid().read() { + return Open::Focused + } + } + if let Some(_backgrounded_chat) = open_chats.chats().read().get(contact_user.jid().read().deref()) { + return Open::Open + } + Open::Closed + }; + let focused =move || open().is_focused(); + let open=move || open().is_open(); + + view! { + <div class="roster-list-item" class:open=move || open() class:focused=move || focused() on:click=open_chat> + <Transition fallback=|| view! { <img class="avatar" src=NO_AVATAR /> } > + <img class="avatar" src=move || avatar.read().as_deref().map(|avatar| avatar.clone()).unwrap_or_default() /> + </Transition> + <div class="item-info"> + <h3>{name}</h3> + </div> + </div> + } +} + pub async fn get_avatar(user: Store<User>) -> String { if let Some(avatar) = &user.read().avatar { let client = use_context::<Client>().expect("client not in context"); - if let Some(data) = client.file_store.get_file(avatar).await { - let data = BASE64_STANDARD.encode(data); - format!("data:image/jpg;base64, {}", data) + if let Some(data) = client.file_store.get_src(avatar).await { + data } else { NO_AVATAR.to_string() } |