diff options
| -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()          }  | 
