message_editor.rs

  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}