summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar cel 🌸 <cel@bunny.garden>2025-05-08 10:17:25 +0100
committerLibravatar cel 🌸 <cel@bunny.garden>2025-05-08 10:17:25 +0100
commite8aa5b71df378669ff732444a5f8c57098052e5e (patch)
tree66516c48fe7d9531274b68b2b21aa8366913d87d
parent0d8855bee79cc493f40b5092434bce724a3adb55 (diff)
downloadmacaw-web-e8aa5b71df378669ff732444a5f8c57098052e5e.tar.gz
macaw-web-e8aa5b71df378669ff732444a5f8c57098052e5e.tar.bz2
macaw-web-e8aa5b71df378669ff732444a5f8c57098052e5e.zip
feat: sidebar, files in opfs
-rw-r--r--.helix/languages.toml2
-rw-r--r--Cargo.lock1
-rw-r--r--Cargo.toml2
-rw-r--r--Trunk.toml2
-rw-r--r--assets/style.scss68
-rw-r--r--index.html2
-rw-r--r--src/lib.rs220
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"] }
diff --git a/Cargo.lock b/Cargo.lock
index a47f772..2c12742 100644
--- a/Cargo.lock
+++ b/Cargo.lock
@@ -834,6 +834,7 @@ dependencies = [
"uuid",
"wasm-bindgen",
"wasm-bindgen-futures",
+ "web-sys",
]
[[package]]
diff --git a/Cargo.toml b/Cargo.toml
index b535a9a..53db452 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -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" }
diff --git a/Trunk.toml b/Trunk.toml
index a669c48..6e16d80 100644
--- a/Trunk.toml
+++ b/Trunk.toml
@@ -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 */
diff --git a/index.html b/index.html
index 7255857..7f241d5 100644
--- a/index.html
+++ b/index.html
@@ -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 />
diff --git a/src/lib.rs b/src/lib.rs
index a6dd648..2aa32ee 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -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()
}