Add an indicator to the channel chat to see all the messages that you missed (#7781)

Remco Smits created

This pull requests add the following features:
- Show indicator before first unseen message
- Scroll to last unseen message

<img width="241" alt="Screenshot 2024-02-14 at 18 10 35"
src="https://github.com/zed-industries/zed/assets/62463826/ca396daf-7102-4eac-ae50-7d0b5ba9b6d5">


https://github.com/zed-industries/zed/assets/62463826/3a5c4afb-aea7-4e7b-98f6-515c027ef83b

### Questions: 
1. Should we hide the indicator after a couple of seconds? Now the
indicator will hide when you close/reopen the channel chat, because when
the last unseen channel message ID is not smaller than the last message
we will not show it.

Release Notes:
- Added unseen messages indicator for the channel chat.

Change summary

crates/channel/src/channel_store.rs |  19 ++
crates/collab_ui/src/chat_panel.rs  | 280 +++++++++++++++++-------------
2 files changed, 178 insertions(+), 121 deletions(-)

Detailed changes

crates/channel/src/channel_store.rs 🔗

@@ -348,6 +348,21 @@ impl ChannelStore {
             .is_some_and(|state| state.has_new_messages())
     }
 
+    pub fn last_acknowledge_message_id(&self, channel_id: ChannelId) -> Option<u64> {
+        self.channel_states.get(&channel_id).and_then(|state| {
+            if let Some(last_message_id) = state.latest_chat_message {
+                if state
+                    .last_acknowledged_message_id()
+                    .is_some_and(|id| id < last_message_id)
+                {
+                    return state.last_acknowledged_message_id();
+                }
+            }
+
+            None
+        })
+    }
+
     pub fn acknowledge_message_id(
         &mut self,
         channel_id: ChannelId,
@@ -1152,6 +1167,10 @@ impl ChannelState {
         })
     }
 
