Fix more issues with the channels panel

Max Brunsfeld created

* Put the newest notifications at the top
* Have at most 1 notification toast, which is non-interactive,
  but focuses the notification panel on click, and auto-dismisses
  on a timer.

Change summary

crates/channel/src/channel_store.rs                        |   6 
crates/collab/src/db/queries/channels.rs                   |  16 
crates/collab/src/rpc.rs                                   |  16 
crates/collab/src/tests/notification_tests.rs              |  50 +
crates/collab_ui/src/notification_panel.rs                 | 326 +++++--
crates/collab_ui/src/notifications.rs                      | 111 --
crates/collab_ui/src/notifications/contact_notification.rs | 106 --
crates/notifications/src/notification_store.rs             |  29 
crates/rpc/src/notification.rs                             |   6 
9 files changed, 326 insertions(+), 340 deletions(-)

Detailed changes

crates/channel/src/channel_store.rs 🔗

@@ -213,6 +213,12 @@ impl ChannelStore {
         self.channel_index.by_id().values().nth(ix)
     }
 
+    pub fn has_channel_invitation(&self, channel_id: ChannelId) -> bool {
+        self.channel_invitations
+            .iter()
+            .any(|channel| channel.id == channel_id)
+    }
+
     pub fn channel_invitations(&self) -> &[Arc<Channel>] {
         &self.channel_invitations
     }

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

