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::*, 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}