diff options
| author | 2025-05-01 02:17:47 +0100 | |
|---|---|---|
| committer | 2025-05-01 02:17:47 +0100 | |
| commit | 6943577ec5169b04d8a24726fd87e85fc5b261a9 (patch) | |
| tree | 0c2cf4d422bd62c4e4d1f9697766108f8a38b266 | |
| parent | 8012b20aefa1b2e52cccbc132e8e96ce6bf2b81f (diff) | |
| download | macaw-web-6943577ec5169b04d8a24726fd87e85fc5b261a9.tar.gz macaw-web-6943577ec5169b04d8a24726fd87e85fc5b261a9.tar.bz2 macaw-web-6943577ec5169b04d8a24726fd87e85fc5b261a9.zip  | |
feat: new message composer
| -rw-r--r-- | .helix/languages.toml | 2 | ||||
| -rw-r--r-- | Cargo.lock | 186 | ||||
| -rw-r--r-- | Cargo.toml | 3 | ||||
| -rw-r--r-- | assets/style.scss | 107 | ||||
| -rw-r--r-- | index.html | 2 | ||||
| -rw-r--r-- | src/lib.rs | 311 | 
6 files changed, 588 insertions, 23 deletions
diff --git a/.helix/languages.toml b/.helix/languages.toml index 3be859c..75f513c 100644 --- a/.helix/languages.toml +++ b/.helix/languages.toml @@ -1,5 +1,5 @@  [language-server.rust-analyzer]  command = "rust-analyzer" -config = { rustfmt.overrideCommand = ["leptosfmt", "--stdin", "--rustfmt"] } +config = { rustfmt.overrideCommand = ["leptosfmt", "--stdin", "--rustfmt"], cargo.features = ["filamento/reactive_stores"] }  # environment = { "DATABASE_URL" = "sqlite://filamento.db" }  # config = { cargo.features = ["stanza/rfc_6121", "stanza/xep_0203", "stanza/xep_0030", "stanza/xep_0060", "stanza/xep_0172", "stanza/xep_0390", "stanza/xep_0128", "stanza/xep_0115", "stanza/xep_0084", "sqlx/sqlite", "sqlx/runtime-tokio", "sqlx/uuid", "sqlx/chrono", "jid/sqlx", "uuid/v4", "tokio/full", "rsasl/provider_base64", "rsasl/plain", "rsasl/config_builder", "rsasl/scram-sha-1"] } @@ -431,6 +431,17 @@ dependencies = [  ]  [[package]] +name = "cookie" +version = "0.18.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4ddef33a339a91ea89fb53151bd0a4689cfce27055c291dfa69945475d22c747" +dependencies = [ + "percent-encoding", + "time", + "version_check", +] + +[[package]]  name = "core-foundation"  version = "0.9.4"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -515,6 +526,41 @@ dependencies = [  ]  [[package]] +name = "darling" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc7f46116c46ff9ab3eb1597a45688b6715c6e628b5c133e288e709a29bcb4ee" +dependencies = [ + "darling_core", + "darling_macro", +] + +[[package]] +name = "darling_core" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d00b9596d185e565c2207a0b01f8bd1a135483d02d9b7b0a54b11da8d53412e" +dependencies = [ + "fnv", + "ident_case", + "proc-macro2", + "quote", + "strsim", + "syn 2.0.101", +] + +[[package]] +name = "darling_macro" +version = "0.20.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fc34b93ccb385b40dc71c6fceac4b2ad23662c7eeb248cf10d529b7e055b6ead" +dependencies = [ + "darling_core", + "quote", + "syn 2.0.101", +] + +[[package]]  name = "dashmap"  version = "6.1.0"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -535,6 +581,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"  checksum = "2a2330da5de22e8a3cb63252ce2abb30116bf5265e89c0e01bc17015ce30a476"  [[package]] +name = "default-struct-builder" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e0df63c21a4383f94bd5388564829423f35c316aed85dc4f8427aded372c7c0d" +dependencies = [ + "darling", + "proc-macro2", + "quote", + "syn 2.0.101", +] + +[[package]]  name = "delegate-display"  version = "3.0.0"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -549,6 +607,15 @@ dependencies = [  ]  [[package]] +name = "deranged" +version = "0.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9c9e6a11ca8224451684bc0d7d5a7adbf8f2fd6887261a1cfc3c0432f9d4068e" +dependencies = [ + "powerfmt", +] + +[[package]]  name = "derive-where"  version = "1.3.0"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -744,6 +811,7 @@ dependencies = [   "image",   "jid",   "lampada", + "reactive_stores",   "rusqlite",   "serde",   "sha1", @@ -974,6 +1042,18 @@ dependencies = [  ]  [[package]] +name = "gloo-timers" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bbb143cf96099802033e0d4f4963b19fd2e0b728bcf076cd9cf7f6634f092994" +dependencies = [ + "futures-channel", + "futures-core", + "js-sys", + "wasm-bindgen", +] + +[[package]]  name = "gloo-utils"  version = "0.2.0"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1247,6 +1327,12 @@ dependencies = [  ]  [[package]] +name = "ident_case" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e0384b61958566e926dc50660321d12159025e767c18e043daf26b70104c39" + +[[package]]  name = "idna"  version = "0.2.3"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1538,6 +1624,31 @@ dependencies = [  ]  [[package]] +name = "leptos-use" +version = "0.15.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e612389629007497d1e90deecf5bddd790e59e32358546fa7beaf88a68d2067b" +dependencies = [ + "cfg-if", + "chrono", + "codee", + "cookie", + "default-struct-builder", + "futures-util", + "gloo-timers", + "js-sys", + "lazy_static", + "leptos", + "paste", + "send_wrapper", + "thiserror 2.0.12", + "unic-langid", + "wasm-bindgen", + "wasm-bindgen-futures", + "web-sys", +] + +[[package]]  name = "leptos_config"  version = "0.7.8"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -1776,6 +1887,7 @@ dependencies = [   "try_map",   "uuid",   "wasm-bindgen", + "wasm-bindgen-futures",   "web-sys",  ] @@ -1789,6 +1901,7 @@ dependencies = [   "indexmap",   "jid",   "leptos", + "leptos-use",   "leptos_meta",   "leptos_reactive",   "reactive_stores", @@ -1977,6 +2090,12 @@ dependencies = [  ]  [[package]] +name = "num-conv" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "51d515d32fb182ee37cda2ccdcb92950d6a3c2893aa280e540671c2cd0f3b1d9" + +[[package]]  name = "num-derive"  version = "0.4.2"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2164,7 +2283,7 @@ dependencies = [  [[package]]  name = "peanuts"  version = "0.1.0" -source = "git+https://bunny.garden/peanuts#9aa337bd703426f737c5d4f94fe84c4a646b7836" +source = "git+https://bunny.garden/peanuts#94afe363ea88a6fd036d1681542d650e7cd3c2e7"  dependencies = [   "async-recursion",   "circular", @@ -2238,6 +2357,12 @@ dependencies = [  ]  [[package]] +name = "powerfmt" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "439ee305def115ba05938db6eb1644ff94165c5ab5e9420d1c1bcedbba909391" + +[[package]]  name = "ppv-lite86"  version = "0.2.21"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -2879,9 +3004,9 @@ dependencies = [  [[package]]  name = "sha2" -version = "0.10.8" +version = "0.10.9"  source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "793db75ad2bcafc3ffa7c68b215fee268f537982cd901d132f89c6343f3a3dc8" +checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"  dependencies = [   "cfg-if",   "cpufeatures", @@ -3015,6 +3140,12 @@ dependencies = [  ]  [[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]]  name = "stylance"  version = "0.6.0"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3243,6 +3374,37 @@ dependencies = [  ]  [[package]] +name = "time" +version = "0.3.41" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8a7619e19bc266e0f9c5e6686659d394bc57973859340060a69221e57dbc0c40" +dependencies = [ + "deranged", + "itoa", + "num-conv", + "powerfmt", + "serde", + "time-core", + "time-macros", +] + +[[package]] +name = "time-core" +version = "0.1.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9e9a38711f559d9e3ce1cdb06dd7c5b8ea546bc90052da6d06bb76da74bb07c" + +[[package]] +name = "time-macros" +version = "0.2.22" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3526739392ec93fd8b359c8e98514cb3e8e021beb4e5f597b00a0221f8ed8a49" +dependencies = [ + "num-conv", + "time-core", +] + +[[package]]  name = "tinystr"  version = "0.7.6"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -3500,6 +3662,24 @@ source = "registry+https://github.com/rust-lang/crates.io-index"  checksum = "1dccffe3ce07af9386bfd29e80c0ab1a8205a2fc34e4bcd40364df902cfa8f3f"  [[package]] +name = "unic-langid" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23dd9d1e72a73b25e07123a80776aae3e7b0ec461ef94f9151eed6ec88005a44" +dependencies = [ + "unic-langid-impl", +] + +[[package]] +name = "unic-langid-impl" +version = "0.9.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a5422c1f65949306c99240b81de9f3f15929f5a8bfe05bb44b034cc8bf593e5" +dependencies = [ + "tinystr", +] + +[[package]]  name = "unicode-bidi"  version = "0.3.18"  source = "registry+https://github.com/rust-lang/crates.io-index" @@ -5,11 +5,12 @@ edition = "2024"  [dependencies]  console_error_panic_hook = "0.1.7" -filamento = { path = "../luz/filamento", features = ["serde"] } +filamento = { path = "../luz/filamento", features = ["serde", "reactive_stores"] }  futures = "0.3.31"  indexmap = "2.9.0"  jid = { path = "../luz/jid" }  leptos = { version = "0.7.5", features = ["csr"] } +leptos-use = "0.15.7"  leptos_meta = "0.7.8"  leptos_reactive = { version = "0.6.15", features = ["csr"] }  reactive_stores = "0.1.8" diff --git a/assets/style.scss b/assets/style.scss index fdc6e61..d4b4e91 100644 --- a/assets/style.scss +++ b/assets/style.scss @@ -14,6 +14,10 @@ body {    word-wrap: break-word;    text-wrap: wrap;    overflow-wrap: break-word; +  display: flex; +  justify-content: stretch; +  align-items: stretch; +  flex: 1 1 auto;  }  html { @@ -54,12 +58,15 @@ html {  .panel {    border: 2px solid black; +  border-color: black; +  border-style: solid;    background: linear-gradient(180deg, #364B3B 0%, #19311F 100%);  }  #login-form {    padding: 32px;    margin: 4px; +  max-width: 850px;  }  #login-form form { @@ -131,8 +138,13 @@ p {  }  .chats-list { -  border: 2px solid black; -  height: 100%; +  border-width: 0 2px 0 0; +  border-color: black; +  border-style: solid; +  height: auto; +  width: 400px; +  flex: 0 0 auto; +  align-self: stretch;  }  .chats-list>* { @@ -162,11 +174,98 @@ p {    /* background: linear-gradient(0deg, #364B3B 0%, #19311F 100%); */  } +.avatar { +  object-fit: contain; +  border-radius: 50%; +  width: 48px; +  height: 48px; +} +  .chats-list-item .avatar {    width: 48px;    height: 48px; -  object-fit: contain; -  border-radius: 50%; +} + +.chats-list-chats { +  height: auto; +} + +.open-chat-views { +  flex: 1 1 100%; +} + +.open-chat-view { +  height: auto; +} + +.chat-view-header .avatar { +  width: 64px; +  height: 64px; +} + +.chat-view-header { +  border-width: 0 0 2px 0; +  display: flex; +  padding: 16px; +  gap: 16px; +} + +.chat-view-header h2 { +  font-family: 'K2D'; +  font-weight: bold; +} + +.new-message-composer { +  border-width: 2px 0 0 0; +  width: auto; +  padding: 16px; +} + +.new-message-composer textarea { +  font-family: 'K2D'; +  border: 2px solid black; +  font-size: 16px; +  padding: 8px; +  /* width: auto; */ +  margin: 0; +  height: auto; +  /* resize: none; */ +  /* box-sizing: border-box; */ +  /* overflow: scroll; */ +  /* display: block; */ +} + +.grow-wrap { +  /* easy way to plop the elements on top of each other and have them both sized based on the tallest one's height */ +  display: grid; +} + +.grow-wrap::after { +  /* Note the weird space! Needed to preventy jumpy behavior */ +  content: attr(data-replicated-value) " "; +  /* This is how textarea text behaves */ +  white-space: pre-wrap; +  /* Hidden from view, clicks, and screen readers */ +  visibility: hidden; +  max-height: 5em; +} + +.grow-wrap>textarea { +  /* You could leave this, but after a user resizes, then it ruins the auto sizing */ +  resize: none; +  /* Firefox shows scrollbar on growth, you can hide like this. */ +  overflow: scroll; +} + +.grow-wrap>textarea, +.grow-wrap::after { +  /* Identical styling required!! */ +  border: 2px solid black; +  padding: 8px; +  font: inherit; + +  /* Place on top of each other */ +  grid-area: 1 / 1 / 2 / 2;  }  /* font-families */ @@ -21,4 +21,4 @@  <body></body> -</html>
\ No newline at end of file +</html> @@ -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>  | 
