diff options
Diffstat (limited to 'src/components')
-rw-r--r-- | src/components/avatar.rs | 36 | ||||
-rw-r--r-- | src/components/chat_header.rs | 23 | ||||
-rw-r--r-- | src/components/chats_list.rs | 109 | ||||
-rw-r--r-- | src/components/chats_list/chats_list_item.rs | 66 | ||||
-rw-r--r-- | src/components/icon.rs | 57 | ||||
-rw-r--r-- | src/components/message.rs | 52 | ||||
-rw-r--r-- | src/components/message_composer.rs | 97 | ||||
-rw-r--r-- | src/components/message_history_buffer.rs | 176 | ||||
-rw-r--r-- | src/components/mod.rs | 13 | ||||
-rw-r--r-- | src/components/modal.rs | 16 | ||||
-rw-r--r-- | src/components/new_chat.rs | 123 | ||||
-rw-r--r-- | src/components/overlay.rs | 16 | ||||
-rw-r--r-- | src/components/personal_status.rs | 178 | ||||
-rw-r--r-- | src/components/roster_list.rs | 58 | ||||
-rw-r--r-- | src/components/roster_list/contact_request_manager.rs | 198 | ||||
-rw-r--r-- | src/components/roster_list/roster_list_item.rs | 62 | ||||
-rw-r--r-- | src/components/sidebar.rs | 176 |
17 files changed, 1456 insertions, 0 deletions
diff --git a/src/components/avatar.rs b/src/components/avatar.rs new file mode 100644 index 0000000..9265ef7 --- /dev/null +++ b/src/components/avatar.rs @@ -0,0 +1,36 @@ +use filamento::{presence::PresenceType, user::User}; +use leptos::prelude::*; +use reactive_stores::Store; + +use crate::{components::icon::{show_to_icon, IconComponent}, icon::Icon, user::get_avatar, user_presences::UserPresences}; + +#[component] +pub fn AvatarWithPresence(user: Store<User>) -> impl IntoView { + let avatar = LocalResource::new(move || get_avatar(user)); + let user_presences: Store<UserPresences> = use_context().expect("no user presences in context"); + let presence = move || user_presences.write().get_user_presences(&user.read().jid).read().presence(); + let show_icon = move || presence().map(|(_, presence)| { + match presence.presence { + PresenceType::Online(online) => if let Some(show) = online.show { + Some(show_to_icon(show)) + } else { + Some(Icon::Available16Color) + }, + PresenceType::Offline(offline) => None, + } + }).unwrap_or_default(); + + view! { + <div class="avatar-with-presence"> + <img class="avatar" src=move || avatar.get() /> + {move || if let Some(icon) = show_icon() { + view!{ + <IconComponent icon=icon class:presence-show-icon=true /> + }.into_any() + } else { + view! {}.into_any() + }} + </div> + } +} + diff --git a/src/components/chat_header.rs b/src/components/chat_header.rs new file mode 100644 index 0000000..208e7f6 --- /dev/null +++ b/src/components/chat_header.rs @@ -0,0 +1,23 @@ +use filamento::user::UserStoreFields; +use leptos::prelude::*; +use reactive_stores::ArcStore; + +use crate::{chat::MacawChat, components::avatar::AvatarWithPresence, user::get_name}; + +#[component] +pub fn ChatViewHeader(chat: MacawChat) -> impl IntoView { + let chat_user = <ArcStore<filamento::user::User> as Clone>::clone(&chat.user).into(); + let name = move || get_name(chat_user, true); + let jid = move || chat_user.jid().read().to_string(); + + view! { + <div class="chat-view-header panel"> + <AvatarWithPresence user=chat_user /> + <div class="user-info"> + <h2 class="name">{name}</h2> + <h3>{jid}</h3> + </div> + </div> + } +} + diff --git a/src/components/chats_list.rs b/src/components/chats_list.rs new file mode 100644 index 0000000..b8cf34c --- /dev/null +++ b/src/components/chats_list.rs @@ -0,0 +1,109 @@ +use chats_list_item::ChatsListItem; +use indexmap::IndexMap; +use jid::BareJID; +use leptos::prelude::*; +use tracing::debug; + +use crate::{chat::MacawChat, client::Client, components::{icon::IconComponent, new_chat::NewChatWidget, overlay::Overlay}, icon::Icon, message::MacawMessage, message_subscriptions::MessageSubscriptions}; + +mod chats_list_item; + +#[component] +pub fn ChatsList() -> impl IntoView { + let (chats, set_chats) = signal(IndexMap::new()); + + let load_chats = LocalResource::new(move || async move { + let client = use_context::<Client>().expect("client not in context"); + let chats = client + .get_chats_ordered_with_latest_messages_and_users() + .await + .map_err(|e| e.to_string()); + match chats { + Ok(c) => { + let chats = c + .into_iter() + .map(|((chat, chat_user), (message, message_user))| { + ( + chat.correspondent.clone(), + ( + MacawChat::got_chat_and_user(chat, chat_user), + MacawMessage::got_message_and_user(message, message_user), + ), + ) + }) + .collect::<IndexMap<BareJID, _>>(); + set_chats.set(chats); + } + Err(_) => { + // TODO: show error message at top of chats list + } + } + }); + + let (open_new_chat, set_open_new_chat) = signal(false); + + // TODO: filter new messages signal + let new_messages_signal: RwSignal<MessageSubscriptions> = use_context().unwrap(); + let (sub_id, set_sub_id) = signal(None); + let _load_new_messages = LocalResource::new(move || async move { + load_chats.await; + let (sub_id, mut new_messages) = new_messages_signal.write().subscribe_all(); + set_sub_id.set(Some(sub_id)); + while let Some((to, new_message)) = new_messages.recv().await { + debug!("got new message in let"); + let mut chats = set_chats.write(); + if let Some((chat, _latest_message)) = chats.shift_remove(&to) { + // TODO: check if new message is actually latest message + debug!("chat existed"); + debug!("new message: {}", new_message.message.read().body.body); + chats.insert_before(0, to, (chat.clone(), new_message)); + debug!("done setting"); + } else { + debug!("the chat didn't exist"); + let client = use_context::<Client>().expect("client not in context"); + let chat = client.get_chat(to.clone()).await.unwrap(); + let user = client.get_user(to.clone()).await.unwrap(); + debug!("before got chat"); + let chat = MacawChat::got_chat_and_user(chat, user); + debug!("after got chat"); + chats.insert_before(0, to, (chat, new_message)); + debug!("done setting"); + } + } + debug!("set the new message"); + }); + on_cleanup(move || { + if let Some(sub_id) = sub_id.get() { + new_messages_signal.write().unsubscribe_all(sub_id); + } + }); + + view! { + <div class="chats-list panel"> + // TODO: update icon, tooltip on hover. + <div class="header"> + <h2>Chats</h2> + <div class="new-chat header-icon" class:open=open_new_chat > + <IconComponent icon=Icon::NewBubble24 on:click=move |_| set_open_new_chat.update(|state| *state = !*state)/> + {move || { + if *open_new_chat.read() { + view! { + <Overlay set_open=set_open_new_chat> + <NewChatWidget set_open_new_chat /> + </Overlay> + }.into_any() + } else { + view! {}.into_any() + } + }} + </div> + </div> + <div class="chats-list-chats"> + <For each=move || chats.get() key=|chat| chat.1.1.message.read().id let(chat)> + <ChatsListItem chat=chat.1.0 message=chat.1.1 /> + </For> + </div> + </div> + } +} + diff --git a/src/components/chats_list/chats_list_item.rs b/src/components/chats_list/chats_list_item.rs new file mode 100644 index 0000000..191f163 --- /dev/null +++ b/src/components/chats_list/chats_list_item.rs @@ -0,0 +1,66 @@ +use std::ops::Deref; + +use chrono::Local; +use filamento::{chat::{Chat, ChatStoreFields, Message, MessageStoreFields}, user::User}; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; +use tracing::debug; + +use crate::{chat::MacawChat, components::{avatar::AvatarWithPresence, sidebar::Open}, message::MacawMessage, open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}, user::get_name}; + +#[component] +pub fn ChatsListItem(chat: MacawChat, message: MacawMessage) -> impl IntoView { + let chat_chat: Store<Chat> = <ArcStore<Chat> as Clone>::clone(&chat.chat).into(); + let chat_user: Store<User> = + <ArcStore<filamento::user::User> as Clone>::clone(&chat.user).into(); + let message_message: Store<Message> = <ArcStore<Message> as Clone>::clone(&message.message).into(); + let name = move || get_name(chat_user, true); + + // TODO: store fine-grained reactivity + let latest_message_body = move || message_message.body().get().body; + let open_chats: Store<OpenChatsPanel> = + use_context().expect("no open chats panel store in context"); + + 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 == *chat_chat.correspondent().read() { + return Open::Focused; + } + } + if let Some(_backgrounded_chat) = open_chats + .chats() + .read() + .get(chat_chat.correspondent().read().deref()) + { + return Open::Open; + } + Open::Closed + }; + let focused = move || open().is_focused(); + let open = move || open().is_open(); + + let date = move || message_message.timestamp().read().naive_local(); + let now = move || Local::now().naive_local(); + let timeinfo = move || if date().date() == now().date() { + // TODO: localisation/config + date().time().format("%H:%M").to_string() + } else { + date().date().format("%d/%m").to_string() + }; + + view! { + <div class="chats-list-item" class:open=move || open() class:focused=move || focused() on:click=open_chat> + <AvatarWithPresence user=chat_user /> + <div class="item-info"> + <div class="main-info"><p class="name">{name}</p><p class="timestamp">{timeinfo}</p></div> + <div class="sub-info"><p class="message-preview">{latest_message_body}</p><p><!-- "TODO: delivery or unread state" --></p></div> + </div> + </div> + } +} diff --git a/src/components/icon.rs b/src/components/icon.rs new file mode 100644 index 0000000..7eaa52f --- /dev/null +++ b/src/components/icon.rs @@ -0,0 +1,57 @@ +use leptos::prelude::*; +use filamento::{chat::Delivery, presence::Show}; + +use crate::icon::Icon; + +// TODO: rename +#[component] +pub fn IconComponent(icon: Icon) -> impl IntoView { + view! { + <img class:light=icon.light() class:icon=true style=move || format!("height: {}px; width: {}px", icon.size(), icon.size()) src=move || icon.src() /> + } +} + +pub fn show_to_icon(show: Show) -> Icon { + match show { + Show::Away => Icon::Away16Color, + Show::Chat => Icon::Chat16Color, + Show::DoNotDisturb => Icon::Dnd16Color, + Show::ExtendedAway => Icon::Xa16Color, + } +} + +#[component] +pub fn Delivery(delivery: Delivery) -> impl IntoView { + match delivery { + // TODO: proper icon coloring/theming + Delivery::Sending => { + view! { <IconComponent class:visible=true class:light=true icon=Icon::Sending16 /> } + .into_any() + } + Delivery::Written => { + view! { <IconComponent class:light=true icon=Icon::Sent16 /> }.into_any() + } + // TODO: message receipts + // Delivery::Written => view! {}.into_any(), + Delivery::Sent => view! { <IconComponent class:light=true icon=Icon::Sent16 /> }.into_any(), + Delivery::Delivered => { + view! { <IconComponent class:light=true icon=Icon::Delivered16 /> }.into_any() + } + // TODO: check if there is also the icon class + Delivery::Read => { + view! { <IconComponent class:light=true class:read=true icon=Icon::Delivered16 /> } + .into_any() + } + Delivery::Failed => { + view! { <IconComponent class:visible=true class:light=true icon=Icon::Error16Color /> } + .into_any() + } + // TODO: queued icon + Delivery::Queued => { + view! { <IconComponent class:visible=true class:light=true icon=Icon::Sending16 /> } + .into_any() + } + } +} + + diff --git a/src/components/message.rs b/src/components/message.rs new file mode 100644 index 0000000..2ae2ef0 --- /dev/null +++ b/src/components/message.rs @@ -0,0 +1,52 @@ +use filamento::chat::MessageStoreFields; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; + +use crate::{message::MacawMessage, user::{get_avatar, get_name, NO_AVATAR}}; + +use super::icon::Delivery; + +#[component] +pub fn Message(message: MacawMessage, major: bool, r#final: bool) -> impl IntoView { + let message_message: Store<filamento::chat::Message> = + <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message).into(); + let message_user = <ArcStore<filamento::user::User> as Clone>::clone(&message.user).into(); + let avatar = LocalResource::new(move || get_avatar(message_user)); + let name = move || get_name(message_user, false); + + // TODO: chrono-humanize? + // TODO: if final, show delivery not only on hover. + // {move || message_message.delivery().read().map(|delivery| delivery.to_string()).unwrap_or_default()} + if major { + view! { + <div class:final=r#final class="chat-message major"> + <div class="left"> + <Transition fallback=|| view! { <img class="avatar" src=NO_AVATAR /> } > + <img class="avatar" src=move || avatar.get() /> + </Transition> + </div> + <div class="middle"> + <div class="message-info"> + <div class="message-user-name">{name}</div> + <div class="message-timestamp">{move || message_message.timestamp().read().format("%H:%M").to_string()}</div> + </div> + <div class="message-text"> + {move || message_message.body().read().body.clone()} + </div> + </div> + <div class="right message-delivery">{move || message_message.delivery().get().map(|delivery| view! { <Delivery class:light=true delivery /> } ) }</div> + </div> + }.into_any() + } else { + view! { + <div class:final=r#final class="chat-message minor"> + <div class="left message-timestamp"> + {move || message_message.timestamp().read().format("%H:%M").to_string()} + </div> + <div class="middle message-text">{move || message_message.body().read().body.clone()}</div> + <div class="right message-delivery">{move || message_message.delivery().get().map(|delivery| view! { <Delivery delivery /> } ) }</div> + </div> + }.into_any() + } +} + diff --git a/src/components/message_composer.rs b/src/components/message_composer.rs new file mode 100644 index 0000000..3876a5a --- /dev/null +++ b/src/components/message_composer.rs @@ -0,0 +1,97 @@ +use filamento::chat::Body; +use jid::BareJID; +use leptos::{html::Div, prelude::*, task::spawn_local}; + +use crate::client::Client; + +#[component] +pub fn ChatViewMessageComposer(chat: BareJID) -> impl IntoView { + let message_input: NodeRef<Div> = NodeRef::new(); + + // TODO: load last message draft + let new_message = RwSignal::new("".to_string()); + let client: Client = use_context().expect("no client in context"); + let client = RwSignal::new(client); + let (shift_pressed, set_shift_pressed) = signal(false); + + let send_message = move || { + let value = chat.clone(); + spawn_local(async move { + match client + .read() + .send_message( + value, + Body { + body: new_message.get(), + }, + ) + .await + { + Ok(_) => { + new_message.set("".to_string()); + message_input + .write() + .as_ref() + .expect("message input div not mounted") + .set_text_content(Some("")); + } + Err(e) => tracing::error!("message send error: {}", e), + } + }) + }; + + let _focus = Effect::new(move |_| { + if let Some(input) = message_input.get() { + let _ = input.focus(); + // TODO: set the last draft + input.set_text_content(Some("")); + // input.style("height: 0"); + // let height = input.scroll_height(); + // input.style(format!("height: {}px", height)); + } + }); + + // let on_input = move |ev: Event| { + // // let keyboard_event: KeyboardEvent = ev.try_into().unwrap(); + // debug!("got input event"); + // let key= event_target_value(&ev); + // new_message.set(key); + // debug!("set new message"); + // }; + // + + // TODO: placeholder + view! { + <form + class="new-message-composer panel" + > + <div + class="text-box" + on:input:target=move |ev| new_message.set(ev.target().text_content().unwrap_or_default()) + node_ref=message_input + contenteditable + on:keydown=move |ev| { + match ev.key_code() { + 16 => set_shift_pressed.set(true), + 13 => if !shift_pressed.get() { + ev.prevent_default(); + send_message(); + } + _ => {} + // debug!("shift pressed down"); + } + } + on:keyup=move |ev| { + match ev.key_code() { + 16 => set_shift_pressed.set(false), + _ => {} + // debug!("shift released"); + } + } + ></div> + // <input hidden type="submit" /> + </form> + } +} + + diff --git a/src/components/message_history_buffer.rs b/src/components/message_history_buffer.rs new file mode 100644 index 0000000..36439a8 --- /dev/null +++ b/src/components/message_history_buffer.rs @@ -0,0 +1,176 @@ +use chrono::{NaiveDateTime, TimeDelta}; +use filamento::{chat::{Chat, ChatStoreFields, MessageStoreFields}, user::User}; +use indexmap::IndexMap; +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; +use tracing::{debug, error}; +use uuid::Uuid; + +use crate::{chat::MacawChat, client::Client, components::message::Message, message::MacawMessage, message_subscriptions::MessageSubscriptions}; + +#[component] +pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView { + let (messages, set_messages) = arc_signal(IndexMap::new()); + let chat_chat: Store<Chat> = + <ArcStore<filamento::chat::Chat> as Clone>::clone(&chat.chat).into(); + let chat_user: Store<User> = + <ArcStore<filamento::user::User> as Clone>::clone(&chat.user).into(); + + let load_set_messages = set_messages.clone(); + let load_messages = LocalResource::new(move || { + let load_set_messages = load_set_messages.clone(); + async move { + let client = use_context::<Client>().expect("client not in context"); + let messages = client + .get_messages_with_users(chat_chat.correspondent().get()) + .await + .map_err(|e| e.to_string()); + match messages { + Ok(m) => { + let messages = m + .into_iter() + .map(|(message, message_user)| { + ( + message.id, + MacawMessage::got_message_and_user(message, message_user), + ) + }) + .collect::<IndexMap<Uuid, _>>(); + load_set_messages.set(messages); + } + Err(err) => { + error!("{err}") + // TODO: show error message at top of chats list + } + } + } + }); + + // TODO: filter new messages signal + let new_messages_signal: RwSignal<MessageSubscriptions> = use_context().unwrap(); + let (sub_id, set_sub_id) = signal(None); + let load_new_messages_set = set_messages.clone(); + let _load_new_messages = LocalResource::new(move || { + let load_new_messages_set = load_new_messages_set.clone(); + async move { + load_messages.await; + let (sub_id, mut new_messages) = new_messages_signal + .write() + .subscribe_chat(chat_chat.correspondent().get()); + set_sub_id.set(Some(sub_id)); + while let Some(new_message) = new_messages.recv().await { + debug!("got new message in let message buffer"); + let mut messages = load_new_messages_set.write(); + if let Some((_, last)) = messages.last() { + if *<ArcStore<filamento::chat::Message> as Clone>::clone(&last.message) + .timestamp() + .read() + < *<ArcStore<filamento::chat::Message> as Clone>::clone(&new_message.message) + .timestamp() + .read() + { + messages.insert( + <ArcStore<filamento::chat::Message> as Clone>::clone( + &new_message.message, + ) + .id() + .get(), + new_message, + ); + debug!("set the new message in message buffer"); + } else { + let index = match messages.binary_search_by(|_, value| { + <ArcStore<filamento::chat::Message> as Clone>::clone(&value.message) + .timestamp() + .read() + .cmp( + &<ArcStore<filamento::chat::Message> as Clone>::clone( + &new_message.message, + ) + .timestamp() + .read(), + ) + }) { + Ok(i) => i, + Err(i) => i, + }; + messages.insert_before( + // TODO: check if this logic is correct + index, + <ArcStore<filamento::chat::Message> as Clone>::clone( + &new_message.message, + ) + .id() + .get(), + new_message, + ); + debug!("set the new message in message buffer"); + } + } else { + messages.insert( + <ArcStore<filamento::chat::Message> as Clone>::clone(&new_message.message) + .id() + .get(), + new_message, + ); + debug!("set the new message in message buffer"); + } + } + } + }); + on_cleanup(move || { + if let Some(sub_id) = sub_id.get() { + new_messages_signal + .write() + .unsubscribe_chat(sub_id, chat_chat.correspondent().get()); + } + }); + + let each = move || { + let mut last_timestamp = NaiveDateTime::MIN; + let mut last_user: Option<BareJID> = None; + let mut messages = messages + .get() + .into_iter() + .map(|(id, message)| { + let message_timestamp = + <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message) + .timestamp() + .read() + .naive_local(); + // TODO: mark new day + // if message_timestamp.date() > last_timestamp.date() { + // messages_view = messages_view.push(date(message_timestamp.date())); + // } + let major = if last_user.as_ref() != Some(&message.message.read().from) + || message_timestamp - last_timestamp > TimeDelta::minutes(3) + { + true + } else { + false + }; + last_user = Some( + <ArcStore<filamento::chat::Message> as Clone>::clone(&message.message) + .from() + .get(), + ); + last_timestamp = message_timestamp; + (id, (message, major, false)) + }) + .collect::<Vec<_>>(); + if let Some((_id, (_, _, last))) = messages.last_mut() { + *last = true + } + messages.into_iter().rev() + }; + + view! { + <div class="messages-buffer"> + <For each=each key=|message| (message.0, message.1.1, message.1.2) let(message)> + <Message message=message.1.0 major=message.1.1 r#final=message.1.2 /> + </For> + </div> + } +} + diff --git a/src/components/mod.rs b/src/components/mod.rs new file mode 100644 index 0000000..879f99e --- /dev/null +++ b/src/components/mod.rs @@ -0,0 +1,13 @@ +pub mod sidebar; +mod chats_list; +mod new_chat; +mod roster_list; +mod avatar; +mod message; +pub mod message_history_buffer; +pub mod message_composer; +pub mod chat_header; +mod overlay; +pub mod modal; +pub mod icon; +mod personal_status; diff --git a/src/components/modal.rs b/src/components/modal.rs new file mode 100644 index 0000000..62e1fac --- /dev/null +++ b/src/components/modal.rs @@ -0,0 +1,16 @@ +use leptos::prelude::*; +use leptos::ev::MouseEvent; + +#[component] +pub fn Modal(on_background_click: impl Fn(MouseEvent) + 'static, children: Children) -> impl IntoView { + view! { + <div class="modal" on:click=move |e| { + if e.current_target() == e.target() { + on_background_click(e) + } + }> + {children()} + </div> + } +} + diff --git a/src/components/new_chat.rs b/src/components/new_chat.rs new file mode 100644 index 0000000..8047afb --- /dev/null +++ b/src/components/new_chat.rs @@ -0,0 +1,123 @@ +use std::str::FromStr; + +use filamento::{chat::Chat, error::{CommandError, DatabaseError}, user::User}; +use jid::{BareJID, JID}; +use leptos::{html::Input, prelude::*}; +use reactive_stores::{ArcStore, Store}; +use thiserror::Error; + +use crate::{chat::MacawChat, client::Client, open_chats::OpenChatsPanel, state_store::StateStore, user::MacawUser}; + +#[derive(Clone, Debug, Error)] +pub enum NewChatError { + #[error("Missing JID")] + MissingJID, + #[error("Invalid JID: {0}")] + InvalidJID(#[from] jid::ParseError), + #[error("Database: {0}")] + Db(#[from] CommandError<DatabaseError>), +} + +#[component] +pub fn NewChatWidget(set_open_new_chat: WriteSignal<bool>) -> impl IntoView { + let jid = RwSignal::new("".to_string()); + + // TODO: compartmentalise into error component, form component... + let (error, set_error) = signal(None::<NewChatError>); + 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 (new_chat_pending, set_new_chat_pending) = signal(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>> = + use_context().expect("no user state store"); + + let open_chat = Action::new_local(move |_| { + let client = client.clone(); + async move { + set_new_chat_pending.set(true); + + if jid.read_untracked().is_empty() { + set_error.set(Some(NewChatError::MissingJID)); + set_new_chat_pending.set(false); + return; + } + + let jid = match JID::from_str(&jid.read_untracked()) { + // TODO: ability to direct address a resource? + Ok(j) => j.to_bare(), + Err(e) => { + set_error.set(Some(e.into())); + set_new_chat_pending.set(false); + return; + } + }; + + let chat_jid = jid; + let (chat, user) = match client.get_chat_and_user(chat_jid).await { + Ok(c) => c, + Err(e) => { + 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 user = MacawUser { user }; + let chat = chat_state_store.store(chat.correspondent.clone(), ArcStore::new(chat)); + MacawChat { chat, user } + }; + open_chats.update(|open_chats| open_chats.open(chat.clone())); + set_open_new_chat.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)); + } + }); + + view! { + <div class="new-chat-widget"> + <form on:submit=move |ev| { + ev.prevent_default(); + open_chat.dispatch(()); + }> + {error_message} + <input + disabled=new_chat_pending + placeholder="JID" + type="text" + node_ref=jid_input + bind:value=jid + name="jid" + id="jid" + autofocus="true" + /> + <input disabled=new_chat_pending class="button" type="submit" value="Start Chat" /> + </form> + </div> + } +} + diff --git a/src/components/overlay.rs b/src/components/overlay.rs new file mode 100644 index 0000000..d4ff1bf --- /dev/null +++ b/src/components/overlay.rs @@ -0,0 +1,16 @@ +use leptos::prelude::*; +use tracing::debug; + +#[component] +pub fn Overlay(set_open: WriteSignal<bool>, children: Children) -> impl IntoView { + view! { + <div class="overlay"> + <div class="overlay-background" on:click=move |_| { + debug!("set open to false"); + set_open.update(|state| *state = false) + }></div> + <div class="overlay-content">{children()}</div> + </div> + } +} + diff --git a/src/components/personal_status.rs b/src/components/personal_status.rs new file mode 100644 index 0000000..f830a1b --- /dev/null +++ b/src/components/personal_status.rs @@ -0,0 +1,178 @@ +use filamento::{presence::{Offline, Online, PresenceType, Show}, user::{User, UserStoreFields}}; +use leptos::{html, prelude::*}; +use reactive_stores::{ArcStore, Store}; +use tracing::{debug, error}; + +use crate::{client::Client, components::{avatar::AvatarWithPresence, overlay::Overlay}, user::{get_name, MacawUser}, user_presences::UserPresences, views::{macaw::settings::SettingsPage, AppState}}; + +#[component] +pub fn PersonalStatus() -> impl IntoView { + let user: LocalResource<MacawUser> = use_context().expect("no local user in context"); + + let (open, set_open) = signal(false); + move || if let Some(user) = user.get() { + let user: Store<User> = <ArcStore<filamento::user::User> as Clone>::clone(&(*user.user)).into(); + view! { + <div class="dock-item" class:focused=move || *open.read() on:click=move |_| { + debug!("set open to true"); + set_open.update(|state| *state = !*state) + }> + <AvatarWithPresence user=user /> + <div class="dock-pill"></div> + </div> + {move || { + let open = open.get(); + debug!("open = {:?}", open); + if open { + view! { + <Overlay set_open> + <PersonalStatusMenu user set_open/> + </Overlay> + }.into_any() + } else { + view! {}.into_any() + }}} + }.into_any() + } else { + view! {}.into_any() + } +} + +#[component] +pub fn PersonalStatusMenu(user: Store<User>, set_open: WriteSignal<bool>) -> impl IntoView { + let set_app: WriteSignal<AppState> = use_context().unwrap(); + let show_settings: RwSignal<Option<SettingsPage>> = use_context().unwrap(); + let user_presences: Store<UserPresences> = use_context().expect("no user presence store"); + + let client = use_context::<Client>().expect("client not in context"); + let client1 = client.clone(); + let (show_value, set_show_value) = signal({ + let show = match user_presences.write().get_user_presences(&user.jid().read()).write().resource_presence(client.resource.read().clone().unwrap_or_default()).presence { + PresenceType::Online(online) => match online.show { + Some(s) => match s { + Show::Away => 3, + Show::Chat => 0, + Show::DoNotDisturb => 2, + Show::ExtendedAway => 4, + }, + None => 1, + }, + PresenceType::Offline(_offline) => 5, + }; + debug!("initial show = {show}"); + show + }); + + let show_select: NodeRef<html::Select> = NodeRef::new(); + + let disconnect = Action::new_local(move |()| { + let client = client.clone(); + async move { + client.disconnect(Offline::default()).await; + } + }); + let set_status = Action::new_local(move |show_value: &i32| { + let show_value = show_value.to_owned(); + let client = client1.clone(); + async move { + if let Err(e) = match show_value { + 0 => { + if let Ok(r) = client.connect().await { + client.resource.set(Some(r)) + }; + client.set_status(Online { show: Some(Show::Chat), ..Default::default() }).await + }, + 1 => { + if let Ok(r) = client.connect().await { + client.resource.set(Some(r)) + }; + client.set_status(Online { show: None, ..Default::default() }).await + }, + 2 => { + if let Ok(r) = client.connect().await { + client.resource.set(Some(r)) + }; + client.set_status(Online { show: Some(Show::DoNotDisturb), ..Default::default() }).await + }, + 3 => { + if let Ok(r) = client.connect().await { + client.resource.set(Some(r)) + }; + client.set_status(Online { show: Some(Show::Away), ..Default::default() }).await + }, + 4 => { + if let Ok(r) = client.connect().await { + client.resource.set(Some(r)) + }; + client.set_status(Online { show: Some(Show::ExtendedAway), ..Default::default() }).await + }, + 5 => { + if let Ok(_) = client.disconnect(Offline::default()).await { + client.resource.set(None) + } + set_show_value.set(5); + return + } + _ => { + error!("invalid availability select"); + return + } + } { + error!("show set error: {e}"); + return + } + set_show_value.set(show_value); + } + }); + + view! { + <div class="personal-status-menu menu"> + <div class="user"> + <AvatarWithPresence user=user /> + <div class="user-info"> + <div class="nick">{move || get_name(user, false)}</div> + <div class="jid">{move || user.jid().with(|jid| jid.to_string())}</div> + </div> + </div> + <div class="status-edit"> + <select + node_ref=show_select + on:change:target=move |ev| { + let show_value = ev.target().value().parse().unwrap(); + set_status.dispatch(show_value); + } + prop:show_value=move || show_value.get().to_string() + > + <option value="0" selected=move || show_value.get_untracked() == 0>Available to Chat</option> + <option value="1" selected=move || show_value.get_untracked() == 1>Online</option> + <option value="2" selected=move || show_value.get_untracked() == 2>Do not disturb</option> + <option value="3" selected=move || show_value.get_untracked() == 3>Away</option> + <option value="4" selected=move || show_value.get_untracked() == 4>Extended Away</option> + <option value="5" selected=move || show_value.get_untracked() == 5>Offline</option> + </select> + </div> + <hr /> + <div class="menu-item" on:click=move |_| { + show_settings.set(Some(SettingsPage::Profile)); + set_open.set(false); + }> + Profile + </div> + <div class="menu-item" on:click=move |_| { + show_settings.set(Some(SettingsPage::Account)); + set_open.set(false); + }> + Settings + </div> + <hr /> + <div class="menu-item" on:click=move |_| { + // TODO: check if client is actually dropped/shutdown eventually + disconnect.dispatch(()); + set_app.set(AppState::LoggedOut) + }> + Log out + </div> + </div> + } +} + diff --git a/src/components/roster_list.rs b/src/components/roster_list.rs new file mode 100644 index 0000000..a398ffe --- /dev/null +++ b/src/components/roster_list.rs @@ -0,0 +1,58 @@ +use std::collections::HashSet; + +use contact_request_manager::AddContact; +use jid::BareJID; +use leptos::prelude::*; +use reactive_stores::Store; +use roster_list_item::RosterListItem; + +use crate::{components::icon::IconComponent, icon::Icon, roster::{Roster, RosterStoreFields}}; + +mod contact_request_manager; +mod roster_list_item; + +#[component] +pub fn RosterList() -> impl IntoView { + let requests: ReadSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions in context"); + + 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="add-contact header-icon" class:open=open_add_contact> + <IconComponent icon=Icon::AddContact24 on:click=move |_| set_open_add_contact.update(|state| *state = !*state)/> + {move || { + if !requests.read().is_empty() { + view! { + <div class="badge"></div> + }.into_any() + } else { + view! {}.into_any() + } + }} + </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 /> + </For> + </div> + </div> + } +} + 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..174e677 --- /dev/null +++ b/src/components/roster_list/contact_request_manager.rs @@ -0,0 +1,198 @@ +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 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).await; + } + }); + + 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 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()); } >Reject</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..46ac1cc --- /dev/null +++ b/src/components/roster_list/roster_list_item.rs @@ -0,0 +1,62 @@ +use std::ops::Deref; + +use filamento::{chat::Chat, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}}; +use leptos::prelude::*; +use reactive_stores::{ArcStore, Store}; +use tracing::debug; + +use crate::{chat::MacawChat, components::{avatar::AvatarWithPresence, sidebar::Open}, contact::MacawContact, open_chats::{OpenChatsPanel, OpenChatsPanelStoreFields}, user::get_name}; + +#[component] +pub 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 name = move || get_name(contact_user, false); + + 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> + <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">{move || contact_contact.subscription().read().to_string()}</div> + </div> + </div> + } +} + diff --git a/src/components/sidebar.rs b/src/components/sidebar.rs new file mode 100644 index 0000000..ca753ef --- /dev/null +++ b/src/components/sidebar.rs @@ -0,0 +1,176 @@ +use std::collections::HashSet; + +use jid::BareJID; +use leptos::prelude::*; + +use crate::components::{ + personal_status::PersonalStatus, + chats_list::ChatsList, + roster_list::RosterList, +}; + +#[derive(PartialEq, Eq, Clone, Copy)] +pub enum SidebarOpen { + Roster, + Chats, +} + +pub enum Open { + /// Currently on screen + Focused, + /// Open in background somewhere (e.g. in another chat tab) + Open, + /// Closed + Closed, +} + +impl Open { + pub fn is_focused(&self) -> bool { + match self { + Open::Focused => true, + Open::Open => false, + Open::Closed => false, + } + } + + pub fn is_open(&self) -> bool { + match self { + Open::Focused => true, + Open::Open => true, + Open::Closed => false, + } + } +} + +/// returns whether the state was changed to open (true) or closed (false) +pub fn toggle_open(state: &mut Option<SidebarOpen>, open: SidebarOpen) -> bool { + match state { + Some(opened) => { + if *opened == open { + *state = None; + false + } else { + *state = Some(open); + true + } + } + None => { + *state = Some(open); + true + }, + } +} + +#[component] +pub fn Sidebar() -> impl IntoView { + let requests: ReadSignal<HashSet<BareJID>> = use_context().expect("no pending subscriptions in context"); + + // 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>); + let (just_closed, set_just_closed) = signal(false); + + view! { + <div class="sidebar" on:mouseleave=move |_| { + set_hovered.set(None); + set_just_closed.set(false); + }> + <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::Roster) + on:mouseenter=move |_| { + set_just_closed.set(false); + set_hovered.set(Some(SidebarOpen::Roster)) + } + on:click=move |_| { + set_open.update(|state| { + if !toggle_open(state, SidebarOpen::Roster) { + set_just_closed.set(true); + } + }) + }> + <div class="dock-pill"></div> + <div class="dock-icon"> + <div class="icon-with-badge"> + <img src="/assets/caw.png" /> + {move || { + let len = requests.read().len(); + if len > 0 { + view! { + <div class="badge">{len}</div> + }.into_any() + } else { + view! {}.into_any() + } + }} + </div> + </div> + </div> + <div class="chats-tab dock-item" class:focused=move || *open.read() == Some(SidebarOpen::Chats) class:hovering=move || *hovered.read() == Some(SidebarOpen::Chats) + on:mouseenter=move |_| { + set_just_closed.set(false); + set_hovered.set(Some(SidebarOpen::Chats)) + } + on:click=move |_| { + set_open.update(|state| { + if !toggle_open(state, SidebarOpen::Chats) { + set_just_closed.set(true); + } + }) + }> + <div class="dock-pill"></div> + <img src="/assets/bubble.png" /> + </div> + </div> + <div class="pins"> + </div> + <div class="personal"> + <PersonalStatus /> + </div> + </div> + {move || if let Some(hovered) = *hovered.read() { + if Some(hovered) != *open.read() { + if !just_closed.get() { + match hovered { + SidebarOpen::Roster => view! { + <div class="sidebar-drawer sidebar-hovering-drawer"> + <RosterList /> + </div> + }.into_any(), + SidebarOpen::Chats => view! { + <div class="sidebar-drawer sidebar-hovering-drawer"> + <ChatsList /> + </div> + }.into_any(), + } + } else { + + view! {}.into_any() + } + } else { + view! {}.into_any() + } + } else { + view! {}.into_any() + }} + {move || if let Some(opened) = *open.read() { + match opened { + SidebarOpen::Roster => view! { + <div class="sidebar-drawer"> + <RosterList /> + </div> + }.into_any(), + SidebarOpen::Chats => view! { + <div class="sidebar-drawer"> + <ChatsList /> + </div> + }.into_any(), + } + } else { + view! {}.into_any() + }} + </div> + } +} + |