1use anyhow::Result;
2use channel::{ChannelMembership, ChannelStore, MessageParams};
3use client::{ChannelId, UserId};
4use collections::{HashMap, HashSet};
5use editor::{AnchorRangeExt, CompletionProvider, Editor, EditorElement, EditorStyle};
6use fuzzy::{StringMatch, StringMatchCandidate};
7use gpui::{
8 AsyncWindowContext, FocusableView, FontStyle, FontWeight, HighlightStyle, IntoElement, Model,
9 Render, SharedString, 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 channel_store: Model<ChannelStore>,
35 channel_members: HashMap<String, UserId>,
36 mentions: Vec<UserId>,
37 mentions_task: Option<Task<()>>,
38 channel_id: Option<ChannelId>,
39 reply_to_message_id: Option<u64>,
40 edit_message_id: Option<u64>,
41}
42
43struct MessageEditorCompletionProvider(WeakView<MessageEditor>);
44
45impl CompletionProvider for MessageEditorCompletionProvider {
46 fn completions(
47 &self,
48 buffer: &Model<Buffer>,
49 buffer_position: language::Anchor,
50 cx: &mut ViewContext<Editor>,
51 ) -> Task<anyhow::Result<Vec<Completion>>> {
52 let Some(handle) = self.0.upgrade() else {
53 return Task::ready(Ok(Vec::new()));
54 };
55 handle.update(cx, |message_editor, cx| {
56 message_editor.completions(buffer, buffer_position, cx)
57 })
58 }
59
60 fn resolve_completions(
61 &self,
62 _buffer: Model<Buffer>,
63 _completion_indices: Vec<usize>,
64 _completions: Arc<RwLock<Box<[Completion]>>>,
65 _cx: &mut ViewContext<Editor>,
66 ) -> Task<anyhow::Result<bool>> {
67 Task::ready(Ok(false))
68 }
69
70 fn apply_additional_edits_for_completion(
71 &self,
72 _buffer: Model<Buffer>,
73 _completion: Completion,
74 _push_to_history: bool,
75 _cx: &mut ViewContext<Editor>,
76 ) -> Task<Result<Option<language::Transaction>>> {
77 Task::ready(Ok(None))
78 }
79}
80
81impl MessageEditor {
82 pub fn new(
83 language_registry: Arc<LanguageRegistry>,
84 channel_store: Model<ChannelStore>,
85 editor: View<Editor>,
86 cx: &mut ViewContext<Self>,
87 ) -> Self {
88 let this = cx.view().downgrade();
89 editor.update(cx, |editor, cx| {
90 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
91 editor.set_use_autoclose(false);
92 editor.set_completion_provider(Box::new(MessageEditorCompletionProvider(this)));
93 editor.set_auto_replace_emoji_shortcode(
94 MessageEditorSettings::get_global(cx)
95 .auto_replace_emoji_shortcode
96 .unwrap_or_default(),
97 );
98 });
99
100 let buffer = editor
101 .read(cx)
102 .buffer()
103 .read(cx)
104 .as_singleton()
105 .expect("message editor must be singleton");
106
107 cx.subscribe(&buffer, Self::on_buffer_event).detach();
108 cx.observe_global::<settings::SettingsStore>(|view, cx| {
109 view.editor.update(cx, |editor, cx| {
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 .detach();
118
119 let markdown = language_registry.language_for_name("Markdown");
120 cx.spawn(|_, mut cx| async move {
121 let markdown = markdown.await?;
122 buffer.update(&mut cx, |buffer, cx| {
123 buffer.set_language(Some(markdown), cx)
124 })
125 })
126 .detach_and_log_err(cx);
127
128 Self {
129 editor,
130 channel_store,
131 channel_members: HashMap::default(),
132 channel_id: None,
133 mentions: Vec::new(),
134 mentions_task: None,
135 reply_to_message_id: None,
136 edit_message_id: None,
137 }
138 }
139
140 pub fn reply_to_message_id(&self) -> Option<u64> {
141 self.reply_to_message_id
142 }
143
144 pub fn set_reply_to_message_id(&mut self, reply_to_message_id: u64) {
145 self.reply_to_message_id = Some(reply_to_message_id);
146 }
147
148 pub fn clear_reply_to_message_id(&mut self) {
149 self.reply_to_message_id = None;
150 }
151
152 pub fn edit_message_id(&self) -> Option<u64> {
153 self.edit_message_id
154 }
155
156 pub fn set_edit_message_id(&mut self, edit_message_id: u64) {
157 self.edit_message_id = Some(edit_message_id);
158 }
159
160 pub fn clear_edit_message_id(&mut self) {
161 self.edit_message_id = None;
162 }
163
164 pub fn set_channel(
165 &mut self,
166 channel_id: ChannelId,
167 channel_name: Option<SharedString>,
168 cx: &mut ViewContext<Self>,
169 ) {
170 self.editor.update(cx, |editor, cx| {
171 if let Some(channel_name) = channel_name {
172 editor.set_placeholder_text(format!("Message #{channel_name}"), cx);
173 } else {
174 editor.set_placeholder_text("Message Channel", cx);
175 }
176 });
177 self.channel_id = Some(channel_id);
178 self.refresh_users(cx);
179 }
180
181 pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
182 if let Some(channel_id) = self.channel_id {
183 let members = self.channel_store.update(cx, |store, cx| {
184 store.get_channel_member_details(channel_id, cx)
185 });
186 cx.spawn(|this, mut cx| async move {
187 let members = members.await?;
188 this.update(&mut cx, |this, cx| this.set_members(members, cx))?;
189 anyhow::Ok(())
190 })
191 .detach_and_log_err(cx);
192 }
193 }
194
195 pub fn set_members(&mut self, members: Vec<ChannelMembership>, _: &mut ViewContext<Self>) {
196 self.channel_members.clear();
197 self.channel_members.extend(
198 members
199 .into_iter()
200 .map(|member| (member.user.github_login.clone(), member.user.id)),
201 );
202 }
203
204 pub fn take_message(&mut self, cx: &mut ViewContext<Self>) -> MessageParams {
205 self.editor.update(cx, |editor, cx| {
206 let highlights = editor.text_highlights::<Self>(cx);
207 let text = editor.text(cx);
208 let snapshot = editor.buffer().read(cx).snapshot(cx);
209 let mentions = if let Some((_, ranges)) = highlights {
210 ranges
211 .iter()
212 .map(|range| range.to_offset(&snapshot))
213 .zip(self.mentions.iter().copied())
214 .collect()
215 } else {
216 Vec::new()
217 };
218
219 editor.clear(cx);
220 self.mentions.clear();
221 let reply_to_message_id = std::mem::take(&mut self.reply_to_message_id);
222
223 MessageParams {
224 text,
225 mentions,
226 reply_to_message_id,
227 }
228 })
229 }
230
231 fn on_buffer_event(
232 &mut self,
233 buffer: Model<Buffer>,
234 event: &language::Event,
235 cx: &mut ViewContext<Self>,
236 ) {
237 if let language::Event::Reparsed | language::Event::Edited = event {
238 let buffer = buffer.read(cx).snapshot();
239 self.mentions_task = Some(cx.spawn(|this, cx| async move {
240 cx.background_executor()
241 .timer(MENTIONS_DEBOUNCE_INTERVAL)
242 .await;
243 Self::find_mentions(this, buffer, cx).await;
244 }));
245 }
246 }
247
248 fn completions(
249 &mut self,
250 buffer: &Model<Buffer>,
251 end_anchor: Anchor,
252 cx: &mut ViewContext<Self>,
253 ) -> Task<Result<Vec<Completion>>> {
254 if let Some((start_anchor, query, candidates)) =
255 self.collect_mention_candidates(buffer, end_anchor, cx)
256 {
257 if !candidates.is_empty() {
258 return cx.spawn(|_, cx| async move {
259 Ok(Self::resolve_completions_for_candidates(
260 &cx,
261 query.as_str(),
262 &candidates,
263 start_anchor..end_anchor,
264 Self::completion_for_mention,
265 )
266 .await)
267 });
268 }
269 }
270
271 if let Some((start_anchor, query, candidates)) =
272 self.collect_emoji_candidates(buffer, end_anchor, cx)
273 {
274 if !candidates.is_empty() {
275 return cx.spawn(|_, cx| async move {
276 Ok(Self::resolve_completions_for_candidates(
277 &cx,
278 query.as_str(),
279 candidates,
280 start_anchor..end_anchor,
281 Self::completion_for_emoji,
282 )
283 .await)
284 });
285 }
286 }
287
288 Task::ready(Ok(vec![]))
289 }
290
291 async fn resolve_completions_for_candidates(
292 cx: &AsyncWindowContext,
293 query: &str,
294 candidates: &[StringMatchCandidate],
295 range: Range<Anchor>,
296 completion_fn: impl Fn(&StringMatch) -> (String, CodeLabel),
297 ) -> Vec<Completion> {
298 let matches = fuzzy::match_strings(
299 &candidates,
300 &query,
301 true,
302 10,
303 &Default::default(),
304 cx.background_executor().clone(),
305 )
306 .await;
307
308 matches
309 .into_iter()
310 .map(|mat| {
311 let (new_text, label) = completion_fn(&mat);
312 Completion {
313 old_range: range.clone(),
314 new_text,
315 label,
316 documentation: None,
317 server_id: LanguageServerId(0), // TODO: Make this optional or something?
318 lsp_completion: Default::default(), // TODO: Make this optional or something?
319 }
320 })
321 .collect()
322 }
323
324 fn completion_for_mention(mat: &StringMatch) -> (String, CodeLabel) {
325 let label = CodeLabel {
326 filter_range: 1..mat.string.len() + 1,
327 text: format!("@{}", mat.string),
328 runs: Vec::new(),
329 };
330 (mat.string.clone(), label)
331 }
332
333 fn completion_for_emoji(mat: &StringMatch) -> (String, CodeLabel) {
334 let emoji = emojis::get_by_shortcode(&mat.string).unwrap();
335 let label = CodeLabel {
336 filter_range: 1..mat.string.len() + 1,
337 text: format!(":{}: {}", mat.string, emoji),
338 runs: Vec::new(),
339 };
340 (emoji.to_string(), label)
341 }
342
343 fn collect_mention_candidates(
344 &mut self,
345 buffer: &Model<Buffer>,
346 end_anchor: Anchor,
347 cx: &mut ViewContext<Self>,
348 ) -> Option<(Anchor, String, Vec<StringMatchCandidate>)> {
349 let end_offset = end_anchor.to_offset(buffer.read(cx));
350
351 let Some(query) = buffer.update(cx, |buffer, _| {
352 let mut query = String::new();
353 for ch in buffer.reversed_chars_at(end_offset).take(100) {
354 if ch == '@' {
355 return Some(query.chars().rev().collect::<String>());
356 }
357 if ch.is_whitespace() || !ch.is_ascii() {
358 break;
359 }
360 query.push(ch);
361 }
362 None
363 }) else {
364 return None;
365 };
366
367 let start_offset = end_offset - query.len();
368 let start_anchor = buffer.read(cx).anchor_before(start_offset);
369
370 let mut names = HashSet::default();
371 for (github_login, _) in self.channel_members.iter() {
372 names.insert(github_login.clone());
373 }
374 if let Some(channel_id) = self.channel_id {
375 for participant in self.channel_store.read(cx).channel_participants(channel_id) {
376 names.insert(participant.github_login.clone());
377 }
378 }
379
380 let candidates = names
381 .into_iter()
382 .map(|user| StringMatchCandidate {
383 id: 0,
384 string: user.clone(),
385 char_bag: user.chars().collect(),
386 })
387 .collect::<Vec<_>>();
388
389 Some((start_anchor, query, candidates))
390 }
391
392 fn collect_emoji_candidates(
393 &mut self,
394 buffer: &Model<Buffer>,
395 end_anchor: Anchor,
396 cx: &mut ViewContext<Self>,
397 ) -> Option<(Anchor, String, &'static [StringMatchCandidate])> {
398 lazy_static! {
399 static ref EMOJI_FUZZY_MATCH_CANDIDATES: Vec<StringMatchCandidate> = {
400 let emojis = emojis::iter()
401 .flat_map(|s| s.shortcodes())
402 .map(|emoji| StringMatchCandidate {
403 id: 0,
404 string: emoji.to_string(),
405 char_bag: emoji.chars().collect(),
406 })
407 .collect::<Vec<_>>();
408 emojis
409 };
410 }
411
412 let end_offset = end_anchor.to_offset(buffer.read(cx));
413
414 let Some(query) = buffer.update(cx, |buffer, _| {
415 let mut query = String::new();
416 for ch in buffer.reversed_chars_at(end_offset).take(100) {
417 if ch == ':' {
418 let next_char = buffer
419 .reversed_chars_at(end_offset - query.len() - 1)
420 .next();
421 // Ensure we are at the start of the message or that the previous character is a whitespace
422 if next_char.is_none() || next_char.unwrap().is_whitespace() {
423 return Some(query.chars().rev().collect::<String>());
424 }
425
426 // If the previous character is not a whitespace, we are in the middle of a word
427 // and we only want to complete the shortcode if the word is made up of other emojis
428 let mut containing_word = String::new();
429 for ch in buffer
430 .reversed_chars_at(end_offset - query.len() - 1)
431 .take(100)
432 {
433 if ch.is_whitespace() {
434 break;
435 }
436 containing_word.push(ch);
437 }
438 let containing_word = containing_word.chars().rev().collect::<String>();
439 if util::word_consists_of_emojis(containing_word.as_str()) {
440 return Some(query.chars().rev().collect::<String>());
441 }
442 break;
443 }
444 if ch.is_whitespace() || !ch.is_ascii() {
445 break;
446 }
447 query.push(ch);
448 }
449 None
450 }) else {
451 return None;
452 };
453
454 let start_offset = end_offset - query.len() - 1;
455 let start_anchor = buffer.read(cx).anchor_before(start_offset);
456
457 Some((start_anchor, query, &EMOJI_FUZZY_MATCH_CANDIDATES))
458 }
459
460 async fn find_mentions(
461 this: WeakView<MessageEditor>,
462 buffer: BufferSnapshot,
463 mut cx: AsyncWindowContext,
464 ) {
465 let (buffer, ranges) = cx
466 .background_executor()
467 .spawn(async move {
468 let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
469 (buffer, ranges)
470 })
471 .await;
472
473 this.update(&mut cx, |this, cx| {
474 let mut anchor_ranges = Vec::new();
475 let mut mentioned_user_ids = Vec::new();
476 let mut text = String::new();
477
478 this.editor.update(cx, |editor, cx| {
479 let multi_buffer = editor.buffer().read(cx).snapshot(cx);
480 for range in ranges {
481 text.clear();
482 text.extend(buffer.text_for_range(range.clone()));
483 if let Some(username) = text.strip_prefix('@') {
484 if let Some(user_id) = this.channel_members.get(username) {
485 let start = multi_buffer.anchor_after(range.start);
486 let end = multi_buffer.anchor_after(range.end);
487
488 mentioned_user_ids.push(*user_id);
489 anchor_ranges.push(start..end);
490 }
491 }
492 }
493
494 editor.clear_highlights::<Self>(cx);
495 editor.highlight_text::<Self>(
496 anchor_ranges,
497 HighlightStyle {
498 font_weight: Some(FontWeight::BOLD),
499 ..Default::default()
500 },
501 cx,
502 )
503 });
504
505 this.mentions = mentioned_user_ids;
506 this.mentions_task.take();
507 })
508 .ok();
509 }
510
511 pub(crate) fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
512 self.editor.read(cx).focus_handle(cx)
513 }
514}
515
516impl Render for MessageEditor {
517 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
518 let settings = ThemeSettings::get_global(cx);
519 let text_style = TextStyle {
520 color: if self.editor.read(cx).read_only(cx) {
521 cx.theme().colors().text_disabled
522 } else {
523 cx.theme().colors().text
524 },
525 font_family: settings.ui_font.family.clone(),
526 font_features: settings.ui_font.features.clone(),
527 font_size: TextSize::Small.rems(cx).into(),
528 font_weight: FontWeight::NORMAL,
529 font_style: FontStyle::Normal,
530 line_height: relative(1.3),
531 background_color: None,
532 underline: None,
533 strikethrough: None,
534 white_space: WhiteSpace::Normal,
535 };
536
537 div()
538 .w_full()
539 .px_2()
540 .py_1()
541 .bg(cx.theme().colors().editor_background)
542 .rounded_md()
543 .child(EditorElement::new(
544 &self.editor,
545 EditorStyle {
546 local_player: cx.theme().players().local(),
547 text: text_style,
548 ..Default::default()
549 },
550 ))
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use client::{Client, User, UserStore};
558 use clock::FakeSystemClock;
559 use gpui::TestAppContext;
560 use language::{Language, LanguageConfig};
561 use project::Project;
562 use rpc::proto;
563 use settings::SettingsStore;
564 use util::{http::FakeHttpClient, test::marked_text_ranges};
565
566 #[gpui::test]
567 async fn test_message_editor(cx: &mut TestAppContext) {
568 let language_registry = init_test(cx);
569
570 let (editor, cx) = cx.add_window_view(|cx| {
571 MessageEditor::new(
572 language_registry,
573 ChannelStore::global(cx),
574 cx.new_view(|cx| Editor::auto_height(4, cx)),
575 cx,
576 )
577 });
578 cx.executor().run_until_parked();
579
580 editor.update(cx, |editor, cx| {
581 editor.set_members(
582 vec![
583 ChannelMembership {
584 user: Arc::new(User {
585 github_login: "a-b".into(),
586 id: 101,
587 avatar_uri: "avatar_a-b".into(),
588 }),
589 kind: proto::channel_member::Kind::Member,
590 role: proto::ChannelRole::Member,
591 },
592 ChannelMembership {
593 user: Arc::new(User {
594 github_login: "C_D".into(),
595 id: 102,
596 avatar_uri: "avatar_C_D".into(),
597 }),
598 kind: proto::channel_member::Kind::Member,
599 role: proto::ChannelRole::Member,
600 },
601 ],
602 cx,
603 );
604
605 editor.editor.update(cx, |editor, cx| {
606 editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
607 });
608 });
609
610 cx.executor().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
611
612 editor.update(cx, |editor, cx| {
613 let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
614 assert_eq!(
615 editor.take_message(cx),
616 MessageParams {
617 text,
618 mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
619 reply_to_message_id: None
620 }
621 );
622 });
623 }
624
625 fn init_test(cx: &mut TestAppContext) -> Arc<LanguageRegistry> {
626 cx.update(|cx| {
627 let settings = SettingsStore::test(cx);
628 cx.set_global(settings);
629
630 let clock = Arc::new(FakeSystemClock::default());
631 let http = FakeHttpClient::with_404_response();
632 let client = Client::new(clock, http.clone(), cx);
633 let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
634 theme::init(theme::LoadThemes::JustBase, cx);
635 Project::init_settings(cx);
636 language::init(cx);
637 editor::init(cx);
638 client::init(&client, cx);
639 channel::init(&client, user_store, cx);
640
641 MessageEditorSettings::register(cx);
642 });
643
644 let language_registry = Arc::new(LanguageRegistry::test(cx.executor()));
645 language_registry.add(Arc::new(Language::new(
646 LanguageConfig {
647 name: "Markdown".into(),
648 ..Default::default()
649 },
650 Some(tree_sitter_markdown::language()),
651 )));
652 language_registry
653 }
654}