mirror of
https://github.com/zed-industries/zed.git
synced 2025-01-24 02:46:43 +00:00
Start work on notification panel
This commit is contained in:
parent
50cf25ae97
commit
d1756b621f
24 changed files with 1021 additions and 241 deletions
22
Cargo.lock
generated
22
Cargo.lock
generated
|
@ -1559,6 +1559,7 @@ dependencies = [
|
||||||
"language",
|
"language",
|
||||||
"log",
|
"log",
|
||||||
"menu",
|
"menu",
|
||||||
|
"notifications",
|
||||||
"picker",
|
"picker",
|
||||||
"postage",
|
"postage",
|
||||||
"project",
|
"project",
|
||||||
|
@ -4727,6 +4728,26 @@ dependencies = [
|
||||||
"minimal-lexical",
|
"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]]
|
[[package]]
|
||||||
name = "ntapi"
|
name = "ntapi"
|
||||||
version = "0.3.7"
|
version = "0.3.7"
|
||||||
|
@ -10123,6 +10144,7 @@ dependencies = [
|
||||||
"log",
|
"log",
|
||||||
"lsp",
|
"lsp",
|
||||||
"node_runtime",
|
"node_runtime",
|
||||||
|
"notifications",
|
||||||
"num_cpus",
|
"num_cpus",
|
||||||
"outline",
|
"outline",
|
||||||
"parking_lot 0.11.2",
|
"parking_lot 0.11.2",
|
||||||
|
|
|
@ -47,6 +47,7 @@ members = [
|
||||||
"crates/media",
|
"crates/media",
|
||||||
"crates/menu",
|
"crates/menu",
|
||||||
"crates/node_runtime",
|
"crates/node_runtime",
|
||||||
|
"crates/notifications",
|
||||||
"crates/outline",
|
"crates/outline",
|
||||||
"crates/picker",
|
"crates/picker",
|
||||||
"crates/plugin",
|
"crates/plugin",
|
||||||
|
|
3
assets/icons/bell.svg
Normal file
3
assets/icons/bell.svg
Normal 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 |
|
@ -139,6 +139,14 @@
|
||||||
// Default width of the channels panel.
|
// Default width of the channels panel.
|
||||||
"default_width": 240
|
"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": {
|
"assistant": {
|
||||||
// Whether to show the assistant panel button in the status bar.
|
// Whether to show the assistant panel button in the status bar.
|
||||||
"button": true,
|
"button": true,
|
||||||
|
|
|
@ -451,22 +451,7 @@ async fn messages_from_proto(
|
||||||
user_store: &ModelHandle<UserStore>,
|
user_store: &ModelHandle<UserStore>,
|
||||||
cx: &mut AsyncAppContext,
|
cx: &mut AsyncAppContext,
|
||||||
) -> Result<SumTree<ChannelMessage>> {
|
) -> Result<SumTree<ChannelMessage>> {
|
||||||
let unique_user_ids = proto_messages
|
let messages = ChannelMessage::from_proto_vec(proto_messages, user_store, cx).await?;
|
||||||
.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 mut result = SumTree::new();
|
let mut result = SumTree::new();
|
||||||
result.extend(messages, &());
|
result.extend(messages, &());
|
||||||
Ok(result)
|
Ok(result)
|
||||||
|
@ -498,6 +483,30 @@ impl ChannelMessage {
|
||||||
pub fn is_pending(&self) -> bool {
|
pub fn is_pending(&self) -> bool {
|
||||||
matches!(self.id, ChannelMessageId::Pending(_))
|
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 {
|
impl sum_tree::Item for ChannelMessage {
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
mod channel_index;
|
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 anyhow::{anyhow, Result};
|
||||||
use channel_index::ChannelIndex;
|
use channel_index::ChannelIndex;
|
||||||
use client::{Client, Subscription, User, UserId, UserStore};
|
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> {
|
pub fn has_channel_buffer_changed(&self, channel_id: ChannelId) -> Option<bool> {
|
||||||
self.channel_index
|
self.channel_index
|
||||||
.by_id()
|
.by_id()
|
||||||
|
|
|
@ -327,7 +327,8 @@ CREATE TABLE "notifications" (
|
||||||
"kind" INTEGER NOT NULL REFERENCES notification_kinds (id),
|
"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_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");
|
CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id");
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
CREATE TABLE "notification_kinds" (
|
CREATE TABLE "notification_kinds" (
|
||||||
"id" INTEGER PRIMARY KEY NOT NULL,
|
"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");
|
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 (
|
CREATE TABLE notifications (
|
||||||
"id" SERIAL PRIMARY KEY,
|
"id" SERIAL PRIMARY KEY,
|
||||||
"created_at" TIMESTAMP NOT NULL DEFAULT now(),
|
"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),
|
"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_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");
|
CREATE INDEX "index_notifications_on_recipient_id" ON "notifications" ("recipient_id");
|
||||||
|
|
|
@ -124,7 +124,11 @@ impl Database {
|
||||||
.await
|
.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 {
|
self.transaction(|tx| async move {
|
||||||
let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
|
let (id_a, id_b, a_to_b) = if sender_id < receiver_id {
|
||||||
(sender_id, receiver_id, true)
|
(sender_id, receiver_id, true)
|
||||||
|
@ -162,7 +166,14 @@ impl Database {
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
if rows_affected == 1 {
|
if rows_affected == 1 {
|
||||||
Ok(())
|
self.create_notification(
|
||||||
|
receiver_id,
|
||||||
|
rpc::Notification::ContactRequest {
|
||||||
|
requester_id: sender_id.to_proto(),
|
||||||
|
},
|
||||||
|
&*tx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
} else {
|
} else {
|
||||||
Err(anyhow!("contact already requested"))?
|
Err(anyhow!("contact already requested"))?
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
use super::*;
|
use super::*;
|
||||||
use rpc::{Notification, NotificationEntityKind, NotificationKind};
|
use rpc::{Notification, NotificationKind};
|
||||||
|
|
||||||
impl Database {
|
impl Database {
|
||||||
pub async fn ensure_notification_kinds(&self) -> Result<()> {
|
pub async fn ensure_notification_kinds(&self) -> Result<()> {
|
||||||
|
@ -25,49 +25,16 @@ impl Database {
|
||||||
) -> Result<proto::AddNotifications> {
|
) -> Result<proto::AddNotifications> {
|
||||||
self.transaction(|tx| async move {
|
self.transaction(|tx| async move {
|
||||||
let mut result = proto::AddNotifications::default();
|
let mut result = proto::AddNotifications::default();
|
||||||
|
|
||||||
let mut rows = notification::Entity::find()
|
let mut rows = notification::Entity::find()
|
||||||
.filter(notification::Column::RecipientId.eq(recipient_id))
|
.filter(notification::Column::RecipientId.eq(recipient_id))
|
||||||
.order_by_desc(notification::Column::Id)
|
.order_by_desc(notification::Column::Id)
|
||||||
.limit(limit as u64)
|
.limit(limit as u64)
|
||||||
.stream(&*tx)
|
.stream(&*tx)
|
||||||
.await?;
|
.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 {
|
while let Some(row) = rows.next().await {
|
||||||
let row = row?;
|
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 {
|
result.notifications.push(proto::Notification {
|
||||||
|
id: row.id.to_proto(),
|
||||||
kind: row.kind as u32,
|
kind: row.kind as u32,
|
||||||
timestamp: row.created_at.assume_utc().unix_timestamp() as u64,
|
timestamp: row.created_at.assume_utc().unix_timestamp() as u64,
|
||||||
is_read: row.is_read,
|
is_read: row.is_read,
|
||||||
|
@ -76,43 +43,7 @@ impl Database {
|
||||||
entity_id_3: row.entity_id_3.map(|id| id as u64),
|
entity_id_3: row.entity_id_3.map(|id| id as u64),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
result.notifications.reverse();
|
||||||
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,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Ok(result)
|
Ok(result)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
|
@ -123,18 +54,27 @@ impl Database {
|
||||||
recipient_id: UserId,
|
recipient_id: UserId,
|
||||||
notification: Notification,
|
notification: Notification,
|
||||||
tx: &DatabaseTransaction,
|
tx: &DatabaseTransaction,
|
||||||
) -> Result<()> {
|
) -> Result<proto::Notification> {
|
||||||
let (kind, associated_entities) = notification.to_parts();
|
let (kind, associated_entities) = notification.to_parts();
|
||||||
notification::ActiveModel {
|
let model = notification::ActiveModel {
|
||||||
recipient_id: ActiveValue::Set(recipient_id),
|
recipient_id: ActiveValue::Set(recipient_id),
|
||||||
kind: ActiveValue::Set(kind as i32),
|
kind: ActiveValue::Set(kind as i32),
|
||||||
entity_id_1: ActiveValue::Set(associated_entities[0].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_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_3: ActiveValue::Set(associated_entities[2].map(|id| id as i32)),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
}
|
}
|
||||||
.save(&*tx)
|
.save(&*tx)
|
||||||
.await?;
|
.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 MESSAGE_COUNT_PER_PAGE: usize = 100;
|
||||||
const MAX_MESSAGE_LEN: usize = 1024;
|
const MAX_MESSAGE_LEN: usize = 1024;
|
||||||
|
const INITIAL_NOTIFICATION_COUNT: usize = 30;
|
||||||
|
|
||||||
lazy_static! {
|
lazy_static! {
|
||||||
static ref METRIC_CONNECTIONS: IntGauge =
|
static ref METRIC_CONNECTIONS: IntGauge =
|
||||||
|
@ -290,6 +291,8 @@ impl Server {
|
||||||
let pool = self.connection_pool.clone();
|
let pool = self.connection_pool.clone();
|
||||||
let live_kit_client = self.app_state.live_kit_client.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");
|
let span = info_span!("start server");
|
||||||
self.executor.spawn_detached(
|
self.executor.spawn_detached(
|
||||||
async move {
|
async move {
|
||||||
|
@ -578,15 +581,17 @@ impl Server {
|
||||||
this.app_state.db.set_user_connected_once(user_id, true).await?;
|
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_contacts(user_id),
|
||||||
this.app_state.db.get_channels_for_user(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?;
|
).await?;
|
||||||
|
|
||||||
{
|
{
|
||||||
let mut pool = this.connection_pool.lock();
|
let mut pool = this.connection_pool.lock();
|
||||||
pool.add_connection(connection_id, user_id, user.admin);
|
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_contacts_update(contacts, &pool))?;
|
||||||
this.peer.send(connection_id, build_initial_channels_update(
|
this.peer.send(connection_id, build_initial_channels_update(
|
||||||
channels_for_user,
|
channels_for_user,
|
||||||
|
@ -2064,7 +2069,7 @@ async fn request_contact(
|
||||||
return Err(anyhow!("cannot add yourself as a contact"))?;
|
return Err(anyhow!("cannot add yourself as a contact"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
session
|
let notification = session
|
||||||
.db()
|
.db()
|
||||||
.await
|
.await
|
||||||
.send_contact_request(requester_id, responder_id)
|
.send_contact_request(requester_id, responder_id)
|
||||||
|
@ -2095,6 +2100,12 @@ async fn request_contact(
|
||||||
.user_connection_ids(responder_id)
|
.user_connection_ids(responder_id)
|
||||||
{
|
{
|
||||||
session.peer.send(connection_id, update.clone())?;
|
session.peer.send(connection_id, update.clone())?;
|
||||||
|
session.peer.send(
|
||||||
|
connection_id,
|
||||||
|
proto::AddNotifications {
|
||||||
|
notifications: vec![notification.clone()],
|
||||||
|
},
|
||||||
|
)?;
|
||||||
}
|
}
|
||||||
|
|
||||||
response.send(proto::Ack {})?;
|
response.send(proto::Ack {})?;
|
||||||
|
|
|
@ -37,6 +37,7 @@ fuzzy = { path = "../fuzzy" }
|
||||||
gpui = { path = "../gpui" }
|
gpui = { path = "../gpui" }
|
||||||
language = { path = "../language" }
|
language = { path = "../language" }
|
||||||
menu = { path = "../menu" }
|
menu = { path = "../menu" }
|
||||||
|
notifications = { path = "../notifications" }
|
||||||
rich_text = { path = "../rich_text" }
|
rich_text = { path = "../rich_text" }
|
||||||
picker = { path = "../picker" }
|
picker = { path = "../picker" }
|
||||||
project = { path = "../project" }
|
project = { path = "../project" }
|
||||||
|
@ -65,6 +66,7 @@ client = { path = "../client", features = ["test-support"] }
|
||||||
collections = { path = "../collections", features = ["test-support"] }
|
collections = { path = "../collections", features = ["test-support"] }
|
||||||
editor = { path = "../editor", features = ["test-support"] }
|
editor = { path = "../editor", features = ["test-support"] }
|
||||||
gpui = { path = "../gpui", features = ["test-support"] }
|
gpui = { path = "../gpui", features = ["test-support"] }
|
||||||
|
notifications = { path = "../notifications", features = ["test-support"] }
|
||||||
project = { path = "../project", features = ["test-support"] }
|
project = { path = "../project", features = ["test-support"] }
|
||||||
settings = { path = "../settings", features = ["test-support"] }
|
settings = { path = "../settings", features = ["test-support"] }
|
||||||
util = { path = "../util", 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 anyhow::Result;
|
||||||
use call::ActiveCall;
|
use call::ActiveCall;
|
||||||
use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
|
use channel::{ChannelChat, ChannelChatEvent, ChannelMessageId, ChannelStore};
|
||||||
|
@ -6,15 +9,14 @@ use client::Client;
|
||||||
use collections::HashMap;
|
use collections::HashMap;
|
||||||
use db::kvp::KEY_VALUE_STORE;
|
use db::kvp::KEY_VALUE_STORE;
|
||||||
use editor::Editor;
|
use editor::Editor;
|
||||||
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
|
|
||||||
use gpui::{
|
use gpui::{
|
||||||
actions,
|
actions,
|
||||||
elements::*,
|
elements::*,
|
||||||
platform::{CursorStyle, MouseButton},
|
platform::{CursorStyle, MouseButton},
|
||||||
serde_json,
|
serde_json,
|
||||||
views::{ItemType, Select, SelectStyle},
|
views::{ItemType, Select, SelectStyle},
|
||||||
AnyViewHandle, AppContext, AsyncAppContext, Entity, ImageData, ModelHandle, Subscription, Task,
|
AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Subscription, Task, View,
|
||||||
View, ViewContext, ViewHandle, WeakViewHandle,
|
ViewContext, ViewHandle, WeakViewHandle,
|
||||||
};
|
};
|
||||||
use language::{language_settings::SoftWrap, LanguageRegistry};
|
use language::{language_settings::SoftWrap, LanguageRegistry};
|
||||||
use menu::Confirm;
|
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(
|
fn render_remove(
|
||||||
message_id_to_remove: Option<u64>,
|
message_id_to_remove: Option<u64>,
|
||||||
cx: &mut ViewContext<'_, '_, ChatPanel>,
|
cx: &mut ViewContext<'_, '_, ChatPanel>,
|
||||||
|
@ -810,14 +786,14 @@ impl Panel for ChatPanel {
|
||||||
self.active = active;
|
self.active = active;
|
||||||
if active {
|
if active {
|
||||||
self.acknowledge_last_message(cx);
|
self.acknowledge_last_message(cx);
|
||||||
if !is_chat_feature_enabled(cx) {
|
if !is_channels_feature_enabled(cx) {
|
||||||
cx.emit(Event::Dismissed);
|
cx.emit(Event::Dismissed);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
|
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")
|
.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> {
|
fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> impl Element<V> {
|
||||||
Svg::new(svg_path)
|
Svg::new(svg_path)
|
||||||
.with_color(style.color)
|
.with_color(style.color)
|
||||||
|
|
|
@ -5,27 +5,34 @@ mod collab_titlebar_item;
|
||||||
mod contact_notification;
|
mod contact_notification;
|
||||||
mod face_pile;
|
mod face_pile;
|
||||||
mod incoming_call_notification;
|
mod incoming_call_notification;
|
||||||
|
pub mod notification_panel;
|
||||||
mod notifications;
|
mod notifications;
|
||||||
mod panel_settings;
|
mod panel_settings;
|
||||||
pub mod project_shared_notification;
|
pub mod project_shared_notification;
|
||||||
mod sharing_status_indicator;
|
mod sharing_status_indicator;
|
||||||
|
|
||||||
use call::{report_call_event_for_room, ActiveCall, Room};
|
use call::{report_call_event_for_room, ActiveCall, Room};
|
||||||
|
use feature_flags::{ChannelsAlpha, FeatureFlagAppExt};
|
||||||
use gpui::{
|
use gpui::{
|
||||||
actions,
|
actions,
|
||||||
|
elements::{Empty, Image},
|
||||||
geometry::{
|
geometry::{
|
||||||
rect::RectF,
|
rect::RectF,
|
||||||
vector::{vec2f, Vector2F},
|
vector::{vec2f, Vector2F},
|
||||||
},
|
},
|
||||||
platform::{Screen, WindowBounds, WindowKind, WindowOptions},
|
platform::{Screen, WindowBounds, WindowKind, WindowOptions},
|
||||||
AppContext, Task,
|
AnyElement, AppContext, Element, ImageData, Task,
|
||||||
};
|
};
|
||||||
use std::{rc::Rc, sync::Arc};
|
use std::{rc::Rc, sync::Arc};
|
||||||
|
use theme::Theme;
|
||||||
|
use time::{OffsetDateTime, UtcOffset};
|
||||||
use util::ResultExt;
|
use util::ResultExt;
|
||||||
use workspace::AppState;
|
use workspace::AppState;
|
||||||
|
|
||||||
pub use collab_titlebar_item::CollabTitlebarItem;
|
pub use collab_titlebar_item::CollabTitlebarItem;
|
||||||
pub use panel_settings::{ChatPanelSettings, CollaborationPanelSettings};
|
pub use panel_settings::{
|
||||||
|
ChatPanelSettings, CollaborationPanelSettings, NotificationPanelSettings,
|
||||||
|
};
|
||||||
|
|
||||||
actions!(
|
actions!(
|
||||||
collab,
|
collab,
|
||||||
|
@ -35,6 +42,7 @@ actions!(
|
||||||
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
|
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
|
||||||
settings::register::<CollaborationPanelSettings>(cx);
|
settings::register::<CollaborationPanelSettings>(cx);
|
||||||
settings::register::<ChatPanelSettings>(cx);
|
settings::register::<ChatPanelSettings>(cx);
|
||||||
|
settings::register::<NotificationPanelSettings>(cx);
|
||||||
|
|
||||||
vcs_menu::init(cx);
|
vcs_menu::init(cx);
|
||||||
collab_titlebar_item::init(cx);
|
collab_titlebar_item::init(cx);
|
||||||
|
@ -130,3 +138,57 @@ fn notification_window_options(
|
||||||
screen: Some(screen),
|
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>()
|
||||||
|
}
|
||||||
|
|
427
crates/collab_ui/src/notification_panel.rs
Normal file
427
crates/collab_ui/src/notification_panel.rs
Normal 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)
|
||||||
|
}
|
|
@ -18,6 +18,13 @@ pub struct ChatPanelSettings {
|
||||||
pub default_width: f32,
|
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)]
|
#[derive(Clone, Default, Serialize, Deserialize, JsonSchema, Debug)]
|
||||||
pub struct PanelSettingsContent {
|
pub struct PanelSettingsContent {
|
||||||
pub button: Option<bool>,
|
pub button: Option<bool>,
|
||||||
|
@ -27,9 +34,7 @@ pub struct PanelSettingsContent {
|
||||||
|
|
||||||
impl Setting for CollaborationPanelSettings {
|
impl Setting for CollaborationPanelSettings {
|
||||||
const KEY: Option<&'static str> = Some("collaboration_panel");
|
const KEY: Option<&'static str> = Some("collaboration_panel");
|
||||||
|
|
||||||
type FileContent = PanelSettingsContent;
|
type FileContent = PanelSettingsContent;
|
||||||
|
|
||||||
fn load(
|
fn load(
|
||||||
default_value: &Self::FileContent,
|
default_value: &Self::FileContent,
|
||||||
user_values: &[&Self::FileContent],
|
user_values: &[&Self::FileContent],
|
||||||
|
@ -41,9 +46,19 @@ impl Setting for CollaborationPanelSettings {
|
||||||
|
|
||||||
impl Setting for ChatPanelSettings {
|
impl Setting for ChatPanelSettings {
|
||||||
const KEY: Option<&'static str> = Some("chat_panel");
|
const KEY: Option<&'static str> = Some("chat_panel");
|
||||||
|
|
||||||
type FileContent = PanelSettingsContent;
|
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(
|
fn load(
|
||||||
default_value: &Self::FileContent,
|
default_value: &Self::FileContent,
|
||||||
user_values: &[&Self::FileContent],
|
user_values: &[&Self::FileContent],
|
||||||
|
|
42
crates/notifications/Cargo.toml
Normal file
42
crates/notifications/Cargo.toml
Normal 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"] }
|
256
crates/notifications/src/notification_store.rs
Normal file
256
crates/notifications/src/notification_store.rs
Normal 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 ¬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;
|
UnlinkChannel unlink_channel = 141;
|
||||||
MoveChannel move_channel = 142;
|
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;
|
bool done = 2;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
message GetChannelMessagesById {
|
||||||
|
repeated uint64 message_ids = 1;
|
||||||
|
}
|
||||||
|
|
||||||
message LinkChannel {
|
message LinkChannel {
|
||||||
uint64 channel_id = 1;
|
uint64 channel_id = 1;
|
||||||
uint64 to = 2;
|
uint64 to = 2;
|
||||||
|
@ -1562,37 +1567,14 @@ message UpdateDiffBase {
|
||||||
|
|
||||||
message AddNotifications {
|
message AddNotifications {
|
||||||
repeated Notification notifications = 1;
|
repeated Notification notifications = 1;
|
||||||
repeated User users = 2;
|
|
||||||
repeated Channel channels = 3;
|
|
||||||
repeated ChannelMessage messages = 4;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
message Notification {
|
message Notification {
|
||||||
uint32 kind = 1;
|
uint64 id = 1;
|
||||||
uint64 timestamp = 2;
|
uint32 kind = 2;
|
||||||
bool is_read = 3;
|
uint64 timestamp = 3;
|
||||||
optional uint64 entity_id_1 = 4;
|
bool is_read = 4;
|
||||||
optional uint64 entity_id_2 = 5;
|
optional uint64 entity_id_1 = 5;
|
||||||
optional uint64 entity_id_3 = 6;
|
optional uint64 entity_id_2 = 6;
|
||||||
|
optional uint64 entity_id_3 = 7;
|
||||||
// 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;
|
|
||||||
// }
|
|
||||||
}
|
}
|
||||||
|
|
|
@ -7,14 +7,19 @@ use strum::{Display, EnumIter, EnumString, IntoEnumIterator};
|
||||||
#[derive(Copy, Clone, Debug, EnumIter, EnumString, Display)]
|
#[derive(Copy, Clone, Debug, EnumIter, EnumString, Display)]
|
||||||
pub enum NotificationKind {
|
pub enum NotificationKind {
|
||||||
ContactRequest = 0,
|
ContactRequest = 0,
|
||||||
ChannelInvitation = 1,
|
ContactRequestAccepted = 1,
|
||||||
ChannelMessageMention = 2,
|
ChannelInvitation = 2,
|
||||||
|
ChannelMessageMention = 3,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
pub enum Notification {
|
pub enum Notification {
|
||||||
ContactRequest {
|
ContactRequest {
|
||||||
requester_id: u64,
|
requester_id: u64,
|
||||||
},
|
},
|
||||||
|
ContactRequestAccepted {
|
||||||
|
contact_id: u64,
|
||||||
|
},
|
||||||
ChannelInvitation {
|
ChannelInvitation {
|
||||||
inviter_id: u64,
|
inviter_id: u64,
|
||||||
channel_id: u64,
|
channel_id: u64,
|
||||||
|
@ -26,13 +31,6 @@ pub enum Notification {
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Copy, Clone)]
|
|
||||||
pub enum NotificationEntityKind {
|
|
||||||
User,
|
|
||||||
Channel,
|
|
||||||
ChannelMessage,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Notification {
|
impl Notification {
|
||||||
/// Load this notification from its generic representation, which is
|
/// Load this notification from its generic representation, which is
|
||||||
/// used to represent it in the database, and in the wire protocol.
|
/// 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.
|
/// 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> {
|
pub fn from_parts(kind: NotificationKind, entity_ids: [Option<u64>; 3]) -> Option<Self> {
|
||||||
use NotificationKind::*;
|
use NotificationKind::*;
|
||||||
|
|
||||||
Some(match kind {
|
Some(match kind {
|
||||||
ContactRequest => Self::ContactRequest {
|
ContactRequest => Self::ContactRequest {
|
||||||
requester_id: entity_ids[0]?,
|
requester_id: entity_ids[0]?,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
ContactRequestAccepted => Self::ContactRequest {
|
||||||
|
requester_id: entity_ids[0]?,
|
||||||
|
},
|
||||||
|
|
||||||
ChannelInvitation => Self::ChannelInvitation {
|
ChannelInvitation => Self::ChannelInvitation {
|
||||||
inviter_id: entity_ids[0]?,
|
inviter_id: entity_ids[0]?,
|
||||||
channel_id: entity_ids[1]?,
|
channel_id: entity_ids[1]?,
|
||||||
},
|
},
|
||||||
|
|
||||||
ChannelMessageMention => Self::ChannelMessageMention {
|
ChannelMessageMention => Self::ChannelMessageMention {
|
||||||
sender_id: entity_ids[0]?,
|
sender_id: entity_ids[0]?,
|
||||||
channel_id: entity_ids[1]?,
|
channel_id: entity_ids[1]?,
|
||||||
|
@ -65,33 +68,23 @@ impl Notification {
|
||||||
/// The order in which a given notification type's fields are listed must
|
/// 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
|
/// 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.
|
/// not change, because they're stored in that order in the database.
|
||||||
///
|
pub fn to_parts(&self) -> (NotificationKind, [Option<u64>; 3]) {
|
||||||
/// 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]) {
|
|
||||||
use NotificationKind::*;
|
use NotificationKind::*;
|
||||||
|
|
||||||
match self {
|
match self {
|
||||||
Self::ContactRequest { requester_id } => (
|
Self::ContactRequest { requester_id } => {
|
||||||
ContactRequest,
|
(ContactRequest, [Some(*requester_id), None, None])
|
||||||
[
|
}
|
||||||
Some((*requester_id, NotificationEntityKind::User)),
|
|
||||||
None,
|
Self::ContactRequestAccepted { contact_id } => {
|
||||||
None,
|
(ContactRequest, [Some(*contact_id), None, None])
|
||||||
],
|
}
|
||||||
),
|
|
||||||
|
|
||||||
Self::ChannelInvitation {
|
Self::ChannelInvitation {
|
||||||
inviter_id,
|
inviter_id,
|
||||||
channel_id,
|
channel_id,
|
||||||
} => (
|
} => (
|
||||||
ChannelInvitation,
|
ChannelInvitation,
|
||||||
[
|
[Some(*inviter_id), Some(*channel_id), None],
|
||||||
Some((*inviter_id, NotificationEntityKind::User)),
|
|
||||||
Some((*channel_id, NotificationEntityKind::User)),
|
|
||||||
None,
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
|
|
||||||
Self::ChannelMessageMention {
|
Self::ChannelMessageMention {
|
||||||
|
@ -100,11 +93,7 @@ impl Notification {
|
||||||
message_id,
|
message_id,
|
||||||
} => (
|
} => (
|
||||||
ChannelMessageMention,
|
ChannelMessageMention,
|
||||||
[
|
[Some(*sender_id), Some(*channel_id), Some(*message_id)],
|
||||||
Some((*sender_id, NotificationEntityKind::User)),
|
|
||||||
Some((*channel_id, NotificationEntityKind::ChannelMessage)),
|
|
||||||
Some((*message_id, NotificationEntityKind::Channel)),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -133,6 +133,7 @@ impl fmt::Display for PeerId {
|
||||||
|
|
||||||
messages!(
|
messages!(
|
||||||
(Ack, Foreground),
|
(Ack, Foreground),
|
||||||
|
(AddNotifications, Foreground),
|
||||||
(AddProjectCollaborator, Foreground),
|
(AddProjectCollaborator, Foreground),
|
||||||
(ApplyCodeAction, Background),
|
(ApplyCodeAction, Background),
|
||||||
(ApplyCodeActionResponse, Background),
|
(ApplyCodeActionResponse, Background),
|
||||||
|
@ -166,6 +167,7 @@ messages!(
|
||||||
(GetHoverResponse, Background),
|
(GetHoverResponse, Background),
|
||||||
(GetChannelMessages, Background),
|
(GetChannelMessages, Background),
|
||||||
(GetChannelMessagesResponse, Background),
|
(GetChannelMessagesResponse, Background),
|
||||||
|
(GetChannelMessagesById, Background),
|
||||||
(SendChannelMessage, Background),
|
(SendChannelMessage, Background),
|
||||||
(SendChannelMessageResponse, Background),
|
(SendChannelMessageResponse, Background),
|
||||||
(GetCompletions, Background),
|
(GetCompletions, Background),
|
||||||
|
@ -329,6 +331,7 @@ request_messages!(
|
||||||
(SetChannelMemberAdmin, Ack),
|
(SetChannelMemberAdmin, Ack),
|
||||||
(SendChannelMessage, SendChannelMessageResponse),
|
(SendChannelMessage, SendChannelMessageResponse),
|
||||||
(GetChannelMessages, GetChannelMessagesResponse),
|
(GetChannelMessages, GetChannelMessagesResponse),
|
||||||
|
(GetChannelMessagesById, GetChannelMessagesResponse),
|
||||||
(GetChannelMembers, GetChannelMembersResponse),
|
(GetChannelMembers, GetChannelMembersResponse),
|
||||||
(JoinChannel, JoinRoomResponse),
|
(JoinChannel, JoinRoomResponse),
|
||||||
(RemoveChannelMessage, Ack),
|
(RemoveChannelMessage, Ack),
|
||||||
|
|
|
@ -50,6 +50,7 @@ language_selector = { path = "../language_selector" }
|
||||||
lsp = { path = "../lsp" }
|
lsp = { path = "../lsp" }
|
||||||
language_tools = { path = "../language_tools" }
|
language_tools = { path = "../language_tools" }
|
||||||
node_runtime = { path = "../node_runtime" }
|
node_runtime = { path = "../node_runtime" }
|
||||||
|
notifications = { path = "../notifications" }
|
||||||
assistant = { path = "../assistant" }
|
assistant = { path = "../assistant" }
|
||||||
outline = { path = "../outline" }
|
outline = { path = "../outline" }
|
||||||
plugin_runtime = { path = "../plugin_runtime",optional = true }
|
plugin_runtime = { path = "../plugin_runtime",optional = true }
|
||||||
|
|
|
@ -202,6 +202,7 @@ fn main() {
|
||||||
activity_indicator::init(cx);
|
activity_indicator::init(cx);
|
||||||
language_tools::init(cx);
|
language_tools::init(cx);
|
||||||
call::init(app_state.client.clone(), app_state.user_store.clone(), 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);
|
collab_ui::init(&app_state, cx);
|
||||||
feedback::init(cx);
|
feedback::init(cx);
|
||||||
welcome::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);
|
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(
|
cx.add_action(
|
||||||
|workspace: &mut Workspace,
|
|workspace: &mut Workspace,
|
||||||
_: &terminal_panel::ToggleFocus,
|
_: &terminal_panel::ToggleFocus,
|
||||||
|
@ -275,9 +282,8 @@ pub fn initialize_workspace(
|
||||||
QuickActionBar::new(buffer_search_bar, workspace)
|
QuickActionBar::new(buffer_search_bar, workspace)
|
||||||
});
|
});
|
||||||
toolbar.add_item(quick_action_bar, cx);
|
toolbar.add_item(quick_action_bar, cx);
|
||||||
let diagnostic_editor_controls = cx.add_view(|_| {
|
let diagnostic_editor_controls =
|
||||||
diagnostics::ToolbarControls::new()
|
cx.add_view(|_| diagnostics::ToolbarControls::new());
|
||||||
});
|
|
||||||
toolbar.add_item(diagnostic_editor_controls, cx);
|
toolbar.add_item(diagnostic_editor_controls, cx);
|
||||||
let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
|
let project_search_bar = cx.add_view(|_| ProjectSearchBar::new());
|
||||||
toolbar.add_item(project_search_bar, cx);
|
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());
|
collab_ui::collab_panel::CollabPanel::load(workspace_handle.clone(), cx.clone());
|
||||||
let chat_panel =
|
let chat_panel =
|
||||||
collab_ui::chat_panel::ChatPanel::load(workspace_handle.clone(), cx.clone());
|
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,
|
project_panel,
|
||||||
terminal_panel,
|
terminal_panel,
|
||||||
assistant_panel,
|
assistant_panel,
|
||||||
channels_panel,
|
channels_panel,
|
||||||
chat_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| {
|
workspace_handle.update(&mut cx, |workspace, cx| {
|
||||||
let project_panel_position = project_panel.position(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(assistant_panel, cx);
|
||||||
workspace.add_panel(channels_panel, cx);
|
workspace.add_panel(channels_panel, cx);
|
||||||
workspace.add_panel(chat_panel, cx);
|
workspace.add_panel(chat_panel, cx);
|
||||||
|
workspace.add_panel(notification_panel, cx);
|
||||||
|
|
||||||
if !was_deserialized
|
if !was_deserialized
|
||||||
&& workspace
|
&& workspace
|
||||||
|
|
Loading…
Reference in a new issue