diff options
author | 2025-05-23 14:58:49 +0100 | |
---|---|---|
committer | 2025-05-23 14:58:49 +0100 | |
commit | 07b0d4a4412498123a4436bb6eb643ed34a1bfed (patch) | |
tree | a71957d0c43a0ade56f4d8d0af56314e64d58dfc | |
parent | f3cf5c1cfca048cfb496592af18e07782e3d22a2 (diff) | |
download | macaw-web-07b0d4a4412498123a4436bb6eb643ed34a1bfed.tar.gz macaw-web-07b0d4a4412498123a4436bb6eb643ed34a1bfed.tar.bz2 macaw-web-07b0d4a4412498123a4436bb6eb643ed34a1bfed.zip |
feat: roster requests
-rw-r--r-- | assets/style.scss | 34 | ||||
-rw-r--r-- | src/lib.rs | 220 |
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 */ @@ -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> } |