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