@@ -187,6 +187,7 @@ impl Database {
                     rpc::Notification::ChannelInvitation {
                         channel_id: channel_id.to_proto(),
                         channel_name: channel.name,
+                        inviter_id: inviter_id.to_proto(),
                     },
                     true,
                     &*tx,
@@ -276,6 +277,7 @@ impl Database {
                     &rpc::Notification::ChannelInvitation {
                         channel_id: channel_id.to_proto(),
                         channel_name: Default::default(),
+                        inviter_id: Default::default(),
                     },
                     accept,
                     &*tx,
@@ -292,7 +294,7 @@ impl Database {
         channel_id: ChannelId,
         member_id: UserId,
         remover_id: UserId,
-    ) -> Result<()> {
+    ) -> Result<Option<NotificationId>> {
         self.transaction(|tx| async move {
             self.check_user_is_channel_admin(channel_id, remover_id, &*tx)
                 .await?;
@@ -310,7 +312,17 @@ impl Database {
                 Err(anyhow!("no such member"))?;
             }
 
-            Ok(())
+            Ok(self
+                .remove_notification(
+                    member_id,
+                    rpc::Notification::ChannelInvitation {
+                        channel_id: channel_id.to_proto(),
+                        channel_name: Default::default(),
+                        inviter_id: Default::default(),
+                    },
+                    &*tx,
+                )
+                .await?)
         })
         .await
     }

crates/collab/src/rpc.rs 🔗

@@ -2331,7 +2331,8 @@ async fn remove_channel_member(
     let channel_id = ChannelId::from_proto(request.channel_id);
     let member_id = UserId::from_proto(request.user_id);
 
-    db.remove_channel_member(channel_id, member_id, session.user_id)
+    let removed_notification_id = db
+        .remove_channel_member(channel_id, member_id, session.user_id)
         .await?;
 
     let mut update = proto::UpdateChannels::default();
@@ -2342,7 +2343,18 @@ async fn remove_channel_member(
         .await
         .user_connection_ids(member_id)
     {
-        session.peer.send(connection_id, update.clone())?;
+        session.peer.send(connection_id, update.clone()).trace_err();
+        if let Some(notification_id) = removed_notification_id {
+            session
+                .peer
+                .send(
+                    connection_id,
+                    proto::DeleteNotification {
+                        notification_id: notification_id.to_proto(),
+                    },
+                )
+                .trace_err();
+        }
     }
 
     response.send(proto::Ack {})?;

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

@@ -1,5 +1,7 @@
 use crate::tests::TestServer;
 use gpui::{executor::Deterministic, TestAppContext};
+use notifications::NotificationEvent;
+use parking_lot::Mutex;
 use rpc::Notification;
 use std::sync::Arc;
 
@@ -14,6 +16,23 @@ async fn test_notifications(
     let client_a = server.create_client(cx_a, "user_a").await;
     let client_b = server.create_client(cx_b, "user_b").await;
 
+    let notification_events_a = Arc::new(Mutex::new(Vec::new()));
+    let notification_events_b = Arc::new(Mutex::new(Vec::new()));
+    client_a.notification_store().update(cx_a, |_, cx| {
+        let events = notification_events_a.clone();
+        cx.subscribe(&cx.handle(), move |_, _, event, _| {
+            events.lock().push(event.clone());
+        })
+        .detach()
+    });
+    client_b.notification_store().update(cx_b, |_, cx| {
+        let events = notification_events_b.clone();
+        cx.subscribe(&cx.handle(), move |_, _, event, _| {
+            events.lock().push(event.clone());
+        })
+        .detach()
+    });
+
     // Client A sends a contact request to client B.
     client_a
         .user_store()
@@ -36,6 +55,18 @@ async fn test_notifications(
             }
         );
         assert!(!entry.is_read);
+        assert_eq!(
+            &notification_events_b.lock()[0..],
+            &[
+                NotificationEvent::NewNotification {
+                    entry: entry.clone(),
+                },
+                NotificationEvent::NotificationsUpdated {
+                    old_range: 0..0,
+                    new_count: 1
+                }
+            ]
+        );
 
         store.respond_to_notification(entry.notification.clone(), true, cx);
     });
@@ -49,6 +80,18 @@ async fn test_notifications(
         let entry = store.notification_at(0).unwrap();
         assert!(entry.is_read);
         assert_eq!(entry.response, Some(true));
+        assert_eq!(
+            &notification_events_b.lock()[2..],
+            &[
+                NotificationEvent::NotificationRead {
+                    entry: entry.clone(),
+                },
+                NotificationEvent::NotificationsUpdated {
+                    old_range: 0..1,
+                    new_count: 1
+                }
+            ]
+        );
     });
 
     // Client A receives a notification that client B accepted their request.
@@ -89,12 +132,13 @@ async fn test_notifications(
         assert_eq!(store.notification_count(), 2);
         assert_eq!(store.unread_notification_count(), 1);
 
-        let entry = store.notification_at(1).unwrap();
+        let entry = store.notification_at(0).unwrap();
         assert_eq!(
             entry.notification,
             Notification::ChannelInvitation {
                 channel_id,
-                channel_name: "the-channel".to_string()
+                channel_name: "the-channel".to_string(),
+                inviter_id: client_a.id()
             }
         );
         assert!(!entry.is_read);
@@ -108,7 +152,7 @@ async fn test_notifications(
         assert_eq!(store.notification_count(), 2);
         assert_eq!(store.unread_notification_count(), 0);
 
-        let entry = store.notification_at(1).unwrap();
+        let entry = store.notification_at(0).unwrap();
         assert!(entry.is_read);
         assert_eq!(entry.response, Some(true));
     });

crates/collab_ui/src/notification_panel.rs 🔗

@@ -1,11 +1,9 @@
 use crate::{
-    format_timestamp, is_channels_feature_enabled,
-    notifications::contact_notification::ContactNotification, render_avatar,
-    NotificationPanelSettings,
+    format_timestamp, is_channels_feature_enabled, render_avatar, NotificationPanelSettings,
 };
 use anyhow::Result;
 use channel::ChannelStore;
-use client::{Client, Notification, UserStore};
+use client::{Client, Notification, User, UserStore};
 use db::kvp::KEY_VALUE_STORE;
 use futures::StreamExt;
 use gpui::{
@@ -19,7 +17,7 @@ use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
 use project::Fs;
 use serde::{Deserialize, Serialize};
 use settings::SettingsStore;
-use std::sync::Arc;
+use std::{sync::Arc, time::Duration};
 use theme::{IconButton, Theme};
 use time::{OffsetDateTime, UtcOffset};
 use util::{ResultExt, TryFutureExt};
@@ -28,6 +26,7 @@ use workspace::{
     Workspace,
 };
 
+const TOAST_DURATION: Duration = Duration::from_secs(5);
 const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
 
 pub struct NotificationPanel {
@@ -42,6 +41,7 @@ pub struct NotificationPanel {
     pending_serialization: Task<Option<()>>,
     subscriptions: Vec<gpui::Subscription>,
     workspace: WeakViewHandle<Workspace>,
+    current_notification_toast: Option<(u64, Task<()>)>,
     local_timezone: UtcOffset,
     has_focus: bool,
 }
@@ -58,7 +58,7 @@ pub enum Event {
     Dismissed,
 }
 
-actions!(chat_panel, [ToggleFocus]);
+actions!(notification_panel, [ToggleFocus]);
 
 pub fn init(_cx: &mut AppContext) {}
 
@@ -69,14 +69,8 @@ impl NotificationPanel {
         let user_store = workspace.app_state().user_store.clone();
         let workspace_handle = workspace.weak_handle();
 
-        let notification_list =
-            ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
-                this.render_notification(ix, cx)
-            });
-
         cx.add_view(|cx| {
             let mut status = client.status();
-
             cx.spawn(|this, mut cx| async move {
                 while let Some(_) = status.next().await {
                     if this
@@ -91,6 +85,12 @@ impl NotificationPanel {
             })
             .detach();
 
+            let notification_list =
+                ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
+                    this.render_notification(ix, cx)
+                        .unwrap_or_else(|| Empty::new().into_any())
+                });
+
             let mut this = Self {
                 fs,
                 client,
@@ -102,6 +102,7 @@ impl NotificationPanel {
                 pending_serialization: Task::ready(None),
                 workspace: workspace_handle,
                 has_focus: false,
+                current_notification_toast: None,
                 subscriptions: Vec::new(),
                 active: false,
                 width: None,
@@ -169,73 +170,20 @@ impl NotificationPanel {
         );
     }
 
-    fn render_notification(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        self.try_render_notification(ix, cx)
-            .unwrap_or_else(|| Empty::new().into_any())
-    }
-
-    fn try_render_notification(
+    fn render_notification(
         &mut self,
         ix: usize,
         cx: &mut ViewContext<Self>,
     ) -> Option<AnyElement<Self>> {
-        let notification_store = self.notification_store.read(cx);
-        let user_store = self.user_store.read(cx);
-        let channel_store = self.channel_store.read(cx);
-        let entry = notification_store.notification_at(ix)?;
-        let notification = entry.notification.clone();
+        let entry = self.notification_store.read(cx).notification_at(ix)?;
         let now = OffsetDateTime::now_utc();
         let timestamp = entry.timestamp;
-
-        let icon;
-        let text;
-        let actor;
-        let needs_acceptance;
-        match 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_acceptance = true;
-                actor = Some(requester);
-            }
-            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_acceptance = false;
-                actor = Some(responder);
-            }
-            Notification::ChannelInvitation {
-                ref channel_name, ..
-            } => {
-                actor = None;
-                icon = "icons/hash.svg";
-                text = format!("you were invited to join the #{channel_name} channel");
-                needs_acceptance = true;
-            }
-            Notification::ChannelMessageMention {
-                sender_id,
-                channel_id,
-                message_id,
-            } => {
-                let sender = user_store.get_cached_user(sender_id)?;
-                let channel = channel_store.channel_for_id(channel_id)?;
-                let message = notification_store.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_acceptance = false;
-                actor = Some(sender);
-            }
-        }
+        let (actor, text, icon, needs_response) = self.present_notification(entry, cx)?;
 
         let theme = theme::current(cx);
         let style = &theme.notification_panel;
         let response = entry.response;
+        let notification = entry.notification.clone();
 
         let message_style = if entry.is_read {
             style.read_text.clone()
@@ -276,7 +224,7 @@ impl NotificationPanel {
                             )
                             .into_any(),
                         )
-                    } else if needs_acceptance {
+                    } else if needs_response {
                         Some(
                             Flex::row()
                                 .with_children([
@@ -336,6 +284,69 @@ impl NotificationPanel {
         )
     }
 
+    fn present_notification(
+        &self,
+        entry: &NotificationEntry,
+        cx: &AppContext,
+    ) -> Option<(Option<Arc<client::User>>, String, &'static str, bool)> {
+        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);
+            }
+            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);
+            }
+            Notification::ChannelInvitation {
+                ref channel_name,
+                channel_id,
+                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);
+            }
+            Notification::ChannelMessageMention {
+                sender_id,
+                channel_id,
+                message_id,
+            } => {
+                let sender = user_store.get_cached_user(sender_id)?;
+                let channel = channel_store.channel_for_id(channel_id)?;
+                let message = self
+                    .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((actor, text, icon, needs_response))
+    }
+
     fn render_sign_in_prompt(
         &self,
         theme: &Arc<Theme>,
@@ -387,7 +398,7 @@ impl NotificationPanel {
         match event {
             NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
             NotificationEvent::NotificationRemoved { entry }
-            | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry, cx),
+            | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx),
             NotificationEvent::NotificationsUpdated {
                 old_range,
                 new_count,
@@ -399,49 +410,44 @@ impl NotificationPanel {
     }
 
     fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
-        let id = entry.id as usize;
-        match entry.notification {
-            Notification::ContactRequest {
-                sender_id: actor_id,
-            }
-            | Notification::ContactRequestAccepted {
-                responder_id: actor_id,
-            } => {
-                let user_store = self.user_store.clone();
-                let Some(user) = user_store.read(cx).get_cached_user(actor_id) else {
-                    return;
-                };
-                self.workspace
-                    .update(cx, |workspace, cx| {
-                        workspace.show_notification(id, cx, |cx| {
-                            cx.add_view(|_| {
-                                ContactNotification::new(
-                                    user,
-                                    entry.notification.clone(),
-                                    user_store,
-                                )
-                            })
-                        })
-                    })
+        let Some((actor, text, _, _)) = self.present_notification(entry, cx) else {
+            return;
+        };
+
+        let id = entry.id;
+        self.current_notification_toast = Some((
+            id,
+            cx.spawn(|this, mut cx| async move {
+                cx.background().timer(TOAST_DURATION).await;
+                this.update(&mut cx, |this, cx| this.remove_toast(id, cx))
                     .ok();
-            }
-            Notification::ChannelInvitation { .. } => {}
-            Notification::ChannelMessageMention { .. } => {}
-        }
+            }),
+        ));
+
+        self.workspace
+            .update(cx, |workspace, cx| {
+                workspace.show_notification(0, cx, |cx| {
+                    let workspace = cx.weak_handle();
+                    cx.add_view(|_| NotificationToast {
+                        actor,
+                        text,
+                        workspace,
+                    })
+                })
+            })
+            .ok();
     }
 
-    fn remove_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
-        let id = entry.id as usize;
-        match entry.notification {
-            Notification::ContactRequest { .. } | Notification::ContactRequestAccepted { .. } => {
+    fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
+        if let Some((current_id, _)) = &self.current_notification_toast {
+            if *current_id == notification_id {
+                self.current_notification_toast.take();
                 self.workspace
                     .update(cx, |workspace, cx| {
-                        workspace.dismiss_notification::<ContactNotification>(id, cx)
+                        workspace.dismiss_notification::<NotificationToast>(0, cx)
                     })
                     .ok();
             }
-            Notification::ChannelInvitation { .. } => {}
-            Notification::ChannelMessageMention { .. } => {}
         }
     }
 
@@ -582,3 +588,111 @@ fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> im
         .contained()
         .with_style(style.container)
 }
+
+pub struct NotificationToast {
+    actor: Option<Arc<User>>,
+    text: String,
+    workspace: WeakViewHandle<Workspace>,
+}
+
+pub enum ToastEvent {
+    Dismiss,
+}
+
+impl NotificationToast {
+    fn focus_notification_panel(&self, cx: &mut AppContext) {
+        let workspace = self.workspace.clone();
+        cx.defer(move |cx| {
+            workspace
+                .update(cx, |workspace, cx| {
+                    workspace.focus_panel::<NotificationPanel>(cx);
+                })
+                .ok();
+        })
+    }
+}
+
+impl Entity for NotificationToast {
+    type Event = ToastEvent;
+}
+
+impl View for NotificationToast {
+    fn ui_name() -> &'static str {
+        "ContactNotification"
+    }
+
+    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
+        let user = self.actor.clone();
+        let theme = theme::current(cx).clone();
+        let theme = &theme.contact_notification;
+
+        MouseEventHandler::new::<Self, _>(0, cx, |_, cx| {
+            Flex::row()
+                .with_children(user.and_then(|user| {
+                    Some(
+                        Image::from_data(user.avatar.clone()?)
+                            .with_style(theme.header_avatar)
+                            .aligned()
+                            .constrained()
+                            .with_height(
+                                cx.font_cache()
+                                    .line_height(theme.header_message.text.font_size),
+                            )
+                            .aligned()
+                            .top(),
+                    )
+                }))
+                .with_child(
+                    Text::new(self.text.clone(), theme.header_message.text.clone())
+                        .contained()
+                        .with_style(theme.header_message.container)
+                        .aligned()
+                        .top()
+                        .left()
+                        .flex(1., true),
+                )
+                .with_child(
+                    MouseEventHandler::new::<ToastEvent, _>(0, cx, |state, _| {
+                        let style = theme.dismiss_button.style_for(state);
+                        Svg::new("icons/x.svg")
+                            .with_color(style.color)
+                            .constrained()
+                            .with_width(style.icon_width)
+                            .aligned()
+                            .contained()
+                            .with_style(style.container)
+                            .constrained()
+                            .with_width(style.button_width)
+                            .with_height(style.button_width)
+                    })
+                    .with_cursor_style(CursorStyle::PointingHand)
+                    .with_padding(Padding::uniform(5.))
+                    .on_click(MouseButton::Left, move |_, _, cx| {
+                        cx.emit(ToastEvent::Dismiss)
+                    })
+                    .aligned()
+                    .constrained()
+                    .with_height(
+                        cx.font_cache()
+                            .line_height(theme.header_message.text.font_size),
+                    )
+                    .aligned()
+                    .top()
+                    .flex_float(),
+                )
+                .contained()
+        })
+        .with_cursor_style(CursorStyle::PointingHand)
+        .on_click(MouseButton::Left, move |_, this, cx| {
+            this.focus_notification_panel(cx);
+            cx.emit(ToastEvent::Dismiss);
+        })
+        .into_any()
+    }
+}
+
+impl workspace::notifications::Notification for NotificationToast {
+    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
+        matches!(event, ToastEvent::Dismiss)
+    }
+}

