message_editor.rs

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