diff options
| author | 2025-05-23 14:58:49 +0100 | |
|---|---|---|
| committer | 2025-05-23 14:58:49 +0100 | |
| commit | 07b0d4a4412498123a4436bb6eb643ed34a1bfed (patch) | |
| tree | a71957d0c43a0ade56f4d8d0af56314e64d58dfc /src | |
| parent | f3cf5c1cfca048cfb496592af18e07782e3d22a2 (diff) | |
| download | macaw-web-07b0d4a4412498123a4436bb6eb643ed34a1bfed.tar.gz macaw-web-07b0d4a4412498123a4436bb6eb643ed34a1bfed.tar.bz2 macaw-web-07b0d4a4412498123a4436bb6eb643ed34a1bfed.zip  | |
feat: roster requests
Diffstat (limited to 'src')
| -rw-r--r-- | src/lib.rs | 220 | 
1 files changed, 214 insertions, 6 deletions
@@ -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>      }  | 
