1use channel::{Channel, ChannelMembership, ChannelStore};
2use client::UserId;
3use collections::HashMap;
4use editor::{AnchorRangeExt, Editor};
5use gpui::{
6 elements::ChildView, AnyElement, AsyncAppContext, Element, Entity, ModelHandle, Task, View,
7 ViewContext, ViewHandle, WeakViewHandle,
8};
9use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry};
10use lazy_static::lazy_static;
11use project::search::SearchQuery;
12use std::{ops::Range, sync::Arc, time::Duration};
13
14const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
15
16lazy_static! {
17 static ref MENTIONS_SEARCH: SearchQuery = SearchQuery::regex(
18 "@[-_\\w]+",
19 false,
20 false,
21 Default::default(),
22 Default::default()
23 )
24 .unwrap();
25}
26
27pub struct MessageEditor {
28 pub editor: ViewHandle<Editor>,
29 channel_store: ModelHandle<ChannelStore>,
30 users: HashMap<String, UserId>,
31 mentions: Vec<UserId>,
32 mentions_task: Option<Task<()>>,
33 channel: Option<Arc<Channel>>,
34}
35
36#[derive(Debug, PartialEq, Eq)]
37pub struct ChatMessage {
38 pub text: String,
39 pub mentions: Vec<(Range<usize>, UserId)>,
40}
41
42impl MessageEditor {
43 pub fn new(
44 language_registry: Arc<LanguageRegistry>,
45 channel_store: ModelHandle<ChannelStore>,
46 editor: ViewHandle<Editor>,
47 cx: &mut ViewContext<Self>,
48 ) -> Self {
49 editor.update(cx, |editor, cx| {
50 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
51 });
52
53 let buffer = editor
54 .read(cx)
55 .buffer()
56 .read(cx)
57 .as_singleton()
58 .expect("message editor must be singleton");
59
60 cx.subscribe(&buffer, Self::on_buffer_event).detach();
61
62 let markdown = language_registry.language_for_name("Markdown");
63 cx.app_context()
64 .spawn(|mut cx| async move {
65 let markdown = markdown.await?;
66 buffer.update(&mut cx, |buffer, cx| {
67 buffer.set_language(Some(markdown), cx)
68 });
69 anyhow::Ok(())
70 })
71 .detach_and_log_err(cx);
72
73 Self {
74 editor,
75 channel_store,
76 users: HashMap::default(),
77 channel: None,
78 mentions: Vec::new(),
79 mentions_task: None,
80 }
81 }
82
83 pub fn set_channel(&mut self, channel: Arc<Channel>, cx: &mut ViewContext<Self>) {
84 self.editor.update(cx, |editor, cx| {
85 editor.set_placeholder_text(format!("Message #{}", channel.name), cx);
86 });
87 self.channel = Some(channel);
88 self.refresh_users(cx);
89 }
90
91 pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
92 if let Some(channel) = &self.channel {
93 let members = self.channel_store.update(cx, |store, cx| {
94 store.get_channel_member_details(channel.id, cx)
95 });
96 cx.spawn(|this, mut cx| async move {
97 let members = members.await?;
98 this.update(&mut cx, |this, cx| this.set_members(members, cx))?;
99 anyhow::Ok(())
100 })
101 .detach_and_log_err(cx);
102 }
103 }
104
105 pub fn set_members(&mut self, members: Vec<ChannelMembership>, cx: &mut ViewContext<Self>) {
106 self.users.clear();
107 self.users.extend(
108 members
109 .into_iter()
110 .map(|member| (member.user.github_login.clone(), member.user.id)),
111 );
112 }
113
114 pub fn take_message(&mut self, cx: &mut ViewContext<Self>) -> ChatMessage {
115 self.editor.update(cx, |editor, cx| {
116 let highlights = editor.text_highlights::<Self>(cx);
117 let text = editor.text(cx);
118 let snapshot = editor.buffer().read(cx).snapshot(cx);
119 let mentions = if let Some((_, ranges)) = highlights {
120 ranges
121 .iter()
122 .map(|range| range.to_offset(&snapshot))
123 .zip(self.mentions.iter().copied())
124 .collect()
125 } else {
126 Vec::new()
127 };
128
129 editor.clear(cx);
130 self.mentions.clear();
131
132 ChatMessage { text, mentions }
133 })
134 }
135
136 fn on_buffer_event(
137 &mut self,
138 buffer: ModelHandle<Buffer>,
139 event: &language::Event,
140 cx: &mut ViewContext<Self>,
141 ) {
142 if let language::Event::Reparsed | language::Event::Edited = event {
143 let buffer = buffer.read(cx).snapshot();
144 self.mentions_task = Some(cx.spawn(|this, cx| async move {
145 cx.background().timer(MENTIONS_DEBOUNCE_INTERVAL).await;
146 Self::find_mentions(this, buffer, cx).await;
147 }));
148 }
149 }
150
151 async fn find_mentions(
152 this: WeakViewHandle<MessageEditor>,
153 buffer: BufferSnapshot,
154 mut cx: AsyncAppContext,
155 ) {
156 let (buffer, ranges) = cx
157 .background()
158 .spawn(async move {
159 let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
160 (buffer, ranges)
161 })
162 .await;
163
164 this.update(&mut cx, |this, cx| {
165 let mut anchor_ranges = Vec::new();
166 let mut mentioned_user_ids = Vec::new();
167 let mut text = String::new();
168
169 this.editor.update(cx, |editor, cx| {
170 let multi_buffer = editor.buffer().read(cx).snapshot(cx);
171 for range in ranges {
172 text.clear();
173 text.extend(buffer.text_for_range(range.clone()));
174 if let Some(username) = text.strip_prefix("@") {
175 if let Some(user_id) = this.users.get(username) {
176 let start = multi_buffer.anchor_after(range.start);
177 let end = multi_buffer.anchor_after(range.end);
178
179 mentioned_user_ids.push(*user_id);
180 anchor_ranges.push(start..end);
181 }
182 }
183 }
184
185 editor.clear_highlights::<Self>(cx);
186 editor.highlight_text::<Self>(
187 anchor_ranges,
188 theme::current(cx).chat_panel.mention_highlight,
189 cx,
190 )
191 });
192
193 this.mentions = mentioned_user_ids;
194 this.mentions_task.take();
195 })
196 .ok();
197 }
198}
199
200impl Entity for MessageEditor {
201 type Event = ();
202}
203
204impl View for MessageEditor {
205 fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> AnyElement<Self> {
206 ChildView::new(&self.editor, cx).into_any()
207 }
208
209 fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
210 if cx.is_self_focused() {
211 cx.focus(&self.editor);
212 }
213 }
214}
215
216#[cfg(test)]
217mod tests {
218 use super::*;
219 use client::{Client, User, UserStore};
220 use gpui::{TestAppContext, WindowHandle};
221 use language::{Language, LanguageConfig};
222 use rpc::proto;
223 use settings::SettingsStore;
224 use util::{http::FakeHttpClient, test::marked_text_ranges};
225
226 #[gpui::test]
227 async fn test_message_editor(cx: &mut TestAppContext) {
228 let editor = init_test(cx);
229 let editor = editor.root(cx);
230
231 editor.update(cx, |editor, cx| {
232 editor.set_members(
233 vec![
234 ChannelMembership {
235 user: Arc::new(User {
236 github_login: "a-b".into(),
237 id: 101,
238 avatar: None,
239 }),
240 kind: proto::channel_member::Kind::Member,
241 admin: false,
242 },
243 ChannelMembership {
244 user: Arc::new(User {
245 github_login: "C_D".into(),
246 id: 102,
247 avatar: None,
248 }),
249 kind: proto::channel_member::Kind::Member,
250 admin: false,
251 },
252 ],
253 cx,
254 );
255
256 editor.editor.update(cx, |editor, cx| {
257 editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
258 });
259 });
260
261 cx.foreground().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
262
263 editor.update(cx, |editor, cx| {
264 let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
265 assert_eq!(
266 editor.take_message(cx),
267 ChatMessage {
268 text,
269 mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
270 }
271 );
272 });
273 }
274
275 fn init_test(cx: &mut TestAppContext) -> WindowHandle<MessageEditor> {
276 cx.foreground().forbid_parking();
277
278 cx.update(|cx| {
279 let http = FakeHttpClient::with_404_response();
280 let client = Client::new(http.clone(), cx);
281 let user_store = cx.add_model(|cx| UserStore::new(client.clone(), http, cx));
282 cx.set_global(SettingsStore::test(cx));
283 theme::init((), cx);
284 language::init(cx);
285 editor::init(cx);
286 client::init(&client, cx);
287 channel::init(&client, user_store, cx);
288 });
289
290 let language_registry = Arc::new(LanguageRegistry::test());
291 language_registry.add(Arc::new(Language::new(
292 LanguageConfig {
293 name: "Markdown".into(),
294 ..Default::default()
295 },
296 Some(tree_sitter_markdown::language()),
297 )));
298
299 let editor = cx.add_window(|cx| {
300 MessageEditor::new(
301 language_registry,
302 ChannelStore::global(cx),
303 cx.add_view(|cx| Editor::auto_height(4, None, cx)),
304 cx,
305 )
306 });
307 cx.foreground().run_until_parked();
308 editor
309 }
310}