Detailed changes
@@ -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",
@@ -47,6 +47,7 @@ members = [
"crates/media",
"crates/menu",
"crates/node_runtime",
+ "crates/notifications",
"crates/outline",
"crates/picker",
"crates/plugin",
@@ -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>
@@ -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,
@@ -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 {
@@ -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()
@@ -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");
@@ -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");
@@ -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"))?
}
@@ -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),
+ })
}
}
@@ -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 {})?;
@@ -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"] }
@@ -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)
@@ -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>()
+}
@@ -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)
+}
@@ -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],
@@ -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"] }
@@ -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 ¬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;
+ }
+}
@@ -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;
}
@@ -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)],
),
}
}
@@ -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),
@@ -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 }
@@ -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);
@@ -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