diff options
Diffstat (limited to 'src/lib.rs')
-rw-r--r-- | src/lib.rs | 220 |
1 files changed, 195 insertions, 25 deletions
@@ -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() } |