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",
|
||||
"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",
|
||||
|
|
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": 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>()
|
||||
}
|
||||
|
|
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,
|
||||
}
|
||||
|
||||
#[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],
|
||||
|
|
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;
|
||||
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
|
||||
|
|
Loading…
Reference in a new issue