Start work on chat mentions

Max Brunsfeld created

Change summary

Cargo.lock                                        |   1 
crates/collab/src/db/queries/channels.rs          |  18 +
crates/collab_ui/Cargo.toml                       |   1 
crates/collab_ui/src/chat_panel.rs                |  69 ++---
crates/collab_ui/src/chat_panel/message_editor.rs | 218 +++++++++++++++++
crates/rpc/proto/zed.proto                        |   7 
crates/rpc/src/proto.rs                           |   2 
crates/theme/src/theme.rs                         |   1 
styles/src/style_tree/chat_panel.ts               |   1 
9 files changed, 271 insertions(+), 47 deletions(-)

Detailed changes

Cargo.lock 🔗

@@ -1558,6 +1558,7 @@ dependencies = [
  "fuzzy",
  "gpui",
  "language",
+ "lazy_static",
  "log",
  "menu",
  "notifications",

crates/collab/src/db/queries/channels.rs 🔗

@@ -552,7 +552,8 @@ impl Database {
         user_id: UserId,
     ) -> Result<Vec<proto::ChannelMember>> {
         self.transaction(|tx| async move {
-            self.check_user_is_channel_admin(channel_id, user_id, &*tx)
+            let user_membership = self
+                .check_user_is_channel_member(channel_id, user_id, &*tx)
                 .await?;
 
             #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)]
@@ -613,6 +614,14 @@ impl Database {
                 });
             }
 
+            // If the user is not an admin, don't give them all of the details
+            if !user_membership.admin {
+                rows.retain_mut(|row| {
+                    row.admin = false;
+                    row.kind != proto::channel_member::Kind::Invitee as i32
+                });
+            }
+
             Ok(rows)
         })
         .await
