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::*, UiTextSize};
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 editor.set_use_autoclose(false);
44 });
45
46 let buffer = editor
47 .read(cx)
48 .buffer()
49 .read(cx)
50 .as_singleton()
51 .expect("message editor must be singleton");
52
53 cx.subscribe(&buffer, Self::on_buffer_event).detach();
54
55 let markdown = language_registry.language_for_name("Markdown");
56 cx.spawn(|_, mut cx| async move {
57 let markdown = markdown.await?;
58 buffer.update(&mut cx, |buffer, cx| {
59 buffer.set_language(Some(markdown), cx)
60 })
61 })
62 .detach_and_log_err(cx);
63
64 Self {
65 editor,
66 channel_store,
67 users: HashMap::default(),
68 channel_id: None,
69 mentions: Vec::new(),
70 mentions_task: None,
71 }
72 }
73
74 pub fn set_channel(
75 &mut self,
76 channel_id: u64,
77 channel_name: Option<SharedString>,
78 cx: &mut ViewContext<Self>,
79 ) {
80 self.editor.update(cx, |editor, cx| {
81 if let Some(channel_name) = channel_name {
82 editor.set_placeholder_text(format!("Message #{}", channel_name), cx);
83 } else {
84 editor.set_placeholder_text(format!("Message Channel"), cx);
85 }
86 });
87 self.channel_id = Some(channel_id);
88 self.refresh_users(cx);
89 }
90
91 pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
92 if let Some(channel_id) = self.channel_id {
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>, _: &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>) -> MessageParams {
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 MessageParams { text, mentions }
133 })
134 }
135
136 fn on_buffer_event(
137 &mut self,
138 buffer: Model<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_executor()
146 .timer(MENTIONS_DEBOUNCE_INTERVAL)
147 .await;
148 Self::find_mentions(this, buffer, cx).await;
149 }));
150 }
151 }
152
153 async fn find_mentions(
154 this: WeakView<MessageEditor>,
155 buffer: BufferSnapshot,
156 mut cx: AsyncWindowContext,
157 ) {
158 let (buffer, ranges) = cx
159 .background_executor()
160 .spawn(async move {
161 let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
162 (buffer, ranges)
163 })
164 .await;
165
166 this.update(&mut cx, |this, cx| {
167 let mut anchor_ranges = Vec::new();
168 let mut mentioned_user_ids = Vec::new();
169 let mut text = String::new();
170
171 this.editor.update(cx, |editor, cx| {
172 let multi_buffer = editor.buffer().read(cx).snapshot(cx);
173 for range in ranges {
174 text.clear();
175 text.extend(buffer.text_for_range(range.clone()));
176 if let Some(username) = text.strip_prefix("@") {
177 if let Some(user_id) = this.users.get(username) {
178 let start = multi_buffer.anchor_after(range.start);
179 let end = multi_buffer.anchor_after(range.end);
180
181 mentioned_user_ids.push(*user_id);
182 anchor_ranges.push(start..end);
183 }
184 }
185 }
186
187 editor.clear_highlights::<Self>(cx);
188 editor.highlight_text::<Self>(
189 anchor_ranges,
190 HighlightStyle {
191 font_weight: Some(FontWeight::BOLD),
192 ..Default::default()
193 },
194 cx,
195 )
196 });
197
198 this.mentions = mentioned_user_ids;
199 this.mentions_task.take();
200 })
201 .ok();
202 }
203
204 pub(crate) fn focus_handle(&self, cx: &gpui::AppContext) -> gpui::FocusHandle {
205 self.editor.read(cx).focus_handle(cx)
206 }
207}
208
209impl Render for MessageEditor {
210 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
211 let settings = ThemeSettings::get_global(cx);
212 let text_style = TextStyle {
213 color: if self.editor.read(cx).read_only(cx) {
214 cx.theme().colors().text_disabled
215 } else {
216 cx.theme().colors().text
217 },
218 font_family: settings.ui_font.family.clone(),
219 font_features: settings.ui_font.features,
220 font_size: UiTextSize::Small.rems().into(),
221 font_weight: FontWeight::NORMAL,
222 font_style: FontStyle::Normal,
223 line_height: relative(1.3).into(),
224 background_color: None,
225 underline: None,
226 white_space: WhiteSpace::Normal,
227 };
228
229 div()
230 .w_full()
231 .px_2()
232 .py_1()
233 .bg(cx.theme().colors().editor_background)
234 .rounded_md()
235 .child(EditorElement::new(
236 &self.editor,
237 EditorStyle {
238 local_player: cx.theme().players().local(),
239 text: text_style,
240 ..Default::default()
241 },
242 ))
243 }
244}
245
246#[cfg(test)]
247mod tests {
248 use super::*;
249 use client::{Client, User, UserStore};
250 use gpui::TestAppContext;
251 use language::{Language, LanguageConfig};
252 use rpc::proto;
253 use settings::SettingsStore;
254 use util::{http::FakeHttpClient, test::marked_text_ranges};
255
256 #[gpui::test]
257 async fn test_message_editor(cx: &mut TestAppContext) {
258 let language_registry = init_test(cx);
259
260 let (editor, cx) = cx.add_window_view(|cx| {
261 MessageEditor::new(
262 language_registry,
263 ChannelStore::global(cx),
264 cx.new_view(|cx| Editor::auto_height(4, cx)),
265 cx,
266 )
267 });
268 cx.executor().run_until_parked();
269
270 editor.update(cx, |editor, cx| {
271 editor.set_members(
272 vec![
273 ChannelMembership {
274 user: Arc::new(User {
275 github_login: "a-b".into(),
276 id: 101,
277 avatar_uri: "avatar_a-b".into(),
278 }),
279 kind: proto::channel_member::Kind::Member,
280 role: proto::ChannelRole::Member,
281 },
282 ChannelMembership {
283 user: Arc::new(User {
284 github_login: "C_D".into(),
285 id: 102,
286 avatar_uri: "avatar_C_D".into(),
287 }),
288 kind: proto::channel_member::Kind::Member,
289 role: proto::ChannelRole::Member,
290 },
291 ],
292 cx,
293 );
294
295 editor.editor.update(cx, |editor, cx| {
296 editor.set_text("Hello, @a-b! Have you met @C_D?", cx)
297 });
298 });
299
300 cx.executor().advance_clock(MENTIONS_DEBOUNCE_INTERVAL);
301
302 editor.update(cx, |editor, cx| {
303 let (text, ranges) = marked_text_ranges("Hello, «@a-b»! Have you met «@C_D»?", false);
304 assert_eq!(
305 editor.take_message(cx),
306 MessageParams {
307 text,
308 mentions: vec![(ranges[0].clone(), 101), (ranges[1].clone(), 102)],
309 }
310 );
311 });
312 }
313
314 fn init_test(cx: &mut TestAppContext) -> Arc<LanguageRegistry> {
315 cx.update(|cx| {
316 let settings = SettingsStore::test(cx);
317 cx.set_global(settings);
318
319 let http = FakeHttpClient::with_404_response();
320 let client = Client::new(http.clone(), cx);
321 let user_store = cx.new_model(|cx| UserStore::new(client.clone(), cx));
322 theme::init(theme::LoadThemes::JustBase, cx);
323 language::init(cx);
324 editor::init(cx);
325 client::init(&client, cx);
326 channel::init(&client, user_store, cx);
327 });
328
329 let language_registry = Arc::new(LanguageRegistry::test());
330 language_registry.add(Arc::new(Language::new(
331 LanguageConfig {
332 name: "Markdown".into(),
333 ..Default::default()
334 },
335 Some(tree_sitter_markdown::language()),
336 )));
337 language_registry
338 }
339}