Start work on notification panel

Max Brunsfeld created

Change summary

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 
crates/collab/migrations.sqlite/20221109000000_test_schema.sql   |   3 
crates/collab/migrations/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 
crates/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, 1,020 insertions(+), 240 deletions(-)

Detailed changes

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",

Cargo.toml 🔗

@@ -47,6 +47,7 @@ members = [
     "crates/media",
     "crates/menu",
     "crates/node_runtime",
+    "crates/notifications",
     "crates/outline",
     "crates/picker",
     "crates/plugin",

assets/icons/bell.svg 🔗

@@ -0,0 +1,3 @@
+<svg width="32" height="32" viewBox="0 0 32 32" xmlns="http://www.w3.org/2000/svg">
+<path fill="white" fill-rule="evenodd" stroke="none" d="M20 14v3c0 .768.289 1.47.764 2h-9.528c.475-.53.764-1.232.764-2v-3c0-2.21 1.79-4 4-4 2.21 0 4 1.79 4 4zm1 0v3c0 1.105.895 2 2 2v1H9v-1c1.105 0 2-.895 2-2v-3c0-2.761 2.239-5 5-5 2.761 0 5 2.239 5 5zm-5 9c-1.105 0-2-.895-2-2h-1c0 1.657 1.343 3 3 3 1.657 0 3-1.343 3-3h-1c0 1.105-.895 2-2 2z" />
+</svg>

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,

crates/channel/src/channel_chat.rs 🔗

@@ -451,22 +451,7 @@ async fn messages_from_proto(
     user_store: &ModelHandle<UserStore>,
     cx: &mut AsyncAppContext,
 ) -> Result<SumTree<ChannelMessage>> {
-    let unique_user_ids = proto_messages
-        .iter()
-        .map(|m| m.sender_id)
-        .collect::<HashSet<_>>()
-        .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<proto::ChannelMessage>,
+        user_store: &ModelHandle<UserStore>,
+        cx: &mut AsyncAppContext,
+    ) -> Result<Vec<Self>> {
+        let unique_user_ids = proto_messages
+            .iter()
+            .map(|m| m.sender_id)
+            .collect::<HashSet<_>>()
+            .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 {

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<u64>,
+        cx: &mut ModelContext<Self>,
+    ) -> Task<Result<Vec<ChannelMessage>>> {
+        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<bool> {
         self.channel_index
             .by_id()

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");

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");

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<proto::Notification> {
         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"))?
             }

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<proto::AddNotifications> {
         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<proto::Notification> {
         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),
+        })
     }
 }

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 {})?;

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"] }

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<Arc<ImageData>>, theme: &Arc<Theme>) -> AnyElement<ChatPanel> {
-    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<u64>,
     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::<ChatPanelSettings>(cx).button && is_chat_feature_enabled(cx))
+        (settings::get::<ChatPanelSettings>(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::<ChannelsAlpha>()
-}
-
-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<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
     Svg::new(svg_path)
         .with_color(style.color)

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<AppState>, cx: &mut AppContext) {
     settings::register::<CollaborationPanelSettings>(cx);
     settings::register::<ChatPanelSettings>(cx);
+    settings::register::<NotificationPanelSettings>(cx);
 
     vcs_menu::init(cx);
     collab_titlebar_item::init(cx);
@@ -130,3 +138,57 @@ fn notification_window_options(
         screen: Some(screen),
     }
 }
