Display invite response buttons inline in notification panel

Max Brunsfeld created

Change summary

crates/channel/src/channel_store.rs                              |   7 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql   |   5 
crates/collab/migrations/20231004130100_create_notifications.sql |   5 
crates/collab/src/db.rs                                          |   2 
crates/collab/src/db/queries/channels.rs                         |  57 
crates/collab/src/db/queries/contacts.rs                         |  62 
crates/collab/src/db/queries/notifications.rs                    |  82 
crates/collab/src/db/tables/notification.rs                      |   3 
crates/collab/src/rpc.rs                                         |  82 
crates/collab/src/tests/channel_tests.rs                         |   8 
crates/collab/src/tests/test_server.rs                           |   8 
crates/collab_ui/src/collab_panel.rs                             |   9 
crates/collab_ui/src/notification_panel.rs                       | 154 +
crates/notifications/src/notification_store.rs                   |   9 
crates/rpc/proto/zed.proto                                       |   9 
crates/rpc/src/notification.rs                                   |  11 
crates/theme/src/theme.rs                                        |  16 
styles/src/style_tree/app.ts                                     |   2 
styles/src/style_tree/notification_panel.ts                      |  57 
19 files changed, 420 insertions(+), 168 deletions(-)

Detailed changes

crates/channel/src/channel_store.rs 🔗

