Add notifications for accepted contact requests

Max Brunsfeld and Nathan Sobo created

Co-authored-by: Nathan Sobo <nathan@zed.dev>

Change summary

assets/themes/cave-dark.json                       |   2 
assets/themes/cave-light.json                      |   2 
assets/themes/dark.json                            |   2 
assets/themes/light.json                           |   2 
assets/themes/solarized-dark.json                  |   2 
assets/themes/solarized-light.json                 |   2 
assets/themes/sulphurpool-dark.json                |   2 
assets/themes/sulphurpool-light.json               |   2 
crates/client/src/user.rs                          |  40 ++
crates/collab/src/rpc.rs                           |  10 
crates/collab/src/rpc/store.rs                     |  12 
crates/contacts_panel/src/contact_notification.rs  | 224 ++++++++++++++++
crates/contacts_panel/src/contact_notifications.rs | 206 --------------
crates/contacts_panel/src/contacts_panel.rs        |  35 +-
crates/rpc/proto/zed.proto                         |   1 
crates/theme/src/theme.rs                          |   4 
styles/src/styleTree/app.ts                        |   4 
styles/src/styleTree/contactNotification.ts        |   2 
18 files changed, 301 insertions(+), 253 deletions(-)

Detailed changes

assets/themes/cave-dark.json 🔗

@@ -1687,7 +1687,7 @@
       "left": 6
     }
   },
