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>  | 