+    fn last_acknowledged_message_id(&self) -> Option<u64> {
+        self.observed_chat_message
+    }
+
     fn acknowledge_message_id(&mut self, message_id: u64) {
         let observed = self.observed_chat_message.get_or_insert(message_id);
         *observed = (*observed).max(message_id);

crates/collab_ui/src/chat_panel.rs 🔗

@@ -63,6 +63,7 @@ pub struct ChatPanel {
     focus_handle: FocusHandle,
     open_context_menu: Option<(u64, Subscription)>,
     highlighted_message: Option<(u64, Task<()>)>,
+    last_acknowledged_message_id: Option<u64>,
 }
 
 #[derive(Serialize, Deserialize)]
@@ -126,6 +127,7 @@ impl ChatPanel {
                 focus_handle: cx.focus_handle(),
                 open_context_menu: None,
                 highlighted_message: None,
+                last_acknowledged_message_id: None,
             };
 
             if let Some(channel_id) = ActiveCall::global(cx)
@@ -281,6 +283,13 @@ impl ChatPanel {
     fn acknowledge_last_message(&mut self, cx: &mut ViewContext<Self>) {
         if self.active && self.is_scrolled_to_bottom {
             if let Some((chat, _)) = &self.active_chat {
+                if let Some(channel_id) = self.channel_id(cx) {
+                    self.last_acknowledged_message_id = self
+                        .channel_store
+                        .read(cx)
+                        .last_acknowledge_message_id(channel_id);
+                }
+
                 chat.update(cx, |chat, cx| {
                     chat.acknowledge_last_message(cx);
                 });
@@ -454,120 +463,145 @@ impl ChatPanel {
             cx.theme().colors().panel_background
         };
 
-        v_flex().w_full().relative().child(
-            div()
-                .bg(background)
-                .rounded_md()
-                .overflow_hidden()
-                .px_1()
-                .py_0p5()
-                .when(!is_continuation_from_previous, |this| {
-                    this.mt_2().child(
-                        h_flex()
-                            .text_ui_sm()
-                            .child(div().absolute().child(
-                                Avatar::new(message.sender.avatar_uri.clone()).size(rems(1.)),
-                            ))
-                            .child(
+        v_flex()
+            .w_full()
+            .relative()
+            .child(
+                div()
+                    .bg(background)
+                    .rounded_md()
+                    .overflow_hidden()
+                    .px_1()
+                    .py_0p5()
+                    .when(!is_continuation_from_previous, |this| {
+                        this.mt_2().child(
+                            h_flex()
+                                .text_ui_sm()
+                                .child(div().absolute().child(
+                                    Avatar::new(message.sender.avatar_uri.clone()).size(rems(1.)),
+                                ))
+                                .child(
+                                    div()
+                                        .pl(cx.rem_size() + px(6.0))
+                                        .pr(px(8.0))
+                                        .font_weight(FontWeight::BOLD)
+                                        .child(Label::new(message.sender.github_login.clone())),
+                                )
+                                .child(
+                                    Label::new(format_timestamp(
+                                        OffsetDateTime::now_utc(),
+                                        message.timestamp,
+                                        self.local_timezone,
+                                        None,
+                                    ))
+                                    .size(LabelSize::Small)
+                                    .color(Color::Muted),
+                                ),
+                        )
+                    })
+                    .when(
+                        message.reply_to_message_id.is_some() && reply_to_message.is_none(),
+                        |this| {
+                            const MESSAGE_DELETED: &str = "Message has been deleted";
+
+                            let body_text = StyledText::new(MESSAGE_DELETED).with_highlights(
+                                &cx.text_style(),
+                                vec![(
+                                    0..MESSAGE_DELETED.len(),
+                                    HighlightStyle {
+                                        font_style: Some(FontStyle::Italic),
+                                        ..Default::default()
+                                    },
+                                )],
+                            );
+
+                            this.child(
                                 div()
-                                    .pl(cx.rem_size() + px(6.0))
-                                    .pr(px(8.0))
-                                    .font_weight(FontWeight::BOLD)
-                                    .child(Label::new(message.sender.github_login.clone())),
+                                    .border_l_2()
+                                    .text_ui_xs()
+                                    .border_color(cx.theme().colors().border)
+                                    .px_1()
+                                    .py_0p5()
+                                    .child(body_text),
                             )
-                            .child(
-                                Label::new(format_timestamp(
-                                    OffsetDateTime::now_utc(),
-                                    message.timestamp,
-                                    self.local_timezone,
-                                    None,
-                                ))
-                                .size(LabelSize::Small)
-                                .color(Color::Muted),
-                            ),
+                        },
                     )
-                })
-                .when(
-                    message.reply_to_message_id.is_some() && reply_to_message.is_none(),
-                    |this| {
-                        const MESSAGE_DELETED: &str = "Message has been deleted";
-
-                        let body_text = StyledText::new(MESSAGE_DELETED).with_highlights(
-                            &cx.text_style(),
-                            vec![(
-                                0..MESSAGE_DELETED.len(),
-                                HighlightStyle {
-                                    font_style: Some(FontStyle::Italic),
-                                    ..Default::default()
-                                },
-                            )],
-                        );
-
-                        this.child(
-                            div()
-                                .border_l_2()
-                                .text_ui_xs()
-                                .border_color(cx.theme().colors().border)
-                                .px_1()
-                                .py_0p5()
-                                .child(body_text),
-                        )
-                    },
-                )
-                .when_some(reply_to_message, |el, reply_to_message| {
-                    el.child(self.render_replied_to_message(
-                        Some(message.id),
-                        &reply_to_message,
-                        cx,
-                    ))
-                })
-                .when(mentioning_you || replied_to_you, |this| this.my_0p5())
-                .map(|el| {
-                    let text = self.markdown_data.entry(message.id).or_insert_with(|| {
-                        Self::render_markdown_with_mentions(
-                            &self.languages,
-                            self.client.id(),
-                            &message,
+                    .when_some(reply_to_message, |el, reply_to_message| {
+                        el.child(self.render_replied_to_message(
+                            Some(message.id),
+                            &reply_to_message,
+                            cx,
+                        ))
+                    })
+                    .when(mentioning_you || replied_to_you, |this| this.my_0p5())
+                    .map(|el| {
+                        let text = self.markdown_data.entry(message.id).or_insert_with(|| {
+                            Self::render_markdown_with_mentions(
+                                &self.languages,
+                                self.client.id(),
+                                &message,
+                            )
+                        });
+                        el.child(
+                            v_flex()
+                                .w_full()
+                                .text_ui_sm()
+                                .id(element_id)
+                                .group("")
+                                .child(text.element("body".into(), cx))
+                                .child(
+                                    div()
+                                        .absolute()
+                                        .z_index(1)
+                                        .right_0()
+                                        .w_6()
+                                        .bg(background)
+                                        .when(!self.has_open_menu(message_id), |el| {
+                                            el.visible_on_hover("")
+                                        })
+                                        .when_some(message_id, |el, message_id| {
+                                            el.child(
+                                                popover_menu(("menu", message_id))
+                                                    .trigger(IconButton::new(
+                                                        ("trigger", message_id),
+                                                        IconName::Ellipsis,
+                                                    ))
+                                                    .menu(move |cx| {
+                                                        Some(Self::render_message_menu(
+                                                            &this,
+                                                            message_id,
+                                                            can_delete_message,
+                                                            cx,
+                                                        ))
+                                                    }),
+                                            )
+                                        }),
+                                ),
                         )
-                    });
-                    el.child(
-                        v_flex()
-                            .w_full()
-                            .text_ui_sm()
-                            .id(element_id)
-                            .group("")
-                            .child(text.element("body".into(), cx))
+                    }),
+            )
+            .when(
+                self.last_acknowledged_message_id
+                    .is_some_and(|l| Some(l) == message_id),
+                |this| {
+                    this.child(
+                        h_flex()
+                            .py_2()
+                            .gap_1()
+                            .items_center()
+                            .child(div().w_full().h_0p5().bg(cx.theme().colors().border))
                             .child(
                                 div()
-                                    .absolute()
-                                    .z_index(1)
-                                    .right_0()
-                                    .w_6()
-                                    .bg(background)
-                                    .when(!self.has_open_menu(message_id), |el| {
-                                        el.visible_on_hover("")
-                                    })
-                                    .when_some(message_id, |el, message_id| {
-                                        el.child(
-                                            popover_menu(("menu", message_id))
-                                                .trigger(IconButton::new(
-                                                    ("trigger", message_id),
-                                                    IconName::Ellipsis,
-                                                ))
-                                                .menu(move |cx| {
-                                                    Some(Self::render_message_menu(
-                                                        &this,
-                                                        message_id,
-                                                        can_delete_message,
-                                                        cx,
-                                                    ))
-                                                }),
-                                        )
-                                    }),
-                            ),
+                                    .px_1()
+                                    .rounded_md()
+                                    .text_ui_xs()
+                                    .bg(cx.theme().colors().background)
+                                    .child("New messages"),
+                            )
+                            .child(div().w_full().h_0p5().bg(cx.theme().colors().border)),
                     )
-                }),
-        )
+                },
+            )
     }
 
     fn has_open_menu(&self, message_id: Option<u64>) -> bool {
@@ -682,8 +716,11 @@ impl ChatPanel {
 
         cx.spawn(|this, mut cx| async move {
             let chat = open_chat.await?;
-            this.update(&mut cx, |this, cx| {
+            let highlight_message_id = scroll_to_message_id;
+            let scroll_to_message_id = this.update(&mut cx, |this, cx| {
                 this.set_active_chat(chat.clone(), cx);
+
+                scroll_to_message_id.or_else(|| this.last_acknowledged_message_id)
             })?;
 
             if let Some(message_id) = scroll_to_message_id {
@@ -691,21 +728,22 @@ impl ChatPanel {
                     ChannelChat::load_history_since_message(chat.clone(), message_id, (*cx).clone())
                         .await
                 {
-                    let task = cx.spawn({
-                        let this = this.clone();
-
-                        |mut cx| async move {
-                            cx.background_executor().timer(Duration::from_secs(2)).await;
-                            this.update(&mut cx, |this, cx| {
-                                this.highlighted_message.take();
-                                cx.notify();
-                            })
-                            .ok();
+                    this.update(&mut cx, |this, cx| {
+                        if let Some(highlight_message_id) = highlight_message_id {
+                            let task = cx.spawn({
+                                |this, mut cx| async move {
+                                    cx.background_executor().timer(Duration::from_secs(2)).await;
+                                    this.update(&mut cx, |this, cx| {
+                                        this.highlighted_message.take();
+                                        cx.notify();
+                                    })
+                                    .ok();
+                                }
+                            });
+
+                            this.highlighted_message = Some((highlight_message_id, task));
                         }
-                    });
 
-                    this.update(&mut cx, |this, cx| {
-                        this.highlighted_message = Some((message_id, task));
                         if this.active_chat.as_ref().map_or(false, |(c, _)| *c == chat) {
                             this.message_list.scroll_to(ListOffset {
                                 item_ix,