From cf6ce0dbadf971d9366e418415992907e4b871e5 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 4 Oct 2023 14:16:32 -0700 Subject: [PATCH 01/44] Start work on storing notifications in the database --- Cargo.lock | 23 +++ Cargo.toml | 1 + .../20221109000000_test_schema.sql | 19 +++ .../20231004130100_create_notifications.sql | 18 +++ crates/collab/src/db.rs | 2 +- crates/collab/src/db/ids.rs | 1 + crates/collab/src/db/queries.rs | 1 + crates/collab/src/db/queries/access_tokens.rs | 1 + crates/collab/src/db/queries/notifications.rs | 140 ++++++++++++++++++ crates/collab/src/db/tables.rs | 2 + crates/collab/src/db/tables/notification.rs | 29 ++++ .../collab/src/db/tables/notification_kind.rs | 14 ++ crates/rpc/Cargo.toml | 1 + crates/rpc/proto/zed.proto | 41 ++++- crates/rpc/src/notification.rs | 105 +++++++++++++ crates/rpc/src/rpc.rs | 3 + 16 files changed, 399 insertions(+), 2 deletions(-) create mode 100644 crates/collab/migrations/20231004130100_create_notifications.sql create mode 100644 crates/collab/src/db/queries/notifications.rs create mode 100644 crates/collab/src/db/tables/notification.rs create mode 100644 crates/collab/src/db/tables/notification_kind.rs create mode 100644 crates/rpc/src/notification.rs diff --git a/Cargo.lock b/Cargo.lock index 01153ca0f8222e341ee77287518dca45ed53048d..a426a6a1cab743a44dde4a91864bc51e48dad1d7 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6403,6 +6403,7 @@ dependencies = [ "serde_derive", "smol", "smol-timeout", + "strum", "tempdir", "tracing", "util", @@ -6623,6 +6624,12 @@ dependencies = [ "untrusted", ] +[[package]] +name = "rustversion" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7ffc183a10b4478d04cbbbfc96d0873219d962dd5accaff2ffbd4ceb7df837f4" + [[package]] name = "rustybuzz" version = "0.3.0" @@ -7698,6 +7705,22 @@ name = "strum" version = "0.25.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "290d54ea6f91c969195bdbcd7442c8c2a2ba87da8bf60a7ee86a235d4bc1e125" +dependencies = [ + "strum_macros", +] + +[[package]] +name = "strum_macros" +version = "0.25.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ad8d03b598d3d0fff69bf533ee3ef19b8eeb342729596df84bcc7e1f96ec4059" +dependencies = [ + "heck 0.4.1", + "proc-macro2", + "quote", + "rustversion", + "syn 2.0.37", +] [[package]] name = "subtle" diff --git a/Cargo.toml b/Cargo.toml index 532610efd631edb05fee1040b2c2800e9c256a42..adb7fedb2689572b0bb9b73751fb8c6f5d2d51ce 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -112,6 +112,7 @@ serde_derive = { version = "1.0", features = ["deserialize_in_place"] } serde_json = { version = "1.0", features = ["preserve_order", "raw_value"] } smallvec = { version = "1.6", features = ["union"] } smol = { version = "1.2" } +strum = { version = "0.25.0", features = ["derive"] } sysinfo = "0.29.10" tempdir = { version = "0.3.7" } thiserror = { version = "1.0.29" } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 5a84bfd796a88e09769004cc40d0cc6a06e3118a..0e811d84559e50c1d6ce4e49661431f176e21bc0 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -312,3 +312,22 @@ CREATE TABLE IF NOT EXISTS "observed_channel_messages" ( ); CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "observed_channel_messages" ("user_id", "channel_id"); + +CREATE TABLE "notification_kinds" ( + "id" INTEGER PRIMARY KEY NOT NULL, + "name" VARCHAR NOT NULL, +); + +CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name"); + +CREATE TABLE "notifications" ( + "id" INTEGER PRIMARY KEY AUTOINCREMENT, + "created_at" TIMESTAMP NOT NULL default now, + "recipent_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), + "is_read" BOOLEAN NOT NULL DEFAULT FALSE, + "entity_id_1" INTEGER, + "entity_id_2" INTEGER +); + +CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql new file mode 100644 index 0000000000000000000000000000000000000000..e0c7b290b434e5f85218a394924d0384478dfabe --- /dev/null +++ b/crates/collab/migrations/20231004130100_create_notifications.sql @@ -0,0 +1,18 @@ +CREATE TABLE "notification_kinds" ( + "id" INTEGER PRIMARY KEY NOT NULL, + "name" VARCHAR NOT NULL, +); + +CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name"); + +CREATE TABLE notifications ( + "id" SERIAL PRIMARY KEY, + "created_at" TIMESTAMP NOT NULL DEFAULT now(), + "recipent_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), + "is_read" BOOLEAN NOT NULL DEFAULT FALSE + "entity_id_1" INTEGER, + "entity_id_2" INTEGER +); + +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 e60b7cc33dfb22f030c5600cfa0d8d0794438c1d..56e7c0d942b0beb432fc1e796b8f0248cb9ceab5 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -20,7 +20,7 @@ use rpc::{ }; use sea_orm::{ entity::prelude::*, - sea_query::{Alias, Expr, OnConflict, Query}, + sea_query::{Alias, Expr, OnConflict}, ActiveValue, Condition, ConnectionTrait, DatabaseConnection, DatabaseTransaction, DbErr, FromQueryResult, IntoActiveModel, IsolationLevel, JoinType, QueryOrder, QuerySelect, Statement, TransactionTrait, diff --git a/crates/collab/src/db/ids.rs b/crates/collab/src/db/ids.rs index 23bb9e53bf9803ba64693f94605a8e87f904c571..b5873a152fe243730d0695740f43582ee430a1b7 100644 --- a/crates/collab/src/db/ids.rs +++ b/crates/collab/src/db/ids.rs @@ -80,3 +80,4 @@ id_type!(SignupId); id_type!(UserId); id_type!(ChannelBufferCollaboratorId); id_type!(FlagId); +id_type!(NotificationId); diff --git a/crates/collab/src/db/queries.rs b/crates/collab/src/db/queries.rs index 80bd8704b27704361241a93c56f5945ef51ef3cc..629e26f1a9e2ac1479f80984d2f9ae3efe7e9ab7 100644 --- a/crates/collab/src/db/queries.rs +++ b/crates/collab/src/db/queries.rs @@ -5,6 +5,7 @@ pub mod buffers; pub mod channels; pub mod contacts; pub mod messages; +pub mod notifications; pub mod projects; pub mod rooms; pub mod servers; diff --git a/crates/collab/src/db/queries/access_tokens.rs b/crates/collab/src/db/queries/access_tokens.rs index def9428a2bedc0a8635364bc48aeb6fe419a2f11..589b6483dfceb5df285ac67b03edbee493e4705b 100644 --- a/crates/collab/src/db/queries/access_tokens.rs +++ b/crates/collab/src/db/queries/access_tokens.rs @@ -1,4 +1,5 @@ use super::*; +use sea_orm::sea_query::Query; impl Database { pub async fn create_access_token( diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs new file mode 100644 index 0000000000000000000000000000000000000000..2907ad85b7a2a4ce58c46d5aea827d626f310f44 --- /dev/null +++ b/crates/collab/src/db/queries/notifications.rs @@ -0,0 +1,140 @@ +use super::*; +use rpc::{Notification, NotificationEntityKind, NotificationKind}; + +impl Database { + pub async fn ensure_notification_kinds(&self) -> Result<()> { + self.transaction(|tx| async move { + notification_kind::Entity::insert_many(NotificationKind::all().map(|kind| { + notification_kind::ActiveModel { + id: ActiveValue::Set(kind as i32), + name: ActiveValue::Set(kind.to_string()), + } + })) + .on_conflict(OnConflict::new().do_nothing().to_owned()) + .exec(&*tx) + .await?; + Ok(()) + }) + .await + } + + pub async fn get_notifications( + &self, + recipient_id: UserId, + limit: usize, + ) -> Result { + self.transaction(|tx| async move { + let mut result = proto::AddNotifications::default(); + + let mut rows = notification::Entity::find() + .filter(notification::Column::RecipientId.eq(recipient_id)) + .order_by_desc(notification::Column::Id) + .limit(limit as u64) + .stream(&*tx) + .await?; + + let mut user_ids = Vec::new(); + let mut channel_ids = Vec::new(); + let mut message_ids = Vec::new(); + while let Some(row) = rows.next().await { + let row = row?; + + let Some(kind) = NotificationKind::from_i32(row.kind) else { + continue; + }; + let Some(notification) = Notification::from_fields( + kind, + [ + row.entity_id_1.map(|id| id as u64), + row.entity_id_2.map(|id| id as u64), + row.entity_id_3.map(|id| id as u64), + ], + ) else { + continue; + }; + + // Gather the ids of all associated entities. + let (_, associated_entities) = notification.to_fields(); + for entity in associated_entities { + let Some((id, kind)) = entity else { + break; + }; + match kind { + NotificationEntityKind::User => &mut user_ids, + NotificationEntityKind::Channel => &mut channel_ids, + NotificationEntityKind::ChannelMessage => &mut message_ids, + } + .push(id); + } + + result.notifications.push(proto::Notification { + kind: row.kind as u32, + timestamp: row.created_at.assume_utc().unix_timestamp() as u64, + is_read: row.is_read, + entity_id_1: row.entity_id_1.map(|id| id as u64), + entity_id_2: row.entity_id_2.map(|id| id as u64), + entity_id_3: row.entity_id_3.map(|id| id as u64), + }); + } + + let users = user::Entity::find() + .filter(user::Column::Id.is_in(user_ids)) + .all(&*tx) + .await?; + let channels = channel::Entity::find() + .filter(user::Column::Id.is_in(channel_ids)) + .all(&*tx) + .await?; + let messages = channel_message::Entity::find() + .filter(user::Column::Id.is_in(message_ids)) + .all(&*tx) + .await?; + + for user in users { + result.users.push(proto::User { + id: user.id.to_proto(), + github_login: user.github_login, + avatar_url: String::new(), + }); + } + for channel in channels { + result.channels.push(proto::Channel { + id: channel.id.to_proto(), + name: channel.name, + }); + } + for message in messages { + result.messages.push(proto::ChannelMessage { + id: message.id.to_proto(), + body: message.body, + timestamp: message.sent_at.assume_utc().unix_timestamp() as u64, + sender_id: message.sender_id.to_proto(), + nonce: None, + }); + } + + Ok(result) + }) + .await + } + + pub async fn create_notification( + &self, + recipient_id: UserId, + notification: Notification, + tx: &DatabaseTransaction, + ) -> Result<()> { + let (kind, associated_entities) = notification.to_fields(); + notification::ActiveModel { + recipient_id: ActiveValue::Set(recipient_id), + kind: ActiveValue::Set(kind as i32), + entity_id_1: ActiveValue::Set(associated_entities[0].map(|(id, _)| id as i32)), + entity_id_2: ActiveValue::Set(associated_entities[1].map(|(id, _)| id as i32)), + entity_id_3: ActiveValue::Set(associated_entities[2].map(|(id, _)| id as i32)), + ..Default::default() + } + .save(&*tx) + .await?; + Ok(()) + } +} diff --git a/crates/collab/src/db/tables.rs b/crates/collab/src/db/tables.rs index e19391da7dd513970b0fa593d7977fa7689c0510..4336217b2301e2a8b5949f1371f76af03f03b113 100644 --- a/crates/collab/src/db/tables.rs +++ b/crates/collab/src/db/tables.rs @@ -12,6 +12,8 @@ pub mod contact; pub mod feature_flag; pub mod follower; pub mod language_server; +pub mod notification; +pub mod notification_kind; pub mod observed_buffer_edits; pub mod observed_channel_messages; pub mod project; diff --git a/crates/collab/src/db/tables/notification.rs b/crates/collab/src/db/tables/notification.rs new file mode 100644 index 0000000000000000000000000000000000000000..6a0abe9dc689a13ca8066c6c5033c5f603132734 --- /dev/null +++ b/crates/collab/src/db/tables/notification.rs @@ -0,0 +1,29 @@ +use crate::db::{NotificationId, UserId}; +use sea_orm::entity::prelude::*; +use time::PrimitiveDateTime; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "notifications")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: NotificationId, + pub recipient_id: UserId, + pub kind: i32, + pub is_read: bool, + pub created_at: PrimitiveDateTime, + pub entity_id_1: Option, + pub entity_id_2: Option, + pub entity_id_3: Option, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation { + #[sea_orm( + belongs_to = "super::user::Entity", + from = "Column::RecipientId", + to = "super::user::Column::Id" + )] + Recipient, +} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/collab/src/db/tables/notification_kind.rs b/crates/collab/src/db/tables/notification_kind.rs new file mode 100644 index 0000000000000000000000000000000000000000..32dfb2065adf234703707defd8115167e864f372 --- /dev/null +++ b/crates/collab/src/db/tables/notification_kind.rs @@ -0,0 +1,14 @@ +use sea_orm::entity::prelude::*; + +#[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] +#[sea_orm(table_name = "notification_kinds")] +pub struct Model { + #[sea_orm(primary_key)] + pub id: i32, + pub name: String, +} + +#[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] +pub enum Relation {} + +impl ActiveModelBehavior for ActiveModel {} diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index 3c307be4fbd299bb0a5fa65813da56f69a77641b..bc750374ddb7367d06b6accdafdcf66e282d6949 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -29,6 +29,7 @@ rsa = "0.4" serde.workspace = true serde_derive.workspace = true smol-timeout = "0.6" +strum.workspace = true tracing = { version = "0.1.34", features = ["log"] } zstd = "0.11" diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 3501e70e6ac4191c5fe3141620e18cd8f7b8c32d..f51d11d3db96f1515ec9153502e33653fc3556df 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -170,7 +170,9 @@ message Envelope { LinkChannel link_channel = 140; UnlinkChannel unlink_channel = 141; - MoveChannel move_channel = 142; // current max: 144 + MoveChannel move_channel = 142; + + AddNotifications add_notification = 145; // Current max } } @@ -1557,3 +1559,40 @@ message UpdateDiffBase { uint64 buffer_id = 2; optional string diff_base = 3; } + +message AddNotifications { + repeated Notification notifications = 1; + repeated User users = 2; + repeated Channel channels = 3; + repeated ChannelMessage messages = 4; +} + +message Notification { + uint32 kind = 1; + uint64 timestamp = 2; + bool is_read = 3; + optional uint64 entity_id_1 = 4; + optional uint64 entity_id_2 = 5; + optional uint64 entity_id_3 = 6; + + // oneof variant { + // ContactRequest contact_request = 3; + // ChannelInvitation channel_invitation = 4; + // ChatMessageMention chat_message_mention = 5; + // }; + + // message ContactRequest { + // uint64 requester_id = 1; + // } + + // message ChannelInvitation { + // uint64 inviter_id = 1; + // uint64 channel_id = 2; + // } + + // message ChatMessageMention { + // uint64 sender_id = 1; + // uint64 channel_id = 2; + // uint64 message_id = 3; + // } +} diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs new file mode 100644 index 0000000000000000000000000000000000000000..40794a11c3cb562d06b133a03a133656d9523dfb --- /dev/null +++ b/crates/rpc/src/notification.rs @@ -0,0 +1,105 @@ +use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; + +// An integer indicating a type of notification. The variants' numerical +// values are stored in the database, so they should never be removed +// or changed. +#[repr(i32)] +#[derive(Copy, Clone, Debug, EnumIter, EnumString, Display)] +pub enum NotificationKind { + ContactRequest = 0, + ChannelInvitation = 1, + ChannelMessageMention = 2, +} + +pub enum Notification { + ContactRequest { + requester_id: u64, + }, + ChannelInvitation { + inviter_id: u64, + channel_id: u64, + }, + ChannelMessageMention { + sender_id: u64, + channel_id: u64, + message_id: u64, + }, +} + +#[derive(Copy, Clone)] +pub enum NotificationEntityKind { + User, + Channel, + ChannelMessage, +} + +impl Notification { + pub fn from_fields(kind: NotificationKind, entity_ids: [Option; 3]) -> Option { + use NotificationKind::*; + + Some(match kind { + ContactRequest => Self::ContactRequest { + requester_id: entity_ids[0]?, + }, + ChannelInvitation => Self::ChannelInvitation { + inviter_id: entity_ids[0]?, + channel_id: entity_ids[1]?, + }, + ChannelMessageMention => Self::ChannelMessageMention { + sender_id: entity_ids[0]?, + channel_id: entity_ids[1]?, + message_id: entity_ids[2]?, + }, + }) + } + + pub fn to_fields(&self) -> (NotificationKind, [Option<(u64, NotificationEntityKind)>; 3]) { + use NotificationKind::*; + + match self { + Self::ContactRequest { requester_id } => ( + ContactRequest, + [ + Some((*requester_id, NotificationEntityKind::User)), + None, + None, + ], + ), + + Self::ChannelInvitation { + inviter_id, + channel_id, + } => ( + ChannelInvitation, + [ + Some((*inviter_id, NotificationEntityKind::User)), + Some((*channel_id, NotificationEntityKind::User)), + None, + ], + ), + + Self::ChannelMessageMention { + sender_id, + channel_id, + message_id, + } => ( + ChannelMessageMention, + [ + Some((*sender_id, NotificationEntityKind::User)), + Some((*channel_id, NotificationEntityKind::ChannelMessage)), + Some((*message_id, NotificationEntityKind::Channel)), + ], + ), + } + } +} + +impl NotificationKind { + pub fn all() -> impl Iterator { + Self::iter() + } + + pub fn from_i32(i: i32) -> Option { + Self::iter().find(|kind| *kind as i32 == i) + } +} diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 942672b94bab387a95bfe37bb6b73308e73496dc..539ef014bbfd4589962dc4f897fd44fc3756bb1f 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -1,9 +1,12 @@ pub mod auth; mod conn; +mod notification; mod peer; pub mod proto; + pub use conn::Connection; pub use peer::*; +pub use notification::*; mod macros; pub const PROTOCOL_VERSION: u32 = 64; From 50cf25ae970decfd11b24d4bd0bba579de097708 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 5 Oct 2023 16:43:18 -0700 Subject: [PATCH 02/44] Add notification doc comments --- crates/collab/src/db/queries/notifications.rs | 6 +++--- crates/rpc/src/notification.rs | 20 +++++++++++++++++-- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 2907ad85b7a2a4ce58c46d5aea827d626f310f44..67fd00e3ec0d0294793d4128d3d357e7f59e3d68 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -42,7 +42,7 @@ impl Database { let Some(kind) = NotificationKind::from_i32(row.kind) else { continue; }; - let Some(notification) = Notification::from_fields( + let Some(notification) = Notification::from_parts( kind, [ row.entity_id_1.map(|id| id as u64), @@ -54,7 +54,7 @@ impl Database { }; // Gather the ids of all associated entities. - let (_, associated_entities) = notification.to_fields(); + let (_, associated_entities) = notification.to_parts(); for entity in associated_entities { let Some((id, kind)) = entity else { break; @@ -124,7 +124,7 @@ impl Database { notification: Notification, tx: &DatabaseTransaction, ) -> Result<()> { - let (kind, associated_entities) = notification.to_fields(); + let (kind, associated_entities) = notification.to_parts(); notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), kind: ActiveValue::Set(kind as i32), diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 40794a11c3cb562d06b133a03a133656d9523dfb..512a4731b45012c04e67d32b65075b6b94b97a57 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -34,7 +34,13 @@ pub enum NotificationEntityKind { } impl Notification { - pub fn from_fields(kind: NotificationKind, entity_ids: [Option; 3]) -> Option { + /// Load this notification from its generic representation, which is + /// used to represent it in the database, and in the wire protocol. + /// + /// The order in which a given notification type's fields are listed must + /// match the order they're listed in the `to_parts` method, and it must + /// not change, because they're stored in that order in the database. + pub fn from_parts(kind: NotificationKind, entity_ids: [Option; 3]) -> Option { use NotificationKind::*; Some(match kind { @@ -53,7 +59,17 @@ impl Notification { }) } - pub fn to_fields(&self) -> (NotificationKind, [Option<(u64, NotificationEntityKind)>; 3]) { + /// Convert this notification into its generic representation, which is + /// used to represent it in the database, and in the wire protocol. + /// + /// The order in which a given notification type's fields are listed must + /// match the order they're listed in the `from_parts` method, and it must + /// not change, because they're stored in that order in the database. + /// + /// Along with each field, provide the kind of entity that the field refers + /// to. This is used to load the associated entities for a batch of + /// notifications from the database. + pub fn to_parts(&self) -> (NotificationKind, [Option<(u64, NotificationEntityKind)>; 3]) { use NotificationKind::*; match self { From d1756b621f62c7541cffc86f632fb305e2ab2228 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 6 Oct 2023 12:56:18 -0700 Subject: [PATCH 03/44] Start work on notification panel --- Cargo.lock | 22 + Cargo.toml | 1 + assets/icons/bell.svg | 3 + assets/settings/default.json | 8 + crates/channel/src/channel_chat.rs | 41 +- crates/channel/src/channel_store.rs | 29 +- .../20221109000000_test_schema.sql | 3 +- .../20231004130100_create_notifications.sql | 9 +- crates/collab/src/db/queries/contacts.rs | 15 +- crates/collab/src/db/queries/notifications.rs | 96 +--- crates/collab/src/rpc.rs | 17 +- crates/collab_ui/Cargo.toml | 2 + crates/collab_ui/src/chat_panel.rs | 69 +-- crates/collab_ui/src/collab_ui.rs | 66 ++- crates/collab_ui/src/notification_panel.rs | 427 ++++++++++++++++++ crates/collab_ui/src/panel_settings.rs | 21 +- crates/notifications/Cargo.toml | 42 ++ .../notifications/src/notification_store.rs | 256 +++++++++++ crates/rpc/proto/zed.proto | 44 +- crates/rpc/src/notification.rs | 57 +-- crates/rpc/src/proto.rs | 3 + crates/zed/Cargo.toml | 1 + crates/zed/src/main.rs | 1 + crates/zed/src/zed.rs | 27 +- 24 files changed, 1020 insertions(+), 240 deletions(-) create mode 100644 assets/icons/bell.svg create mode 100644 crates/collab_ui/src/notification_panel.rs create mode 100644 crates/notifications/Cargo.toml create mode 100644 crates/notifications/src/notification_store.rs diff --git a/Cargo.lock b/Cargo.lock index a426a6a1cab743a44dde4a91864bc51e48dad1d7..e43cc8b5eb3f4ba01af41db1e8ffd595d0b5e166 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1559,6 +1559,7 @@ dependencies = [ "language", "log", "menu", + "notifications", "picker", "postage", "project", @@ -4727,6 +4728,26 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "notifications" +version = "0.1.0" +dependencies = [ + "anyhow", + "channel", + "client", + "clock", + "collections", + "db", + "feature_flags", + "gpui", + "rpc", + "settings", + "sum_tree", + "text", + "time", + "util", +] + [[package]] name = "ntapi" version = "0.3.7" @@ -10123,6 +10144,7 @@ dependencies = [ "log", "lsp", "node_runtime", + "notifications", "num_cpus", "outline", "parking_lot 0.11.2", diff --git a/Cargo.toml b/Cargo.toml index adb7fedb2689572b0bb9b73751fb8c6f5d2d51ce..ca4a308bae55e58cccda5c19de49c6cb5b915b48 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -47,6 +47,7 @@ members = [ "crates/media", "crates/menu", "crates/node_runtime", + "crates/notifications", "crates/outline", "crates/picker", "crates/plugin", diff --git a/assets/icons/bell.svg b/assets/icons/bell.svg new file mode 100644 index 0000000000000000000000000000000000000000..46b01b6b3871aacc6dee71e7644a7fa9af2050c7 --- /dev/null +++ b/assets/icons/bell.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/settings/default.json b/assets/settings/default.json index 1611d80e2ffbb24018c07d0081e930cc05defc2e..bab114b2f0f264d993be0869dc764c8becdb8f31 100644 --- a/assets/settings/default.json +++ b/assets/settings/default.json @@ -139,6 +139,14 @@ // Default width of the channels panel. "default_width": 240 }, + "notification_panel": { + // Whether to show the collaboration panel button in the status bar. + "button": true, + // Where to dock channels panel. Can be 'left' or 'right'. + "dock": "right", + // Default width of the channels panel. + "default_width": 240 + }, "assistant": { // Whether to show the assistant panel button in the status bar. "button": true, diff --git a/crates/channel/src/channel_chat.rs b/crates/channel/src/channel_chat.rs index 734182886b3bebeacd03dbc177bf8ffcb8ab64e2..5c4e0f88f6cf6926f1124776e5d852a44d90fca0 100644 --- a/crates/channel/src/channel_chat.rs +++ b/crates/channel/src/channel_chat.rs @@ -451,22 +451,7 @@ async fn messages_from_proto( user_store: &ModelHandle, cx: &mut AsyncAppContext, ) -> Result> { - let unique_user_ids = proto_messages - .iter() - .map(|m| m.sender_id) - .collect::>() - .into_iter() - .collect(); - user_store - .update(cx, |user_store, cx| { - user_store.get_users(unique_user_ids, cx) - }) - .await?; - - let mut messages = Vec::with_capacity(proto_messages.len()); - for message in proto_messages { - messages.push(ChannelMessage::from_proto(message, user_store, cx).await?); - } + let messages = ChannelMessage::from_proto_vec(proto_messages, user_store, cx).await?; let mut result = SumTree::new(); result.extend(messages, &()); Ok(result) @@ -498,6 +483,30 @@ impl ChannelMessage { pub fn is_pending(&self) -> bool { matches!(self.id, ChannelMessageId::Pending(_)) } + + pub async fn from_proto_vec( + proto_messages: Vec, + user_store: &ModelHandle, + cx: &mut AsyncAppContext, + ) -> Result> { + let unique_user_ids = proto_messages + .iter() + .map(|m| m.sender_id) + .collect::>() + .into_iter() + .collect(); + user_store + .update(cx, |user_store, cx| { + user_store.get_users(unique_user_ids, cx) + }) + .await?; + + let mut messages = Vec::with_capacity(proto_messages.len()); + for message in proto_messages { + messages.push(ChannelMessage::from_proto(message, user_store, cx).await?); + } + Ok(messages) + } } impl sum_tree::Item for ChannelMessage { diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index bceb2c094d5b7d8e0f6b58702a593d70fc139e0d..4a1292cdb200f4aa06e45104d353c1b835c2a2aa 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -1,6 +1,6 @@ mod channel_index; -use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat}; +use crate::{channel_buffer::ChannelBuffer, channel_chat::ChannelChat, ChannelMessage}; use anyhow::{anyhow, Result}; use channel_index::ChannelIndex; use client::{Client, Subscription, User, UserId, UserStore}; @@ -248,6 +248,33 @@ impl ChannelStore { ) } + pub fn fetch_channel_messages( + &self, + message_ids: Vec, + cx: &mut ModelContext, + ) -> Task>> { + let request = if message_ids.is_empty() { + None + } else { + Some( + self.client + .request(proto::GetChannelMessagesById { message_ids }), + ) + }; + cx.spawn_weak(|this, mut cx| async move { + if let Some(request) = request { + let response = request.await?; + let this = this + .upgrade(&cx) + .ok_or_else(|| anyhow!("channel store dropped"))?; + let user_store = this.read_with(&cx, |this, _| this.user_store.clone()); + ChannelMessage::from_proto_vec(response.messages, &user_store, &mut cx).await + } else { + Ok(Vec::new()) + } + }) + } + pub fn has_channel_buffer_changed(&self, channel_id: ChannelId) -> Option { self.channel_index .by_id() diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 0e811d84559e50c1d6ce4e49661431f176e21bc0..70c913dc95ab276775f7ca3fdda10b07c6e3017b 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -327,7 +327,8 @@ CREATE TABLE "notifications" ( "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "entity_id_1" INTEGER, - "entity_id_2" INTEGER + "entity_id_2" INTEGER, + "entity_id_3" INTEGER ); CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql index e0c7b290b434e5f85218a394924d0384478dfabe..cac3f2d8df4a78502137ba21762f10264a3d2d2c 100644 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ b/crates/collab/migrations/20231004130100_create_notifications.sql @@ -1,6 +1,6 @@ CREATE TABLE "notification_kinds" ( "id" INTEGER PRIMARY KEY NOT NULL, - "name" VARCHAR NOT NULL, + "name" VARCHAR NOT NULL ); CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name"); @@ -8,11 +8,12 @@ CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ( CREATE TABLE notifications ( "id" SERIAL PRIMARY KEY, "created_at" TIMESTAMP NOT NULL DEFAULT now(), - "recipent_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), - "is_read" BOOLEAN NOT NULL DEFAULT FALSE + "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "entity_id_1" INTEGER, - "entity_id_2" INTEGER + "entity_id_2" INTEGER, + "entity_id_3" INTEGER ); CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index 2171f1a6bf87354b8017fd35b47d77bba1e25af0..d922bc5ca2f688e426a76e8035b9d95c823b7439 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -124,7 +124,11 @@ impl Database { .await } - pub async fn send_contact_request(&self, sender_id: UserId, receiver_id: UserId) -> Result<()> { + pub async fn send_contact_request( + &self, + sender_id: UserId, + receiver_id: UserId, + ) -> Result { self.transaction(|tx| async move { let (id_a, id_b, a_to_b) = if sender_id < receiver_id { (sender_id, receiver_id, true) @@ -162,7 +166,14 @@ impl Database { .await?; if rows_affected == 1 { - Ok(()) + self.create_notification( + receiver_id, + rpc::Notification::ContactRequest { + requester_id: sender_id.to_proto(), + }, + &*tx, + ) + .await } else { Err(anyhow!("contact already requested"))? } diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 67fd00e3ec0d0294793d4128d3d357e7f59e3d68..293b896a50db5d9ae18e4c2bf8557d0f3cedee68 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -1,5 +1,5 @@ use super::*; -use rpc::{Notification, NotificationEntityKind, NotificationKind}; +use rpc::{Notification, NotificationKind}; impl Database { pub async fn ensure_notification_kinds(&self) -> Result<()> { @@ -25,49 +25,16 @@ impl Database { ) -> Result { self.transaction(|tx| async move { let mut result = proto::AddNotifications::default(); - let mut rows = notification::Entity::find() .filter(notification::Column::RecipientId.eq(recipient_id)) .order_by_desc(notification::Column::Id) .limit(limit as u64) .stream(&*tx) .await?; - - let mut user_ids = Vec::new(); - let mut channel_ids = Vec::new(); - let mut message_ids = Vec::new(); while let Some(row) = rows.next().await { let row = row?; - - let Some(kind) = NotificationKind::from_i32(row.kind) else { - continue; - }; - let Some(notification) = Notification::from_parts( - kind, - [ - row.entity_id_1.map(|id| id as u64), - row.entity_id_2.map(|id| id as u64), - row.entity_id_3.map(|id| id as u64), - ], - ) else { - continue; - }; - - // Gather the ids of all associated entities. - let (_, associated_entities) = notification.to_parts(); - for entity in associated_entities { - let Some((id, kind)) = entity else { - break; - }; - match kind { - NotificationEntityKind::User => &mut user_ids, - NotificationEntityKind::Channel => &mut channel_ids, - NotificationEntityKind::ChannelMessage => &mut message_ids, - } - .push(id); - } - result.notifications.push(proto::Notification { + id: row.id.to_proto(), kind: row.kind as u32, timestamp: row.created_at.assume_utc().unix_timestamp() as u64, is_read: row.is_read, @@ -76,43 +43,7 @@ impl Database { entity_id_3: row.entity_id_3.map(|id| id as u64), }); } - - let users = user::Entity::find() - .filter(user::Column::Id.is_in(user_ids)) - .all(&*tx) - .await?; - let channels = channel::Entity::find() - .filter(user::Column::Id.is_in(channel_ids)) - .all(&*tx) - .await?; - let messages = channel_message::Entity::find() - .filter(user::Column::Id.is_in(message_ids)) - .all(&*tx) - .await?; - - for user in users { - result.users.push(proto::User { - id: user.id.to_proto(), - github_login: user.github_login, - avatar_url: String::new(), - }); - } - for channel in channels { - result.channels.push(proto::Channel { - id: channel.id.to_proto(), - name: channel.name, - }); - } - for message in messages { - result.messages.push(proto::ChannelMessage { - id: message.id.to_proto(), - body: message.body, - timestamp: message.sent_at.assume_utc().unix_timestamp() as u64, - sender_id: message.sender_id.to_proto(), - nonce: None, - }); - } - + result.notifications.reverse(); Ok(result) }) .await @@ -123,18 +54,27 @@ impl Database { recipient_id: UserId, notification: Notification, tx: &DatabaseTransaction, - ) -> Result<()> { + ) -> Result { let (kind, associated_entities) = notification.to_parts(); - notification::ActiveModel { + let model = notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), kind: ActiveValue::Set(kind as i32), - entity_id_1: ActiveValue::Set(associated_entities[0].map(|(id, _)| id as i32)), - entity_id_2: ActiveValue::Set(associated_entities[1].map(|(id, _)| id as i32)), - entity_id_3: ActiveValue::Set(associated_entities[2].map(|(id, _)| id as i32)), + entity_id_1: ActiveValue::Set(associated_entities[0].map(|id| id as i32)), + entity_id_2: ActiveValue::Set(associated_entities[1].map(|id| id as i32)), + entity_id_3: ActiveValue::Set(associated_entities[2].map(|id| id as i32)), ..Default::default() } .save(&*tx) .await?; - Ok(()) + + Ok(proto::Notification { + id: model.id.as_ref().to_proto(), + kind: *model.kind.as_ref() as u32, + timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, + is_read: false, + entity_id_1: model.entity_id_1.as_ref().map(|id| id as u64), + entity_id_2: model.entity_id_2.as_ref().map(|id| id as u64), + entity_id_3: model.entity_id_3.as_ref().map(|id| id as u64), + }) } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index e5c6d94ce03b8b3b1d64ed58be4da53f9dcca112..eb123cf960652eed2d6375f1e67d5694df15133e 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -70,6 +70,7 @@ pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); const MESSAGE_COUNT_PER_PAGE: usize = 100; const MAX_MESSAGE_LEN: usize = 1024; +const INITIAL_NOTIFICATION_COUNT: usize = 30; lazy_static! { static ref METRIC_CONNECTIONS: IntGauge = @@ -290,6 +291,8 @@ impl Server { let pool = self.connection_pool.clone(); let live_kit_client = self.app_state.live_kit_client.clone(); + self.app_state.db.ensure_notification_kinds().await?; + let span = info_span!("start server"); self.executor.spawn_detached( async move { @@ -578,15 +581,17 @@ impl Server { this.app_state.db.set_user_connected_once(user_id, true).await?; } - let (contacts, channels_for_user, channel_invites) = future::try_join3( + let (contacts, channels_for_user, channel_invites, notifications) = future::try_join4( this.app_state.db.get_contacts(user_id), this.app_state.db.get_channels_for_user(user_id), - this.app_state.db.get_channel_invites_for_user(user_id) + this.app_state.db.get_channel_invites_for_user(user_id), + this.app_state.db.get_notifications(user_id, INITIAL_NOTIFICATION_COUNT) ).await?; { let mut pool = this.connection_pool.lock(); pool.add_connection(connection_id, user_id, user.admin); + this.peer.send(connection_id, notifications)?; this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?; this.peer.send(connection_id, build_initial_channels_update( channels_for_user, @@ -2064,7 +2069,7 @@ async fn request_contact( return Err(anyhow!("cannot add yourself as a contact"))?; } - session + let notification = session .db() .await .send_contact_request(requester_id, responder_id) @@ -2095,6 +2100,12 @@ async fn request_contact( .user_connection_ids(responder_id) { session.peer.send(connection_id, update.clone())?; + session.peer.send( + connection_id, + proto::AddNotifications { + notifications: vec![notification.clone()], + }, + )?; } response.send(proto::Ack {})?; diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 98790778c98d69afa90743f8e40d94aa397cf886..25f2d9f91aecb18175874365e53e90cd07e18003 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -37,6 +37,7 @@ fuzzy = { path = "../fuzzy" } gpui = { path = "../gpui" } language = { path = "../language" } menu = { path = "../menu" } +notifications = { path = "../notifications" } rich_text = { path = "../rich_text" } picker = { path = "../picker" } project = { path = "../project" } @@ -65,6 +66,7 @@ client = { path = "../client", features = ["test-support"] } collections = { path = "../collections", features = ["test-support"] } editor = { path = "../editor", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } +notifications = { path = "../notifications", features = ["test-support"] } project = { path = "../project", features = ["test-support"] } settings = { path = "../settings", features = ["test-support"] } util = { path = "../util", features = ["test-support"] } diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index 1a17b48f19c303d9cd26915ff331c2fcc340cc89..d58a406d7875b9e6bb6818f42adef9a7044d0a54 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -1,4 +1,7 @@ -use crate::{channel_view::ChannelView, ChatPanelSettings}; +use crate::{ + channel_view::ChannelView, format_timestamp, is_channels_feature_enabled, render_avatar, + ChatPanelSettings, +}; use anyhow::Result; use call::ActiveCall; use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore}; @@ -6,15 +9,14 @@ use client::Client; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use editor::Editor; -use feature_flags::{ChannelsAlpha, FeatureFlagAppExt}; use gpui::{ actions, elements::*, platform::{CursorStyle, MouseButton}, serde_json, views::{ItemType, Select, SelectStyle}, - AnyViewHandle, AppContext, AsyncAppContext, Entity, ImageData, ModelHandle, Subscription, Task, - View, ViewContext, ViewHandle, WeakViewHandle, + AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View, + ViewContext, ViewHandle, WeakViewHandle, }; use language::{language_settings::SoftWrap, LanguageRegistry}; use menu::Confirm; @@ -675,32 +677,6 @@ impl ChatPanel { } } -fn render_avatar(avatar: Option>, theme: &Arc) -> AnyElement { - let avatar_style = theme.chat_panel.avatar; - - avatar - .map(|avatar| { - Image::from_data(avatar) - .with_style(avatar_style.image) - .aligned() - .contained() - .with_corner_radius(avatar_style.outer_corner_radius) - .constrained() - .with_width(avatar_style.outer_width) - .with_height(avatar_style.outer_width) - .into_any() - }) - .unwrap_or_else(|| { - Empty::new() - .constrained() - .with_width(avatar_style.outer_width) - .into_any() - }) - .contained() - .with_style(theme.chat_panel.avatar_container) - .into_any() -} - fn render_remove( message_id_to_remove: Option, cx: &mut ViewContext<'_, '_, ChatPanel>, @@ -810,14 +786,14 @@ impl Panel for ChatPanel { self.active = active; if active { self.acknowledge_last_message(cx); - if !is_chat_feature_enabled(cx) { + if !is_channels_feature_enabled(cx) { cx.emit(Event::Dismissed); } } } fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { - (settings::get::(cx).button && is_chat_feature_enabled(cx)) + (settings::get::(cx).button && is_channels_feature_enabled(cx)) .then(|| "icons/conversations.svg") } @@ -842,35 +818,6 @@ impl Panel for ChatPanel { } } -fn is_chat_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool { - cx.is_staff() || cx.has_flag::() -} - -fn format_timestamp( - mut timestamp: OffsetDateTime, - mut now: OffsetDateTime, - local_timezone: UtcOffset, -) -> String { - timestamp = timestamp.to_offset(local_timezone); - now = now.to_offset(local_timezone); - - let today = now.date(); - let date = timestamp.date(); - let mut hour = timestamp.hour(); - let mut part = "am"; - if hour > 12 { - hour -= 12; - part = "pm"; - } - if date == today { - format!("{:02}:{:02}{}", hour, timestamp.minute(), part) - } else if date.next_day() == Some(today) { - format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part) - } else { - format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) - } -} - fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { Svg::new(svg_path) .with_color(style.color) diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 57d6f7b4f6b7e17b9426c75cbac2e9b80491c048..0a22c063be07a809a6b7887c8b9bfd4ee0c2df9e 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -5,27 +5,34 @@ mod collab_titlebar_item; mod contact_notification; mod face_pile; mod incoming_call_notification; +pub mod notification_panel; mod notifications; mod panel_settings; pub mod project_shared_notification; mod sharing_status_indicator; use call::{report_call_event_for_room, ActiveCall, Room}; +use feature_flags::{ChannelsAlpha, FeatureFlagAppExt}; use gpui::{ actions, + elements::{Empty, Image}, geometry::{ rect::RectF, vector::{vec2f, Vector2F}, }, platform::{Screen, WindowBounds, WindowKind, WindowOptions}, - AppContext, Task, + AnyElement, AppContext, Element, ImageData, Task, }; use std::{rc::Rc, sync::Arc}; +use theme::Theme; +use time::{OffsetDateTime, UtcOffset}; use util::ResultExt; use workspace::AppState; pub use collab_titlebar_item::CollabTitlebarItem; -pub use panel_settings::{ChatPanelSettings, CollaborationPanelSettings}; +pub use panel_settings::{ + ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings, +}; actions!( collab, @@ -35,6 +42,7 @@ actions!( pub fn init(app_state: &Arc, cx: &mut AppContext) { settings::register::(cx); settings::register::(cx); + settings::register::(cx); vcs_menu::init(cx); collab_titlebar_item::init(cx); @@ -130,3 +138,57 @@ fn notification_window_options( screen: Some(screen), } } + +fn render_avatar(avatar: Option>, theme: &Arc) -> AnyElement { + let avatar_style = theme.chat_panel.avatar; + avatar + .map(|avatar| { + Image::from_data(avatar) + .with_style(avatar_style.image) + .aligned() + .contained() + .with_corner_radius(avatar_style.outer_corner_radius) + .constrained() + .with_width(avatar_style.outer_width) + .with_height(avatar_style.outer_width) + .into_any() + }) + .unwrap_or_else(|| { + Empty::new() + .constrained() + .with_width(avatar_style.outer_width) + .into_any() + }) + .contained() + .with_style(theme.chat_panel.avatar_container) + .into_any() +} + +fn format_timestamp( + mut timestamp: OffsetDateTime, + mut now: OffsetDateTime, + local_timezone: UtcOffset, +) -> String { + timestamp = timestamp.to_offset(local_timezone); + now = now.to_offset(local_timezone); + + let today = now.date(); + let date = timestamp.date(); + let mut hour = timestamp.hour(); + let mut part = "am"; + if hour > 12 { + hour -= 12; + part = "pm"; + } + if date == today { + format!("{:02}:{:02}{}", hour, timestamp.minute(), part) + } else if date.next_day() == Some(today) { + format!("yesterday at {:02}:{:02}{}", hour, timestamp.minute(), part) + } else { + format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) + } +} + +fn is_channels_feature_enabled(cx: &gpui::WindowContext<'_>) -> bool { + cx.is_staff() || cx.has_flag::() +} diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs new file mode 100644 index 0000000000000000000000000000000000000000..a78caf5ff6e0013c9c10b2341215e5d30ac39a1c --- /dev/null +++ b/crates/collab_ui/src/notification_panel.rs @@ -0,0 +1,427 @@ +use crate::{ + format_timestamp, is_channels_feature_enabled, render_avatar, NotificationPanelSettings, +}; +use anyhow::Result; +use channel::ChannelStore; +use client::{Client, Notification, UserStore}; +use db::kvp::KEY_VALUE_STORE; +use futures::StreamExt; +use gpui::{ + actions, + elements::*, + platform::{CursorStyle, MouseButton}, + serde_json, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View, + ViewContext, ViewHandle, WeakViewHandle, WindowContext, +}; +use notifications::{NotificationEntry, NotificationEvent, NotificationStore}; +use project::Fs; +use serde::{Deserialize, Serialize}; +use settings::SettingsStore; +use std::sync::Arc; +use theme::{IconButton, Theme}; +use time::{OffsetDateTime, UtcOffset}; +use util::{ResultExt, TryFutureExt}; +use workspace::{ + dock::{DockPosition, Panel}, + Workspace, +}; + +const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel"; + +pub struct NotificationPanel { + client: Arc, + user_store: ModelHandle, + channel_store: ModelHandle, + notification_store: ModelHandle, + fs: Arc, + width: Option, + active: bool, + notification_list: ListState, + pending_serialization: Task>, + subscriptions: Vec, + local_timezone: UtcOffset, + has_focus: bool, +} + +#[derive(Serialize, Deserialize)] +struct SerializedNotificationPanel { + width: Option, +} + +#[derive(Debug)] +pub enum Event { + DockPositionChanged, + Focus, + Dismissed, +} + +actions!(chat_panel, [ToggleFocus]); + +pub fn init(_cx: &mut AppContext) {} + +impl NotificationPanel { + pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> ViewHandle { + let fs = workspace.app_state().fs.clone(); + let client = workspace.app_state().client.clone(); + let user_store = workspace.app_state().user_store.clone(); + + let notification_list = + ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { + this.render_notification(ix, cx) + }); + + cx.add_view(|cx| { + let mut status = client.status(); + + cx.spawn(|this, mut cx| async move { + while let Some(_) = status.next().await { + if this + .update(&mut cx, |_, cx| { + cx.notify(); + }) + .is_err() + { + break; + } + } + }) + .detach(); + + let mut this = Self { + fs, + client, + user_store, + local_timezone: cx.platform().local_timezone(), + channel_store: ChannelStore::global(cx), + notification_store: NotificationStore::global(cx), + notification_list, + pending_serialization: Task::ready(None), + has_focus: false, + subscriptions: Vec::new(), + active: false, + width: None, + }; + + let mut old_dock_position = this.position(cx); + this.subscriptions.extend([ + cx.subscribe(&this.notification_store, Self::on_notification_event), + cx.observe_global::(move |this: &mut Self, cx| { + let new_dock_position = this.position(cx); + if new_dock_position != old_dock_position { + old_dock_position = new_dock_position; + cx.emit(Event::DockPositionChanged); + } + cx.notify(); + }), + ]); + this + }) + } + + pub fn load( + workspace: WeakViewHandle, + cx: AsyncAppContext, + ) -> Task>> { + cx.spawn(|mut cx| async move { + let serialized_panel = if let Some(panel) = cx + .background() + .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) }) + .await + .log_err() + .flatten() + { + Some(serde_json::from_str::(&panel)?) + } else { + None + }; + + workspace.update(&mut cx, |workspace, cx| { + let panel = Self::new(workspace, cx); + if let Some(serialized_panel) = serialized_panel { + panel.update(cx, |panel, cx| { + panel.width = serialized_panel.width; + cx.notify(); + }); + } + panel + }) + }) + } + + fn serialize(&mut self, cx: &mut ViewContext) { + let width = self.width; + self.pending_serialization = cx.background().spawn( + async move { + KEY_VALUE_STORE + .write_kvp( + NOTIFICATION_PANEL_KEY.into(), + serde_json::to_string(&SerializedNotificationPanel { width })?, + ) + .await?; + anyhow::Ok(()) + } + .log_err(), + ); + } + + fn render_notification(&mut self, ix: usize, cx: &mut ViewContext) -> AnyElement { + self.try_render_notification(ix, cx) + .unwrap_or_else(|| Empty::new().into_any()) + } + + fn try_render_notification( + &mut self, + ix: usize, + cx: &mut ViewContext, + ) -> Option> { + let notification_store = self.notification_store.read(cx); + let user_store = self.user_store.read(cx); + let channel_store = self.channel_store.read(cx); + let entry = notification_store.notification_at(ix).unwrap(); + let now = OffsetDateTime::now_utc(); + let timestamp = entry.timestamp; + + let icon; + let text; + let actor; + match entry.notification { + Notification::ContactRequest { requester_id } => { + actor = user_store.get_cached_user(requester_id)?; + icon = "icons/plus.svg"; + text = format!("{} wants to add you as a contact", actor.github_login); + } + Notification::ContactRequestAccepted { contact_id } => { + actor = user_store.get_cached_user(contact_id)?; + icon = "icons/plus.svg"; + text = format!("{} accepted your contact invite", actor.github_login); + } + Notification::ChannelInvitation { + inviter_id, + channel_id, + } => { + actor = user_store.get_cached_user(inviter_id)?; + let channel = channel_store.channel_for_id(channel_id)?; + + icon = "icons/hash.svg"; + text = format!( + "{} invited you to join the #{} channel", + actor.github_login, channel.name + ); + } + Notification::ChannelMessageMention { + sender_id, + channel_id, + message_id, + } => { + actor = user_store.get_cached_user(sender_id)?; + let channel = channel_store.channel_for_id(channel_id)?; + let message = notification_store.channel_message_for_id(message_id)?; + + icon = "icons/conversations.svg"; + text = format!( + "{} mentioned you in the #{} channel:\n{}", + actor.github_login, channel.name, message.body, + ); + } + } + + let theme = theme::current(cx); + let style = &theme.chat_panel.message; + + Some( + MouseEventHandler::new::(ix, cx, |state, _| { + let container = style.container.style_for(state); + + Flex::column() + .with_child( + Flex::row() + .with_child(render_avatar(actor.avatar.clone(), &theme)) + .with_child(render_icon_button(&theme.chat_panel.icon_button, icon)) + .with_child( + Label::new( + format_timestamp(timestamp, now, self.local_timezone), + style.timestamp.text.clone(), + ) + .contained() + .with_style(style.timestamp.container), + ) + .align_children_center(), + ) + .with_child(Text::new(text, style.body.clone())) + .contained() + .with_style(*container) + .into_any() + }) + .into_any(), + ) + } + + fn render_sign_in_prompt( + &self, + theme: &Arc, + cx: &mut ViewContext, + ) -> AnyElement { + enum SignInPromptLabel {} + + MouseEventHandler::new::(0, cx, |mouse_state, _| { + Label::new( + "Sign in to view your notifications".to_string(), + theme + .chat_panel + .sign_in_prompt + .style_for(mouse_state) + .clone(), + ) + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + let client = this.client.clone(); + cx.spawn(|_, cx| async move { + client.authenticate_and_connect(true, &cx).log_err().await; + }) + .detach(); + }) + .aligned() + .into_any() + } + + fn on_notification_event( + &mut self, + _: ModelHandle, + event: &NotificationEvent, + _: &mut ViewContext, + ) { + match event { + NotificationEvent::NotificationsUpdated { + old_range, + new_count, + } => { + self.notification_list.splice(old_range.clone(), *new_count); + } + } + } +} + +impl Entity for NotificationPanel { + type Event = Event; +} + +impl View for NotificationPanel { + fn ui_name() -> &'static str { + "NotificationPanel" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + let theme = theme::current(cx); + let element = if self.client.user_id().is_some() { + List::new(self.notification_list.clone()) + .contained() + .with_style(theme.chat_panel.list) + .into_any() + } else { + self.render_sign_in_prompt(&theme, cx) + }; + element + .contained() + .with_style(theme.chat_panel.container) + .constrained() + .with_min_width(150.) + .into_any() + } + + fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext) { + self.has_focus = true; + } + + fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { + self.has_focus = false; + } +} + +impl Panel for NotificationPanel { + fn position(&self, cx: &gpui::WindowContext) -> DockPosition { + settings::get::(cx).dock + } + + fn position_is_valid(&self, position: DockPosition) -> bool { + matches!(position, DockPosition::Left | DockPosition::Right) + } + + fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext) { + settings::update_settings_file::( + self.fs.clone(), + cx, + move |settings| settings.dock = Some(position), + ); + } + + fn size(&self, cx: &gpui::WindowContext) -> f32 { + self.width + .unwrap_or_else(|| settings::get::(cx).default_width) + } + + fn set_size(&mut self, size: Option, cx: &mut ViewContext) { + self.width = size; + self.serialize(cx); + cx.notify(); + } + + fn set_active(&mut self, active: bool, cx: &mut ViewContext) { + self.active = active; + if active { + if !is_channels_feature_enabled(cx) { + cx.emit(Event::Dismissed); + } + } + } + + fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { + (settings::get::(cx).button && is_channels_feature_enabled(cx)) + .then(|| "icons/bell.svg") + } + + fn icon_tooltip(&self) -> (String, Option>) { + ( + "Notification Panel".to_string(), + Some(Box::new(ToggleFocus)), + ) + } + + fn icon_label(&self, cx: &WindowContext) -> Option { + let count = self.notification_store.read(cx).unread_notification_count(); + if count == 0 { + None + } else { + Some(count.to_string()) + } + } + + fn should_change_position_on_event(event: &Self::Event) -> bool { + matches!(event, Event::DockPositionChanged) + } + + fn should_close_on_event(event: &Self::Event) -> bool { + matches!(event, Event::Dismissed) + } + + fn has_focus(&self, _cx: &gpui::WindowContext) -> bool { + self.has_focus + } + + fn is_focus_event(event: &Self::Event) -> bool { + matches!(event, Event::Focus) + } +} + +fn render_icon_button(style: &IconButton, svg_path: &'static str) -> impl Element { + Svg::new(svg_path) + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + .contained() + .with_style(style.container) +} diff --git a/crates/collab_ui/src/panel_settings.rs b/crates/collab_ui/src/panel_settings.rs index c1aa6e5e0157692c9008f9b50688903300c60c68..f8678d774e0fc1401f572bbb2e9583048103973d 100644 --- a/crates/collab_ui/src/panel_settings.rs +++ b/crates/collab_ui/src/panel_settings.rs @@ -18,6 +18,13 @@ pub struct ChatPanelSettings { pub default_width: f32, } +#[derive(Deserialize, Debug)] +pub struct NotificationPanelSettings { + pub button: bool, + pub dock: DockPosition, + pub default_width: f32, +} + #[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)] pub struct PanelSettingsContent { pub button: Option, @@ -27,9 +34,7 @@ pub struct PanelSettingsContent { impl Setting for CollaborationPanelSettings { const KEY: Option<&'static str> = Some("collaboration_panel"); - type FileContent = PanelSettingsContent; - fn load( default_value: &Self::FileContent, user_values: &[&Self::FileContent], @@ -41,9 +46,19 @@ impl Setting for CollaborationPanelSettings { impl Setting for ChatPanelSettings { const KEY: Option<&'static str> = Some("chat_panel"); - type FileContent = PanelSettingsContent; + fn load( + default_value: &Self::FileContent, + user_values: &[&Self::FileContent], + _: &gpui::AppContext, + ) -> anyhow::Result { + Self::load_via_json_merge(default_value, user_values) + } +} +impl Setting for NotificationPanelSettings { + const KEY: Option<&'static str> = Some("notification_panel"); + type FileContent = PanelSettingsContent; fn load( default_value: &Self::FileContent, user_values: &[&Self::FileContent], diff --git a/crates/notifications/Cargo.toml b/crates/notifications/Cargo.toml new file mode 100644 index 0000000000000000000000000000000000000000..1425e079d6fcbb53cf45ab57850fefe19a495741 --- /dev/null +++ b/crates/notifications/Cargo.toml @@ -0,0 +1,42 @@ +[package] +name = "notifications" +version = "0.1.0" +edition = "2021" +publish = false + +[lib] +path = "src/notification_store.rs" +doctest = false + +[features] +test-support = [ + "channel/test-support", + "collections/test-support", + "gpui/test-support", + "rpc/test-support", +] + +[dependencies] +channel = { path = "../channel" } +client = { path = "../client" } +clock = { path = "../clock" } +collections = { path = "../collections" } +db = { path = "../db" } +feature_flags = { path = "../feature_flags" } +gpui = { path = "../gpui" } +rpc = { path = "../rpc" } +settings = { path = "../settings" } +sum_tree = { path = "../sum_tree" } +text = { path = "../text" } +util = { path = "../util" } + +anyhow.workspace = true +time.workspace = true + +[dev-dependencies] +client = { path = "../client", features = ["test-support"] } +collections = { path = "../collections", features = ["test-support"] } +gpui = { path = "../gpui", features = ["test-support"] } +rpc = { path = "../rpc", features = ["test-support"] } +settings = { path = "../settings", features = ["test-support"] } +util = { path = "../util", features = ["test-support"] } diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs new file mode 100644 index 0000000000000000000000000000000000000000..9bfa67c76e33ed19901afe280bfa8cecda7fca26 --- /dev/null +++ b/crates/notifications/src/notification_store.rs @@ -0,0 +1,256 @@ +use anyhow::Result; +use channel::{ChannelMessage, ChannelMessageId, ChannelStore}; +use client::{Client, UserStore}; +use collections::HashMap; +use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle}; +use rpc::{proto, Notification, NotificationKind, TypedEnvelope}; +use std::{ops::Range, sync::Arc}; +use sum_tree::{Bias, SumTree}; +use time::OffsetDateTime; + +pub fn init(client: Arc, user_store: ModelHandle, cx: &mut AppContext) { + let notification_store = cx.add_model(|cx| NotificationStore::new(client, user_store, cx)); + cx.set_global(notification_store); +} + +pub struct NotificationStore { + _client: Arc, + user_store: ModelHandle, + channel_messages: HashMap, + channel_store: ModelHandle, + notifications: SumTree, + _subscriptions: Vec, +} + +pub enum NotificationEvent { + NotificationsUpdated { + old_range: Range, + new_count: usize, + }, +} + +#[derive(Debug, PartialEq, Eq, Clone)] +pub struct NotificationEntry { + pub id: u64, + pub notification: Notification, + pub timestamp: OffsetDateTime, + pub is_read: bool, +} + +#[derive(Clone, Debug, Default)] +pub struct NotificationSummary { + max_id: u64, + count: usize, + unread_count: usize, +} + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct Count(usize); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct UnreadCount(usize); + +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, PartialOrd, Ord)] +struct NotificationId(u64); + +impl NotificationStore { + pub fn global(cx: &AppContext) -> ModelHandle { + cx.global::>().clone() + } + + pub fn new( + client: Arc, + user_store: ModelHandle, + cx: &mut ModelContext, + ) -> Self { + Self { + channel_store: ChannelStore::global(cx), + notifications: Default::default(), + channel_messages: Default::default(), + _subscriptions: vec![ + client.add_message_handler(cx.handle(), Self::handle_add_notifications) + ], + user_store, + _client: client, + } + } + + pub fn notification_count(&self) -> usize { + self.notifications.summary().count + } + + pub fn unread_notification_count(&self) -> usize { + self.notifications.summary().unread_count + } + + pub fn channel_message_for_id(&self, id: u64) -> Option<&ChannelMessage> { + self.channel_messages.get(&id) + } + + pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> { + let mut cursor = self.notifications.cursor::(); + cursor.seek(&Count(ix), Bias::Right, &()); + cursor.item() + } + + async fn handle_add_notifications( + this: ModelHandle, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + let mut user_ids = Vec::new(); + let mut message_ids = Vec::new(); + + let notifications = envelope + .payload + .notifications + .into_iter() + .filter_map(|message| { + Some(NotificationEntry { + id: message.id, + is_read: message.is_read, + timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64) + .ok()?, + notification: Notification::from_parts( + NotificationKind::from_i32(message.kind as i32)?, + [ + message.entity_id_1, + message.entity_id_2, + message.entity_id_3, + ], + )?, + }) + }) + .collect::>(); + if notifications.is_empty() { + return Ok(()); + } + + for entry in ¬ifications { + match entry.notification { + Notification::ChannelInvitation { inviter_id, .. } => { + user_ids.push(inviter_id); + } + Notification::ContactRequest { requester_id } => { + user_ids.push(requester_id); + } + Notification::ContactRequestAccepted { contact_id } => { + user_ids.push(contact_id); + } + Notification::ChannelMessageMention { + sender_id, + message_id, + .. + } => { + user_ids.push(sender_id); + message_ids.push(message_id); + } + } + } + + let (user_store, channel_store) = this.read_with(&cx, |this, _| { + (this.user_store.clone(), this.channel_store.clone()) + }); + + user_store + .update(&mut cx, |store, cx| store.get_users(user_ids, cx)) + .await?; + let messages = channel_store + .update(&mut cx, |store, cx| { + store.fetch_channel_messages(message_ids, cx) + }) + .await?; + this.update(&mut cx, |this, cx| { + this.channel_messages + .extend(messages.into_iter().filter_map(|message| { + if let ChannelMessageId::Saved(id) = message.id { + Some((id, message)) + } else { + None + } + })); + + let mut cursor = this.notifications.cursor::<(NotificationId, Count)>(); + let mut new_notifications = SumTree::new(); + let mut old_range = 0..0; + for (i, notification) in notifications.into_iter().enumerate() { + new_notifications.append( + cursor.slice(&NotificationId(notification.id), Bias::Left, &()), + &(), + ); + + if i == 0 { + old_range.start = cursor.start().1 .0; + } + + if cursor + .item() + .map_or(true, |existing| existing.id != notification.id) + { + cursor.next(&()); + } + + new_notifications.push(notification, &()); + } + + old_range.end = cursor.start().1 .0; + let new_count = new_notifications.summary().count; + new_notifications.append(cursor.suffix(&()), &()); + drop(cursor); + + this.notifications = new_notifications; + cx.emit(NotificationEvent::NotificationsUpdated { + old_range, + new_count, + }); + }); + + Ok(()) + } +} + +impl Entity for NotificationStore { + type Event = NotificationEvent; +} + +impl sum_tree::Item for NotificationEntry { + type Summary = NotificationSummary; + + fn summary(&self) -> Self::Summary { + NotificationSummary { + max_id: self.id, + count: 1, + unread_count: if self.is_read { 0 } else { 1 }, + } + } +} + +impl sum_tree::Summary for NotificationSummary { + type Context = (); + + fn add_summary(&mut self, summary: &Self, _: &()) { + self.max_id = self.max_id.max(summary.max_id); + self.count += summary.count; + self.unread_count += summary.unread_count; + } +} + +impl<'a> sum_tree::Dimension<'a, NotificationSummary> for NotificationId { + fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + debug_assert!(summary.max_id > self.0); + self.0 = summary.max_id; + } +} + +impl<'a> sum_tree::Dimension<'a, NotificationSummary> for Count { + fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + self.0 += summary.count; + } +} + +impl<'a> sum_tree::Dimension<'a, NotificationSummary> for UnreadCount { + fn add_summary(&mut self, summary: &NotificationSummary, _: &()) { + self.0 += summary.unread_count; + } +} diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index f51d11d3db96f1515ec9153502e33653fc3556df..4b5c17ae8b0b4e69639cd69d3e651c600113fa33 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -172,7 +172,8 @@ message Envelope { UnlinkChannel unlink_channel = 141; MoveChannel move_channel = 142; - AddNotifications add_notification = 145; // Current max + AddNotifications add_notifications = 145; + GetChannelMessagesById get_channel_messages_by_id = 146; // Current max } } @@ -1101,6 +1102,10 @@ message GetChannelMessagesResponse { bool done = 2; } +message GetChannelMessagesById { + repeated uint64 message_ids = 1; +} + message LinkChannel { uint64 channel_id = 1; uint64 to = 2; @@ -1562,37 +1567,14 @@ message UpdateDiffBase { message AddNotifications { repeated Notification notifications = 1; - repeated User users = 2; - repeated Channel channels = 3; - repeated ChannelMessage messages = 4; } message Notification { - uint32 kind = 1; - uint64 timestamp = 2; - bool is_read = 3; - optional uint64 entity_id_1 = 4; - optional uint64 entity_id_2 = 5; - optional uint64 entity_id_3 = 6; - - // oneof variant { - // ContactRequest contact_request = 3; - // ChannelInvitation channel_invitation = 4; - // ChatMessageMention chat_message_mention = 5; - // }; - - // message ContactRequest { - // uint64 requester_id = 1; - // } - - // message ChannelInvitation { - // uint64 inviter_id = 1; - // uint64 channel_id = 2; - // } - - // message ChatMessageMention { - // uint64 sender_id = 1; - // uint64 channel_id = 2; - // uint64 message_id = 3; - // } + uint64 id = 1; + uint32 kind = 2; + uint64 timestamp = 3; + bool is_read = 4; + optional uint64 entity_id_1 = 5; + optional uint64 entity_id_2 = 6; + optional uint64 entity_id_3 = 7; } diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 512a4731b45012c04e67d32b65075b6b94b97a57..fc6dc54d15199f6b65e700362d24179f2ea50b8a 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -7,14 +7,19 @@ use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; #[derive(Copy, Clone, Debug, EnumIter, EnumString, Display)] pub enum NotificationKind { ContactRequest = 0, - ChannelInvitation = 1, - ChannelMessageMention = 2, + ContactRequestAccepted = 1, + ChannelInvitation = 2, + ChannelMessageMention = 3, } +#[derive(Debug, Clone, PartialEq, Eq)] pub enum Notification { ContactRequest { requester_id: u64, }, + ContactRequestAccepted { + contact_id: u64, + }, ChannelInvitation { inviter_id: u64, channel_id: u64, @@ -26,13 +31,6 @@ pub enum Notification { }, } -#[derive(Copy, Clone)] -pub enum NotificationEntityKind { - User, - Channel, - ChannelMessage, -} - impl Notification { /// Load this notification from its generic representation, which is /// used to represent it in the database, and in the wire protocol. @@ -42,15 +40,20 @@ impl Notification { /// not change, because they're stored in that order in the database. pub fn from_parts(kind: NotificationKind, entity_ids: [Option; 3]) -> Option { use NotificationKind::*; - Some(match kind { ContactRequest => Self::ContactRequest { requester_id: entity_ids[0]?, }, + + ContactRequestAccepted => Self::ContactRequest { + requester_id: entity_ids[0]?, + }, + ChannelInvitation => Self::ChannelInvitation { inviter_id: entity_ids[0]?, channel_id: entity_ids[1]?, }, + ChannelMessageMention => Self::ChannelMessageMention { sender_id: entity_ids[0]?, channel_id: entity_ids[1]?, @@ -65,33 +68,23 @@ impl Notification { /// The order in which a given notification type's fields are listed must /// match the order they're listed in the `from_parts` method, and it must /// not change, because they're stored in that order in the database. - /// - /// Along with each field, provide the kind of entity that the field refers - /// to. This is used to load the associated entities for a batch of - /// notifications from the database. - pub fn to_parts(&self) -> (NotificationKind, [Option<(u64, NotificationEntityKind)>; 3]) { + pub fn to_parts(&self) -> (NotificationKind, [Option; 3]) { use NotificationKind::*; - match self { - Self::ContactRequest { requester_id } => ( - ContactRequest, - [ - Some((*requester_id, NotificationEntityKind::User)), - None, - None, - ], - ), + Self::ContactRequest { requester_id } => { + (ContactRequest, [Some(*requester_id), None, None]) + } + + Self::ContactRequestAccepted { contact_id } => { + (ContactRequest, [Some(*contact_id), None, None]) + } Self::ChannelInvitation { inviter_id, channel_id, } => ( ChannelInvitation, - [ - Some((*inviter_id, NotificationEntityKind::User)), - Some((*channel_id, NotificationEntityKind::User)), - None, - ], + [Some(*inviter_id), Some(*channel_id), None], ), Self::ChannelMessageMention { @@ -100,11 +93,7 @@ impl Notification { message_id, } => ( ChannelMessageMention, - [ - Some((*sender_id, NotificationEntityKind::User)), - Some((*channel_id, NotificationEntityKind::ChannelMessage)), - Some((*message_id, NotificationEntityKind::Channel)), - ], + [Some(*sender_id), Some(*channel_id), Some(*message_id)], ), } } diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index f0d7937f6f9592b2574bff7b93d3097ceba24d94..4d8f60c89607efea3ac92a7635fbfc246bb769e2 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -133,6 +133,7 @@ impl fmt::Display for PeerId { messages!( (Ack, Foreground), + (AddNotifications, Foreground), (AddProjectCollaborator, Foreground), (ApplyCodeAction, Background), (ApplyCodeActionResponse, Background), @@ -166,6 +167,7 @@ messages!( (GetHoverResponse, Background), (GetChannelMessages, Background), (GetChannelMessagesResponse, Background), + (GetChannelMessagesById, Background), (SendChannelMessage, Background), (SendChannelMessageResponse, Background), (GetCompletions, Background), @@ -329,6 +331,7 @@ request_messages!( (SetChannelMemberAdmin, Ack), (SendChannelMessage, SendChannelMessageResponse), (GetChannelMessages, GetChannelMessagesResponse), + (GetChannelMessagesById, GetChannelMessagesResponse), (GetChannelMembers, GetChannelMembersResponse), (JoinChannel, JoinRoomResponse), (RemoveChannelMessage, Ack), diff --git a/crates/zed/Cargo.toml b/crates/zed/Cargo.toml index 4174f7d6d54089ffe4250d3a6f80ab0060b1306d..c9dab0d223ebebfa479f69be6342340926f2cfd8 100644 --- a/crates/zed/Cargo.toml +++ b/crates/zed/Cargo.toml @@ -50,6 +50,7 @@ language_selector = { path = "../language_selector" } lsp = { path = "../lsp" } language_tools = { path = "../language_tools" } node_runtime = { path = "../node_runtime" } +notifications = { path = "../notifications" } assistant = { path = "../assistant" } outline = { path = "../outline" } plugin_runtime = { path = "../plugin_runtime",optional = true } diff --git a/crates/zed/src/main.rs b/crates/zed/src/main.rs index 16189f6c4e43237d12d7579f590561ef0125fde1..52ba8247b7a481b15b060b33e0587b763d12ccae 100644 --- a/crates/zed/src/main.rs +++ b/crates/zed/src/main.rs @@ -202,6 +202,7 @@ fn main() { activity_indicator::init(cx); language_tools::init(cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); + notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); collab_ui::init(&app_state, cx); feedback::init(cx); welcome::init(cx); diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 4e9a34c2699ab977eca0ed07b4784cc52f55922d..8caff21c5f5756525bd2a677f2cec44549b23786 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -221,6 +221,13 @@ pub fn init(app_state: &Arc, cx: &mut gpui::AppContext) { workspace.toggle_panel_focus::(cx); }, ); + cx.add_action( + |workspace: &mut Workspace, + _: &collab_ui::notification_panel::ToggleFocus, + cx: &mut ViewContext| { + workspace.toggle_panel_focus::(cx); + }, + ); cx.add_action( |workspace: &mut Workspace, _: &terminal_panel::ToggleFocus, @@ -275,9 +282,8 @@ pub fn initialize_workspace( QuickActionBar::new(buffer_search_bar, workspace) }); toolbar.add_item(quick_action_bar, cx); - let diagnostic_editor_controls = cx.add_view(|_| { - diagnostics::ToolbarControls::new() - }); + let diagnostic_editor_controls = + cx.add_view(|_| diagnostics::ToolbarControls::new()); toolbar.add_item(diagnostic_editor_controls, cx); let project_search_bar = cx.add_view(|_| ProjectSearchBar::new()); toolbar.add_item(project_search_bar, cx); @@ -351,12 +357,24 @@ pub fn initialize_workspace( collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone()); let chat_panel = collab_ui::chat_panel::ChatPanel::load(workspace_handle.clone(), cx.clone()); - let (project_panel, terminal_panel, assistant_panel, channels_panel, chat_panel) = futures::try_join!( + let notification_panel = collab_ui::notification_panel::NotificationPanel::load( + workspace_handle.clone(), + cx.clone(), + ); + let ( + project_panel, + terminal_panel, + assistant_panel, + channels_panel, + chat_panel, + notification_panel, + ) = futures::try_join!( project_panel, terminal_panel, assistant_panel, channels_panel, chat_panel, + notification_panel, )?; workspace_handle.update(&mut cx, |workspace, cx| { let project_panel_position = project_panel.position(cx); @@ -377,6 +395,7 @@ pub fn initialize_workspace( workspace.add_panel(assistant_panel, cx); workspace.add_panel(channels_panel, cx); workspace.add_panel(chat_panel, cx); + workspace.add_panel(notification_panel, cx); if !was_deserialized && workspace From 69c65597d96925cdcf011a75bb97eb0c005e9efc Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 11 Oct 2023 17:39:15 -0700 Subject: [PATCH 04/44] Fix use statement order --- crates/rpc/src/rpc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 539ef014bbfd4589962dc4f897fd44fc3756bb1f..4bf90669b236ae301cfbf9e2ecb4d179211df17d 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -5,8 +5,8 @@ mod peer; pub mod proto; pub use conn::Connection; -pub use peer::*; pub use notification::*; +pub use peer::*; mod macros; pub const PROTOCOL_VERSION: u32 = 64; From 1e1256dbdd82dd59458f78d53dc7593a3b9760b7 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Wed, 11 Oct 2023 17:39:41 -0700 Subject: [PATCH 05/44] Set RUST_LOG to info by default in zed-local script --- script/zed-local | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/script/zed-local b/script/zed-local index 683e31ef14d82800b8040383ee1e61709407456b..b7574a903b3ceed0f390a9881c09c61c3f5b05a6 100755 --- a/script/zed-local +++ b/script/zed-local @@ -55,6 +55,8 @@ let users = [ 'iamnbutler' ] +const RUST_LOG = process.env.RUST_LOG || 'info' + // If a user is specified, make sure it's first in the list const user = process.env.ZED_IMPERSONATE if (user) { @@ -81,7 +83,8 @@ setTimeout(() => { ZED_ALWAYS_ACTIVE: '1', ZED_SERVER_URL: 'http://localhost:8080', ZED_ADMIN_API_TOKEN: 'secret', - ZED_WINDOW_SIZE: `${instanceWidth},${instanceHeight}` + ZED_WINDOW_SIZE: `${instanceWidth},${instanceHeight}`, + RUST_LOG, } }) } From fed3ffb681645b32ad8718aa721858740519ca7f Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 12 Oct 2023 14:43:36 -0700 Subject: [PATCH 06/44] Set up notification store for integration tests --- Cargo.lock | 1 + crates/collab/Cargo.toml | 1 + .../migrations.sqlite/20221109000000_test_schema.sql | 8 ++++---- crates/collab/src/tests/test_server.rs | 3 ++- 4 files changed, 8 insertions(+), 5 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e43cc8b5eb3f4ba01af41db1e8ffd595d0b5e166..02deccb39a4f6432218ef893be73aeb22812c1a9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1502,6 +1502,7 @@ dependencies = [ "lsp", "nanoid", "node_runtime", + "notifications", "parking_lot 0.11.2", "pretty_assertions", "project", diff --git a/crates/collab/Cargo.toml b/crates/collab/Cargo.toml index b91f0e1a5ff7d3b5d1c7061fc65738dc4a5a6321..c139da831e356f0e500880c6e05d72ca3857a4e5 100644 --- a/crates/collab/Cargo.toml +++ b/crates/collab/Cargo.toml @@ -73,6 +73,7 @@ git = { path = "../git", features = ["test-support"] } live_kit_client = { path = "../live_kit_client", features = ["test-support"] } lsp = { path = "../lsp", features = ["test-support"] } node_runtime = { path = "../node_runtime" } +notifications = { path = "../notifications", features = ["test-support"] } project = { path = "../project", features = ["test-support"] } rpc = { path = "../rpc", features = ["test-support"] } settings = { path = "../settings", features = ["test-support"] } diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 70c913dc95ab276775f7ca3fdda10b07c6e3017b..c5c556500f6a2ec4916efe7b9c379aa13beecfa5 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -192,7 +192,7 @@ CREATE INDEX "index_followers_on_room_id" ON "followers" ("room_id"); CREATE TABLE "channels" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "name" VARCHAR NOT NULL, - "created_at" TIMESTAMP NOT NULL DEFAULT now + "created_at" TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ); CREATE TABLE IF NOT EXISTS "channel_chat_participants" ( @@ -315,15 +315,15 @@ CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "ob CREATE TABLE "notification_kinds" ( "id" INTEGER PRIMARY KEY NOT NULL, - "name" VARCHAR NOT NULL, + "name" VARCHAR NOT NULL ); CREATE UNIQUE INDEX "index_notification_kinds_on_name" ON "notification_kinds" ("name"); CREATE TABLE "notifications" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, - "created_at" TIMESTAMP NOT NULL default now, - "recipent_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, + "created_at" TIMESTAMP NOT NULL default CURRENT_TIMESTAMP, + "recipient_id" INTEGER NOT NULL REFERENCES users (id) ON DELETE CASCADE, "kind" INTEGER NOT NULL REFERENCES notification_kinds (id), "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "entity_id_1" INTEGER, diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 7397489b345ba87bb140e3c2d02627c33029a112..9d03d1e17ec597d59caffd908ab1ba4308967189 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -231,7 +231,8 @@ impl TestServer { workspace::init(app_state.clone(), cx); audio::init((), cx); call::init(client.clone(), user_store.clone(), cx); - channel::init(&client, user_store, cx); + channel::init(&client, user_store.clone(), cx); + notifications::init(client.clone(), user_store, cx); }); client From 324112884073afd168227a5cd1a3df3388127ac1 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 12 Oct 2023 17:17:45 -0700 Subject: [PATCH 07/44] Make notification db representation more flexible --- Cargo.lock | 1 + .../20221109000000_test_schema.sql | 9 +- .../20231004130100_create_notifications.sql | 9 +- crates/collab/src/db.rs | 9 ++ crates/collab/src/db/ids.rs | 1 + crates/collab/src/db/queries/contacts.rs | 37 +++-- crates/collab/src/db/queries/notifications.rs | 69 ++++---- crates/collab/src/db/tables/notification.rs | 11 +- .../collab/src/db/tables/notification_kind.rs | 3 +- crates/collab/src/db/tests.rs | 6 +- crates/collab/src/lib.rs | 4 +- crates/collab/src/rpc.rs | 2 - crates/collab_ui/src/notification_panel.rs | 12 +- .../notifications/src/notification_store.rs | 30 ++-- crates/rpc/Cargo.toml | 2 + crates/rpc/proto/zed.proto | 11 +- crates/rpc/src/notification.rs | 152 ++++++++---------- 17 files changed, 195 insertions(+), 173 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 02deccb39a4f6432218ef893be73aeb22812c1a9..c6d7a5ef854c04bde3d3e8b6399698b02ab9c6d1 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -6423,6 +6423,7 @@ dependencies = [ "rsa 0.4.0", "serde", "serde_derive", + "serde_json", "smol", "smol-timeout", "strum", diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index c5c556500f6a2ec4916efe7b9c379aa13beecfa5..a10155fd1dcc1d369a61a934d1fff27a0cd9557c 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -314,7 +314,7 @@ CREATE TABLE IF NOT EXISTS "observed_channel_messages" ( CREATE UNIQUE INDEX "index_observed_channel_messages_user_and_channel_id" ON "observed_channel_messages" ("user_id", "channel_id"); CREATE TABLE "notification_kinds" ( - "id" INTEGER PRIMARY KEY NOT NULL, + "id" INTEGER PRIMARY KEY AUTOINCREMENT, "name" VARCHAR NOT NULL ); @@ -322,13 +322,12 @@ 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), - "is_read" BOOLEAN NOT NULL DEFAULT FALSE, - "entity_id_1" INTEGER, - "entity_id_2" INTEGER, - "entity_id_3" INTEGER + "content" TEXT ); CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql index cac3f2d8df4a78502137ba21762f10264a3d2d2c..83cfd4397810ebb5934998c177ca1b1b1a885fd4 100644 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ b/crates/collab/migrations/20231004130100_create_notifications.sql @@ -1,5 +1,5 @@ CREATE TABLE "notification_kinds" ( - "id" INTEGER PRIMARY KEY NOT NULL, + "id" SERIAL PRIMARY KEY, "name" VARCHAR NOT NULL ); @@ -7,13 +7,12 @@ 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), - "is_read" BOOLEAN NOT NULL DEFAULT FALSE, - "entity_id_1" INTEGER, - "entity_id_2" INTEGER, - "entity_id_3" INTEGER + "content" TEXT ); 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 56e7c0d942b0beb432fc1e796b8f0248cb9ceab5..9aea23ca8438736377db3ce26d7c2d220e7597fe 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -55,6 +55,8 @@ pub struct Database { rooms: DashMap>>, rng: Mutex, executor: Executor, + notification_kinds_by_id: HashMap, + notification_kinds_by_name: HashMap, #[cfg(test)] runtime: Option, } @@ -69,6 +71,8 @@ impl Database { pool: sea_orm::Database::connect(options).await?, rooms: DashMap::with_capacity(16384), rng: Mutex::new(StdRng::seed_from_u64(0)), + notification_kinds_by_id: HashMap::default(), + notification_kinds_by_name: HashMap::default(), executor, #[cfg(test)] runtime: None, @@ -121,6 +125,11 @@ impl Database { Ok(new_migrations) } + pub async fn initialize_static_data(&mut self) -> Result<()> { + self.initialize_notification_enum().await?; + Ok(()) + } + pub async fn transaction(&self, f: F) -> Result where F: Send + Fn(TransactionHandle) -> Fut, diff --git a/crates/collab/src/db/ids.rs b/crates/collab/src/db/ids.rs index b5873a152fe243730d0695740f43582ee430a1b7..bd07af8a35c8bd2883cceaee2f7cda775a03d611 100644 --- a/crates/collab/src/db/ids.rs +++ b/crates/collab/src/db/ids.rs @@ -81,3 +81,4 @@ id_type!(UserId); id_type!(ChannelBufferCollaboratorId); id_type!(FlagId); id_type!(NotificationId); +id_type!(NotificationKindId); diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index d922bc5ca2f688e426a76e8035b9d95c823b7439..083315e29011bc547188a57789c668bea16d7383 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -165,18 +165,18 @@ impl Database { .exec_without_returning(&*tx) .await?; - if rows_affected == 1 { - self.create_notification( - receiver_id, - rpc::Notification::ContactRequest { - requester_id: sender_id.to_proto(), - }, - &*tx, - ) - .await - } else { - Err(anyhow!("contact already requested"))? + if rows_affected == 0 { + Err(anyhow!("contact already requested"))?; } + + self.create_notification( + receiver_id, + rpc::Notification::ContactRequest { + actor_id: sender_id.to_proto(), + }, + &*tx, + ) + .await }) .await } @@ -260,7 +260,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) @@ -298,11 +298,18 @@ impl Database { result.rows_affected }; - if rows_affected == 1 { - Ok(()) - } else { + if rows_affected == 0 { Err(anyhow!("no such contact request"))? } + + self.create_notification( + requester_id, + rpc::Notification::ContactRequestAccepted { + actor_id: responder_id.to_proto(), + }, + &*tx, + ) + .await }) .await } diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 293b896a50db5d9ae18e4c2bf8557d0f3cedee68..8c4c511299b85238c57af40578de1c166eb23224 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -1,21 +1,25 @@ use super::*; -use rpc::{Notification, NotificationKind}; +use rpc::Notification; impl Database { - pub async fn ensure_notification_kinds(&self) -> Result<()> { - self.transaction(|tx| async move { - notification_kind::Entity::insert_many(NotificationKind::all().map(|kind| { - notification_kind::ActiveModel { - id: ActiveValue::Set(kind as i32), - name: ActiveValue::Set(kind.to_string()), - } - })) - .on_conflict(OnConflict::new().do_nothing().to_owned()) - .exec(&*tx) - .await?; - Ok(()) - }) - .await + pub async fn initialize_notification_enum(&mut self) -> Result<()> { + notification_kind::Entity::insert_many(Notification::all_kinds().iter().map(|kind| { + notification_kind::ActiveModel { + name: ActiveValue::Set(kind.to_string()), + ..Default::default() + } + })) + .on_conflict(OnConflict::new().do_nothing().to_owned()) + .exec_without_returning(&self.pool) + .await?; + + let mut rows = notification_kind::Entity::find().stream(&self.pool).await?; + while let Some(row) = rows.next().await { + let row = row?; + self.notification_kinds_by_name.insert(row.name, row.id); + } + + Ok(()) } pub async fn get_notifications( @@ -33,14 +37,16 @@ impl Database { .await?; while let Some(row) = rows.next().await { let row = row?; + let Some(kind) = self.notification_kinds_by_id.get(&row.kind) else { + continue; + }; result.notifications.push(proto::Notification { id: row.id.to_proto(), - kind: row.kind as u32, + kind: kind.to_string(), timestamp: row.created_at.assume_utc().unix_timestamp() as u64, is_read: row.is_read, - entity_id_1: row.entity_id_1.map(|id| id as u64), - entity_id_2: row.entity_id_2.map(|id| id as u64), - entity_id_3: row.entity_id_3.map(|id| id as u64), + content: row.content, + actor_id: row.actor_id.map(|id| id.to_proto()), }); } result.notifications.reverse(); @@ -55,26 +61,31 @@ impl Database { notification: Notification, tx: &DatabaseTransaction, ) -> Result { - let (kind, associated_entities) = notification.to_parts(); + let notification = notification.to_any(); + let kind = *self + .notification_kinds_by_name + .get(notification.kind.as_ref()) + .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; + let model = notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), - kind: ActiveValue::Set(kind as i32), - entity_id_1: ActiveValue::Set(associated_entities[0].map(|id| id as i32)), - entity_id_2: ActiveValue::Set(associated_entities[1].map(|id| id as i32)), - entity_id_3: ActiveValue::Set(associated_entities[2].map(|id| id as i32)), - ..Default::default() + kind: ActiveValue::Set(kind), + content: ActiveValue::Set(notification.content.clone()), + actor_id: ActiveValue::Set(notification.actor_id.map(|id| UserId::from_proto(id))), + is_read: ActiveValue::NotSet, + created_at: ActiveValue::NotSet, + id: ActiveValue::NotSet, } .save(&*tx) .await?; Ok(proto::Notification { id: model.id.as_ref().to_proto(), - kind: *model.kind.as_ref() as u32, + kind: notification.kind.to_string(), timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, is_read: false, - entity_id_1: model.entity_id_1.as_ref().map(|id| id as u64), - entity_id_2: model.entity_id_2.as_ref().map(|id| id as u64), - entity_id_3: model.entity_id_3.as_ref().map(|id| id as u64), + content: notification.content, + actor_id: notification.actor_id, }) } } diff --git a/crates/collab/src/db/tables/notification.rs b/crates/collab/src/db/tables/notification.rs index 6a0abe9dc689a13ca8066c6c5033c5f603132734..a35e00fb5b6ab257843b9b6472286f7163003a34 100644 --- a/crates/collab/src/db/tables/notification.rs +++ b/crates/collab/src/db/tables/notification.rs @@ -1,4 +1,4 @@ -use crate::db::{NotificationId, UserId}; +use crate::db::{NotificationId, NotificationKindId, UserId}; use sea_orm::entity::prelude::*; use time::PrimitiveDateTime; @@ -7,13 +7,12 @@ use time::PrimitiveDateTime; pub struct Model { #[sea_orm(primary_key)] pub id: NotificationId, - pub recipient_id: UserId, - pub kind: i32, pub is_read: bool, pub created_at: PrimitiveDateTime, - pub entity_id_1: Option, - pub entity_id_2: Option, - pub entity_id_3: Option, + pub recipient_id: UserId, + pub actor_id: Option, + pub kind: NotificationKindId, + pub content: String, } #[derive(Copy, Clone, Debug, EnumIter, DeriveRelation)] diff --git a/crates/collab/src/db/tables/notification_kind.rs b/crates/collab/src/db/tables/notification_kind.rs index 32dfb2065adf234703707defd8115167e864f372..865b5da04bad2a7068aa6a2fd8e8adbe7586fd08 100644 --- a/crates/collab/src/db/tables/notification_kind.rs +++ b/crates/collab/src/db/tables/notification_kind.rs @@ -1,10 +1,11 @@ +use crate::db::NotificationKindId; use sea_orm::entity::prelude::*; #[derive(Clone, Debug, PartialEq, Eq, DeriveEntityModel)] #[sea_orm(table_name = "notification_kinds")] pub struct Model { #[sea_orm(primary_key)] - pub id: i32, + pub id: NotificationKindId, pub name: String, } diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 6a91fd6ffe145c1a31f9b5264029a04ddb1ef1de..465ff56444fc92b99e1da5786aa9620596ebf615 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -31,7 +31,7 @@ impl TestDb { let mut db = runtime.block_on(async { let mut options = ConnectOptions::new(url); options.max_connections(5); - let db = Database::new(options, Executor::Deterministic(background)) + let mut db = Database::new(options, Executor::Deterministic(background)) .await .unwrap(); let sql = include_str!(concat!( @@ -45,6 +45,7 @@ impl TestDb { )) .await .unwrap(); + db.initialize_notification_enum().await.unwrap(); db }); @@ -79,11 +80,12 @@ impl TestDb { options .max_connections(5) .idle_timeout(Duration::from_secs(0)); - let db = Database::new(options, Executor::Deterministic(background)) + let mut db = Database::new(options, Executor::Deterministic(background)) .await .unwrap(); let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); db.migrate(Path::new(migrations_path), false).await.unwrap(); + db.initialize_notification_enum().await.unwrap(); db }); diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 13fb8ed0ebc2c27a8fb63ba3ec5485f74f6e4390..17224242173645ae2f27ee01daa431d58fdd63b1 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -119,7 +119,9 @@ impl AppState { pub async fn new(config: Config) -> Result> { let mut db_options = db::ConnectOptions::new(config.database_url.clone()); db_options.max_connections(config.database_max_connections); - let db = Database::new(db_options, Executor::Production).await?; + let mut db = Database::new(db_options, Executor::Production).await?; + db.initialize_notification_enum().await?; + let live_kit_client = if let Some(((server, key), secret)) = config .live_kit_server .as_ref() diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index eb123cf960652eed2d6375f1e67d5694df15133e..01da0dc88ac4165d811a499325d94aea7427446b 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -291,8 +291,6 @@ impl Server { let pool = self.connection_pool.clone(); let live_kit_client = self.app_state.live_kit_client.clone(); - self.app_state.db.ensure_notification_kinds().await?; - let span = info_span!("start server"); self.executor.spawn_detached( async move { diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index a78caf5ff6e0013c9c10b2341215e5d30ac39a1c..334d844cf510b12685e9b75b20aa058b27dedb6b 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -185,18 +185,22 @@ impl NotificationPanel { let text; let actor; match entry.notification { - Notification::ContactRequest { requester_id } => { + Notification::ContactRequest { + actor_id: requester_id, + } => { actor = user_store.get_cached_user(requester_id)?; icon = "icons/plus.svg"; text = format!("{} wants to add you as a contact", actor.github_login); } - Notification::ContactRequestAccepted { contact_id } => { + Notification::ContactRequestAccepted { + actor_id: contact_id, + } => { actor = user_store.get_cached_user(contact_id)?; icon = "icons/plus.svg"; text = format!("{} accepted your contact invite", actor.github_login); } Notification::ChannelInvitation { - inviter_id, + actor_id: inviter_id, channel_id, } => { actor = user_store.get_cached_user(inviter_id)?; @@ -209,7 +213,7 @@ impl NotificationPanel { ); } Notification::ChannelMessageMention { - sender_id, + actor_id: sender_id, channel_id, message_id, } => { diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 9bfa67c76e33ed19901afe280bfa8cecda7fca26..4ebbf4609323c5739cae2baedfe9fd25cb97988f 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -3,7 +3,7 @@ use channel::{ChannelMessage, ChannelMessageId, ChannelStore}; use client::{Client, UserStore}; use collections::HashMap; use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle}; -use rpc::{proto, Notification, NotificationKind, TypedEnvelope}; +use rpc::{proto, AnyNotification, Notification, TypedEnvelope}; use std::{ops::Range, sync::Arc}; use sum_tree::{Bias, SumTree}; use time::OffsetDateTime; @@ -112,14 +112,11 @@ impl NotificationStore { is_read: message.is_read, timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64) .ok()?, - notification: Notification::from_parts( - NotificationKind::from_i32(message.kind as i32)?, - [ - message.entity_id_1, - message.entity_id_2, - message.entity_id_3, - ], - )?, + notification: Notification::from_any(&AnyNotification { + actor_id: message.actor_id, + kind: message.kind.into(), + content: message.content, + })?, }) }) .collect::>(); @@ -129,17 +126,24 @@ impl NotificationStore { for entry in ¬ifications { match entry.notification { - Notification::ChannelInvitation { inviter_id, .. } => { + Notification::ChannelInvitation { + actor_id: inviter_id, + .. + } => { user_ids.push(inviter_id); } - Notification::ContactRequest { requester_id } => { + Notification::ContactRequest { + actor_id: requester_id, + } => { user_ids.push(requester_id); } - Notification::ContactRequestAccepted { contact_id } => { + Notification::ContactRequestAccepted { + actor_id: contact_id, + } => { user_ids.push(contact_id); } Notification::ChannelMessageMention { - sender_id, + actor_id: sender_id, message_id, .. } => { diff --git a/crates/rpc/Cargo.toml b/crates/rpc/Cargo.toml index bc750374ddb7367d06b6accdafdcf66e282d6949..a2895e5f1ba51232fed8b68240ea2cb20f3d61a1 100644 --- a/crates/rpc/Cargo.toml +++ b/crates/rpc/Cargo.toml @@ -17,6 +17,7 @@ clock = { path = "../clock" } collections = { path = "../collections" } gpui = { path = "../gpui", optional = true } util = { path = "../util" } + anyhow.workspace = true async-lock = "2.4" async-tungstenite = "0.16" @@ -27,6 +28,7 @@ prost.workspace = true rand.workspace = true rsa = "0.4" serde.workspace = true +serde_json.workspace = true serde_derive.workspace = true smol-timeout = "0.6" strum.workspace = true diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 4b5c17ae8b0b4e69639cd69d3e651c600113fa33..f7671890242f3dc7be05b8fc3c1a98f97e1eceba 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1571,10 +1571,9 @@ message AddNotifications { message Notification { uint64 id = 1; - uint32 kind = 2; - uint64 timestamp = 3; - bool is_read = 4; - optional uint64 entity_id_1 = 5; - optional uint64 entity_id_2 = 6; - optional uint64 entity_id_3 = 7; + uint64 timestamp = 2; + bool is_read = 3; + string kind = 4; + string content = 5; + optional uint64 actor_id = 6; } diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index fc6dc54d15199f6b65e700362d24179f2ea50b8a..839966aea6552300d261327439d2c31201be548d 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -1,110 +1,94 @@ -use strum::{Display, EnumIter, EnumString, IntoEnumIterator}; +use serde::{Deserialize, Serialize}; +use serde_json::Value; +use std::borrow::Cow; +use strum::{EnumVariantNames, IntoStaticStr, VariantNames as _}; -// An integer indicating a type of notification. The variants' numerical -// values are stored in the database, so they should never be removed -// or changed. -#[repr(i32)] -#[derive(Copy, Clone, Debug, EnumIter, EnumString, Display)] -pub enum NotificationKind { - ContactRequest = 0, - ContactRequestAccepted = 1, - ChannelInvitation = 2, - ChannelMessageMention = 3, -} +const KIND: &'static str = "kind"; +const ACTOR_ID: &'static str = "actor_id"; -#[derive(Debug, Clone, PartialEq, Eq)] +#[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, IntoStaticStr, Serialize, Deserialize)] +#[serde(tag = "kind")] pub enum Notification { ContactRequest { - requester_id: u64, + actor_id: u64, }, ContactRequestAccepted { - contact_id: u64, + actor_id: u64, }, ChannelInvitation { - inviter_id: u64, + actor_id: u64, channel_id: u64, }, ChannelMessageMention { - sender_id: u64, + actor_id: u64, channel_id: u64, message_id: u64, }, } -impl Notification { - /// Load this notification from its generic representation, which is - /// used to represent it in the database, and in the wire protocol. - /// - /// The order in which a given notification type's fields are listed must - /// match the order they're listed in the `to_parts` method, and it must - /// not change, because they're stored in that order in the database. - pub fn from_parts(kind: NotificationKind, entity_ids: [Option; 3]) -> Option { - use NotificationKind::*; - Some(match kind { - ContactRequest => Self::ContactRequest { - requester_id: entity_ids[0]?, - }, - - ContactRequestAccepted => Self::ContactRequest { - requester_id: entity_ids[0]?, - }, - - ChannelInvitation => Self::ChannelInvitation { - inviter_id: entity_ids[0]?, - channel_id: entity_ids[1]?, - }, +#[derive(Debug)] +pub struct AnyNotification { + pub kind: Cow<'static, str>, + pub actor_id: Option, + pub content: String, +} - ChannelMessageMention => Self::ChannelMessageMention { - sender_id: entity_ids[0]?, - channel_id: entity_ids[1]?, - message_id: entity_ids[2]?, - }, - }) +impl Notification { + pub fn to_any(&self) -> AnyNotification { + let kind: &'static str = self.into(); + let mut value = serde_json::to_value(self).unwrap(); + let mut actor_id = None; + if let Some(value) = value.as_object_mut() { + value.remove("kind"); + actor_id = value + .remove("actor_id") + .and_then(|value| Some(value.as_i64()? as u64)); + } + AnyNotification { + kind: Cow::Borrowed(kind), + actor_id, + content: serde_json::to_string(&value).unwrap(), + } } - /// Convert this notification into its generic representation, which is - /// used to represent it in the database, and in the wire protocol. - /// - /// The order in which a given notification type's fields are listed must - /// match the order they're listed in the `from_parts` method, and it must - /// not change, because they're stored in that order in the database. - pub fn to_parts(&self) -> (NotificationKind, [Option; 3]) { - use NotificationKind::*; - match self { - Self::ContactRequest { requester_id } => { - (ContactRequest, [Some(*requester_id), None, None]) - } - - Self::ContactRequestAccepted { contact_id } => { - (ContactRequest, [Some(*contact_id), None, None]) - } - - Self::ChannelInvitation { - inviter_id, - channel_id, - } => ( - ChannelInvitation, - [Some(*inviter_id), Some(*channel_id), None], - ), - - Self::ChannelMessageMention { - sender_id, - channel_id, - message_id, - } => ( - ChannelMessageMention, - [Some(*sender_id), Some(*channel_id), Some(*message_id)], - ), + pub fn from_any(notification: &AnyNotification) -> Option { + let mut value = serde_json::from_str::(¬ification.content).ok()?; + let object = value.as_object_mut()?; + object.insert(KIND.into(), notification.kind.to_string().into()); + if let Some(actor_id) = notification.actor_id { + object.insert(ACTOR_ID.into(), actor_id.into()); } + serde_json::from_value(value).ok() } -} -impl NotificationKind { - pub fn all() -> impl Iterator { - Self::iter() + pub fn all_kinds() -> &'static [&'static str] { + Self::VARIANTS } +} - pub fn from_i32(i: i32) -> Option { - Self::iter().find(|kind| *kind as i32 == i) +#[test] +fn test_notification() { + // Notifications can be serialized and deserialized. + for notification in [ + Notification::ContactRequest { actor_id: 1 }, + Notification::ContactRequestAccepted { actor_id: 2 }, + Notification::ChannelInvitation { + actor_id: 0, + channel_id: 100, + }, + Notification::ChannelMessageMention { + actor_id: 200, + channel_id: 30, + message_id: 1, + }, + ] { + let serialized = notification.to_any(); + let deserialized = Notification::from_any(&serialized).unwrap(); + assert_eq!(deserialized, notification); } + + // When notifications are serialized, redundant data is not stored + // in the JSON. + let notification = Notification::ContactRequest { actor_id: 1 }; + assert_eq!(notification.to_any().content, "{}"); } From 034e9935d4b3792a6b8e1e0f439379caae2325eb Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Thu, 12 Oct 2023 17:39:04 -0700 Subject: [PATCH 08/44] Remove old contact request notification mechanism, use notification instead --- crates/client/src/user.rs | 35 +++++---------- crates/collab/src/db.rs | 15 ++----- crates/collab/src/db/queries/contacts.rs | 5 --- crates/collab/src/db/tests/db_tests.rs | 19 +-------- crates/collab/src/rpc.rs | 54 ++++++++++-------------- crates/rpc/proto/zed.proto | 2 - crates/rpc/src/notification.rs | 12 +++++- crates/zed/src/zed.rs | 1 + 8 files changed, 49 insertions(+), 94 deletions(-) diff --git a/crates/client/src/user.rs b/crates/client/src/user.rs index 6aa41708e3ae3e3c3504ab82791278c8a1837c0a..d02c22d7976ce2388bfb1474b23b62572ed5cf2b 100644 --- a/crates/client/src/user.rs +++ b/crates/client/src/user.rs @@ -293,21 +293,19 @@ impl UserStore { // No need to paralellize here let mut updated_contacts = Vec::new(); for contact in message.contacts { - let should_notify = contact.should_notify; - updated_contacts.push(( - Arc::new(Contact::from_proto(contact, &this, &mut cx).await?), - should_notify, + updated_contacts.push(Arc::new( + Contact::from_proto(contact, &this, &mut cx).await?, )); } let mut incoming_requests = Vec::new(); for request in message.incoming_requests { - incoming_requests.push({ - let user = this - .update(&mut cx, |this, cx| this.get_user(request.requester_id, cx)) - .await?; - (user, request.should_notify) - }); + incoming_requests.push( + this.update(&mut cx, |this, cx| { + this.get_user(request.requester_id, cx) + }) + .await?, + ); } let mut outgoing_requests = Vec::new(); @@ -330,13 +328,7 @@ impl UserStore { this.contacts .retain(|contact| !removed_contacts.contains(&contact.user.id)); // Update existing contacts and insert new ones - for (updated_contact, should_notify) in updated_contacts { - if should_notify { - cx.emit(Event::Contact { - user: updated_contact.user.clone(), - kind: ContactEventKind::Accepted, - }); - } + for updated_contact in updated_contacts { match this.contacts.binary_search_by_key( &&updated_contact.user.github_login, |contact| &contact.user.github_login, @@ -359,14 +351,7 @@ impl UserStore { } }); // Update existing incoming requests and insert new ones - for (user, should_notify) in incoming_requests { - if should_notify { - cx.emit(Event::Contact { - user: user.clone(), - kind: ContactEventKind::Requested, - }); - } - + for user in incoming_requests { match this .incoming_contact_requests .binary_search_by_key(&&user.github_login, |contact| { diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 9aea23ca8438736377db3ce26d7c2d220e7597fe..67055d27eecfb16b884ca956ae9612ea8b646e3f 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -370,18 +370,9 @@ impl RoomGuard { #[derive(Clone, Debug, PartialEq, Eq)] pub enum Contact { - Accepted { - user_id: UserId, - should_notify: bool, - busy: bool, - }, - Outgoing { - user_id: UserId, - }, - Incoming { - user_id: UserId, - should_notify: bool, - }, + Accepted { user_id: UserId, busy: bool }, + Outgoing { user_id: UserId }, + Incoming { user_id: UserId }, } impl Contact { diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index 083315e29011bc547188a57789c668bea16d7383..f02bae667ae21803ae48b43dacf34cde95d73e28 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -8,7 +8,6 @@ impl Database { user_id_b: UserId, a_to_b: bool, accepted: bool, - should_notify: bool, user_a_busy: bool, user_b_busy: bool, } @@ -53,7 +52,6 @@ impl Database { if db_contact.accepted { contacts.push(Contact::Accepted { user_id: db_contact.user_id_b, - should_notify: db_contact.should_notify && db_contact.a_to_b, busy: db_contact.user_b_busy, }); } else if db_contact.a_to_b { @@ -63,19 +61,16 @@ impl Database { } else { contacts.push(Contact::Incoming { user_id: db_contact.user_id_b, - should_notify: db_contact.should_notify, }); } } else if db_contact.accepted { contacts.push(Contact::Accepted { user_id: db_contact.user_id_a, - should_notify: db_contact.should_notify && !db_contact.a_to_b, busy: db_contact.user_a_busy, }); } else if db_contact.a_to_b { contacts.push(Contact::Incoming { user_id: db_contact.user_id_a, - should_notify: db_contact.should_notify, }); } else { contacts.push(Contact::Outgoing { diff --git a/crates/collab/src/db/tests/db_tests.rs b/crates/collab/src/db/tests/db_tests.rs index 1520e081c07ead1afc376f84d2e12918fef40db2..d175bd743d25057a651334b22b0c93cacbb22927 100644 --- a/crates/collab/src/db/tests/db_tests.rs +++ b/crates/collab/src/db/tests/db_tests.rs @@ -264,10 +264,7 @@ async fn test_add_contacts(db: &Arc) { ); assert_eq!( db.get_contacts(user_2).await.unwrap(), - &[Contact::Incoming { - user_id: user_1, - should_notify: true - }] + &[Contact::Incoming { user_id: user_1 }] ); // User 2 dismisses the contact request notification without accepting or rejecting. @@ -280,10 +277,7 @@ async fn test_add_contacts(db: &Arc) { .unwrap(); assert_eq!( db.get_contacts(user_2).await.unwrap(), - &[Contact::Incoming { - user_id: user_1, - should_notify: false - }] + &[Contact::Incoming { user_id: user_1 }] ); // User can't accept their own contact request @@ -299,7 +293,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_1).await.unwrap(), &[Contact::Accepted { user_id: user_2, - should_notify: true, busy: false, }], ); @@ -309,7 +302,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_2).await.unwrap(), &[Contact::Accepted { user_id: user_1, - should_notify: false, busy: false, }] ); @@ -326,7 +318,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_1).await.unwrap(), &[Contact::Accepted { user_id: user_2, - should_notify: true, busy: false, }] ); @@ -339,7 +330,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_1).await.unwrap(), &[Contact::Accepted { user_id: user_2, - should_notify: false, busy: false, }] ); @@ -353,12 +343,10 @@ async fn test_add_contacts(db: &Arc) { &[ Contact::Accepted { user_id: user_2, - should_notify: false, busy: false, }, Contact::Accepted { user_id: user_3, - should_notify: false, busy: false, } ] @@ -367,7 +355,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_3).await.unwrap(), &[Contact::Accepted { user_id: user_1, - should_notify: false, busy: false, }], ); @@ -383,7 +370,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_2).await.unwrap(), &[Contact::Accepted { user_id: user_1, - should_notify: false, busy: false, }] ); @@ -391,7 +377,6 @@ async fn test_add_contacts(db: &Arc) { db.get_contacts(user_3).await.unwrap(), &[Contact::Accepted { user_id: user_1, - should_notify: false, busy: false, }], ); diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 01da0dc88ac4165d811a499325d94aea7427446b..60cdaeec7094e34645ce9c3cb52897d47f86f56b 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -388,7 +388,7 @@ impl Server { let contacts = app_state.db.get_contacts(user_id).await.trace_err(); if let Some((busy, contacts)) = busy.zip(contacts) { let pool = pool.lock(); - let updated_contact = contact_for_user(user_id, false, busy, &pool); + let updated_contact = contact_for_user(user_id, busy, &pool); for contact in contacts { if let db::Contact::Accepted { user_id: contact_user_id, @@ -690,7 +690,7 @@ impl Server { if let Some(user) = self.app_state.db.get_user_by_id(inviter_id).await? { if let Some(code) = &user.invite_code { let pool = self.connection_pool.lock(); - let invitee_contact = contact_for_user(invitee_id, true, false, &pool); + let invitee_contact = contact_for_user(invitee_id, false, &pool); for connection_id in pool.user_connection_ids(inviter_id) { self.peer.send( connection_id, @@ -2090,7 +2090,6 @@ async fn request_contact( .incoming_requests .push(proto::IncomingContactRequest { requester_id: requester_id.to_proto(), - should_notify: true, }); for connection_id in session .connection_pool() @@ -2124,7 +2123,8 @@ async fn respond_to_contact_request( } else { let accept = request.response == proto::ContactRequestResponse::Accept as i32; - db.respond_to_contact_request(responder_id, requester_id, accept) + let notification = db + .respond_to_contact_request(responder_id, requester_id, accept) .await?; let requester_busy = db.is_user_busy(requester_id).await?; let responder_busy = db.is_user_busy(responder_id).await?; @@ -2135,7 +2135,7 @@ async fn respond_to_contact_request( if accept { update .contacts - .push(contact_for_user(requester_id, false, requester_busy, &pool)); + .push(contact_for_user(requester_id, requester_busy, &pool)); } update .remove_incoming_requests @@ -2149,13 +2149,19 @@ async fn respond_to_contact_request( if accept { update .contacts - .push(contact_for_user(responder_id, true, responder_busy, &pool)); + .push(contact_for_user(responder_id, responder_busy, &pool)); } 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())?; + session.peer.send( + connection_id, + proto::AddNotifications { + notifications: vec![notification.clone()], + }, + )?; } } @@ -3127,42 +3133,28 @@ fn build_initial_contacts_update( for contact in contacts { match contact { - db::Contact::Accepted { - user_id, - should_notify, - busy, - } => { - update - .contacts - .push(contact_for_user(user_id, should_notify, busy, &pool)); + db::Contact::Accepted { user_id, busy } => { + update.contacts.push(contact_for_user(user_id, busy, &pool)); } db::Contact::Outgoing { user_id } => update.outgoing_requests.push(user_id.to_proto()), - db::Contact::Incoming { - user_id, - should_notify, - } => update - .incoming_requests - .push(proto::IncomingContactRequest { - requester_id: user_id.to_proto(), - should_notify, - }), + db::Contact::Incoming { user_id } => { + update + .incoming_requests + .push(proto::IncomingContactRequest { + requester_id: user_id.to_proto(), + }) + } } } update } -fn contact_for_user( - user_id: UserId, - should_notify: bool, - busy: bool, - pool: &ConnectionPool, -) -> proto::Contact { +fn contact_for_user(user_id: UserId, busy: bool, pool: &ConnectionPool) -> proto::Contact { proto::Contact { user_id: user_id.to_proto(), online: pool.is_user_online(user_id), busy, - should_notify, } } @@ -3223,7 +3215,7 @@ async fn update_user_contacts(user_id: UserId, session: &Session) -> Result<()> let busy = db.is_user_busy(user_id).await?; let pool = session.connection_pool().await; - let updated_contact = contact_for_user(user_id, false, busy, &pool); + let updated_contact = contact_for_user(user_id, busy, &pool); for contact in contacts { if let db::Contact::Accepted { user_id: contact_user_id, diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index f7671890242f3dc7be05b8fc3c1a98f97e1eceba..8dca38bdfd6d62a2d8fe6f2674c4a4245445b281 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1223,7 +1223,6 @@ message ShowContacts {} message IncomingContactRequest { uint64 requester_id = 1; - bool should_notify = 2; } message UpdateDiagnostics { @@ -1549,7 +1548,6 @@ message Contact { uint64 user_id = 1; bool online = 2; bool busy = 3; - bool should_notify = 4; } message WorktreeMetadata { diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 839966aea6552300d261327439d2c31201be548d..8aabb9b9df92912fc7a53615f2898e2a684cb5c2 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -6,6 +6,12 @@ use strum::{EnumVariantNames, IntoStaticStr, VariantNames as _}; const KIND: &'static str = "kind"; const ACTOR_ID: &'static str = "actor_id"; +/// A notification that can be stored, associated with a given user. +/// +/// This struct is stored in the collab database as JSON, so it shouldn't be +/// changed in a backward-incompatible way. +/// +/// For example, when renaming a variant, add a serde alias for the old name. #[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, IntoStaticStr, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum Notification { @@ -26,6 +32,8 @@ pub enum Notification { }, } +/// The representation of a notification that is stored in the database and +/// sent over the wire. #[derive(Debug)] pub struct AnyNotification { pub kind: Cow<'static, str>, @@ -87,8 +95,8 @@ fn test_notification() { assert_eq!(deserialized, notification); } - // When notifications are serialized, redundant data is not stored - // in the JSON. + // When notifications are serialized, the `kind` and `actor_id` fields are + // stored separately, and do not appear redundantly in the JSON. let notification = Notification::ContactRequest { actor_id: 1 }; assert_eq!(notification.to_any().content, "{}"); } diff --git a/crates/zed/src/zed.rs b/crates/zed/src/zed.rs index 8caff21c5f5756525bd2a677f2cec44549b23786..5226557235a101392e224800890b7dc36acd4899 100644 --- a/crates/zed/src/zed.rs +++ b/crates/zed/src/zed.rs @@ -2445,6 +2445,7 @@ mod tests { audio::init((), cx); channel::init(&app_state.client, app_state.user_store.clone(), cx); call::init(app_state.client.clone(), app_state.user_store.clone(), cx); + notifications::init(app_state.client.clone(), app_state.user_store.clone(), cx); workspace::init(app_state.clone(), cx); Project::init_settings(cx); language::init(cx); From 8db86dcebfe4a2520d737e3c8f0889a3c0152343 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 11:21:45 -0700 Subject: [PATCH 09/44] Connect notification panel to notification toasts --- Cargo.lock | 1 + crates/collab/src/db/queries/notifications.rs | 18 +- crates/collab/src/rpc.rs | 35 +++- crates/collab/src/tests/following_tests.rs | 2 +- crates/collab_ui/Cargo.toml | 4 +- crates/collab_ui/src/collab_titlebar_item.rs | 28 +-- crates/collab_ui/src/collab_ui.rs | 8 +- crates/collab_ui/src/notification_panel.rs | 37 +++- crates/collab_ui/src/notifications.rs | 12 +- .../contact_notification.rs | 15 +- .../incoming_call_notification.rs | 0 .../project_shared_notification.rs | 0 .../notifications/src/notification_store.rs | 58 ++++-- crates/rpc/proto/zed.proto | 51 +++-- crates/rpc/src/proto.rs | 181 +++++++++--------- 15 files changed, 272 insertions(+), 178 deletions(-) rename crates/collab_ui/src/{ => notifications}/contact_notification.rs (91%) rename crates/collab_ui/src/{ => notifications}/incoming_call_notification.rs (100%) rename crates/collab_ui/src/{ => notifications}/project_shared_notification.rs (100%) diff --git a/Cargo.lock b/Cargo.lock index c6d7a5ef854c04bde3d3e8b6399698b02ab9c6d1..8ee5449f9fc882a4109e5ff93d874837e0df135d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1566,6 +1566,7 @@ dependencies = [ "project", "recent_projects", "rich_text", + "rpc", "schemars", "serde", "serde_derive", diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 8c4c511299b85238c57af40578de1c166eb23224..7c48ad42cbbad57ced39ed5708be86172f8d23e9 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -26,11 +26,19 @@ impl Database { &self, recipient_id: UserId, limit: usize, - ) -> Result { + before_id: Option, + ) -> Result> { self.transaction(|tx| async move { - let mut result = proto::AddNotifications::default(); + let mut result = Vec::new(); + let mut condition = + Condition::all().add(notification::Column::RecipientId.eq(recipient_id)); + + if let Some(before_id) = before_id { + condition = condition.add(notification::Column::Id.lt(before_id)); + } + let mut rows = notification::Entity::find() - .filter(notification::Column::RecipientId.eq(recipient_id)) + .filter(condition) .order_by_desc(notification::Column::Id) .limit(limit as u64) .stream(&*tx) @@ -40,7 +48,7 @@ impl Database { let Some(kind) = self.notification_kinds_by_id.get(&row.kind) else { continue; }; - result.notifications.push(proto::Notification { + result.push(proto::Notification { id: row.id.to_proto(), kind: kind.to_string(), timestamp: row.created_at.assume_utc().unix_timestamp() as u64, @@ -49,7 +57,7 @@ impl Database { actor_id: row.actor_id.map(|id| id.to_proto()), }); } - result.notifications.reverse(); + result.reverse(); Ok(result) }) .await diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 60cdaeec7094e34645ce9c3cb52897d47f86f56b..abf7ac58574ecd5296e0e0514af4163e0bf14230 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -70,7 +70,7 @@ pub const CLEANUP_TIMEOUT: Duration = Duration::from_secs(10); const MESSAGE_COUNT_PER_PAGE: usize = 100; const MAX_MESSAGE_LEN: usize = 1024; -const INITIAL_NOTIFICATION_COUNT: usize = 30; +const NOTIFICATION_COUNT_PER_PAGE: usize = 50; lazy_static! { static ref METRIC_CONNECTIONS: IntGauge = @@ -269,6 +269,7 @@ impl Server { .add_request_handler(send_channel_message) .add_request_handler(remove_channel_message) .add_request_handler(get_channel_messages) + .add_request_handler(get_notifications) .add_request_handler(link_channel) .add_request_handler(unlink_channel) .add_request_handler(move_channel) @@ -579,17 +580,15 @@ impl Server { this.app_state.db.set_user_connected_once(user_id, true).await?; } - let (contacts, channels_for_user, channel_invites, notifications) = future::try_join4( + let (contacts, channels_for_user, channel_invites) = future::try_join3( this.app_state.db.get_contacts(user_id), this.app_state.db.get_channels_for_user(user_id), this.app_state.db.get_channel_invites_for_user(user_id), - this.app_state.db.get_notifications(user_id, INITIAL_NOTIFICATION_COUNT) ).await?; { let mut pool = this.connection_pool.lock(); pool.add_connection(connection_id, user_id, user.admin); - this.peer.send(connection_id, notifications)?; this.peer.send(connection_id, build_initial_contacts_update(contacts, &pool))?; this.peer.send(connection_id, build_initial_channels_update( channels_for_user, @@ -2099,8 +2098,8 @@ async fn request_contact( session.peer.send(connection_id, update.clone())?; session.peer.send( connection_id, - proto::AddNotifications { - notifications: vec![notification.clone()], + proto::NewNotification { + notification: Some(notification.clone()), }, )?; } @@ -2158,8 +2157,8 @@ async fn respond_to_contact_request( session.peer.send(connection_id, update.clone())?; session.peer.send( connection_id, - proto::AddNotifications { - notifications: vec![notification.clone()], + proto::NewNotification { + notification: Some(notification.clone()), }, )?; } @@ -3008,6 +3007,26 @@ async fn get_channel_messages( Ok(()) } +async fn get_notifications( + request: proto::GetNotifications, + response: Response, + session: Session, +) -> Result<()> { + let notifications = session + .db() + .await + .get_notifications( + session.user_id, + NOTIFICATION_COUNT_PER_PAGE, + request + .before_id + .map(|id| db::NotificationId::from_proto(id)), + ) + .await?; + response.send(proto::GetNotificationsResponse { notifications })?; + Ok(()) +} + async fn update_diff_base(request: proto::UpdateDiffBase, session: Session) -> Result<()> { let project_id = ProjectId::from_proto(request.project_id); let project_connection_ids = session diff --git a/crates/collab/src/tests/following_tests.rs b/crates/collab/src/tests/following_tests.rs index f3857e3db37343aee1d4ba68116a0bc236f61e98..a28f2ae87f0984241ca7df30fac0807d4e0fa31b 100644 --- a/crates/collab/src/tests/following_tests.rs +++ b/crates/collab/src/tests/following_tests.rs @@ -1,6 +1,6 @@ use crate::{rpc::RECONNECT_TIMEOUT, tests::TestServer}; use call::ActiveCall; -use collab_ui::project_shared_notification::ProjectSharedNotification; +use collab_ui::notifications::project_shared_notification::ProjectSharedNotification; use editor::{Editor, ExcerptRange, MultiBuffer}; use gpui::{executor::Deterministic, geometry::vector::vec2f, TestAppContext, ViewHandle}; use live_kit_client::MacOSDisplay; diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 25f2d9f91aecb18175874365e53e90cd07e18003..4a0f8c5e8b2948375bbdb32c56a8b3859ffc33b1 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -41,7 +41,8 @@ notifications = { path = "../notifications" } rich_text = { path = "../rich_text" } picker = { path = "../picker" } project = { path = "../project" } -recent_projects = {path = "../recent_projects"} +recent_projects = { path = "../recent_projects" } +rpc = { path = "../rpc" } settings = { path = "../settings" } feature_flags = {path = "../feature_flags"} theme = { path = "../theme" } @@ -68,6 +69,7 @@ editor = { path = "../editor", features = ["test-support"] } gpui = { path = "../gpui", features = ["test-support"] } notifications = { path = "../notifications", features = ["test-support"] } project = { path = "../project", features = ["test-support"] } +rpc = { path = "../rpc", features = ["test-support"] } settings = { path = "../settings", features = ["test-support"] } util = { path = "../util", features = ["test-support"] } workspace = { path = "../workspace", features = ["test-support"] } diff --git a/crates/collab_ui/src/collab_titlebar_item.rs b/crates/collab_ui/src/collab_titlebar_item.rs index 211ee863e89f6ce7bfe3aa44826c0a8f827a6f85..dca8f892e4676ea37b3b9d9c2c284e3d7332d98b 100644 --- a/crates/collab_ui/src/collab_titlebar_item.rs +++ b/crates/collab_ui/src/collab_titlebar_item.rs @@ -1,10 +1,10 @@ use crate::{ - contact_notification::ContactNotification, face_pile::FacePile, toggle_deafen, toggle_mute, - toggle_screen_sharing, LeaveCall, ToggleDeafen, ToggleMute, ToggleScreenSharing, + face_pile::FacePile, toggle_deafen, toggle_mute, toggle_screen_sharing, LeaveCall, + ToggleDeafen, ToggleMute, ToggleScreenSharing, }; use auto_update::AutoUpdateStatus; use call::{ActiveCall, ParticipantLocation, Room}; -use client::{proto::PeerId, Client, ContactEventKind, SignIn, SignOut, User, UserStore}; +use client::{proto::PeerId, Client, SignIn, SignOut, User, UserStore}; use clock::ReplicaId; use context_menu::{ContextMenu, ContextMenuItem}; use gpui::{ @@ -151,28 +151,6 @@ impl CollabTitlebarItem { this.window_activation_changed(active, cx) })); subscriptions.push(cx.observe(&user_store, |_, _, cx| cx.notify())); - subscriptions.push( - cx.subscribe(&user_store, move |this, user_store, event, cx| { - if let Some(workspace) = this.workspace.upgrade(cx) { - workspace.update(cx, |workspace, cx| { - if let client::Event::Contact { user, kind } = event { - if let ContactEventKind::Requested | ContactEventKind::Accepted = kind { - workspace.show_notification(user.id as usize, cx, |cx| { - cx.add_view(|cx| { - ContactNotification::new( - user.clone(), - *kind, - user_store, - cx, - ) - }) - }) - } - } - }); - } - }), - ); Self { workspace: workspace.weak_handle(), diff --git a/crates/collab_ui/src/collab_ui.rs b/crates/collab_ui/src/collab_ui.rs index 0a22c063be07a809a6b7887c8b9bfd4ee0c2df9e..c9a758e0ad30248cade5094d4d1c697e27326ffb 100644 --- a/crates/collab_ui/src/collab_ui.rs +++ b/crates/collab_ui/src/collab_ui.rs @@ -2,13 +2,10 @@ pub mod channel_view; pub mod chat_panel; pub mod collab_panel; mod collab_titlebar_item; -mod contact_notification; mod face_pile; -mod incoming_call_notification; pub mod notification_panel; -mod notifications; +pub mod notifications; mod panel_settings; -pub mod project_shared_notification; mod sharing_status_indicator; use call::{report_call_event_for_room, ActiveCall, Room}; @@ -48,8 +45,7 @@ pub fn init(app_state: &Arc, cx: &mut AppContext) { collab_titlebar_item::init(cx); collab_panel::init(cx); chat_panel::init(cx); - incoming_call_notification::init(&app_state, cx); - project_shared_notification::init(&app_state, cx); + notifications::init(&app_state, cx); sharing_status_indicator::init(cx); cx.add_global_action(toggle_screen_sharing); diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 334d844cf510b12685e9b75b20aa058b27dedb6b..bae2f88bc6a644134308c8fed20f88c41dd67210 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -1,5 +1,7 @@ use crate::{ - format_timestamp, is_channels_feature_enabled, render_avatar, NotificationPanelSettings, + format_timestamp, is_channels_feature_enabled, + notifications::contact_notification::ContactNotification, render_avatar, + NotificationPanelSettings, }; use anyhow::Result; use channel::ChannelStore; @@ -39,6 +41,7 @@ pub struct NotificationPanel { notification_list: ListState, pending_serialization: Task>, subscriptions: Vec, + workspace: WeakViewHandle, local_timezone: UtcOffset, has_focus: bool, } @@ -64,6 +67,7 @@ impl NotificationPanel { let fs = workspace.app_state().fs.clone(); let client = workspace.app_state().client.clone(); let user_store = workspace.app_state().user_store.clone(); + let workspace_handle = workspace.weak_handle(); let notification_list = ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { @@ -96,6 +100,7 @@ impl NotificationPanel { notification_store: NotificationStore::global(cx), notification_list, pending_serialization: Task::ready(None), + workspace: workspace_handle, has_focus: false, subscriptions: Vec::new(), active: false, @@ -177,7 +182,7 @@ impl NotificationPanel { let notification_store = self.notification_store.read(cx); let user_store = self.user_store.read(cx); let channel_store = self.channel_store.read(cx); - let entry = notification_store.notification_at(ix).unwrap(); + let entry = notification_store.notification_at(ix)?; let now = OffsetDateTime::now_utc(); let timestamp = entry.timestamp; @@ -293,7 +298,7 @@ impl NotificationPanel { &mut self, _: ModelHandle, event: &NotificationEvent, - _: &mut ViewContext, + cx: &mut ViewContext, ) { match event { NotificationEvent::NotificationsUpdated { @@ -301,7 +306,33 @@ impl NotificationPanel { new_count, } => { self.notification_list.splice(old_range.clone(), *new_count); + cx.notify(); } + NotificationEvent::NewNotification { entry } => match entry.notification { + Notification::ContactRequest { actor_id } + | Notification::ContactRequestAccepted { actor_id } => { + let user_store = self.user_store.clone(); + let Some(user) = user_store.read(cx).get_cached_user(actor_id) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + workspace.show_notification(actor_id as usize, cx, |cx| { + cx.add_view(|cx| { + ContactNotification::new( + user.clone(), + entry.notification.clone(), + user_store, + cx, + ) + }) + }) + }) + .ok(); + } + Notification::ChannelInvitation { .. } => {} + Notification::ChannelMessageMention { .. } => {} + }, } } } diff --git a/crates/collab_ui/src/notifications.rs b/crates/collab_ui/src/notifications.rs index 5943e016cb17096cc7701882a09586a72e44081b..e4456163c661712df0adf207e795cd7a0f8bfc90 100644 --- a/crates/collab_ui/src/notifications.rs +++ b/crates/collab_ui/src/notifications.rs @@ -2,13 +2,23 @@ use client::User; use gpui::{ elements::*, platform::{CursorStyle, MouseButton}, - AnyElement, Element, ViewContext, + AnyElement, AppContext, Element, ViewContext, }; use std::sync::Arc; +use workspace::AppState; + +pub mod contact_notification; +pub mod incoming_call_notification; +pub mod project_shared_notification; enum Dismiss {} enum Button {} +pub fn init(app_state: &Arc, cx: &mut AppContext) { + incoming_call_notification::init(app_state, cx); + project_shared_notification::init(app_state, cx); +} + pub fn render_user_notification( user: Arc, title: &'static str, diff --git a/crates/collab_ui/src/contact_notification.rs b/crates/collab_ui/src/notifications/contact_notification.rs similarity index 91% rename from crates/collab_ui/src/contact_notification.rs rename to crates/collab_ui/src/notifications/contact_notification.rs index a998be8efda017746b61e4f7a26ccc83fd17fadd..cbd5f237f887f5d493d258ba4e376d477f2dadd6 100644 --- a/crates/collab_ui/src/contact_notification.rs +++ b/crates/collab_ui/src/notifications/contact_notification.rs @@ -1,14 +1,13 @@ -use std::sync::Arc; - use crate::notifications::render_user_notification; use client::{ContactEventKind, User, UserStore}; use gpui::{elements::*, Entity, ModelHandle, View, ViewContext}; +use std::sync::Arc; use workspace::notifications::Notification; pub struct ContactNotification { user_store: ModelHandle, user: Arc, - kind: client::ContactEventKind, + notification: rpc::Notification, } #[derive(Clone, PartialEq)] @@ -34,8 +33,8 @@ impl View for ContactNotification { } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - match self.kind { - ContactEventKind::Requested => render_user_notification( + match self.notification { + rpc::Notification::ContactRequest { .. } => render_user_notification( self.user.clone(), "wants to add you as a contact", Some("They won't be alerted if you decline."), @@ -56,7 +55,7 @@ impl View for ContactNotification { ], cx, ), - ContactEventKind::Accepted => render_user_notification( + rpc::Notification::ContactRequestAccepted { .. } => render_user_notification( self.user.clone(), "accepted your contact request", None, @@ -78,7 +77,7 @@ impl Notification for ContactNotification { impl ContactNotification { pub fn new( user: Arc, - kind: client::ContactEventKind, + notification: rpc::Notification, user_store: ModelHandle, cx: &mut ViewContext, ) -> Self { @@ -97,7 +96,7 @@ impl ContactNotification { Self { user, - kind, + notification, user_store, } } diff --git a/crates/collab_ui/src/incoming_call_notification.rs b/crates/collab_ui/src/notifications/incoming_call_notification.rs similarity index 100% rename from crates/collab_ui/src/incoming_call_notification.rs rename to crates/collab_ui/src/notifications/incoming_call_notification.rs diff --git a/crates/collab_ui/src/project_shared_notification.rs b/crates/collab_ui/src/notifications/project_shared_notification.rs similarity index 100% rename from crates/collab_ui/src/project_shared_notification.rs rename to crates/collab_ui/src/notifications/project_shared_notification.rs diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 4ebbf4609323c5739cae2baedfe9fd25cb97988f..6583b4a4c6f42f516cc89283dd4002f9e5a27ebf 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -2,7 +2,7 @@ use anyhow::Result; use channel::{ChannelMessage, ChannelMessageId, ChannelStore}; use client::{Client, UserStore}; use collections::HashMap; -use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle}; +use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; use rpc::{proto, AnyNotification, Notification, TypedEnvelope}; use std::{ops::Range, sync::Arc}; use sum_tree::{Bias, SumTree}; @@ -14,7 +14,7 @@ pub fn init(client: Arc, user_store: ModelHandle, cx: &mut Ap } pub struct NotificationStore { - _client: Arc, + client: Arc, user_store: ModelHandle, channel_messages: HashMap, channel_store: ModelHandle, @@ -27,6 +27,9 @@ pub enum NotificationEvent { old_range: Range, new_count: usize, }, + NewNotification { + entry: NotificationEntry, + }, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -63,16 +66,19 @@ impl NotificationStore { user_store: ModelHandle, cx: &mut ModelContext, ) -> Self { - Self { + let this = Self { channel_store: ChannelStore::global(cx), notifications: Default::default(), channel_messages: Default::default(), _subscriptions: vec![ - client.add_message_handler(cx.handle(), Self::handle_add_notifications) + client.add_message_handler(cx.handle(), Self::handle_new_notification) ], user_store, - _client: client, - } + client, + }; + + this.load_more_notifications(cx).detach(); + this } pub fn notification_count(&self) -> usize { @@ -93,18 +99,42 @@ impl NotificationStore { cursor.item() } - async fn handle_add_notifications( + pub fn load_more_notifications(&self, cx: &mut ModelContext) -> Task> { + let request = self + .client + .request(proto::GetNotifications { before_id: None }); + cx.spawn(|this, cx| async move { + let response = request.await?; + Self::add_notifications(this, false, response.notifications, cx).await?; + Ok(()) + }) + } + + async fn handle_new_notification( this: ModelHandle, - envelope: TypedEnvelope, + envelope: TypedEnvelope, _: Arc, + cx: AsyncAppContext, + ) -> Result<()> { + Self::add_notifications( + this, + true, + envelope.payload.notification.into_iter().collect(), + cx, + ) + .await + } + + async fn add_notifications( + this: ModelHandle, + is_new: bool, + notifications: Vec, mut cx: AsyncAppContext, ) -> Result<()> { let mut user_ids = Vec::new(); let mut message_ids = Vec::new(); - let notifications = envelope - .payload - .notifications + let notifications = notifications .into_iter() .filter_map(|message| { Some(NotificationEntry { @@ -195,6 +225,12 @@ impl NotificationStore { cursor.next(&()); } + if is_new { + cx.emit(NotificationEvent::NewNotification { + entry: notification.clone(), + }); + } + new_notifications.push(notification, &()); } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 8dca38bdfd6d62a2d8fe6f2674c4a4245445b281..3f47dfaab564e3a42a490c82ea1d84737d30400f 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -155,25 +155,28 @@ message Envelope { UpdateChannelBufferCollaborators update_channel_buffer_collaborators = 128; RejoinChannelBuffers rejoin_channel_buffers = 129; RejoinChannelBuffersResponse rejoin_channel_buffers_response = 130; - AckBufferOperation ack_buffer_operation = 143; - - JoinChannelChat join_channel_chat = 131; - JoinChannelChatResponse join_channel_chat_response = 132; - LeaveChannelChat leave_channel_chat = 133; - SendChannelMessage send_channel_message = 134; - SendChannelMessageResponse send_channel_message_response = 135; - ChannelMessageSent channel_message_sent = 136; - GetChannelMessages get_channel_messages = 137; - GetChannelMessagesResponse get_channel_messages_response = 138; - RemoveChannelMessage remove_channel_message = 139; - AckChannelMessage ack_channel_message = 144; - - LinkChannel link_channel = 140; - UnlinkChannel unlink_channel = 141; - MoveChannel move_channel = 142; - - AddNotifications add_notifications = 145; - GetChannelMessagesById get_channel_messages_by_id = 146; // Current max + AckBufferOperation ack_buffer_operation = 131; + + JoinChannelChat join_channel_chat = 132; + JoinChannelChatResponse join_channel_chat_response = 133; + LeaveChannelChat leave_channel_chat = 134; + SendChannelMessage send_channel_message = 135; + SendChannelMessageResponse send_channel_message_response = 136; + ChannelMessageSent channel_message_sent = 137; + GetChannelMessages get_channel_messages = 138; + GetChannelMessagesResponse get_channel_messages_response = 139; + RemoveChannelMessage remove_channel_message = 140; + AckChannelMessage ack_channel_message = 141; + GetChannelMessagesById get_channel_messages_by_id = 142; + + LinkChannel link_channel = 143; + UnlinkChannel unlink_channel = 144; + MoveChannel move_channel = 145; + + NewNotification new_notification = 146; + GetNotifications get_notifications = 147; + GetNotificationsResponse get_notifications_response = 148; // Current max + } } @@ -1563,7 +1566,15 @@ message UpdateDiffBase { optional string diff_base = 3; } -message AddNotifications { +message GetNotifications { + optional uint64 before_id = 1; +} + +message NewNotification { + Notification notification = 1; +} + +message GetNotificationsResponse { repeated Notification notifications = 1; } diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index 4d8f60c89607efea3ac92a7635fbfc246bb769e2..eb548efd391c098f2a0d974eeba56a381b049729 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -133,7 +133,8 @@ impl fmt::Display for PeerId { messages!( (Ack, Foreground), - (AddNotifications, Foreground), + (AckBufferOperation, Background), + (AckChannelMessage, Background), (AddProjectCollaborator, Foreground), (ApplyCodeAction, Background), (ApplyCodeActionResponse, Background), @@ -144,58 +145,74 @@ messages!( (Call, Foreground), (CallCanceled, Foreground), (CancelCall, Foreground), + (ChannelMessageSent, Foreground), (CopyProjectEntry, Foreground), (CreateBufferForPeer, Foreground), (CreateChannel, Foreground), (CreateChannelResponse, Foreground), - (ChannelMessageSent, Foreground), (CreateProjectEntry, Foreground), (CreateRoom, Foreground), (CreateRoomResponse, Foreground), (DeclineCall, Foreground), + (DeleteChannel, Foreground), (DeleteProjectEntry, Foreground), (Error, Foreground), (ExpandProjectEntry, Foreground), + (ExpandProjectEntryResponse, Foreground), (Follow, Foreground), (FollowResponse, Foreground), (FormatBuffers, Foreground), (FormatBuffersResponse, Foreground), (FuzzySearchUsers, Foreground), - (GetCodeActions, Background), - (GetCodeActionsResponse, Background), - (GetHover, Background), - (GetHoverResponse, Background), + (GetChannelMembers, Foreground), + (GetChannelMembersResponse, Foreground), (GetChannelMessages, Background), - (GetChannelMessagesResponse, Background), (GetChannelMessagesById, Background), - (SendChannelMessage, Background), - (SendChannelMessageResponse, Background), + (GetChannelMessagesResponse, Background), + (GetCodeActions, Background), + (GetCodeActionsResponse, Background), (GetCompletions, Background), (GetCompletionsResponse, Background), (GetDefinition, Background), (GetDefinitionResponse, Background), - (GetTypeDefinition, Background), - (GetTypeDefinitionResponse, Background), (GetDocumentHighlights, Background), (GetDocumentHighlightsResponse, Background), - (GetReferences, Background), - (GetReferencesResponse, Background), + (GetHover, Background), + (GetHoverResponse, Background), + (GetNotifications, Foreground), + (GetNotificationsResponse, Foreground), + (GetPrivateUserInfo, Foreground), + (GetPrivateUserInfoResponse, Foreground), (GetProjectSymbols, Background), (GetProjectSymbolsResponse, Background), + (GetReferences, Background), + (GetReferencesResponse, Background), + (GetTypeDefinition, Background), + (GetTypeDefinitionResponse, Background), (GetUsers, Foreground), (Hello, Foreground), (IncomingCall, Foreground), + (InlayHints, Background), + (InlayHintsResponse, Background), (InviteChannelMember, Foreground), - (UsersResponse, Foreground), + (JoinChannel, Foreground), + (JoinChannelBuffer, Foreground), + (JoinChannelBufferResponse, Foreground), + (JoinChannelChat, Foreground), + (JoinChannelChatResponse, Foreground), (JoinProject, Foreground), (JoinProjectResponse, Foreground), (JoinRoom, Foreground), (JoinRoomResponse, Foreground), - (JoinChannelChat, Foreground), - (JoinChannelChatResponse, Foreground), + (LeaveChannelBuffer, Background), (LeaveChannelChat, Foreground), (LeaveProject, Foreground), (LeaveRoom, Foreground), + (LinkChannel, Foreground), + (MoveChannel, Foreground), + (NewNotification, Foreground), + (OnTypeFormatting, Background), + (OnTypeFormattingResponse, Background), (OpenBufferById, Background), (OpenBufferByPath, Background), (OpenBufferForSymbol, Background), @@ -203,58 +220,54 @@ messages!( (OpenBufferResponse, Background), (PerformRename, Background), (PerformRenameResponse, Background), - (OnTypeFormatting, Background), - (OnTypeFormattingResponse, Background), - (InlayHints, Background), - (InlayHintsResponse, Background), - (ResolveInlayHint, Background), - (ResolveInlayHintResponse, Background), - (RefreshInlayHints, Foreground), (Ping, Foreground), (PrepareRename, Background), (PrepareRenameResponse, Background), - (ExpandProjectEntryResponse, Foreground), (ProjectEntryResponse, Foreground), + (RefreshInlayHints, Foreground), + (RejoinChannelBuffers, Foreground), + (RejoinChannelBuffersResponse, Foreground), (RejoinRoom, Foreground), (RejoinRoomResponse, Foreground), - (RemoveContact, Foreground), - (RemoveChannelMember, Foreground), - (RemoveChannelMessage, Foreground), (ReloadBuffers, Foreground), (ReloadBuffersResponse, Foreground), + (RemoveChannelMember, Foreground), + (RemoveChannelMessage, Foreground), + (RemoveContact, Foreground), (RemoveProjectCollaborator, Foreground), + (RenameChannel, Foreground), + (RenameChannelResponse, Foreground), (RenameProjectEntry, Foreground), (RequestContact, Foreground), - (RespondToContactRequest, Foreground), + (ResolveInlayHint, Background), + (ResolveInlayHintResponse, Background), (RespondToChannelInvite, Foreground), - (JoinChannel, Foreground), + (RespondToContactRequest, Foreground), (RoomUpdated, Foreground), (SaveBuffer, Foreground), - (RenameChannel, Foreground), - (RenameChannelResponse, Foreground), - (SetChannelMemberAdmin, Foreground), (SearchProject, Background), (SearchProjectResponse, Background), + (SendChannelMessage, Background), + (SendChannelMessageResponse, Background), + (SetChannelMemberAdmin, Foreground), (ShareProject, Foreground), (ShareProjectResponse, Foreground), (ShowContacts, Foreground), (StartLanguageServer, Foreground), (SynchronizeBuffers, Foreground), (SynchronizeBuffersResponse, Foreground), - (RejoinChannelBuffers, Foreground), - (RejoinChannelBuffersResponse, Foreground), (Test, Foreground), (Unfollow, Foreground), + (UnlinkChannel, Foreground), (UnshareProject, Foreground), (UpdateBuffer, Foreground), (UpdateBufferFile, Foreground), - (UpdateContacts, Foreground), - (DeleteChannel, Foreground), - (MoveChannel, Foreground), - (LinkChannel, Foreground), - (UnlinkChannel, Foreground), + (UpdateChannelBuffer, Foreground), + (UpdateChannelBufferCollaborators, Foreground), (UpdateChannels, Foreground), + (UpdateContacts, Foreground), (UpdateDiagnosticSummary, Foreground), + (UpdateDiffBase, Foreground), (UpdateFollowers, Foreground), (UpdateInviteInfo, Foreground), (UpdateLanguageServer, Foreground), @@ -263,18 +276,7 @@ messages!( (UpdateProjectCollaborator, Foreground), (UpdateWorktree, Foreground), (UpdateWorktreeSettings, Foreground), - (UpdateDiffBase, Foreground), - (GetPrivateUserInfo, Foreground), - (GetPrivateUserInfoResponse, Foreground), - (GetChannelMembers, Foreground), - (GetChannelMembersResponse, Foreground), - (JoinChannelBuffer, Foreground), - (JoinChannelBufferResponse, Foreground), - (LeaveChannelBuffer, Background), - (UpdateChannelBuffer, Foreground), - (UpdateChannelBufferCollaborators, Foreground), - (AckBufferOperation, Background), - (AckChannelMessage, Background), + (UsersResponse, Foreground), ); request_messages!( @@ -286,73 +288,74 @@ request_messages!( (Call, Ack), (CancelCall, Ack), (CopyProjectEntry, ProjectEntryResponse), + (CreateChannel, CreateChannelResponse), (CreateProjectEntry, ProjectEntryResponse), (CreateRoom, CreateRoomResponse), - (CreateChannel, CreateChannelResponse), (DeclineCall, Ack), + (DeleteChannel, Ack), (DeleteProjectEntry, ProjectEntryResponse), (ExpandProjectEntry, ExpandProjectEntryResponse), (Follow, FollowResponse), (FormatBuffers, FormatBuffersResponse), + (FuzzySearchUsers, UsersResponse), + (GetChannelMembers, GetChannelMembersResponse), + (GetChannelMessages, GetChannelMessagesResponse), + (GetChannelMessagesById, GetChannelMessagesResponse), (GetCodeActions, GetCodeActionsResponse), - (GetHover, GetHoverResponse), (GetCompletions, GetCompletionsResponse), (GetDefinition, GetDefinitionResponse), - (GetTypeDefinition, GetTypeDefinitionResponse), (GetDocumentHighlights, GetDocumentHighlightsResponse), - (GetReferences, GetReferencesResponse), + (GetHover, GetHoverResponse), + (GetNotifications, GetNotificationsResponse), (GetPrivateUserInfo, GetPrivateUserInfoResponse), (GetProjectSymbols, GetProjectSymbolsResponse), - (FuzzySearchUsers, UsersResponse), + (GetReferences, GetReferencesResponse), + (GetTypeDefinition, GetTypeDefinitionResponse), (GetUsers, UsersResponse), + (IncomingCall, Ack), + (InlayHints, InlayHintsResponse), (InviteChannelMember, Ack), + (JoinChannel, JoinRoomResponse), + (JoinChannelBuffer, JoinChannelBufferResponse), + (JoinChannelChat, JoinChannelChatResponse), (JoinProject, JoinProjectResponse), (JoinRoom, JoinRoomResponse), - (JoinChannelChat, JoinChannelChatResponse), + (LeaveChannelBuffer, Ack), (LeaveRoom, Ack), - (RejoinRoom, RejoinRoomResponse), - (IncomingCall, Ack), + (LinkChannel, Ack), + (MoveChannel, Ack), + (OnTypeFormatting, OnTypeFormattingResponse), (OpenBufferById, OpenBufferResponse), (OpenBufferByPath, OpenBufferResponse), (OpenBufferForSymbol, OpenBufferForSymbolResponse), - (Ping, Ack), (PerformRename, PerformRenameResponse), + (Ping, Ack), (PrepareRename, PrepareRenameResponse), - (OnTypeFormatting, OnTypeFormattingResponse), - (InlayHints, InlayHintsResponse), - (ResolveInlayHint, ResolveInlayHintResponse), (RefreshInlayHints, Ack), + (RejoinChannelBuffers, RejoinChannelBuffersResponse), + (RejoinRoom, RejoinRoomResponse), (ReloadBuffers, ReloadBuffersResponse), - (RequestContact, Ack), (RemoveChannelMember, Ack), - (RemoveContact, Ack), - (RespondToContactRequest, Ack), - (RespondToChannelInvite, Ack), - (SetChannelMemberAdmin, Ack), - (SendChannelMessage, SendChannelMessageResponse), - (GetChannelMessages, GetChannelMessagesResponse), - (GetChannelMessagesById, GetChannelMessagesResponse), - (GetChannelMembers, GetChannelMembersResponse), - (JoinChannel, JoinRoomResponse), (RemoveChannelMessage, Ack), - (DeleteChannel, Ack), - (RenameProjectEntry, ProjectEntryResponse), + (RemoveContact, Ack), (RenameChannel, RenameChannelResponse), - (LinkChannel, Ack), - (UnlinkChannel, Ack), - (MoveChannel, Ack), + (RenameProjectEntry, ProjectEntryResponse), + (RequestContact, Ack), + (ResolveInlayHint, ResolveInlayHintResponse), + (RespondToChannelInvite, Ack), + (RespondToContactRequest, Ack), (SaveBuffer, BufferSaved), (SearchProject, SearchProjectResponse), + (SendChannelMessage, SendChannelMessageResponse), + (SetChannelMemberAdmin, Ack), (ShareProject, ShareProjectResponse), (SynchronizeBuffers, SynchronizeBuffersResponse), - (RejoinChannelBuffers, RejoinChannelBuffersResponse), (Test, Test), + (UnlinkChannel, Ack), (UpdateBuffer, Ack), (UpdateParticipantLocation, Ack), (UpdateProject, Ack), (UpdateWorktree, Ack), - (JoinChannelBuffer, JoinChannelBufferResponse), - (LeaveChannelBuffer, Ack) ); entity_messages!( @@ -371,25 +374,25 @@ entity_messages!( GetCodeActions, GetCompletions, GetDefinition, - GetTypeDefinition, GetDocumentHighlights, GetHover, - GetReferences, GetProjectSymbols, + GetReferences, + GetTypeDefinition, + InlayHints, JoinProject, LeaveProject, + OnTypeFormatting, OpenBufferById, OpenBufferByPath, OpenBufferForSymbol, PerformRename, - OnTypeFormatting, - InlayHints, - ResolveInlayHint, - RefreshInlayHints, PrepareRename, + RefreshInlayHints, ReloadBuffers, RemoveProjectCollaborator, RenameProjectEntry, + ResolveInlayHint, SaveBuffer, SearchProject, StartLanguageServer, @@ -398,19 +401,19 @@ entity_messages!( UpdateBuffer, UpdateBufferFile, UpdateDiagnosticSummary, + UpdateDiffBase, UpdateLanguageServer, UpdateProject, UpdateProjectCollaborator, UpdateWorktree, UpdateWorktreeSettings, - UpdateDiffBase ); entity_messages!( channel_id, ChannelMessageSent, - UpdateChannelBuffer, RemoveChannelMessage, + UpdateChannelBuffer, UpdateChannelBufferCollaborators, ); From bc6ba5f547496853ed37a3da709b830f83b22d0b Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 11:23:39 -0700 Subject: [PATCH 10/44] Bump protocol version --- crates/rpc/src/rpc.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/rpc/src/rpc.rs b/crates/rpc/src/rpc.rs index 4bf90669b236ae301cfbf9e2ecb4d179211df17d..682ba6ac73e98349ae4b5a40269eaddf53712b27 100644 --- a/crates/rpc/src/rpc.rs +++ b/crates/rpc/src/rpc.rs @@ -9,4 +9,4 @@ pub use notification::*; pub use peer::*; mod macros; -pub const PROTOCOL_VERSION: u32 = 64; +pub const PROTOCOL_VERSION: u32 = 65; From 39e3ddb0803f77c2bd4e1600fd3b363ad6c38353 Mon Sep 17 00:00:00 2001 From: Nate Butler Date: Fri, 13 Oct 2023 15:00:32 -0400 Subject: [PATCH 11/44] Update bell.svg --- assets/icons/bell.svg | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/assets/icons/bell.svg b/assets/icons/bell.svg index 46b01b6b3871aacc6dee71e7644a7fa9af2050c7..ea1c6dd42e8821b632f6de97d143a7b9f4b97fd2 100644 --- a/assets/icons/bell.svg +++ b/assets/icons/bell.svg @@ -1,3 +1,8 @@ - - + + From 83fb8d20b7b49e51209ae9582d0980e75577a4a8 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 15:37:08 -0700 Subject: [PATCH 12/44] Remove contact notifications when cancelling a contact request --- crates/channel/src/channel_store.rs | 3 - crates/collab/src/db/queries/contacts.rs | 22 ++- crates/collab/src/db/queries/notifications.rs | 45 +++++- crates/collab/src/rpc.rs | 11 +- crates/collab_ui/src/notification_panel.rs | 66 +++++---- .../src/notifications/contact_notification.rs | 16 +-- .../notifications/src/notification_store.rs | 128 +++++++++++++----- crates/rpc/proto/zed.proto | 7 +- crates/rpc/src/notification.rs | 14 +- crates/rpc/src/proto.rs | 1 + 10 files changed, 225 insertions(+), 88 deletions(-) diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index 4a1292cdb200f4aa06e45104d353c1b835c2a2aa..918a1e1dc1f962ac48ef8e32c01982caa270a9b8 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -127,9 +127,6 @@ impl ChannelStore { this.update(&mut cx, |this, cx| this.handle_disconnect(true, cx)); } } - if status.is_connected() { - } else { - } } Some(()) }); diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index f02bae667ae21803ae48b43dacf34cde95d73e28..ddb7959ef26571b47608c77b4607ea9d1e6d5957 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -185,7 +185,11 @@ impl Database { /// /// * `requester_id` - The user that initiates this request /// * `responder_id` - The user that will be removed - pub async fn remove_contact(&self, requester_id: UserId, responder_id: UserId) -> Result { + pub async fn remove_contact( + &self, + requester_id: UserId, + responder_id: UserId, + ) -> Result<(bool, Option)> { self.transaction(|tx| async move { let (id_a, id_b) = if responder_id < requester_id { (responder_id, requester_id) @@ -204,7 +208,21 @@ impl Database { .ok_or_else(|| anyhow!("no such contact"))?; contact::Entity::delete_by_id(contact.id).exec(&*tx).await?; - Ok(contact.accepted) + + let mut deleted_notification_id = None; + if !contact.accepted { + deleted_notification_id = self + .delete_notification( + responder_id, + rpc::Notification::ContactRequest { + actor_id: requester_id.to_proto(), + }, + &*tx, + ) + .await?; + } + + Ok((contact.accepted, deleted_notification_id)) }) .await } diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 7c48ad42cbbad57ced39ed5708be86172f8d23e9..2ea5fd149f5a15e2623cb51881f301d26563c26f 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -3,12 +3,12 @@ use rpc::Notification; impl Database { pub async fn initialize_notification_enum(&mut self) -> Result<()> { - notification_kind::Entity::insert_many(Notification::all_kinds().iter().map(|kind| { - notification_kind::ActiveModel { + notification_kind::Entity::insert_many(Notification::all_variant_names().iter().map( + |kind| notification_kind::ActiveModel { name: ActiveValue::Set(kind.to_string()), ..Default::default() - } - })) + }, + )) .on_conflict(OnConflict::new().do_nothing().to_owned()) .exec_without_returning(&self.pool) .await?; @@ -19,6 +19,12 @@ impl Database { self.notification_kinds_by_name.insert(row.name, row.id); } + for name in Notification::all_variant_names() { + if let Some(id) = self.notification_kinds_by_name.get(*name).copied() { + self.notification_kinds_by_id.insert(id, name); + } + } + Ok(()) } @@ -46,6 +52,7 @@ impl Database { while let Some(row) = rows.next().await { let row = row?; let Some(kind) = self.notification_kinds_by_id.get(&row.kind) else { + log::warn!("unknown notification kind {:?}", row.kind); continue; }; result.push(proto::Notification { @@ -96,4 +103,34 @@ impl Database { actor_id: notification.actor_id, }) } + + pub async fn delete_notification( + &self, + recipient_id: UserId, + notification: Notification, + tx: &DatabaseTransaction, + ) -> Result> { + let notification = notification.to_any(); + let kind = *self + .notification_kinds_by_name + .get(notification.kind.as_ref()) + .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; + let actor_id = notification.actor_id.map(|id| UserId::from_proto(id)); + let notification = notification::Entity::find() + .filter( + Condition::all() + .add(notification::Column::RecipientId.eq(recipient_id)) + .add(notification::Column::Kind.eq(kind)) + .add(notification::Column::ActorId.eq(actor_id)) + .add(notification::Column::Content.eq(notification.content)), + ) + .one(tx) + .await?; + if let Some(notification) = ¬ification { + notification::Entity::delete_by_id(notification.id) + .exec(tx) + .await?; + } + Ok(notification.map(|notification| notification.id)) + } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 921ebccfb1ec1f77d6b22fab3cccfac645c9a28d..7a3cdb13ab797792ba833d733315d5ff6a23243e 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2177,7 +2177,8 @@ async fn remove_contact( let requester_id = session.user_id; let responder_id = UserId::from_proto(request.user_id); let db = session.db().await; - let contact_accepted = db.remove_contact(requester_id, responder_id).await?; + let (contact_accepted, deleted_notification_id) = + db.remove_contact(requester_id, responder_id).await?; let pool = session.connection_pool().await; // Update outgoing contact requests of requester @@ -2204,6 +2205,14 @@ async fn remove_contact( } for connection_id in pool.user_connection_ids(responder_id) { session.peer.send(connection_id, update.clone())?; + if let Some(notification_id) = deleted_notification_id { + session.peer.send( + connection_id, + proto::DeleteNotification { + notification_id: notification_id.to_proto(), + }, + )?; + } } response.send(proto::Ack {})?; diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index bae2f88bc6a644134308c8fed20f88c41dd67210..978255a081cbe7ce17713055c7fdd6ba20b45be1 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -301,6 +301,8 @@ impl NotificationPanel { cx: &mut ViewContext, ) { match event { + NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx), + NotificationEvent::NotificationRemoved { entry } => self.remove_toast(entry, cx), NotificationEvent::NotificationsUpdated { old_range, new_count, @@ -308,31 +310,49 @@ impl NotificationPanel { self.notification_list.splice(old_range.clone(), *new_count); cx.notify(); } - NotificationEvent::NewNotification { entry } => match entry.notification { - Notification::ContactRequest { actor_id } - | Notification::ContactRequestAccepted { actor_id } => { - let user_store = self.user_store.clone(); - let Some(user) = user_store.read(cx).get_cached_user(actor_id) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - workspace.show_notification(actor_id as usize, cx, |cx| { - cx.add_view(|cx| { - ContactNotification::new( - user.clone(), - entry.notification.clone(), - user_store, - cx, - ) - }) + } + } + + fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { + let id = entry.id as usize; + match entry.notification { + Notification::ContactRequest { actor_id } + | Notification::ContactRequestAccepted { actor_id } => { + let user_store = self.user_store.clone(); + let Some(user) = user_store.read(cx).get_cached_user(actor_id) else { + return; + }; + self.workspace + .update(cx, |workspace, cx| { + workspace.show_notification(id, cx, |cx| { + cx.add_view(|_| { + ContactNotification::new( + user, + entry.notification.clone(), + user_store, + ) }) }) - .ok(); - } - Notification::ChannelInvitation { .. } => {} - Notification::ChannelMessageMention { .. } => {} - }, + }) + .ok(); + } + Notification::ChannelInvitation { .. } => {} + Notification::ChannelMessageMention { .. } => {} + } + } + + fn remove_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { + let id = entry.id as usize; + match entry.notification { + Notification::ContactRequest { .. } | Notification::ContactRequestAccepted { .. } => { + self.workspace + .update(cx, |workspace, cx| { + workspace.dismiss_notification::(id, cx) + }) + .ok(); + } + Notification::ChannelInvitation { .. } => {} + Notification::ChannelMessageMention { .. } => {} } } } diff --git a/crates/collab_ui/src/notifications/contact_notification.rs b/crates/collab_ui/src/notifications/contact_notification.rs index cbd5f237f887f5d493d258ba4e376d477f2dadd6..2e3c3ca58a36f90c6ece3cfc56b0ca11228f7cde 100644 --- a/crates/collab_ui/src/notifications/contact_notification.rs +++ b/crates/collab_ui/src/notifications/contact_notification.rs @@ -1,5 +1,5 @@ use crate::notifications::render_user_notification; -use client::{ContactEventKind, User, UserStore}; +use client::{User, UserStore}; use gpui::{elements::*, Entity, ModelHandle, View, ViewContext}; use std::sync::Arc; use workspace::notifications::Notification; @@ -79,21 +79,7 @@ impl ContactNotification { user: Arc, notification: rpc::Notification, user_store: ModelHandle, - cx: &mut ViewContext, ) -> Self { - cx.subscribe(&user_store, move |this, _, event, cx| { - if let client::Event::Contact { - kind: ContactEventKind::Cancelled, - user, - } = event - { - if user.id == this.user.id { - cx.emit(Event::Dismiss); - } - } - }) - .detach(); - Self { user, notification, diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 6583b4a4c6f42f516cc89283dd4002f9e5a27ebf..087637a10037799c0059bf23e3c9db1df20fe818 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -2,11 +2,13 @@ use anyhow::Result; use channel::{ChannelMessage, ChannelMessageId, ChannelStore}; use client::{Client, UserStore}; use collections::HashMap; +use db::smol::stream::StreamExt; use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; use rpc::{proto, AnyNotification, Notification, TypedEnvelope}; use std::{ops::Range, sync::Arc}; use sum_tree::{Bias, SumTree}; use time::OffsetDateTime; +use util::ResultExt; pub fn init(client: Arc, user_store: ModelHandle, cx: &mut AppContext) { let notification_store = cx.add_model(|cx| NotificationStore::new(client, user_store, cx)); @@ -19,6 +21,7 @@ pub struct NotificationStore { channel_messages: HashMap, channel_store: ModelHandle, notifications: SumTree, + _watch_connection_status: Task>, _subscriptions: Vec, } @@ -30,6 +33,9 @@ pub enum NotificationEvent { NewNotification { entry: NotificationEntry, }, + NotificationRemoved { + entry: NotificationEntry, + }, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -66,19 +72,34 @@ impl NotificationStore { user_store: ModelHandle, cx: &mut ModelContext, ) -> Self { - let this = Self { + let mut connection_status = client.status(); + let watch_connection_status = cx.spawn_weak(|this, mut cx| async move { + while let Some(status) = connection_status.next().await { + let this = this.upgrade(&cx)?; + match status { + client::Status::Connected { .. } => { + this.update(&mut cx, |this, cx| this.handle_connect(cx)) + .await + .log_err()?; + } + _ => this.update(&mut cx, |this, cx| this.handle_disconnect(cx)), + } + } + Some(()) + }); + + Self { channel_store: ChannelStore::global(cx), notifications: Default::default(), channel_messages: Default::default(), + _watch_connection_status: watch_connection_status, _subscriptions: vec![ - client.add_message_handler(cx.handle(), Self::handle_new_notification) + client.add_message_handler(cx.handle(), Self::handle_new_notification), + client.add_message_handler(cx.handle(), Self::handle_delete_notification), ], user_store, client, - }; - - this.load_more_notifications(cx).detach(); - this + } } pub fn notification_count(&self) -> usize { @@ -110,6 +131,16 @@ impl NotificationStore { }) } + fn handle_connect(&mut self, cx: &mut ModelContext) -> Task> { + self.notifications = Default::default(); + self.channel_messages = Default::default(); + self.load_more_notifications(cx) + } + + fn handle_disconnect(&mut self, cx: &mut ModelContext) { + cx.notify() + } + async fn handle_new_notification( this: ModelHandle, envelope: TypedEnvelope, @@ -125,6 +156,18 @@ impl NotificationStore { .await } + async fn handle_delete_notification( + this: ModelHandle, + envelope: TypedEnvelope, + _: Arc, + mut cx: AsyncAppContext, + ) -> Result<()> { + this.update(&mut cx, |this, cx| { + this.splice_notifications([(envelope.payload.notification_id, None)], false, cx); + Ok(()) + }) + } + async fn add_notifications( this: ModelHandle, is_new: bool, @@ -205,26 +248,47 @@ impl NotificationStore { } })); - let mut cursor = this.notifications.cursor::<(NotificationId, Count)>(); - let mut new_notifications = SumTree::new(); - let mut old_range = 0..0; - for (i, notification) in notifications.into_iter().enumerate() { - new_notifications.append( - cursor.slice(&NotificationId(notification.id), Bias::Left, &()), - &(), - ); - - if i == 0 { - old_range.start = cursor.start().1 .0; - } + this.splice_notifications( + notifications + .into_iter() + .map(|notification| (notification.id, Some(notification))), + is_new, + cx, + ); + }); + + Ok(()) + } + + fn splice_notifications( + &mut self, + notifications: impl IntoIterator)>, + is_new: bool, + cx: &mut ModelContext<'_, NotificationStore>, + ) { + let mut cursor = self.notifications.cursor::<(NotificationId, Count)>(); + let mut new_notifications = SumTree::new(); + let mut old_range = 0..0; + + for (i, (id, new_notification)) in notifications.into_iter().enumerate() { + new_notifications.append(cursor.slice(&NotificationId(id), Bias::Left, &()), &()); - if cursor - .item() - .map_or(true, |existing| existing.id != notification.id) - { + if i == 0 { + old_range.start = cursor.start().1 .0; + } + + if let Some(existing_notification) = cursor.item() { + if existing_notification.id == id { + if new_notification.is_none() { + cx.emit(NotificationEvent::NotificationRemoved { + entry: existing_notification.clone(), + }); + } cursor.next(&()); } + } + if let Some(notification) = new_notification { if is_new { cx.emit(NotificationEvent::NewNotification { entry: notification.clone(), @@ -233,20 +297,18 @@ impl NotificationStore { new_notifications.push(notification, &()); } + } - old_range.end = cursor.start().1 .0; - let new_count = new_notifications.summary().count; - new_notifications.append(cursor.suffix(&()), &()); - drop(cursor); + old_range.end = cursor.start().1 .0; + let new_count = new_notifications.summary().count - old_range.start; + new_notifications.append(cursor.suffix(&()), &()); + drop(cursor); - this.notifications = new_notifications; - cx.emit(NotificationEvent::NotificationsUpdated { - old_range, - new_count, - }); + self.notifications = new_notifications; + cx.emit(NotificationEvent::NotificationsUpdated { + old_range, + new_count, }); - - Ok(()) } } diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 30e43dc43b842315520db7914d4993704111ca67..d27bbade6f2425018875f875591d523d6c2dffcc 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -177,7 +177,8 @@ message Envelope { NewNotification new_notification = 148; GetNotifications get_notifications = 149; - GetNotificationsResponse get_notifications_response = 150; // Current max + GetNotificationsResponse get_notifications_response = 150; + DeleteNotification delete_notification = 151; // Current max } } @@ -1590,6 +1591,10 @@ message GetNotificationsResponse { repeated Notification notifications = 1; } +message DeleteNotification { + uint64 notification_id = 1; +} + message Notification { uint64 id = 1; uint64 timestamp = 2; diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 8aabb9b9df92912fc7a53615f2898e2a684cb5c2..8224c2696c32c812daea429642275a96b8310dbc 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -1,5 +1,5 @@ use serde::{Deserialize, Serialize}; -use serde_json::Value; +use serde_json::{map, Value}; use std::borrow::Cow; use strum::{EnumVariantNames, IntoStaticStr, VariantNames as _}; @@ -47,10 +47,12 @@ impl Notification { let mut value = serde_json::to_value(self).unwrap(); let mut actor_id = None; if let Some(value) = value.as_object_mut() { - value.remove("kind"); - actor_id = value - .remove("actor_id") - .and_then(|value| Some(value.as_i64()? as u64)); + value.remove(KIND); + if let map::Entry::Occupied(e) = value.entry(ACTOR_ID) { + if e.get().is_u64() { + actor_id = e.remove().as_u64(); + } + } } AnyNotification { kind: Cow::Borrowed(kind), @@ -69,7 +71,7 @@ impl Notification { serde_json::from_value(value).ok() } - pub fn all_kinds() -> &'static [&'static str] { + pub fn all_variant_names() -> &'static [&'static str] { Self::VARIANTS } } diff --git a/crates/rpc/src/proto.rs b/crates/rpc/src/proto.rs index bca56e9c776e1d2c94b9325b11c4f5940b6ccf61..b2a72c4ce1cf86528659052f9195e0fb840b0206 100644 --- a/crates/rpc/src/proto.rs +++ b/crates/rpc/src/proto.rs @@ -155,6 +155,7 @@ messages!( (CreateRoomResponse, Foreground), (DeclineCall, Foreground), (DeleteChannel, Foreground), + (DeleteNotification, Foreground), (DeleteProjectEntry, Foreground), (Error, Foreground), (ExpandProjectEntry, Foreground), From 5a0afcc83541a725b3dc140b1982b159f671abfd Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 15:49:31 -0700 Subject: [PATCH 13/44] Simplify notification serialization --- crates/collab/src/db.rs | 3 +- crates/collab/src/db/queries/notifications.rs | 8 +-- .../notifications/src/notification_store.rs | 8 +-- crates/rpc/src/notification.rs | 50 ++++++++----------- 4 files changed, 29 insertions(+), 40 deletions(-) diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 67055d27eecfb16b884ca956ae9612ea8b646e3f..1bf5c95f6b2aafd210ae72ee00775681d5e67233 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -13,6 +13,7 @@ use anyhow::anyhow; use collections::{BTreeMap, HashMap, HashSet}; use dashmap::DashMap; use futures::StreamExt; +use queries::channels::ChannelGraph; use rand::{prelude::StdRng, Rng, SeedableRng}; use rpc::{ proto::{self}, @@ -47,8 +48,6 @@ pub use ids::*; pub use sea_orm::ConnectOptions; pub use tables::user::Model as User; -use self::queries::channels::ChannelGraph; - pub struct Database { options: ConnectOptions, pool: DatabaseConnection, diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index 2ea5fd149f5a15e2623cb51881f301d26563c26f..bf9c9d74efdd82ce6ad96aeb354a81b3de70ede4 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -76,10 +76,10 @@ impl Database { notification: Notification, tx: &DatabaseTransaction, ) -> Result { - let notification = notification.to_any(); + let notification = notification.to_proto(); let kind = *self .notification_kinds_by_name - .get(notification.kind.as_ref()) + .get(¬ification.kind) .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; let model = notification::ActiveModel { @@ -110,10 +110,10 @@ impl Database { notification: Notification, tx: &DatabaseTransaction, ) -> Result> { - let notification = notification.to_any(); + let notification = notification.to_proto(); let kind = *self .notification_kinds_by_name - .get(notification.kind.as_ref()) + .get(¬ification.kind) .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; let actor_id = notification.actor_id.map(|id| UserId::from_proto(id)); let notification = notification::Entity::find() diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 087637a10037799c0059bf23e3c9db1df20fe818..af39941d2f1673b8a8b951c5bb451c956a7b68e2 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -4,7 +4,7 @@ use client::{Client, UserStore}; use collections::HashMap; use db::smol::stream::StreamExt; use gpui::{AppContext, AsyncAppContext, Entity, ModelContext, ModelHandle, Task}; -use rpc::{proto, AnyNotification, Notification, TypedEnvelope}; +use rpc::{proto, Notification, TypedEnvelope}; use std::{ops::Range, sync::Arc}; use sum_tree::{Bias, SumTree}; use time::OffsetDateTime; @@ -185,11 +185,7 @@ impl NotificationStore { is_read: message.is_read, timestamp: OffsetDateTime::from_unix_timestamp(message.timestamp as i64) .ok()?, - notification: Notification::from_any(&AnyNotification { - actor_id: message.actor_id, - kind: message.kind.into(), - content: message.content, - })?, + notification: Notification::from_proto(&message)?, }) }) .collect::>(); diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 8224c2696c32c812daea429642275a96b8310dbc..6ff96601594aed5131165a4ae87e598135a66368 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -1,7 +1,7 @@ +use crate::proto; use serde::{Deserialize, Serialize}; use serde_json::{map, Value}; -use std::borrow::Cow; -use strum::{EnumVariantNames, IntoStaticStr, VariantNames as _}; +use strum::{EnumVariantNames, VariantNames as _}; const KIND: &'static str = "kind"; const ACTOR_ID: &'static str = "actor_id"; @@ -9,10 +9,12 @@ const ACTOR_ID: &'static str = "actor_id"; /// A notification that can be stored, associated with a given user. /// /// This struct is stored in the collab database as JSON, so it shouldn't be -/// changed in a backward-incompatible way. +/// changed in a backward-incompatible way. For example, when renaming a +/// variant, add a serde alias for the old name. /// -/// For example, when renaming a variant, add a serde alias for the old name. -#[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, IntoStaticStr, Serialize, Deserialize)] +/// When a notification is initiated by a user, use the `actor_id` field +/// to store the user's id. +#[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum Notification { ContactRequest { @@ -32,36 +34,28 @@ pub enum Notification { }, } -/// The representation of a notification that is stored in the database and -/// sent over the wire. -#[derive(Debug)] -pub struct AnyNotification { - pub kind: Cow<'static, str>, - pub actor_id: Option, - pub content: String, -} - impl Notification { - pub fn to_any(&self) -> AnyNotification { - let kind: &'static str = self.into(); + pub fn to_proto(&self) -> proto::Notification { let mut value = serde_json::to_value(self).unwrap(); let mut actor_id = None; - if let Some(value) = value.as_object_mut() { - value.remove(KIND); - if let map::Entry::Occupied(e) = value.entry(ACTOR_ID) { - if e.get().is_u64() { - actor_id = e.remove().as_u64(); - } + let value = value.as_object_mut().unwrap(); + let Some(Value::String(kind)) = value.remove(KIND) else { + unreachable!() + }; + if let map::Entry::Occupied(e) = value.entry(ACTOR_ID) { + if e.get().is_u64() { + actor_id = e.remove().as_u64(); } } - AnyNotification { - kind: Cow::Borrowed(kind), + proto::Notification { + kind, actor_id, content: serde_json::to_string(&value).unwrap(), + ..Default::default() } } - pub fn from_any(notification: &AnyNotification) -> Option { + pub fn from_proto(notification: &proto::Notification) -> Option { let mut value = serde_json::from_str::(¬ification.content).ok()?; let object = value.as_object_mut()?; object.insert(KIND.into(), notification.kind.to_string().into()); @@ -92,13 +86,13 @@ fn test_notification() { message_id: 1, }, ] { - let serialized = notification.to_any(); - let deserialized = Notification::from_any(&serialized).unwrap(); + let message = notification.to_proto(); + let deserialized = Notification::from_proto(&message).unwrap(); assert_eq!(deserialized, notification); } // When notifications are serialized, the `kind` and `actor_id` fields are // stored separately, and do not appear redundantly in the JSON. let notification = Notification::ContactRequest { actor_id: 1 }; - assert_eq!(notification.to_any().content, "{}"); + assert_eq!(notification.to_proto().content, "{}"); } From cb7b011d6be5ba45022c62446448a2a46afe6341 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 16:57:28 -0700 Subject: [PATCH 14/44] Avoid creating duplicate invite notifications --- .../20221109000000_test_schema.sql | 2 +- crates/collab/src/db/queries/channels.rs | 13 ++- crates/collab/src/db/queries/contacts.rs | 8 +- crates/collab/src/db/queries/notifications.rs | 83 +++++++++++++------ crates/collab/src/rpc.rs | 40 ++++++--- crates/collab_ui/src/notification_panel.rs | 7 +- 6 files changed, 109 insertions(+), 44 deletions(-) diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index a10155fd1dcc1d369a61a934d1fff27a0cd9557c..4372d7dc8a62cdfbd3276a9771f9012896590733 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -330,4 +330,4 @@ CREATE TABLE "notifications" ( "content" TEXT ); -CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id"); +CREATE INDEX "index_notifications_on_recipient_id_is_read" ON "notifications" ("recipient_id", "is_read"); diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index c576d2406b81279c38561406c3801c02ddaf4377..d64b8028e3aa61ada6e245ad3c770b1bf48df77d 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,7 +176,16 @@ impl Database { .insert(&*tx) .await?; - Ok(()) + self.create_notification( + invitee_id, + rpc::Notification::ChannelInvitation { + actor_id: inviter_id.to_proto(), + channel_id: channel_id.to_proto(), + }, + true, + &*tx, + ) + .await }) .await } diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index ddb7959ef26571b47608c77b4607ea9d1e6d5957..709ed941f752e8f9e45ca165d695b6cd3b66862f 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) @@ -169,6 +169,7 @@ impl Database { rpc::Notification::ContactRequest { actor_id: sender_id.to_proto(), }, + true, &*tx, ) .await @@ -212,7 +213,7 @@ impl Database { let mut deleted_notification_id = None; if !contact.accepted { deleted_notification_id = self - .delete_notification( + .remove_notification( responder_id, rpc::Notification::ContactRequest { actor_id: requester_id.to_proto(), @@ -273,7 +274,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) @@ -320,6 +321,7 @@ impl Database { rpc::Notification::ContactRequestAccepted { actor_id: responder_id.to_proto(), }, + true, &*tx, ) .await diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index bf9c9d74efdd82ce6ad96aeb354a81b3de70ede4..b8b2a15421e92c3df2cf449919d5c30629bd0a13 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -51,18 +51,12 @@ impl Database { .await?; while let Some(row) = rows.next().await { let row = row?; - let Some(kind) = self.notification_kinds_by_id.get(&row.kind) else { - log::warn!("unknown notification kind {:?}", row.kind); - continue; - }; - result.push(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()), - }); + let kind = row.kind; + if let Some(proto) = self.model_to_proto(row) { + result.push(proto); + } else { + log::warn!("unknown notification kind {:?}", kind); + } } result.reverse(); Ok(result) @@ -74,19 +68,48 @@ impl Database { &self, recipient_id: UserId, notification: Notification, + avoid_duplicates: bool, tx: &DatabaseTransaction, - ) -> Result { - let notification = notification.to_proto(); + ) -> Result> { + let notification_proto = notification.to_proto(); let kind = *self .notification_kinds_by_name - .get(¬ification.kind) - .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; + .get(¬ification_proto.kind) + .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification_proto.kind))?; + let actor_id = notification_proto.actor_id.map(|id| UserId::from_proto(id)); + + if avoid_duplicates { + let mut existing_notifications = notification::Entity::find() + .filter( + Condition::all() + .add(notification::Column::RecipientId.eq(recipient_id)) + .add(notification::Column::IsRead.eq(false)) + .add(notification::Column::Kind.eq(kind)) + .add(notification::Column::ActorId.eq(actor_id)), + ) + .stream(&*tx) + .await?; + + // Check if this notification already exists. Don't rely on the + // JSON serialization being identical, in case the notification enum + // is changed in backward-compatible ways over time. + while let Some(row) = existing_notifications.next().await { + let row = row?; + if let Some(proto) = self.model_to_proto(row) { + if let Some(existing) = Notification::from_proto(&proto) { + if existing == notification { + return Ok(None); + } + } + } + } + } let model = notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), kind: ActiveValue::Set(kind), - content: ActiveValue::Set(notification.content.clone()), - actor_id: ActiveValue::Set(notification.actor_id.map(|id| UserId::from_proto(id))), + content: ActiveValue::Set(notification_proto.content.clone()), + actor_id: ActiveValue::Set(actor_id), is_read: ActiveValue::NotSet, created_at: ActiveValue::NotSet, id: ActiveValue::NotSet, @@ -94,17 +117,17 @@ impl Database { .save(&*tx) .await?; - Ok(proto::Notification { + Ok(Some(proto::Notification { id: model.id.as_ref().to_proto(), - kind: notification.kind.to_string(), + kind: notification_proto.kind.to_string(), timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, is_read: false, - content: notification.content, - actor_id: notification.actor_id, - }) + content: notification_proto.content, + actor_id: notification_proto.actor_id, + })) } - pub async fn delete_notification( + pub async fn remove_notification( &self, recipient_id: UserId, notification: Notification, @@ -133,4 +156,16 @@ impl Database { } Ok(notification.map(|notification| notification.id)) } + + 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()), + }) + } } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 7a3cdb13ab797792ba833d733315d5ff6a23243e..cd82490649d8fbd04b4b7c5a36ac2f36e2701761 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2097,12 +2097,14 @@ async fn request_contact( .user_connection_ids(responder_id) { session.peer.send(connection_id, update.clone())?; - session.peer.send( - connection_id, - proto::NewNotification { - notification: Some(notification.clone()), - }, - )?; + if let Some(notification) = ¬ification { + session.peer.send( + connection_id, + proto::NewNotification { + notification: Some(notification.clone()), + }, + )?; + } } response.send(proto::Ack {})?; @@ -2156,12 +2158,14 @@ async fn respond_to_contact_request( .push(responder_id.to_proto()); for connection_id in pool.user_connection_ids(requester_id) { session.peer.send(connection_id, update.clone())?; - session.peer.send( - connection_id, - proto::NewNotification { - notification: Some(notification.clone()), - }, - )?; + if let Some(notification) = ¬ification { + session.peer.send( + connection_id, + proto::NewNotification { + notification: Some(notification.clone()), + }, + )?; + } } } @@ -2306,7 +2310,8 @@ 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); - db.invite_channel_member(channel_id, invitee_id, session.user_id, request.admin) + let notification = db + .invite_channel_member(channel_id, invitee_id, session.user_id, request.admin) .await?; let (channel, _) = db @@ -2319,12 +2324,21 @@ async fn invite_channel_member( id: channel.id.to_proto(), name: channel.name, }); + for connection_id in session .connection_pool() .await .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()), + }, + )?; + } } response.send(proto::Ack {})?; diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 978255a081cbe7ce17713055c7fdd6ba20b45be1..9f69b7144c3025aa7c0a674fec765e60e4db7b06 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -209,7 +209,12 @@ impl NotificationPanel { channel_id, } => { actor = user_store.get_cached_user(inviter_id)?; - let channel = channel_store.channel_for_id(channel_id)?; + let channel = channel_store.channel_for_id(channel_id).or_else(|| { + channel_store + .channel_invitations() + .iter() + .find(|c| c.id == channel_id) + })?; icon = "icons/hash.svg"; text = format!( From ff245c61d2eb9bf2da51c8f9feb2cd091b697554 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Fri, 13 Oct 2023 17:10:46 -0700 Subject: [PATCH 15/44] Reduce duplication in notification queries --- crates/collab/src/db/queries/notifications.rs | 89 ++++++++++--------- 1 file changed, 48 insertions(+), 41 deletions(-) diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index b8b2a15421e92c3df2cf449919d5c30629bd0a13..50e961957cfd00b338390cdf96831af02f4be23a 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -71,6 +71,16 @@ impl Database { avoid_duplicates: bool, tx: &DatabaseTransaction, ) -> Result> { + if avoid_duplicates { + if self + .find_notification(recipient_id, ¬ification, tx) + .await? + .is_some() + { + return Ok(None); + } + } + let notification_proto = notification.to_proto(); let kind = *self .notification_kinds_by_name @@ -78,33 +88,6 @@ impl Database { .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification_proto.kind))?; let actor_id = notification_proto.actor_id.map(|id| UserId::from_proto(id)); - if avoid_duplicates { - let mut existing_notifications = notification::Entity::find() - .filter( - Condition::all() - .add(notification::Column::RecipientId.eq(recipient_id)) - .add(notification::Column::IsRead.eq(false)) - .add(notification::Column::Kind.eq(kind)) - .add(notification::Column::ActorId.eq(actor_id)), - ) - .stream(&*tx) - .await?; - - // Check if this notification already exists. Don't rely on the - // JSON serialization being identical, in case the notification enum - // is changed in backward-compatible ways over time. - while let Some(row) = existing_notifications.next().await { - let row = row?; - if let Some(proto) = self.model_to_proto(row) { - if let Some(existing) = Notification::from_proto(&proto) { - if existing == notification { - return Ok(None); - } - } - } - } - } - let model = notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), kind: ActiveValue::Set(kind), @@ -119,7 +102,7 @@ impl Database { Ok(Some(proto::Notification { id: model.id.as_ref().to_proto(), - kind: notification_proto.kind.to_string(), + kind: notification_proto.kind, timestamp: model.created_at.as_ref().assume_utc().unix_timestamp() as u64, is_read: false, content: notification_proto.content, @@ -133,28 +116,52 @@ impl Database { notification: Notification, tx: &DatabaseTransaction, ) -> Result> { - let notification = notification.to_proto(); + let id = self + .find_notification(recipient_id, ¬ification, tx) + .await?; + if let Some(id) = id { + notification::Entity::delete_by_id(id).exec(tx).await?; + } + Ok(id) + } + + pub async fn find_notification( + &self, + recipient_id: UserId, + notification: &Notification, + tx: &DatabaseTransaction, + ) -> Result> { + let proto = notification.to_proto(); let kind = *self .notification_kinds_by_name - .get(¬ification.kind) - .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification.kind))?; - let actor_id = notification.actor_id.map(|id| UserId::from_proto(id)); - let notification = notification::Entity::find() + .get(&proto.kind) + .ok_or_else(|| anyhow!("invalid notification kind {:?}", proto.kind))?; + let mut rows = notification::Entity::find() .filter( Condition::all() .add(notification::Column::RecipientId.eq(recipient_id)) + .add(notification::Column::IsRead.eq(false)) .add(notification::Column::Kind.eq(kind)) - .add(notification::Column::ActorId.eq(actor_id)) - .add(notification::Column::Content.eq(notification.content)), + .add(notification::Column::ActorId.eq(proto.actor_id)), ) - .one(tx) + .stream(&*tx) .await?; - if let Some(notification) = ¬ification { - notification::Entity::delete_by_id(notification.id) - .exec(tx) - .await?; + + // Don't rely on the JSON serialization being identical, in case the + // notification type is changed in backward-compatible ways. + 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(existing) = Notification::from_proto(&proto) { + if existing == *notification { + return Ok(Some(id)); + } + } + } } - Ok(notification.map(|notification| notification.id)) + + Ok(None) } fn model_to_proto(&self, row: notification::Model) -> Option { From c66385f0f9099b89f07f2a6997da182218b4f69e Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Mon, 16 Oct 2023 12:54:44 -0700 Subject: [PATCH 16/44] Add an empty state to the notification panel --- crates/collab_ui/src/notification_panel.rs | 21 ++++++++++++++++++--- crates/gpui/src/elements/list.rs | 4 ++++ 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 9f69b7144c3025aa7c0a674fec765e60e4db7b06..7bf5000ec873c2830ad1875516da00d1cdf6bd4a 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -299,6 +299,19 @@ impl NotificationPanel { .into_any() } + fn render_empty_state( + &self, + theme: &Arc, + _cx: &mut ViewContext, + ) -> AnyElement { + Label::new( + "You have no notifications".to_string(), + theme.chat_panel.sign_in_prompt.default.clone(), + ) + .aligned() + .into_any() + } + fn on_notification_event( &mut self, _: ModelHandle, @@ -373,13 +386,15 @@ impl View for NotificationPanel { fn render(&mut self, cx: &mut ViewContext) -> AnyElement { let theme = theme::current(cx); - let element = if self.client.user_id().is_some() { + let element = if self.client.user_id().is_none() { + self.render_sign_in_prompt(&theme, cx) + } else if self.notification_list.item_count() == 0 { + self.render_empty_state(&theme, cx) + } else { List::new(self.notification_list.clone()) .contained() .with_style(theme.chat_panel.list) .into_any() - } else { - self.render_sign_in_prompt(&theme, cx) }; element .contained() diff --git a/crates/gpui/src/elements/list.rs b/crates/gpui/src/elements/list.rs index a23b6fc5e3eb335b84fd4039697c11a2fc07e84d..eaa09a0392d8dbc43f1d35f3f25007606802cd33 100644 --- a/crates/gpui/src/elements/list.rs +++ b/crates/gpui/src/elements/list.rs @@ -378,6 +378,10 @@ impl ListState { .extend((0..element_count).map(|_| ListItem::Unrendered), &()); } + pub fn item_count(&self) -> usize { + self.0.borrow().items.summary().count + } + pub fn splice(&self, old_range: Range, count: usize) { let state = &mut *self.0.borrow_mut(); From f225039d360e21a84eda2d6c157103d4169af83e Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 09:12:55 -0700 Subject: [PATCH 17/44] 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 | 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 ++++++++++++++---- .../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(-) 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 918a1e1dc1f962ac48ef8e32c01982caa270a9b8..d8dc7896eaeafe14385a2378380c8565e9fee40b 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 4372d7dc8a62cdfbd3276a9771f9012896590733..8e714f14441d00cf9a379f56387de183385fd801 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 83cfd4397810ebb5934998c177ca1b1b1a885fd4..277f16f4e39594ca6216f913a23323cf311ab322 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 1bf5c95f6b2aafd210ae72ee00775681d5e67233..852d3645dd84d14ca3831a5bd2bb18976304a525 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 d64b8028e3aa61ada6e245ad3c770b1bf48df77d..9754c2ac83c5b9cf787b7a58240364794126e1f5 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 709ed941f752e8f9e45ca165d695b6cd3b66862f..4509bb8495def06cc06688919991553966229531 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 50e961957cfd00b338390cdf96831af02f4be23a..d4024232b0e1d2ab4ec82833382a0363a3a16fac 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 a35e00fb5b6ab257843b9b6472286f7163003a34..12517c04f67b01e345913b868ad6e18b4154d6c0 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 cd82490649d8fbd04b4b7c5a36ac2f36e2701761..9f3c22ce976d5eeb31d69efbdabe39a053f50b42 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 7cfcce832b4cd4e05a953156828e517ef85af9fe..fa82f55b39914cde6e89646b5f820438b90ff8ec 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 9d03d1e17ec597d59caffd908ab1ba4308967189..2dddd5961bac01b9198d3387125b48a41150b422 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 30505b0876c054d8f1b05fcec531d6d04bde5fa1..911b94ae9354e38fb38c21c08db744b0d88b22b4 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 7bf5000ec873c2830ad1875516da00d1cdf6bd4a..73c07949d0fd892aee73a6bad6787c39b63c7281 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 af39941d2f1673b8a8b951c5bb451c956a7b68e2..d0691db106f937f112f44fd0fc100fce4f5016d1 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 d27bbade6f2425018875f875591d523d6c2dffcc..46db82047ee5594aaee47eeaf9bcf8810315186e 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 6ff96601594aed5131165a4ae87e598135a66368..b03e928197b6afcdb62ea272db7e5d74098d3208 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 f335444b58c2380f2fcec66e9890f32e406db796..389d15ef05dbca610f904db6f785fb4acede64cb 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 3233909fd02b43d05b748fe2e07c14fb6d885c4a..aff934e9c6004eb7fa8cb8faf9438a066fe7292a 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 0000000000000000000000000000000000000000..9afdf1e00a2c81bcc8f1b662c55466db032c7388 --- /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"), + } + } +} From f2d36a47ae5df75b4e52f027b3a1835740bce678 Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 10:34:50 -0700 Subject: [PATCH 18/44] Generalize notifications' actor id to entity id This way, we can retrieve channel invite notifications when responding to the invites. --- .../20221109000000_test_schema.sql | 2 +- .../20231004130100_create_notifications.sql | 2 +- crates/collab/src/db.rs | 2 +- crates/collab/src/db/queries/channels.rs | 7 ++ crates/collab/src/db/queries/contacts.rs | 8 +- crates/collab/src/db/queries/notifications.rs | 91 ++++++++++--------- crates/collab/src/db/tables/notification.rs | 2 +- crates/collab/src/db/tests.rs | 4 +- crates/collab/src/lib.rs | 2 +- crates/collab_ui/src/notification_panel.rs | 37 ++++---- .../notifications/src/notification_store.rs | 6 +- crates/rpc/proto/zed.proto | 4 +- crates/rpc/src/notification.rs | 46 ++++++---- 13 files changed, 115 insertions(+), 98 deletions(-) diff --git a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql index 8e714f14441d00cf9a379f56387de183385fd801..1efd14e6eb7aba246f075ffbfdbab02d2d3ab041 100644 --- a/crates/collab/migrations.sqlite/20221109000000_test_schema.sql +++ b/crates/collab/migrations.sqlite/20221109000000_test_schema.sql @@ -324,8 +324,8 @@ CREATE TABLE "notifications" ( "id" INTEGER PRIMARY KEY AUTOINCREMENT, "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), + "entity_id" INTEGER, "content" TEXT, "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "response" BOOLEAN diff --git a/crates/collab/migrations/20231004130100_create_notifications.sql b/crates/collab/migrations/20231004130100_create_notifications.sql index 277f16f4e39594ca6216f913a23323cf311ab322..cdc6674ff18a5f61a33831c7e47f78b3477c5cec 100644 --- a/crates/collab/migrations/20231004130100_create_notifications.sql +++ b/crates/collab/migrations/20231004130100_create_notifications.sql @@ -9,8 +9,8 @@ CREATE TABLE notifications ( "id" SERIAL PRIMARY KEY, "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), + "entity_id" INTEGER, "content" TEXT, "is_read" BOOLEAN NOT NULL DEFAULT FALSE, "response" BOOLEAN diff --git a/crates/collab/src/db.rs b/crates/collab/src/db.rs index 852d3645dd84d14ca3831a5bd2bb18976304a525..4c9e47a27012c2de650fbbbe5089d035118bf839 100644 --- a/crates/collab/src/db.rs +++ b/crates/collab/src/db.rs @@ -125,7 +125,7 @@ impl Database { } pub async fn initialize_static_data(&mut self) -> Result<()> { - self.initialize_notification_enum().await?; + self.initialize_notification_kinds().await?; Ok(()) } diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 9754c2ac83c5b9cf787b7a58240364794126e1f5..745bd6e3abc402be7cb392d5ba35de9df0d5e128 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -166,6 +166,11 @@ impl Database { self.check_user_is_channel_admin(channel_id, inviter_id, &*tx) .await?; + let channel = channel::Entity::find_by_id(channel_id) + .one(&*tx) + .await? + .ok_or_else(|| anyhow!("no such channel"))?; + channel_member::ActiveModel { channel_id: ActiveValue::Set(channel_id), user_id: ActiveValue::Set(invitee_id), @@ -181,6 +186,7 @@ impl Database { invitee_id, rpc::Notification::ChannelInvitation { channel_id: channel_id.to_proto(), + channel_name: channel.name, }, true, &*tx, @@ -269,6 +275,7 @@ impl Database { user_id, &rpc::Notification::ChannelInvitation { channel_id: channel_id.to_proto(), + channel_name: Default::default(), }, accept, &*tx, diff --git a/crates/collab/src/db/queries/contacts.rs b/crates/collab/src/db/queries/contacts.rs index 4509bb8495def06cc06688919991553966229531..841f9faa2075a1a679c671a2ccb8984a9fdbad20 100644 --- a/crates/collab/src/db/queries/contacts.rs +++ b/crates/collab/src/db/queries/contacts.rs @@ -168,7 +168,7 @@ impl Database { .create_notification( receiver_id, rpc::Notification::ContactRequest { - actor_id: sender_id.to_proto(), + sender_id: sender_id.to_proto(), }, true, &*tx, @@ -219,7 +219,7 @@ impl Database { .remove_notification( responder_id, rpc::Notification::ContactRequest { - actor_id: requester_id.to_proto(), + sender_id: requester_id.to_proto(), }, &*tx, ) @@ -324,7 +324,7 @@ impl Database { self.respond_to_notification( responder_id, &rpc::Notification::ContactRequest { - actor_id: requester_id.to_proto(), + sender_id: requester_id.to_proto(), }, accept, &*tx, @@ -337,7 +337,7 @@ impl Database { self.create_notification( requester_id, rpc::Notification::ContactRequestAccepted { - actor_id: responder_id.to_proto(), + responder_id: responder_id.to_proto(), }, true, &*tx, diff --git a/crates/collab/src/db/queries/notifications.rs b/crates/collab/src/db/queries/notifications.rs index d4024232b0e1d2ab4ec82833382a0363a3a16fac..893bedb72b910a678c4c46c110724bf0729b2184 100644 --- a/crates/collab/src/db/queries/notifications.rs +++ b/crates/collab/src/db/queries/notifications.rs @@ -2,7 +2,7 @@ use super::*; use rpc::Notification; impl Database { - pub async fn initialize_notification_enum(&mut self) -> Result<()> { + pub async fn initialize_notification_kinds(&mut self) -> Result<()> { notification_kind::Entity::insert_many(Notification::all_variant_names().iter().map( |kind| notification_kind::ActiveModel { name: ActiveValue::Set(kind.to_string()), @@ -64,6 +64,9 @@ impl Database { .await } + /// Create a notification. If `avoid_duplicates` is set to true, then avoid + /// creating a new notification if the given recipient already has an + /// unread notification with the given kind and entity id. pub async fn create_notification( &self, recipient_id: UserId, @@ -81,22 +84,14 @@ impl Database { } } - let notification_proto = notification.to_proto(); - let kind = *self - .notification_kinds_by_name - .get(¬ification_proto.kind) - .ok_or_else(|| anyhow!("invalid notification kind {:?}", notification_proto.kind))?; - let actor_id = notification_proto.actor_id.map(|id| UserId::from_proto(id)); - + let proto = notification.to_proto(); + let kind = notification_kind_from_proto(self, &proto)?; let model = notification::ActiveModel { recipient_id: ActiveValue::Set(recipient_id), kind: ActiveValue::Set(kind), - 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, + entity_id: ActiveValue::Set(proto.entity_id.map(|id| id as i32)), + content: ActiveValue::Set(proto.content.clone()), + ..Default::default() } .save(&*tx) .await?; @@ -105,16 +100,18 @@ impl Database { recipient_id, proto::Notification { id: model.id.as_ref().to_proto(), - kind: notification_proto.kind, + kind: 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, + content: proto.content, + entity_id: proto.entity_id, }, ))) } + /// Remove an unread notification with the given recipient, kind and + /// entity id. pub async fn remove_notification( &self, recipient_id: UserId, @@ -130,6 +127,8 @@ impl Database { Ok(id) } + /// Populate the response for the notification with the given kind and + /// entity id. pub async fn respond_to_notification( &self, recipient_id: UserId, @@ -156,47 +155,38 @@ impl Database { } } - pub async fn find_notification( + /// Find an unread notification by its recipient, kind and entity id. + async fn find_notification( &self, recipient_id: UserId, notification: &Notification, tx: &DatabaseTransaction, ) -> Result> { let proto = notification.to_proto(); - let kind = *self - .notification_kinds_by_name - .get(&proto.kind) - .ok_or_else(|| anyhow!("invalid notification kind {:?}", proto.kind))?; - let mut rows = notification::Entity::find() + let kind = notification_kind_from_proto(self, &proto)?; + + #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] + enum QueryIds { + Id, + } + + Ok(notification::Entity::find() + .select_only() + .column(notification::Column::Id) .filter( Condition::all() .add(notification::Column::RecipientId.eq(recipient_id)) .add(notification::Column::IsRead.eq(false)) .add(notification::Column::Kind.eq(kind)) - .add(if proto.actor_id.is_some() { - notification::Column::ActorId.eq(proto.actor_id) + .add(if proto.entity_id.is_some() { + notification::Column::EntityId.eq(proto.entity_id) } else { - notification::Column::ActorId.is_null() + notification::Column::EntityId.is_null() }), ) - .stream(&*tx) - .await?; - - // Don't rely on the JSON serialization being identical, in case the - // notification type is changed in backward-compatible ways. - while let Some(row) = rows.next().await { - let row = row?; - let id = row.id; - if let Some(proto) = model_to_proto(self, row) { - if let Some(existing) = Notification::from_proto(&proto) { - if existing == *notification { - return Ok(Some(id)); - } - } - } - } - - Ok(None) + .into_values::<_, QueryIds>() + .one(&*tx) + .await?) } } @@ -209,6 +199,17 @@ fn model_to_proto(this: &Database, row: notification::Model) -> Option Result { + Ok(this + .notification_kinds_by_name + .get(&proto.kind) + .copied() + .ok_or_else(|| anyhow!("invalid notification kind {:?}", proto.kind))?) +} diff --git a/crates/collab/src/db/tables/notification.rs b/crates/collab/src/db/tables/notification.rs index 12517c04f67b01e345913b868ad6e18b4154d6c0..3105198fa21764351b4a2343258e91055b6a8641 100644 --- a/crates/collab/src/db/tables/notification.rs +++ b/crates/collab/src/db/tables/notification.rs @@ -9,8 +9,8 @@ pub struct Model { pub id: NotificationId, pub created_at: PrimitiveDateTime, pub recipient_id: UserId, - pub actor_id: Option, pub kind: NotificationKindId, + pub entity_id: Option, pub content: String, pub is_read: bool, pub response: Option, diff --git a/crates/collab/src/db/tests.rs b/crates/collab/src/db/tests.rs index 465ff56444fc92b99e1da5786aa9620596ebf615..f05a4cbebb40ebcfe2cbf5f3d6eae9baddf74891 100644 --- a/crates/collab/src/db/tests.rs +++ b/crates/collab/src/db/tests.rs @@ -45,7 +45,7 @@ impl TestDb { )) .await .unwrap(); - db.initialize_notification_enum().await.unwrap(); + db.initialize_notification_kinds().await.unwrap(); db }); @@ -85,7 +85,7 @@ impl TestDb { .unwrap(); let migrations_path = concat!(env!("CARGO_MANIFEST_DIR"), "/migrations"); db.migrate(Path::new(migrations_path), false).await.unwrap(); - db.initialize_notification_enum().await.unwrap(); + db.initialize_notification_kinds().await.unwrap(); db }); diff --git a/crates/collab/src/lib.rs b/crates/collab/src/lib.rs index 17224242173645ae2f27ee01daa431d58fdd63b1..85216525b0018c6d051c55a5882af8445f45c7d0 100644 --- a/crates/collab/src/lib.rs +++ b/crates/collab/src/lib.rs @@ -120,7 +120,7 @@ impl AppState { let mut db_options = db::ConnectOptions::new(config.database_url.clone()); db_options.max_connections(config.database_max_connections); let mut db = Database::new(db_options, Executor::Production).await?; - db.initialize_notification_enum().await?; + db.initialize_notification_kinds().await?; let live_kit_client = if let Some(((server, key), secret)) = config .live_kit_server diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 73c07949d0fd892aee73a6bad6787c39b63c7281..3f1bafb10ec23b994d6a0a1492e9aeaf4f9cc872 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -192,39 +192,34 @@ impl NotificationPanel { let actor; let needs_acceptance; match notification { - Notification::ContactRequest { actor_id } => { - let requester = user_store.get_cached_user(actor_id)?; + Notification::ContactRequest { sender_id } => { + let requester = user_store.get_cached_user(sender_id)?; icon = "icons/plus.svg"; text = format!("{} wants to add you as a contact", requester.github_login); needs_acceptance = true; actor = Some(requester); } - Notification::ContactRequestAccepted { actor_id } => { - let responder = user_store.get_cached_user(actor_id)?; + Notification::ContactRequestAccepted { responder_id } => { + let responder = user_store.get_cached_user(responder_id)?; icon = "icons/plus.svg"; text = format!("{} accepted your contact invite", responder.github_login); needs_acceptance = false; actor = Some(responder); } - Notification::ChannelInvitation { channel_id } => { + Notification::ChannelInvitation { + ref channel_name, .. + } => { actor = None; - let channel = channel_store.channel_for_id(channel_id).or_else(|| { - channel_store - .channel_invitations() - .iter() - .find(|c| c.id == channel_id) - })?; - icon = "icons/hash.svg"; - text = format!("you were invited to join the #{} channel", channel.name); + text = format!("you were invited to join the #{channel_name} channel"); needs_acceptance = true; } Notification::ChannelMessageMention { - actor_id, + sender_id, channel_id, message_id, } => { - let sender = user_store.get_cached_user(actor_id)?; + let sender = user_store.get_cached_user(sender_id)?; let channel = channel_store.channel_for_id(channel_id)?; let message = notification_store.channel_message_for_id(message_id)?; @@ -405,8 +400,12 @@ impl NotificationPanel { fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { let id = entry.id as usize; match entry.notification { - Notification::ContactRequest { actor_id } - | Notification::ContactRequestAccepted { actor_id } => { + Notification::ContactRequest { + sender_id: actor_id, + } + | Notification::ContactRequestAccepted { + responder_id: actor_id, + } => { let user_store = self.user_store.clone(); let Some(user) = user_store.read(cx).get_cached_user(actor_id) else { return; @@ -452,7 +451,9 @@ impl NotificationPanel { cx: &mut ViewContext, ) { match notification { - Notification::ContactRequest { actor_id } => { + Notification::ContactRequest { + sender_id: actor_id, + } => { self.user_store .update(cx, |store, cx| { store.respond_to_contact_request(actor_id, response, cx) diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index d0691db106f937f112f44fd0fc100fce4f5016d1..43afb8181ab6ddaf978d9e4aee96d34eadb0bafa 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -199,17 +199,17 @@ impl NotificationStore { match entry.notification { Notification::ChannelInvitation { .. } => {} Notification::ContactRequest { - actor_id: requester_id, + sender_id: requester_id, } => { user_ids.push(requester_id); } Notification::ContactRequestAccepted { - actor_id: contact_id, + responder_id: contact_id, } => { user_ids.push(contact_id); } Notification::ChannelMessageMention { - actor_id: sender_id, + sender_id, message_id, .. } => { diff --git a/crates/rpc/proto/zed.proto b/crates/rpc/proto/zed.proto index 46db82047ee5594aaee47eeaf9bcf8810315186e..a5ba1c1cf7891e5c50e45d792b0223d809c19fdd 100644 --- a/crates/rpc/proto/zed.proto +++ b/crates/rpc/proto/zed.proto @@ -1599,8 +1599,8 @@ message Notification { uint64 id = 1; uint64 timestamp = 2; string kind = 3; - string content = 4; - optional uint64 actor_id = 5; + optional uint64 entity_id = 4; + string content = 5; bool is_read = 6; optional bool response = 7; } diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index b03e928197b6afcdb62ea272db7e5d74098d3208..06dff82b758ee2da9b16394ec7d506305de48597 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -4,32 +4,37 @@ use serde_json::{map, Value}; use strum::{EnumVariantNames, VariantNames as _}; const KIND: &'static str = "kind"; -const ACTOR_ID: &'static str = "actor_id"; +const ENTITY_ID: &'static str = "entity_id"; -/// A notification that can be stored, associated with a given user. +/// A notification that can be stored, associated with a given recipient. /// /// This struct is stored in the collab database as JSON, so it shouldn't be /// changed in a backward-incompatible way. For example, when renaming a /// 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. This is value is stored in a dedicated column -/// in the database, so it can be queried more efficiently. +/// Most notification types have a special field which is aliased to +/// `entity_id`. This field is stored in its own database column, and can +/// be used to query the notification. #[derive(Debug, Clone, PartialEq, Eq, EnumVariantNames, Serialize, Deserialize)] #[serde(tag = "kind")] pub enum Notification { ContactRequest { - actor_id: u64, + #[serde(rename = "entity_id")] + sender_id: u64, }, ContactRequestAccepted { - actor_id: u64, + #[serde(rename = "entity_id")] + responder_id: u64, }, ChannelInvitation { + #[serde(rename = "entity_id")] channel_id: u64, + channel_name: String, }, ChannelMessageMention { - actor_id: u64, + sender_id: u64, channel_id: u64, + #[serde(rename = "entity_id")] message_id: u64, }, } @@ -37,19 +42,19 @@ pub enum Notification { impl Notification { pub fn to_proto(&self) -> proto::Notification { let mut value = serde_json::to_value(self).unwrap(); - let mut actor_id = None; + let mut entity_id = None; let value = value.as_object_mut().unwrap(); let Some(Value::String(kind)) = value.remove(KIND) else { unreachable!("kind is the enum tag") }; - if let map::Entry::Occupied(e) = value.entry(ACTOR_ID) { + if let map::Entry::Occupied(e) = value.entry(ENTITY_ID) { if e.get().is_u64() { - actor_id = e.remove().as_u64(); + entity_id = e.remove().as_u64(); } } proto::Notification { kind, - actor_id, + entity_id, content: serde_json::to_string(&value).unwrap(), ..Default::default() } @@ -59,8 +64,8 @@ impl Notification { let mut value = serde_json::from_str::(¬ification.content).ok()?; let object = value.as_object_mut()?; object.insert(KIND.into(), notification.kind.to_string().into()); - if let Some(actor_id) = notification.actor_id { - object.insert(ACTOR_ID.into(), actor_id.into()); + if let Some(entity_id) = notification.entity_id { + object.insert(ENTITY_ID.into(), entity_id.into()); } serde_json::from_value(value).ok() } @@ -74,11 +79,14 @@ impl Notification { fn test_notification() { // Notifications can be serialized and deserialized. for notification in [ - Notification::ContactRequest { actor_id: 1 }, - Notification::ContactRequestAccepted { actor_id: 2 }, - Notification::ChannelInvitation { channel_id: 100 }, + Notification::ContactRequest { sender_id: 1 }, + Notification::ContactRequestAccepted { responder_id: 2 }, + Notification::ChannelInvitation { + channel_id: 100, + channel_name: "the-channel".into(), + }, Notification::ChannelMessageMention { - actor_id: 200, + sender_id: 200, channel_id: 30, message_id: 1, }, @@ -90,6 +98,6 @@ fn test_notification() { // When notifications are serialized, the `kind` and `actor_id` fields are // stored separately, and do not appear redundantly in the JSON. - let notification = Notification::ContactRequest { actor_id: 1 }; + let notification = Notification::ContactRequest { sender_id: 1 }; assert_eq!(notification.to_proto().content, "{}"); } From 52834dbf210845343ade57b084f3db2b1dc2e8ff Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 11:21:38 -0700 Subject: [PATCH 19/44] Add notifications integration test --- crates/collab/src/tests.rs | 1 + crates/collab/src/tests/notification_tests.rs | 115 ++++++++++++++++++ crates/collab/src/tests/test_server.rs | 7 ++ crates/collab_ui/src/notification_panel.rs | 25 +--- .../notifications/src/notification_store.rs | 36 +++++- 5 files changed, 163 insertions(+), 21 deletions(-) create mode 100644 crates/collab/src/tests/notification_tests.rs diff --git a/crates/collab/src/tests.rs b/crates/collab/src/tests.rs index e78bbe3466318cfc44fbcf298cef65a86350a0b8..139910e1f6f25281139865f0b80b8e20e15648d0 100644 --- a/crates/collab/src/tests.rs +++ b/crates/collab/src/tests.rs @@ -6,6 +6,7 @@ mod channel_message_tests; mod channel_tests; mod following_tests; mod integration_tests; +mod notification_tests; mod random_channel_buffer_tests; mod random_project_collaboration_tests; mod randomized_test_helpers; diff --git a/crates/collab/src/tests/notification_tests.rs b/crates/collab/src/tests/notification_tests.rs new file mode 100644 index 0000000000000000000000000000000000000000..da94bd6fad80cac4e6983fa56a39f29148a0215f --- /dev/null +++ b/crates/collab/src/tests/notification_tests.rs @@ -0,0 +1,115 @@ +use crate::tests::TestServer; +use gpui::{executor::Deterministic, TestAppContext}; +use rpc::Notification; +use std::sync::Arc; + +#[gpui::test] +async fn test_notifications( + deterministic: Arc, + cx_a: &mut TestAppContext, + cx_b: &mut TestAppContext, +) { + deterministic.forbid_parking(); + let mut server = TestServer::start(&deterministic).await; + let client_a = server.create_client(cx_a, "user_a").await; + let client_b = server.create_client(cx_b, "user_b").await; + + // Client A sends a contact request to client B. + client_a + .user_store() + .update(cx_a, |store, cx| store.request_contact(client_b.id(), cx)) + .await + .unwrap(); + + // Client B receives a contact request notification and responds to the + // request, accepting it. + deterministic.run_until_parked(); + client_b.notification_store().update(cx_b, |store, cx| { + assert_eq!(store.notification_count(), 1); + assert_eq!(store.unread_notification_count(), 1); + + let entry = store.notification_at(0).unwrap(); + assert_eq!( + entry.notification, + Notification::ContactRequest { + sender_id: client_a.id() + } + ); + assert!(!entry.is_read); + + store.respond_to_notification(entry.notification.clone(), true, cx); + }); + + // Client B sees the notification is now read, and that they responded. + deterministic.run_until_parked(); + client_b.notification_store().read_with(cx_b, |store, _| { + assert_eq!(store.notification_count(), 1); + assert_eq!(store.unread_notification_count(), 0); + + let entry = store.notification_at(0).unwrap(); + assert!(entry.is_read); + assert_eq!(entry.response, Some(true)); + }); + + // Client A receives a notification that client B accepted their request. + client_a.notification_store().read_with(cx_a, |store, _| { + assert_eq!(store.notification_count(), 1); + assert_eq!(store.unread_notification_count(), 1); + + let entry = store.notification_at(0).unwrap(); + assert_eq!( + entry.notification, + Notification::ContactRequestAccepted { + responder_id: client_b.id() + } + ); + assert!(!entry.is_read); + }); + + // Client A creates a channel and invites client B to be a member. + let channel_id = client_a + .channel_store() + .update(cx_a, |store, cx| { + store.create_channel("the-channel", None, cx) + }) + .await + .unwrap(); + client_a + .channel_store() + .update(cx_a, |store, cx| { + store.invite_member(channel_id, client_b.id(), false, cx) + }) + .await + .unwrap(); + + // Client B receives a channel invitation notification and responds to the + // invitation, accepting it. + deterministic.run_until_parked(); + client_b.notification_store().update(cx_b, |store, cx| { + assert_eq!(store.notification_count(), 2); + assert_eq!(store.unread_notification_count(), 1); + + let entry = store.notification_at(1).unwrap(); + assert_eq!( + entry.notification, + Notification::ChannelInvitation { + channel_id, + channel_name: "the-channel".to_string() + } + ); + assert!(!entry.is_read); + + store.respond_to_notification(entry.notification.clone(), true, cx); + }); + + // Client B sees the notification is now read, and that they responded. + deterministic.run_until_parked(); + client_b.notification_store().read_with(cx_b, |store, _| { + assert_eq!(store.notification_count(), 2); + assert_eq!(store.unread_notification_count(), 0); + + let entry = store.notification_at(1).unwrap(); + assert!(entry.is_read); + assert_eq!(entry.response, Some(true)); + }); +} diff --git a/crates/collab/src/tests/test_server.rs b/crates/collab/src/tests/test_server.rs index 2dddd5961bac01b9198d3387125b48a41150b422..806b57bb591fd0502cdf28cdd69d879ef2f50639 100644 --- a/crates/collab/src/tests/test_server.rs +++ b/crates/collab/src/tests/test_server.rs @@ -16,6 +16,7 @@ use futures::{channel::oneshot, StreamExt as _}; use gpui::{executor::Deterministic, ModelHandle, Task, TestAppContext, WindowHandle}; use language::LanguageRegistry; use node_runtime::FakeNodeRuntime; +use notifications::NotificationStore; use parking_lot::Mutex; use project::{Project, WorktreeId}; use rpc::RECEIVE_TIMEOUT; @@ -46,6 +47,7 @@ pub struct TestClient { pub username: String, pub app_state: Arc, channel_store: ModelHandle, + notification_store: ModelHandle, state: RefCell, } @@ -244,6 +246,7 @@ impl TestServer { app_state, username: name.to_string(), channel_store: cx.read(ChannelStore::global).clone(), + notification_store: cx.read(NotificationStore::global).clone(), state: Default::default(), }; client.wait_for_current_user(cx).await; @@ -449,6 +452,10 @@ impl TestClient { &self.channel_store } + pub fn notification_store(&self) -> &ModelHandle { + &self.notification_store + } + pub fn user_store(&self) -> &ModelHandle { &self.app_state.user_store } diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 3f1bafb10ec23b994d6a0a1492e9aeaf4f9cc872..30242d63604caa2ba429e39cc520d5a80146e806 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -386,7 +386,8 @@ impl NotificationPanel { ) { match event { NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx), - NotificationEvent::NotificationRemoved { entry } => self.remove_toast(entry, cx), + NotificationEvent::NotificationRemoved { entry } + | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry, cx), NotificationEvent::NotificationsUpdated { old_range, new_count, @@ -450,25 +451,9 @@ impl NotificationPanel { response: bool, cx: &mut ViewContext, ) { - match notification { - Notification::ContactRequest { - sender_id: 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(); - } - _ => {} - } + self.notification_store.update(cx, |store, cx| { + store.respond_to_notification(notification, response, cx); + }); } } diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 43afb8181ab6ddaf978d9e4aee96d34eadb0bafa..5a1ed2677e178e25448048165cf7d3b0efbb4cf8 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -36,6 +36,9 @@ pub enum NotificationEvent { NotificationRemoved { entry: NotificationEntry, }, + NotificationRead { + entry: NotificationEntry, + }, } #[derive(Debug, PartialEq, Eq, Clone)] @@ -272,7 +275,13 @@ impl NotificationStore { if let Some(existing_notification) = cursor.item() { if existing_notification.id == id { - if new_notification.is_none() { + if let Some(new_notification) = &new_notification { + if new_notification.is_read { + cx.emit(NotificationEvent::NotificationRead { + entry: new_notification.clone(), + }); + } + } else { cx.emit(NotificationEvent::NotificationRemoved { entry: existing_notification.clone(), }); @@ -303,6 +312,31 @@ impl NotificationStore { new_count, }); } + + pub fn respond_to_notification( + &mut self, + notification: Notification, + response: bool, + cx: &mut ModelContext, + ) { + match notification { + Notification::ContactRequest { sender_id } => { + self.user_store + .update(cx, |store, cx| { + store.respond_to_contact_request(sender_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 NotificationStore { From 660021f5e5600b4808d3d11ae1ca985ae0ff57cb Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 15:43:06 -0700 Subject: [PATCH 20/44] Fix more issues with the channels panel * Put the newest notifications at the top * Have at most 1 notification toast, which is non-interactive, but focuses the notification panel on click, and auto-dismisses on a timer. --- crates/channel/src/channel_store.rs | 6 + crates/collab/src/db/queries/channels.rs | 16 +- crates/collab/src/rpc.rs | 16 +- crates/collab/src/tests/notification_tests.rs | 50 ++- crates/collab_ui/src/notification_panel.rs | 326 ++++++++++++------ crates/collab_ui/src/notifications.rs | 111 +----- .../src/notifications/contact_notification.rs | 106 ------ .../notifications/src/notification_store.rs | 29 +- crates/rpc/src/notification.rs | 6 +- 9 files changed, 326 insertions(+), 340 deletions(-) delete mode 100644 crates/collab_ui/src/notifications/contact_notification.rs diff --git a/crates/channel/src/channel_store.rs b/crates/channel/src/channel_store.rs index d8dc7896eaeafe14385a2378380c8565e9fee40b..ae8a797d066d4409e1f693c3f4b4fb6b4ebdebd8 100644 --- a/crates/channel/src/channel_store.rs +++ b/crates/channel/src/channel_store.rs @@ -213,6 +213,12 @@ impl ChannelStore { self.channel_index.by_id().values().nth(ix) } + pub fn has_channel_invitation(&self, channel_id: ChannelId) -> bool { + self.channel_invitations + .iter() + .any(|channel| channel.id == channel_id) + } + pub fn channel_invitations(&self) -> &[Arc] { &self.channel_invitations } diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index 745bd6e3abc402be7cb392d5ba35de9df0d5e128..d2499ab3cef6c097a9b167c82fd1f193533d27f1 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -187,6 +187,7 @@ impl Database { rpc::Notification::ChannelInvitation { channel_id: channel_id.to_proto(), channel_name: channel.name, + inviter_id: inviter_id.to_proto(), }, true, &*tx, @@ -276,6 +277,7 @@ impl Database { &rpc::Notification::ChannelInvitation { channel_id: channel_id.to_proto(), channel_name: Default::default(), + inviter_id: Default::default(), }, accept, &*tx, @@ -292,7 +294,7 @@ impl Database { channel_id: ChannelId, member_id: UserId, remover_id: UserId, - ) -> Result<()> { + ) -> Result> { self.transaction(|tx| async move { self.check_user_is_channel_admin(channel_id, remover_id, &*tx) .await?; @@ -310,7 +312,17 @@ impl Database { Err(anyhow!("no such member"))?; } - Ok(()) + Ok(self + .remove_notification( + member_id, + rpc::Notification::ChannelInvitation { + channel_id: channel_id.to_proto(), + channel_name: Default::default(), + inviter_id: Default::default(), + }, + &*tx, + ) + .await?) }) .await } diff --git a/crates/collab/src/rpc.rs b/crates/collab/src/rpc.rs index 9f3c22ce976d5eeb31d69efbdabe39a053f50b42..053058e06e5f9a33dc33f7976fb4d51aa7ba0c85 100644 --- a/crates/collab/src/rpc.rs +++ b/crates/collab/src/rpc.rs @@ -2331,7 +2331,8 @@ async fn remove_channel_member( let channel_id = ChannelId::from_proto(request.channel_id); let member_id = UserId::from_proto(request.user_id); - db.remove_channel_member(channel_id, member_id, session.user_id) + let removed_notification_id = db + .remove_channel_member(channel_id, member_id, session.user_id) .await?; let mut update = proto::UpdateChannels::default(); @@ -2342,7 +2343,18 @@ async fn remove_channel_member( .await .user_connection_ids(member_id) { - session.peer.send(connection_id, update.clone())?; + session.peer.send(connection_id, update.clone()).trace_err(); + if let Some(notification_id) = removed_notification_id { + session + .peer + .send( + connection_id, + proto::DeleteNotification { + notification_id: notification_id.to_proto(), + }, + ) + .trace_err(); + } } response.send(proto::Ack {})?; diff --git a/crates/collab/src/tests/notification_tests.rs b/crates/collab/src/tests/notification_tests.rs index da94bd6fad80cac4e6983fa56a39f29148a0215f..518208c0c7a61f36f495e9701735dc4e3a54d46b 100644 --- a/crates/collab/src/tests/notification_tests.rs +++ b/crates/collab/src/tests/notification_tests.rs @@ -1,5 +1,7 @@ use crate::tests::TestServer; use gpui::{executor::Deterministic, TestAppContext}; +use notifications::NotificationEvent; +use parking_lot::Mutex; use rpc::Notification; use std::sync::Arc; @@ -14,6 +16,23 @@ async fn test_notifications( let client_a = server.create_client(cx_a, "user_a").await; let client_b = server.create_client(cx_b, "user_b").await; + let notification_events_a = Arc::new(Mutex::new(Vec::new())); + let notification_events_b = Arc::new(Mutex::new(Vec::new())); + client_a.notification_store().update(cx_a, |_, cx| { + let events = notification_events_a.clone(); + cx.subscribe(&cx.handle(), move |_, _, event, _| { + events.lock().push(event.clone()); + }) + .detach() + }); + client_b.notification_store().update(cx_b, |_, cx| { + let events = notification_events_b.clone(); + cx.subscribe(&cx.handle(), move |_, _, event, _| { + events.lock().push(event.clone()); + }) + .detach() + }); + // Client A sends a contact request to client B. client_a .user_store() @@ -36,6 +55,18 @@ async fn test_notifications( } ); assert!(!entry.is_read); + assert_eq!( + ¬ification_events_b.lock()[0..], + &[ + NotificationEvent::NewNotification { + entry: entry.clone(), + }, + NotificationEvent::NotificationsUpdated { + old_range: 0..0, + new_count: 1 + } + ] + ); store.respond_to_notification(entry.notification.clone(), true, cx); }); @@ -49,6 +80,18 @@ async fn test_notifications( let entry = store.notification_at(0).unwrap(); assert!(entry.is_read); assert_eq!(entry.response, Some(true)); + assert_eq!( + ¬ification_events_b.lock()[2..], + &[ + NotificationEvent::NotificationRead { + entry: entry.clone(), + }, + NotificationEvent::NotificationsUpdated { + old_range: 0..1, + new_count: 1 + } + ] + ); }); // Client A receives a notification that client B accepted their request. @@ -89,12 +132,13 @@ async fn test_notifications( assert_eq!(store.notification_count(), 2); assert_eq!(store.unread_notification_count(), 1); - let entry = store.notification_at(1).unwrap(); + let entry = store.notification_at(0).unwrap(); assert_eq!( entry.notification, Notification::ChannelInvitation { channel_id, - channel_name: "the-channel".to_string() + channel_name: "the-channel".to_string(), + inviter_id: client_a.id() } ); assert!(!entry.is_read); @@ -108,7 +152,7 @@ async fn test_notifications( assert_eq!(store.notification_count(), 2); assert_eq!(store.unread_notification_count(), 0); - let entry = store.notification_at(1).unwrap(); + let entry = store.notification_at(0).unwrap(); assert!(entry.is_read); assert_eq!(entry.response, Some(true)); }); diff --git a/crates/collab_ui/src/notification_panel.rs b/crates/collab_ui/src/notification_panel.rs index 30242d63604caa2ba429e39cc520d5a80146e806..93ba05a6711bce21492c4207db292c6da792daf2 100644 --- a/crates/collab_ui/src/notification_panel.rs +++ b/crates/collab_ui/src/notification_panel.rs @@ -1,11 +1,9 @@ use crate::{ - format_timestamp, is_channels_feature_enabled, - notifications::contact_notification::ContactNotification, render_avatar, - NotificationPanelSettings, + format_timestamp, is_channels_feature_enabled, render_avatar, NotificationPanelSettings, }; use anyhow::Result; use channel::ChannelStore; -use client::{Client, Notification, UserStore}; +use client::{Client, Notification, User, UserStore}; use db::kvp::KEY_VALUE_STORE; use futures::StreamExt; use gpui::{ @@ -19,7 +17,7 @@ use notifications::{NotificationEntry, NotificationEvent, NotificationStore}; use project::Fs; use serde::{Deserialize, Serialize}; use settings::SettingsStore; -use std::sync::Arc; +use std::{sync::Arc, time::Duration}; use theme::{IconButton, Theme}; use time::{OffsetDateTime, UtcOffset}; use util::{ResultExt, TryFutureExt}; @@ -28,6 +26,7 @@ use workspace::{ Workspace, }; +const TOAST_DURATION: Duration = Duration::from_secs(5); const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel"; pub struct NotificationPanel { @@ -42,6 +41,7 @@ pub struct NotificationPanel { pending_serialization: Task>, subscriptions: Vec, workspace: WeakViewHandle, + current_notification_toast: Option<(u64, Task<()>)>, local_timezone: UtcOffset, has_focus: bool, } @@ -58,7 +58,7 @@ pub enum Event { Dismissed, } -actions!(chat_panel, [ToggleFocus]); +actions!(notification_panel, [ToggleFocus]); pub fn init(_cx: &mut AppContext) {} @@ -69,14 +69,8 @@ impl NotificationPanel { let user_store = workspace.app_state().user_store.clone(); let workspace_handle = workspace.weak_handle(); - let notification_list = - ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { - this.render_notification(ix, cx) - }); - cx.add_view(|cx| { let mut status = client.status(); - cx.spawn(|this, mut cx| async move { while let Some(_) = status.next().await { if this @@ -91,6 +85,12 @@ impl NotificationPanel { }) .detach(); + let notification_list = + ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { + this.render_notification(ix, cx) + .unwrap_or_else(|| Empty::new().into_any()) + }); + let mut this = Self { fs, client, @@ -102,6 +102,7 @@ impl NotificationPanel { pending_serialization: Task::ready(None), workspace: workspace_handle, has_focus: false, + current_notification_toast: None, subscriptions: Vec::new(), active: false, width: None, @@ -169,73 +170,20 @@ impl NotificationPanel { ); } - fn render_notification(&mut self, ix: usize, cx: &mut ViewContext) -> AnyElement { - self.try_render_notification(ix, cx) - .unwrap_or_else(|| Empty::new().into_any()) - } - - fn try_render_notification( + fn render_notification( &mut self, ix: usize, cx: &mut ViewContext, ) -> Option> { - let notification_store = self.notification_store.read(cx); - let user_store = self.user_store.read(cx); - let channel_store = self.channel_store.read(cx); - let entry = notification_store.notification_at(ix)?; - let notification = entry.notification.clone(); + let entry = self.notification_store.read(cx).notification_at(ix)?; let now = OffsetDateTime::now_utc(); let timestamp = entry.timestamp; - - let icon; - let text; - let actor; - let needs_acceptance; - match notification { - Notification::ContactRequest { sender_id } => { - let requester = user_store.get_cached_user(sender_id)?; - icon = "icons/plus.svg"; - text = format!("{} wants to add you as a contact", requester.github_login); - needs_acceptance = true; - actor = Some(requester); - } - Notification::ContactRequestAccepted { responder_id } => { - let responder = user_store.get_cached_user(responder_id)?; - icon = "icons/plus.svg"; - text = format!("{} accepted your contact invite", responder.github_login); - needs_acceptance = false; - actor = Some(responder); - } - Notification::ChannelInvitation { - ref channel_name, .. - } => { - actor = None; - icon = "icons/hash.svg"; - text = format!("you were invited to join the #{channel_name} channel"); - needs_acceptance = true; - } - Notification::ChannelMessageMention { - sender_id, - channel_id, - message_id, - } => { - let sender = user_store.get_cached_user(sender_id)?; - let channel = channel_store.channel_for_id(channel_id)?; - let message = notification_store.channel_message_for_id(message_id)?; - - icon = "icons/conversations.svg"; - text = format!( - "{} mentioned you in the #{} channel:\n{}", - sender.github_login, channel.name, message.body, - ); - needs_acceptance = false; - actor = Some(sender); - } - } + let (actor, text, icon, needs_response) = self.present_notification(entry, cx)?; let theme = theme::current(cx); let style = &theme.notification_panel; let response = entry.response; + let notification = entry.notification.clone(); let message_style = if entry.is_read { style.read_text.clone() @@ -276,7 +224,7 @@ impl NotificationPanel { ) .into_any(), ) - } else if needs_acceptance { + } else if needs_response { Some( Flex::row() .with_children([ @@ -336,6 +284,69 @@ impl NotificationPanel { ) } + fn present_notification( + &self, + entry: &NotificationEntry, + cx: &AppContext, + ) -> Option<(Option>, String, &'static str, bool)> { + let user_store = self.user_store.read(cx); + let channel_store = self.channel_store.read(cx); + let icon; + let text; + let actor; + let needs_response; + match entry.notification { + Notification::ContactRequest { sender_id } => { + let requester = user_store.get_cached_user(sender_id)?; + icon = "icons/plus.svg"; + text = format!("{} wants to add you as a contact", requester.github_login); + needs_response = user_store.is_contact_request_pending(&requester); + actor = Some(requester); + } + Notification::ContactRequestAccepted { responder_id } => { + let responder = user_store.get_cached_user(responder_id)?; + icon = "icons/plus.svg"; + text = format!("{} accepted your contact invite", responder.github_login); + needs_response = false; + actor = Some(responder); + } + Notification::ChannelInvitation { + ref channel_name, + channel_id, + inviter_id, + } => { + let inviter = user_store.get_cached_user(inviter_id)?; + icon = "icons/hash.svg"; + text = format!( + "{} invited you to join the #{channel_name} channel", + inviter.github_login + ); + needs_response = channel_store.has_channel_invitation(channel_id); + actor = Some(inviter); + } + Notification::ChannelMessageMention { + sender_id, + channel_id, + message_id, + } => { + let sender = user_store.get_cached_user(sender_id)?; + let channel = channel_store.channel_for_id(channel_id)?; + let message = self + .notification_store + .read(cx) + .channel_message_for_id(message_id)?; + icon = "icons/conversations.svg"; + text = format!( + "{} mentioned you in the #{} channel:\n{}", + sender.github_login, channel.name, message.body, + ); + needs_response = false; + actor = Some(sender); + } + } + Some((actor, text, icon, needs_response)) + } + fn render_sign_in_prompt( &self, theme: &Arc, @@ -387,7 +398,7 @@ impl NotificationPanel { match event { NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx), NotificationEvent::NotificationRemoved { entry } - | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry, cx), + | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx), NotificationEvent::NotificationsUpdated { old_range, new_count, @@ -399,49 +410,44 @@ impl NotificationPanel { } fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { - let id = entry.id as usize; - match entry.notification { - Notification::ContactRequest { - sender_id: actor_id, - } - | Notification::ContactRequestAccepted { - responder_id: actor_id, - } => { - let user_store = self.user_store.clone(); - let Some(user) = user_store.read(cx).get_cached_user(actor_id) else { - return; - }; - self.workspace - .update(cx, |workspace, cx| { - workspace.show_notification(id, cx, |cx| { - cx.add_view(|_| { - ContactNotification::new( - user, - entry.notification.clone(), - user_store, - ) - }) - }) - }) + let Some((actor, text, _, _)) = self.present_notification(entry, cx) else { + return; + }; + + let id = entry.id; + self.current_notification_toast = Some(( + id, + cx.spawn(|this, mut cx| async move { + cx.background().timer(TOAST_DURATION).await; + this.update(&mut cx, |this, cx| this.remove_toast(id, cx)) .ok(); - } - Notification::ChannelInvitation { .. } => {} - Notification::ChannelMessageMention { .. } => {} - } + }), + )); + + self.workspace + .update(cx, |workspace, cx| { + workspace.show_notification(0, cx, |cx| { + let workspace = cx.weak_handle(); + cx.add_view(|_| NotificationToast { + actor, + text, + workspace, + }) + }) + }) + .ok(); } - fn remove_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { - let id = entry.id as usize; - match entry.notification { - Notification::ContactRequest { .. } | Notification::ContactRequestAccepted { .. } => { + fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext) { + if let Some((current_id, _)) = &self.current_notification_toast { + if *current_id == notification_id { + self.current_notification_toast.take(); self.workspace .update(cx, |workspace, cx| { - workspace.dismiss_notification::(id, cx) + workspace.dismiss_notification::(0, cx) }) .ok(); } - Notification::ChannelInvitation { .. } => {} - Notification::ChannelMessageMention { .. } => {} } } @@ -582,3 +588,111 @@ fn render_icon_button(style: &IconButton, svg_path: &'static str) -> im .contained() .with_style(style.container) } + +pub struct NotificationToast { + actor: Option>, + text: String, + workspace: WeakViewHandle, +} + +pub enum ToastEvent { + Dismiss, +} + +impl NotificationToast { + fn focus_notification_panel(&self, cx: &mut AppContext) { + let workspace = self.workspace.clone(); + cx.defer(move |cx| { + workspace + .update(cx, |workspace, cx| { + workspace.focus_panel::(cx); + }) + .ok(); + }) + } +} + +impl Entity for NotificationToast { + type Event = ToastEvent; +} + +impl View for NotificationToast { + fn ui_name() -> &'static str { + "ContactNotification" + } + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + let user = self.actor.clone(); + let theme = theme::current(cx).clone(); + let theme = &theme.contact_notification; + + MouseEventHandler::new::(0, cx, |_, cx| { + Flex::row() + .with_children(user.and_then(|user| { + Some( + Image::from_data(user.avatar.clone()?) + .with_style(theme.header_avatar) + .aligned() + .constrained() + .with_height( + cx.font_cache() + .line_height(theme.header_message.text.font_size), + ) + .aligned() + .top(), + ) + })) + .with_child( + Text::new(self.text.clone(), theme.header_message.text.clone()) + .contained() + .with_style(theme.header_message.container) + .aligned() + .top() + .left() + .flex(1., true), + ) + .with_child( + MouseEventHandler::new::(0, cx, |state, _| { + let style = theme.dismiss_button.style_for(state); + Svg::new("icons/x.svg") + .with_color(style.color) + .constrained() + .with_width(style.icon_width) + .aligned() + .contained() + .with_style(style.container) + .constrained() + .with_width(style.button_width) + .with_height(style.button_width) + }) + .with_cursor_style(CursorStyle::PointingHand) + .with_padding(Padding::uniform(5.)) + .on_click(MouseButton::Left, move |_, _, cx| { + cx.emit(ToastEvent::Dismiss) + }) + .aligned() + .constrained() + .with_height( + cx.font_cache() + .line_height(theme.header_message.text.font_size), + ) + .aligned() + .top() + .flex_float(), + ) + .contained() + }) + .with_cursor_style(CursorStyle::PointingHand) + .on_click(MouseButton::Left, move |_, this, cx| { + this.focus_notification_panel(cx); + cx.emit(ToastEvent::Dismiss); + }) + .into_any() + } +} + +impl workspace::notifications::Notification for NotificationToast { + fn should_dismiss_notification_on_event(&self, event: &::Event) -> bool { + matches!(event, ToastEvent::Dismiss) + } +} diff --git a/crates/collab_ui/src/notifications.rs b/crates/collab_ui/src/notifications.rs index e4456163c661712df0adf207e795cd7a0f8bfc90..5c184ec5c86ab268f4455b21d855ca118d40d50b 100644 --- a/crates/collab_ui/src/notifications.rs +++ b/crates/collab_ui/src/notifications.rs @@ -1,120 +1,11 @@ -use client::User; -use gpui::{ - elements::*, - platform::{CursorStyle, MouseButton}, - AnyElement, AppContext, Element, ViewContext, -}; +use gpui::AppContext; use std::sync::Arc; use workspace::AppState; -pub mod contact_notification; pub mod incoming_call_notification; pub mod project_shared_notification; -enum Dismiss {} -enum Button {} - pub fn init(app_state: &Arc, cx: &mut AppContext) { incoming_call_notification::init(app_state, cx); project_shared_notification::init(app_state, cx); } - -pub fn render_user_notification( - user: Arc, - title: &'static str, - body: Option<&'static str>, - on_dismiss: F, - buttons: Vec<(&'static str, Box)>)>, - cx: &mut ViewContext, -) -> AnyElement -where - F: 'static + Fn(&mut V, &mut ViewContext), -{ - let theme = theme::current(cx).clone(); - let theme = &theme.contact_notification; - - Flex::column() - .with_child( - Flex::row() - .with_children(user.avatar.clone().map(|avatar| { - Image::from_data(avatar) - .with_style(theme.header_avatar) - .aligned() - .constrained() - .with_height( - cx.font_cache() - .line_height(theme.header_message.text.font_size), - ) - .aligned() - .top() - })) - .with_child( - Text::new( - format!("{} {}", user.github_login, title), - theme.header_message.text.clone(), - ) - .contained() - .with_style(theme.header_message.container) - .aligned() - .top() - .left() - .flex(1., true), - ) - .with_child( - MouseEventHandler::new::(user.id as usize, cx, |state, _| { - let style = theme.dismiss_button.style_for(state); - Svg::new("icons/x.svg") - .with_color(style.color) - .constrained() - .with_width(style.icon_width) - .aligned() - .contained() - .with_style(style.container) - .constrained() - .with_width(style.button_width) - .with_height(style.button_width) - }) - .with_cursor_style(CursorStyle::PointingHand) - .with_padding(Padding::uniform(5.)) - .on_click(MouseButton::Left, move |_, view, cx| on_dismiss(view, cx)) - .aligned() - .constrained() - .with_height( - cx.font_cache() - .line_height(theme.header_message.text.font_size), - ) - .aligned() - .top() - .flex_float(), - ) - .into_any_named("contact notification header"), - ) - .with_children(body.map(|body| { - Label::new(body, theme.body_message.text.clone()) - .contained() - .with_style(theme.body_message.container) - })) - .with_children(if buttons.is_empty() { - None - } else { - Some( - Flex::row() - .with_children(buttons.into_iter().enumerate().map( - |(ix, (message, handler))| { - MouseEventHandler::new::(ix, cx, |state, _| { - let button = theme.button.style_for(state); - Label::new(message, button.text.clone()) - .contained() - .with_style(button.container) - }) - .with_cursor_style(CursorStyle::PointingHand) - .on_click(MouseButton::Left, move |_, view, cx| handler(view, cx)) - }, - )) - .aligned() - .right(), - ) - }) - .contained() - .into_any() -} diff --git a/crates/collab_ui/src/notifications/contact_notification.rs b/crates/collab_ui/src/notifications/contact_notification.rs deleted file mode 100644 index 2e3c3ca58a36f90c6ece3cfc56b0ca11228f7cde..0000000000000000000000000000000000000000 --- a/crates/collab_ui/src/notifications/contact_notification.rs +++ /dev/null @@ -1,106 +0,0 @@ -use crate::notifications::render_user_notification; -use client::{User, UserStore}; -use gpui::{elements::*, Entity, ModelHandle, View, ViewContext}; -use std::sync::Arc; -use workspace::notifications::Notification; - -pub struct ContactNotification { - user_store: ModelHandle, - user: Arc, - notification: rpc::Notification, -} - -#[derive(Clone, PartialEq)] -struct Dismiss(u64); - -#[derive(Clone, PartialEq)] -pub struct RespondToContactRequest { - pub user_id: u64, - pub accept: bool, -} - -pub enum Event { - Dismiss, -} - -impl Entity for ContactNotification { - type Event = Event; -} - -impl View for ContactNotification { - fn ui_name() -> &'static str { - "ContactNotification" - } - - fn render(&mut self, cx: &mut ViewContext) -> AnyElement { - match self.notification { - rpc::Notification::ContactRequest { .. } => render_user_notification( - self.user.clone(), - "wants to add you as a contact", - Some("They won't be alerted if you decline."), - |notification, cx| notification.dismiss(cx), - vec![ - ( - "Decline", - Box::new(|notification, cx| { - notification.respond_to_contact_request(false, cx) - }), - ), - ( - "Accept", - Box::new(|notification, cx| { - notification.respond_to_contact_request(true, cx) - }), - ), - ], - cx, - ), - rpc::Notification::ContactRequestAccepted { .. } => render_user_notification( - self.user.clone(), - "accepted your contact request", - None, - |notification, cx| notification.dismiss(cx), - vec![], - cx, - ), - _ => unreachable!(), - } - } -} - -impl Notification for ContactNotification { - fn should_dismiss_notification_on_event(&self, event: &::Event) -> bool { - matches!(event, Event::Dismiss) - } -} - -impl ContactNotification { - pub fn new( - user: Arc, - notification: rpc::Notification, - user_store: ModelHandle, - ) -> Self { - Self { - user, - notification, - user_store, - } - } - - fn dismiss(&mut self, cx: &mut ViewContext) { - self.user_store.update(cx, |store, cx| { - store - .dismiss_contact_request(self.user.id, cx) - .detach_and_log_err(cx); - }); - cx.emit(Event::Dismiss); - } - - fn respond_to_contact_request(&mut self, accept: bool, cx: &mut ViewContext) { - self.user_store - .update(cx, |store, cx| { - store.respond_to_contact_request(self.user.id, accept, cx) - }) - .detach(); - } -} diff --git a/crates/notifications/src/notification_store.rs b/crates/notifications/src/notification_store.rs index 5a1ed2677e178e25448048165cf7d3b0efbb4cf8..0ee4ad35f1581bf9e75a2de3863a79346de876ac 100644 --- a/crates/notifications/src/notification_store.rs +++ b/crates/notifications/src/notification_store.rs @@ -25,6 +25,7 @@ pub struct NotificationStore { _subscriptions: Vec, } +#[derive(Clone, PartialEq, Eq, Debug)] pub enum NotificationEvent { NotificationsUpdated { old_range: Range, @@ -118,7 +119,13 @@ impl NotificationStore { self.channel_messages.get(&id) } + // Get the nth newest notification. pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> { + let count = self.notifications.summary().count; + if ix >= count { + return None; + } + let ix = count - 1 - ix; let mut cursor = self.notifications.cursor::(); cursor.seek(&Count(ix), Bias::Right, &()); cursor.item() @@ -200,7 +207,9 @@ impl NotificationStore { for entry in ¬ifications { match entry.notification { - Notification::ChannelInvitation { .. } => {} + Notification::ChannelInvitation { inviter_id, .. } => { + user_ids.push(inviter_id); + } Notification::ContactRequest { sender_id: requester_id, } => { @@ -273,8 +282,11 @@ impl NotificationStore { old_range.start = cursor.start().1 .0; } - if let Some(existing_notification) = cursor.item() { - if existing_notification.id == id { + let old_notification = cursor.item(); + if let Some(old_notification) = old_notification { + if old_notification.id == id { + cursor.next(&()); + if let Some(new_notification) = &new_notification { if new_notification.is_read { cx.emit(NotificationEvent::NotificationRead { @@ -283,20 +295,19 @@ impl NotificationStore { } } else { cx.emit(NotificationEvent::NotificationRemoved { - entry: existing_notification.clone(), + entry: old_notification.clone(), }); } - cursor.next(&()); } - } - - if let Some(notification) = new_notification { + } else if let Some(new_notification) = &new_notification { if is_new { cx.emit(NotificationEvent::NewNotification { - entry: notification.clone(), + entry: new_notification.clone(), }); } + } + if let Some(notification) = new_notification { new_notifications.push(notification, &()); } } diff --git a/crates/rpc/src/notification.rs b/crates/rpc/src/notification.rs index 06dff82b758ee2da9b16394ec7d506305de48597..c5476469be90112e6ae294665c2bc53319cf38be 100644 --- a/crates/rpc/src/notification.rs +++ b/crates/rpc/src/notification.rs @@ -30,12 +30,13 @@ pub enum Notification { #[serde(rename = "entity_id")] channel_id: u64, channel_name: String, + inviter_id: u64, }, ChannelMessageMention { - sender_id: u64, - channel_id: u64, #[serde(rename = "entity_id")] message_id: u64, + sender_id: u64, + channel_id: u64, }, } @@ -84,6 +85,7 @@ fn test_notification() { Notification::ChannelInvitation { channel_id: 100, channel_name: "the-channel".into(), + inviter_id: 50, }, Notification::ChannelMessageMention { sender_id: 200, From ee87ac2f9b9f4ea2432b96ea63e8b3cd8b428d1a Mon Sep 17 00:00:00 2001 From: Max Brunsfeld Date: Tue, 17 Oct 2023 17:59:42 -0700 Subject: [PATCH 21/44] Start work on chat mentions --- Cargo.lock | 1 + crates/collab/src/db/queries/channels.rs | 18 +- crates/collab_ui/Cargo.toml | 1 + crates/collab_ui/src/chat_panel.rs | 69 +++--- .../src/chat_panel/message_editor.rs | 218 ++++++++++++++++++ crates/rpc/proto/zed.proto | 7 +- crates/rpc/src/proto.rs | 2 + crates/theme/src/theme.rs | 1 + styles/src/style_tree/chat_panel.ts | 1 + 9 files changed, 271 insertions(+), 47 deletions(-) create mode 100644 crates/collab_ui/src/chat_panel/message_editor.rs diff --git a/Cargo.lock b/Cargo.lock index a2fc2bf2d81e90c21330f2e9ec4ffd9f46842461..ce517efd09603d8a949be285a6d12339f083ac0c 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1558,6 +1558,7 @@ dependencies = [ "fuzzy", "gpui", "language", + "lazy_static", "log", "menu", "notifications", diff --git a/crates/collab/src/db/queries/channels.rs b/crates/collab/src/db/queries/channels.rs index d2499ab3cef6c097a9b167c82fd1f193533d27f1..1ca38b2e3c9b2a1a585e9ee8a47e52a5ab974813 100644 --- a/crates/collab/src/db/queries/channels.rs +++ b/crates/collab/src/db/queries/channels.rs @@ -552,7 +552,8 @@ impl Database { user_id: UserId, ) -> Result> { self.transaction(|tx| async move { - self.check_user_is_channel_admin(channel_id, user_id, &*tx) + let user_membership = self + .check_user_is_channel_member(channel_id, user_id, &*tx) .await?; #[derive(Copy, Clone, Debug, EnumIter, DeriveColumn)] @@ -613,6 +614,14 @@ impl Database { }); } + // If the user is not an admin, don't give them all of the details + if !user_membership.admin { + rows.retain_mut(|row| { + row.admin = false; + row.kind != proto::channel_member::Kind::Invitee as i32 + }); + } + Ok(rows) }) .await @@ -644,9 +653,9 @@ impl Database { channel_id: ChannelId, user_id: UserId, tx: &DatabaseTransaction, - ) -> Result<()> { + ) -> Result { let channel_ids = self.get_channel_ancestors(channel_id, tx).await?; - channel_member::Entity::find() + Ok(channel_member::Entity::find() .filter( channel_member::Column::ChannelId .is_in(channel_ids) @@ -654,8 +663,7 @@ impl Database { ) .one(&*tx) .await? - .ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?; - Ok(()) + .ok_or_else(|| anyhow!("user is not a channel member or channel does not exist"))?) } pub async fn check_user_is_channel_admin( diff --git a/crates/collab_ui/Cargo.toml b/crates/collab_ui/Cargo.toml index 4a0f8c5e8b2948375bbdb32c56a8b3859ffc33b1..697faace800ebbbc8f6435ab1ae53bc0b8bebcc7 100644 --- a/crates/collab_ui/Cargo.toml +++ b/crates/collab_ui/Cargo.toml @@ -54,6 +54,7 @@ zed-actions = {path = "../zed-actions"} anyhow.workspace = true futures.workspace = true +lazy_static.workspace = true log.workspace = true schemars.workspace = true postage.workspace = true diff --git a/crates/collab_ui/src/chat_panel.rs b/crates/collab_ui/src/chat_panel.rs index d58a406d7875b9e6bb6818f42adef9a7044d0a54..28bfe62109898c940c7572d7dfbf8ff1a84d3cd8 100644 --- a/crates/collab_ui/src/chat_panel.rs +++ b/crates/collab_ui/src/chat_panel.rs @@ -18,8 +18,9 @@ use gpui::{ AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View, ViewContext, ViewHandle, WeakViewHandle, }; -use language::{language_settings::SoftWrap, LanguageRegistry}; +use language::LanguageRegistry; use menu::Confirm; +use message_editor::MessageEditor; use project::Fs; use rich_text::RichText; use serde::{Deserialize, Serialize}; @@ -33,6 +34,8 @@ use workspace::{ Workspace, }; +mod message_editor; + const MESSAGE_LOADING_THRESHOLD: usize = 50; const CHAT_PANEL_KEY: &'static str = "ChatPanel"; @@ -42,7 +45,7 @@ pub struct ChatPanel { languages: Arc, active_chat: Option<(ModelHandle, Subscription)>, message_list: ListState, - input_editor: ViewHandle, + input_editor: ViewHandle, channel_select: ViewHandle