mirror of
https://github.com/zed-industries/zed.git
synced 2024-12-24 17:28:40 +00:00
Fix more issues with the channels panel
* Put the newest notifications at the top * Have at most 1 notification toast, which is non-interactive, but focuses the notification panel on click, and auto-dismisses on a timer.
This commit is contained in:
parent
52834dbf21
commit
660021f5e5
9 changed files with 328 additions and 342 deletions
|
@ -213,6 +213,12 @@ impl ChannelStore {
|
||||||
self.channel_index.by_id().values().nth(ix)
|
self.channel_index.by_id().values().nth(ix)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn has_channel_invitation(&self, channel_id: ChannelId) -> bool {
|
||||||
|
self.channel_invitations
|
||||||
|
.iter()
|
||||||
|
.any(|channel| channel.id == channel_id)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn channel_invitations(&self) -> &[Arc<Channel>] {
|
pub fn channel_invitations(&self) -> &[Arc<Channel>] {
|
||||||
&self.channel_invitations
|
&self.channel_invitations
|
||||||
}
|
}
|
||||||
|
|
|
@ -187,6 +187,7 @@ impl Database {
|
||||||
rpc::Notification::ChannelInvitation {
|
rpc::Notification::ChannelInvitation {
|
||||||
channel_id: channel_id.to_proto(),
|
channel_id: channel_id.to_proto(),
|
||||||
channel_name: channel.name,
|
channel_name: channel.name,
|
||||||
|
inviter_id: inviter_id.to_proto(),
|
||||||
},
|
},
|
||||||
true,
|
true,
|
||||||
&*tx,
|
&*tx,
|
||||||
|
@ -276,6 +277,7 @@ impl Database {
|
||||||
&rpc::Notification::ChannelInvitation {
|
&rpc::Notification::ChannelInvitation {
|
||||||
channel_id: channel_id.to_proto(),
|
channel_id: channel_id.to_proto(),
|
||||||
channel_name: Default::default(),
|
channel_name: Default::default(),
|
||||||
|
inviter_id: Default::default(),
|
||||||
},
|
},
|
||||||
accept,
|
accept,
|
||||||
&*tx,
|
&*tx,
|
||||||
|
@ -292,7 +294,7 @@ impl Database {
|
||||||
channel_id: ChannelId,
|
channel_id: ChannelId,
|
||||||
member_id: UserId,
|
member_id: UserId,
|
||||||
remover_id: UserId,
|
remover_id: UserId,
|
||||||
) -> Result<()> {
|
) -> Result<Option<NotificationId>> {
|
||||||
self.transaction(|tx| async move {
|
self.transaction(|tx| async move {
|
||||||
self.check_user_is_channel_admin(channel_id, remover_id, &*tx)
|
self.check_user_is_channel_admin(channel_id, remover_id, &*tx)
|
||||||
.await?;
|
.await?;
|
||||||
|
@ -310,7 +312,17 @@ impl Database {
|
||||||
Err(anyhow!("no such member"))?;
|
Err(anyhow!("no such member"))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
Ok(())
|
Ok(self
|
||||||
|
.remove_notification(
|
||||||
|
member_id,
|
||||||
|
rpc::Notification::ChannelInvitation {
|
||||||
|
channel_id: channel_id.to_proto(),
|
||||||
|
channel_name: Default::default(),
|
||||||
|
inviter_id: Default::default(),
|
||||||
|
},
|
||||||
|
&*tx,
|
||||||
|
)
|
||||||
|
.await?)
|
||||||
})
|
})
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
|
@ -2331,7 +2331,8 @@ async fn remove_channel_member(
|
||||||
let channel_id = ChannelId::from_proto(request.channel_id);
|
let channel_id = ChannelId::from_proto(request.channel_id);
|
||||||
let member_id = UserId::from_proto(request.user_id);
|
let member_id = UserId::from_proto(request.user_id);
|
||||||
|
|
||||||
db.remove_channel_member(channel_id, member_id, session.user_id)
|
let removed_notification_id = db
|
||||||
|
.remove_channel_member(channel_id, member_id, session.user_id)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
let mut update = proto::UpdateChannels::default();
|
let mut update = proto::UpdateChannels::default();
|
||||||
|
@ -2342,7 +2343,18 @@ async fn remove_channel_member(
|
||||||
.await
|
.await
|
||||||
.user_connection_ids(member_id)
|
.user_connection_ids(member_id)
|
||||||
{
|
{
|
||||||
session.peer.send(connection_id, update.clone())?;
|
session.peer.send(connection_id, update.clone()).trace_err();
|
||||||
|
if let Some(notification_id) = removed_notification_id {
|
||||||
|
session
|
||||||
|
.peer
|
||||||
|
.send(
|
||||||
|
connection_id,
|
||||||
|
proto::DeleteNotification {
|
||||||
|
notification_id: notification_id.to_proto(),
|
||||||
|
},
|
||||||
|
)
|
||||||
|
.trace_err();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
response.send(proto::Ack {})?;
|
response.send(proto::Ack {})?;
|
||||||
|
|
|
@ -1,5 +1,7 @@
|
||||||
use crate::tests::TestServer;
|
use crate::tests::TestServer;
|
||||||
use gpui::{executor::Deterministic, TestAppContext};
|
use gpui::{executor::Deterministic, TestAppContext};
|
||||||
|
use notifications::NotificationEvent;
|
||||||
|
use parking_lot::Mutex;
|
||||||
use rpc::Notification;
|
use rpc::Notification;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
|
@ -14,6 +16,23 @@ async fn test_notifications(
|
||||||
let client_a = server.create_client(cx_a, "user_a").await;
|
let client_a = server.create_client(cx_a, "user_a").await;
|
||||||
let client_b = server.create_client(cx_b, "user_b").await;
|
let client_b = server.create_client(cx_b, "user_b").await;
|
||||||
|
|
||||||
|
let notification_events_a = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
let notification_events_b = Arc::new(Mutex::new(Vec::new()));
|
||||||
|
client_a.notification_store().update(cx_a, |_, cx| {
|
||||||
|
let events = notification_events_a.clone();
|
||||||
|
cx.subscribe(&cx.handle(), move |_, _, event, _| {
|
||||||
|
events.lock().push(event.clone());
|
||||||
|
})
|
||||||
|
.detach()
|
||||||
|
});
|
||||||
|
client_b.notification_store().update(cx_b, |_, cx| {
|
||||||
|
let events = notification_events_b.clone();
|
||||||
|
cx.subscribe(&cx.handle(), move |_, _, event, _| {
|
||||||
|
events.lock().push(event.clone());
|
||||||
|
})
|
||||||
|
.detach()
|
||||||
|
});
|
||||||
|
|
||||||
// Client A sends a contact request to client B.
|
// Client A sends a contact request to client B.
|
||||||
client_a
|
client_a
|
||||||
.user_store()
|
.user_store()
|
||||||
|
@ -36,6 +55,18 @@ async fn test_notifications(
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert!(!entry.is_read);
|
assert!(!entry.is_read);
|
||||||
|
assert_eq!(
|
||||||
|
¬ification_events_b.lock()[0..],
|
||||||
|
&[
|
||||||
|
NotificationEvent::NewNotification {
|
||||||
|
entry: entry.clone(),
|
||||||
|
},
|
||||||
|
NotificationEvent::NotificationsUpdated {
|
||||||
|
old_range: 0..0,
|
||||||
|
new_count: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
|
|
||||||
store.respond_to_notification(entry.notification.clone(), true, cx);
|
store.respond_to_notification(entry.notification.clone(), true, cx);
|
||||||
});
|
});
|
||||||
|
@ -49,6 +80,18 @@ async fn test_notifications(
|
||||||
let entry = store.notification_at(0).unwrap();
|
let entry = store.notification_at(0).unwrap();
|
||||||
assert!(entry.is_read);
|
assert!(entry.is_read);
|
||||||
assert_eq!(entry.response, Some(true));
|
assert_eq!(entry.response, Some(true));
|
||||||
|
assert_eq!(
|
||||||
|
¬ification_events_b.lock()[2..],
|
||||||
|
&[
|
||||||
|
NotificationEvent::NotificationRead {
|
||||||
|
entry: entry.clone(),
|
||||||
|
},
|
||||||
|
NotificationEvent::NotificationsUpdated {
|
||||||
|
old_range: 0..1,
|
||||||
|
new_count: 1
|
||||||
|
}
|
||||||
|
]
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
// Client A receives a notification that client B accepted their request.
|
// Client A receives a notification that client B accepted their request.
|
||||||
|
@ -89,12 +132,13 @@ async fn test_notifications(
|
||||||
assert_eq!(store.notification_count(), 2);
|
assert_eq!(store.notification_count(), 2);
|
||||||
assert_eq!(store.unread_notification_count(), 1);
|
assert_eq!(store.unread_notification_count(), 1);
|
||||||
|
|
||||||
let entry = store.notification_at(1).unwrap();
|
let entry = store.notification_at(0).unwrap();
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
entry.notification,
|
entry.notification,
|
||||||
Notification::ChannelInvitation {
|
Notification::ChannelInvitation {
|
||||||
channel_id,
|
channel_id,
|
||||||
channel_name: "the-channel".to_string()
|
channel_name: "the-channel".to_string(),
|
||||||
|
inviter_id: client_a.id()
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
assert!(!entry.is_read);
|
assert!(!entry.is_read);
|
||||||
|
@ -108,7 +152,7 @@ async fn test_notifications(
|
||||||
assert_eq!(store.notification_count(), 2);
|
assert_eq!(store.notification_count(), 2);
|
||||||
assert_eq!(store.unread_notification_count(), 0);
|
assert_eq!(store.unread_notification_count(), 0);
|
||||||
|
|
||||||
let entry = store.notification_at(1).unwrap();
|
let entry = store.notification_at(0).unwrap();
|
||||||
assert!(entry.is_read);
|
assert!(entry.is_read);
|
||||||
assert_eq!(entry.response, Some(true));
|
assert_eq!(entry.response, Some(true));
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,9 @@
|
||||||
use crate::{
|
use crate::{
|
||||||
format_timestamp, is_channels_feature_enabled,
|
format_timestamp, is_channels_feature_enabled, render_avatar, NotificationPanelSettings,
|
||||||
notifications::contact_notification::ContactNotification, render_avatar,
|
|
||||||
NotificationPanelSettings,
|
|
||||||
};
|
};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use channel::ChannelStore;
|
use channel::ChannelStore;
|
||||||
use client::{Client, Notification, UserStore};
|
use client::{Client, Notification, User, UserStore};
|
||||||
use db::kvp::KEY_VALUE_STORE;
|
use db::kvp::KEY_VALUE_STORE;
|
||||||
use futures::StreamExt;
|
use futures::StreamExt;
|
||||||
use gpui::{
|
use gpui::{
|
||||||
|
@ -19,7 +17,7 @@ use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
|
||||||
use project::Fs;
|
use project::Fs;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use settings::SettingsStore;
|
use settings::SettingsStore;
|
||||||
use std::sync::Arc;
|
use std::{sync::Arc, time::Duration};
|
||||||
use theme::{IconButton, Theme};
|
use theme::{IconButton, Theme};
|
||||||
use time::{OffsetDateTime, UtcOffset};
|
use time::{OffsetDateTime, UtcOffset};
|
||||||
use util::{ResultExt, TryFutureExt};
|
use util::{ResultExt, TryFutureExt};
|
||||||
|
@ -28,6 +26,7 @@ use workspace::{
|
||||||
Workspace,
|
Workspace,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const TOAST_DURATION: Duration = Duration::from_secs(5);
|
||||||
const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
|
const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
|
||||||
|
|
||||||
pub struct NotificationPanel {
|
pub struct NotificationPanel {
|
||||||
|
@ -42,6 +41,7 @@ pub struct NotificationPanel {
|
||||||
pending_serialization: Task<Option<()>>,
|
pending_serialization: Task<Option<()>>,
|
||||||
subscriptions: Vec<gpui::Subscription>,
|
subscriptions: Vec<gpui::Subscription>,
|
||||||
workspace: WeakViewHandle<Workspace>,
|
workspace: WeakViewHandle<Workspace>,
|
||||||
|
current_notification_toast: Option<(u64, Task<()>)>,
|
||||||
local_timezone: UtcOffset,
|
local_timezone: UtcOffset,
|
||||||
has_focus: bool,
|
has_focus: bool,
|
||||||
}
|
}
|
||||||
|
@ -58,7 +58,7 @@ pub enum Event {
|
||||||
Dismissed,
|
Dismissed,
|
||||||
}
|
}
|
||||||
|
|
||||||
actions!(chat_panel, [ToggleFocus]);
|
actions!(notification_panel, [ToggleFocus]);
|
||||||
|
|
||||||
pub fn init(_cx: &mut AppContext) {}
|
pub fn init(_cx: &mut AppContext) {}
|
||||||
|
|
||||||
|
@ -69,14 +69,8 @@ impl NotificationPanel {
|
||||||
let user_store = workspace.app_state().user_store.clone();
|
let user_store = workspace.app_state().user_store.clone();
|
||||||
let workspace_handle = workspace.weak_handle();
|
let workspace_handle = workspace.weak_handle();
|
||||||
|
|
||||||
let notification_list =
|
|
||||||
ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
|
|
||||||
this.render_notification(ix, cx)
|
|
||||||
});
|
|
||||||
|
|
||||||
cx.add_view(|cx| {
|
cx.add_view(|cx| {
|
||||||
let mut status = client.status();
|
let mut status = client.status();
|
||||||
|
|
||||||
cx.spawn(|this, mut cx| async move {
|
cx.spawn(|this, mut cx| async move {
|
||||||
while let Some(_) = status.next().await {
|
while let Some(_) = status.next().await {
|
||||||
if this
|
if this
|
||||||
|
@ -91,6 +85,12 @@ impl NotificationPanel {
|
||||||
})
|
})
|
||||||
.detach();
|
.detach();
|
||||||
|
|
||||||
|
let notification_list =
|
||||||
|
ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
|
||||||
|
this.render_notification(ix, cx)
|
||||||
|
.unwrap_or_else(|| Empty::new().into_any())
|
||||||
|
});
|
||||||
|
|
||||||
let mut this = Self {
|
let mut this = Self {
|
||||||
fs,
|
fs,
|
||||||
client,
|
client,
|
||||||
|
@ -102,6 +102,7 @@ impl NotificationPanel {
|
||||||
pending_serialization: Task::ready(None),
|
pending_serialization: Task::ready(None),
|
||||||
workspace: workspace_handle,
|
workspace: workspace_handle,
|
||||||
has_focus: false,
|
has_focus: false,
|
||||||
|
current_notification_toast: None,
|
||||||
subscriptions: Vec::new(),
|
subscriptions: Vec::new(),
|
||||||
active: false,
|
active: false,
|
||||||
width: None,
|
width: None,
|
||||||
|
@ -169,73 +170,20 @@ impl NotificationPanel {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_notification(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
|
fn render_notification(
|
||||||
self.try_render_notification(ix, cx)
|
|
||||||
.unwrap_or_else(|| Empty::new().into_any())
|
|
||||||
}
|
|
||||||
|
|
||||||
fn try_render_notification(
|
|
||||||
&mut self,
|
&mut self,
|
||||||
ix: usize,
|
ix: usize,
|
||||||
cx: &mut ViewContext<Self>,
|
cx: &mut ViewContext<Self>,
|
||||||
) -> Option<AnyElement<Self>> {
|
) -> Option<AnyElement<Self>> {
|
||||||
let notification_store = self.notification_store.read(cx);
|
let entry = self.notification_store.read(cx).notification_at(ix)?;
|
||||||
let user_store = self.user_store.read(cx);
|
|
||||||
let channel_store = self.channel_store.read(cx);
|
|
||||||
let entry = notification_store.notification_at(ix)?;
|
|
||||||
let notification = entry.notification.clone();
|
|
||||||
let now = OffsetDateTime::now_utc();
|
let now = OffsetDateTime::now_utc();
|
||||||
let timestamp = entry.timestamp;
|
let timestamp = entry.timestamp;
|
||||||
|
let (actor, text, icon, needs_response) = self.present_notification(entry, cx)?;
|
||||||
let icon;
|
|
||||||
let text;
|
|
||||||
let actor;
|
|
||||||
let needs_acceptance;
|
|
||||||
match notification {
|
|
||||||
Notification::ContactRequest { sender_id } => {
|
|
||||||
let requester = user_store.get_cached_user(sender_id)?;
|
|
||||||
icon = "icons/plus.svg";
|
|
||||||
text = format!("{} wants to add you as a contact", requester.github_login);
|
|
||||||
needs_acceptance = true;
|
|
||||||
actor = Some(requester);
|
|
||||||
}
|
|
||||||
Notification::ContactRequestAccepted { responder_id } => {
|
|
||||||
let responder = user_store.get_cached_user(responder_id)?;
|
|
||||||
icon = "icons/plus.svg";
|
|
||||||
text = format!("{} accepted your contact invite", responder.github_login);
|
|
||||||
needs_acceptance = false;
|
|
||||||
actor = Some(responder);
|
|
||||||
}
|
|
||||||
Notification::ChannelInvitation {
|
|
||||||
ref channel_name, ..
|
|
||||||
} => {
|
|
||||||
actor = None;
|
|
||||||
icon = "icons/hash.svg";
|
|
||||||
text = format!("you were invited to join the #{channel_name} channel");
|
|
||||||
needs_acceptance = true;
|
|
||||||
}
|
|
||||||
Notification::ChannelMessageMention {
|
|
||||||
sender_id,
|
|
||||||
channel_id,
|
|
||||||
message_id,
|
|
||||||
} => {
|
|
||||||
let sender = 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{}",
|
|
||||||
sender.github_login, channel.name, message.body,
|
|
||||||
);
|
|
||||||
needs_acceptance = false;
|
|
||||||
actor = Some(sender);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let theme = theme::current(cx);
|
let theme = theme::current(cx);
|
||||||
let style = &theme.notification_panel;
|
let style = &theme.notification_panel;
|
||||||
let response = entry.response;
|
let response = entry.response;
|
||||||
|
let notification = entry.notification.clone();
|
||||||
|
|
||||||
let message_style = if entry.is_read {
|
let message_style = if entry.is_read {
|
||||||
style.read_text.clone()
|
style.read_text.clone()
|
||||||
|
@ -276,7 +224,7 @@ impl NotificationPanel {
|
||||||
)
|
)
|
||||||
.into_any(),
|
.into_any(),
|
||||||
)
|
)
|
||||||
} else if needs_acceptance {
|
} else if needs_response {
|
||||||
Some(
|
Some(
|
||||||
Flex::row()
|
Flex::row()
|
||||||
.with_children([
|
.with_children([
|
||||||
|
@ -336,6 +284,69 @@ impl NotificationPanel {
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn present_notification(
|
||||||
|
&self,
|
||||||
|
entry: &NotificationEntry,
|
||||||
|
cx: &AppContext,
|
||||||
|
) -> Option<(Option<Arc<client::User>>, String, &'static str, bool)> {
|
||||||
|
let user_store = self.user_store.read(cx);
|
||||||
|
let channel_store = self.channel_store.read(cx);
|
||||||
|
let icon;
|
||||||
|
let text;
|
||||||
|
let actor;
|
||||||
|
let needs_response;
|
||||||
|
match entry.notification {
|
||||||
|
Notification::ContactRequest { sender_id } => {
|
||||||
|
let requester = user_store.get_cached_user(sender_id)?;
|
||||||
|
icon = "icons/plus.svg";
|
||||||
|
text = format!("{} wants to add you as a contact", requester.github_login);
|
||||||
|
needs_response = user_store.is_contact_request_pending(&requester);
|
||||||
|
actor = Some(requester);
|
||||||
|
}
|
||||||
|
Notification::ContactRequestAccepted { responder_id } => {
|
||||||
|
let responder = user_store.get_cached_user(responder_id)?;
|
||||||
|
icon = "icons/plus.svg";
|
||||||
|
text = format!("{} accepted your contact invite", responder.github_login);
|
||||||
|
needs_response = false;
|
||||||
|
actor = Some(responder);
|
||||||
|
}
|
||||||
|
Notification::ChannelInvitation {
|
||||||
|
ref channel_name,
|
||||||
|
channel_id,
|
||||||
|
inviter_id,
|
||||||
|
} => {
|
||||||
|
let inviter = user_store.get_cached_user(inviter_id)?;
|
||||||
|
icon = "icons/hash.svg";
|
||||||
|
text = format!(
|
||||||
|
"{} invited you to join the #{channel_name} channel",
|
||||||
|
inviter.github_login
|
||||||
|
);
|
||||||
|
needs_response = channel_store.has_channel_invitation(channel_id);
|
||||||
|
actor = Some(inviter);
|
||||||
|
}
|
||||||
|
Notification::ChannelMessageMention {
|
||||||
|
sender_id,
|
||||||
|
channel_id,
|
||||||
|
message_id,
|
||||||
|
} => {
|
||||||
|
let sender = user_store.get_cached_user(sender_id)?;
|
||||||
|
let channel = channel_store.channel_for_id(channel_id)?;
|
||||||
|
let message = self
|
||||||
|
.notification_store
|
||||||
|
.read(cx)
|
||||||
|
.channel_message_for_id(message_id)?;
|
||||||
|
icon = "icons/conversations.svg";
|
||||||
|
text = format!(
|
||||||
|
"{} mentioned you in the #{} channel:\n{}",
|
||||||
|
sender.github_login, channel.name, message.body,
|
||||||
|
);
|
||||||
|
needs_response = false;
|
||||||
|
actor = Some(sender);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Some((actor, text, icon, needs_response))
|
||||||
|
}
|
||||||
|
|
||||||
fn render_sign_in_prompt(
|
fn render_sign_in_prompt(
|
||||||
&self,
|
&self,
|
||||||
theme: &Arc<Theme>,
|
theme: &Arc<Theme>,
|
||||||
|
@ -387,7 +398,7 @@ impl NotificationPanel {
|
||||||
match event {
|
match event {
|
||||||
NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
|
NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
|
||||||
NotificationEvent::NotificationRemoved { entry }
|
NotificationEvent::NotificationRemoved { entry }
|
||||||
| NotificationEvent::NotificationRead { entry } => self.remove_toast(entry, cx),
|
| NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx),
|
||||||
NotificationEvent::NotificationsUpdated {
|
NotificationEvent::NotificationsUpdated {
|
||||||
old_range,
|
old_range,
|
||||||
new_count,
|
new_count,
|
||||||
|
@ -399,49 +410,44 @@ impl NotificationPanel {
|
||||||
}
|
}
|
||||||
|
|
||||||
fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
|
fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
|
||||||
let id = entry.id as usize;
|
let Some((actor, text, _, _)) = self.present_notification(entry, cx) else {
|
||||||
match entry.notification {
|
return;
|
||||||
Notification::ContactRequest {
|
};
|
||||||
sender_id: actor_id,
|
|
||||||
}
|
let id = entry.id;
|
||||||
| Notification::ContactRequestAccepted {
|
self.current_notification_toast = Some((
|
||||||
responder_id: actor_id,
|
id,
|
||||||
} => {
|
cx.spawn(|this, mut cx| async move {
|
||||||
let user_store = self.user_store.clone();
|
cx.background().timer(TOAST_DURATION).await;
|
||||||
let Some(user) = user_store.read(cx).get_cached_user(actor_id) else {
|
this.update(&mut cx, |this, cx| this.remove_toast(id, cx))
|
||||||
return;
|
|
||||||
};
|
|
||||||
self.workspace
|
|
||||||
.update(cx, |workspace, cx| {
|
|
||||||
workspace.show_notification(id, cx, |cx| {
|
|
||||||
cx.add_view(|_| {
|
|
||||||
ContactNotification::new(
|
|
||||||
user,
|
|
||||||
entry.notification.clone(),
|
|
||||||
user_store,
|
|
||||||
)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
})
|
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}),
|
||||||
Notification::ChannelInvitation { .. } => {}
|
));
|
||||||
Notification::ChannelMessageMention { .. } => {}
|
|
||||||
}
|
self.workspace
|
||||||
|
.update(cx, |workspace, cx| {
|
||||||
|
workspace.show_notification(0, cx, |cx| {
|
||||||
|
let workspace = cx.weak_handle();
|
||||||
|
cx.add_view(|_| NotificationToast {
|
||||||
|
actor,
|
||||||
|
text,
|
||||||
|
workspace,
|
||||||
|
})
|
||||||
|
})
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn remove_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
|
fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
|
||||||
let id = entry.id as usize;
|
if let Some((current_id, _)) = &self.current_notification_toast {
|
||||||
match entry.notification {
|
if *current_id == notification_id {
|
||||||
Notification::ContactRequest { .. } | Notification::ContactRequestAccepted { .. } => {
|
self.current_notification_toast.take();
|
||||||
self.workspace
|
self.workspace
|
||||||
.update(cx, |workspace, cx| {
|
.update(cx, |workspace, cx| {
|
||||||
workspace.dismiss_notification::<ContactNotification>(id, cx)
|
workspace.dismiss_notification::<NotificationToast>(0, cx)
|
||||||
})
|
})
|
||||||
.ok();
|
.ok();
|
||||||
}
|
}
|
||||||
Notification::ChannelInvitation { .. } => {}
|
|
||||||
Notification::ChannelMessageMention { .. } => {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -582,3 +588,111 @@ fn render_icon_button<V: View>(style: &IconButton, svg_path: &'static str) -> im
|
||||||
.contained()
|
.contained()
|
||||||
.with_style(style.container)
|
.with_style(style.container)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub struct NotificationToast {
|
||||||
|
actor: Option<Arc<User>>,
|
||||||
|
text: String,
|
||||||
|
workspace: WeakViewHandle<Workspace>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub enum ToastEvent {
|
||||||
|
Dismiss,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl NotificationToast {
|
||||||
|
fn focus_notification_panel(&self, cx: &mut AppContext) {
|
||||||
|
let workspace = self.workspace.clone();
|
||||||
|
cx.defer(move |cx| {
|
||||||
|
workspace
|
||||||
|
.update(cx, |workspace, cx| {
|
||||||
|
workspace.focus_panel::<NotificationPanel>(cx);
|
||||||
|
})
|
||||||
|
.ok();
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Entity for NotificationToast {
|
||||||
|
type Event = ToastEvent;
|
||||||
|
}
|
||||||
|
|
||||||
|
impl View for NotificationToast {
|
||||||
|
fn ui_name() -> &'static str {
|
||||||
|
"ContactNotification"
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
|
||||||
|
let user = self.actor.clone();
|
||||||
|
let theme = theme::current(cx).clone();
|
||||||
|
let theme = &theme.contact_notification;
|
||||||
|
|
||||||
|
MouseEventHandler::new::<Self, _>(0, cx, |_, cx| {
|
||||||
|
Flex::row()
|
||||||
|
.with_children(user.and_then(|user| {
|
||||||
|
Some(
|
||||||
|
Image::from_data(user.avatar.clone()?)
|
||||||
|
.with_style(theme.header_avatar)
|
||||||
|
.aligned()
|
||||||
|
.constrained()
|
||||||
|
.with_height(
|
||||||
|
cx.font_cache()
|
||||||
|
.line_height(theme.header_message.text.font_size),
|
||||||
|
)
|
||||||
|
.aligned()
|
||||||
|
.top(),
|
||||||
|
)
|
||||||
|
}))
|
||||||
|
.with_child(
|
||||||
|
Text::new(self.text.clone(), theme.header_message.text.clone())
|
||||||
|
.contained()
|
||||||
|
.with_style(theme.header_message.container)
|
||||||
|
.aligned()
|
||||||
|
.top()
|
||||||
|
.left()
|
||||||
|
.flex(1., true),
|
||||||
|
)
|
||||||
|
.with_child(
|
||||||
|
MouseEventHandler::new::<ToastEvent, _>(0, cx, |state, _| {
|
||||||
|
let style = theme.dismiss_button.style_for(state);
|
||||||
|
Svg::new("icons/x.svg")
|
||||||
|
.with_color(style.color)
|
||||||
|
.constrained()
|
||||||
|
.with_width(style.icon_width)
|
||||||
|
.aligned()
|
||||||
|
.contained()
|
||||||
|
.with_style(style.container)
|
||||||
|
.constrained()
|
||||||
|
.with_width(style.button_width)
|
||||||
|
.with_height(style.button_width)
|
||||||
|
})
|
||||||
|
.with_cursor_style(CursorStyle::PointingHand)
|
||||||
|
.with_padding(Padding::uniform(5.))
|
||||||
|
.on_click(MouseButton::Left, move |_, _, cx| {
|
||||||
|
cx.emit(ToastEvent::Dismiss)
|
||||||
|
})
|
||||||
|
.aligned()
|
||||||
|
.constrained()
|
||||||
|
.with_height(
|
||||||
|
cx.font_cache()
|
||||||
|
.line_height(theme.header_message.text.font_size),
|
||||||
|
)
|
||||||
|
.aligned()
|
||||||
|
.top()
|
||||||
|
.flex_float(),
|
||||||
|
)
|
||||||
|
.contained()
|
||||||
|
})
|
||||||
|
.with_cursor_style(CursorStyle::PointingHand)
|
||||||
|
.on_click(MouseButton::Left, move |_, this, cx| {
|
||||||
|
this.focus_notification_panel(cx);
|
||||||
|
cx.emit(ToastEvent::Dismiss);
|
||||||
|
})
|
||||||
|
.into_any()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl workspace::notifications::Notification for NotificationToast {
|
||||||
|
fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
|
||||||
|
matches!(event, ToastEvent::Dismiss)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
@ -1,120 +1,11 @@
|
||||||
use client::User;
|
use gpui::AppContext;
|
||||||
use gpui::{
|
|
||||||
elements::*,
|
|
||||||
platform::{CursorStyle, MouseButton},
|
|
||||||
AnyElement, AppContext, Element, ViewContext,
|
|
||||||
};
|
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use workspace::AppState;
|
use workspace::AppState;
|
||||||
|
|
||||||
pub mod contact_notification;
|
|
||||||
pub mod incoming_call_notification;
|
pub mod incoming_call_notification;
|
||||||
pub mod project_shared_notification;
|
pub mod project_shared_notification;
|
||||||
|
|
||||||
enum Dismiss {}
|
|
||||||
enum Button {}
|
|
||||||
|
|
||||||
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
|
pub fn init(app_state: &Arc<AppState>, cx: &mut AppContext) {
|
||||||
incoming_call_notification::init(app_state, cx);
|
incoming_call_notification::init(app_state, cx);
|
||||||
project_shared_notification::init(app_state, cx);
|
project_shared_notification::init(app_state, cx);
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn render_user_notification<F, V: 'static>(
|
|
||||||
user: Arc<User>,
|
|
||||||
title: &'static str,
|
|
||||||
body: Option<&'static str>,
|
|
||||||
on_dismiss: F,
|
|
||||||
buttons: Vec<(&'static str, Box<dyn Fn(&mut V, &mut ViewContext<V>)>)>,
|
|
||||||
cx: &mut ViewContext<V>,
|
|
||||||
) -> AnyElement<V>
|
|
||||||
where
|
|
||||||
F: 'static + Fn(&mut V, &mut ViewContext<V>),
|
|
||||||
{
|
|
||||||
let theme = theme::current(cx).clone();
|
|
||||||
let theme = &theme.contact_notification;
|
|
||||||
|
|
||||||
Flex::column()
|
|
||||||
.with_child(
|
|
||||||
Flex::row()
|
|
||||||
.with_children(user.avatar.clone().map(|avatar| {
|
|
||||||
Image::from_data(avatar)
|
|
||||||
.with_style(theme.header_avatar)
|
|
||||||
.aligned()
|
|
||||||
.constrained()
|
|
||||||
.with_height(
|
|
||||||
cx.font_cache()
|
|
||||||
.line_height(theme.header_message.text.font_size),
|
|
||||||
)
|
|
||||||
.aligned()
|
|
||||||
.top()
|
|
||||||
}))
|
|
||||||
.with_child(
|
|
||||||
Text::new(
|
|
||||||
format!("{} {}", user.github_login, title),
|
|
||||||
theme.header_message.text.clone(),
|
|
||||||
)
|
|
||||||
.contained()
|
|
||||||
.with_style(theme.header_message.container)
|
|
||||||
.aligned()
|
|
||||||
.top()
|
|
||||||
.left()
|
|
||||||
.flex(1., true),
|
|
||||||
)
|
|
||||||
.with_child(
|
|
||||||
MouseEventHandler::new::<Dismiss, _>(user.id as usize, cx, |state, _| {
|
|
||||||
let style = theme.dismiss_button.style_for(state);
|
|
||||||
Svg::new("icons/x.svg")
|
|
||||||
.with_color(style.color)
|
|
||||||
.constrained()
|
|
||||||
.with_width(style.icon_width)
|
|
||||||
.aligned()
|
|
||||||
.contained()
|
|
||||||
.with_style(style.container)
|
|
||||||
.constrained()
|
|
||||||
.with_width(style.button_width)
|
|
||||||
.with_height(style.button_width)
|
|
||||||
})
|
|
||||||
.with_cursor_style(CursorStyle::PointingHand)
|
|
||||||
.with_padding(Padding::uniform(5.))
|
|
||||||
.on_click(MouseButton::Left, move |_, view, cx| on_dismiss(view, cx))
|
|
||||||
.aligned()
|
|
||||||
.constrained()
|
|
||||||
.with_height(
|
|
||||||
cx.font_cache()
|
|
||||||
.line_height(theme.header_message.text.font_size),
|
|
||||||
)
|
|
||||||
.aligned()
|
|
||||||
.top()
|
|
||||||
.flex_float(),
|
|
||||||
)
|
|
||||||
.into_any_named("contact notification header"),
|
|
||||||
)
|
|
||||||
.with_children(body.map(|body| {
|
|
||||||
Label::new(body, theme.body_message.text.clone())
|
|
||||||
.contained()
|
|
||||||
.with_style(theme.body_message.container)
|
|
||||||
}))
|
|
||||||
.with_children(if buttons.is_empty() {
|
|
||||||
None
|
|
||||||
} else {
|
|
||||||
Some(
|
|
||||||
Flex::row()
|
|
||||||
.with_children(buttons.into_iter().enumerate().map(
|
|
||||||
|(ix, (message, handler))| {
|
|
||||||
MouseEventHandler::new::<Button, _>(ix, cx, |state, _| {
|
|
||||||
let button = theme.button.style_for(state);
|
|
||||||
Label::new(message, button.text.clone())
|
|
||||||
.contained()
|
|
||||||
.with_style(button.container)
|
|
||||||
})
|
|
||||||
.with_cursor_style(CursorStyle::PointingHand)
|
|
||||||
.on_click(MouseButton::Left, move |_, view, cx| handler(view, cx))
|
|
||||||
},
|
|
||||||
))
|
|
||||||
.aligned()
|
|
||||||
.right(),
|
|
||||||
)
|
|
||||||
})
|
|
||||||
.contained()
|
|
||||||
.into_any()
|
|
||||||
}
|
|
||||||
|
|
|
@ -1,106 +0,0 @@
|
||||||
use crate::notifications::render_user_notification;
|
|
||||||
use client::{User, UserStore};
|
|
||||||
use gpui::{elements::*, Entity, ModelHandle, View, ViewContext};
|
|
||||||
use std::sync::Arc;
|
|
||||||
use workspace::notifications::Notification;
|
|
||||||
|
|
||||||
pub struct ContactNotification {
|
|
||||||
user_store: ModelHandle<UserStore>,
|
|
||||||
user: Arc<User>,
|
|
||||||
notification: rpc::Notification,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
|
||||||
struct Dismiss(u64);
|
|
||||||
|
|
||||||
#[derive(Clone, PartialEq)]
|
|
||||||
pub struct RespondToContactRequest {
|
|
||||||
pub user_id: u64,
|
|
||||||
pub accept: bool,
|
|
||||||
}
|
|
||||||
|
|
||||||
pub enum Event {
|
|
||||||
Dismiss,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Entity for ContactNotification {
|
|
||||||
type Event = Event;
|
|
||||||
}
|
|
||||||
|
|
||||||
impl View for ContactNotification {
|
|
||||||
fn ui_name() -> &'static str {
|
|
||||||
"ContactNotification"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
|
|
||||||
match self.notification {
|
|
||||||
rpc::Notification::ContactRequest { .. } => render_user_notification(
|
|
||||||
self.user.clone(),
|
|
||||||
"wants to add you as a contact",
|
|
||||||
Some("They won't be alerted if you decline."),
|
|
||||||
|notification, cx| notification.dismiss(cx),
|
|
||||||
vec![
|
|
||||||
(
|
|
||||||
"Decline",
|
|
||||||
Box::new(|notification, cx| {
|
|
||||||
notification.respond_to_contact_request(false, cx)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
(
|
|
||||||
"Accept",
|
|
||||||
Box::new(|notification, cx| {
|
|
||||||
notification.respond_to_contact_request(true, cx)
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
cx,
|
|
||||||
),
|
|
||||||
rpc::Notification::ContactRequestAccepted { .. } => render_user_notification(
|
|
||||||
self.user.clone(),
|
|
||||||
"accepted your contact request",
|
|
||||||
None,
|
|
||||||
|notification, cx| notification.dismiss(cx),
|
|
||||||
vec![],
|
|
||||||
cx,
|
|
||||||
),
|
|
||||||
_ => unreachable!(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Notification for ContactNotification {
|
|
||||||
fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
|
|
||||||
matches!(event, Event::Dismiss)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl ContactNotification {
|
|
||||||
pub fn new(
|
|
||||||
user: Arc<User>,
|
|
||||||
notification: rpc::Notification,
|
|
||||||
user_store: ModelHandle<UserStore>,
|
|
||||||
) -> Self {
|
|
||||||
Self {
|
|
||||||
user,
|
|
||||||
notification,
|
|
||||||
user_store,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn dismiss(&mut self, cx: &mut ViewContext<Self>) {
|
|
||||||
self.user_store.update(cx, |store, cx| {
|
|
||||||
store
|
|
||||||
.dismiss_contact_request(self.user.id, cx)
|
|
||||||
.detach_and_log_err(cx);
|
|
||||||
});
|
|
||||||
cx.emit(Event::Dismiss);
|
|
||||||
}
|
|
||||||
|
|
||||||
fn respond_to_contact_request(&mut self, accept: bool, cx: &mut ViewContext<Self>) {
|
|
||||||
self.user_store
|
|
||||||
.update(cx, |store, cx| {
|
|
||||||
store.respond_to_contact_request(self.user.id, accept, cx)
|
|
||||||
})
|
|
||||||
.detach();
|
|
||||||
}
|
|
||||||
}
|
|
|
@ -25,6 +25,7 @@ pub struct NotificationStore {
|
||||||
_subscriptions: Vec<client::Subscription>,
|
_subscriptions: Vec<client::Subscription>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, PartialEq, Eq, Debug)]
|
||||||
pub enum NotificationEvent {
|
pub enum NotificationEvent {
|
||||||
NotificationsUpdated {
|
NotificationsUpdated {
|
||||||
old_range: Range<usize>,
|
old_range: Range<usize>,
|
||||||
|
@ -118,7 +119,13 @@ impl NotificationStore {
|
||||||
self.channel_messages.get(&id)
|
self.channel_messages.get(&id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get the nth newest notification.
|
||||||
pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> {
|
pub fn notification_at(&self, ix: usize) -> Option<&NotificationEntry> {
|
||||||
|
let count = self.notifications.summary().count;
|
||||||
|
if ix >= count {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let ix = count - 1 - ix;
|
||||||
let mut cursor = self.notifications.cursor::<Count>();
|
let mut cursor = self.notifications.cursor::<Count>();
|
||||||
cursor.seek(&Count(ix), Bias::Right, &());
|
cursor.seek(&Count(ix), Bias::Right, &());
|
||||||
cursor.item()
|
cursor.item()
|
||||||
|
@ -200,7 +207,9 @@ impl NotificationStore {
|
||||||
|
|
||||||
for entry in ¬ifications {
|
for entry in ¬ifications {
|
||||||
match entry.notification {
|
match entry.notification {
|
||||||
Notification::ChannelInvitation { .. } => {}
|
Notification::ChannelInvitation { inviter_id, .. } => {
|
||||||
|
user_ids.push(inviter_id);
|
||||||
|
}
|
||||||
Notification::ContactRequest {
|
Notification::ContactRequest {
|
||||||
sender_id: requester_id,
|
sender_id: requester_id,
|
||||||
} => {
|
} => {
|
||||||
|
@ -273,8 +282,11 @@ impl NotificationStore {
|
||||||
old_range.start = cursor.start().1 .0;
|
old_range.start = cursor.start().1 .0;
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(existing_notification) = cursor.item() {
|
let old_notification = cursor.item();
|
||||||
if existing_notification.id == id {
|
if let Some(old_notification) = old_notification {
|
||||||
|
if old_notification.id == id {
|
||||||
|
cursor.next(&());
|
||||||
|
|
||||||
if let Some(new_notification) = &new_notification {
|
if let Some(new_notification) = &new_notification {
|
||||||
if new_notification.is_read {
|
if new_notification.is_read {
|
||||||
cx.emit(NotificationEvent::NotificationRead {
|
cx.emit(NotificationEvent::NotificationRead {
|
||||||
|
@ -283,20 +295,19 @@ impl NotificationStore {
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
cx.emit(NotificationEvent::NotificationRemoved {
|
cx.emit(NotificationEvent::NotificationRemoved {
|
||||||
entry: existing_notification.clone(),
|
entry: old_notification.clone(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
cursor.next(&());
|
}
|
||||||
|
} else if let Some(new_notification) = &new_notification {
|
||||||
|
if is_new {
|
||||||
|
cx.emit(NotificationEvent::NewNotification {
|
||||||
|
entry: new_notification.clone(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(notification) = new_notification {
|
if let Some(notification) = new_notification {
|
||||||
if is_new {
|
|
||||||
cx.emit(NotificationEvent::NewNotification {
|
|
||||||
entry: notification.clone(),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
new_notifications.push(notification, &());
|
new_notifications.push(notification, &());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -30,12 +30,13 @@ pub enum Notification {
|
||||||
#[serde(rename = "entity_id")]
|
#[serde(rename = "entity_id")]
|
||||||
channel_id: u64,
|
channel_id: u64,
|
||||||
channel_name: String,
|
channel_name: String,
|
||||||
|
inviter_id: u64,
|
||||||
},
|
},
|
||||||
ChannelMessageMention {
|
ChannelMessageMention {
|
||||||
sender_id: u64,
|
|
||||||
channel_id: u64,
|
|
||||||
#[serde(rename = "entity_id")]
|
#[serde(rename = "entity_id")]
|
||||||
message_id: u64,
|
message_id: u64,
|
||||||
|
sender_id: u64,
|
||||||
|
channel_id: u64,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -84,6 +85,7 @@ fn test_notification() {
|
||||||
Notification::ChannelInvitation {
|
Notification::ChannelInvitation {
|
||||||
channel_id: 100,
|
channel_id: 100,
|
||||||
channel_name: "the-channel".into(),
|
channel_name: "the-channel".into(),
|
||||||
|
inviter_id: 50,
|
||||||
},
|
},
|
||||||
Notification::ChannelMessageMention {
|
Notification::ChannelMessageMention {
|
||||||
sender_id: 200,
|
sender_id: 200,
|
||||||
|
|
Loading…
Reference in a new issue