message_editor.rs

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