@@ -644,9 +653,9 @@ impl Database {
         channel_id: ChannelId,
         user_id: UserId,
         tx: &DatabaseTransaction,
-    ) -> Result<()> {
+    ) -> Result<channel_member::Model> {
         let channel_ids = self.get_channel_ancestors(channel_id, tx).await?;
-        channel_member::Entity::find()
+        Ok(channel_member::Entity::find()
             .filter(
                 channel_member::Column::ChannelId
                     .is_in(channel_ids)
@@ -654,8 +663,7 @@ impl Database {
             )
             .one(&*tx)
             .await?
-            .ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?;
-        Ok(())
+            .ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?)
     }
 
     pub async fn check_user_is_channel_admin(

crates/collab_ui/Cargo.toml 🔗

@@ -54,6 +54,7 @@ zed-actions = {path = "../zed-actions"}
 
 anyhow.workspace = true
 futures.workspace = true
+lazy_static.workspace = true
 log.workspace = true
 schemars.workspace = true
 postage.workspace = true

crates/collab_ui/src/chat_panel.rs 🔗

@@ -18,8 +18,9 @@ use gpui::{
     AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
     ViewContext, ViewHandle, WeakViewHandle,
 };
-use language::{language_settings::SoftWrap, LanguageRegistry};
+use language::LanguageRegistry;
 use menu::Confirm;
+use message_editor::MessageEditor;
 use project::Fs;
 use rich_text::RichText;
 use serde::{Deserialize, Serialize};
@@ -33,6 +34,8 @@ use workspace::{
     Workspace,
 };
 
+mod message_editor;
+
 const MESSAGE_LOADING_THRESHOLD: usize = 50;
 const CHAT_PANEL_KEY: &'static str = "ChatPanel";
 
@@ -42,7 +45,7 @@ pub struct ChatPanel {
     languages: Arc<LanguageRegistry>,
     active_chat: Option<(ModelHandle<ChannelChat>, Subscription)>,
     message_list: ListState<ChatPanel>,
-    input_editor: ViewHandle<Editor>,
+    input_editor: ViewHandle<MessageEditor>,
     channel_select: ViewHandle<Select>,
     local_timezone: UtcOffset,
     fs: Arc<dyn Fs>,
@@ -87,13 +90,18 @@ impl ChatPanel {
         let languages = workspace.app_state().languages.clone();
 
         let input_editor = cx.add_view(|cx| {
-            let mut editor = Editor::auto_height(
-                4,
-                Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
+            MessageEditor::new(
+                languages.clone(),
+                channel_store.clone(),
+                cx.add_view(|cx| {
+                    Editor::auto_height(
+                        4,
+                        Some(Arc::new(|theme| theme.chat_panel.input_editor.clone())),
+                        cx,
+                    )
+                }),
                 cx,
-            );
-            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
-            editor
+            )
         });
 
         let workspace_handle = workspace.weak_handle();
@@ -138,7 +146,6 @@ impl ChatPanel {
                 client,
                 channel_store,
                 languages,
-
                 active_chat: Default::default(),
                 pending_serialization: Task::ready(None),
                 message_list,
@@ -187,25 +194,6 @@ impl ChatPanel {
             })
             .detach();
 
-            let markdown = this.languages.language_for_name("Markdown");
-            cx.spawn(|this, mut cx| async move {
-                let markdown = markdown.await?;
-
-                this.update(&mut cx, |this, cx| {
-                    this.input_editor.update(cx, |editor, cx| {
-                        editor.buffer().update(cx, |multi_buffer, cx| {
-                            multi_buffer
-                                .as_singleton()
-                                .unwrap()
-                                .update(cx, |buffer, cx| buffer.set_language(Some(markdown), cx))
-                        })
-                    })
-                })?;
-
-                anyhow::Ok(())
-            })
-            .detach_and_log_err(cx);
-
             this
         })
     }
@@ -269,15 +257,15 @@ impl ChatPanel {
 
     fn set_active_chat(&mut self, chat: ModelHandle<ChannelChat>, cx: &mut ViewContext<Self>) {
         if self.active_chat.as_ref().map(|e| &e.0) != Some(&chat) {
-            let id = chat.read(cx).channel().id;
-            {
+            let id = {
                 let chat = chat.read(cx);
+                let channel = chat.channel().clone();
                 self.message_list.reset(chat.message_count());
-                let placeholder = format!("Message #{}", chat.channel().name);
-                self.input_editor.update(cx, move |editor, cx| {
-                    editor.set_placeholder_text(placeholder, cx);
+                self.input_editor.update(cx, |editor, cx| {
+                    editor.set_channel(channel.clone(), cx);
                 });
-            }
+                channel.id
+            };
             let subscription = cx.subscribe(&chat, Self::channel_did_change);
             self.active_chat = Some((chat, subscription));
             self.acknowledge_last_message(cx);
@@ -606,14 +594,12 @@ impl ChatPanel {
 
     fn send(&mut self, _: &Confirm, cx: &mut ViewContext<Self>) {
         if let Some((chat, _)) = self.active_chat.as_ref() {
-            let body = self.input_editor.update(cx, |editor, cx| {
-                let body = editor.text(cx);
-                editor.clear(cx);
-                body
-            });
+            let message = self
+                .input_editor
+                .update(cx, |editor, cx| editor.take_message(cx));
 
             if let Some(task) = chat
-                .update(cx, |chat, cx| chat.send_message(body, cx))
+                .update(cx, |chat, cx| chat.send_message(message.text, cx))
                 .log_err()
             {
                 task.detach();
@@ -747,7 +733,8 @@ impl View for ChatPanel {
             *self.client.status().borrow(),
             client::Status::Connected { .. }
         ) {
-            cx.focus(&self.input_editor);
+            let editor = self.input_editor.read(cx).editor.clone();
+            cx.focus(&editor);
         }
     }
 

crates/collab_ui/src/chat_panel/message_editor.rs 🔗

@@ -0,0 +1,218 @@
+use channel::{Channel, ChannelStore};
+use client::UserId;
+use collections::HashMap;
+use editor::{AnchorRangeExt, Editor};
+use gpui::{
+    elements::ChildView, AnyElement, AsyncAppContext, Element, Entity, ModelHandle, Task, View,
+    ViewContext, ViewHandle, WeakViewHandle,
+};
+use language::{language_settings::SoftWrap, Buffer, BufferSnapshot, LanguageRegistry};
+use lazy_static::lazy_static;
+use project::search::SearchQuery;
+use std::{ops::Range, sync::Arc, time::Duration};
+
+const MENTIONS_DEBOUNCE_INTERVAL: Duration = Duration::from_millis(50);
+
+lazy_static! {
+    static ref MENTIONS_SEARCH: SearchQuery = SearchQuery::regex(
+        "@[-_\\w]+",
+        false,
+        false,
+        Default::default(),
+        Default::default()
+    )
+    .unwrap();
+}
+
+pub struct MessageEditor {
+    pub editor: ViewHandle<Editor>,
+    channel_store: ModelHandle<ChannelStore>,
+    users: HashMap<String, UserId>,
+    mentions: Vec<UserId>,
+    mentions_task: Option<Task<()>>,
+    channel: Option<Arc<Channel>>,
+}
+
+pub struct ChatMessage {
+    pub text: String,
+    pub mentions: Vec<(Range<usize>, UserId)>,
+}
+
+impl MessageEditor {
+    pub fn new(
+        language_registry: Arc<LanguageRegistry>,
+        channel_store: ModelHandle<ChannelStore>,
+        editor: ViewHandle<Editor>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        editor.update(cx, |editor, cx| {
+            editor.set_soft_wrap_mode(SoftWrap::EditorWidth, cx);
+        });
+
+        let buffer = editor
+            .read(cx)
+            .buffer()
+            .read(cx)
+            .as_singleton()
+            .expect("message editor must be singleton");
+
+        cx.subscribe(&buffer, Self::on_buffer_event).detach();
+        cx.subscribe(&editor, |_, _, event, cx| {
+            if let editor::Event::Focused = event {
+                eprintln!("focused");
+                cx.notify()
+            }
+        })
+        .detach();
+
+        let markdown = language_registry.language_for_name("Markdown");
+        cx.app_context()
+            .spawn(|mut cx| async move {
+                let markdown = markdown.await?;
+                buffer.update(&mut cx, |buffer, cx| {
+                    buffer.set_language(Some(markdown), cx)
+                });
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+
+        Self {
+            editor,
+            channel_store,
+            users: HashMap::default(),
+            channel: None,
+            mentions: Vec::new(),
+            mentions_task: None,
+        }
+    }
+
+    pub fn set_channel(&mut self, channel: Arc<Channel>, cx: &mut ViewContext<Self>) {
+        self.editor.update(cx, |editor, cx| {
+            editor.set_placeholder_text(format!("Message #{}", channel.name), cx);
+        });
+        self.channel = Some(channel);
+        self.refresh_users(cx);
+    }
+
+    pub fn refresh_users(&mut self, cx: &mut ViewContext<Self>) {
+        if let Some(channel) = &self.channel {
+            let members = self.channel_store.update(cx, |store, cx| {
+                store.get_channel_member_details(channel.id, cx)
+            });
+            cx.spawn(|this, mut cx| async move {
+                let members = members.await?;
+                this.update(&mut cx, |this, _| {
+                    this.users.clear();
+                    this.users.extend(
+                        members
+                            .into_iter()
+                            .map(|member| (member.user.github_login.clone(), member.user.id)),
+                    );
+                })?;
+                anyhow::Ok(())
+            })
+            .detach_and_log_err(cx);
+        }
+    }
+
+    pub fn take_message(&mut self, cx: &mut ViewContext<Self>) -> ChatMessage {
+        self.editor.update(cx, |editor, cx| {
+            let highlights = editor.text_highlights::<Self>(cx);
+            let text = editor.text(cx);
+            let snapshot = editor.buffer().read(cx).snapshot(cx);
+            let mentions = if let Some((_, ranges)) = highlights {
+                ranges
+                    .iter()
+                    .map(|range| range.to_offset(&snapshot))
+                    .zip(self.mentions.iter().copied())
+                    .collect()
+            } else {
+                Vec::new()
+            };
+
+            editor.clear(cx);
+            self.mentions.clear();
+
+            ChatMessage { text, mentions }
+        })
+    }
+
+    fn on_buffer_event(
+        &mut self,
+        buffer: ModelHandle<Buffer>,
+        event: &language::Event,
+        cx: &mut ViewContext<Self>,
+    ) {
+        if let language::Event::Reparsed | language::Event::Edited = event {
+            let buffer = buffer.read(cx).snapshot();
+            self.mentions_task = Some(cx.spawn(|this, cx| async move {
+                cx.background().timer(MENTIONS_DEBOUNCE_INTERVAL).await;
+                Self::find_mentions(this, buffer, cx).await;
+            }));
+        }
+    }
+
+    async fn find_mentions(
+        this: WeakViewHandle<MessageEditor>,
+        buffer: BufferSnapshot,
+        mut cx: AsyncAppContext,
+    ) {
+        let (buffer, ranges) = cx
+            .background()
+            .spawn(async move {
+                let ranges = MENTIONS_SEARCH.search(&buffer, None).await;
+                (buffer, ranges)
+            })
+            .await;
+
+        this.update(&mut cx, |this, cx| {
+            let mut anchor_ranges = Vec::new();
+            let mut mentioned_user_ids = Vec::new();
+            let mut text = String::new();
+
+            this.editor.update(cx, |editor, cx| {
+                let multi_buffer = editor.buffer().read(cx).snapshot(cx);
+                for range in ranges {
+                    text.clear();
+                    text.extend(buffer.text_for_range(range.clone()));
+                    if let Some(username) = text.strip_prefix("@") {
+                        if let Some(user_id) = this.users.get(username) {
+                            let start = multi_buffer.anchor_after(range.start);
+                            let end = multi_buffer.anchor_after(range.end);
+
+                            mentioned_user_ids.push(*user_id);
+                            anchor_ranges.push(start..end);
+                        }
+                    }
+                }
+
+                editor.clear_highlights::<Self>(cx);
+                editor.highlight_text::<Self>(
+                    anchor_ranges,
+                    theme::current(cx).chat_panel.mention_highlight,
+                    cx,
+                )
+            });
+
+            this.mentions = mentioned_user_ids;
+            this.mentions_task.take();
+        })
+        .ok();
+    }
+}
+
+impl Entity for MessageEditor {
+    type Event = ();
+}
+
+impl View for MessageEditor {
+    fn render(&mut self, cx: &mut ViewContext<'_, '_, Self>) -> AnyElement<Self> {
+        ChildView::new(&self.editor, cx).into_any()
+    }
+
+    fn focus_in(&mut self, _: gpui::AnyViewHandle, cx: &mut ViewContext<Self>) {
+        if cx.is_self_focused() {
+            cx.focus(&self.editor);
+        }
+    }
+}

crates/rpc/proto/zed.proto 🔗

@@ -178,7 +178,8 @@ message Envelope {
         NewNotification new_notification = 148;
         GetNotifications get_notifications = 149;
         GetNotificationsResponse get_notifications_response = 150;
-        DeleteNotification delete_notification = 151; // Current max
+        DeleteNotification delete_notification = 151;
+        MarkNotificationsRead mark_notifications_read = 152; // Current max
     }
 }
 
@@ -1595,6 +1596,10 @@ message DeleteNotification {
     uint64 notification_id = 1;
 }
 
+message MarkNotificationsRead {
+    repeated uint64 notification_ids = 1;
+}
+
 message Notification {
     uint64 id = 1;
     uint64 timestamp = 2;

crates/rpc/src/proto.rs 🔗

@@ -210,6 +210,7 @@ messages!(
     (LeaveProject, Foreground),
     (LeaveRoom, Foreground),
     (LinkChannel, Foreground),
+    (MarkNotificationsRead, Foreground),
     (MoveChannel, Foreground),
     (NewNotification, Foreground),
     (OnTypeFormatting, Background),
@@ -326,6 +327,7 @@ request_messages!(
     (LeaveChannelBuffer, Ack),
     (LeaveRoom, Ack),
     (LinkChannel, Ack),
+    (MarkNotificationsRead, Ack),
     (MoveChannel, Ack),
     (OnTypeFormatting, OnTypeFormattingResponse),
     (OpenBufferById, OpenBufferResponse),

crates/theme/src/theme.rs 🔗

@@ -638,6 +638,7 @@ pub struct ChatPanel {
     pub avatar: AvatarStyle,
     pub avatar_container: ContainerStyle,
     pub message: ChatMessage,
+    pub mention_highlight: HighlightStyle,
     pub continuation_message: ChatMessage,
     pub last_message_bottom_spacing: f32,
     pub pending_message: ChatMessage,

styles/src/style_tree/chat_panel.ts 🔗

@@ -91,6 +91,7 @@ export default function chat_panel(): any {
                 top: 4,
             },
         },
+        mention_highlight: { weight: 'bold' },
         message: {
             ...interactive({
                 base: {