message_editor.rs

  1use anyhow::{Context as _, Result};
  2use channel::{ChannelChat, ChannelStore, MessageParams};
  3use client::{UserId, UserStore};
  4use collections::HashSet;
  5use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle};
  6use fuzzy::{StringMatch, StringMatchCandidate};
  7use gpui::{
  8    AsyncApp, AsyncWindowContext, Context, Entity, Focusable, FontStyle, FontWeight,
  9    HighlightStyle, IntoElement, Render, Task, TextStyle, WeakEntity, Window,
 10};
 11use language::{
 12    language_settings::SoftWrap, Anchor, Buffer, BufferSnapshot, CodeLabel, LanguageRegistry,
 13    ToOffset,
 14};
 15use project::{search::SearchQuery, Completion, CompletionSource};
 16use settings::Settings;
 17use std::{
 18    cell::RefCell,
 19    ops::Range,
 20    rc::Rc,
 21    sync::{Arc, LazyLock},
 22    time::Duration,
 23};
 24use theme::ThemeSettings;
 25use ui::{prelude::*, TextSize};
 26
 27use crate::panel_settings::MessageEditorSettings;
 28
 29const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
 30
 31static MENTIONS_SEARCH: LazyLock<SearchQuery> = LazyLock::new(|| {
 32    SearchQuery::regex(
 33        "@[-_\\w]+",
 34        false,
 35        false,
 36        false,
 37        Default::default(),
 38        Default::default(),
 39        None,
 40    )
 41    .unwrap()
 42});
 43
 44pub struct MessageEditor {
 45    pub editor: Entity<Editor>,
 46    user_store: Entity<UserStore>,
 47    channel_chat: Option<Entity<ChannelChat>>,
 48    mentions: Vec<UserId>,
 49    mentions_task: Option<Task<()>>,
 50    reply_to_message_id: Option<u64>,
 51    edit_message_id: Option<u64>,
 52}
 53
 54struct MessageEditorCompletionProvider(WeakEntity<MessageEditor>);
 55
 56impl CompletionProvider for MessageEditorCompletionProvider {
 57    fn completions(
 58        &self,
 59        buffer: &Entity<Buffer>,
 60        buffer_position: language::Anchor,
 61        _: editor::CompletionContext,
 62        _window: &mut Window,
 63        cx: &mut Context<Editor>,
 64    ) -> Task<Result<Option<Vec<Completion>>>> {
 65        let Some(handle) = self.0.upgrade() else {
 66            return Task::ready(Ok(None));
 67        };
 68        handle.update(cx, |message_editor, cx| {
 69            message_editor.completions(buffer, buffer_position, cx)
 70        })
 71    }
 72
 73    fn resolve_completions(
 74        &self,
 75        _buffer: Entity<Buffer>,
 76        _completion_indices: Vec<usize>,
 77        _completions: Rc<RefCell<Box<[Completion]>>>,
 78        _cx: &mut Context<Editor>,
 79    ) -> Task<anyhow::Result<bool>> {
 80        Task::ready(Ok(false))
 81    }
 82
 83    fn is_completion_trigger(
 84        &self,
 85        _buffer: &Entity<Buffer>,
 86        _position: language::Anchor,
 87        text: &str,
 88        _trigger_in_words: bool,
 89        _cx: &mut Context<Editor>,
 90    ) -> bool {
 91        text == "@"
 92    }
 93}
 94
 95impl MessageEditor {
 96    pub fn new(
 97        language_registry: Arc<LanguageRegistry>,
 98        user_store: Entity<UserStore>,
 99        channel_chat: Option<Entity<ChannelChat>>,
100        editor: Entity<Editor>,
101        window: &mut Window,
102        cx: &mut Context<Self>,
103    ) -> Self {
104        let this = cx.entity().downgrade();
105        editor.update(cx, |editor, cx| {
106            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
107            editor.set_use_autoclose(false);
108            editor.set_show_gutter(false, cx);
109            editor.set_show_wrap_guides(false, cx);
110            editor.set_show_indent_guides(false, cx);
111            editor.set_completion_provider(Some(Box::new(MessageEditorCompletionProvider(this))));
112            editor.set_auto_replace_emoji_shortcode(
113                MessageEditorSettings::get_global(cx)
114                    .auto_replace_emoji_shortcode
115                    .unwrap_or_default(),
116            );
117        });
118
119        let buffer = editor
120            .read(cx)
121            .buffer()
122            .read(cx)
123            .as_singleton()
124            .expect("message editor must be singleton");
125
126        cx.subscribe_in(&buffer, window, Self::on_buffer_event)
127            .detach();
128        cx.observe_global::<settings::SettingsStore>(|this, cx| {
129            this.editor.update(cx, |editor, cx| {
130                editor.set_auto_replace_emoji_shortcode(
131                    MessageEditorSettings::get_global(cx)
132                        .auto_replace_emoji_shortcode
133                        .unwrap_or_default(),
134                )
135            })
136        })
137        .detach();
138
139        let markdown = language_registry.language_for_name("Markdown");
140        cx.spawn_in(window, async move |_, cx| {
141            let markdown = markdown.await.context("failed to load Markdown language")?;
142            buffer.update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx))
143        })
144        .detach_and_log_err(cx);
145
146        Self {
147            editor,
148            user_store,
149            channel_chat,
150            mentions: Vec::new(),
151            mentions_task: None,
152            reply_to_message_id: None,
153            edit_message_id: None,
154        }
155    }
156
157    pub fn reply_to_message_id(&self) -> Option<u64> {
158        self.reply_to_message_id
159    }
160
161    pub fn set_reply_to_message_id(&mut self, reply_to_message_id: u64) {
162        self.reply_to_message_id = Some(reply_to_message_id);
163    }
164
165    pub fn clear_reply_to_message_id(&mut self) {
166        self.reply_to_message_id = None;
167    }
168
169    pub fn edit_message_id(&self) -> Option<u64> {
170        self.edit_message_id
171    }
172
173    pub fn set_edit_message_id(&mut self, edit_message_id: u64) {
174        self.edit_message_id = Some(edit_message_id);
175    }
176
177    pub fn clear_edit_message_id(&mut self) {
178        self.edit_message_id = None;
179    }
180
181    pub fn set_channel_chat(&mut self, chat: Entity<ChannelChat>, cx: &mut Context<Self>) {
182        let channel_id = chat.read(cx).channel_id;
183        self.channel_chat = Some(chat);
184        let channel_name = ChannelStore::global(cx)
185            .read(cx)
186            .channel_for_id(channel_id)
187            .map(|channel| channel.name.clone());
188        self.editor.update(cx, |editor, cx| {
189            if let Some(channel_name) = channel_name {
190                editor.set_placeholder_text(format!("Message #{channel_name}"), cx);
191            } else {
192                editor.set_placeholder_text("Message Channel", cx);
193            }
194        });
195    }
196
197    pub fn take_message(&mut self, window: &mut Window, cx: &mut Context<Self>) -> MessageParams {
198        self.editor.update(cx, |editor, cx| {
199            let highlights = editor.text_highlights::<Self>(cx);
200            let text = editor.text(cx);
201            let snapshot = editor.buffer().read(cx).snapshot(cx);
202            let mentions = if let Some((_, ranges)) = highlights {
203                ranges
204                    .iter()
205                    .map(|range| range.to_offset(&snapshot))
206                    .zip(self.mentions.iter().copied())
207                    .collect()
208            } else {
209                Vec::new()
210            };
211
212            editor.clear(window, cx);
213            self.mentions.clear();
214            let reply_to_message_id = std::mem::take(&mut self.reply_to_message_id);
215
216            MessageParams {
217                text,
218                mentions,
219                reply_to_message_id,
220            }
221        })
222    }
223
224    fn on_buffer_event(
225        &mut self,
226        buffer: &Entity<Buffer>,
227        event: &language::BufferEvent,
228        window: &mut Window,
229        cx: &mut Context<Self>,
230    ) {
231        if let language::BufferEvent::Reparsed | language::BufferEvent::Edited = event {
232            let buffer = buffer.read(cx).snapshot();
233            self.mentions_task = Some(cx.spawn_in(window, async move |this, cx| {
234                cx.background_executor()
235                    .timer(MENTIONS_DEBOUNCE_INTERVAL)
236                    .await;
237                Self::find_mentions(this, buffer, cx).await;
238            }));
239        }
240    }
241
242    fn completions(
243        &mut self,
244        buffer: &Entity<Buffer>,
245        end_anchor: Anchor,
246        cx: &mut Context<Self>,
247    ) -> Task<Result<Option<Vec<Completion>>>> {
248        if let Some((start_anchor, query, candidates)) =
249            self.collect_mention_candidates(buffer, end_anchor, cx)
250        {
251            if !candidates.is_empty() {
252                return cx.spawn(async move |_, cx| {
253                    Ok(Some(
254                        Self::resolve_completions_for_candidates(
255                            &cx,
256                            query.as_str(),
257                            &candidates,
258                            start_anchor..end_anchor,
259                            Self::completion_for_mention,
260                        )
261                        .await,
262                    ))
263                });
264            }
265        }
266
267        if let Some((start_anchor, query, candidates)) =
268            self.collect_emoji_candidates(buffer, end_anchor, cx)
269        {
270            if !candidates.is_empty() {
271                return cx.spawn(async move |_, cx| {
272                    Ok(Some(
273                        Self::resolve_completions_for_candidates(
274                            &cx,
275                            query.as_str(),
276                            candidates,
277                            start_anchor..end_anchor,
278                            Self::completion_for_emoji,
279                        )
280                        .await,
281                    ))
282                });
283            }
284        }
285
286        Task::ready(Ok(Some(Vec::new())))
287    }
288
289    async fn resolve_completions_for_candidates(
290        cx: &AsyncApp,
291        query: &str,
292        candidates: &[StringMatchCandidate],
293        range: Range<Anchor>,
294        completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
295    ) -> Vec<Completion> {
296        let matches = fuzzy::match_strings(
297            candidates,
298            query,
299            true,
300            10,
301            &Default::default(),
302            cx.background_executor().clone(),
303        )
304        .await;
305
306        matches
307            .into_iter()
308            .map(|mat| {
309                let (new_text, label) = completion_fn(&mat);
310                Completion {
311                    old_range: range.clone(),
312                    new_text,
313                    label,
314                    confirm: None,
315                    documentation: None,
316                    source: CompletionSource::Custom,
317                }
318            })
319            .collect()
320    }
321
322    fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
323        let label = CodeLabel {
324            filter_range: 1..mat.string.len() + 1,
325            text: format!("@{}", mat.string),
326            runs: Vec::new(),
327        };
328        (mat.string.clone(), label)
329    }
330
331    fn completion_for_emoji(mat: &StringMatch) -> (String, CodeLabel) {
332        let emoji = emojis::get_by_shortcode(&mat.string).unwrap();
333        let label = CodeLabel {
334            filter_range: 1..mat.string.len() + 1,
335            text: format!(":{}: {}", mat.string, emoji),
336            runs: Vec::new(),
337        };
338        (emoji.to_string(), label)
339    }
340
341    fn collect_mention_candidates(
342        &mut self,
343        buffer: &Entity<Buffer>,
344        end_anchor: Anchor,
345        cx: &mut Context<Self>,
346    ) -> Option<(Anchor, String, Vec<StringMatchCandidate>)> {
347        let end_offset = end_anchor.to_offset(buffer.read(cx));
348
349        let query = buffer.update(cx, |buffer, _| {
350            let mut query = String::new();
351            for ch in buffer.reversed_chars_at(end_offset).take(100) {
352                if ch == '@' {
353                    return Some(query.chars().rev().collect::<String>());
354                }
355                if ch.is_whitespace() || !ch.is_ascii() {
356                    break;
357                }
358                query.push(ch);
359            }
360            None
361        })?;
362
363        let start_offset = end_offset - query.len();
364        let start_anchor = buffer.read(cx).anchor_before(start_offset);
365
366        let mut names = HashSet::default();
367        if let Some(chat) = self.channel_chat.as_ref() {
368            let chat = chat.read(cx);
369            for participant in ChannelStore::global(cx)
370                .read(cx)
371                .channel_participants(chat.channel_id)
372            {
373                names.insert(participant.github_login.clone());
374            }
375            for message in chat
376                .messages_in_range(chat.message_count().saturating_sub(100)..chat.message_count())
377            {
378                names.insert(message.sender.github_login.clone());
379            }
380        }
381
382        let candidates = names
383            .into_iter()
384            .map(|user| StringMatchCandidate::new(0, &user))
385            .collect::<Vec<_>>();
386
387        Some((start_anchor, query, candidates))
388    }
389
390    fn collect_emoji_candidates(
391        &mut self,
392        buffer: &Entity<Buffer>,
393        end_anchor: Anchor,
394        cx: &mut Context<Self>,
395    ) -> Option<(Anchor, String, &'static [StringMatchCandidate])> {
396        static EMOJI_FUZZY_MATCH_CANDIDATES: LazyLock<Vec<StringMatchCandidate>> =
397            LazyLock::new(|| {
398                let emojis = emojis::iter()
399                    .flat_map(|s| s.shortcodes())
400                    .map(|emoji| StringMatchCandidate::new(0, emoji))
401                    .collect::<Vec<_>>();
402                emojis
403            });
404
405        let end_offset = end_anchor.to_offset(buffer.read(cx));
406
407        let query = buffer.update(cx, |buffer, _| {
408            let mut query = String::new();
409            for ch in buffer.reversed_chars_at(end_offset).take(100) {
410                if ch == ':' {
411                    let next_char = buffer
412                        .reversed_chars_at(end_offset - query.len() - 1)
413                        .next();
414                    // Ensure we are at the start of the message or that the previous character is a whitespace
415                    if next_char.is_none() || next_char.unwrap().is_whitespace() {
416                        return Some(query.chars().rev().collect::<String>());
417                    }
418
419                    // If the previous character is not a whitespace, we are in the middle of a word
420                    // and we only want to complete the shortcode if the word is made up of other emojis
421                    let mut containing_word = String::new();
422                    for ch in buffer
423                        .reversed_chars_at(end_offset - query.len() - 1)
424                        .take(100)
425                    {
426                        if ch.is_whitespace() {
427                            break;
428                        }
429                        containing_word.push(ch);
430                    }
431                    let containing_word = containing_word.chars().rev().collect::<String>();
432                    if util::word_consists_of_emojis(containing_word.as_str()) {
433                        return Some(query.chars().rev().collect::<String>());
434                    }
435                    break;
436                }
437                if ch.is_whitespace() || !ch.is_ascii() {
438                    break;
439                }
440                query.push(ch);
441            }
442            None
443        })?;
444
445        let start_offset = end_offset - query.len() - 1;
446        let start_anchor = buffer.read(cx).anchor_before(start_offset);
447
448        Some((start_anchor, query, &EMOJI_FUZZY_MATCH_CANDIDATES))
449    }
450
451    async fn find_mentions(
452        this: WeakEntity<MessageEditor>,
453        buffer: BufferSnapshot,
454        cx: &mut AsyncWindowContext,
455    ) {
456        let (buffer, ranges) = cx
457            .background_spawn(async move {
458                let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
459                (buffer, ranges)
460            })
461            .await;
462
463        this.update(cx, |this, cx| {
464            let mut anchor_ranges = Vec::new();
465            let mut mentioned_user_ids = Vec::new();
466            let mut text = String::new();
467
468            this.editor.update(cx, |editor, cx| {
469                let multi_buffer = editor.buffer().read(cx).snapshot(cx);
470                for range in ranges {
471                    text.clear();
472                    text.extend(buffer.text_for_range(range.clone()));
473                    if let Some(username) = text.strip_prefix('@') {
474                        if let Some(user) = this
475                            .user_store
476                            .read(cx)
477                            .cached_user_by_github_login(username)
478                        {
479                            let start = multi_buffer.anchor_after(range.start);
480                            let end = multi_buffer.anchor_after(range.end);
481
482                            mentioned_user_ids.push(user.id);
483                            anchor_ranges.push(start..end);
484                        }
485                    }
486                }
487
488                editor.clear_highlights::<Self>(cx);
489                editor.highlight_text::<Self>(
490                    anchor_ranges,
491                    HighlightStyle {
492                        font_weight: Some(FontWeight::BOLD),
493                        ..Default::default()
494                    },
495                    cx,
496                )
497            });
498
499            this.mentions = mentioned_user_ids;
500            this.mentions_task.take();
501        })
502        .ok();
503    }
504
505    pub(crate) fn focus_handle(&self, cx: &gpui::App) -> gpui::FocusHandle {
506        self.editor.read(cx).focus_handle(cx)
507    }
508}
509
510impl Render for MessageEditor {
511    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
512        let settings = ThemeSettings::get_global(cx);
513        let text_style = TextStyle {
514            color: if self.editor.read(cx).read_only(cx) {
515                cx.theme().colors().text_disabled
516            } else {
517                cx.theme().colors().text
518            },
519            font_family: settings.ui_font.family.clone(),
520            font_features: settings.ui_font.features.clone(),
521            font_fallbacks: settings.ui_font.fallbacks.clone(),
522            font_size: TextSize::Small.rems(cx).into(),
523            font_weight: settings.ui_font.weight,
524            font_style: FontStyle::Normal,
525            line_height: relative(1.3),
526            ..Default::default()
527        };
528
529        div()
530            .w_full()
531            .px_2()
532            .py_1()
533            .bg(cx.theme().colors().editor_background)
534            .rounded_sm()
535            .child(EditorElement::new(
536                &self.editor,
537                EditorStyle {
538                    local_player: cx.theme().players().local(),
539                    text: text_style,
540                    ..Default::default()
541                },
542            ))
543    }
544}