1use std::{sync::Arc, time::Duration};
2
3use channel::{ChannelId, ChannelMembership, ChannelStore, MessageParams};
4use client::UserId;
5use collections::HashMap;
6use editor::{AnchorRangeExt, Editor, EditorElement, EditorStyle};
7use gpui::{
8 AsyncWindowContext, FocusableView, FontStyle, FontWeight, HighlightStyle, IntoElement, Model,
9 Render, SharedString, Task, TextStyle, View, ViewContext, WeakView, WhiteSpace,
10};
11use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry};
12use lazy_static::lazy_static;
13use project::search::SearchQuery;
14use settings::Settings;
15use theme::ThemeSettings;
16use ui::prelude::*;
17
18const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
19
20lazy_static! {
21 static ref MENTIONS_SEARCH: SearchQuery =
22 SearchQuery::regex("@[-_\\w]+", false, false, false, Vec::new(), Vec::new()).unwrap();
23}
24
25pub struct MessageEditor {
26 pub editor: View<Editor>,
27 channel_store: Model<ChannelStore>,
28 users: HashMap<String, UserId>,
29 mentions: Vec<UserId>,
30 mentions_task: Option<Task<()>>,
31 channel_id: Option<ChannelId>,
32}
33
34impl MessageEditor {
35 pub fn new(
36 language_registry: Arc<LanguageRegistry>,
37 channel_store: Model<ChannelStore>,
38 editor: View<Editor>,
39 cx: &mut ViewContext<Self>,
40 ) -> Self {
41 editor.update(cx, |editor, cx| {
42 editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
43 });
44
45 let buffer = editor
46 .read(cx)
47 .buffer()
48 .read(cx)
49 .as_singleton()
50 .expect("message editor must be singleton");
51
52 cx.subscribe(&buffer, Self::on_buffer_event).detach();
53
54 let markdown = language_registry.language_for_name("Markdown");
55 cx.spawn(|_, mut cx| async move {
56 let markdown = markdown.await?;
57 buffer.update(&mut cx, |buffer, cx| {
58 buffer.set_language(Some(markdown), cx)
59 })
60 })
61 .detach_and_log_err(cx);
62
63 Self {
64 editor,
65 channel_store,
66 users: HashMap::default(),
67 channel_id: None,
68 mentions: Vec::new(),
69 mentions_task: None,
70 }
71 }
72
73 pub fn set_channel(
74 &mut self,
75 channel_id: u64,
76 channel_name: Option<SharedString>,
77 cx: &mut ViewContext<Self>,
78 ) {
79 self.editor.update(cx, |editor, cx| {
80 if let Some(channel_name) = channel_name {
81 editor.set_placeholder_text(format!("Message #{}", channel_name), cx);
82 } else {
83 editor.set_placeholder_text(format!("Message Channel"), cx);
84 }
85 });
86 self.channel_id = Some(channel_id);
87 self.refresh_users(cx);
88 }
89
90 pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
91 if let Some(channel_id) = self.channel_id {
92 let members = self.channel_store.update(cx, |store, cx| {
93 store.get_channel_member_details(channel_id, cx)
94 });
95 cx.spawn(|this, mut cx| async move {
96 let members = members.await?;
97 this.update(&mut cx, |this, cx| this.set_members(members, cx))?;
98 anyhow::Ok(())
99 })
100 .detach_and_log_err(cx);
101 }
102 }
103
104 pub fn set_members(&mut self, members: Vec<ChannelMembership>, _: &mut ViewContext<Self>) {
105 self.users.clear();
106 self.users.extend(
107 members
108 .into_iter()
109 .map(|member| (member.user.github_login.clone(), member.user.id)),
110 );
111 }
112
113 pub fn take_message(&mut self, cx: &mut ViewContext<Self>) -> MessageParams {
114 self.editor.update(cx, |editor, cx| {
115 let highlights = editor.text_highlights::<Self>(cx);
116 let text = editor.text(cx);
117 let snapshot = editor.buffer().read(cx).snapshot(cx);
118 let mentions = if let Some((_, ranges)) = highlights {
119 ranges
120 .iter()
121 .map(|range| range.to_offset(&snapshot))
122 .zip(self.mentions.iter().copied())
123 .collect()
124 } else {
125 Vec::new()
126 };
127
128 editor.clear(cx);
129 self.mentions.clear();
130
131 MessageParams { text, mentions }
132 })
133 }
134
135 fn on_buffer_event(
136 &mut self,
137 buffer: Model<Buffer>,
138 event: &language::Event,
139 cx: &mut ViewContext<Self>,
140 ) {
141 if let language::Event::Reparsed | language::Event::Edited = event {
142 let buffer = buffer.read(cx).snapshot();
143 self.mentions_task = Some(cx.spawn(|this, cx| async move {
144 cx.background_executor()
145 .timer(MENTIONS_DEBOUNCE_INTERVAL)
146 .await;
147 Self::find_mentions(this, buffer, cx).await;
148 }));
149 }
150 }
151
152 async fn find_mentions(
153 this: WeakView<MessageEditor>,
154 buffer: BufferSnapshot,
155 mut cx: AsyncWindowContext,
156 ) {
157 let (buffer, ranges) = cx
158 .background_executor()
159 .spawn(async move {
160 let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
161 (buffer, ranges)
162 })
163 .await;
164
165 this.update(&mut cx, |this, cx| {
166 let mut anchor_ranges = Vec::new();
167 let mut mentioned_user_ids = Vec::new();
168 let mut text = String::new();
169
170 this.editor.update(cx, |editor, cx| {
171 let multi_buffer = editor.buffer().read(cx).snapshot(cx);
172 for range in ranges {
173 text.clear();
174 text.extend(buffer.text_for_range(range.clone()));
175 if let Some(username) = text.strip_prefix("@") {
176 if let Some(user_id) = this.users.get(username) {
177 let start = multi_buffer.anchor_after(range.start);
178 let end = multi_buffer.anchor_after(range.end);
179
180 mentioned_user_ids.push(*user_id);
181 anchor_ranges.push(start..end);
182 }
183 }
184 }
185
186 editor.clear_highlights::<Self>(cx);
187 editor.highlight_text::<Self>(
188 anchor_ranges,
189 HighlightStyle {
190 font_weight: Some(FontWeight::BOLD),
191 ..Default::default()
192 },
193 cx,
194 )
195 });
196
197 this.mentions = mentioned_user_ids;
198 this.mentions_task.take();
199 })
200 .ok();
201 }
202
203 pub(crate) fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
204 self.editor.read(cx).focus_handle(cx)
205 }
206}
207
208impl Render for MessageEditor {
209 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
210 let settings = ThemeSettings::get_global(cx);
211 let text_style = TextStyle {
212 color: if self.editor.read(cx).read_only(cx) {
213 cx.theme().colors().text_disabled
214 } else {
215 cx.theme().colors().text
216 },
217 font_family: settings.ui_font.family.clone(),
218 font_features: settings.ui_font.features,
219 font_size: rems(0.875).into(),
220 font_weight: FontWeight::NORMAL,
221 font_style: FontStyle::Normal,
222 line_height: relative(1.3).into(),
223 background_color: None,
224 underline: None,
225 white_space: WhiteSpace::Normal,
226 };
227
228 div()
229 .w_full()
230 .px_2()
231 .py_1()
232 .bg(cx.theme().colors().editor_background)
233 .rounded_md()
234 .child(EditorElement::new(
235 &self.editor,
236 EditorStyle {
237 local_player: cx.theme().players().local(),
238 text: text_style,
239 ..Default::default()
240 },
241 ))
242 }
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use client::{Client, User, UserStore};
249 use gpui::TestAppContext;
250 use language::{Language, LanguageConfig};
251 use rpc::proto;
252 use settings::SettingsStore;
253 use util::{http::FakeHttpClient, test::marked_text_ranges};
254
255 #[gpui::test]
256 async fn test_message_editor(cx: &mut TestAppContext) {
257 let language_registry = init_test(cx);
258
259 let (editor, cx) = cx.add_window_view(|cx| {
260 MessageEditor::new(
261 language_registry,
262 ChannelStore::global(cx),
263 cx.new_view(|cx| Editor::auto_height(4, cx)),
264 cx,
265 )
266 });
267 cx.executor().run_until_parked();
268
269 editor.update(cx, |editor, cx| {
270 editor.set_members(
271 vec![
272 ChannelMembership {
273 user: Arc::new(User {
274 github_login: "a-b".into(),
275 id: 101,
276 avatar_uri: "avatar_a-b".into(),
277 }),
278 kind: proto::channel_member::Kind::Member,
279 role: proto::ChannelRole::Member,
280 },
281 ChannelMembership {
282 user: Arc::new(User {
283 github_login: "C_D".into(),
284 id: 102,
285 avatar_uri: "avatar_C_D".into(),
286 }),
287 kind: proto::channel_member::Kind::Member,
288 role: proto::ChannelRole::Member,
289 },
290 ],
291 cx,
292 );
293
294 editor.editor.update(cx, |editor, cx| {
295 editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
296 });
297 });
298
299 cx.executor().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
300
301 editor.update(cx, |editor, cx| {
302 let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
303 assert_eq!(
304 editor.take_message(cx),
305 MessageParams {
306 text,
307 mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
308 }
309 );
310 });
311 }
312
313 fn init_test(cx: &mut TestAppContext) -> Arc<LanguageRegistry> {
314 cx.update(|cx| {
315 let settings = SettingsStore::test(cx);
316 cx.set_global(settings);
317
318 let http = FakeHttpClient::with_404_response();
319 let client = Client::new(http.clone(), cx);
320 let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
321 theme::init(theme::LoadThemes::JustBase, cx);
322 language::init(cx);
323 editor::init(cx);
324 client::init(&client, cx);
325 channel::init(&client, user_store, cx);
326 });
327
328 let language_registry = Arc::new(LanguageRegistry::test());
329 language_registry.add(Arc::new(Language::new(
330 LanguageConfig {
331 name: "Markdown".into(),
332 ..Default::default()
333 },
334 Some(tree_sitter_markdown::language()),
335 )));
336 language_registry
337 }
338}