-  "incoming_request_notification": {
+  "contact_notification": {
     "header_avatar": {
       "height": 12,
       "width": 12,

assets/themes/cave-light.json 🔗

@@ -1687,7 +1687,7 @@
       "left": 6
     }
   },
-  "incoming_request_notification": {
+  "contact_notification": {
     "header_avatar": {
       "height": 12,
       "width": 12,

assets/themes/dark.json 🔗

@@ -1687,7 +1687,7 @@
       "left": 6
     }
   },
-  "incoming_request_notification": {
+  "contact_notification": {
     "header_avatar": {
       "height": 12,
       "width": 12,

assets/themes/light.json 🔗

@@ -1687,7 +1687,7 @@
       "left": 6
     }
   },
-  "incoming_request_notification": {
+  "contact_notification": {
     "header_avatar": {
       "height": 12,
       "width": 12,

assets/themes/solarized-dark.json 🔗

@@ -1687,7 +1687,7 @@
       "left": 6
     }
   },
-  "incoming_request_notification": {
+  "contact_notification": {
     "header_avatar": {
       "height": 12,
       "width": 12,

crates/client/src/user.rs 🔗

@@ -54,13 +54,21 @@ pub struct UserStore {
     _maintain_current_user: Task<()>,
 }
 
-pub enum Event {
-    ContactRequested(Arc<User>),
-    ContactRequestCancelled(Arc<User>),
+#[derive(Clone)]
+pub struct ContactEvent {
+    pub user: Arc<User>,
+    pub kind: ContactEventKind,
+}
+
+#[derive(Clone, Copy)]
+pub enum ContactEventKind {
+    Requested,
+    Accepted,
+    Cancelled,
 }
 
 impl Entity for UserStore {
-    type Event = Event;
+    type Event = ContactEvent;
 }
 
 enum UpdateContacts {
@@ -178,8 +186,10 @@ impl UserStore {
                     // No need to paralellize here
                     let mut updated_contacts = Vec::new();
                     for contact in message.contacts {
-                        updated_contacts.push(Arc::new(
-                            Contact::from_proto(contact, &this, &mut cx).await?,
+                        let should_notify = contact.should_notify;
+                        updated_contacts.push((
+                            Arc::new(Contact::from_proto(contact, &this, &mut cx).await?),
+                            should_notify,
                         ));
                     }
 
@@ -215,7 +225,13 @@ impl UserStore {
                         this.contacts
                             .retain(|contact| !removed_contacts.contains(&contact.user.id));
                         // Update existing contacts and insert new ones
-                        for updated_contact in updated_contacts {
+                        for (updated_contact, should_notify) in updated_contacts {
+                            if should_notify {
+                                cx.emit(ContactEvent {
+                                    user: updated_contact.user.clone(),
+                                    kind: ContactEventKind::Accepted,
+                                });
+                            }
                             match this.contacts.binary_search_by_key(
                                 &&updated_contact.user.github_login,
                                 |contact| &contact.user.github_login,
@@ -228,7 +244,10 @@ impl UserStore {
                         // Remove incoming contact requests
                         this.incoming_contact_requests.retain(|user| {
                             if removed_incoming_requests.contains(&user.id) {
-                                cx.emit(Event::ContactRequestCancelled(user.clone()));
+                                cx.emit(ContactEvent {
+                                    user: user.clone(),
+                                    kind: ContactEventKind::Cancelled,
+                                });
                                 false
                             } else {
                                 true
@@ -237,7 +256,10 @@ impl UserStore {
                         // Update existing incoming requests and insert new ones
                         for (user, should_notify) in incoming_requests {
                             if should_notify {
-                                cx.emit(Event::ContactRequested(user.clone()));
+                                cx.emit(ContactEvent {
+                                    user: user.clone(),
+                                    kind: ContactEventKind::Requested,
+                                });
                             }
 
                             match this

crates/collab/src/rpc.rs 🔗

@@ -420,7 +420,7 @@ impl Server {
     async fn update_user_contacts(self: &Arc<Server>, user_id: UserId) -> Result<()> {
         let contacts = self.app_state.db.get_contacts(user_id).await?;
         let store = self.store().await;
-        let updated_contact = store.contact_for_user(user_id);
+        let updated_contact = store.contact_for_user(user_id, false);
         for contact in contacts {
             if let db::Contact::Accepted {
                 user_id: contact_user_id,
@@ -1049,7 +1049,9 @@ impl Server {
             // Update responder with new contact
             let mut update = proto::UpdateContacts::default();
             if accept {
-                update.contacts.push(store.contact_for_user(requester_id));
+                update
+                    .contacts
+                    .push(store.contact_for_user(requester_id, false));
             }
             update
                 .remove_incoming_requests
@@ -1061,7 +1063,9 @@ impl Server {
             // Update requester with new contact
             let mut update = proto::UpdateContacts::default();
             if accept {
-                update.contacts.push(store.contact_for_user(responder_id));
+                update
+                    .contacts
+                    .push(store.contact_for_user(responder_id, true));
             }
             update
                 .remove_outgoing_requests

crates/collab/src/rpc/store.rs 🔗

@@ -225,8 +225,13 @@ impl Store {
 
         for contact in contacts {
             match contact {
-                db::Contact::Accepted { user_id, .. } => {
-                    update.contacts.push(self.contact_for_user(user_id));
+                db::Contact::Accepted {
+                    user_id,
+                    should_notify,
+                } => {
+                    update
+                        .contacts
+                        .push(self.contact_for_user(user_id, should_notify));
                 }
                 db::Contact::Outgoing { user_id } => {
                     update.outgoing_requests.push(user_id.to_proto())
@@ -246,11 +251,12 @@ impl Store {
         update
     }
 
-    pub fn contact_for_user(&self, user_id: UserId) -> proto::Contact {
+    pub fn contact_for_user(&self, user_id: UserId, should_notify: bool) -> proto::Contact {
         proto::Contact {
             user_id: user_id.to_proto(),
             projects: self.project_metadata_for_user(user_id),
             online: self.is_user_online(user_id),
+            should_notify,
         }
     }
 

crates/contacts_panel/src/contact_notification.rs 🔗

@@ -0,0 +1,224 @@
+use client::{ContactEvent, ContactEventKind, UserStore};
+use gpui::{
+    elements::*, impl_internal_actions, platform::CursorStyle, Entity, ModelHandle,
+    MutableAppContext, RenderContext, View, ViewContext,
+};
+use settings::Settings;
+use workspace::Notification;
+
+impl_internal_actions!(contact_notifications, [Dismiss, RespondToContactRequest]);
+
+pub fn init(cx: &mut MutableAppContext) {
+    cx.add_action(ContactNotification::dismiss);
+    cx.add_action(ContactNotification::respond_to_contact_request);
+}
+
+pub struct ContactNotification {
+    user_store: ModelHandle<UserStore>,
+    event: ContactEvent,
+}
+
+#[derive(Clone)]
+struct Dismiss(u64);
+
+#[derive(Clone)]
+pub struct RespondToContactRequest {
+    pub user_id: u64,
+    pub accept: bool,
+}
+
+pub enum Event {
+    Dismiss,
+}
+
+enum Reject {}
+enum Accept {}
+
+impl Entity for ContactNotification {
+    type Event = Event;
+}
+
+impl View for ContactNotification {
+    fn ui_name() -> &'static str {
+        "ContactNotification"
+    }
+
+    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        match self.event.kind {
+            ContactEventKind::Requested => self.render_incoming_request(cx),
+            ContactEventKind::Accepted => self.render_acceptance(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(
+        event: ContactEvent,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ViewContext<Self>,
+    ) -> Self {
+        cx.subscribe(&user_store, move |this, _, event, cx| {
+            if let client::ContactEvent {
+                kind: ContactEventKind::Cancelled,
+                user,
+            } = event
+            {
+                if user.id == this.event.user.id {
+                    cx.emit(Event::Dismiss);
+                }
+            }
+        })
+        .detach();
+
+        Self { event, user_store }
+    }
+
+    fn render_incoming_request(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = cx.global::<Settings>().theme.clone();
+        let theme = &theme.contact_notification;
+        let user = &self.event.user;
+        let user_id = user.id;
+
+        Flex::column()
+            .with_child(self.render_header("added you", theme, cx))
+            .with_child(
+                Label::new(
+                    "They won't know if you decline.".to_string(),
+                    theme.body_message.text.clone(),
+                )
+                .contained()
+                .with_style(theme.body_message.container)
+                .boxed(),
+            )
+            .with_child(
+                Flex::row()
+                    .with_child(
+                        MouseEventHandler::new::<Reject, _, _>(
+                            self.event.user.id as usize,
+                            cx,
+                            |_, _| {
+                                Label::new("Reject".to_string(), theme.button.text.clone())
+                                    .contained()
+                                    .with_style(theme.button.container)
+                                    .boxed()
+                            },
+                        )
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(move |_, cx| {
+                            cx.dispatch_action(RespondToContactRequest {
+                                user_id,
+                                accept: false,
+                            });
+                        })
+                        .boxed(),
+                    )
+                    .with_child(
+                        MouseEventHandler::new::<Accept, _, _>(user.id as usize, cx, |_, _| {
+                            Label::new("Accept".to_string(), theme.button.text.clone())
+                                .contained()
+                                .with_style(theme.button.container)
+                                .boxed()
+                        })
+                        .with_cursor_style(CursorStyle::PointingHand)
+                        .on_click(move |_, cx| {
+                            cx.dispatch_action(RespondToContactRequest {
+                                user_id,
+                                accept: true,
+                            });
+                        })
+                        .boxed(),
+                    )
+                    .aligned()
+                    .right()
+                    .boxed(),
+            )
+            .contained()
+            .boxed()
+    }
+
+    fn render_acceptance(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
+        let theme = cx.global::<Settings>().theme.clone();
+        let theme = &theme.contact_notification;
+
+        self.render_header("accepted your contact request", theme, cx)
+    }
+
+    fn render_header(
+        &self,
+        message: &'static str,
+        theme: &theme::ContactNotification,
+        cx: &mut RenderContext<Self>,
+    ) -> ElementBox {
+        let user = &self.event.user;
+        let user_id = user.id;
+        Flex::row()
+            .with_children(user.avatar.clone().map(|avatar| {
+                Image::new(avatar)
+                    .with_style(theme.header_avatar)
+                    .aligned()
+                    .left()
+                    .boxed()
+            }))
+            .with_child(
+                Label::new(
+                    format!("{} {}", user.github_login, message),
+                    theme.header_message.text.clone(),
+                )
+                .contained()
+                .with_style(theme.header_message.container)
+                .aligned()
+                .boxed(),
+            )
+            .with_child(
+                MouseEventHandler::new::<Dismiss, _, _>(user.id as usize, cx, |_, _| {
+                    Svg::new("icons/reject.svg")
+                        .with_color(theme.dismiss_button.color)
+                        .constrained()
+                        .with_width(theme.dismiss_button.icon_width)
+                        .aligned()
+                        .contained()
+                        .with_style(theme.dismiss_button.container)
+                        .constrained()
+                        .with_width(theme.dismiss_button.button_width)
+                        .with_height(theme.dismiss_button.button_width)
+                        .aligned()
+                        .boxed()
+                })
+                .with_cursor_style(CursorStyle::PointingHand)
+                .on_click(move |_, cx| cx.dispatch_action(Dismiss(user_id)))
+                .flex_float()
+                .boxed(),
+            )
+            .constrained()
+            .with_height(theme.header_height)
+            .boxed()
+    }
+
+    fn dismiss(&mut self, _: &Dismiss, cx: &mut ViewContext<Self>) {
+        self.user_store.update(cx, |store, cx| {
+            store
+                .dismiss_contact_request(self.event.user.id, cx)
+                .detach_and_log_err(cx);
+        });
+        cx.emit(Event::Dismiss);
+    }
+
+    fn respond_to_contact_request(
+        &mut self,
+        action: &RespondToContactRequest,
+        cx: &mut ViewContext<Self>,
+    ) {
+        self.user_store
+            .update(cx, |store, cx| {
+                store.respond_to_contact_request(action.user_id, action.accept, cx)
+            })
+            .detach();
+    }
+}

crates/contacts_panel/src/contact_notifications.rs 🔗

@@ -1,206 +0,0 @@
-use client::{User, UserStore};
-use gpui::{
-    elements::*, impl_internal_actions, platform::CursorStyle, Entity, ModelHandle,
-    MutableAppContext, RenderContext, View, ViewContext,
-};
-use settings::Settings;
-use std::sync::Arc;
-use workspace::Notification;
-
-impl_internal_actions!(contact_notifications, [Dismiss, RespondToContactRequest]);
-
-pub fn init(cx: &mut MutableAppContext) {
-    cx.add_action(IncomingRequestNotification::dismiss);
-    cx.add_action(IncomingRequestNotification::respond_to_contact_request);
-}
-
-pub struct IncomingRequestNotification {
-    user: Arc<User>,
-    user_store: ModelHandle<UserStore>,
-}
-
-#[derive(Clone)]
-struct Dismiss(u64);
-
-#[derive(Clone)]
-pub struct RespondToContactRequest {
-    pub user_id: u64,
-    pub accept: bool,
-}
-
-pub enum Event {
-    Dismiss,
-}
-
-impl Entity for IncomingRequestNotification {
-    type Event = Event;
-}
-
-impl View for IncomingRequestNotification {
-    fn ui_name() -> &'static str {
-        "IncomingRequestNotification"
-    }
-
-    fn render(&mut self, cx: &mut RenderContext<Self>) -> ElementBox {
-        enum Dismiss {}
-        enum Reject {}
-        enum Accept {}
-
-        let theme = cx.global::<Settings>().theme.clone();
-        let theme = &theme.incoming_request_notification;
-        let user_id = self.user.id;
-
-        Flex::column()
-            .with_child(
-                Flex::row()
-                    .with_children(self.user.avatar.clone().map(|avatar| {
-                        Image::new(avatar)
-                            .with_style(theme.header_avatar)
-                            .aligned()
-                            .left()
-                            .boxed()
-                    }))
-                    .with_child(
-                        Label::new(
-                            format!("{} added you", self.user.github_login),
-                            theme.header_message.text.clone(),
-                        )
-                        .contained()
-                        .with_style(theme.header_message.container)
-                        .aligned()
-                        .boxed(),
-                    )
-                    .with_child(
-                        MouseEventHandler::new::<Dismiss, _, _>(
-                            self.user.id as usize,
-                            cx,
-                            |_, _| {
-                                Svg::new("icons/reject.svg")
-                                    .with_color(theme.dismiss_button.color)
-                                    .constrained()
-                                    .with_width(theme.dismiss_button.icon_width)
-                                    .aligned()
-                                    .contained()
-                                    .with_style(theme.dismiss_button.container)
-                                    .constrained()
-                                    .with_width(theme.dismiss_button.button_width)
-                                    .with_height(theme.dismiss_button.button_width)
-                                    .aligned()
-                                    .boxed()
-                            },
-                        )
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(move |_, cx| cx.dispatch_action(Dismiss(user_id)))
-                        .flex_float()
-                        .boxed(),
-                    )
-                    .constrained()
-                    .with_height(theme.header_height)
-                    .boxed(),
-            )
-            .with_child(
-                Label::new(
-                    "They won't know if you decline.".to_string(),
-                    theme.body_message.text.clone(),
-                )
-                .contained()
-                .with_style(theme.body_message.container)
-                .boxed(),
-            )
-            .with_child(
-                Flex::row()
-                    .with_child(
-                        MouseEventHandler::new::<Reject, _, _>(
-                            self.user.id as usize,
-                            cx,
-                            |_, _| {
-                                Label::new("Reject".to_string(), theme.button.text.clone())
-                                    .contained()
-                                    .with_style(theme.button.container)
-                                    .boxed()
-                            },
-                        )
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(move |_, cx| {
-                            cx.dispatch_action(RespondToContactRequest {
-                                user_id,
-                                accept: false,
-                            });
-                        })
-                        .boxed(),
-                    )
-                    .with_child(
-                        MouseEventHandler::new::<Accept, _, _>(
-                            self.user.id as usize,
-                            cx,
-                            |_, _| {
-                                Label::new("Accept".to_string(), theme.button.text.clone())
-                                    .contained()
-                                    .with_style(theme.button.container)
-                                    .boxed()
-                            },
-                        )
-                        .with_cursor_style(CursorStyle::PointingHand)
-                        .on_click(move |_, cx| {
-                            cx.dispatch_action(RespondToContactRequest {
-                                user_id,
-                                accept: true,
-                            });
-                        })
-                        .boxed(),
-                    )
-                    .aligned()
-                    .right()
-                    .boxed(),
-            )
-            .contained()
-            .boxed()
-    }
-}
-
-impl Notification for IncomingRequestNotification {
-    fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
-        matches!(event, Event::Dismiss)
-    }
-}
-
-impl IncomingRequestNotification {
-    pub fn new(
-        user: Arc<User>,
-        user_store: ModelHandle<UserStore>,
-        cx: &mut ViewContext<Self>,
-    ) -> Self {
-        let user_id = user.id;
-        cx.subscribe(&user_store, move |_, _, event, cx| {
-            if let client::Event::ContactRequestCancelled(user) = event {
-                if user.id == user_id {
-                    cx.emit(Event::Dismiss);
-                }
-            }
-        })
-        .detach();
-
-        Self { user, user_store }
-    }
-
-    fn dismiss(&mut self, _: &Dismiss, 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,
-        action: &RespondToContactRequest,
-        cx: &mut ViewContext<Self>,
-    ) {
-        self.user_store
-            .update(cx, |store, cx| {
-                store.respond_to_contact_request(action.user_id, action.accept, cx)
-            })
-            .detach();
-    }
-}

crates/contacts_panel/src/contacts_panel.rs 🔗

@@ -1,8 +1,8 @@
 mod contact_finder;
-mod contact_notifications;
+mod contact_notification;
 
-use client::{Contact, User, UserStore};
-use contact_notifications::IncomingRequestNotification;
+use client::{Contact, ContactEventKind, User, UserStore};
+use contact_notification::ContactNotification;
 use editor::{Cancel, Editor};
 use fuzzy::{match_strings, StringMatchCandidate};
 use gpui::{
@@ -55,7 +55,7 @@ pub struct RespondToContactRequest {
 
 pub fn init(cx: &mut MutableAppContext) {
     contact_finder::init(cx);
-    contact_notifications::init(cx);
+    contact_notification::init(cx);
     cx.add_action(ContactsPanel::request_contact);
     cx.add_action(ContactsPanel::remove_contact);
     cx.add_action(ContactsPanel::respond_to_contact_request);
@@ -85,25 +85,22 @@ impl ContactsPanel {
         .detach();
 
         cx.subscribe(&app_state.user_store, {
-            let user_store = app_state.user_store.clone();
-            move |_, _, event, cx| match event {
-                client::Event::ContactRequested(user) => {
-                    if let Some(workspace) = workspace.upgrade(cx) {
-                        workspace.update(cx, |workspace, cx| {
-                            workspace.show_notification(
+            let user_store = app_state.user_store.downgrade();
+            move |_, _, event, cx| {
+                if let Some((workspace, user_store)) =
+                    workspace.upgrade(cx).zip(user_store.upgrade(cx))
+                {
+                    workspace.update(cx, |workspace, cx| match event.kind {
+                        ContactEventKind::Requested | ContactEventKind::Accepted => workspace
+                            .show_notification(
                                 cx.add_view(|cx| {
-                                    IncomingRequestNotification::new(
-                                        user.clone(),
-                                        user_store.clone(),
-                                        cx,
-                                    )
+                                    ContactNotification::new(event.clone(), user_store, cx)
                                 }),
                                 cx,
-                            )
-                        })
-                    }
+                            ),
+                        _ => {}
+                    });
                 }
-                _ => {}
             }
         })
         .detach();

crates/rpc/proto/zed.proto 🔗

@@ -877,6 +877,7 @@ message Contact {
     uint64 user_id = 1;
     repeated ProjectMetadata projects = 2;
     bool online = 3;
+    bool should_notify = 4;
 }
 
 message ProjectMetadata {

crates/theme/src/theme.rs 🔗

@@ -29,7 +29,7 @@ pub struct Theme {
     pub search: Search,
     pub project_diagnostics: ProjectDiagnostics,
     pub breadcrumbs: ContainedText,
-    pub incoming_request_notification: IncomingRequestNotification,
+    pub contact_notification: ContactNotification,
 }
 
 #[derive(Deserialize, Default)]
@@ -357,7 +357,7 @@ pub struct ProjectDiagnostics {
 }
 
 #[derive(Deserialize, Default)]
-pub struct IncomingRequestNotification {
+pub struct ContactNotification {
     pub header_avatar: ImageStyle,
     pub header_message: ContainedText,
     pub header_height: f32,

styles/src/styleTree/app.ts 🔗

@@ -10,7 +10,7 @@ import search from "./search";
 import picker from "./picker";
 import workspace from "./workspace";
 import projectDiagnostics from "./projectDiagnostics";
-import incomingRequestNotification from "./incomingRequestNotification";
+import contactNotification from "./contactNotification";
 
 export const panel = {
   padding: { top: 12, left: 12, bottom: 12, right: 12 },
@@ -34,6 +34,6 @@ export default function app(theme: Theme): Object {
         left: 6,
       },
     },
-    incomingRequestNotification: incomingRequestNotification(theme),
+    contactNotification: contactNotification(theme),
   };
 }

styles/src/styleTree/incomingRequestNotification.ts → styles/src/styleTree/contactNotification.ts 🔗

@@ -1,7 +1,7 @@
 import Theme from "../themes/theme";
 import { backgroundColor, iconColor, text } from "./components";
 
-export default function incomingRequestNotification(theme: Theme): Object {
+export default function contactNotification(theme: Theme): Object {
   return {
     headerAvatar: {
       height: 12,