crates/collab_ui/src/notifications.rs 🔗

@@ -1,120 +1,11 @@
-use client::User;
-use gpui::{
-    elements::*,
-    platform::{CursorStyle, MouseButton},
-    AnyElement, AppContext, Element, ViewContext,
-};
+use gpui::AppContext;
 use std::sync::Arc;
 use workspace::AppState;
 
-pub mod contact_notification;
 pub mod incoming_call_notification;
 pub mod project_shared_notification;
 
-enum Dismiss {}
-enum Button {}
-
 pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
     incoming_call_notification::init(app_state, cx);
     project_shared_notification::init(app_state, cx);
 }
-
-pub fn render_user_notification<F, V: 'static>(
-    user: Arc<User>,
-    title: &'static str,
-    body: Option<&'static str>,
-    on_dismiss: F,
-    buttons: Vec<(&'static str, Box<dyn Fn(&mut V, &mut ViewContext<V>)>)>,
-    cx: &mut ViewContext<V>,
-) -> AnyElement<V>
-where
-    F: 'static + Fn(&mut V, &mut ViewContext<V>),
-{
-    let theme = theme::current(cx).clone();
-    let theme = &theme.contact_notification;
-
-    Flex::column()
-        .with_child(
-            Flex::row()
-                .with_children(user.avatar.clone().map(|avatar| {
-                    Image::from_data(avatar)
-                        .with_style(theme.header_avatar)
-                        .aligned()
-                        .constrained()
-                        .with_height(
-                            cx.font_cache()
-                                .line_height(theme.header_message.text.font_size),
-                        )
-                        .aligned()
-                        .top()
-                }))
-                .with_child(
-                    Text::new(
-                        format!("{} {}", user.github_login, title),
-                        theme.header_message.text.clone(),
-                    )
-                    .contained()
-                    .with_style(theme.header_message.container)
-                    .aligned()
-                    .top()
-                    .left()
-                    .flex(1., true),
-                )
-                .with_child(
-                    MouseEventHandler::new::<Dismiss, _>(user.id as usize, cx, |state, _| {
-                        let style = theme.dismiss_button.style_for(state);
-                        Svg::new("icons/x.svg")
-                            .with_color(style.color)
-                            .constrained()
-                            .with_width(style.icon_width)
-                            .aligned()
-                            .contained()
-                            .with_style(style.container)
-                            .constrained()
-                            .with_width(style.button_width)
-                            .with_height(style.button_width)
-                    })
-                    .with_cursor_style(CursorStyle::PointingHand)
-                    .with_padding(Padding::uniform(5.))
-                    .on_click(MouseButton::Left, move |_, view, cx| on_dismiss(view, cx))
-                    .aligned()
-                    .constrained()
-                    .with_height(
-                        cx.font_cache()
-                            .line_height(theme.header_message.text.font_size),
-                    )
-                    .aligned()
-                    .top()
-                    .flex_float(),
-                )
-                .into_any_named("contact notification header"),
-        )
-        .with_children(body.map(|body| {
-            Label::new(body, theme.body_message.text.clone())
-                .contained()
-                .with_style(theme.body_message.container)
-        }))
-        .with_children(if buttons.is_empty() {
-            None
-        } else {
-            Some(
-                Flex::row()
-                    .with_children(buttons.into_iter().enumerate().map(
-                        |(ix, (message, handler))| {
-                            MouseEventHandler::new::<Button, _>(ix, cx, |state, _| {
-                                let button = theme.button.style_for(state);
-                                Label::new(message, button.text.clone())
-                                    .contained()
-                                    .with_style(button.container)
-                            })
-                            .with_cursor_style(CursorStyle::PointingHand)
-                            .on_click(MouseButton::Left, move |_, view, cx| handler(view, cx))
-                        },
-                    ))
-                    .aligned()
-                    .right(),
-            )
-        })
-        .contained()
-        .into_any()
-}

