Navigate to chat messages when clicking them in the notification panel

Max Brunsfeld created

Change summary

crates/channel/src/channel_chat.rs               |  96 +++++++++----
crates/channel/src/channel_store_tests.rs        |   2 
crates/collab/src/tests/channel_message_tests.rs |   2 
crates/collab_ui/src/chat_panel.rs               |  26 +++
crates/collab_ui/src/collab_panel.rs             |   4 
crates/collab_ui/src/notification_panel.rs       | 117 +++++++++++++----
6 files changed, 177 insertions(+), 70 deletions(-)

Detailed changes

crates/channel/src/channel_chat.rs 🔗

@@ -8,7 +8,12 @@ use client::{
 use futures::lock::Mutex;
 use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task};
 use rand::prelude::*;
-use std::{collections::HashSet, mem, ops::Range, sync::Arc};
+use std::{
+    collections::HashSet,
+    mem,
+    ops::{ControlFlow, Range},
+    sync::Arc,
+};
 use sum_tree::{Bias, SumTree};
 use time::OffsetDateTime;
 use util::{post_inc, ResultExt as _, TryFutureExt};
@@ -201,41 +206,68 @@ impl ChannelChat {
         })
     }
 
-    pub fn load_more_messages(&mut self, cx: &mut ModelContext<Self>) -> bool {
-        if !self.loaded_all_messages {
-            let rpc = self.rpc.clone();
-            let user_store = self.user_store.clone();
-            let channel_id = self.channel.id;
-            if let Some(before_message_id) =
-                self.messages.first().and_then(|message| match message.id {
-                    ChannelMessageId::Saved(id) => Some(id),
-                    ChannelMessageId::Pending(_) => None,
-                })
-            {
-                cx.spawn(|this, mut cx| {
-                    async move {
-                        let response = rpc
-                            .request(proto::GetChannelMessages {
-                                channel_id,
-                                before_message_id,
-                            })
-                            .await?;
-                        let loaded_all_messages = response.done;
-                        let messages =
-                            messages_from_proto(response.messages, &user_store, &mut cx).await?;
-                        this.update(&mut cx, |this, cx| {
-                            this.loaded_all_messages = loaded_all_messages;
-                            this.insert_messages(messages, cx);
+    pub fn load_more_messages(&mut self, cx: &mut ModelContext<Self>) -> Option<Task<Option<()>>> {
+        if self.loaded_all_messages {
+            return None;
+        }
+
+        let rpc = self.rpc.clone();
+        let user_store = self.user_store.clone();
+        let channel_id = self.channel.id;
+        let before_message_id = self.first_loaded_message_id()?;
+        Some(cx.spawn(|this, mut cx| {
+            async move {
+                let response = rpc
+                    .request(proto::GetChannelMessages {
+                        channel_id,
+                        before_message_id,
+                    })
+                    .await?;
+                let loaded_all_messages = response.done;
+                let messages = messages_from_proto(response.messages, &user_store, &mut cx).await?;
+                this.update(&mut cx, |this, cx| {
+                    this.loaded_all_messages = loaded_all_messages;
+                    this.insert_messages(messages, cx);
+                });
+                anyhow::Ok(())
+            }
+            .log_err()
+        }))
+    }
+
+    pub fn first_loaded_message_id(&mut self) -> Option<u64> {
+        self.messages.first().and_then(|message| match message.id {
+            ChannelMessageId::Saved(id) => Some(id),
+            ChannelMessageId::Pending(_) => None,
+        })
+    }
+
+    pub async fn load_history_since_message(
+        chat: ModelHandle<Self>,
+        message_id: u64,
+        mut cx: AsyncAppContext,
+    ) -> Option<usize> {
+        loop {
+            let step = chat.update(&mut cx, |chat, cx| {
+                if let Some(first_id) = chat.first_loaded_message_id() {
+                    if first_id <= message_id {
+                        let mut cursor = chat.messages.cursor::<(ChannelMessageId, Count)>();
+                        let message_id = ChannelMessageId::Saved(message_id);
+                        cursor.seek(&message_id, Bias::Left, &());
+                        return ControlFlow::Break(if cursor.start().0 == message_id {
+                            Some(cursor.start().1 .0)
+                        } else {
+                            None
                         });
-                        anyhow::Ok(())
                     }
-                    .log_err()
-                })
-                .detach();
-                return true;
+                }
+                ControlFlow::Continue(chat.load_more_messages(cx))
+            });
+            match step {
+                ControlFlow::Break(ix) => return ix,
+                ControlFlow::Continue(task) => task?.await?,
             }
         }
-        false
     }
 
     pub fn acknowledge_last_message(&mut self, cx: &mut ModelContext<Self>) {

crates/channel/src/channel_store_tests.rs 🔗

@@ -295,7 +295,7 @@ async fn test_channel_messages(cx: &mut TestAppContext) {
 
     // Scroll up to view older messages.
     channel.update(cx, |channel, cx| {
-        assert!(channel.load_more_messages(cx));
+        channel.load_more_messages(cx).unwrap().detach();
     });
     let get_messages = server.receive::<proto::GetChannelMessages>().await.unwrap();
     assert_eq!(get_messages.payload.channel_id, 5);

crates/collab/src/tests/channel_message_tests.rs 🔗

@@ -332,7 +332,7 @@ async fn test_channel_message_changes(
     chat_panel_b
         .update(cx_b, |chat_panel, cx| {
             chat_panel.set_active(true, cx);
-            chat_panel.select_channel(channel_id, cx)
+            chat_panel.select_channel(channel_id, None, cx)
         })
         .await
         .unwrap();

crates/collab_ui/src/chat_panel.rs 🔗

@@ -188,7 +188,7 @@ impl ChatPanel {
                     .channel_at(selected_ix)
                     .map(|e| e.id);
                 if let Some(selected_channel_id) = selected_channel_id {
-                    this.select_channel(selected_channel_id, cx)
+                    this.select_channel(selected_channel_id, None, cx)
                         .detach_and_log_err(cx);
                 }
             })
@@ -622,7 +622,9 @@ impl ChatPanel {
     fn load_more_messages(&mut self, _: &LoadMoreMessages, cx: &mut ViewContext<Self>) {
         if let Some((chat, _)) = self.active_chat.as_ref() {
             chat.update(cx, |channel, cx| {
-                channel.load_more_messages(cx);
+                if let Some(task) = channel.load_more_messages(cx) {
+                    task.detach();
+                }
             })
         }
     }
@@ -630,6 +632,7 @@ impl ChatPanel {
     pub fn select_channel(
         &mut self,
         selected_channel_id: u64,
+        scroll_to_message_id: Option<u64>,
         cx: &mut ViewContext<ChatPanel>,
     ) -> Task<Result<()>> {
         if let Some((chat, _)) = &self.active_chat {
@@ -645,8 +648,23 @@ impl ChatPanel {
             let chat = open_chat.await?;
             this.update(&mut cx, |this, cx| {
                 this.markdown_data = Default::default();
-                this.set_active_chat(chat, cx);
-            })
+                this.set_active_chat(chat.clone(), cx);
+            })?;
+
+            if let Some(message_id) = scroll_to_message_id {
+                if let Some(item_ix) =
+                    ChannelChat::load_history_since_message(chat, message_id, cx.clone()).await
+                {
+                    this.update(&mut cx, |this, _| {
+                        this.message_list.scroll_to(ListOffset {
+                            item_ix,
+                            offset_in_item: 0.,
+                        });
+                    })?;
+                }
+            }
+
+            Ok(())
         })
     }
 

crates/collab_ui/src/collab_panel.rs 🔗

@@ -3366,7 +3366,9 @@ impl CollabPanel {
                 workspace.update(cx, |workspace, cx| {
                     if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
                         panel.update(cx, |panel, cx| {
-                            panel.select_channel(channel_id, cx).detach_and_log_err(cx);
+                            panel
+                                .select_channel(channel_id, None, cx)
+                                .detach_and_log_err(cx);
                         });
                     }
                 });

crates/collab_ui/src/notification_panel.rs 🔗

@@ -1,5 +1,6 @@
 use crate::{
-    format_timestamp, is_channels_feature_enabled, render_avatar, NotificationPanelSettings,
+    chat_panel::ChatPanel, format_timestamp, is_channels_feature_enabled, render_avatar,
+    NotificationPanelSettings,
 };
 use anyhow::Result;
 use channel::ChannelStore;
@@ -58,6 +59,14 @@ pub enum Event {
     Dismissed,
 }
 
+pub struct NotificationPresenter {
+    pub actor: Option<Arc<client::User>>,
+    pub text: String,
+    pub icon: &'static str,
+    pub needs_response: bool,
+    pub can_navigate: bool,
+}
+
 actions!(notification_panel, [ToggleFocus]);
 
 pub fn init(_cx: &mut AppContext) {}
@@ -178,7 +187,13 @@ impl NotificationPanel {
         let entry = self.notification_store.read(cx).notification_at(ix)?;
         let now = OffsetDateTime::now_utc();
         let timestamp = entry.timestamp;
-        let (actor, text, icon, needs_response) = self.present_notification(entry, cx)?;
+        let NotificationPresenter {
+            actor,
+            text,
+            icon,
+            needs_response,
+            can_navigate,
+        } = self.present_notification(entry, cx)?;
 
         let theme = theme::current(cx);
         let style = &theme.notification_panel;
@@ -280,6 +295,15 @@ impl NotificationPanel {
                     .with_style(container)
                     .into_any()
             })
+            .with_cursor_style(if can_navigate {
+                CursorStyle::PointingHand
+            } else {
+                CursorStyle::default()
+            })
+            .on_click(MouseButton::Left, {
+                let notification = notification.clone();
+                move |_, this, cx| this.did_click_notification(&notification, cx)
+            })
             .into_any(),
         )
     }
@@ -288,27 +312,29 @@ impl NotificationPanel {
         &self,
         entry: &NotificationEntry,
         cx: &AppContext,
-    ) -> Option<(Option<Arc<client::User>>, String, &'static str, bool)> {
+    ) -> Option<NotificationPresenter> {
         let user_store = self.user_store.read(cx);
         let channel_store = self.channel_store.read(cx);
-        let icon;
-        let text;
-        let actor;
-        let needs_response;
         match entry.notification {
             Notification::ContactRequest { sender_id } => {
                 let requester = user_store.get_cached_user(sender_id)?;
-                icon = "icons/plus.svg";
-                text = format!("{} wants to add you as a contact", requester.github_login);
-                needs_response = user_store.is_contact_request_pending(&requester);
-                actor = Some(requester);
+                Some(NotificationPresenter {
+                    icon: "icons/plus.svg",
+                    text: format!("{} wants to add you as a contact", requester.github_login),
+                    needs_response: user_store.is_contact_request_pending(&requester),
+                    actor: Some(requester),
+                    can_navigate: false,
+                })
             }
             Notification::ContactRequestAccepted { responder_id } => {
                 let responder = user_store.get_cached_user(responder_id)?;
-                icon = "icons/plus.svg";
-                text = format!("{} accepted your contact invite", responder.github_login);
-                needs_response = false;
-                actor = Some(responder);
+                Some(NotificationPresenter {
+                    icon: "icons/plus.svg",
+                    text: format!("{} accepted your contact invite", responder.github_login),
+                    needs_response: false,
+                    actor: Some(responder),
+                    can_navigate: false,
+                })
             }
             Notification::ChannelInvitation {
                 ref channel_name,
@@ -316,13 +342,16 @@ impl NotificationPanel {
                 inviter_id,
             } => {
                 let inviter = user_store.get_cached_user(inviter_id)?;
-                icon = "icons/hash.svg";
-                text = format!(
-                    "{} invited you to join the #{channel_name} channel",
-                    inviter.github_login
-                );
-                needs_response = channel_store.has_channel_invitation(channel_id);
-                actor = Some(inviter);
+                Some(NotificationPresenter {
+                    icon: "icons/hash.svg",
+                    text: format!(
+                        "{} invited you to join the #{channel_name} channel",
+                        inviter.github_login
+                    ),
+                    needs_response: channel_store.has_channel_invitation(channel_id),
+                    actor: Some(inviter),
+                    can_navigate: false,
+                })
             }
             Notification::ChannelMessageMention {
                 sender_id,
@@ -335,16 +364,41 @@ impl NotificationPanel {
                     .notification_store
                     .read(cx)
                     .channel_message_for_id(message_id)?;
-                icon = "icons/conversations.svg";
-                text = format!(
-                    "{} mentioned you in the #{} channel:\n{}",
-                    sender.github_login, channel.name, message.body,
-                );
-                needs_response = false;
-                actor = Some(sender);
+                Some(NotificationPresenter {
+                    icon: "icons/conversations.svg",
+                    text: format!(
+                        "{} mentioned you in the #{} channel:\n{}",
+                        sender.github_login, channel.name, message.body,
+                    ),
+                    needs_response: false,
+                    actor: Some(sender),
+                    can_navigate: true,
+                })
+            }
+        }
+    }
+
+    fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext<Self>) {
+        if let Notification::ChannelMessageMention {
+            message_id,
+            channel_id,
+            ..
+        } = notification.clone()
+        {
+            if let Some(workspace) = self.workspace.upgrade(cx) {
+                cx.app_context().defer(move |cx| {
+                    workspace.update(cx, |workspace, cx| {
+                        if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
+                            panel.update(cx, |panel, cx| {
+                                panel
+                                    .select_channel(channel_id, Some(message_id), cx)
+                                    .detach_and_log_err(cx);
+                            });
+                        }
+                    });
+                });
             }
         }
-        Some((actor, text, icon, needs_response))
     }
 
     fn render_sign_in_prompt(
@@ -410,7 +464,8 @@ impl NotificationPanel {
     }
 
     fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
-        let Some((actor, text, _, _)) = self.present_notification(entry, cx) else {
+        let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
+        else {
             return;
         };