diff --git a/Cargo.lock b/Cargo.lock index a426a6a1ca..e43cc8b5eb 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 adb7fedb26..ca4a308bae 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 0000000000..46b01b6b38 --- /dev/null +++ b/assets/icons/bell.svg @@ -0,0 +1,3 @@ + + + diff --git a/assets/settings/default.json b/assets/settings/default.json index 1611d80e2f..bab114b2f0 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 734182886b..5c4e0f88f6 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 bceb2c094d..4a1292cdb2 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 0e811d8455..70c913dc95 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 e0c7b290b4..cac3f2d8df 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 2171f1a6bf..d922bc5ca2 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 67fd00e3ec..293b896a50 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 e5c6d94ce0..eb123cf960 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 98790778c9..25f2d9f91a 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 1a17b48f19..d58a406d78 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 57d6f7b4f6..0a22c063be 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 0000000000..a78caf5ff6 --- /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 c1aa6e5e01..f8678d774e 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 0000000000..1425e079d6 --- /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 0000000000..9bfa67c76e --- /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 f51d11d3db..4b5c17ae8b 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 512a4731b4..fc6dc54d15 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 f0d7937f6f..4d8f60c896 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 4174f7d6d5..c9dab0d223 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 16189f6c4e..52ba8247b7 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 4e9a34c269..8caff21c5f 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