Start work on notification panel

This commit is contained in:
Max Brunsfeld 2023-10-06 12:56:18 -07:00
parent 50cf25ae97
commit d1756b621f
24 changed files with 1021 additions and 241 deletions

22
Cargo.lock generated
View file

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

View file

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

3
assets/icons/bell.svg Normal file
View file

@ -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>

After

Width:  |  Height:  |  Size: 439 B

View file

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

View file

@ -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 {

View file

@ -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()

View file

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

View file

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

View file

@ -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"))?
}

View file

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

View file

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

View file

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

View file

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

View file

@ -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>()
}

View file

@ -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)
}

View file

@ -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],

View file

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

View file

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

View file

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

View file

@ -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)],
),
}
}

View file

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

View file

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

View file

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

View file

@ -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