@@ -673,14 +673,15 @@ impl ChannelStore {
         &mut self,
         channel_id: ChannelId,
         accept: bool,
-    ) -> impl Future<Output = Result<()>> {
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<()>> {
         let client = self.client.clone();
-        async move {
+        cx.background().spawn(async move {
             client
                 .request(proto::RespondToChannelInvite { channel_id, accept })
                 .await?;
             Ok(())
-        }
+        })
     }
 
     pub fn get_channel_member_details(

crates/collab/migrations.sqlite/20221109000000_test_schema.sql 🔗

@@ -322,12 +322,13 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" (
 
 CREATE TABLE "notifications" (
     "id" INTEGER PRIMARY KEY AUTOINCREMENT,
-    "is_read" BOOLEAN NOT NULL DEFAULT FALSE,
     "created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP,
     "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
     "actor_id" INTEGER REFERENCES users (id) ON DELETE CASCADE,
     "kind" INTEGER NOT NULL REFERENCES notification_kinds (id),
-    "content" TEXT
+    "content" TEXT,
+    "is_read" BOOLEAN NOT NULL DEFAULT FALSE,
+    "response" BOOLEAN
 );
 
 CREATE INDEX "index_notifications_on_recipient_id_is_read" ON "notifications" ("recipient_id", "is_read");

crates/collab/migrations/20231004130100_create_notifications.sql 🔗

@@ -7,12 +7,13 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" (
 
 CREATE TABLE notifications (
     "id" SERIAL PRIMARY KEY,
-    "is_read" BOOLEAN NOT NULL DEFAULT FALSE,
     "created_at" TIMESTAMP NOT NULL DEFAULT now(),
     "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE,
     "actor_id" INTEGER REFERENCES users (id) ON DELETE CASCADE,
     "kind" INTEGER NOT NULL REFERENCES notification_kinds (id),
-    "content" TEXT
+    "content" TEXT,
+    "is_read" BOOLEAN NOT NULL DEFAULT FALSE,
+    "response" BOOLEAN
 );
 
 CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id");

crates/collab/src/db.rs 🔗

@@ -384,6 +384,8 @@ impl Contact {
     }
 }
 
+pub type NotificationBatch = Vec<(UserId, proto::Notification)>;
+
 #[derive(Clone, Debug, PartialEq, Eq, FromQueryResult, Serialize, Deserialize)]
 pub struct Invite {
     pub email_address: String,

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

@@ -161,7 +161,7 @@ impl Database {
         invitee_id: UserId,
         inviter_id: UserId,
         is_admin: bool,
-    ) -> Result<Option<proto::Notification>> {
+    ) -> Result<NotificationBatch> {
         self.transaction(move |tx| async move {
             self.check_user_is_channel_admin(channel_id, inviter_id, &*tx)
                 .await?;
@@ -176,16 +176,18 @@ impl Database {
             .insert(&*tx)
             .await?;
 
-            self.create_notification(
-                invitee_id,
-                rpc::Notification::ChannelInvitation {
-                    actor_id: inviter_id.to_proto(),
-                    channel_id: channel_id.to_proto(),
-                },
-                true,
-                &*tx,
-            )
-            .await
+            Ok(self
+                .create_notification(
+                    invitee_id,
+                    rpc::Notification::ChannelInvitation {
+                        channel_id: channel_id.to_proto(),
+                    },
+                    true,
+                    &*tx,
+                )
+                .await?
+                .into_iter()
+                .collect())
         })
         .await
     }
@@ -228,7 +230,7 @@ impl Database {
         channel_id: ChannelId,
         user_id: UserId,
         accept: bool,
-    ) -> Result<()> {
+    ) -> Result<NotificationBatch> {
         self.transaction(move |tx| async move {
             let rows_affected = if accept {
                 channel_member::Entity::update_many()
@@ -246,21 +248,34 @@ impl Database {
                     .await?
                     .rows_affected
             } else {
-                channel_member::ActiveModel {
-                    channel_id: ActiveValue::Unchanged(channel_id),
-                    user_id: ActiveValue::Unchanged(user_id),
-                    ..Default::default()
-                }
-                .delete(&*tx)
-                .await?
-                .rows_affected
+                channel_member::Entity::delete_many()
+                    .filter(
+                        channel_member::Column::ChannelId
+                            .eq(channel_id)
+                            .and(channel_member::Column::UserId.eq(user_id))
+                            .and(channel_member::Column::Accepted.eq(false)),
+                    )
+                    .exec(&*tx)
+                    .await?
+                    .rows_affected
             };
 
             if rows_affected == 0 {
                 Err(anyhow!("no such invitation"))?;
             }
 
-            Ok(())
+            Ok(self
+                .respond_to_notification(
+                    user_id,
+                    &rpc::Notification::ChannelInvitation {
+                        channel_id: channel_id.to_proto(),
+                    },
+                    accept,
+                    &*tx,
+                )
+                .await?
+                .into_iter()
+                .collect())
         })
         .await
     }

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

@@ -123,7 +123,7 @@ impl Database {
         &self,
         sender_id: UserId,
         receiver_id: UserId,
-    ) -> Result<Option<proto::Notification>> {
+    ) -> Result<NotificationBatch> {
         self.transaction(|tx| async move {
             let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
                 (sender_id, receiver_id, true)
@@ -164,15 +164,18 @@ impl Database {
                 Err(anyhow!("contact already requested"))?;
             }
 
-            self.create_notification(
-                receiver_id,
-                rpc::Notification::ContactRequest {
-                    actor_id: sender_id.to_proto(),
-                },
-                true,
-                &*tx,
-            )
-            .await
+            Ok(self
+                .create_notification(
+                    receiver_id,
+                    rpc::Notification::ContactRequest {
+                        actor_id: sender_id.to_proto(),
+                    },
+                    true,
+                    &*tx,
+                )
+                .await?
+                .into_iter()
+                .collect())
         })
         .await
     }
@@ -274,7 +277,7 @@ impl Database {
         responder_id: UserId,
         requester_id: UserId,
         accept: bool,
-    ) -> Result<Option<proto::Notification>> {
+    ) -> Result<NotificationBatch> {
         self.transaction(|tx| async move {
             let (id_a, id_b, a_to_b) = if responder_id < requester_id {
                 (responder_id, requester_id, false)
@@ -316,15 +319,34 @@ impl Database {
                 Err(anyhow!("no such contact request"))?
             }
 
-            self.create_notification(
-                requester_id,
-                rpc::Notification::ContactRequestAccepted {
-                    actor_id: responder_id.to_proto(),
-                },
-                true,
-                &*tx,
-            )
-            .await
+            let mut notifications = Vec::new();
+            notifications.extend(
+                self.respond_to_notification(
+                    responder_id,
+                    &rpc::Notification::ContactRequest {
+                        actor_id: requester_id.to_proto(),
+                    },
+                    accept,
+                    &*tx,
+                )
+                .await?,
+            );
+
+            if accept {
+                notifications.extend(
+                    self.create_notification(
+                        requester_id,
+                        rpc::Notification::ContactRequestAccepted {
+                            actor_id: responder_id.to_proto(),
+                        },
+                        true,
+                        &*tx,
+                    )
+                    .await?,
+                );
+            }
+
+            Ok(notifications)
         })
         .await
     }

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

@@ -52,7 +52,7 @@ impl Database {
             while let Some(row) = rows.next().await {
                 let row = row?;
                 let kind = row.kind;
-                if let Some(proto) = self.model_to_proto(row) {
+                if let Some(proto) = model_to_proto(self, row) {
                     result.push(proto);
                 } else {
                     log::warn!("unknown notification kind {:?}", kind);
@@ -70,7 +70,7 @@ impl Database {
         notification: Notification,
         avoid_duplicates: bool,
         tx: &DatabaseTransaction,
-    ) -> Result<Option<proto::Notification>> {
+    ) -> Result<Option<(UserId, proto::Notification)>> {
         if avoid_duplicates {
             if self
                 .find_notification(recipient_id, &notification, tx)
@@ -94,20 +94,25 @@ impl Database {
             content: ActiveValue::Set(notification_proto.content.clone()),
             actor_id: ActiveValue::Set(actor_id),
             is_read: ActiveValue::NotSet,
+            response: ActiveValue::NotSet,
             created_at: ActiveValue::NotSet,
             id: ActiveValue::NotSet,
         }
         .save(&*tx)
         .await?;
 
-        Ok(Some(proto::Notification {
-            id: model.id.as_ref().to_proto(),
-            kind: notification_proto.kind,
-            timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64,
-            is_read: false,
-            content: notification_proto.content,
-            actor_id: notification_proto.actor_id,
-        }))
+        Ok(Some((
+            recipient_id,
+            proto::Notification {
+                id: model.id.as_ref().to_proto(),
+                kind: notification_proto.kind,
+                timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64,
+                is_read: false,
+                response: None,
+                content: notification_proto.content,
+                actor_id: notification_proto.actor_id,
+            },
+        )))
     }
 
     pub async fn remove_notification(
@@ -125,6 +130,32 @@ impl Database {
         Ok(id)
     }
 
+    pub async fn respond_to_notification(
+        &self,
+        recipient_id: UserId,
+        notification: &Notification,
+        response: bool,
+        tx: &DatabaseTransaction,
+    ) -> Result<Option<(UserId, proto::Notification)>> {
+        if let Some(id) = self
+            .find_notification(recipient_id, notification, tx)
+            .await?
+        {
+            let row = notification::Entity::update(notification::ActiveModel {
+                id: ActiveValue::Unchanged(id),
+                recipient_id: ActiveValue::Unchanged(recipient_id),
+                response: ActiveValue::Set(Some(response)),
+                is_read: ActiveValue::Set(true),
+                ..Default::default()
+            })
+            .exec(tx)
+            .await?;
+            Ok(model_to_proto(self, row).map(|notification| (recipient_id, notification)))
+        } else {
+            Ok(None)
+        }
+    }
+
     pub async fn find_notification(
         &self,
         recipient_id: UserId,
@@ -142,7 +173,11 @@ impl Database {
                     .add(notification::Column::RecipientId.eq(recipient_id))
                     .add(notification::Column::IsRead.eq(false))
                     .add(notification::Column::Kind.eq(kind))
-                    .add(notification::Column::ActorId.eq(proto.actor_id)),
+                    .add(if proto.actor_id.is_some() {
+                        notification::Column::ActorId.eq(proto.actor_id)
+                    } else {
+                        notification::Column::ActorId.is_null()
+                    }),
             )
             .stream(&*tx)
             .await?;
@@ -152,7 +187,7 @@ impl Database {
         while let Some(row) = rows.next().await {
             let row = row?;
             let id = row.id;
-            if let Some(proto) = self.model_to_proto(row) {
+            if let Some(proto) = model_to_proto(self, row) {
                 if let Some(existing) = Notification::from_proto(&proto) {
                     if existing == *notification {
                         return Ok(Some(id));
@@ -163,16 +198,17 @@ impl Database {
 
         Ok(None)
     }
+}
 
-    fn model_to_proto(&self, row: notification::Model) -> Option<proto::Notification> {
-        let kind = self.notification_kinds_by_id.get(&row.kind)?;
-        Some(proto::Notification {
-            id: row.id.to_proto(),
-            kind: kind.to_string(),
-            timestamp: row.created_at.assume_utc().unix_timestamp() as u64,
-            is_read: row.is_read,
-            content: row.content,
-            actor_id: row.actor_id.map(|id| id.to_proto()),
-        })
-    }
+fn model_to_proto(this: &Database, row: notification::Model) -> Option<proto::Notification> {
+    let kind = this.notification_kinds_by_id.get(&row.kind)?;
+    Some(proto::Notification {
+        id: row.id.to_proto(),
+        kind: kind.to_string(),
+        timestamp: row.created_at.assume_utc().unix_timestamp() as u64,
+        is_read: row.is_read,
+        response: row.response,
+        content: row.content,
+        actor_id: row.actor_id.map(|id| id.to_proto()),
+    })
 }

crates/collab/src/db/tables/notification.rs 🔗

@@ -7,12 +7,13 @@ use time::PrimitiveDateTime;
 pub struct Model {
     #[sea_orm(primary_key)]
     pub id: NotificationId,
-    pub is_read: bool,
     pub created_at: PrimitiveDateTime,
     pub recipient_id: UserId,
     pub actor_id: Option<UserId>,
     pub kind: NotificationKindId,
     pub content: String,
+    pub is_read: bool,
+    pub response: Option<bool>,
 }
 
 #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)]

crates/collab/src/rpc.rs 🔗

@@ -2067,7 +2067,7 @@ async fn request_contact(
         return Err(anyhow!("cannot add yourself as a contact"))?;
     }
 
-    let notification = session
+    let notifications = session
         .db()
         .await
         .send_contact_request(requester_id, responder_id)
@@ -2091,22 +2091,13 @@ async fn request_contact(
         .push(proto::IncomingContactRequest {
             requester_id: requester_id.to_proto(),
         });
-    for connection_id in session
-        .connection_pool()
-        .await
-        .user_connection_ids(responder_id)
-    {
+    let connection_pool = session.connection_pool().await;
+    for connection_id in connection_pool.user_connection_ids(responder_id) {
         session.peer.send(connection_id, update.clone())?;
-        if let Some(notification) = &notification {
-            session.peer.send(
-                connection_id,
-                proto::NewNotification {
-                    notification: Some(notification.clone()),
-                },
-            )?;
-        }
     }
 
+    send_notifications(&*connection_pool, &session.peer, notifications);
+
     response.send(proto::Ack {})?;
     Ok(())
 }
@@ -2125,7 +2116,7 @@ async fn respond_to_contact_request(
     } else {
         let accept = request.response == proto::ContactRequestResponse::Accept as i32;
 
-        let notification = db
+        let notifications = db
             .respond_to_contact_request(responder_id, requester_id, accept)
             .await?;
         let requester_busy = db.is_user_busy(requester_id).await?;
@@ -2156,17 +2147,12 @@ async fn respond_to_contact_request(
         update
             .remove_outgoing_requests
             .push(responder_id.to_proto());
+
         for connection_id in pool.user_connection_ids(requester_id) {
             session.peer.send(connection_id, update.clone())?;
-            if let Some(notification) = &notification {
-                session.peer.send(
-                    connection_id,
-                    proto::NewNotification {
-                        notification: Some(notification.clone()),
-                    },
-                )?;
-            }
         }
+
+        send_notifications(&*pool, &session.peer, notifications);
     }
 
     response.send(proto::Ack {})?;
@@ -2310,7 +2296,7 @@ async fn invite_channel_member(
     let db = session.db().await;
     let channel_id = ChannelId::from_proto(request.channel_id);
     let invitee_id = UserId::from_proto(request.user_id);
-    let notification = db
+    let notifications = db
         .invite_channel_member(channel_id, invitee_id, session.user_id, request.admin)
         .await?;
 
@@ -2325,22 +2311,13 @@ async fn invite_channel_member(
         name: channel.name,
     });
 
-    for connection_id in session
-        .connection_pool()
-        .await
-        .user_connection_ids(invitee_id)
-    {
+    let pool = session.connection_pool().await;
+    for connection_id in pool.user_connection_ids(invitee_id) {
         session.peer.send(connection_id, update.clone())?;
-        if let Some(notification) = &notification {
-            session.peer.send(
-                connection_id,
-                proto::NewNotification {
-                    notification: Some(notification.clone()),
-                },
-            )?;
-        }
     }
 
+    send_notifications(&*pool, &session.peer, notifications);
+
     response.send(proto::Ack {})?;
     Ok(())
 }
@@ -2588,7 +2565,8 @@ async fn respond_to_channel_invite(
 ) -> Result<()> {
     let db = session.db().await;
     let channel_id = ChannelId::from_proto(request.channel_id);
-    db.respond_to_channel_invite(channel_id, session.user_id, request.accept)
+    let notifications = db
+        .respond_to_channel_invite(channel_id, session.user_id, request.accept)
         .await?;
 
     let mut update = proto::UpdateChannels::default();
@@ -2636,6 +2614,11 @@ async fn respond_to_channel_invite(
             );
     }
     session.peer.send(session.connection_id, update)?;
+    send_notifications(
+        &*session.connection_pool().await,
+        &session.peer,
+        notifications,
+    );
     response.send(proto::Ack {})?;
 
     Ok(())
@@ -2853,6 +2836,29 @@ fn channel_buffer_updated<T: EnvelopedMessage>(
     });
 }
 
+fn send_notifications(
+    connection_pool: &ConnectionPool,
+    peer: &Peer,
+    notifications: db::NotificationBatch,
+) {
+    for (user_id, notification) in notifications {
+        for connection_id in connection_pool.user_connection_ids(user_id) {
+            if let Err(error) = peer.send(
+                connection_id,
+                proto::NewNotification {
+                    notification: Some(notification.clone()),
+                },
+            ) {
+                tracing::error!(
+                    "failed to send notification to {:?} {}",
+                    connection_id,
+                    error
+                );
+            }
+        }
+    }
+}
+
 async fn send_channel_message(
     request: proto::SendChannelMessage,
     response: Response<proto::SendChannelMessage>,

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

@@ -117,8 +117,8 @@ async fn test_core_channels(
     // Client B accepts the invitation.
     client_b
         .channel_store()
-        .update(cx_b, |channels, _| {
-            channels.respond_to_channel_invite(channel_a_id, true)
+        .update(cx_b, |channels, cx| {
+            channels.respond_to_channel_invite(channel_a_id, true, cx)
         })
         .await
         .unwrap();
@@ -856,8 +856,8 @@ async fn test_lost_channel_creation(
     // Client B accepts the invite
     client_b
         .channel_store()
-        .update(cx_b, |channel_store, _| {
-            channel_store.respond_to_channel_invite(channel_id, true)
+        .update(cx_b, |channel_store, cx| {
+            channel_store.respond_to_channel_invite(channel_id, true, cx)
         })
         .await
         .unwrap();

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

@@ -339,8 +339,8 @@ impl TestServer {
 
             member_cx
                 .read(ChannelStore::global)
-                .update(*member_cx, |channels, _| {
-                    channels.respond_to_channel_invite(channel_id, true)
+                .update(*member_cx, |channels, cx| {
+                    channels.respond_to_channel_invite(channel_id, true, cx)
                 })
                 .await
                 .unwrap();
@@ -626,8 +626,8 @@ impl TestClient {
 
         other_cx
             .read(ChannelStore::global)
-            .update(other_cx, |channel_store, _| {
-                channel_store.respond_to_channel_invite(channel, true)
+            .update(other_cx, |channel_store, cx| {
+                channel_store.respond_to_channel_invite(channel, true, cx)
             })
             .await
             .unwrap();

crates/collab_ui/src/collab_panel.rs 🔗

@@ -3181,10 +3181,11 @@ impl CollabPanel {
         accept: bool,
         cx: &mut ViewContext<Self>,
     ) {
-        let respond = self.channel_store.update(cx, |store, _| {
-            store.respond_to_channel_invite(channel_id, accept)
-        });
-        cx.foreground().spawn(respond).detach();
+        self.channel_store
+            .update(cx, |store, cx| {
+                store.respond_to_channel_invite(channel_id, accept, cx)
+            })
+            .detach();
     }
 
     fn call(

crates/collab_ui/src/notification_panel.rs 🔗

@@ -183,32 +183,31 @@ impl NotificationPanel {
         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 now = OffsetDateTime::now_utc();
         let timestamp = entry.timestamp;
 
         let icon;
         let text;
         let actor;
-        match entry.notification {
-            Notification::ContactRequest {
-                actor_id: requester_id,
-            } => {
-                actor = user_store.get_cached_user(requester_id)?;
+        let needs_acceptance;
+        match notification {
+            Notification::ContactRequest { actor_id } => {
+                let requester = user_store.get_cached_user(actor_id)?;
                 icon = "icons/plus.svg";
-                text = format!("{} wants to add you as a contact", actor.github_login);
+                text = format!("{} wants to add you as a contact", requester.github_login);
+                needs_acceptance = true;
+                actor = Some(requester);
             }
-            Notification::ContactRequestAccepted {
-                actor_id: contact_id,
-            } => {
-                actor = user_store.get_cached_user(contact_id)?;
+            Notification::ContactRequestAccepted { actor_id } => {
+                let responder = user_store.get_cached_user(actor_id)?;
                 icon = "icons/plus.svg";
-                text = format!("{} accepted your contact invite", actor.github_login);
+                text = format!("{} accepted your contact invite", responder.github_login);
+                needs_acceptance = false;
+                actor = Some(responder);
             }
-            Notification::ChannelInvitation {
-                actor_id: inviter_id,
-                channel_id,
-            } => {
-                actor = user_store.get_cached_user(inviter_id)?;
+            Notification::ChannelInvitation { channel_id } => {
+                actor = None;
                 let channel = channel_store.channel_for_id(channel_id).or_else(|| {
                     channel_store
                         .channel_invitations()
@@ -217,39 +216,51 @@ impl NotificationPanel {
                 })?;
 
                 icon = "icons/hash.svg";
-                text = format!(
-                    "{} invited you to join the #{} channel",
-                    actor.github_login, channel.name
-                );
+                text = format!("you were invited to join the #{} channel", channel.name);
+                needs_acceptance = true;
             }
             Notification::ChannelMessageMention {
-                actor_id: sender_id,
+                actor_id,
                 channel_id,
                 message_id,
             } => {
-                actor = user_store.get_cached_user(sender_id)?;
+                let sender = user_store.get_cached_user(actor_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{}",
-                    actor.github_login, channel.name, message.body,
+                    sender.github_login, channel.name, message.body,
                 );
+                needs_acceptance = false;
+                actor = Some(sender);
             }
         }
 
         let theme = theme::current(cx);
-        let style = &theme.chat_panel.message;
+        let style = &theme.notification_panel;
+        let response = entry.response;
+
+        let message_style = if entry.is_read {
+            style.read_text.clone()
+        } else {
+            style.unread_text.clone()
+        };
+
+        enum Decline {}
+        enum Accept {}
 
         Some(
-            MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |state, _| {
-                let container = style.container.style_for(state);
+            MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |_, cx| {
+                let container = message_style.container;
 
                 Flex::column()
                     .with_child(
                         Flex::row()
-                            .with_child(render_avatar(actor.avatar.clone(), &theme))
+                            .with_children(
+                                actor.map(|actor| render_avatar(actor.avatar.clone(), &theme)),
+                            )
                             .with_child(render_icon_button(&theme.chat_panel.icon_button, icon))
                             .with_child(
                                 Label::new(
@@ -261,9 +272,69 @@ impl NotificationPanel {
                             )
                             .align_children_center(),
                     )
-                    .with_child(Text::new(text, style.body.clone()))
+                    .with_child(Text::new(text, message_style.text.clone()))
+                    .with_children(if let Some(is_accepted) = response {
+                        Some(
+                            Label::new(
+                                if is_accepted { "Accepted" } else { "Declined" },
+                                style.button.text.clone(),
+                            )
+                            .into_any(),
+                        )
+                    } else if needs_acceptance {
+                        Some(
+                            Flex::row()
+                                .with_children([
+                                    MouseEventHandler::new::<Decline, _>(ix, cx, |state, _| {
+                                        let button = style.button.style_for(state);
+                                        Label::new("Decline", button.text.clone())
+                                            .contained()
+                                            .with_style(button.container)
+                                    })
+                                    .with_cursor_style(CursorStyle::PointingHand)
+                                    .on_click(
+                                        MouseButton::Left,
+                                        {
+                                            let notification = notification.clone();
+                                            move |_, view, cx| {
+                                                view.respond_to_notification(
+                                                    notification.clone(),
+                                                    false,
+                                                    cx,
+                                                );
+                                            }
+                                        },
+                                    ),
+                                    MouseEventHandler::new::<Accept, _>(ix, cx, |state, _| {
+                                        let button = style.button.style_for(state);
+                                        Label::new("Accept", button.text.clone())
+                                            .contained()
+                                            .with_style(button.container)
+                                    })
+                                    .with_cursor_style(CursorStyle::PointingHand)
+                                    .on_click(
+                                        MouseButton::Left,
+                                        {
+                                            let notification = notification.clone();
+                                            move |_, view, cx| {
+                                                view.respond_to_notification(
+                                                    notification.clone(),
+                                                    true,
+                                                    cx,
+                                                );
+                                            }
+                                        },
+                                    ),
+                                ])
+                                .aligned()
+                                .right()
+                                .into_any(),
+                        )
+                    } else {
+                        None
+                    })
                     .contained()
-                    .with_style(*container)
+                    .with_style(container)
                     .into_any()
             })
             .into_any(),
@@ -373,6 +444,31 @@ impl NotificationPanel {
             Notification::ChannelMessageMention { .. } => {}
         }
     }
+
+    fn respond_to_notification(
+        &mut self,
+        notification: Notification,
+        response: bool,
+        cx: &mut ViewContext<Self>,
+    ) {
+        match notification {
+            Notification::ContactRequest { actor_id } => {
+                self.user_store
+                    .update(cx, |store, cx| {
+                        store.respond_to_contact_request(actor_id, response, cx)
+                    })
+                    .detach();
+            }
+            Notification::ChannelInvitation { channel_id, .. } => {
+                self.channel_store
+                    .update(cx, |store, cx| {
+                        store.respond_to_channel_invite(channel_id, response, cx)
+                    })
+                    .detach();
+            }
+            _ => {}
+        }
+    }
 }
 
 impl Entity for NotificationPanel {

crates/notifications/src/notification_store.rs 🔗

@@ -44,6 +44,7 @@ pub struct NotificationEntry {
     pub notification: Notification,
     pub timestamp: OffsetDateTime,
     pub is_read: bool,
+    pub response: Option<bool>,
 }
 
 #[derive(Clone, Debug, Default)]
@@ -186,6 +187,7 @@ impl NotificationStore {
                     timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64)
                         .ok()?,
                     notification: Notification::from_proto(&message)?,
+                    response: message.response,
                 })
             })
             .collect::<Vec<_>>();
@@ -195,12 +197,7 @@ impl NotificationStore {
 
         for entry in &notifications {
             match entry.notification {
-                Notification::ChannelInvitation {
-                    actor_id: inviter_id,
-                    ..
-                } => {
-                    user_ids.push(inviter_id);
-                }
+                Notification::ChannelInvitation { .. } => {}
                 Notification::ContactRequest {
                     actor_id: requester_id,
                 } => {

crates/rpc/proto/zed.proto 🔗

@@ -1598,8 +1598,9 @@ message DeleteNotification {
 message Notification {
     uint64 id = 1;
     uint64 timestamp = 2;
-    bool is_read = 3;
-    string kind = 4;
-    string content = 5;
-    optional uint64 actor_id = 6;
+    string kind = 3;
+    string content = 4;
+    optional uint64 actor_id = 5;
+    bool is_read = 6;
+    optional bool response = 7;
 }

crates/rpc/src/notification.rs 🔗

@@ -13,7 +13,8 @@ const ACTOR_ID: &'static str = "actor_id";
 /// variant, add a serde alias for the old name.
 ///
 /// When a notification is initiated by a user, use the `actor_id` field
-/// to store the user's id.
+/// to store the user's id. This is value is stored in a dedicated column
+/// in the database, so it can be queried more efficiently.
 #[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, Serialize, Deserialize)]
 #[serde(tag = "kind")]
 pub enum Notification {
@@ -24,7 +25,6 @@ pub enum Notification {
         actor_id: u64,
     },
     ChannelInvitation {
-        actor_id: u64,
         channel_id: u64,
     },
     ChannelMessageMention {
@@ -40,7 +40,7 @@ impl Notification {
         let mut actor_id = None;
         let value = value.as_object_mut().unwrap();
         let Some(Value::String(kind)) = value.remove(KIND) else {
-            unreachable!()
+            unreachable!("kind is the enum tag")
         };
         if let map::Entry::Occupied(e) = value.entry(ACTOR_ID) {
             if e.get().is_u64() {
@@ -76,10 +76,7 @@ fn test_notification() {
     for notification in [
         Notification::ContactRequest { actor_id: 1 },
         Notification::ContactRequestAccepted { actor_id: 2 },
-        Notification::ChannelInvitation {
-            actor_id: 0,
-            channel_id: 100,
-        },
+        Notification::ChannelInvitation { channel_id: 100 },
         Notification::ChannelMessageMention {
             actor_id: 200,
             channel_id: 30,

crates/theme/src/theme.rs 🔗

@@ -53,6 +53,7 @@ pub struct Theme {
     pub collab_panel: CollabPanel,
     pub project_panel: ProjectPanel,
     pub chat_panel: ChatPanel,
+    pub notification_panel: NotificationPanel,
     pub command_palette: CommandPalette,
     pub picker: Picker,
     pub editor: Editor,
@@ -644,6 +645,21 @@ pub struct ChatPanel {
     pub icon_button: Interactive<IconButton>,
 }
 
+#[derive(Deserialize, Default, JsonSchema)]
+pub struct NotificationPanel {
+    #[serde(flatten)]
+    pub container: ContainerStyle,
+    pub list: ContainerStyle,
+    pub avatar: AvatarStyle,
+    pub avatar_container: ContainerStyle,
+    pub sign_in_prompt: Interactive<TextStyle>,
+    pub icon_button: Interactive<IconButton>,
+    pub unread_text: ContainedText,
+    pub read_text: ContainedText,
+    pub timestamp: ContainedText,
+    pub button: Interactive<ContainedText>,
+}
+
 #[derive(Deserialize, Default, JsonSchema)]
 pub struct ChatMessage {
     #[serde(flatten)]

styles/src/style_tree/app.ts 🔗

@@ -13,6 +13,7 @@ import project_shared_notification from "./project_shared_notification"
 import tooltip from "./tooltip"
 import terminal from "./terminal"
 import chat_panel from "./chat_panel"
+import notification_panel from "./notification_panel"
 import collab_panel from "./collab_panel"
 import toolbar_dropdown_menu from "./toolbar_dropdown_menu"
 import incoming_call_notification from "./incoming_call_notification"
@@ -57,6 +58,7 @@ export default function app(): any {
         assistant: assistant(),
         feedback: feedback(),
         chat_panel: chat_panel(),
+        notification_panel: notification_panel(),
         component_test: component_test(),
     }
 }

styles/src/style_tree/notification_panel.ts 🔗

@@ -0,0 +1,57 @@
+import { background, text } from "./components"
+import { icon_button } from "../component/icon_button"
+import { useTheme } from "../theme"
+import { interactive } from "../element"
+
+export default function chat_panel(): any {
+    const theme = useTheme()
+    const layer = theme.middle
+
+    return {
+        background: background(layer),
+        avatar: {
+            icon_width: 24,
+            icon_height: 24,
+            corner_radius: 4,
+            outer_width: 24,
+            outer_corner_radius: 16,
+        },
+        read_text: text(layer, "sans", "base"),
+        unread_text: text(layer, "sans", "base"),
+        button: interactive({
+            base: {
+                ...text(theme.lowest, "sans", "on", { size: "xs" }),
+                background: background(theme.lowest, "on"),
+                padding: 4,
+                corner_radius: 6,
+                margin: { left: 6 },
+            },
+
+            state: {
+                hovered: {
+                    background: background(theme.lowest, "on", "hovered"),
+                },
+            },
+        }),
+        timestamp: text(layer, "sans", "base", "disabled"),
+        avatar_container: {
+            padding: {
+                right: 6,
+                left: 2,
+                top: 2,
+                bottom: 2,
+            }
+        },
+        list: {
+
+        },
+        icon_button: icon_button({
+            variant: "ghost",
+            color: "variant",
+            size: "sm",
+        }),
+        sign_in_prompt: {
+            default: text(layer, "sans", "base"),
+        }
+    }
+}