From f225039d360e21a84eda2d6c157103d4169af83e Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 09:12:55 -0700 Subject: [PATCH] Display invite response buttons inline in notification panel --- crates/channel/src/channel_store.rs | 7 +- .../20221109000000_test_schema.sql | 5 +- .../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 | 84 +++++++--- 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 ++++++++++++++---- .../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, 421 insertions(+), 169 deletions(-) create mode 100644 styles/src/style_tree/notification_panel.ts diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 918a1e1dc1..d8dc7896ea 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -673,14 +673,15 @@ impl ChannelStore { &mut self, channel_id: ChannelId, accept: bool, - ) -> impl Future> { + cx: &mut ModelContext, + ) -> Task> { 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( diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 4372d7dc8a..8e714f1444 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/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"); diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql index 83cfd43978..277f16f4e3 100644 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ b/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"); diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 1bf5c95f6b..852d3645dd 100644 --- a/crates/collab/src/db.rs +++ b/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, diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index d64b8028e3..9754c2ac83 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -161,7 +161,7 @@ impl Database { invitee_id: UserId, inviter_id: UserId, is_admin: bool, - ) -> Result> { + ) -> Result { 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 { 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 } diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index 709ed941f7..4509bb8495 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -123,7 +123,7 @@ impl Database { &self, sender_id: UserId, receiver_id: UserId, - ) -> Result> { + ) -> Result { 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> { + ) -> Result { 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 } diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 50e961957c..d4024232b0 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/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> { + ) -> Result> { if avoid_duplicates { if self .find_notification(recipient_id, ¬ification, 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> { + 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 { - 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 { + 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()), + }) } diff --git a/crates/collab/src/db/tables/notification.rs b/crates/collab/src/db/tables/notification.rs index a35e00fb5b..12517c04f6 100644 --- a/crates/collab/src/db/tables/notification.rs +++ b/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, pub kind: NotificationKindId, pub content: String, + pub is_read: bool, + pub response: Option, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index cd82490649..9f3c22ce97 100644 --- a/crates/collab/src/rpc.rs +++ b/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) = ¬ification { - 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) = ¬ification { - 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) = ¬ification { - 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( }); } +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, diff --git a/crates/collab/src/tests/channel_tests.rs b/crates/collab/src/tests/channel_tests.rs index 7cfcce832b..fa82f55b39 100644 --- a/crates/collab/src/tests/channel_tests.rs +++ b/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(); diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 9d03d1e17e..2dddd5961b 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/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(); diff --git a/crates/collab_ui/src/collab_panel.rs b/crates/collab_ui/src/collab_panel.rs index 30505b0876..911b94ae93 100644 --- a/crates/collab_ui/src/collab_panel.rs +++ b/crates/collab_ui/src/collab_panel.rs @@ -3181,10 +3181,11 @@ impl CollabPanel { accept: bool, cx: &mut ViewContext, ) { - 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( diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 7bf5000ec8..73c07949d0 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/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::(ix, cx, |state, _| { - let container = style.container.style_for(state); + MouseEventHandler::new::(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::(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::(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, + ) { + 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 { diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index af39941d2f..d0691db106 100644 --- a/crates/notifications/src/notification_store.rs +++ b/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, } #[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::>(); @@ -195,12 +197,7 @@ impl NotificationStore { for entry in ¬ifications { match entry.notification { - Notification::ChannelInvitation { - actor_id: inviter_id, - .. - } => { - user_ids.push(inviter_id); - } + Notification::ChannelInvitation { .. } => {} Notification::ContactRequest { actor_id: requester_id, } => { diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index d27bbade6f..46db82047e 100644 --- a/crates/rpc/proto/zed.proto +++ b/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; } diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 6ff9660159..b03e928197 100644 --- a/crates/rpc/src/notification.rs +++ b/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, diff --git a/crates/theme/src/theme.rs b/crates/theme/src/theme.rs index f335444b58..389d15ef05 100644 --- a/crates/theme/src/theme.rs +++ b/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, } +#[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, + pub icon_button: Interactive, + pub unread_text: ContainedText, + pub read_text: ContainedText, + pub timestamp: ContainedText, + pub button: Interactive, +} + #[derive(Deserialize, Default, JsonSchema)] pub struct ChatMessage { #[serde(flatten)] diff --git a/styles/src/style_tree/app.ts b/styles/src/style_tree/app.ts index 3233909fd0..aff934e9c6 100644 --- a/styles/src/style_tree/app.ts +++ b/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(), } } diff --git a/styles/src/style_tree/notification_panel.ts b/styles/src/style_tree/notification_panel.ts new file mode 100644 index 0000000000..9afdf1e00a --- /dev/null +++ b/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"), + } + } +}