From f8827d301015a7e0839b2254b9f36d27932609ed Mon Sep 17 00:00:00 2001 From: Ian Clarke Date: Mon, 26 Jan 2026 17:17:07 -0600 Subject: [PATCH] feat(ui): add emoji picker to message composer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a 4x4 grid of popular emojis (๐Ÿ‘โค๏ธ๐Ÿ˜‚๐Ÿ‘€๐Ÿ”ฅ๐Ÿ˜ฎ๐Ÿ˜ข๐Ÿ˜ก๐ŸŽ‰๐Ÿค”๐Ÿ‘‹โœ…๐Ÿ’ฏ๐Ÿ™๐Ÿ˜๐Ÿ‘) accessible via a subtle grayscale smiley button to the left of the message input. Clicking an emoji inserts it at the end of the message. The emoji picker: - Uses a compact popup that appears above the button - Closes automatically after selection or when clicking outside - Shares emoji constants with the reaction picker for consistency Closes #77 Co-Authored-By: Claude Opus 4.5 --- ui/src/components/conversation.rs | 1 + .../components/conversation/emoji_picker.rs | 151 ++++++++++++++++++ .../conversation/message_actions.rs | 7 +- .../components/conversation/message_input.rs | 45 +++++- 4 files changed, 196 insertions(+), 8 deletions(-) create mode 100644 ui/src/components/conversation/emoji_picker.rs diff --git a/ui/src/components/conversation.rs b/ui/src/components/conversation.rs index 0622a84e..ba9e79b8 100644 --- a/ui/src/components/conversation.rs +++ b/ui/src/components/conversation.rs @@ -3,6 +3,7 @@ use crate::components::app::{CURRENT_ROOM, EDIT_ROOM_MODAL, MEMBER_INFO_MODAL, N use crate::room_data::SendMessageError; use crate::util::ecies::encrypt_with_symmetric_key; use crate::util::{format_utc_as_full_datetime, format_utc_as_local_time, get_current_system_time}; +mod emoji_picker; mod message_actions; mod message_input; mod not_member_notification; diff --git a/ui/src/components/conversation/emoji_picker.rs b/ui/src/components/conversation/emoji_picker.rs new file mode 100644 index 00000000..047c4485 --- /dev/null +++ b/ui/src/components/conversation/emoji_picker.rs @@ -0,0 +1,151 @@ +//! Emoji picker component for inserting emojis into messages. + +use dioxus::prelude::*; + +/// Emoji categories with their emojis +pub const EMOJI_CATEGORIES: &[(&str, &str, &[&str])] = &[ + ( + "smileys", + "๐Ÿ˜€", + &[ + "๐Ÿ˜€", "๐Ÿ˜ƒ", "๐Ÿ˜„", "๐Ÿ˜", "๐Ÿ˜†", "๐Ÿ˜…", "๐Ÿคฃ", "๐Ÿ˜‚", "๐Ÿ™‚", "๐Ÿ˜Š", "๐Ÿ˜‡", "๐Ÿฅฐ", "๐Ÿ˜", "๐Ÿคฉ", + "๐Ÿ˜˜", "๐Ÿ˜—", "๐Ÿ˜š", "๐Ÿ˜™", "๐Ÿฅฒ", "๐Ÿ˜‹", "๐Ÿ˜›", "๐Ÿ˜œ", "๐Ÿคช", "๐Ÿ˜", "๐Ÿค‘", "๐Ÿค—", "๐Ÿคญ", "๐Ÿคซ", + "๐Ÿค”", "๐Ÿค", "๐Ÿคจ", "๐Ÿ˜", "๐Ÿ˜‘", "๐Ÿ˜ถ", "๐Ÿ˜", "๐Ÿ˜’", "๐Ÿ™„", "๐Ÿ˜ฌ", "๐Ÿ˜ฎโ€๐Ÿ’จ", "๐Ÿคฅ", "๐Ÿ˜Œ", "๐Ÿ˜”", + "๐Ÿ˜ช", "๐Ÿคค", "๐Ÿ˜ด", "๐Ÿ˜ท", "๐Ÿค’", "๐Ÿค•", "๐Ÿคข", "๐Ÿคฎ", "๐Ÿคง", "๐Ÿฅต", "๐Ÿฅถ", "๐Ÿฅด", "๐Ÿ˜ต", "๐Ÿคฏ", + "๐Ÿค ", "๐Ÿฅณ", "๐Ÿฅธ", "๐Ÿ˜Ž", "๐Ÿค“", "๐Ÿง", "๐Ÿ˜•", "๐Ÿ˜Ÿ", "๐Ÿ™", "๐Ÿ˜ฎ", "๐Ÿ˜ฏ", "๐Ÿ˜ฒ", "๐Ÿ˜ณ", "๐Ÿฅบ", + "๐Ÿ˜ฆ", "๐Ÿ˜ง", "๐Ÿ˜จ", "๐Ÿ˜ฐ", "๐Ÿ˜ฅ", "๐Ÿ˜ข", "๐Ÿ˜ญ", "๐Ÿ˜ฑ", "๐Ÿ˜–", "๐Ÿ˜ฃ", "๐Ÿ˜ž", "๐Ÿ˜“", "๐Ÿ˜ฉ", "๐Ÿ˜ซ", + "๐Ÿฅฑ", "๐Ÿ˜ค", "๐Ÿ˜ก", "๐Ÿ˜ ", "๐Ÿคฌ", "๐Ÿ˜ˆ", "๐Ÿ‘ฟ", "๐Ÿ’€", "โ˜ ๏ธ", "๐Ÿ’ฉ", "๐Ÿคก", "๐Ÿ‘น", "๐Ÿ‘บ", "๐Ÿ‘ป", + "๐Ÿ‘ฝ", "๐Ÿ‘พ", "๐Ÿค–", + ], + ), + ( + "gestures", + "๐Ÿ‘‹", + &[ + "๐Ÿ‘‹", "๐Ÿคš", "๐Ÿ–๏ธ", "โœ‹", "๐Ÿ––", "๐Ÿ‘Œ", "๐ŸคŒ", "๐Ÿค", "โœŒ๏ธ", "๐Ÿคž", "๐ŸคŸ", "๐Ÿค˜", "๐Ÿค™", "๐Ÿ‘ˆ", + "๐Ÿ‘‰", "๐Ÿ‘†", "๐Ÿ–•", "๐Ÿ‘‡", "โ˜๏ธ", "๐Ÿ‘", "๐Ÿ‘Ž", "โœŠ", "๐Ÿ‘Š", "๐Ÿค›", "๐Ÿคœ", "๐Ÿ‘", "๐Ÿ™Œ", "๐Ÿ‘", + "๐Ÿคฒ", "๐Ÿค", "๐Ÿ™", "โœ๏ธ", "๐Ÿ’…", "๐Ÿคณ", "๐Ÿ’ช", "๐Ÿฆพ", "๐Ÿฆฟ", "๐Ÿฆต", "๐Ÿฆถ", "๐Ÿ‘‚", "๐Ÿฆป", "๐Ÿ‘ƒ", + "๐Ÿง ", "๐Ÿซ€", "๐Ÿซ", "๐Ÿฆท", "๐Ÿฆด", "๐Ÿ‘€", "๐Ÿ‘๏ธ", "๐Ÿ‘…", "๐Ÿ‘„", "๐Ÿ’‹", + ], + ), + ( + "hearts", + "โค๏ธ", + &[ + "โค๏ธ", "๐Ÿงก", "๐Ÿ’›", "๐Ÿ’š", "๐Ÿ’™", "๐Ÿ’œ", "๐Ÿ–ค", "๐Ÿค", "๐ŸคŽ", "๐Ÿ’”", "โฃ๏ธ", "๐Ÿ’•", "๐Ÿ’ž", "๐Ÿ’“", + "๐Ÿ’—", "๐Ÿ’–", "๐Ÿ’˜", "๐Ÿ’", "๐Ÿ’Ÿ", "โ™ฅ๏ธ", "๐Ÿ’Œ", "๐Ÿ’", "๐ŸŒน", "๐Ÿฅ€", "๐ŸŒท", + ], + ), + ( + "celebration", + "๐ŸŽ‰", + &[ + "๐ŸŽ‰", "๐ŸŽŠ", "๐ŸŽˆ", "๐ŸŽ", "๐ŸŽ€", "๐Ÿช…", "๐Ÿช†", "๐ŸŽ‚", "๐Ÿฐ", "๐Ÿง", "๐Ÿฅณ", "๐Ÿฅ‚", "๐Ÿพ", "โœจ", + "๐ŸŽ†", "๐ŸŽ‡", "๐ŸŽ„", "๐ŸŽƒ", "๐ŸŽ—๏ธ", "๐Ÿ†", "๐Ÿฅ‡", "๐Ÿฅˆ", "๐Ÿฅ‰", "๐Ÿ…", "๐ŸŽ–๏ธ", "๐ŸŽญ", "๐ŸŽช", + ], + ), + ( + "animals", + "๐Ÿถ", + &[ + "๐Ÿถ", "๐Ÿฑ", "๐Ÿญ", "๐Ÿน", "๐Ÿฐ", "๐ŸฆŠ", "๐Ÿป", "๐Ÿผ", "๐Ÿปโ€โ„๏ธ", "๐Ÿจ", "๐Ÿฏ", "๐Ÿฆ", "๐Ÿฎ", + "๐Ÿท", "๐Ÿธ", "๐Ÿต", "๐Ÿ™ˆ", "๐Ÿ™‰", "๐Ÿ™Š", "๐Ÿ”", "๐Ÿง", "๐Ÿฆ", "๐Ÿค", "๐Ÿฆ†", "๐Ÿฆ…", "๐Ÿฆ‰", "๐Ÿฆ‡", + "๐Ÿบ", "๐Ÿ—", "๐Ÿด", "๐Ÿฆ„", "๐Ÿ", "๐Ÿชฑ", "๐Ÿ›", "๐Ÿฆ‹", "๐ŸŒ", "๐Ÿž", "๐Ÿœ", "๐Ÿชฐ", "๐Ÿชฒ", "๐Ÿชณ", + "๐ŸฆŸ", "๐Ÿฆ—", "๐Ÿ•ท๏ธ", "๐Ÿฆ‚", "๐Ÿข", "๐Ÿ", "๐ŸฆŽ", "๐Ÿฆ–", "๐Ÿฆ•", "๐Ÿ™", "๐Ÿฆ‘", "๐Ÿฆ", "๐Ÿฆž", "๐Ÿฆ€", + "๐Ÿก", "๐Ÿ ", "๐ŸŸ", "๐Ÿฌ", "๐Ÿณ", "๐Ÿ‹", "๐Ÿฆˆ", "๐ŸŠ", + ], + ), + ( + "food", + "๐Ÿ•", + &[ + "๐Ÿ•", "๐Ÿ”", "๐ŸŸ", "๐ŸŒญ", "๐Ÿฟ", "๐Ÿง‚", "๐Ÿฅ“", "๐Ÿฅš", "๐Ÿณ", "๐Ÿง‡", "๐Ÿฅž", "๐Ÿงˆ", "๐Ÿž", "๐Ÿฅ", + "๐Ÿฅ–", "๐Ÿฅจ", "๐Ÿง€", "๐Ÿฅ—", "๐Ÿฅ™", "๐Ÿฅช", "๐ŸŒฎ", "๐ŸŒฏ", "๐Ÿซ”", "๐Ÿฅซ", "๐Ÿ", "๐Ÿœ", "๐Ÿฒ", "๐Ÿ›", + "๐Ÿฃ", "๐Ÿฑ", "๐ŸฅŸ", "๐Ÿฆช", "๐Ÿค", "๐Ÿ™", "๐Ÿš", "๐Ÿ˜", "๐Ÿฅ", "๐Ÿฅ ", "๐Ÿฅฎ", "๐Ÿข", "๐Ÿก", "๐Ÿง", + "๐Ÿจ", "๐Ÿฆ", "๐Ÿฅง", "๐Ÿง", "๐Ÿฐ", "๐ŸŽ‚", "๐Ÿฎ", "๐Ÿญ", "๐Ÿฌ", "๐Ÿซ", "๐Ÿฉ", "๐Ÿช", "๐ŸŒฐ", "๐Ÿฅœ", + "โ˜•", "๐Ÿต", "๐Ÿงƒ", "๐Ÿฅค", "๐Ÿง‹", "๐Ÿถ", "๐Ÿบ", "๐Ÿป", "๐Ÿฅ‚", "๐Ÿท", "๐Ÿฅƒ", "๐Ÿธ", "๐Ÿน", "๐Ÿง‰", + "๐Ÿพ", + ], + ), + ( + "objects", + "๐Ÿ’ก", + &[ + "๐Ÿ’ก", "๐Ÿ”ฆ", "๐Ÿฎ", "๐Ÿ“ฑ", "๐Ÿ’ป", "๐Ÿ–ฅ๏ธ", "๐Ÿ–จ๏ธ", "โŒจ๏ธ", "๐Ÿ–ฑ๏ธ", "๐Ÿ–ฒ๏ธ", "๐Ÿ’ฝ", "๐Ÿ’พ", "๐Ÿ’ฟ", + "๐Ÿ“€", "๐ŸŽฅ", "๐Ÿ“ท", "๐Ÿ“น", "๐Ÿ“ผ", "๐Ÿ”", "๐Ÿ”Ž", "๐Ÿ”ฌ", "๐Ÿ”ญ", "๐Ÿ“ก", "๐Ÿ’‰", "๐Ÿฉธ", "๐Ÿ’Š", "๐Ÿฉน", + "๐Ÿฉบ", "๐Ÿšช", "๐Ÿ›—", "๐Ÿชž", "๐ŸชŸ", "๐Ÿ›๏ธ", "๐Ÿ›‹๏ธ", "๐Ÿช‘", "๐Ÿšฝ", "๐Ÿช ", "๐Ÿšฟ", "๐Ÿ›", "๐Ÿชค", "๐Ÿช’", + "๐Ÿงด", "๐Ÿงท", "๐Ÿงน", "๐Ÿงบ", "๐Ÿงป", "๐Ÿชฃ", "๐Ÿงผ", "๐Ÿชฅ", "๐Ÿงฝ", "๐Ÿงฏ", "๐Ÿ›’", "๐Ÿšฌ", "โšฐ๏ธ", "๐Ÿชฆ", + "โšฑ๏ธ", "๐Ÿ—ฟ", "๐Ÿชง", "๐Ÿง", + ], + ), + ( + "symbols", + "โœ…", + &[ + "โœ…", "โŒ", "โ“", "โ—", "โ€ผ๏ธ", "โ‰๏ธ", "๐Ÿ’ฏ", "๐Ÿ”ด", "๐ŸŸ ", "๐ŸŸก", "๐ŸŸข", "๐Ÿ”ต", "๐ŸŸฃ", "โšซ", + "โšช", "๐ŸŸค", "๐Ÿ”ถ", "๐Ÿ”ท", "๐Ÿ”ธ", "๐Ÿ”น", "๐Ÿ”บ", "๐Ÿ”ป", "๐Ÿ’ ", "๐Ÿ”˜", "๐Ÿ”ณ", "๐Ÿ”ฒ", "๐Ÿ", "๐Ÿšฉ", + "๐ŸŽŒ", "๐Ÿด", "๐Ÿณ๏ธ", "โฌ›", "โฌœ", "โ—ผ๏ธ", "โ—ป๏ธ", "โ—พ", "โ—ฝ", "โ–ช๏ธ", "โ–ซ๏ธ", "๐Ÿ”ˆ", "๐Ÿ”‡", "๐Ÿ”‰", + "๐Ÿ”Š", "๐Ÿ””", "๐Ÿ”•", "๐Ÿ“ฃ", "๐Ÿ“ข", "๐Ÿ’ฌ", "๐Ÿ’ญ", "๐Ÿ—ฏ๏ธ", "โ™ ๏ธ", "โ™ฃ๏ธ", "โ™ฅ๏ธ", "โ™ฆ๏ธ", "๐Ÿƒ", + "๐ŸŽด", "๐Ÿ€„", + ], + ), +]; + +/// Common/frequently used emojis (shown first) - 4x4 grid +pub const FREQUENT_EMOJIS: &[&str] = &[ + "๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐Ÿ‘€", + "๐Ÿ”ฅ", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐Ÿ˜ก", + "๐ŸŽ‰", "๐Ÿค”", "๐Ÿ‘‹", "โœ…", + "๐Ÿ’ฏ", "๐Ÿ™", "๐Ÿ˜", "๐Ÿ‘", +]; + +#[derive(Props, Clone, PartialEq)] +pub struct EmojiPickerProps { + /// Called when an emoji is selected + pub on_select: EventHandler, + /// Called when the picker should close + pub on_close: EventHandler<()>, +} + +/// Simple emoji picker with a 4x4 grid of popular emojis +#[component] +pub fn EmojiPicker(props: EmojiPickerProps) -> Element { + rsx! { + div { + class: "bg-panel rounded-lg shadow-lg border border-border p-1.5", + // Prevent clicks inside from bubbling + onclick: move |e| e.stop_propagation(), + + // 4x4 emoji grid with tight spacing + div { + class: "grid", + style: "grid-template-columns: repeat(4, 1fr); gap: 2px;", + for emoji in FREQUENT_EMOJIS.iter() { + {render_emoji_button(emoji, &props.on_select, &props.on_close)} + } + } + } + } +} + +fn render_emoji_button( + emoji: &str, + on_select: &EventHandler, + on_close: &EventHandler<()>, +) -> Element { + let emoji_str = emoji.to_string(); + let on_select = on_select.clone(); + let on_close = on_close.clone(); + + rsx! { + button { + key: "{emoji}", + class: "p-1 rounded hover:bg-surface transition-colors text-xl leading-none", + onclick: move |_| { + on_select.call(emoji_str.clone()); + on_close.call(()); + }, + "{emoji}" + } + } +} diff --git a/ui/src/components/conversation/message_actions.rs b/ui/src/components/conversation/message_actions.rs index 4116dcb4..34e061d9 100644 --- a/ui/src/components/conversation/message_actions.rs +++ b/ui/src/components/conversation/message_actions.rs @@ -2,8 +2,7 @@ use dioxus::prelude::*; use river_core::room_state::member::MemberId; use river_core::room_state::message::MessageId; -/// Common emoji reactions to show in the picker -pub const REACTION_EMOJIS: &[&str] = &["๐Ÿ‘", "โค๏ธ", "๐Ÿ˜‚", "๐Ÿ˜ฎ", "๐Ÿ˜ข", "๐Ÿ˜ก", "๐ŸŽ‰", "๐Ÿค”", "๐Ÿ‘‹"]; +use super::emoji_picker::FREQUENT_EMOJIS; /// Props for the message actions component #[derive(Props, Clone, PartialEq)] @@ -48,7 +47,7 @@ pub fn MessageActions(props: MessageActionsProps) -> Element { div { class: "absolute top-full left-0 mt-1 bg-panel rounded-lg shadow-lg border border-border p-2 z-50", div { class: "flex flex-wrap gap-1 max-w-[200px]", - {REACTION_EMOJIS.iter().map(|emoji| { + {FREQUENT_EMOJIS.iter().map(|emoji| { let emoji_str = emoji.to_string(); let msg_id = message_id.clone(); rsx! { @@ -116,7 +115,7 @@ pub fn QuickReactionButton(props: QuickReactionProps) -> Element { if show_picker() { div { class: "absolute top-full right-0 mt-1 bg-panel rounded-lg shadow-lg border border-border p-1 flex gap-0.5 z-50", - {REACTION_EMOJIS.iter().take(6).map(|emoji| { + {FREQUENT_EMOJIS.iter().take(6).map(|emoji| { let emoji_str = emoji.to_string(); let message_id = props.message_id.clone(); rsx! { diff --git a/ui/src/components/conversation/message_input.rs b/ui/src/components/conversation/message_input.rs index b610997d..001f6c0d 100644 --- a/ui/src/components/conversation/message_input.rs +++ b/ui/src/components/conversation/message_input.rs @@ -1,6 +1,7 @@ -use dioxus::logger::tracing::*; use dioxus::prelude::*; +use super::emoji_picker::EmojiPicker; + /// Message input component that owns its own state. /// This isolates keystroke handling from the parent component, /// preventing expensive re-renders of the message list on each keystroke. @@ -8,6 +9,7 @@ use dioxus::prelude::*; pub fn MessageInput(handle_send_message: EventHandler) -> Element { // Own the message state locally - keystrokes only re-render this component let mut message_text = use_signal(|| String::new()); + let mut show_emoji_picker = use_signal(|| false); let mut send_message = move || { let text = message_text.peek().to_string(); @@ -17,10 +19,46 @@ pub fn MessageInput(handle_send_message: EventHandler) -> Element { } }; + // Handle emoji selection - insert at end of message + let handle_emoji_select = move |emoji: String| { + let current = message_text.peek().to_string(); + message_text.set(format!("{}{}", current, emoji)); + }; + rsx! { - div { class: "flex-shrink-0 border-t border-border bg-panel", + // Backdrop for emoji picker - outside the message bar to avoid z-index issues + if show_emoji_picker() { + div { + class: "fixed inset-0 z-40", + onclick: move |_| show_emoji_picker.set(false), + } + } + div { class: "flex-shrink-0 border-t border-border bg-panel relative z-50", div { class: "max-w-4xl mx-auto px-4 py-3", - div { class: "flex gap-3 items-end", + div { class: "flex gap-3 items-center", + // Emoji picker button and popup + div { class: "relative self-center", + button { + class: "p-2.5 rounded-xl hover:bg-surface transition-colors", + title: "Insert emoji", + onclick: move |_| show_emoji_picker.set(!show_emoji_picker()), + span { + class: "text-lg", + style: "filter: grayscale(100%); opacity: 0.6;", + "๐Ÿ™‚" + } + } + // Emoji picker popup (appears above the button) + if show_emoji_picker() { + div { + class: "absolute bottom-full left-0 mb-2", + EmojiPicker { + on_select: handle_emoji_select, + on_close: move |_| show_emoji_picker.set(false), + } + } + } + } textarea { class: "flex-1 px-4 py-2.5 bg-surface border border-border rounded-xl text-text placeholder-text-muted focus:outline-none focus:ring-2 focus:ring-accent/50 focus:border-accent transition-colors resize-none min-h-[44px] max-h-[200px]", placeholder: "Type your message...", @@ -39,7 +77,6 @@ pub fn MessageInput(handle_send_message: EventHandler) -> Element { button { class: "px-5 py-2.5 bg-accent hover:bg-accent-hover text-white font-medium rounded-xl transition-colors disabled:opacity-50 disabled:cursor-not-allowed", onclick: move |_| { - info!("Send button clicked"); send_message(); }, "Send"