+
+fn render_avatar<T: 'static>(avatar: Option<Arc<ImageData>>, theme: &Arc<Theme>) -> AnyElement<T> {
+    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::<ChannelsAlpha>()
+}

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<Client>,
+    user_store: ModelHandle<UserStore>,
+    channel_store: ModelHandle<ChannelStore>,
+    notification_store: ModelHandle<NotificationStore>,
+    fs: Arc<dyn Fs>,
+    width: Option<f32>,
+    active: bool,
+    notification_list: ListState<Self>,
+    pending_serialization: Task<Option<()>>,
+    subscriptions: Vec<gpui::Subscription>,
+    local_timezone: UtcOffset,
+    has_focus: bool,
+}
+
+#[derive(Serialize, Deserialize)]
+struct SerializedNotificationPanel {
+    width: Option<f32>,
+}
+
+#[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<Workspace>) -> ViewHandle<Self> {
+        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::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
+                this.render_notification(ix, cx)
+            });
+
+        cx.add_view(|cx| {
+            let mut status = client.status();
+
+            cx.spawn(|this, mut cx| async move {
+                while let Some(_) = status.next().await {
+                    if this
+                        .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::<SettingsStore, _>(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<Workspace>,
+        cx: AsyncAppContext,
+    ) -> Task<Result<ViewHandle<Self>>> {
+        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::<SerializedNotificationPanel>(&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<Self>) {
+        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<Self>) -> AnyElement<Self> {
+        self.try_render_notification(ix, cx)
+            .unwrap_or_else(|| Empty::new().into_any())
+    }
+
+    fn try_render_notification(
+        &mut self,
+        ix: usize,
+        cx: &mut ViewContext<Self>,
+    ) -> Option<AnyElement<Self>> {
+        let notification_store = self.notification_store.read(cx);
+        let user_store = self.user_store.read(cx);
+        let channel_store = self.channel_store.read(cx);
+        let entry = notification_store.notification_at(ix).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::<NotificationEntry, _>(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<Theme>,
+        cx: &mut ViewContext<Self>,
+    ) -> AnyElement<Self> {
+        enum SignInPromptLabel {}
+
+        MouseEventHandler::new::<SignInPromptLabel, _>(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<NotificationStore>,
+        event: &NotificationEvent,
+        _: &mut ViewContext<Self>,
+    ) {
+        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<Self>) -> AnyElement<Self> {
+        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>) {
+        self.has_focus = true;
+    }
+
+    fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
+        self.has_focus = false;
+    }
+}
+
+impl Panel for NotificationPanel {
+    fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
+        settings::get::<NotificationPanelSettings>(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<Self>) {
+        settings::update_settings_file::<NotificationPanelSettings>(
+            self.fs.clone(),
+            cx,
+            move |settings| settings.dock = Some(position),
+        );
+    }
+
+    fn size(&self, cx: &gpui::WindowContext) -> f32 {
+        self.width
+            .unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width)
+    }
+
+    fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
+        self.width = size;
+        self.serialize(cx);
+        cx.notify();
+    }
+
+    fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
+        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::<NotificationPanelSettings>(cx).button && is_channels_feature_enabled(cx))
+            .then(|| "icons/bell.svg")
+    }
+
+    fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
+        (
+            "Notification Panel".to_string(),
+            Some(Box::new(ToggleFocus)),
+        )
+    }
+
+    fn icon_label(&self, cx: &WindowContext) -> Option<String> {
+        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<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
+    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)
+}

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<bool>,
@@ -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> {
+        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],

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"] }

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<Client>, user_store: ModelHandle<UserStore>, 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<Client>,
+    user_store: ModelHandle<UserStore>,
+    channel_messages: HashMap<u64, ChannelMessage>,
+    channel_store: ModelHandle<ChannelStore>,
+    notifications: SumTree<NotificationEntry>,
+    _subscriptions: Vec<client::Subscription>,
+}
+
+pub enum NotificationEvent {
+    NotificationsUpdated {
+        old_range: Range<usize>,
+        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<Self> {
+        cx.global::<ModelHandle<Self>>().clone()
+    }
+
+    pub fn new(
+        client: Arc<Client>,
+        user_store: ModelHandle<UserStore>,
+        cx: &mut ModelContext<Self>,
+    ) -> 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::<Count>();
+        cursor.seek(&Count(ix), Bias::Right, &());
+        cursor.item()
+    }
+
+    async fn handle_add_notifications(
+        this: ModelHandle<Self>,
+        envelope: TypedEnvelope<proto::AddNotifications>,
+        _: Arc<Client>,
+        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::<Vec<_>>();
+        if notifications.is_empty() {
+            return Ok(());
+        }
+
+        for entry in &notifications {
+            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;
+    }
+}

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;
 }

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<u64>; 3]) -> Option<Self> {
         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<u64>; 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)],
             ),
         }
     }

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),

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 }

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);

crates/zed/src/zed.rs 🔗

@@ -221,6 +221,13 @@ pub fn init(app_state: &Arc<AppState>, cx: &mut gpui::AppContext) {
             workspace.toggle_panel_focus::<collab_ui::chat_panel::ChatPanel>(cx);
         },
     );
+    cx.add_action(
+        |workspace: &mut Workspace,
+         _: &collab_ui::notification_panel::ToggleFocus,
+         cx: &mut ViewContext<Workspace>| {
+            workspace.toggle_panel_focus::<collab_ui::notification_panel::NotificationPanel>(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