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}