summaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorLibravatar cel 🌸 <cel@bunny.garden>2025-05-23 14:58:49 +0100
committerLibravatar cel 🌸 <cel@bunny.garden>2025-05-23 14:58:49 +0100
commit07b0d4a4412498123a4436bb6eb643ed34a1bfed (patch)
treea71957d0c43a0ade56f4d8d0af56314e64d58dfc
parentf3cf5c1cfca048cfb496592af18e07782e3d22a2 (diff)
downloadmacaw-web-07b0d4a4412498123a4436bb6eb643ed34a1bfed.tar.gz
macaw-web-07b0d4a4412498123a4436bb6eb643ed34a1bfed.tar.bz2
macaw-web-07b0d4a4412498123a4436bb6eb643ed34a1bfed.zip
feat: roster requests
-rw-r--r--assets/style.scss34
-rw-r--r--src/lib.rs220
2 files changed, 247 insertions, 7 deletions
diff --git a/assets/style.scss b/assets/style.scss
index 0efe7e9..df90e7c 100644
--- a/assets/style.scss
+++ b/assets/style.scss
@@ -666,7 +666,8 @@ hr {
padding: 4px;
}
-.new-chat:hover, .new-chat.open {
+.new-chat:hover, .new-chat.open,
+.add-contact:hover, .add-contact.open {
background: #00000060;
}
@@ -680,6 +681,37 @@ hr {
align-items: end;
}
+.roster-add-contact {
+ border-bottom: 2px solid black;
+}
+
+.add-contact-menu h3 {
+ margin: 1em 0 0.5em;
+}
+
+.add-contact-menu form {
+ display: flex;
+ gap: 16px;
+}
+
+.add-contact-menu form input {
+ flex: 1 1 auto;
+}
+
+.jid-with-button {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.jid-with-button .jid {
+ flex: 1 1 auto;
+}
+
+.jid-with-button .button {
+ flex: 0 0 auto;
+}
+
/* font-families */
/* thai */
diff --git a/src/lib.rs b/src/lib.rs
index e887fc0..90cae4e 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,12 +1,12 @@
use std::{
borrow::Borrow,
cell::RefCell,
- collections::HashMap,
+ collections::{HashMap, HashSet},
marker::PhantomData,
ops::{Deref, DerefMut},
rc::Rc,
str::FromStr,
- sync::{Arc, RwLock, atomic::AtomicUsize},
+ sync::{atomic::AtomicUsize, Arc, RwLock},
thread::sleep,
time::{self, Duration},
};
@@ -14,7 +14,7 @@ use std::{
use base64::{Engine, prelude::BASE64_STANDARD};
use chrono::{Local, NaiveDateTime, TimeDelta, Utc};
use filamento::{
- chat::{Body, Chat, ChatStoreFields, Delivery, Message, MessageStoreFields}, db::Db, error::{CommandError, ConnectionError, DatabaseError}, files::{opfs::OPFSError, FileStore, FilesMem, FilesOPFS}, presence::{Offline, Online, Presence, PresenceType, Show}, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}, UpdateMessage
+ chat::{Body, Chat, ChatStoreFields, Delivery, Message, MessageStoreFields}, db::Db, error::{CommandError, ConnectionError, DatabaseError, SubscribeError}, files::{opfs::OPFSError, FileStore, FilesMem, FilesOPFS}, presence::{Offline, Online, Presence, PresenceType, Show}, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}, UpdateMessage
};
use futures::stream::StreamExt;
use indexmap::IndexMap;
@@ -646,6 +646,10 @@ fn Macaw(
});
provide_context(client_user);
+ // TODO: timestamp incoming/outgoing subscription requests
+ let (subscription_requests, set_subscription_requests)= signal(HashSet::<JID>::new());
+ provide_context(subscription_requests);
+
// TODO: get cached contacts on login before getting the updated contacts
OnceResource::new(async move {
@@ -716,7 +720,9 @@ fn Macaw(
.set(Some(delivery))
});
}
- UpdateMessage::SubscriptionRequest(jid) => {}
+ 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())
@@ -2161,11 +2167,28 @@ fn NewChatWidget(set_open_new_chat: WriteSignal<bool>) -> impl IntoView {
#[component]
fn RosterList() -> impl IntoView {
let roster: Store<Roster> = use_context().expect("no roster in context");
+ let (open_add_contact, set_open_add_contact) = signal(false);
// TODO: filter new messages signal
view! {
<div class="roster-list panel">
- <div class="header"><h2>Roster</h2><div class="header-icon"><IconComponent icon=Icon::AddContact24 /></div></div>
+ <div class="header">
+ <h2>Roster</h2>
+ <div class="add-contact header-icon" class:open=open_add_contact>
+ <IconComponent icon=Icon::AddContact24 on:click=move |_| set_open_add_contact.update(|state| *state = !*state)/>
+ </div>
+ </div>
+ {move || {
+ if *open_add_contact.read() {
+ view! {
+ <div class="roster-add-contact">
+ <AddContact />
+ </div>
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }
+ }}
<div class="roster-list-roster">
<For each=move || roster.contacts().get() key=|contact| contact.0.clone() let(contact)>
<RosterListItem contact=contact.1 />
@@ -2175,6 +2198,191 @@ fn RosterList() -> impl IntoView {
}
}
+#[derive(Clone, Debug, Error)]
+pub enum AddContactError {
+ #[error("Missing JID")]
+ MissingJID,
+ #[error("Invalid JID: {0}")]
+ InvalidJID(#[from] jid::ParseError),
+ #[error("Subscription: {0}")]
+ Db(#[from] CommandError<SubscribeError>),
+}
+
+#[component]
+fn AddContact() -> impl IntoView {
+ let requests: ReadSignal<HashSet<JID>> = use_context().expect("no pending subscriptions in context");
+ let roster: Store<Roster> = use_context().expect("no roster in context");
+
+ let jid = RwSignal::new("".to_string());
+ // TODO: compartmentalise into error component, form component...
+ let (error, set_error) = signal(None::<AddContactError>);
+ 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 (add_contact_pending, set_add_contact_pending) = signal(false);
+
+ let client = use_context::<Client>().expect("client not in context");
+ let client2 = client.clone();
+ let client3 = client.clone();
+ let client4 = client.clone();
+
+ let add_contact= Action::new_local(move |_| {
+ let client = client.clone();
+ async move {
+ set_add_contact_pending.set(true);
+
+ if jid.read_untracked().is_empty() {
+ set_error.set(Some(AddContactError::MissingJID));
+ set_add_contact_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_add_contact_pending.set(false);
+ return;
+ }
+ };
+
+ let chat_jid = jid.as_bare();
+ // TODO: more options?
+ match client.buddy_request(chat_jid).await {
+ Ok(c) => c,
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_add_contact_pending.set(false);
+ return;
+ },
+ };
+
+ set_add_contact_pending.set(false);
+ }
+ });
+
+ let jid_input = NodeRef::<Input>::new();
+ let _focus = Effect::new(move |_| {
+ if let Some(input) = jid_input.get() {
+ let _ = input.focus();
+ input.set_text_content(Some(""));
+ // input.style("height: 0");
+ // let height = input.scroll_height();
+ // input.style(format!("height: {}px", height));
+ }
+ });
+
+ let outgoing = move || roster.contacts().get().into_iter().filter(|(jid, contact)| {
+ match *contact.contact.subscription().read() {
+ filamento::roster::Subscription::None => false,
+ filamento::roster::Subscription::PendingOut => true,
+ filamento::roster::Subscription::PendingIn => false,
+ filamento::roster::Subscription::PendingInPendingOut => true,
+ filamento::roster::Subscription::OnlyOut => false,
+ filamento::roster::Subscription::OnlyIn => false,
+ filamento::roster::Subscription::OutPendingIn => false,
+ filamento::roster::Subscription::InPendingOut => true,
+ filamento::roster::Subscription::Buddy => false,
+ }
+ }).collect::<Vec<_>>();
+
+ let accept_friend_request = Action::new_local(move |jid: &JID| {
+ let client = client2.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.accept_buddy_request(jid).await;
+ }
+ });
+
+ let reject_friend_request = Action::new_local(move |jid: &JID| {
+ let client = client3.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.unsubscribe_contact(jid).await;
+ }
+ });
+
+ let cancel_subscription_request = Action::new_local(move |jid: &JID| {
+ let client = client4.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.unsubscribe_from_contact(jid).await;
+
+ }
+ });
+
+ view! {
+ <div class="add-contact-menu">
+ <div>
+ {error_message}
+ <form on:submit=move |ev| {
+ ev.prevent_default();
+ add_contact.dispatch(());
+ }>
+ <input
+ disabled=add_contact_pending
+ placeholder="JID"
+ type="text"
+ node_ref=jid_input
+ bind:value=jid
+ name="jid"
+ id="jid"
+ autofocus="true"
+ />
+ <input disabled=add_contact_pending class="button" type="submit" value="Send Friend Request" />
+ </form>
+ </div>
+ {move || if !requests.read().is_empty() {
+ view! {
+ <div>
+ <h3>Incoming Subscription Requests</h3>
+ <For each=move || requests.get() key=|request| request.clone() let(request)>
+ {
+ let request2 = request.clone();
+ let request3 = request.clone();
+ let jid_string = move || request.to_string();
+ view! {
+ <div class="jid-with-button"><div class="jid">{jid_string}</div>
+ <div><div class="button" on:click=move |_| { accept_friend_request.dispatch(request2.clone()); } >Accept</div><div class="button" on:click=move |_| { reject_friend_request.dispatch(request3.clone()); } >Accept</div></div></div>
+ }
+ }
+ </For>
+ </div>
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }}
+ {move || if !outgoing().is_empty() {
+ view! {
+ <div>
+ <h3>Pending Outgoing Subscription Requests</h3>
+ <For each=move || outgoing() key=|(jid, _contact)| jid.clone() let((jid, contact))>
+ {
+ let jid2 = jid.clone();
+ let jid_string = move || jid.to_string();
+ view! {
+ <div class="jid-with-button"><div class="jid">{jid_string}</div><div class="button" on:click=move |_| { cancel_subscription_request.dispatch(jid2.clone()); } >Cancel</div></div>
+ }
+ }
+ </For>
+ </div>
+ }.into_any()
+ } else {
+ view! {}.into_any()
+ }}
+ </div>
+ }
+}
+
#[component]
fn RosterListItem(contact: MacawContact) -> impl IntoView {
let contact_contact: Store<Contact> = contact.contact;
@@ -2222,7 +2430,7 @@ fn RosterListItem(contact: MacawContact) -> impl IntoView {
<AvatarWithPresence user=contact_user />
<div class="item-info">
<div class="main-info"><p class="name">{name}<span class="jid"> - {move || contact_contact.user_jid().read().to_string()}</span></p></div>
- <div class="sub-info"><!-- "TODO: status messages" --></div>
+ <div class="sub-info">{move || contact_contact.subscription().read().to_string()}</div>
</div>
</div>
}