crates/collab_ui/src/notifications/contact_notification.rs 🔗

@@ -1,106 +0,0 @@
-use crate::notifications::render_user_notification;
-use client::{User, UserStore};
-use gpui::{elements::*, Entity, ModelHandle, View, ViewContext};
-use std::sync::Arc;
-use workspace::notifications::Notification;
-
-pub struct ContactNotification {
-    user_store: ModelHandle<UserStore>,
-    user: Arc<User>,
-    notification: rpc::Notification,
-}
-
-#[derive(Clone, PartialEq)]
-struct Dismiss(u64);
-
-#[derive(Clone, PartialEq)]
-pub struct RespondToContactRequest {
-    pub user_id: u64,
-    pub accept: bool,
-}
-
-pub enum Event {
-    Dismiss,
-}
-
-impl Entity for ContactNotification {
-    type Event = Event;
-}
-
-impl View for ContactNotification {
-    fn ui_name() -> &'static str {
-        "ContactNotification"
-    }
-
-    fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
-        match self.notification {
-            rpc::Notification::ContactRequest { .. } => render_user_notification(
-                self.user.clone(),
-                "wants to add you as a contact",
-                Some("They won't be alerted if you decline."),
-                |notification, cx| notification.dismiss(cx),
-                vec![
-                    (
-                        "Decline",
-                        Box::new(|notification, cx| {
-                            notification.respond_to_contact_request(false, cx)
-                        }),
-                    ),
-                    (
-                        "Accept",
-                        Box::new(|notification, cx| {
-                            notification.respond_to_contact_request(true, cx)
-                        }),
-                    ),
-                ],
-                cx,
-            ),
-            rpc::Notification::ContactRequestAccepted { .. } => render_user_notification(
-                self.user.clone(),
-                "accepted your contact request",
-                None,
-                |notification, cx| notification.dismiss(cx),
-                vec![],
-                cx,
-            ),
-            _ => unreachable!(),
-        }
-    }
-}
-
-impl Notification for ContactNotification {
-    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
-        matches!(event, Event::Dismiss)
-    }
-}
-
-impl ContactNotification {
-    pub fn new(
-        user: Arc<User>,
-        notification: rpc::Notification,
-        user_store: ModelHandle<UserStore>,
-    ) -> Self {
-        Self {
-            user,
-            notification,
-            user_store,
-        }
-    }
-
-    fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
-        self.user_store.update(cx, |store, cx| {
-            store
-                .dismiss_contact_request(self.user.id, cx)
-                .detach_and_log_err(cx);
-        });
-        cx.emit(Event::Dismiss);
-    }
-
-    fn respond_to_contact_request(&mut self, accept: bool, cx: &mut ViewContext<Self>) {
-        self.user_store
-            .update(cx, |store, cx| {
-                store.respond_to_contact_request(self.user.id, accept, cx)
-            })
-            .detach();
-    }
-}

