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}