From 761814f8c3bdd9a7bc3f0bd19341f0cd6e8fc2d7 Mon Sep 17 00:00:00 2001 From: Flemmli97 <34157027+Flemmli97@users.noreply.github.com> Date: Fri, 1 Dec 2023 16:33:11 +0100 Subject: [PATCH] feat(chat): User mentioning (#1543) Co-authored-by: Flemmli97 Co-authored-by: sdwoodbury Co-authored-by: Phill Wisniewski <93608357+phillsatellite@users.noreply.github.com> --- Cargo.lock | 1 + common/Cargo.toml | 1 + common/locales/en-US/main.ftl | 5 +- common/src/state/action.rs | 2 +- common/src/state/chats.rs | 5 + common/src/state/mod.rs | 78 ++++++-- common/src/state/pending_message.rs | 2 + common/src/testing/mock.rs | 2 + common/src/warp_runner/ui_adapter/mod.rs | 48 ++++- kit/src/components/message/mod.rs | 18 +- kit/src/components/message/style.scss | 16 ++ kit/src/layout/chatbar/mod.rs | 170 ++++++++++++------ kit/src/layout/chatbar/style.scss | 123 +++++++++++++ kit/src/style.scss | 2 + .../settings/sub_pages/developer.rs | 2 +- ui/src/layouts/chats/data/msg_group.rs | 32 +++- ui/src/layouts/chats/mod.rs | 1 - ui/src/layouts/chats/presentation/chat/mod.rs | 29 ++- .../layouts/chats/presentation/chatbar/mod.rs | 91 +++++++--- .../chats/presentation/messages/mod.rs | 17 +- .../layouts/chats/presentation/sidebar/mod.rs | 6 +- ui/src/layouts/chats/scripts/mod.rs | 1 + ui/src/layouts/chats/scripts/show_context.js | 7 +- .../chats/scripts/user_tag_click_handler.js | 13 ++ ui/src/layouts/chats/style.scss | 54 ------ 25 files changed, 554 insertions(+), 172 deletions(-) create mode 100644 ui/src/layouts/chats/scripts/user_tag_click_handler.js diff --git a/Cargo.lock b/Cargo.lock index 469dc9dd616..92e3c18122b 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1487,6 +1487,7 @@ dependencies = [ "once_cell", "plot_icon", "rand 0.8.5", + "regex", "rodio", "serde", "serde_json", diff --git a/common/Cargo.toml b/common/Cargo.toml index 2f54007de7f..78c076c99fa 100644 --- a/common/Cargo.toml +++ b/common/Cargo.toml @@ -33,6 +33,7 @@ humansize = { workspace = true } zip = "0.6.4" walkdir = { workspace = true } extensions = { workspace = true } +regex = { workspace = true } futures = { workspace = true } # do we really want to pull in all of tokio? diff --git a/common/locales/en-US/main.ftl b/common/locales/en-US/main.ftl index a28797e88aa..f35a45ae1ac 100644 --- a/common/locales/en-US/main.ftl +++ b/common/locales/en-US/main.ftl @@ -100,8 +100,9 @@ messages = Messages .pinned-max = You reached the maximum amount of pinned messages for this chat .missing-emoji-picker = Emoji extension is disabled .unknown-identity = User not found: - .suggested-emoji = Suggested Emoji - + .emoji-suggestion = Suggested Emoji + .username-suggestion = Suggested Users + favorites = Favorites .favorites = Favorites .add = Add to Favorites diff --git a/common/src/state/action.rs b/common/src/state/action.rs index 5f82ec39456..b8aa65b4d53 100644 --- a/common/src/state/action.rs +++ b/common/src/state/action.rs @@ -95,7 +95,7 @@ pub enum Action<'a> { ClearAllPopoutWindows(DesktopContext), // Notifications #[display(fmt = "AddNotification")] - AddNotification(NotificationKind, u32), + AddNotification(NotificationKind, u32, bool), #[display(fmt = "RemoveNotification")] RemoveNotification(NotificationKind, u32), #[display(fmt = "ClearNotification")] diff --git a/common/src/state/chats.rs b/common/src/state/chats.rs index 61bc906d8f0..9d56400224b 100644 --- a/common/src/state/chats.rs +++ b/common/src/state/chats.rs @@ -53,6 +53,10 @@ pub struct Chat { // Unread count for this chat, should be cleared when we view the chat. #[serde(default)] unreads: HashSet, + // This tracks the messages that mentions the user. For future use + // E.g. displaying a list of mentions to the user in a pop up + #[serde(default, skip)] + pub mentions: VecDeque, // If a value exists, we will render the message we're replying to above the chatbar #[serde(skip)] pub replying_to: Option, @@ -87,6 +91,7 @@ impl Default for Chat { creator: Default::default(), messages: Default::default(), unreads: Default::default(), + mentions: Default::default(), replying_to: Default::default(), typing_indicator: Default::default(), draft: Default::default(), diff --git a/common/src/state/mod.rs b/common/src/state/mod.rs index 97abc13360f..15d425f2548 100644 --- a/common/src/state/mod.rs +++ b/common/src/state/mod.rs @@ -23,6 +23,7 @@ pub use chats::{Chat, Chats}; use dioxus_desktop::tao::window::WindowId; pub use friends::Friends; pub use identity::Identity; +use regex::Regex; pub use route::Route; pub use settings::Settings; pub use ui::{Theme, ToastNotification, UI}; @@ -62,7 +63,6 @@ use self::ui::{Font, Layout}; use self::utils::get_available_themes; pub const MAX_PINNED_MESSAGES: u8 = 100; - // todo: create an Identity cache and only store UUID in state.friends and state.chats // store the following information in the cache: key: DID, value: { Identity, HashSet } // the HashSet would be used to determine when to evict an identity. (they are not participating in any conversations and are not a friend) @@ -174,11 +174,11 @@ impl State { } } // ===== Notifications ===== - Action::AddNotification(kind, count) => self.ui.notifications.increment( + Action::AddNotification(kind, count, forced) => self.ui.notifications.increment( &self.configuration, kind, count, - !self.ui.metadata.focused, + forced || !self.ui.metadata.focused, ), Action::RemoveNotification(kind, count) => self.ui.notifications.decrement(kind, count), Action::ClearNotification(kind) => self.ui.notifications.clear_kind(kind), @@ -281,6 +281,7 @@ impl State { inner: m, in_reply_to: None, key: Uuid::new_v4().to_string(), + ..Default::default() }; self.add_msg_to_chat(id, m); } @@ -356,6 +357,7 @@ impl State { self.mutate(Action::AddNotification( notifications::NotificationKind::FriendRequest, 1, + false, )); // TODO: Get state available in this scope. @@ -436,8 +438,15 @@ impl State { match event { MessageEvent::Received { conversation_id, - message, + mut message, } => { + if let Some(ids) = self + .get_chat_by_id(conversation_id) + .map(|c| self.chat_participants(&c)) + { + message.insert_did(&ids, &self.get_own_identity().did_key()); + } + let ping = message.is_mention; self.update_identity_status_hack(&message.inner.sender()); let id = self.identities.get(&message.inner.sender()).cloned(); // todo: don't load all the messages by default. if the user scrolled up, for example, this incoming message may not need to be fetched yet. @@ -450,6 +459,7 @@ impl State { self.mutate(Action::AddNotification( notifications::NotificationKind::Message, 1, + ping, )); // Dispatch notifications only when we're not already focused on the application. @@ -508,15 +518,22 @@ impl State { } MessageEvent::Edited { conversation_id, - message, + mut message, } => { self.update_identity_status_hack(&message.inner.sender()); + let own = self.get_own_identity().did_key(); if let Some(chat) = self.chats.all.get_mut(&conversation_id) { - if let Some(msg) = chat - .messages - .iter_mut() - .find(|msg| msg.inner.id() == message.inner.id()) - { + message.insert_did( + &chat + .participants + .iter() + .filter_map(|id| self.identities.get(id)) + .cloned() + .collect::>(), + &own, + ); + let id = message.inner.id(); + if let Some(msg) = chat.messages.iter_mut().find(|msg| msg.inner.id() == id) { *msg = message.clone(); } @@ -526,12 +543,16 @@ impl State { } } - if let Some(msg) = chat - .pinned_messages - .iter_mut() - .find(|m| m.id() == message.inner.id()) - { - *msg = message.inner; + if let Some(msg) = chat.pinned_messages.iter_mut().find(|m| m.id() == id) { + *msg = message.inner.clone(); + } + + if message.is_mention { + if let Some(msg) = chat.mentions.iter_mut().find(|m| m.inner.id() == id) { + *msg = message.clone(); + } + } else { + chat.mentions.retain(|m| m.inner.id() != id); } } } @@ -548,6 +569,7 @@ impl State { } chat.messages.retain(|msg| msg.inner.id() != message_id); chat.pinned_messages.retain(|msg| msg.id() != message_id); + chat.mentions.retain(|msg| msg.inner.id() != message_id); if let Some(msg) = most_recent_message { if chat.messages.is_empty() { @@ -888,7 +910,8 @@ impl State { let is_active_scrolled = self.chats.active_chat_is_scrolled(); if let Some(chat) = self.chats.all.get_mut(&conversation_id) { chat.typing_indicator.remove(&message.inner.sender()); - chat.messages.push_back(message); + chat.messages.push_back(message.clone()); + chat.mentions.push_back(message); // only care about the most recent message, for the sidebar if chat.messages.len() > 1 { chat.messages.pop_front(); @@ -1868,3 +1891,24 @@ pub fn pending_group_messages<'a>( messages, }) } + +pub fn mention_regex_pattern(id: &Identity, username: bool) -> Regex { + Regex::new(&format!( + "(^| )@{}( |$)", + if username { + id.username() + } else { + id.did_key().to_string() + } + )) + .unwrap() +} + +pub fn mention_replacement_pattern(id: &Identity, visual: bool) -> String { + format!( + r#"
@{}
"#, + if visual { "visual-only" } else { "" }, + id.did_key(), + id.username() + ) +} diff --git a/common/src/state/pending_message.rs b/common/src/state/pending_message.rs index 33fe1c22684..650f1dd4dd6 100644 --- a/common/src/state/pending_message.rs +++ b/common/src/state/pending_message.rs @@ -25,6 +25,7 @@ impl PendingMessage { inner, in_reply_to: None, key: String::new(), + ..Default::default() }; PendingMessage { attachments: attachments @@ -63,6 +64,7 @@ impl PendingMessage { inner, in_reply_to: None, key: Uuid::new_v4().to_string(), + ..Default::default() }; PendingMessage { attachments: attachments diff --git a/common/src/testing/mock.rs b/common/src/testing/mock.rs index 849f40c2fee..ef2905ab1a6 100644 --- a/common/src/testing/mock.rs +++ b/common/src/testing/mock.rs @@ -110,6 +110,7 @@ fn generate_fake_chat(participants: Vec, conversation: Uuid) -> Chat { inner: default_message, in_reply_to: None, key: Uuid::new_v4().to_string(), + ..Default::default() }); } @@ -263,6 +264,7 @@ fn generate_fake_message(conversation_id: Uuid, identities: &[Identity]) -> ui_a inner: default_message, in_reply_to: None, key: Uuid::new_v4().to_string(), + ..Default::default() } } diff --git a/common/src/warp_runner/ui_adapter/mod.rs b/common/src/warp_runner/ui_adapter/mod.rs index 071c9dc435a..5d3947de213 100644 --- a/common/src/warp_runner/ui_adapter/mod.rs +++ b/common/src/warp_runner/ui_adapter/mod.rs @@ -12,7 +12,7 @@ pub use multipass_event::{convert_multipass_event, MultiPassEvent}; pub use raygun_event::{convert_raygun_event, RayGunEvent}; use uuid::Uuid; -use crate::state::{self, chats, MAX_PINNED_MESSAGES}; +use crate::state::{self, chats, Identity, MAX_PINNED_MESSAGES}; use futures::{stream::FuturesOrdered, FutureExt, StreamExt}; use serde::{Deserialize, Serialize}; use std::{ @@ -24,7 +24,7 @@ use warp::{ crypto::DID, error::Error, logging::tracing::log, - multipass::identity::{Identifier, Identity, Platform}, + multipass::identity::{Identifier, Platform}, raygun::{self, Conversation, MessageOptions}, }; @@ -34,16 +34,55 @@ use super::{ /// the UI needs additional information for message replies, namely the text of the message being replied to. /// fetch that before sending the message to the UI. -#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)] +#[derive(Clone, Debug, PartialEq, Eq, Default, Serialize, Deserialize)] pub struct Message { pub inner: warp::raygun::Message, pub in_reply_to: Option<(String, Vec, DID)>, + pub lines_to_render: Option, + pub is_mention: bool, /// this field exists so that the UI can tell Dioxus when a message has been edited and thus /// needs to be re-rendered. Before the addition of this field, the compose view was /// using the message Uuid, but this doesn't change when a message is edited. pub key: String, } +impl Message { + // resolve the message lines to e.g. format user mentions correctly + pub fn insert_did(&mut self, participants: &[state::Identity], own: &DID) { + if self.lines_to_render.is_some() { + return; + } + // Better if warp provides a way of saving mentions in the message + // so dont need to loop over all participants + let lines = self.inner.lines().join("\n"); + let (lines, is_mention) = format_mentions(lines, participants, own, false); + self.is_mention = is_mention; + self.lines_to_render = Some(lines); + } +} + +pub fn format_mentions( + message: String, + participants: &[state::Identity], + own: &DID, + visual: bool, +) -> (String, bool) { + if !message.contains('@') { + return (message, false); + } + let mut result = message; + let mut is_mention = false; + participants.iter().for_each(|id| { + let reg = state::mention_regex_pattern(id, false); + let replaced = reg.replace_all(&result, state::mention_replacement_pattern(id, visual)); + if own.eq(&id.did_key()) && !replaced.eq(&result) { + is_mention = true; + } + result = replaced.to_string(); + }); + (result, is_mention) +} + #[derive(Clone)] pub struct ChatAdapter { pub inner: chats::Chat, @@ -70,6 +109,7 @@ pub async fn convert_raygun_message( ) }), key: Uuid::new_v4().to_string(), + ..Default::default() } } @@ -91,7 +131,7 @@ pub fn get_uninitialized_identity(did: &DID) -> Result { .try_into() .map_err(|_e| warp::error::Error::OtherWithContext("did to short".into()))?; default.set_short_id(short); - Ok(state::Identity::from(default)) + Ok(default) } // this function is used in response to warp events. assuming that the DID from these events is valid. diff --git a/kit/src/components/message/mod.rs b/kit/src/components/message/mod.rs index 2b141b98f63..1ad1fbbfb5f 100644 --- a/kit/src/components/message/mod.rs +++ b/kit/src/components/message/mod.rs @@ -70,6 +70,8 @@ pub struct Props<'a> { // An optional field that, if set, will be used as the text content of a nested p element with a class of "text". with_text: Option, + tagged_text: Option, + reactions: Vec, // An optional field that, if set to true, will add a CSS class of "remote" to the div element. @@ -107,6 +109,8 @@ pub struct Props<'a> { attachments_pending_uploads: Option<&'a Vec>, pinned: bool, + + is_mention: bool, } fn wrap_links_with_a_tags(text: &str) -> String { @@ -133,6 +137,11 @@ pub fn Message<'a>(cx: Scope<'a, Props<'a>>) -> Element<'a> { // omitting the class will display the reactions starting from the bottom right corner let remote_class = ""; //if is_remote { "remote" } else { "" }; let reactions_class = format!("message-reactions-container {remote_class}"); + let rendered_text = cx + .props + .tagged_text + .as_ref() + .or(cx.props.with_text.as_ref()); let has_attachments = cx .props @@ -181,6 +190,7 @@ pub fn Message<'a>(cx: Scope<'a, Props<'a>>) -> Element<'a> { let loading_class = loading.then_some("loading").unwrap_or_default(); let remote_class = is_remote.then_some("remote").unwrap_or_default(); + let mention_class = cx.props.is_mention.then_some("mention").unwrap_or_default(); let order_class = order.to_string(); let msg_pending_class = cx .props @@ -208,8 +218,8 @@ pub fn Message<'a>(cx: Scope<'a, Props<'a>>) -> Element<'a> { div { class: { format_args!( - "message {} {} {} {}", - loading_class, remote_class, order_class, msg_pending_class + "message {} {} {} {} {}", + loading_class, remote_class, order_class, msg_pending_class, mention_class ) }, aria_label: { @@ -249,7 +259,7 @@ pub fn Message<'a>(cx: Scope<'a, Props<'a>>) -> Element<'a> { ), (cx.props.with_text.is_some() && !cx.props.editing).then(|| rsx!( ChatText { - text: cx.props.with_text.clone().unwrap_or_default(), + text: rendered_text.cloned().unwrap_or_default(), remote: is_remote, pending: cx.props.pending, markdown: cx.props.parse_markdown, @@ -338,6 +348,7 @@ pub struct ChatMessageProps { pending: bool, markdown: bool, ascii_emoji: bool, + participants: Option>, } #[allow(non_snake_case)] @@ -448,7 +459,6 @@ pub fn replace_emojis(input: &str) -> String { fn markdown(text: &str, emojis: bool) -> String { let txt = text.trim(); - if emojis { let r = replace_emojis(txt); // TODO: Watch this issue for a fix: https://github.com/open-i18n/rust-unic/issues/280 diff --git a/kit/src/components/message/style.scss b/kit/src/components/message/style.scss index 31b8ed7fbaa..99b19b8796d 100644 --- a/kit/src/components/message/style.scss +++ b/kit/src/components/message/style.scss @@ -34,6 +34,10 @@ } } +.message.mention { + background-color: var(--background-mention); +} + .message.message-pending { opacity: var(--opacity-modifier); } @@ -128,4 +132,16 @@ width: 100%; } } +} + +.message-user-tag { + background-color: var(--text-color-user-tag); + width: fit-content; + height: fit-content; + &:not(.visual-only) { + cursor: pointer; + &:hover { + background-color: color-mix(in srgb, var(--text-color-user-tag) 50%, white); + } + } } \ No newline at end of file diff --git a/kit/src/layout/chatbar/mod.rs b/kit/src/layout/chatbar/mod.rs index 4ea43c85f5c..e1c59061e67 100644 --- a/kit/src/layout/chatbar/mod.rs +++ b/kit/src/layout/chatbar/mod.rs @@ -1,3 +1,4 @@ +use common::state::Identity; use dioxus::prelude::*; use dioxus_elements::input_data::keyboard_types::Code; use warp::constellation::file::File; @@ -5,6 +6,7 @@ use warp::constellation::file::File; use crate::{ components::{ embeds::file_embed::FileEmbed, message::format_text, message_typing::MessageTyping, + user_image::UserImage, }, elements::{button::Button, label::Label, textarea, Appearance}, }; @@ -13,6 +15,34 @@ use common::{icons, language::get_local_text, warp_runner::thumbnail_to_base64}; pub type To = &'static str; +pub enum SuggestionType { + None, + // Emoji suggestions. First is the string that was matched. Second is the emojis matched + Emoji(String, Vec<(String, String)>), + // Username tag suggestions. First is the string that was matched. Second is the users that matched + Tag(String, Vec), +} + +impl SuggestionType { + fn get_replacement_for_index(&self, index: usize) -> (String, String) { + match self { + SuggestionType::None => (String::new(), String::new()), + SuggestionType::Emoji(pattern, v) => (pattern.clone(), v[index].0.clone()), + SuggestionType::Tag(pattern, v) => ( + pattern.clone(), + format!("{}#{}", v[index].username(), v[index].short_id()), + ), + } + } + + fn is_empty(&self) -> bool { + match self { + SuggestionType::None => true, + SuggestionType::Emoji(_, v) => v.is_empty(), + SuggestionType::Tag(_, v) => v.is_empty(), + } + } +} #[derive(Clone, PartialEq)] pub struct Route { pub to: To, @@ -42,9 +72,9 @@ pub struct Props<'a> { #[props(default = false)] is_disabled: bool, ignore_focus: bool, - emoji_suggestions: &'a Vec<(String, String)>, + suggestions: &'a SuggestionType, oncursor_update: Option>, - on_emoji_click: Option>, + on_suggestion_click: Option>, } #[derive(Props)] @@ -137,8 +167,8 @@ pub fn Chatbar<'a>(cx: Scope<'a, Props<'a>>) -> Element<'a> { let controlled_input_id = &cx.props.id; let is_typing = !cx.props.typing_users.is_empty(); let cursor_position = use_ref(cx, || None); - let selected_emoji: &UseRef> = use_ref(cx, || None); - let is_emoji_suggestion_modal_closed: &UseRef = use_ref(cx, || false); + let selected_suggestion: &UseRef> = use_ref(cx, || None); + let is_suggestion_modal_closed: &UseRef = use_ref(cx, || false); let eval = use_eval(cx); cx.render(rsx!( @@ -157,20 +187,20 @@ pub fn Chatbar<'a>(cx: Scope<'a, Props<'a>>) -> Element<'a> { show_char_counter: true, value: if cx.props.is_disabled { get_local_text("messages.loading")} else { cx.props.value.clone().unwrap_or_default()}, onkeyup: move |keycode| { - if !*is_emoji_suggestion_modal_closed.read() && keycode == Code::Escape { - is_emoji_suggestion_modal_closed.with_mut(|i| *i = true); + if !*is_suggestion_modal_closed.read() && keycode == Code::Escape { + is_suggestion_modal_closed.with_mut(|i| *i = true); } }, onchange: move |(v, _)| { cx.props.onchange.call(v); - *is_emoji_suggestion_modal_closed.write_silent() = false; + *is_suggestion_modal_closed.write_silent() = false; }, onreturn: move |(v, is_valid, _)| { - if let Some(i) = selected_emoji.write_silent().take() { - if let Some(e) = cx.props.on_emoji_click.as_ref() { + if let Some(i) = selected_suggestion.write_silent().take() { + if let Some(e) = cx.props.on_suggestion_click.as_ref() { if let Some(p) = cursor_position.read().as_ref() { - let (emoji, alias) = cx.props.emoji_suggestions[i].clone(); - e.call((emoji, alias,*p)); + let (pattern, replacement) = cx.props.suggestions.get_replacement_for_index(i); + e.call((replacement, pattern,*p)); return; } } @@ -186,15 +216,19 @@ pub fn Chatbar<'a>(cx: Scope<'a, Props<'a>>) -> Element<'a> { *cursor_position.write_silent() = Some(p) }, is_disabled: cx.props.is_disabled, - prevent_up_down_arrows: !cx.props.emoji_suggestions.is_empty(), + prevent_up_down_arrows: !cx.props.suggestions.is_empty(), onup_down_arrow: move |code| { - if cx.props.emoji_suggestions.is_empty() { - *selected_emoji.write_silent() = None; + let amount = match cx.props.suggestions { + SuggestionType::None => 0, + SuggestionType::Emoji(_, v) => v.len(), + SuggestionType::Tag(_, v) => v.len(), + }; + if amount == 0 { + *selected_suggestion.write_silent() = None; return; } - let current = &mut *selected_emoji.write_silent(); - let amount = cx.props.emoji_suggestions.len(); + let current = &mut *selected_suggestion.write_silent(); let selected_idx = if code == Code::ArrowDown { match current.as_ref() { Some(v) => (v + 1) % amount, @@ -221,41 +255,93 @@ pub fn Chatbar<'a>(cx: Scope<'a, Props<'a>>) -> Element<'a> { class: "controls", cx.props.controls.as_ref() }, - (!cx.props.emoji_suggestions.is_empty() && !*is_emoji_suggestion_modal_closed.read()).then(|| - rsx!(EmojiSuggesions { - suggestions: cx.props.emoji_suggestions, + (!cx.props.suggestions.is_empty() && !*is_suggestion_modal_closed.read()).then(|| + rsx!(SuggestionsMenu { + suggestions: cx.props.suggestions, on_close: move |_| { - is_emoji_suggestion_modal_closed.with_mut(|i| *i = true); + is_suggestion_modal_closed.with_mut(|i| *i = true); }, - on_emoji_click: move |(emoji, alias)| { - if let Some(e) = cx.props.on_emoji_click.as_ref() { + on_click: move |(emoji, pattern)| { + if let Some(e) = cx.props.on_suggestion_click.as_ref() { if let Some(p) = cursor_position.read().as_ref() { - e.call((emoji, alias, *p)) + e.call((emoji, pattern, *p)) } } }, - selected: selected_emoji.clone(), + selected: selected_suggestion.clone(), })), } )) } #[derive(Props)] -pub struct EmojiSuggestionProps<'a> { - suggestions: &'a Vec<(String, String)>, - on_emoji_click: EventHandler<'a, (String, String)>, +pub struct SuggestionProps<'a> { + suggestions: &'a SuggestionType, + on_click: EventHandler<'a, (String, String)>, on_close: EventHandler<'a, ()>, selected: UseRef>, } #[allow(non_snake_case)] -fn EmojiSuggesions<'a>(cx: Scope<'a, EmojiSuggestionProps<'a>>) -> Element<'a> { +fn SuggestionsMenu<'a>(cx: Scope<'a, SuggestionProps<'a>>) -> Element<'a> { if cx.props.selected.read().is_none() { *cx.props.selected.write_silent() = Some(0); } + let (label, suggestions): (_, Vec<_>) = match cx.props.suggestions { + SuggestionType::None => return cx.render(rsx!(())), + SuggestionType::Emoji(pattern, emojis) => { + let component = emojis.iter().enumerate().map(|(num, (emoji,alias))| { + rsx!(div { + class: format_args!("{} {}", "chatbar-suggestion", match cx.props.selected.read().as_ref() { + Some(v) => if *v == num {"chatbar-selected"} else {""}, + None => "", + }), + aria_label: { + format_args!( + "emoji-suggested-{emoji}", + ) + }, + onclick: move |_| { + cx.props.on_click.call((emoji.clone(), pattern.clone())) + }, + format_args!("{emoji} :{alias}:"), + }) + }).collect(); + (get_local_text("messages.emoji-suggestion"), component) + } + SuggestionType::Tag(pattern, identities) => { + let component = identities.iter().enumerate().map(|(num, id)| { + let username = format!("{}#{}", id.username(), id.short_id()); + rsx!(div { + class: format_args!("{} {}", "chatbar-suggestion", match cx.props.selected.read().as_ref() { + Some(v) => if *v == num {"chatbar-selected"} else {""}, + None => "" + }), + aria_label: { + format_args!( + "username-suggested-{username}", + ) + }, + onclick: move |_| { + cx.props.on_click.call((username.clone(), pattern.clone())) + }, + div { + class: "user-suggestion-profile", + UserImage { + platform: id.platform().into(), + status: id.identity_status().into(), + image: id.profile_picture() + } + } + format_args!("{username}"), + }) + }).collect(); + (get_local_text("messages.username-suggestion"), component) + } + }; cx.render(rsx!(div { - class: "emoji-suggestions", - aria_label: "emoji-suggestions-container", + class: "chatbar-suggestions", + aria_label: "chatbar-suggestions-container", onmouseenter: move |_| { *cx.props.selected.write() = None; }, @@ -264,33 +350,17 @@ fn EmojiSuggesions<'a>(cx: Scope<'a, EmojiSuggestionProps<'a>>) -> Element<'a> { }, Button { small: true, - aria_label: "emoji-suggestion-close-button".into(), + aria_label: "chatbar-suggestion-close-button".into(), appearance: Appearance::Secondary, icon: icons::outline::Shape::XMark, onpress: move |_| cx.props.on_close.call(()), }, div { - class: "emoji-suggestions-header", + class: "chatbar-suggestions-header", Label { - text: get_local_text("messages.suggested-emoji"), + text: label, }, } - cx.props.suggestions.iter().enumerate().map(|(num, (emoji,alias))| { - cx.render(rsx!(div { - class: format_args!("{} {}", "emoji-suggestion", match cx.props.selected.read().as_ref() { - Some(v) => if *v == num {"emoji-selected"} else {""}, - None => "", - }), - aria_label: { - format_args!( - "emoji-suggested-{emoji}", - ) - }, - onclick: move |_| { - cx.props.on_emoji_click.call((emoji.clone(), alias.clone())) - }, - format_args!("{emoji} :{alias}:"), - })) - }) + suggestions.into_iter() })) } diff --git a/kit/src/layout/chatbar/style.scss b/kit/src/layout/chatbar/style.scss index 304811ffb9c..42d897494ff 100644 --- a/kit/src/layout/chatbar/style.scss +++ b/kit/src/layout/chatbar/style.scss @@ -64,4 +64,127 @@ .inline-reply { padding-top: var(--height-input-less); } +} + + +.emoji-suggestions { + position: absolute; + bottom: 100%; + color: var(--text-color); + background: var(--secondary-dark); + padding: var(--gap); + border-radius: var(--border-radius); + border: 1px solid var(--border-subtle-color); + max-height: 200px; + width: calc(100% - var(--padding)); + overflow-y: auto; + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--secondary-dark); + } + + &::-webkit-scrollbar-thumb:hover { + background: var(--secondary-light); + } + + .emoji-suggestions-header { + height: var(--height-input); + display: inline-flex; + align-items: center; + width: 100%; + } + + .emoji-suggestion { + display: flex; + align-items: center; + border-radius: var(--border-radius); + padding: var(--gap); + width: 100%; + + &.emoji-selected { + background-color: var(--secondary-light); + } + &:hover { + cursor: pointer; + background-color: var(--secondary-light); + } + } + + .btn-wrap { + position: absolute; + top: var(--gap); + right: var(--gap); + z-index: 1; + } +} + +.chatbar-suggestions { + position: absolute; + bottom: 100%; + color: var(--text-color); + background: var(--secondary-dark); + padding: var(--gap); + border-radius: var(--border-radius); + border: 1px solid var(--border-subtle-color); + max-height: 200px; + width: calc(100% - var(--padding)); + overflow-y: auto; + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: var(--secondary-dark); + } + + &::-webkit-scrollbar-thumb:hover { + background: var(--secondary-light); + } + + .chatbar-suggestions-header { + height: var(--height-input); + display: inline-flex; + align-items: center; + width: 100%; + } + + .chatbar-suggestion { + display: flex; + align-items: center; + border-radius: var(--border-radius); + padding: var(--gap); + width: 100%; + + &.chatbar-selected { + background-color: var(--secondary-light); + } + &:hover { + cursor: pointer; + background-color: var(--secondary-light); + } + } + + .btn-wrap { + position: absolute; + top: var(--gap); + right: var(--gap); + z-index: 1; + } + + .user-suggestion-profile { + display: flex; + padding-right: var(--padding-less); + div { + max-width: calc(0.8 * var(--height-input)); + max-height: calc(0.8 * var(--height-input)); + width: calc(0.8 * var(--height-input)); + height: calc(0.8 * var(--height-input)); + } + .indicator { + left: calc(100% - 1.3rem); + } + } } \ No newline at end of file diff --git a/kit/src/style.scss b/kit/src/style.scss index 9b05f89d41c..673345467da 100644 --- a/kit/src/style.scss +++ b/kit/src/style.scss @@ -238,6 +238,7 @@ pre code { --text-color-dark: #343a40; --text-color-bright: #ffffff; --text-color-link: #ff860d; + --text-color-user-tag: #576ae5b1; --text-selection: #e0e0e0; --placeholder: #bcbcbc; --primary: #fcfbf4; @@ -252,6 +253,7 @@ pre code { --background-light: var(--secondary-light); --background-dark: #161a21; --background-modal: rgba(0,0,0, 0.9); + --background-mention: #eda46ca1; --success: #1dd1a1; --success-light: #00c29c; --info: #1B9CFC; diff --git a/ui/src/components/settings/sub_pages/developer.rs b/ui/src/components/settings/sub_pages/developer.rs index d2897479d08..063365b67c1 100644 --- a/ui/src/components/settings/sub_pages/developer.rs +++ b/ui/src/components/settings/sub_pages/developer.rs @@ -102,7 +102,7 @@ pub fn DeveloperSettings(cx: Scope) -> Element { ); state .write() - .mutate(Action::AddNotification(NotificationKind::Settings, 1)); + .mutate(Action::AddNotification(NotificationKind::Settings, 1, false)); } } }, diff --git a/ui/src/layouts/chats/data/msg_group.rs b/ui/src/layouts/chats/data/msg_group.rs index 37a6dbc9b08..4f019275563 100644 --- a/ui/src/layouts/chats/data/msg_group.rs +++ b/ui/src/layouts/chats/data/msg_group.rs @@ -3,7 +3,10 @@ use std::collections::VecDeque; -use common::{state::pending_message::PendingMessage, warp_runner::ui_adapter}; +use common::{ + state::{pending_message::PendingMessage, Identity}, + warp_runner::ui_adapter, +}; use warp::{constellation::Progression, crypto::DID}; // Define a struct to represent a group of messages from the same sender. @@ -42,12 +45,16 @@ impl MessageGroupMsg { } pub fn create_message_groups( - my_did: DID, + my_id: Identity, + other_ids: Vec, mut input: VecDeque, ) -> Vec { let mut messages: Vec = vec![]; + let mut other_ids = other_ids.clone(); + other_ids.push(my_id.clone()); - for msg in input.drain(..) { + for mut msg in input.drain(..) { + msg.insert_did(&other_ids, &my_id.did_key()); if let Some(group) = messages.iter_mut().last() { if group.sender == msg.inner.sender() { let g = MessageGroupMsg { @@ -68,7 +75,7 @@ pub fn create_message_groups( } // new group - let mut grp = MessageGroup::new(msg.inner.sender(), &my_did); + let mut grp = MessageGroup::new(msg.inner.sender(), &my_id.did_key()); let g = MessageGroupMsg { message: msg.clone(), is_pending: false, @@ -83,16 +90,25 @@ pub fn create_message_groups( messages } -pub fn pending_group_messages(pending: &Vec, own_did: DID) -> Option { +pub fn pending_group_messages( + pending: &Vec, + other_ids: Vec, + my_id: Identity, +) -> Option { if pending.is_empty() { return None; }; + let mut other_ids = other_ids.clone(); + other_ids.push(my_id.clone()); + let mut messages: Vec = vec![]; let size = pending.len(); for (i, msg) in pending.iter().enumerate() { + let mut message = msg.message.clone(); + message.insert_did(&other_ids, &my_id.did_key()); if i == size - 1 { let g = MessageGroupMsg { - message: msg.message.clone(), + message, is_pending: true, is_first: false, is_last: true, @@ -102,7 +118,7 @@ pub fn pending_group_messages(pending: &Vec, own_did: DID) -> Op continue; } let g = MessageGroupMsg { - message: msg.message.clone(), + message, is_pending: true, is_first: true, is_last: true, @@ -111,7 +127,7 @@ pub fn pending_group_messages(pending: &Vec, own_did: DID) -> Op messages.push(g); } Some(MessageGroup { - sender: own_did, + sender: my_id.did_key(), remote: false, messages, }) diff --git a/ui/src/layouts/chats/mod.rs b/ui/src/layouts/chats/mod.rs index 3245a75c05d..899a4aeda1e 100644 --- a/ui/src/layouts/chats/mod.rs +++ b/ui/src/layouts/chats/mod.rs @@ -4,7 +4,6 @@ mod scripts; pub use presentation::sidebar::Sidebar as ChatSidebar; use presentation::welcome::Welcome; -pub use data::ActiveChat; use std::{path::PathBuf, rc::Rc}; use crate::{ diff --git a/ui/src/layouts/chats/presentation/chat/mod.rs b/ui/src/layouts/chats/presentation/chat/mod.rs index 8e1bddaf3c9..b56c5d8afae 100644 --- a/ui/src/layouts/chats/presentation/chat/mod.rs +++ b/ui/src/layouts/chats/presentation/chat/mod.rs @@ -21,7 +21,7 @@ use crate::{ chatbar::get_chatbar, messages::get_messages, }, - scripts::SHOW_CONTEXT, + scripts::{SHOW_CONTEXT, USER_TAG_SCRIPT}, }, }; @@ -52,6 +52,33 @@ pub fn Compose(cx: Scope) -> Element { let quickprofile_data: &UseRef> = use_ref(cx, || None); let update_script = use_state(cx, String::new); let identity_profile = use_state(cx, DID::default); + + let eval_provider = use_eval(cx); + // Handle user tag click + // We handle it here since user tags are not dioxus components + use_effect(cx, chat_data, |_| { + to_owned![state, eval_provider, quickprofile_data]; + async move { + if let Ok(eval) = eval_provider(USER_TAG_SCRIPT) { + loop { + if let Ok(s) = eval.recv().await { + match serde_json::from_str::<(f64, f64, DID)>( + s.as_str().unwrap_or_default(), + ) { + Ok((x, y, did)) => { + if let Some(id) = state.read().get_identity(&did) { + quickprofile_data.set(Some((x, y, id, false))); + } + } + Err(e) => { + log::error!("failed to deserialize message: {}: {}", s, e); + } + } + } + } + } + } + }); use_effect(cx, quickprofile_data, |data| { to_owned![quick_profile_uuid, update_script, identity_profile]; async move { diff --git a/ui/src/layouts/chats/presentation/chatbar/mod.rs b/ui/src/layouts/chats/presentation/chatbar/mod.rs index 81e1272b718..9394b03dc7f 100644 --- a/ui/src/layouts/chats/presentation/chatbar/mod.rs +++ b/ui/src/layouts/chats/presentation/chatbar/mod.rs @@ -19,7 +19,7 @@ use kit::{ tooltip::{ArrowPosition, Tooltip}, Appearance, }, - layout::chatbar::{Chatbar, Reply}, + layout::chatbar::{Chatbar, Reply, SuggestionType}, }; use once_cell::sync::Lazy; use regex::Regex; @@ -29,6 +29,7 @@ use warp::{crypto::DID, logging::tracing::log, raygun::Location}; const MAX_CHARS_LIMIT: usize = 1024; pub static EMOJI_REGEX: Lazy = Lazy::new(|| Regex::new(":[^:]{2,}:?$").unwrap()); +pub static TAG_REGEX: Lazy = Lazy::new(|| Regex::new("@[^@ ]{2,} ?$").unwrap()); use super::context_menus::FileLocation as FileLocationContext; use crate::{ components::{files::attachments::Attachments, paste_files_with_shortcut}, @@ -59,7 +60,8 @@ pub fn get_chatbar<'a>(cx: &'a Scoped<'a, ChatProps>) -> Element<'a> { let upload_button_menu_uuid = &*cx.use_hook(|| Uuid::new_v4().to_string()); let show_storage_modal = use_state(cx, || false); - let emoji_suggestions = use_state(cx, Vec::new); + let suggestions = use_state(cx, || SuggestionType::None); + let mentions = use_ref(cx, Vec::new); let with_scroll_btn = scroll_btn.read().get(active_chat_id); @@ -175,6 +177,18 @@ pub fn get_chatbar<'a>(cx: &'a Scoped<'a, ChatProps>) -> Element<'a> { .is_empty() }; + let chat_participants: Vec<_> = state + .read() + .get_active_chat() + .map(|chat| { + chat.participants + .iter() + .filter_map(|did| state.read().get_identity(did)) + .collect() + }) + .unwrap_or_default(); + let chat_participants_2 = chat_participants.clone(); + let submit_fn = move || { local_typing_ch.send(TypingIndicator::NotTyping); let active_chat_id = chat_data.read().active_chat.id(); @@ -193,7 +207,14 @@ pub fn get_chatbar<'a>(cx: &'a Scoped<'a, ChatProps>) -> Element<'a> { .and_then(|d| d.draft.clone()) .unwrap_or_default() .lines() - .map(|x| x.trim_end().to_string()) + .map(|x| { + let mut s = x.to_string(); + mentions + .read() + .iter() + .for_each(|(did, name)| s = x.replace(name, &format!("{}", did))); + s + }) .collect::>(); if !active_chat_id.is_nil() { @@ -202,7 +223,8 @@ pub fn get_chatbar<'a>(cx: &'a Scoped<'a, ChatProps>) -> Element<'a> { .mutate(Action::SetChatDraft(active_chat_id, String::new())); } - emoji_suggestions.set(vec![]); + suggestions.set(SuggestionType::None); + mentions.set(vec![]); if !msg_valid(&msg) || active_chat_id.is_nil() { return; @@ -291,16 +313,15 @@ pub fn get_chatbar<'a>(cx: &'a Scoped<'a, ChatProps>) -> Element<'a> { value: state.read().get_active_chat().as_ref().and_then(|d| d.draft.clone()).unwrap_or_default(), onreturn: move |_| submit_fn(), extensions: cx.render(rsx!(for node in ext_renders { rsx!(node) })), - emoji_suggestions: emoji_suggestions, + suggestions: suggestions, oncursor_update: move |(mut v, p): (String, i64)| { if !active_chat_id.is_nil() { let sub: String = v.chars().take(p as usize).collect(); - let capture = EMOJI_REGEX.captures(&sub); - match capture { - Some(emoji) => { + let emoji_capture = EMOJI_REGEX.captures(&sub); + if let Some(emoji) = emoji_capture { let emoji = &emoji[0]; if emoji.contains(char::is_whitespace) { - emoji_suggestions.set(vec![]); + suggestions.set(SuggestionType::None); return; } if emoji.ends_with(':') { @@ -312,19 +333,43 @@ pub fn get_chatbar<'a>(cx: &'a Scoped<'a, ChatProps>) -> Element<'a> { v = v.replace(&sub, &sub.replace(&format!(":{alias}:"), emoji)); state.write().mutate(Action::SetChatDraft(active_chat_id, v)); } - emoji_suggestions.set(vec![]) + suggestions.set(SuggestionType::None); } else { //Suggest emojis let alias = emoji.replace(':', ""); - emoji_suggestions - .set(state.read().ui.emojis.get_matching_emoji(&alias, false)) + suggestions.set(SuggestionType::Emoji(emoji.to_string(), state.read().ui.emojis.get_matching_emoji(&alias, false))); + } + return; + } + let tag_capture = TAG_REGEX.captures(&sub); + match tag_capture { + Some(tag) => { + let tag = &tag[0]; + let tag = tag.replace('@', ""); + if tag.ends_with(' ') { + let name = tag.replace(' ', "").to_lowercase(); + let replacement = chat_participants.iter().find(|id|id.username().to_lowercase().eq(&name)); + if let Some(id) = replacement { + let username = format!("{}#{}", id.username(), id.short_id()); + v = v.replace(&sub, &sub.replace(&tag, &format!("{username} "))); + state.write().mutate(Action::SetChatDraft(active_chat_id, v)); + mentions.write_silent().push((id.did_key(), username)); + } + suggestions.set(SuggestionType::None); + return; } + let lower = tag.to_lowercase(); + let users: Vec<_> = chat_participants.iter().filter(|id|id.username().to_lowercase().starts_with(&lower)) + .cloned().collect(); + suggestions.set(SuggestionType::Tag(tag, users)); + } + None => { + suggestions.set(SuggestionType::None); } - None => emoji_suggestions.set(vec![]), } } }, - on_emoji_click: move |(emoji, _, p): (String, String, i64)| { + on_suggestion_click: move |(replacement, pattern, p): (String, String, i64)| { if !active_chat_id.is_nil() { let mut draft = state .read() @@ -333,14 +378,18 @@ pub fn get_chatbar<'a>(cx: &'a Scoped<'a, ChatProps>) -> Element<'a> { .and_then(|d| d.draft.clone()) .unwrap_or_default(); let sub: String = draft.chars().take(p as usize).collect(); - let capture = EMOJI_REGEX.captures(&sub); - if let Some(e) = capture { - draft = draft.replace(&sub, &sub.replace(&e[0].to_string(), &emoji)); - state - .write() - .mutate(Action::SetChatDraft(active_chat_id, draft)); + draft = draft.replace(&sub, &sub.replace(&pattern, &replacement)); + state + .write() + .mutate(Action::SetChatDraft(active_chat_id, draft)); + if let SuggestionType::Tag(_, _) = suggestions.get() { + let amount = replacement.chars().count() - 9; + let name: String = replacement.chars().take(amount).collect(); // remove short did + if let Some(participant) = chat_participants_2.iter().find(|id|id.username().eq(&name)) { + mentions.write_silent().push((participant.did_key(), replacement)); + } } - emoji_suggestions.set(vec![]) + suggestions.set(SuggestionType::None); } }, controls: cx.render( diff --git a/ui/src/layouts/chats/presentation/messages/mod.rs b/ui/src/layouts/chats/presentation/messages/mod.rs index bb63ab4cfaf..e31df3dbcc3 100644 --- a/ui/src/layouts/chats/presentation/messages/mod.rs +++ b/ui/src/layouts/chats/presentation/messages/mod.rs @@ -161,7 +161,7 @@ pub fn get_messages( rsx!( msg_container_end, loop_over_message_groups { - groups: data::create_message_groups(chat_data.read().active_chat.my_id().did_key(), chat_data.read().active_chat.messages()), + groups: data::create_message_groups(chat_data.read().active_chat.my_id(), chat_data.read().active_chat.other_participants(), chat_data.read().active_chat.messages()), active_chat_id: chat_data.read().active_chat.id(), on_context_menu_action: move |(e, mut id): (Event, Identity)| { let own = state.read().get_own_identity().did_key().eq(&id.did_key()); @@ -560,6 +560,14 @@ fn render_message<'a>(cx: Scope<'a, MessageProps<'a>>) -> Element<'a> { let pending_uploads = grouped_message.file_progress.as_ref(); let render_markdown = state.read().ui.should_transform_markdown_text(); let should_transform_ascii_emojis = state.read().ui.should_transform_ascii_emojis(); + let msg_lines = message.inner.lines().join("\n"); + + let is_mention = message.is_mention; + let rendered_lines = message + .lines_to_render + .as_ref() + .unwrap_or(&msg_lines) + .clone(); cx.render(rsx!( div { @@ -582,7 +590,9 @@ fn render_message<'a>(cx: Scope<'a, MessageProps<'a>>) -> Element<'a> { key: "{message_key}", editing: is_editing, remote: cx.props.is_remote, - with_text: message.inner.lines().join("\n"), + with_text: msg_lines, + tagged_text: rendered_lines, + is_mention: is_mention, reactions: reactions_list, order: if grouped_message.is_first { Order::First } else if grouped_message.is_last { Order::Last } else { Order::Middle }, attachments: message @@ -680,7 +690,8 @@ fn pending_wrapper<'a>(cx: Scope<'a, PendingWrapperProps>) -> Element<'a> { cx.render(rsx!(render_pending_messages { pending_outgoing_message: data::pending_group_messages( &cx.props.msg, - data.active_chat.my_id().did_key(), + data.active_chat.other_participants(), + data.active_chat.my_id(), ), active: data.active_chat.id(), on_context_menu_action: move |e| cx.props.on_context_menu_action.call(e) diff --git a/ui/src/layouts/chats/presentation/sidebar/mod.rs b/ui/src/layouts/chats/presentation/sidebar/mod.rs index 8c6ad2561e6..25026a49426 100644 --- a/ui/src/layouts/chats/presentation/sidebar/mod.rs +++ b/ui/src/layouts/chats/presentation/sidebar/mod.rs @@ -3,6 +3,7 @@ mod search; use common::language::{get_local_text, get_local_text_with_args}; use common::state::{self, identity_search_result, Action, Chat, Identity, State}; +use common::warp_runner::ui_adapter::format_mentions; use common::warp_runner::{RayGunCmd, WarpCmd}; use common::{icons::outline::Shape as Icon, WARP_CMD_CH}; use dioxus::html::input_data::keyboard_types::Code; @@ -287,7 +288,10 @@ pub fn Sidebar(cx: Scope) -> Element { }; let subtext_val = match unwrapped_message.lines().iter().map(|x| x.trim()).find(|x| !x.is_empty()) { - Some(v) => format_text(v, markdown, should_transform_ascii_emojis), + Some(v) => { + let (line, _) = format_mentions(v.to_string(), &participants, &state.read().get_own_identity().did_key(), true); + format_text(&line, markdown, should_transform_ascii_emojis) + } _ => match &unwrapped_message.attachments()[..] { [] => get_local_text("sidebar.chat-new"), [ file ] => file.name(), diff --git a/ui/src/layouts/chats/scripts/mod.rs b/ui/src/layouts/chats/scripts/mod.rs index d2acf6ba38c..e47dd97a2fb 100644 --- a/ui/src/layouts/chats/scripts/mod.rs +++ b/ui/src/layouts/chats/scripts/mod.rs @@ -7,4 +7,5 @@ pub const SHOW_CONTEXT: &str = include_str!("./show_context.js"); pub const SCROLL_TO_TOP: &str = include_str!("./scroll_to_top.js"); pub const SCROLL_TO_BOTTOM: &str = include_str!("./scroll_to_bottom.js"); pub const OBSERVER_SCRIPT: &str = include_str!("./observer_script.js"); +pub const USER_TAG_SCRIPT: &str = include_str!("./user_tag_click_handler.js"); pub const READ_SCROLL: &str = include_str!("./read_scroll.js"); diff --git a/ui/src/layouts/chats/scripts/show_context.js b/ui/src/layouts/chats/scripts/show_context.js index 67dab66b38a..e8be478fb92 100644 --- a/ui/src/layouts/chats/scripts/show_context.js +++ b/ui/src/layouts/chats/scripts/show_context.js @@ -1,8 +1,5 @@ let xPadding = 30 let yPadding = 10 -if ($SELF) { - xPadding *= -1; -} var menus = document.getElementsByClassName("context-menu") for (var i = 0; i < menus.length; i++) { @@ -20,10 +17,12 @@ let screenWidth = window.innerWidth || document.documentElement.clientWidth let screenHeight = window.innerHeight || document.documentElement.clientHeight let overFlowY = offsetY + yPadding > screenHeight +let overFlowX = $SELF || offsetX + width > screenWidth context_menu.style = "" context_menu.style.position = "absolute" context_menu.style.bottom = `${overFlowY ? yPadding : screenHeight - offsetY}px` -if ($SELF) { +if (overFlowX) { + offsetX -= 2 * xPadding context_menu.style.right = `${screenWidth - offsetX}px` } else { // The context menu should be relative to the parents dimensions diff --git a/ui/src/layouts/chats/scripts/user_tag_click_handler.js b/ui/src/layouts/chats/scripts/user_tag_click_handler.js new file mode 100644 index 00000000000..3128f2d0201 --- /dev/null +++ b/ui/src/layouts/chats/scripts/user_tag_click_handler.js @@ -0,0 +1,13 @@ +let tags = document.getElementsByClassName("message-user-tag") +for (var i = 0; i < tags.length; i++) { + let element = tags.item(i) + if (element.classList.contains("visual-only")) + continue + if (!element.hasUserTagEvent) { + element.hasUserTagEvent = true; + element.addEventListener("click", (e) => { + let did = element.getAttribute("value") + dioxus.send(`[${e.clientX}, ${e.clientY}, "${did}"]`) + }); + } +} \ No newline at end of file diff --git a/ui/src/layouts/chats/style.scss b/ui/src/layouts/chats/style.scss index 329db83aa2e..39a5da0cec1 100644 --- a/ui/src/layouts/chats/style.scss +++ b/ui/src/layouts/chats/style.scss @@ -726,57 +726,3 @@ text-decoration: none; } } - -.emoji-suggestions { - position: absolute; - bottom: 100%; - color: var(--text-color); - background: var(--secondary-dark); - padding: var(--gap); - border-radius: var(--border-radius); - border: 1px solid var(--border-subtle-color); - max-height: 200px; - width: calc(100% - var(--padding)); - overflow-y: auto; - &::-webkit-scrollbar-track { - background: transparent; - } - - &::-webkit-scrollbar-thumb { - background: var(--secondary-dark); - } - - &::-webkit-scrollbar-thumb:hover { - background: var(--secondary-light); - } - - .emoji-suggestions-header { - height: var(--height-input); - display: inline-flex; - align-items: center; - width: 100%; - } - - .emoji-suggestion { - display: flex; - align-items: center; - border-radius: var(--border-radius); - padding: var(--gap); - width: 100%; - - &.emoji-selected { - background-color: var(--secondary-light); - } - &:hover { - cursor: pointer; - background-color: var(--secondary-light); - } - } - - .btn-wrap { - position: absolute; - top: var(--gap); - right: var(--gap); - z-index: 1; -} -}