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