Support emoji shortcodes in chat (#8455)

Bennet Bo Fenner and Conrad Irwin created

Completes: https://github.com/zed-industries/zed/issues/7299

Suggestions


https://github.com/zed-industries/zed/assets/53836821/2a81ba89-4634-4d94-8370-6f76ff3e9403

Automatically replacing shortcodes without using the completions (only
enabled when `message_editor` > `auto_replace_emoji_shortcode` is
enabled in the settings):


https://github.com/zed-industries/zed/assets/53836821/10ef2b4b-c67b-4202-b958-332a37dc088e






Release Notes:

- Added autocompletion for emojis in chat when typing emoji shortcodes
([#7299](https://github.com/zed-industries/zed/issues/7299)).
- Added support for automatically replacing emoji shortcodes in chat
(e.g. typing ":wave:" will be converted to "👋")

---------

Co-authored-by: Conrad Irwin <conrad.irwin@gmail.com>

Change summary

Cargo.lock                                        |  11 
Cargo.toml                                        |   1 
assets/settings/default.json                      |   5 
crates/collab_ui/Cargo.toml                       |   1 
crates/collab_ui/src/chat_panel/message_editor.rs | 200 ++++++++++++++--
crates/collab_ui/src/collab_ui.rs                 |   2 
crates/collab_ui/src/panel_settings.rs            |  21 +
crates/editor/Cargo.toml                          |   1 
crates/editor/src/editor.rs                       |  73 ++++++
crates/editor/src/editor_tests.rs                 |  53 ++++
10 files changed, 338 insertions(+), 30 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -2301,6 +2301,7 @@ dependencies = [
  "collections",
  "db",
  "editor",
+ "emojis",
  "extensions_ui",
  "feedback",
  "futures 0.3.28",
@@ -3281,6 +3282,7 @@ dependencies = [
  "copilot",
  "ctor",
  "db",
+ "emojis",
  "env_logger",
  "futures 0.3.28",
  "fuzzy",
@@ -3349,6 +3351,15 @@ dependencies = [
  "zeroize",
 ]
 
+[[package]]
+name = "emojis"
+version = "0.6.1"
+source = "registry+https://github.com/rust-lang/crates.io-index"
+checksum = "4ee61eb945bff65ee7d19d157d39c67c33290ff0742907413fd5eefd29edc979"
+dependencies = [
+ "phf",
+]
+
 [[package]]
 name = "encode_unicode"
 version = "0.3.6"

Cargo.toml 🔗

@@ -207,6 +207,7 @@ ctor = "0.2.6"
 core-foundation = { version = "0.9.3" }
 core-foundation-sys = "0.8.6"
 derive_more = "0.99.17"
+emojis = "0.6.1"
 env_logger = "0.9"
 futures = "0.3"
 git2 = { version = "0.15", default-features = false }

assets/settings/default.json 🔗

@@ -211,6 +211,11 @@
     // Default width of the channels panel.
     "default_width": 240
   },
+  "message_editor": {
+    // Whether to automatically replace emoji shortcodes with emoji characters.
+    // For example: typing `:wave:` gets replaced with `👋`.
+    "auto_replace_emoji_shortcode": true
+  },
   "notification_panel": {
     // Whether to show the collaboration panel button in the status bar.
     "button": true,

crates/collab_ui/Cargo.toml 🔗

@@ -37,6 +37,7 @@ clock.workspace = true
 collections.workspace = true
 db.workspace = true
 editor.workspace = true
+emojis.workspace = true
 extensions_ui.workspace = true
 feedback.workspace = true
 futures.workspace = true

crates/collab_ui/src/chat_panel/message_editor.rs 🔗

@@ -3,7 +3,7 @@ use channel::{ChannelMembership, ChannelStore, MessageParams};
 use client::{ChannelId, UserId};
 use collections::{HashMap, HashSet};
 use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle};
-use fuzzy::StringMatchCandidate;
+use fuzzy::{StringMatch, StringMatchCandidate};
 use gpui::{
     AsyncWindowContext, FocusableView, FontStyle, FontWeight, HighlightStyle, IntoElement, Model,
     Render, SharedString, Task, TextStyle, View, ViewContext, WeakView, WhiteSpace,
@@ -16,10 +16,12 @@ use lazy_static::lazy_static;
 use parking_lot::RwLock;
 use project::search::SearchQuery;
 use settings::Settings;
-use std::{sync::Arc, time::Duration};
+use std::{ops::Range, sync::Arc, time::Duration};
 use theme::ThemeSettings;
 use ui::{prelude::*, UiTextSize};
 
+use crate::panel_settings::MessageEditorSettings;
+
 const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
 
 lazy_static! {
@@ -86,6 +88,11 @@ impl MessageEditor {
             editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
             editor.set_use_autoclose(false);
             editor.set_completion_provider(Box::new(MessageEditorCompletionProvider(this)));
+            editor.set_auto_replace_emoji_shortcode(
+                MessageEditorSettings::get_global(cx)
+                    .auto_replace_emoji_shortcode
+                    .unwrap_or_default(),
+            );
         });
 
         let buffer = editor
@@ -96,6 +103,16 @@ impl MessageEditor {
             .expect("message editor must be singleton");
 
         cx.subscribe(&buffer, Self::on_buffer_event).detach();
+        cx.observe_global::<settings::SettingsStore>(|view, cx| {
+            view.editor.update(cx, |editor, cx| {
+                editor.set_auto_replace_emoji_shortcode(
+                    MessageEditorSettings::get_global(cx)
+                        .auto_replace_emoji_shortcode
+                        .unwrap_or_default(),
+                )
+            })
+        })
+        .detach();
 
         let markdown = language_registry.language_for_name("Markdown");
         cx.spawn(|_, mut cx| async move {
@@ -219,6 +236,101 @@ impl MessageEditor {
         end_anchor: Anchor,
         cx: &mut ViewContext<Self>,
     ) -> Task<Result<Vec<Completion>>> {
+        if let Some((start_anchor, query, candidates)) =
+            self.collect_mention_candidates(buffer, end_anchor, cx)
+        {
+            if !candidates.is_empty() {
+                return cx.spawn(|_, cx| async move {
+                    Ok(Self::resolve_completions_for_candidates(
+                        &cx,
+                        query.as_str(),
+                        &candidates,
+                        start_anchor..end_anchor,
+                        Self::completion_for_mention,
+                    )
+                    .await)
+                });
+            }
+        }
+
+        if let Some((start_anchor, query, candidates)) =
+            self.collect_emoji_candidates(buffer, end_anchor, cx)
+        {
+            if !candidates.is_empty() {
+                return cx.spawn(|_, cx| async move {
+                    Ok(Self::resolve_completions_for_candidates(
+                        &cx,
+                        query.as_str(),
+                        candidates,
+                        start_anchor..end_anchor,
+                        Self::completion_for_emoji,
+                    )
+                    .await)
+                });
+            }
+        }
+
+        Task::ready(Ok(vec![]))
+    }
+
+    async fn resolve_completions_for_candidates(
+        cx: &AsyncWindowContext,
+        query: &str,
+        candidates: &[StringMatchCandidate],
+        range: Range<Anchor>,
+        completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
+    ) -> Vec<Completion> {
+        let matches = fuzzy::match_strings(
+            &candidates,
+            &query,
+            true,
+            10,
+            &Default::default(),
+            cx.background_executor().clone(),
+        )
+        .await;
+
+        matches
+            .into_iter()
+            .map(|mat| {
+                let (new_text, label) = completion_fn(&mat);
+                Completion {
+                    old_range: range.clone(),
+                    new_text,
+                    label,
+                    documentation: None,
+                    server_id: LanguageServerId(0), // TODO: Make this optional or something?
+                    lsp_completion: Default::default(), // TODO: Make this optional or something?
+                }
+            })
+            .collect()
+    }
+
+    fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
+        let label = CodeLabel {
+            filter_range: 1..mat.string.len() + 1,
+            text: format!("@{}", mat.string),
+            runs: Vec::new(),
+        };
+        (mat.string.clone(), label)
+    }
+
+    fn completion_for_emoji(mat: &StringMatch) -> (String, CodeLabel) {
+        let emoji = emojis::get_by_shortcode(&mat.string).unwrap();
+        let label = CodeLabel {
+            filter_range: 1..mat.string.len() + 1,
+            text: format!(":{}: {}", mat.string, emoji),
+            runs: Vec::new(),
+        };
+        (emoji.to_string(), label)
+    }
+
+    fn collect_mention_candidates(
+        &mut self,
+        buffer: &Model<Buffer>,
+        end_anchor: Anchor,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<(Anchor, String, Vec<StringMatchCandidate>)> {
         let end_offset = end_anchor.to_offset(buffer.read(cx));
 
         let Some(query) = buffer.update(cx, |buffer, _| {
@@ -232,9 +344,9 @@ impl MessageEditor {
                 }
                 query.push(ch);
             }
-            return None;
+            None
         }) else {
-            return Task::ready(Ok(vec![]));
+            return None;
         };
 
         let start_offset = end_offset - query.len();
@@ -258,33 +370,59 @@ impl MessageEditor {
                 char_bag: user.chars().collect(),
             })
             .collect::<Vec<_>>();
-        cx.spawn(|_, cx| async move {
-            let matches = fuzzy::match_strings(
-                &candidates,
-                &query,
-                true,
-                10,
-                &Default::default(),
-                cx.background_executor().clone(),
-            )
-            .await;
 
-            Ok(matches
-                .into_iter()
-                .map(|mat| Completion {
-                    old_range: start_anchor..end_anchor,
-                    new_text: mat.string.clone(),
-                    label: CodeLabel {
-                        filter_range: 1..mat.string.len() + 1,
-                        text: format!("@{}", mat.string),
-                        runs: Vec::new(),
-                    },
-                    documentation: None,
-                    server_id: LanguageServerId(0), // TODO: Make this optional or something?
-                    lsp_completion: Default::default(), // TODO: Make this optional or something?
-                })
-                .collect())
-        })
+        Some((start_anchor, query, candidates))
+    }
+
+    fn collect_emoji_candidates(
+        &mut self,
+        buffer: &Model<Buffer>,
+        end_anchor: Anchor,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<(Anchor, String, &'static [StringMatchCandidate])> {
+        lazy_static! {
+            static ref EMOJI_FUZZY_MATCH_CANDIDATES: Vec<StringMatchCandidate> = {
+                let emojis = emojis::iter()
+                    .flat_map(|s| s.shortcodes())
+                    .map(|emoji| StringMatchCandidate {
+                        id: 0,
+                        string: emoji.to_string(),
+                        char_bag: emoji.chars().collect(),
+                    })
+                    .collect::<Vec<_>>();
+                emojis
+            };
+        }
+
+        let end_offset = end_anchor.to_offset(buffer.read(cx));
+
+        let Some(query) = buffer.update(cx, |buffer, _| {
+            let mut query = String::new();
+            for ch in buffer.reversed_chars_at(end_offset).take(100) {
+                if ch == ':' {
+                    let next_char = buffer
+                        .reversed_chars_at(end_offset - query.len() - 1)
+                        .next();
+                    // Ensure we are at the start of the message or that the previous character is a whitespace
+                    if next_char.is_none() || next_char.unwrap().is_whitespace() {
+                        return Some(query.chars().rev().collect::<String>());
+                    }
+                    break;
+                }
+                if ch.is_whitespace() || !ch.is_ascii() {
+                    break;
+                }
+                query.push(ch);
+            }
+            None
+        }) else {
+            return None;
+        };
+
+        let start_offset = end_offset - query.len() - 1;
+        let start_anchor = buffer.read(cx).anchor_before(start_offset);
+
+        Some((start_anchor, query, &EMOJI_FUZZY_MATCH_CANDIDATES))
     }
 
     async fn find_mentions(
@@ -465,6 +603,8 @@ mod tests {
             editor::init(cx);
             client::init(&client, cx);
             channel::init(&client, user_store, cx);
+
+            MessageEditorSettings::register(cx);
         });
 
         let language_registry = Arc::new(LanguageRegistry::test());

crates/collab_ui/src/collab_ui.rs 🔗

@@ -16,6 +16,7 @@ use gpui::{
     actions, point, AppContext, GlobalPixels, Pixels, PlatformDisplay, Size, Task, WindowBounds,
     WindowContext, WindowKind, WindowOptions,
 };
+use panel_settings::MessageEditorSettings;
 pub use panel_settings::{
     ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
 };
@@ -31,6 +32,7 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
     CollaborationPanelSettings::register(cx);
     ChatPanelSettings::register(cx);
     NotificationPanelSettings::register(cx);
+    MessageEditorSettings::register(cx);
 
     vcs_menu::init(cx);
     collab_titlebar_item::init(cx);

crates/collab_ui/src/panel_settings.rs 🔗

@@ -42,6 +42,15 @@ pub struct PanelSettingsContent {
     pub default_width: Option<f32>,
 }
 
+#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
+pub struct MessageEditorSettings {
+    /// Whether to automatically replace emoji shortcodes with emoji characters.
+    /// For example: typing `:wave:` gets replaced with `👋`.
+    ///
+    /// Default: false
+    pub auto_replace_emoji_shortcode: Option<bool>,
+}
+
 impl Settings for CollaborationPanelSettings {
     const KEY: Option<&'static str> = Some("collaboration_panel");
     type FileContent = PanelSettingsContent;
@@ -77,3 +86,15 @@ impl Settings for NotificationPanelSettings {
         Self::load_via_json_merge(default_value, user_values)
     }
 }
+
+impl Settings for MessageEditorSettings {
+    const KEY: Option<&'static str> = Some("message_editor");
+    type FileContent = MessageEditorSettings;
+    fn load(
+        default_value: &Self::FileContent,
+        user_values: &[&Self::FileContent],
+        _: &mut gpui::AppContext,
+    ) -> anyhow::Result<Self> {
+        Self::load_via_json_merge(default_value, user_values)
+    }
+}

crates/editor/Cargo.toml 🔗

@@ -36,6 +36,7 @@ collections.workspace = true
 convert_case = "0.6.0"
 copilot.workspace = true
 db.workspace = true
+emojis.workspace = true
 futures.workspace = true
 fuzzy.workspace = true
 git.workspace = true

crates/editor/src/editor.rs 🔗

@@ -425,6 +425,7 @@ pub struct Editor {
     editor_actions: Vec<Box<dyn Fn(&mut ViewContext<Self>)>>,
     show_copilot_suggestions: bool,
     use_autoclose: bool,
+    auto_replace_emoji_shortcode: bool,
     custom_context_menu: Option<
         Box<
             dyn 'static
@@ -1539,6 +1540,7 @@ impl Editor {
             use_modal_editing: mode == EditorMode::Full,
             read_only: false,
             use_autoclose: true,
+            auto_replace_emoji_shortcode: false,
             leader_peer_id: None,
             remote_id: None,
             hover_state: Default::default(),
@@ -1829,6 +1831,10 @@ impl Editor {
         self.use_autoclose = autoclose;
     }
 
+    pub fn set_auto_replace_emoji_shortcode(&mut self, auto_replace: bool) {
+        self.auto_replace_emoji_shortcode = auto_replace;
+    }
+
     pub fn set_show_copilot_suggestions(&mut self, show_copilot_suggestions: bool) {
         self.show_copilot_suggestions = show_copilot_suggestions;
     }
@@ -2505,6 +2511,47 @@ impl Editor {
                 }
             }
 
+            if self.auto_replace_emoji_shortcode
+                && selection.is_empty()
+                && text.as_ref().ends_with(':')
+            {
+                if let Some(possible_emoji_short_code) =
+                    Self::find_possible_emoji_shortcode_at_position(&snapshot, selection.start)
+                {
+                    if !possible_emoji_short_code.is_empty() {
+                        if let Some(emoji) = emojis::get_by_shortcode(&possible_emoji_short_code) {
+                            let emoji_shortcode_start = Point::new(
+                                selection.start.row,
+                                selection.start.column - possible_emoji_short_code.len() as u32 - 1,
+                            );
+
+                            // Remove shortcode from buffer
+                            edits.push((
+                                emoji_shortcode_start..selection.start,
+                                "".to_string().into(),
+                            ));
+                            new_selections.push((
+                                Selection {
+                                    id: selection.id,
+                                    start: snapshot.anchor_after(emoji_shortcode_start),
+                                    end: snapshot.anchor_before(selection.start),
+                                    reversed: selection.reversed,
+                                    goal: selection.goal,
+                                },
+                                0,
+                            ));
+
+                            // Insert emoji
+                            let selection_start_anchor = snapshot.anchor_after(selection.start);
+                            new_selections.push((selection.map(|_| selection_start_anchor), 0));
+                            edits.push((selection.start..selection.end, emoji.to_string().into()));
+
+                            continue;
+                        }
+                    }
+                }
+            }
+
             // If not handling any auto-close operation, then just replace the selected
             // text with the given input and move the selection to the end of the
             // newly inserted text.
@@ -2588,6 +2635,32 @@ impl Editor {
         });
     }
 
+    fn find_possible_emoji_shortcode_at_position(
+        snapshot: &MultiBufferSnapshot,
+        position: Point,
+    ) -> Option<String> {
+        let mut chars = Vec::new();
+        let mut found_colon = false;
+        for char in snapshot.reversed_chars_at(position).take(100) {
+            // Found a possible emoji shortcode in the middle of the buffer
+            if found_colon && char.is_whitespace() {
+                chars.reverse();
+                return Some(chars.iter().collect());
+            }
+            if char.is_whitespace() || !char.is_ascii() {
+                return None;
+            }
+            if char == ':' {
+                found_colon = true;
+            } else {
+                chars.push(char);
+            }
+        }
+        // Found a possible emoji shortcode at the beginning of the buffer
+        chars.reverse();
+        Some(chars.iter().collect())
+    }
+
     pub fn newline(&mut self, _: &Newline, cx: &mut ViewContext<Self>) {
         self.transact(cx, |this, cx| {
             let (edits, selection_fixup_info): (Vec<_>, Vec<_>) = {

crates/editor/src/editor_tests.rs 🔗

@@ -5171,6 +5171,59 @@ async fn test_delete_autoclose_pair(cx: &mut gpui::TestAppContext) {
     });
 }
 
+#[gpui::test]
+async fn test_auto_replace_emoji_shortcode(cx: &mut gpui::TestAppContext) {
+    init_test(cx, |_| {});
+
+    let language = Arc::new(Language::new(
+        LanguageConfig::default(),
+        Some(tree_sitter_rust::language()),
+    ));
+
+    let buffer = cx.new_model(|cx| {
+        Buffer::new(0, BufferId::new(cx.entity_id().as_u64()).unwrap(), "")
+            .with_language(language, cx)
+    });
+    let buffer = cx.new_model(|cx| MultiBuffer::singleton(buffer, cx));
+    let (editor, cx) = cx.add_window_view(|cx| build_editor(buffer, cx));
+    editor
+        .condition::<crate::EditorEvent>(cx, |view, cx| !view.buffer.read(cx).is_parsing(cx))
+        .await;
+
+    _ = editor.update(cx, |editor, cx| {
+        editor.set_auto_replace_emoji_shortcode(true);
+
+        editor.handle_input("Hello ", cx);
+        editor.handle_input(":wave", cx);
+        assert_eq!(editor.text(cx), "Hello :wave".unindent());
+
+        editor.handle_input(":", cx);
+        assert_eq!(editor.text(cx), "Hello 👋".unindent());
+
+        editor.handle_input(" :smile", cx);
+        assert_eq!(editor.text(cx), "Hello 👋 :smile".unindent());
+
+        editor.handle_input(":", cx);
+        assert_eq!(editor.text(cx), "Hello 👋 😄".unindent());
+
+        editor.handle_input(":1:", cx);
+        assert_eq!(editor.text(cx), "Hello 👋 😄:1:".unindent());
+
+        // Ensure shortcode does not get replaced when it is part of a word
+        editor.handle_input(" Test:wave:", cx);
+        assert_eq!(editor.text(cx), "Hello 👋 😄:1: Test:wave:".unindent());
+
+        editor.set_auto_replace_emoji_shortcode(false);
+
+        // Ensure shortcode does not get replaced when auto replace is off
+        editor.handle_input(" :wave:", cx);
+        assert_eq!(
+            editor.text(cx),
+            "Hello 👋 😄:1: Test:wave: :wave:".unindent()
+        );
+    });
+}
+
 #[gpui::test]
 async fn test_snippets(cx: &mut gpui::TestAppContext) {
     init_test(cx, |_| {});