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