use crate::{chat_panel::ChatPanel, NotificationPanelSettings};use anyhow::Result;use channel::ChannelStore;use client::{ChannelId, Client, Notification, User, UserStore};use collections::HashMap;use db::kvp::KEY_VALUE_STORE;use futures::StreamExt;use gpui::{ actions, div, img, list, px, AnyElement, App, AsyncWindowContext, Context, CursorStyle, DismissEvent, Element, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, StatefulInteractiveElement, Styled, Task, WeakEntity, Window,};use notifications::{NotificationEntry, NotificationEvent, NotificationStore};use project::Fs;use rpc::proto;use serde::{Deserialize, Serialize};use settings::{Settings, SettingsStore};use std::{sync::Arc, time::Duration};use time::{OffsetDateTime, UtcOffset};use ui::{ h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip,};use util::{ResultExt, TryFutureExt};use workspace::notifications::NotificationId;use workspace::{ dock::{DockPosition, Panel, PanelEvent}, Workspace,}; const LOADING_THRESHOLD: usize = 30;const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);const TOAST_DURATION: Duration = Duration::from_secs(5);const NOTIFICATION_PANEL_KEY: &str = "NotificationPanel"; pub struct NotificationPanel { client: Arc<Client>, user_store: Entity<UserStore>, channel_store: Entity<ChannelStore>, notification_store: Entity<NotificationStore>, fs: Arc<dyn Fs>, width: Option<Pixels>, active: bool, notification_list: ListState, pending_serialization: Task<Option<()>>, subscriptions: Vec<gpui::Subscription>, workspace: WeakEntity<Workspace>, current_notification_toast: Option<(u64, Task<()>)>, local_timezone: UtcOffset, focus_handle: FocusHandle, mark_as_read_tasks: HashMap<u64, Task<Result<()>>>, unseen_notifications: Vec<NotificationEntry>,} #[derive(Serialize, Deserialize)]struct SerializedNotificationPanel { width: Option<Pixels>,} #[derive(Debug)]pub enum Event { DockPositionChanged, Focus, Dismissed,} pub struct NotificationPresenter { pub actor: Option<Arc<client::User>>, pub text: String, pub icon: &'static str, pub needs_response: bool, pub can_navigate: bool,} actions!(notification_panel, [ToggleFocus]); pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _| { workspace.register_action(|workspace, _: &ToggleFocus, window, cx| { workspace.toggle_panel_focus::<NotificationPanel>(window, cx); }); }) .detach();} impl NotificationPanel { pub fn new( workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>, ) -> Entity<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 workspace_handle = workspace.weak_handle(); cx.new(|cx| { let mut status = client.status(); cx.spawn_in(window, |this, mut cx| async move { while (status.next().await).is_some() { if this .update(&mut cx, |_: &mut Self, cx| { cx.notify(); }) .is_err() { break; } } }) .detach(); let entity = cx.entity().downgrade(); let notification_list = ListState::new(0, ListAlignment::Top, px(1000.), move |ix, window, cx| { entity .upgrade() .and_then(|entity| { entity.update(cx, |this, cx| this.render_notification(ix, window, cx)) }) .unwrap_or_else(|| div().into_any()) }); notification_list.set_scroll_handler(cx.listener( |this, event: &ListScrollEvent, _, cx| { if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD { if let Some(task) = this .notification_store .update(cx, |store, cx| store.load_more_notifications(false, cx)) { task.detach(); } } }, )); let local_offset = chrono::Local::now().offset().local_minus_utc(); let mut this = Self { fs, client, user_store, local_timezone: UtcOffset::from_whole_seconds(local_offset).unwrap(), channel_store: ChannelStore::global(cx), notification_store: NotificationStore::global(cx), notification_list, pending_serialization: Task::ready(None), workspace: workspace_handle, focus_handle: cx.focus_handle(), current_notification_toast: None, subscriptions: Vec::new(), active: false, mark_as_read_tasks: HashMap::default(), width: None, unseen_notifications: Vec::new(), }; let mut old_dock_position = this.position(window, cx); this.subscriptions.extend([ cx.observe(&this.notification_store, |_, _, cx| cx.notify()), cx.subscribe_in( &this.notification_store, window, Self::on_notification_event, ), cx.observe_global_in::<SettingsStore>( window, move |this: &mut Self, window, cx| { let new_dock_position = this.position(window, 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: WeakEntity<Workspace>, cx: AsyncWindowContext, ) -> Task<Result<Entity<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_in(&mut cx, |workspace, window, cx| { let panel = Self::new(workspace, window, cx); if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|w| w.round()); cx.notify(); }); } panel }) }) } fn serialize(&mut self, cx: &mut Context<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, window: &mut Window, cx: &mut Context<Self>, ) -> Option<AnyElement> { let entry = self.notification_store.read(cx).notification_at(ix)?; let notification_id = entry.id; let now = OffsetDateTime::now_utc(); let timestamp = entry.timestamp; let NotificationPresenter { actor, text, needs_response, can_navigate, .. } = self.present_notification(entry, cx)?; let response = entry.response; let notification = entry.notification.clone(); if self.active && !entry.is_read { self.did_render_notification(notification_id, ¬ification, window, cx); } let relative_timestamp = time_format::format_localized_timestamp( timestamp, now, self.local_timezone, time_format::TimestampFormat::Relative, ); let absolute_timestamp = time_format::format_localized_timestamp( timestamp, now, self.local_timezone, time_format::TimestampFormat::Absolute, ); Some( div() .id(ix) .flex() .flex_row() .size_full() .px_2() .py_1() .gap_2() .hover(|style| style.bg(cx.theme().colors().element_hover)) .when(can_navigate, |el| { el.cursor(CursorStyle::PointingHand).on_click({ let notification = notification.clone(); cx.listener(move |this, _, window, cx| { this.did_click_notification(¬ification, window, cx) }) }) }) .children(actor.map(|actor| { img(actor.avatar_uri.clone()) .flex_none() .w_8() .h_8() .rounded_full() })) .child( v_flex() .gap_1() .size_full() .overflow_hidden() .child(Label::new(text.clone())) .child( h_flex() .child( div() .id("notification_timestamp") .hover(|style| { style .bg(cx.theme().colors().element_selected) .rounded_md() }) .child(Label::new(relative_timestamp).color(Color::Muted)) .tooltip(move |_, cx| { Tooltip::simple(absolute_timestamp.clone(), cx) }), ) .children(if let Some(is_accepted) = response { Some(div().flex().flex_grow().justify_end().child(Label::new( if is_accepted { "You accepted" } else { "You declined" }, ))) } else if needs_response { Some( h_flex() .flex_grow() .justify_end() .child(Button::new("decline", "Decline").on_click({ let notification = notification.clone(); let entity = cx.entity().clone(); move |_, _, cx| { entity.update(cx, |this, cx| { this.respond_to_notification( notification.clone(), false, cx, ) }); } })) .child(Button::new("accept", "Accept").on_click({ let notification = notification.clone(); let entity = cx.entity().clone(); move |_, _, cx| { entity.update(cx, |this, cx| { this.respond_to_notification( notification.clone(), true, cx, ) }); } })), ) } else { None }), ), ) .into_any(), ) } fn present_notification( &self, entry: &NotificationEntry, cx: &App, ) -> Option<NotificationPresenter> { let user_store = self.user_store.read(cx); let channel_store = self.channel_store.read(cx); match entry.notification { Notification::ContactRequest { sender_id } => { let requester = user_store.get_cached_user(sender_id)?; Some(NotificationPresenter { icon: "icons/plus.svg", text: format!("{} wants to add you as a contact", requester.github_login), needs_response: user_store.has_incoming_contact_request(requester.id), actor: Some(requester), can_navigate: false, }) } Notification::ContactRequestAccepted { responder_id } => { let responder = user_store.get_cached_user(responder_id)?; Some(NotificationPresenter { icon: "icons/plus.svg", text: format!("{} accepted your contact invite", responder.github_login), needs_response: false, actor: Some(responder), can_navigate: false, }) } Notification::ChannelInvitation { ref channel_name, channel_id, inviter_id, } => { let inviter = user_store.get_cached_user(inviter_id)?; Some(NotificationPresenter { icon: "icons/hash.svg", text: format!( "{} invited you to join the #{channel_name} channel", inviter.github_login ), needs_response: channel_store.has_channel_invitation(ChannelId(channel_id)), actor: Some(inviter), can_navigate: false, }) } Notification::ChannelMessageMention { sender_id, channel_id, message_id, } => { let sender = user_store.get_cached_user(sender_id)?; let channel = channel_store.channel_for_id(ChannelId(channel_id))?; let message = self .notification_store .read(cx) .channel_message_for_id(message_id)?; Some(NotificationPresenter { icon: "icons/conversations.svg", text: format!( "{} mentioned you in #{}:\n{}", sender.github_login, channel.name, message.body, ), needs_response: false, actor: Some(sender), can_navigate: true, }) } } } fn did_render_notification( &mut self, notification_id: u64, notification: &Notification, window: &mut Window, cx: &mut Context<Self>, ) { let should_mark_as_read = match notification { Notification::ContactRequestAccepted { .. } => true, Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. } | Notification::ChannelMessageMention { .. } => false, }; if should_mark_as_read { self.mark_as_read_tasks .entry(notification_id) .or_insert_with(|| { let client = self.client.clone(); cx.spawn_in(window, |this, mut cx| async move { cx.background_executor().timer(MARK_AS_READ_DELAY).await; client .request(proto::MarkNotificationRead { notification_id }) .await?; this.update(&mut cx, |this, _| { this.mark_as_read_tasks.remove(¬ification_id); })?; Ok(()) }) }); } } fn did_click_notification( &mut self, notification: &Notification, window: &mut Window, cx: &mut Context<Self>, ) { if let Notification::ChannelMessageMention { message_id, channel_id, .. } = notification.clone() { if let Some(workspace) = self.workspace.upgrade() { window.defer(cx, move |window, cx| { workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) { panel.update(cx, |panel, cx| { panel .select_channel(ChannelId(channel_id), Some(message_id), cx) .detach_and_log_err(cx); }); } }); }); } } } fn is_showing_notification(&self, notification: &Notification, cx: &mut Context<Self>) -> bool { if !self.active { return false; } if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification { if let Some(workspace) = self.workspace.upgrade() { return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) { let panel = panel.read(cx); panel.is_scrolled_to_bottom() && panelLines 1–500 of 823. Load more
use crate::{chat_panel::ChatPanel, NotificationPanelSettings}; use anyhow::Result; use channel::ChannelStore; use client::{ChannelId, Client, Notification, User, UserStore}; use collections::HashMap; use db::kvp::KEY_VALUE_STORE; use futures::StreamExt; use gpui::{ actions, div, img, list, px, AnyElement, App, AsyncWindowContext, Context, CursorStyle, DismissEvent, Element, Entity, EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment, ListScrollEvent, ListState, ParentElement, Render, StatefulInteractiveElement, Styled, Task, WeakEntity, Window, }; use notifications::{NotificationEntry, NotificationEvent, NotificationStore}; use project::Fs; use rpc::proto; use serde::{Deserialize, Serialize}; use settings::{Settings, SettingsStore}; use std::{sync::Arc, time::Duration}; use time::{OffsetDateTime, UtcOffset}; use ui::{ h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip, }; use util::{ResultExt, TryFutureExt}; use workspace::notifications::NotificationId; use workspace::{ dock::{DockPosition, Panel, PanelEvent}, Workspace, }; const LOADING_THRESHOLD: usize = 30; const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1); const TOAST_DURATION: Duration = Duration::from_secs(5); const NOTIFICATION_PANEL_KEY: &str = "NotificationPanel"; pub struct NotificationPanel { client: Arc<Client>, user_store: Entity<UserStore>, channel_store: Entity<ChannelStore>, notification_store: Entity<NotificationStore>, fs: Arc<dyn Fs>, width: Option<Pixels>, active: bool, notification_list: ListState, pending_serialization: Task<Option<()>>, subscriptions: Vec<gpui::Subscription>, workspace: WeakEntity<Workspace>, current_notification_toast: Option<(u64, Task<()>)>, local_timezone: UtcOffset, focus_handle: FocusHandle, mark_as_read_tasks: HashMap<u64, Task<Result<()>>>, unseen_notifications: Vec<NotificationEntry>, } #[derive(Serialize, Deserialize)] struct SerializedNotificationPanel { width: Option<Pixels>, } #[derive(Debug)] pub enum Event { DockPositionChanged, Focus, Dismissed, } pub struct NotificationPresenter { pub actor: Option<Arc<client::User>>, pub text: String, pub icon: &'static str, pub needs_response: bool, pub can_navigate: bool, } actions!(notification_panel, [ToggleFocus]); pub fn init(cx: &mut App) { cx.observe_new(|workspace: &mut Workspace, _, _| { workspace.register_action(|workspace, _: &ToggleFocus, window, cx| { workspace.toggle_panel_focus::<NotificationPanel>(window, cx); }); }) .detach(); } impl NotificationPanel { pub fn new( workspace: &mut Workspace, window: &mut Window, cx: &mut Context<Workspace>, ) -> Entity<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 workspace_handle = workspace.weak_handle(); cx.new(|cx| { let mut status = client.status(); cx.spawn_in(window, |this, mut cx| async move { while (status.next().await).is_some() { if this .update(&mut cx, |_: &mut Self, cx| { cx.notify(); }) .is_err() { break; } } }) .detach(); let entity = cx.entity().downgrade(); let notification_list = ListState::new(0, ListAlignment::Top, px(1000.), move |ix, window, cx| { entity .upgrade() .and_then(|entity| { entity.update(cx, |this, cx| this.render_notification(ix, window, cx)) }) .unwrap_or_else(|| div().into_any()) }); notification_list.set_scroll_handler(cx.listener( |this, event: &ListScrollEvent, _, cx| { if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD { if let Some(task) = this .notification_store .update(cx, |store, cx| store.load_more_notifications(false, cx)) { task.detach(); } } }, )); let local_offset = chrono::Local::now().offset().local_minus_utc(); let mut this = Self { fs, client, user_store, local_timezone: UtcOffset::from_whole_seconds(local_offset).unwrap(), channel_store: ChannelStore::global(cx), notification_store: NotificationStore::global(cx), notification_list, pending_serialization: Task::ready(None), workspace: workspace_handle, focus_handle: cx.focus_handle(), current_notification_toast: None, subscriptions: Vec::new(), active: false, mark_as_read_tasks: HashMap::default(), width: None, unseen_notifications: Vec::new(), }; let mut old_dock_position = this.position(window, cx); this.subscriptions.extend([ cx.observe(&this.notification_store, |_, _, cx| cx.notify()), cx.subscribe_in( &this.notification_store, window, Self::on_notification_event, ), cx.observe_global_in::<SettingsStore>( window, move |this: &mut Self, window, cx| { let new_dock_position = this.position(window, 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: WeakEntity<Workspace>, cx: AsyncWindowContext, ) -> Task<Result<Entity<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_in(&mut cx, |workspace, window, cx| { let panel = Self::new(workspace, window, cx); if let Some(serialized_panel) = serialized_panel { panel.update(cx, |panel, cx| { panel.width = serialized_panel.width.map(|w| w.round()); cx.notify(); }); } panel }) }) } fn serialize(&mut self, cx: &mut Context<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, window: &mut Window, cx: &mut Context<Self>, ) -> Option<AnyElement> { let entry = self.notification_store.read(cx).notification_at(ix)?; let notification_id = entry.id; let now = OffsetDateTime::now_utc(); let timestamp = entry.timestamp; let NotificationPresenter { actor, text, needs_response, can_navigate, .. } = self.present_notification(entry, cx)?; let response = entry.response; let notification = entry.notification.clone(); if self.active && !entry.is_read { self.did_render_notification(notification_id, ¬ification, window, cx); } let relative_timestamp = time_format::format_localized_timestamp( timestamp, now, self.local_timezone, time_format::TimestampFormat::Relative, ); let absolute_timestamp = time_format::format_localized_timestamp( timestamp, now, self.local_timezone, time_format::TimestampFormat::Absolute, ); Some( div() .id(ix) .flex() .flex_row() .size_full() .px_2() .py_1() .gap_2() .hover(|style| style.bg(cx.theme().colors().element_hover)) .when(can_navigate, |el| { el.cursor(CursorStyle::PointingHand).on_click({ let notification = notification.clone(); cx.listener(move |this, _, window, cx| { this.did_click_notification(¬ification, window, cx) }) }) }) .children(actor.map(|actor| { img(actor.avatar_uri.clone()) .flex_none() .w_8() .h_8() .rounded_full() })) .child( v_flex() .gap_1() .size_full() .overflow_hidden() .child(Label::new(text.clone())) .child( h_flex() .child( div() .id("notification_timestamp") .hover(|style| { style .bg(cx.theme().colors().element_selected) .rounded_md() }) .child(Label::new(relative_timestamp).color(Color::Muted)) .tooltip(move |_, cx| { Tooltip::simple(absolute_timestamp.clone(), cx) }), ) .children(if let Some(is_accepted) = response { Some(div().flex().flex_grow().justify_end().child(Label::new( if is_accepted { "You accepted" } else { "You declined" }, ))) } else if needs_response { Some( h_flex() .flex_grow() .justify_end() .child(Button::new("decline", "Decline").on_click({ let notification = notification.clone(); let entity = cx.entity().clone(); move |_, _, cx| { entity.update(cx, |this, cx| { this.respond_to_notification( notification.clone(), false, cx, ) }); } })) .child(Button::new("accept", "Accept").on_click({ let notification = notification.clone(); let entity = cx.entity().clone(); move |_, _, cx| { entity.update(cx, |this, cx| { this.respond_to_notification( notification.clone(), true, cx, ) }); } })), ) } else { None }), ), ) .into_any(), ) } fn present_notification( &self, entry: &NotificationEntry, cx: &App, ) -> Option<NotificationPresenter> { let user_store = self.user_store.read(cx); let channel_store = self.channel_store.read(cx); match entry.notification { Notification::ContactRequest { sender_id } => { let requester = user_store.get_cached_user(sender_id)?; Some(NotificationPresenter { icon: "icons/plus.svg", text: format!("{} wants to add you as a contact", requester.github_login), needs_response: user_store.has_incoming_contact_request(requester.id), actor: Some(requester), can_navigate: false, }) } Notification::ContactRequestAccepted { responder_id } => { let responder = user_store.get_cached_user(responder_id)?; Some(NotificationPresenter { icon: "icons/plus.svg", text: format!("{} accepted your contact invite", responder.github_login), needs_response: false, actor: Some(responder), can_navigate: false, }) } Notification::ChannelInvitation { ref channel_name, channel_id, inviter_id, } => { let inviter = user_store.get_cached_user(inviter_id)?; Some(NotificationPresenter { icon: "icons/hash.svg", text: format!( "{} invited you to join the #{channel_name} channel", inviter.github_login ), needs_response: channel_store.has_channel_invitation(ChannelId(channel_id)), actor: Some(inviter), can_navigate: false, }) } Notification::ChannelMessageMention { sender_id, channel_id, message_id, } => { let sender = user_store.get_cached_user(sender_id)?; let channel = channel_store.channel_for_id(ChannelId(channel_id))?; let message = self .notification_store .read(cx) .channel_message_for_id(message_id)?; Some(NotificationPresenter { icon: "icons/conversations.svg", text: format!( "{} mentioned you in #{}:\n{}", sender.github_login, channel.name, message.body, ), needs_response: false, actor: Some(sender), can_navigate: true, }) } } } fn did_render_notification( &mut self, notification_id: u64, notification: &Notification, window: &mut Window, cx: &mut Context<Self>, ) { let should_mark_as_read = match notification { Notification::ContactRequestAccepted { .. } => true, Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. } | Notification::ChannelMessageMention { .. } => false, }; if should_mark_as_read { self.mark_as_read_tasks .entry(notification_id) .or_insert_with(|| { let client = self.client.clone(); cx.spawn_in(window, |this, mut cx| async move { cx.background_executor().timer(MARK_AS_READ_DELAY).await; client .request(proto::MarkNotificationRead { notification_id }) .await?; this.update(&mut cx, |this, _| { this.mark_as_read_tasks.remove(¬ification_id); })?; Ok(()) }) }); } } fn did_click_notification( &mut self, notification: &Notification, window: &mut Window, cx: &mut Context<Self>, ) { if let Notification::ChannelMessageMention { message_id, channel_id, .. } = notification.clone() { if let Some(workspace) = self.workspace.upgrade() { window.defer(cx, move |window, cx| { workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) { panel.update(cx, |panel, cx| { panel .select_channel(ChannelId(channel_id), Some(message_id), cx) .detach_and_log_err(cx); }); } }); }); } } } fn is_showing_notification(&self, notification: &Notification, cx: &mut Context<Self>) -> bool { if !self.active { return false; } if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification { if let Some(workspace) = self.workspace.upgrade() { return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) { let panel = panel.read(cx); panel.is_scrolled_to_bottom() && panel