crates/notifications/src/notification_store.rs 🔗

@@ -25,6 +25,7 @@ pub struct NotificationStore {
     _subscriptions: Vec<client::Subscription>,
 }
 
+#[derive(Clone, PartialEq, Eq, Debug)]
 pub enum NotificationEvent {
     NotificationsUpdated {
         old_range: Range<usize>,
@@ -118,7 +119,13 @@ impl NotificationStore {
         self.channel_messages.get(&id)
     }
 
+    // Get the nth newest notification.
     pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> {
+        let count = self.notifications.summary().count;
+        if ix >= count {
+            return None;
+        }
+        let ix = count - 1 - ix;
         let mut cursor = self.notifications.cursor::<Count>();
         cursor.seek(&Count(ix), Bias::Right, &());
         cursor.item()
@@ -200,7 +207,9 @@ impl NotificationStore {
 
         for entry in &notifications {
             match entry.notification {
-                Notification::ChannelInvitation { .. } => {}
+                Notification::ChannelInvitation { inviter_id, .. } => {
+                    user_ids.push(inviter_id);
+                }
                 Notification::ContactRequest {
                     sender_id: requester_id,
                 } => {
@@ -273,8 +282,11 @@ impl NotificationStore {
                 old_range.start = cursor.start().1 .0;
             }
 
-            if let Some(existing_notification) = cursor.item() {
-                if existing_notification.id == id {
+            let old_notification = cursor.item();
+            if let Some(old_notification) = old_notification {
+                if old_notification.id == id {
+                    cursor.next(&());
+
                     if let Some(new_notification) = &new_notification {
                         if new_notification.is_read {
                             cx.emit(NotificationEvent::NotificationRead {
@@ -283,20 +295,19 @@ impl NotificationStore {
                         }
                     } else {
                         cx.emit(NotificationEvent::NotificationRemoved {
-                            entry: existing_notification.clone(),
+                            entry: old_notification.clone(),
                         });
                     }
-                    cursor.next(&());
                 }
-            }
-
-            if let Some(notification) = new_notification {
+            } else if let Some(new_notification) = &new_notification {
                 if is_new {
                     cx.emit(NotificationEvent::NewNotification {
-                        entry: notification.clone(),
+                        entry: new_notification.clone(),
                     });
                 }
+            }
 
+            if let Some(notification) = new_notification {
                 new_notifications.push(notification, &());
             }
         }

crates/rpc/src/notification.rs 🔗

@@ -30,12 +30,13 @@ pub enum Notification {
         #[serde(rename = "entity_id")]
         channel_id: u64,
         channel_name: String,
+        inviter_id: u64,
     },
     ChannelMessageMention {
-        sender_id: u64,
-        channel_id: u64,
         #[serde(rename = "entity_id")]
         message_id: u64,
+        sender_id: u64,
+        channel_id: u64,
     },
 }
 
@@ -84,6 +85,7 @@ fn test_notification() {
         Notification::ChannelInvitation {
             channel_id: 100,
             channel_name: "the-channel".into(),
+            inviter_id: 50,
         },
         Notification::ChannelMessageMention {
             sender_id: 200,