From c3d677ca81198fe8807b5106b5290bfe0975bf41 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 31 Jul 2026 08:45:56 +0700 Subject: [PATCH 1/4] add send reaction --- crates/chat/src/room.rs | 17 ++++++-- crates/chat_ui/src/lib.rs | 87 +++++++++++++++++++++++++++++---------- 2 files changed, 80 insertions(+), 24 deletions(-) diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index d207c05..8f09b4b 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -1,11 +1,11 @@ use std::cmp::Ordering; use std::hash::{Hash, Hasher}; -use instant::Duration; use anyhow::{Error, anyhow}; use common::EventExt; use device::DeviceRegistry; use gpui::{App, AppContext, Context, EventEmitter, SharedString, Task}; +use instant::Duration; use itertools::Itertools; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; @@ -419,12 +419,23 @@ impl Room { } // Construct a rumor event for direct message - pub fn rumor(&self, content: S, replies: I, cx: &App) -> Option + pub fn rumor( + &self, + content: S, + replies: I, + reaction: bool, + cx: &App, + ) -> Option where S: Into, I: IntoIterator, { - let kind = Kind::PrivateDirectMessage; + let kind = if reaction { + Kind::Reaction + } else { + Kind::PrivateDirectMessage + }; + let content: String = content.into(); let replies: Vec = replies.into_iter().collect(); diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 27d5380..9ab7ed2 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -35,6 +35,9 @@ use ui::{ use crate::text::RenderedText; +const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; +const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"]; + mod actions; mod text; @@ -331,24 +334,49 @@ impl ChatPanel { // Get the message which includes all attachments let content = self.get_input_value(cx); + // Get the replies to this message + let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); + // Return if message is empty if content.trim().is_empty() { window.push_notification("Cannot send an empty message", cx); return; } - self.send_message(&content, window, cx); + self.send_message(&content, replies, false, window, cx); + } + + fn send_reaction( + &mut self, + emoji: &str, + target: &EventId, + window: &mut Window, + cx: &mut Context, + ) { + // Return if emoji is empty + if emoji.trim().is_empty() { + window.push_notification("Cannot send an empty reaction", cx); + return; + } + + self.send_message(emoji, vec![*target], true, window, cx); } /// Send a message to all members of the chat - fn send_message(&mut self, value: &str, window: &mut Window, cx: &mut Context) { + fn send_message( + &mut self, + value: &str, + replies: Vec, + reaction: bool, + window: &mut Window, + cx: &mut Context, + ) { if value.trim().is_empty() { window.push_notification("Cannot send an empty message", cx); return; } let room = self.room.clone(); - let replies: Vec = self.replies_to.read(cx).iter().copied().collect(); let content = value.to_string(); let sent_ids = self.sent_ids.clone(); @@ -357,7 +385,7 @@ impl ChatPanel { return; }; let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| { - let rumor = room.rumor(content.clone(), replies, cx)?; + let rumor = room.rumor(content.clone(), replies, reaction, cx)?; let send_task = room.send(rumor.clone(), cx)?; Some((rumor, send_task)) }) { @@ -575,7 +603,10 @@ impl ChatPanel { fn on_command(&mut self, command: &Command, window: &mut Window, cx: &mut Context) { match command { Command::Insert(content) => { - self.send_message(content, window, cx); + self.input.update(cx, |this, cx| { + let new_value = format!("{} {}", this.value(), content); + this.set_value(new_value, window, cx); + }); } Command::ChangeSubject(subject) => { if self @@ -990,13 +1021,9 @@ impl ChatPanel { .w_full() .px_2() .border_l_2() - .border_color(cx.theme().element_selected) + .border_color(cx.theme().element_active) .text_sm() - .child( - div() - .text_color(cx.theme().text_accent) - .child(author.name()), - ) + .child(div().child(author.name())) .child( div() .w_full() @@ -1192,6 +1219,29 @@ impl ChatPanel { .border_1() .border_color(cx.theme().border) .bg(cx.theme().background) + .children({ + let mut items = vec![]; + + for emoji in COMPACT_REACTION_EMOJIS { + items.push( + Button::new(*emoji) + .label(*emoji) + .tooltip(*emoji) + .small() + .ghost() + .on_click({ + let emoji = *emoji; + let id = *id; + cx.listener(move |this, _event, window, cx| { + this.send_reaction(emoji, &id, window, cx); + }) + }), + ); + } + + items + }) + .child(div().flex_shrink_0().h_4().w_px().bg(cx.theme().border)) .child( Button::new("reply") .icon(IconName::Reply) @@ -1305,7 +1355,7 @@ impl ChatPanel { .gap_1() .text_xs() .text_color(cx.theme().text_muted) - .child(SharedString::from("Replying to:")) + .child("Replying to:") .child( div() .text_color(cx.theme().text_accent) @@ -1402,15 +1452,10 @@ impl ChatPanel { .ghost() .large() .dropdown_menu_with_anchor(gpui::Anchor::BottomLeft, move |this, _window, _cx| { - this.horizontal() - .menu("👍", Box::new(Command::Insert("👍"))) - .menu("👎", Box::new(Command::Insert("👎"))) - .menu("😄", Box::new(Command::Insert("😄"))) - .menu("🎉", Box::new(Command::Insert("🎉"))) - .menu("😕", Box::new(Command::Insert("😕"))) - .menu("❤️", Box::new(Command::Insert("❤️"))) - .menu("🚀", Box::new(Command::Insert("🚀"))) - .menu("👀", Box::new(Command::Insert("👀"))) + let menu = this.horizontal(); + REACTION_EMOJIS.iter().fold(menu, |this, emoji| { + this.menu(*emoji, Box::new(Command::Insert(emoji))) + }) }) } } -- 2.49.1 From e01d2fbef3ceb93c4dc5de6803a7d94a8c8305f0 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 31 Jul 2026 09:27:19 +0700 Subject: [PATCH 2/4] render reaction --- crates/chat_ui/src/lib.rs | 106 +++++++++++++++++++++++++++++++++----- 1 file changed, 94 insertions(+), 12 deletions(-) diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 9ab7ed2..4a52698 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -59,6 +59,9 @@ pub struct ChatPanel { /// All messages (sorted by created_at) messages: Vec, + /// All reactions + reactions: BTreeMap>, + /// Mapping message ids to their rendered texts rendered_texts_by_id: BTreeMap, @@ -163,6 +166,7 @@ impl ChatPanel { focus_handle: cx.focus_handle(), id, messages, + reactions: BTreeMap::new(), room, list_state, input, @@ -262,7 +266,11 @@ impl ChatPanel { move |this, _room, event, window, cx| { match event { RoomEvent::Incoming(message) => { - this.insert_message(message, false, cx); + if message.rumor.kind == Kind::Reaction { + this.insert_reaction(&message.rumor, cx); + } else { + this.insert_message(message, false, cx); + } } RoomEvent::Reload => { // Defer to avoid re-entrant read on Room while @@ -384,8 +392,10 @@ impl ChatPanel { let Some(room_entity) = room.upgrade() else { return; }; + + // Create rumor and send task let (rumor, send_task) = match room_entity.read_with(cx, |room, cx| { - let rumor = room.rumor(content.clone(), replies, reaction, cx)?; + let rumor = room.rumor(content.clone(), replies.clone(), reaction, cx)?; let send_task = room.send(rumor.clone(), cx)?; Some((rumor, send_task)) }) { @@ -399,9 +409,15 @@ impl ChatPanel { let id = rumor.id.expect("rumor must have an id"); // Insert optimistic message and clear input - self.insert_message(&rumor, true, cx); + if rumor.kind != Kind::Reaction { + self.insert_message(&rumor, true, cx); + self.clear(window, cx); + } else { + self.insert_reaction(&rumor, cx); + } + + // Update reports self.insert_reports(id, vec![], cx); - self.clear(window, cx); // Spawn a single task to await the send and update reports self.tasks.push(cx.spawn_in(window, async move |this, cx| { @@ -472,11 +488,31 @@ impl ChatPanel { /// Convert and insert a vector of nostr events into the chat panel fn insert_messages(&mut self, events: &[UnsignedEvent], cx: &mut Context) { for event in events.iter() { + if event.kind == Kind::Reaction { + self.insert_reaction(event, cx); + continue; + } // Bulk inserting messages, so no need to scroll to the latest message self.insert_message(event, false, cx); } } + /// Insert a reaction into the chat panel + fn insert_reaction(&mut self, event: &UnsignedEvent, cx: &mut Context) { + if event.kind != Kind::Reaction { + return; + } + + for id in event.tags.event_ids() { + self.reactions + .entry(id) + .or_default() + .push((SharedString::from(&event.content), event.pubkey)); + } + + cx.notify(); + } + /// Check if a message has any reports fn has_reports(&self, id: &EventId, _cx: &App) -> bool { self.reports_by_id.read().unwrap().get(id).is_some() @@ -491,6 +527,16 @@ impl ChatPanel { self.messages.iter().find(|msg| &msg.id == id) } + /// Get a reaction by its target ID + fn reaction(&self, id: &EventId) -> Vec<(SharedString, PublicKey)> { + self.reactions.get(id).cloned().unwrap_or_default() + } + + /// Check if a message has any reactions + fn has_reaction(&self, id: &EventId) -> bool { + self.reactions.contains_key(id) + } + /// Scroll to a message by its ID fn scroll_to(&self, id: &EventId) { if let Some(ix) = self.messages.iter().position(|msg| &msg.id == id) { @@ -865,6 +911,7 @@ impl ChatPanel { let replies = message.replies_to.as_slice(); let has_replies = !replies.is_empty(); + let has_reactions = self.has_reaction(&id); let has_reports = self.has_reports(&id, cx); // Hide avatar setting @@ -910,12 +957,7 @@ impl ChatPanel { .gap_2() .text_sm() .text_color(cx.theme().text_placeholder) - .child( - div() - .font_semibold() - .text_color(cx.theme().text) - .child(author.name()), - ) + .child(div().font_semibold().child(author.name())) .child(message.created_at.to_human_time()) .when(has_reports, |this| { this.child(self.render_sent_reports(&id, cx)) @@ -926,7 +968,10 @@ impl ChatPanel { this.children(self.render_message_replies(replies, cx)) }) .child(rendered_text) - .child(self.render_media(&message.media, cx)), + .child(self.render_media(&message.media, cx)) + .when(has_reactions, |this| { + this.child(self.render_reactions(&id, cx)) + }), ), ) .child( @@ -1023,7 +1068,7 @@ impl ChatPanel { .border_l_2() .border_color(cx.theme().element_active) .text_sm() - .child(div().child(author.name())) + .child(div().font_semibold().child(author.name())) .child( div() .w_full() @@ -1044,6 +1089,43 @@ impl ChatPanel { items } + fn render_reactions(&self, id: &EventId, cx: &App) -> impl IntoElement { + let current_user = NostrRegistry::global(cx).read(cx).current_user(); + let reactions = self.reaction(id); + + // Group reactions by emoji and collect authors for each + let mut grouped: BTreeMap> = BTreeMap::new(); + for (emoji, author) in &reactions { + grouped.entry(emoji.clone()).or_default().push(*author); + } + + h_flex() + .mt_2() + .gap_1() + .children(grouped.into_iter().map(|(emoji, authors)| { + let count = authors.len(); + let has_reacted = current_user + .map(|pk| authors.contains(&pk)) + .unwrap_or(false); + + h_flex() + .gap_2() + .py_0p5() + .px_1() + .rounded(cx.theme().radius) + .text_xs() + .border_1() + .when(has_reacted, |this| { + this.text_color(cx.theme().secondary_foreground) + .bg(cx.theme().secondary_background) + .border_color(cx.theme().secondary_active) + }) + .when(!has_reacted, |this| this.border_color(cx.theme().border)) + .child(emoji) + .child(SharedString::from(count.to_string())) + })) + } + fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement { let reports = self.sent_reports(id, cx); -- 2.49.1 From a93f827a55e6f6f0de546458a3269d8362464ac9 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 31 Jul 2026 09:46:57 +0700 Subject: [PATCH 3/4] optimize --- crates/chat/src/lib.rs | 48 ++++++++++++++++++++---------- crates/chat/src/room.rs | 6 ++-- crates/chat_ui/src/lib.rs | 61 +++++++++++++++++++++++++-------------- 3 files changed, 74 insertions(+), 41 deletions(-) diff --git a/crates/chat/src/lib.rs b/crates/chat/src/lib.rs index a6c9ec2..30a7117 100644 --- a/crates/chat/src/lib.rs +++ b/crates/chat/src/lib.rs @@ -1,5 +1,5 @@ use std::cmp::Reverse; -use std::collections::{BTreeSet, HashMap, HashSet}; +use std::collections::{BTreeSet, HashMap, HashSet, hash_map}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::{Arc, LazyLock, RwLock}; @@ -80,6 +80,9 @@ pub struct ChatRegistry { /// Chat rooms rooms: Vec>, + /// O(1) room lookup by room ID + room_index: HashMap>, + /// Events that failed to unwrap for any reason trash: Entity>, @@ -170,6 +173,7 @@ impl ChatRegistry { Self { rooms: vec![], + room_index: HashMap::new(), trash: cx.new(|_| BTreeSet::default()), seen: Arc::new(RwLock::new(HashMap::default())), event_map: Arc::new(RwLock::new(HashMap::default())), @@ -436,12 +440,9 @@ impl ChatRegistry { self.tracking.load(Ordering::Acquire) } - /// Get a weak reference to a room by its ID. - pub fn room(&self, id: &u64, cx: &App) -> Option> { - self.rooms - .iter() - .find(|this| &this.read(cx).id == id) - .map(|this| this.downgrade()) + /// Get a weak reference to a room by its ID + pub fn room(&self, id: &u64, _cx: &App) -> Option> { + self.room_index.get(id).map(|room| room.downgrade()) } /// Get all rooms based on the filter. @@ -511,7 +512,11 @@ impl ChatRegistry { }; let room: Room = room.into().organize(&public_key); - self.rooms.insert(0, cx.new(|_| room)); + let room_id = room.id; + let entity = cx.new(|_| room); + + self.room_index.insert(room_id, entity.clone()); + self.rooms.insert(0, entity); cx.emit(ChatEvent::Ping); cx.notify(); @@ -524,9 +529,11 @@ impl ChatRegistry { // Get the room's ID. let id = room.read(cx).id; - // If the room is new, add it to the registry. - if !self.rooms.iter().any(|r| r.read(cx).id == id) { - self.rooms.insert(0, room.to_owned()); + // If the room is new, add it to the registry and index. + if let hash_map::Entry::Vacant(e) = self.room_index.entry(id) { + let entity = room.to_owned(); + e.insert(entity.clone()); + self.rooms.insert(0, entity); } // Emit the open room event deferred to avoid re-entrant reads @@ -537,17 +544,23 @@ impl ChatRegistry { /// Close a room. pub fn close_room(&mut self, id: u64, window: &mut Window, cx: &mut Context) { - if self.rooms.iter().any(|r| r.read(cx).id == id) { + if self.room_index.contains_key(&id) { + self.room_index.remove(&id); + self.rooms.retain(|r| r.read(cx).id != id); cx.defer_in(window, move |_this, _window, cx| { cx.emit(ChatEvent::CloseRoom(id)); }); } } - /// Sort rooms by their created at. + /// Sort rooms by their created at. Only notifies if order changed. pub fn sort(&mut self, cx: &mut Context) { + let before: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect(); self.rooms.sort_by_key(|ev| Reverse(ev.read(cx).created_at)); - cx.notify(); + let after: Vec<_> = self.rooms.iter().map(|ev| ev.read(cx).id).collect(); + if before != after { + cx.notify(); + } } /// Finding rooms based on a query. @@ -574,6 +587,7 @@ impl ChatRegistry { /// Reset the registry. pub fn reset(&mut self, cx: &mut Context) { self.rooms.clear(); + self.room_index.clear(); self.trash.update(cx, |this, cx| { this.clear(); cx.notify(); @@ -601,7 +615,9 @@ impl ChatRegistry { }); } else { let new_room_id = new_room.id; - self.rooms.push(cx.new(|_| new_room)); + let entity = cx.new(|_| new_room); + self.room_index.insert(new_room_id, entity.clone()); + self.rooms.push(entity); let new_index = self.rooms.len(); room_map.insert(new_room_id, new_index); @@ -713,7 +729,7 @@ impl ChatRegistry { return; }; - match self.rooms.iter().find(|e| e.read(cx).id == message.room) { + match self.room_index.get(&message.room).cloned() { Some(room) => { room.update(cx, |this, cx| { if this.kind == RoomKind::Request && message.rumor.pubkey == public_key { diff --git a/crates/chat/src/room.rs b/crates/chat/src/room.rs index 8f09b4b..23fc2ac 100644 --- a/crates/chat/src/room.rs +++ b/crates/chat/src/room.rs @@ -271,8 +271,8 @@ impl Room { } /// Returns the members of the room - pub fn members(&self) -> Vec { - self.members.clone() + pub fn members(&self) -> &[PublicKey] { + &self.members } /// Checks if the room has more than two members (group) @@ -356,7 +356,7 @@ impl Room { pub fn connect(&self, cx: &App) -> Task> { let nostr = NostrRegistry::global(cx); let client = nostr.read(cx).client(); - let members = self.members(); + let members = self.members().to_vec(); cx.background_spawn(async move { let opts = SubscribeAutoCloseOptions::default() diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 4a52698..2b14ab7 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1,4 +1,4 @@ -use std::collections::{BTreeMap, HashSet}; +use std::collections::{BTreeMap, HashMap, HashSet}; use std::sync::{Arc, RwLock}; pub use actions::*; @@ -59,6 +59,9 @@ pub struct ChatPanel { /// All messages (sorted by created_at) messages: Vec, + /// O(1) message lookup by EventId + message_index: HashMap, + /// All reactions reactions: BTreeMap>, @@ -166,6 +169,7 @@ impl ChatPanel { focus_handle: cx.focus_handle(), id, messages, + message_index: HashMap::new(), reactions: BTreeMap::new(), room, list_state, @@ -231,23 +235,29 @@ impl ChatPanel { while let Ok(status) = rx.recv_async().await { { let mut map = reports.write().unwrap(); - for reports in map.values_mut() { - for report in reports.iter_mut() { + let status_id = match &*status { + SendStatus::Ok { id, .. } => *id, + SendStatus::Failed { id, .. } => *id, + }; + + // Find the matching report and update it (exit early on first match) + 'outer: for reports_list in map.values_mut() { + for report in reports_list.iter_mut() { let Some(output) = report.output.as_mut() else { continue; }; + if *output.id() != status_id { + continue; + } match &*status { - SendStatus::Ok { id, relay } => { - if output.id() == id { - output.success.insert(relay.clone(), EventSendStatus::Sent); - } + SendStatus::Ok { relay, .. } => { + output.success.insert(relay.clone(), EventSendStatus::Sent); } - SendStatus::Failed { id, relay, message } => { - if output.id() == id { - output.failed.insert(relay.clone(), message.clone()); - } + SendStatus::Failed { relay, message, .. } => { + output.failed.insert(relay.clone(), message.clone()); } } + break 'outer; } } } @@ -472,6 +482,10 @@ impl ChatPanel { if let Err(pos) = self.messages.binary_search(&msg) { self.messages.insert(pos, msg); + // Rebuild message index after insertion (indices from pos to end shift) + for (i, message) in self.messages.iter().enumerate().skip(pos) { + self.message_index.insert(message.id, i); + } self.list_state.splice(old_len..old_len, 1); if scroll { @@ -514,22 +528,25 @@ impl ChatPanel { } /// Check if a message has any reports - fn has_reports(&self, id: &EventId, _cx: &App) -> bool { - self.reports_by_id.read().unwrap().get(id).is_some() + fn has_reports(&self, id: &EventId) -> bool { + self.reports_by_id.read().unwrap().contains_key(id) } - fn sent_reports(&self, id: &EventId, _cx: &App) -> Option> { + /// Clone reports for a message (used for modal display, not called during render) + fn sent_reports(&self, id: &EventId) -> Option> { self.reports_by_id.read().unwrap().get(id).cloned() } - /// Get a message by its ID + /// Get a message by its ID (O(1) lookup) fn message(&self, id: &EventId) -> Option<&Message> { - self.messages.iter().find(|msg| &msg.id == id) + self.message_index + .get(id) + .and_then(|&ix| self.messages.get(ix)) } - /// Get a reaction by its target ID - fn reaction(&self, id: &EventId) -> Vec<(SharedString, PublicKey)> { - self.reactions.get(id).cloned().unwrap_or_default() + /// Get a reaction by its target ID (returns reference, no allocation) + fn reaction(&self, id: &EventId) -> &[(SharedString, PublicKey)] { + self.reactions.get(id).map(|v| v.as_slice()).unwrap_or(&[]) } /// Check if a message has any reactions @@ -912,7 +929,7 @@ impl ChatPanel { let replies = message.replies_to.as_slice(); let has_replies = !replies.is_empty(); let has_reactions = self.has_reaction(&id); - let has_reports = self.has_reports(&id, cx); + let has_reports = self.has_reports(&id); // Hide avatar setting let hide_avatar = AppSettings::get_hide_avatar(cx); @@ -1095,7 +1112,7 @@ impl ChatPanel { // Group reactions by emoji and collect authors for each let mut grouped: BTreeMap> = BTreeMap::new(); - for (emoji, author) in &reactions { + for (emoji, author) in reactions { grouped.entry(emoji.clone()).or_default().push(*author); } @@ -1127,7 +1144,7 @@ impl ChatPanel { } fn render_sent_reports(&self, id: &EventId, cx: &App) -> impl IntoElement { - let reports = self.sent_reports(id, cx); + let reports = self.sent_reports(id); let pending = reports .as_ref() -- 2.49.1 From 48ea6ca81cc300478b12baa606e7c111be328a64 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 31 Jul 2026 09:53:42 +0700 Subject: [PATCH 4/4] send custom emoji --- Cargo.lock | 1 + crates/chat_ui/Cargo.toml | 1 + crates/chat_ui/src/lib.rs | 19 ++++++++++++++++++- 3 files changed, 20 insertions(+), 1 deletion(-) diff --git a/Cargo.lock b/Cargo.lock index 994d91d..47a1743 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1087,6 +1087,7 @@ dependencies = [ "nostr-sdk", "person", "pulldown-cmark", + "regex", "serde", "settings", "smallvec", diff --git a/crates/chat_ui/Cargo.toml b/crates/chat_ui/Cargo.toml index fd2c1f1..23c2f28 100644 --- a/crates/chat_ui/Cargo.toml +++ b/crates/chat_ui/Cargo.toml @@ -25,4 +25,5 @@ serde.workspace = true linkify = "0.10.0" pulldown-cmark = "0.13.1" +regex = "1" diff --git a/crates/chat_ui/src/lib.rs b/crates/chat_ui/src/lib.rs index 2b14ab7..f4c885d 100644 --- a/crates/chat_ui/src/lib.rs +++ b/crates/chat_ui/src/lib.rs @@ -1,5 +1,5 @@ use std::collections::{BTreeMap, HashMap, HashSet}; -use std::sync::{Arc, RwLock}; +use std::sync::{Arc, LazyLock, RwLock}; pub use actions::*; use anyhow::{Context as AnyhowContext, Error}; @@ -17,6 +17,7 @@ use gpui::{ use itertools::Itertools; use nostr_sdk::prelude::*; use person::{Person, PersonRegistry}; +use regex::Regex; use settings::{AppSettings, SignerKind}; use smallvec::{SmallVec, smallvec}; use state::{NostrRegistry, upload}; @@ -38,6 +39,11 @@ use crate::text::RenderedText; const REACTION_EMOJIS: &[&str] = &["👍", "👎", "😄", "🎉", "😕", "❤️", "🚀", "👀"]; const COMPACT_REACTION_EMOJIS: &[&str] = &["👍", "❤️", "👀"]; +/// Regex matching strings that consist entirely of emoji characters, +/// zero-width joiners, variation selectors, and keycap combiners. +static EMOJI_RE: LazyLock = + LazyLock::new(|| Regex::new(r"^[\p{Emoji}\u{200D}\u{FE0F}\u{20E3}]+$").unwrap()); + mod actions; mod text; @@ -361,6 +367,17 @@ impl ChatPanel { return; } + // If replying to exactly one message with only a valid emoji, + // send as a reaction instead of a text message + if replies.len() == 1 && EMOJI_RE.is_match(&content) && self.attachments.read(cx).is_empty() + { + for reply in &replies { + self.send_reaction(&content, reply, window, cx); + } + self.clear(window, cx); + return; + } + self.send_message(&content, replies, false, window, cx); } -- 2.49.1