diff options
author | 2025-05-01 02:17:47 +0100 | |
---|---|---|
committer | 2025-05-01 02:17:47 +0100 | |
commit | 6943577ec5169b04d8a24726fd87e85fc5b261a9 (patch) | |
tree | 0c2cf4d422bd62c4e4d1f9697766108f8a38b266 /src | |
parent | 8012b20aefa1b2e52cccbc132e8e96ce6bf2b81f (diff) | |
download | macaw-web-6943577ec5169b04d8a24726fd87e85fc5b261a9.tar.gz macaw-web-6943577ec5169b04d8a24726fd87e85fc5b261a9.tar.bz2 macaw-web-6943577ec5169b04d8a24726fd87e85fc5b261a9.zip |
feat: new message composer
Diffstat (limited to 'src')
-rw-r--r-- | src/lib.rs | 311 |
1 files changed, 298 insertions, 13 deletions
@@ -12,17 +12,17 @@ use std::{ }; use filamento::{ - chat::{Chat, Message}, db::Db, error::{CommandError, ConnectionError, DatabaseError}, files::FilesMem, roster::Contact, user::User, UpdateMessage + chat::{Body, Chat, ChatStoreFields, Message, MessageStoreFields}, db::Db, error::{CommandError, ConnectionError, DatabaseError}, files::FilesMem, roster::{Contact, ContactStoreFields}, user::{User, UserStoreFields}, UpdateMessage }; use futures::stream::StreamExt; use indexmap::IndexMap; use jid::JID; use leptos::{ - prelude::*, - task::{spawn, spawn_local}, + html::{self, Div, Input, Textarea}, prelude::*, tachys::dom::document, task::{spawn, spawn_local} }; use leptos_meta::Stylesheet; -use reactive_stores::Store; +use leptos_use::{use_textarea_autosize, UseTextareaAutosizeReturn}; +use reactive_stores::{Store, StoreField}; use stylance::import_style; use thiserror::Error; use tokio::sync::{mpsc::{self, Receiver}, Mutex}; @@ -293,6 +293,80 @@ impl MessageSubscriptions { } } +#[derive(Store)] +pub struct Roster { + #[store(key: JID = |(jid, _)| jid.clone())] + contacts: HashMap<JID, MacawContact>, +} + +impl Roster { + pub fn new() -> Self { + Self { + contacts: HashMap::new(), + } + } +} + +// TODO: multiple panels +// pub struct OpenChats { +// panels: +// } + +#[derive(Store, Default)] +pub struct OpenChatsPanel { + // jid must be a chat in the chats map + chat_view: Option<JID>, + #[store(key: JID = |(jid, _)| jid.clone())] + chats: IndexMap<JID, MacawChat>, +} + +pub fn open_chat(open_chats: Store<OpenChatsPanel>, chat: MacawChat) { + if let Some(jid) = &*open_chats.chat_view().read() { + if let Some((index, _jid, entry)) = open_chats.chats().write().shift_remove_full(jid) { + let new_jid = chat.chat.correspondent().read().clone(); + open_chats.chats().write().insert_before(index, new_jid.clone(), chat); + *open_chats.chat_view().write() = Some(new_jid); + } else { + let new_jid = chat.chat.correspondent().read().clone(); + open_chats.chats().write().insert(new_jid.clone(), chat); + *open_chats.chat_view().write() = Some(new_jid); + } + } else { + let new_jid = chat.chat.correspondent().read().clone(); + open_chats.chats().write().insert(new_jid.clone(), chat); + *open_chats.chat_view().write() = Some(new_jid); + } +} + +impl OpenChatsPanel { + pub fn open(&mut self, chat: MacawChat) { + if let Some(jid) = &mut self.chat_view { + if let Some((index, _jid, entry)) = self.chats.shift_remove_full(jid) { + let new_jid = chat.chat.correspondent().read().clone(); + self.chats.insert_before(index, new_jid.clone(), chat); + *&mut self.chat_view = Some(new_jid); + } else { + let new_jid = chat.chat.correspondent().read().clone(); + self.chats.insert(new_jid.clone(), chat); + *&mut self.chat_view = Some(new_jid); + } + } else { + let new_jid = chat.chat.correspondent().read().clone(); + self.chats.insert(new_jid.clone(), chat); + *&mut self.chat_view = Some(new_jid); + } + } + + // TODO: + // pub fn open_in_new_tab_unfocused(&mut self) { + + // } + + // pub fn open_in_new_tab_focus(&mut self) { + + // } +} + #[component] fn Macaw( // TODO: logout @@ -302,8 +376,7 @@ fn Macaw( ) -> impl IntoView { provide_context(client); - // TODO: roster as store - let (roster, set_roster) = signal(HashMap::<JID, MacawContact>::new()); + let roster = Store::new(Roster::new()); provide_context(roster); let message_subscriptions = RwSignal::new(MessageSubscriptions::new()); @@ -316,17 +389,37 @@ fn Macaw( let users_store: StateStore<JID, Store<User>> = StateStore::new(); provide_context(users_store); + let open_chats = Store::new(OpenChatsPanel::default()); + provide_context(open_chats); + // TODO: get cached contacts on login before getting the updated contacts OnceResource::new(async move { while let Some(update) = updates.recv().await { match update { UpdateMessage::Online(online, items) => { - + let contacts = items.into_iter().map(|(contact, user)| { + (contact.user_jid.clone(), MacawContact::got_contact_and_user(contact, user)) + }).collect(); + roster.contacts().set(contacts); }, UpdateMessage::Offline(offline) => {}, - UpdateMessage::RosterUpdate(contact, user) => {}, - UpdateMessage::RosterDelete(jid) => {}, + UpdateMessage::RosterUpdate(contact, user) => { + roster.contacts().update(|roster| { + if let Some(macaw_contact) = roster.get_mut(&contact.user_jid) { + macaw_contact.set(contact); + } else { + let jid = contact.user_jid.clone(); + let contact = MacawContact::got_contact_and_user(contact, user); + roster.insert(jid, contact); + } + }); + }, + UpdateMessage::RosterDelete(jid) => { + roster.contacts().update(|roster| { + roster.remove(&jid); + }); + }, UpdateMessage::Presence { from, presence } => {}, UpdateMessage::Message { to, from, message } => { // debug!("before got message"); @@ -335,7 +428,9 @@ fn Macaw( spawn_local(async move { message_subscriptions.write_untracked().broadcast(to, new_message).await }); // debug!("after set message"); }, - UpdateMessage::MessageDelivery { id, chat, delivery } => {}, + UpdateMessage::MessageDelivery { id, chat, delivery } => { + messages_store.modify(&id, |message| message.delivery().set(Some(delivery))); + }, UpdateMessage::SubscriptionRequest(jid) => {}, UpdateMessage::NickChanged { jid, nick } => { users_store.modify(&jid, |user| user.update(|user| *&mut user.nick = nick.clone())); @@ -347,7 +442,190 @@ fn Macaw( } }); - view! { <ChatsList /> } + view! { + <ChatsList /> + <OpenChatsPanelView /> + } +} + +#[component] +pub fn OpenChatsPanelView() -> impl IntoView { + let open_chats: Store<OpenChatsPanel> = use_context().expect("no open chats panel in context"); + + // TODO: tabs + // view! { + // {move || { + // if open_chats.chats().read().len() > 1 { + // Some( + // view! { + // <For + // each=move || open_chats.chats().get() + // key=|(jid, _)| jid.clone() + // let(chat) + // ></For> + // }, + // ) + // } else { + // None + // } + // }} + // } + view! { + <div class="open-chat-views"> + {move || { + if let Some(open_chat) = open_chats.chat_view().get() { + if let Some(open_chat) = open_chats.chats().read().get(&open_chat) { + view! { <OpenChatView chat=open_chat.clone() /> }.into_any() + } else { + view! {}.into_any() + } + } else { + view! {}.into_any() + } + }} + </div> + } +} + +#[component] +pub fn OpenChatView(chat: MacawChat) -> impl IntoView { + let chat_chat = *chat.chat; + let chat_jid = move || chat_chat.correspondent().get(); + + view! { + <div class="open-chat-view"> + <ChatViewHeader chat=chat.clone() /> + <MessageHistoryBuffer chat=chat.clone() /> + <ChatViewMessageComposer chat=chat_jid() /> + </div> + } +} + +#[component] +pub fn ChatViewHeader(chat: MacawChat) -> impl IntoView { + let chat_user = *chat.user; + let avatar = move || get_avatar(chat_user); + let name = move || get_name(chat_user); + let jid = move || chat_user.jid().read().to_string(); + + view! { + <div class="chat-view-header panel"> + <img class="avatar" src=avatar /> + <div class="user-info"> + <h2 class="name">{name}</h2> + <h3>{jid}</h3> + </div> + </div> + } +} + +#[component] +pub fn MessageHistoryBuffer(chat: MacawChat) -> impl IntoView { + view! {} +} + +#[component] +pub fn Message(message: MacawMessage, major: bool, r#final: bool) -> impl IntoView { + let message_message = *message.message; + let message_user = *message.user; + let avatar = move || get_avatar(message_user); + let name = move || get_name(message_user); + + // TODO: chrono-humanize? + // TODO: if final, show delivery not only on hover. + if major { + view! { + <div class="chat-message major"> + <img class="avatar" src=avatar /> + <div> + <div class="message-info"> + <div>{name}</div> + <div>{move || message_message.timestamp().read().to_string()}</div> + </div> + <div class="message-text"> + {move || message_message.body().read().body.clone()} + </div> + </div> + <div class="message-delivery"></div> + </div> + }.into_any() + } else { + view! { + <div class="chat-message minor"> + <div class="message-timestamp"> + {move || message_message.timestamp().read().to_string()} + </div> + <div class="message-text">{move || message_message.body().read().body.clone()}</div> + <div class="message-delivery"></div> + </div> + }.into_any() + } +} + +#[component] +pub fn ChatViewMessageComposer(chat: JID) -> impl IntoView { + let message_input: NodeRef<Textarea> = 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 send_message = Action::new(move |_| { + let value = chat.clone(); + async move { + spawn_local(async move { match client.read_untracked().send_message(value, Body { body: new_message.get_untracked() }).await { + Ok(_) => { + new_message.set("".to_string()); + }, + Err(e) => tracing::error!("message send error: {}", e), + }}) + } + }); + + let _focus = Effect::new(move |_| { + if let Some(input) = message_input.get() { + let _ = input.focus(); + // input.style("height: 0"); + // let height = input.scroll_height(); + // input.style(format!("height: {}px", height)); + } + }); + + view! { + <script> + const growers = document.querySelectorAll(".grow-wrap"); + + growers.forEach((grower) => { + const textarea = grower.querySelector("textarea"); + textarea.addEventListener("input", () => { + grower.dataset.replicatedValue = textarea.value; + }); + }); + + </script> + <form + class="new-message-composer panel" + // on:input=resize + // on:input=|_| "this.parentNode.dataset.replicatedValue = this.value" + on:submit=move |ev| { + ev.prevent_default(); + send_message.dispatch(()); + } + > + <div class="grow-wrap"> + <textarea + placeholder="New Message" + prop:value=new_message + on:input=move |ev| new_message.set(event_target_value(&ev)) + name="new_message" + node_ref=message_input + autofocus="true" + /> + </div> + <input hidden type="submit" /> + </form> + } } // V has to be an arc signal @@ -724,8 +1002,9 @@ pub fn get_avatar(user: Store<User>) -> String { } pub fn get_name(user: Store<User>) -> String { - let roster: ReadSignal<HashMap<JID, MacawContact>> = use_context().expect("no roster in context"); + let roster: Store<Roster> = use_context().expect("no roster in context"); if let Some(name) = roster + .contacts() .read() .get(&user.read().jid) .map(|contact| contact.read().name.clone()) @@ -747,9 +1026,15 @@ fn ChatsListItem(chat: MacawChat, message: MacawMessage) -> impl IntoView { // TODO: store fine-grained reactivity let latest_message_body = move || message.get().body.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())); + }; view! { - <div class="chats-list-item"> + <div class="chats-list-item" on:click=open_chat> <img class="avatar" src=avatar /> <div class="item-info"> <h3>{name}</h3> |