summaryrefslogtreecommitdiffstats
path: root/src/components/roster_list
diff options
context:
space:
mode:
Diffstat (limited to 'src/components/roster_list')
-rw-r--r--src/components/roster_list/contact_request_manager.rs284
-rw-r--r--src/components/roster_list/roster_list_item.rs122
2 files changed, 406 insertions, 0 deletions
diff --git a/src/components/roster_list/contact_request_manager.rs b/src/components/roster_list/contact_request_manager.rs
new file mode 100644
index 0000000..4c28142
--- /dev/null
+++ b/src/components/roster_list/contact_request_manager.rs
@@ -0,0 +1,284 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::{collections::HashSet, str::FromStr};
+
+use filamento::{
+ error::{CommandError, SubscribeError},
+ roster::ContactStoreFields,
+};
+use jid::{BareJID, JID};
+use leptos::{html::Input, prelude::*};
+use reactive_stores::Store;
+use thiserror::Error;
+
+use crate::{
+ client::Client,
+ roster::{Roster, RosterStoreFields},
+};
+
+#[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]
+// TODO: rename
+pub fn AddContact() -> impl IntoView {
+ let requests: ReadSignal<HashSet<BareJID>> =
+ use_context().expect("no pending subscriptions in context");
+ let set_requests: WriteSignal<HashSet<BareJID>> =
+ use_context().expect("no pending subscriptions write signal 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 client5 = 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.to_bare(),
+ Err(e) => {
+ set_error.set(Some(e.into()));
+ set_add_contact_pending.set(false);
+ return;
+ }
+ };
+
+ let chat_jid = jid;
+ // 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: &BareJID| {
+ let client = client2.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.accept_buddy_request(jid.clone()).await;
+ set_requests.write().remove(&jid);
+ }
+ });
+
+ let accept_subscription_request = Action::new_local(move |jid: &BareJID| {
+ let client = client5.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.accept_subscription_request(jid.clone()).await;
+ set_requests.write().remove(&jid);
+ }
+ });
+
+ let reject_friend_request = Action::new_local(move |jid: &BareJID| {
+ let client = client3.clone();
+ let jid = jid.clone();
+ async move {
+ // TODO: error
+ client.unsubscribe_contact(jid.clone()).await;
+ set_requests.write().remove(&jid);
+ }
+ });
+
+ let cancel_subscription_request = Action::new_local(move |jid: &BareJID| {
+ 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 request4 = request.clone();
+ let jid_string = move || request.to_string();
+ view! {
+ <div class="jid-with-buttons">
+ <div class="jid">{jid_string}</div>
+ <div class="buttons">
+ <div
+ class="button"
+ on:click=move |_| {
+ reject_friend_request.dispatch(request3.clone());
+ }
+ >
+ Reject
+ </div>
+ <div
+ class="button"
+ on:click=move |_| {
+ accept_subscription_request.dispatch(request4.clone());
+ }
+ >
+ Sub-only
+ </div>
+ <div
+ class="button"
+ on:click=move |_| {
+ accept_friend_request.dispatch(request2.clone());
+ }
+ >
+ Accept Buddy
+ </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>
+ }
+}
diff --git a/src/components/roster_list/roster_list_item.rs b/src/components/roster_list/roster_list_item.rs
new file mode 100644
index 0000000..a6fd714
--- /dev/null
+++ b/src/components/roster_list/roster_list_item.rs
@@ -0,0 +1,122 @@
+// SPDX-FileCopyrightText: 2025 cel <cel@bunny.garden>
+//
+// SPDX-License-Identifier: AGPL-3.0-or-later
+
+use std::ops::Deref;
+
+use filamento::{
+ chat::Chat,
+ roster::{Contact, ContactStoreFields},
+ user::{User, UserStoreFields},
+};
+use jid::BareJID;
+use leptos::prelude::*;
+use reactive_stores::{ArcStore, Store};
+use tracing::debug;
+
+use crate::{
+ chat::{ArcMacawChat, MacawChat},
+ client::Client,
+ components::{avatar::AvatarWithPresence, sidebar::Open},
+ contact::MacawContact,
+ open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields},
+ state_store::StateStore,
+ user::{ArcMacawUser, fetch_avatar, get_name},
+};
+
+#[component]
+pub fn RosterListItem(contact: MacawContact) -> impl IntoView {
+ let name = move || get_name(contact.user.get().into(), false);
+
+ let open_chats: Store<OpenChatsPanel> =
+ use_context().expect("no open chats panel store in context");
+
+ let client = use_context::<Client>().expect("client not in context");
+
+ let chat_state_store: StateStore<BareJID, ArcStore<Chat>> =
+ use_context().expect("no chat state store");
+ let user_state_store: StateStore<BareJID, (ArcStore<User>, ArcRwSignal<String>)> =
+ use_context().expect("no user state store");
+
+ let open_chat = Action::new_local(move |_| {
+ let client = client.clone();
+ async move {
+ let to = contact.user.get().jid().get();
+ let (chat, user) = match client.get_chat_and_user(to).await {
+ Ok(c) => c,
+ Err(e) => {
+ // TODO: error
+ // set_error.set(Some(e.into()));
+ // set_new_chat_pending.set(false);
+ return;
+ }
+ };
+
+ let chat = {
+ // let user = MacawUser::got_user(user);
+ // let user = user_state_store.store(user.jid.clone(), ArcStore::new(user));
+ let old_user = user_state_store.get_listener(user.jid.clone());
+ let user = if let Some(old_user) = old_user {
+ old_user.update(|(old_user, _avatar)| {
+ old_user.set(user);
+ });
+ old_user
+ } else {
+ let avatar = fetch_avatar(user.avatar.as_deref()).await;
+ let avatar = ArcRwSignal::new(avatar);
+ user_state_store.store(user.jid.clone(), (ArcStore::new(user), avatar))
+ };
+ let user = ArcMacawUser { user };
+ let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat));
+ ArcMacawChat { chat, user }
+ };
+ open_chats.update(|open_chats| open_chats.open(chat.clone()));
+ }
+ });
+
+ let current_open_chat: Memo<Option<BareJID>> =
+ use_context().expect("no open chat memo in context");
+
+ let open = move || {
+ if let Some(open_chat) = &*current_open_chat.read() {
+ debug!("got open chat: {:?}", open_chat);
+ if *open_chat == *contact.user.get().jid().read() {
+ return Open::Focused;
+ }
+ }
+ if let Some(_backgrounded_chat) = open_chats
+ .chats()
+ .read()
+ .get(contact.user.get().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=move |_| {
+ open_chat.dispatch(());
+ }
+ >
+ {move || {
+ view! { <AvatarWithPresence user=contact.user /> }
+ }}
+ <div class="item-info">
+ <div class="main-info">
+ <p class="name">
+ {name}
+ <span class="jid">- {move || contact.user_jid().read().to_string()}</span>
+ </p>
+ </div>
+ <div class="sub-info">{move || contact.subscription().read().to_string()}</div>
+ </div>
+ </div>
+ }
+}