use crate::{chat_panel::ChatPanel, render_avatar, NotificationPanelSettings}; use anyhow::Result; use channel::ChannelStore; use client::{Client, Notification, User, UserStore}; use collections::HashMap; 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 rpc::proto; use serde::{Deserialize, Serialize}; use settings::SettingsStore; use std::{sync::Arc, time::Duration}; use theme::{ui, Theme}; use time::{OffsetDateTime, UtcOffset}; use util::{ResultExt, TryFutureExt}; use workspace::{ dock::{DockPosition, Panel}, 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: &'static str = "NotificationPanel"; pub struct NotificationPanel { client: Arc, user_store: ModelHandle, channel_store: ModelHandle, notification_store: ModelHandle, fs: Arc, width: Option, active: bool, notification_list: ListState, pending_serialization: Task>, subscriptions: Vec, workspace: WeakViewHandle, current_notification_toast: Option<(u64, Task<()>)>, local_timezone: UtcOffset, has_focus: bool, mark_as_read_tasks: HashMap>>, } #[derive(Serialize, Deserialize)] struct SerializedNotificationPanel { width: Option, } #[derive(Debug)] pub enum Event { DockPositionChanged, Focus, Dismissed, } pub struct NotificationPresenter { pub actor: Option>, pub text: String, pub icon: &'static str, pub needs_response: bool, pub can_navigate: bool, } actions!(notification_panel, [ToggleFocus]); pub fn init(_cx: &mut AppContext) {} impl NotificationPanel { pub fn new(workspace: &mut Workspace, cx: &mut ViewContext) -> ViewHandle { 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.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 notification_list = ListState::::new(0, Orientation::Top, 1000., move |this, ix, cx| { this.render_notification(ix, cx) .unwrap_or_else(|| Empty::new().into_any()) }); notification_list.set_scroll_handler(|visible_range, count, this, cx| { if count.saturating_sub(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 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), workspace: workspace_handle, has_focus: false, current_notification_toast: None, subscriptions: Vec::new(), active: false, mark_as_read_tasks: HashMap::default(), width: None, }; let mut old_dock_position = this.position(cx); this.subscriptions.extend([ cx.observe(&this.notification_store, |_, _, cx| cx.notify()), cx.subscribe(&this.notification_store, Self::on_notification_event), cx.observe_global::(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, cx: AsyncAppContext, ) -> Task>> { 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::(&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) { 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, ) -> Option> { 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 theme = theme::current(cx); let style = &theme.notification_panel; let response = entry.response; let notification = entry.notification.clone(); let message_style = if entry.is_read { style.read_text.clone() } else { style.unread_text.clone() }; if self.active && !entry.is_read { self.did_render_notification(notification_id, ¬ification, cx); } enum Decline {} enum Accept {} Some( MouseEventHandler::new::(ix, cx, |_, cx| { let container = message_style.container; Flex::row() .with_children(actor.map(|actor| { render_avatar(actor.avatar.clone(), &style.avatar, style.avatar_container) })) .with_child( Flex::column() .with_child(Text::new(text, message_style.text.clone())) .with_child( Flex::row() .with_child( Label::new( format_timestamp(timestamp, now, self.local_timezone), style.timestamp.text.clone(), ) .contained() .with_style(style.timestamp.container), ) .with_children(if let Some(is_accepted) = response { Some( Label::new( if is_accepted { "You accepted" } else { "You declined" }, style.read_text.text.clone(), ) .flex_float() .into_any(), ) } else if needs_response { Some( Flex::row() .with_children([ MouseEventHandler::new::( ix, cx, |state, _| { let button = style.button.style_for(state); Label::new( "Decline", button.text.clone(), ) .contained() .with_style(button.container) }, ) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, { let notification = notification.clone(); move |_, view, cx| { view.respond_to_notification( notification.clone(), false, cx, ); } }), MouseEventHandler::new::( ix, cx, |state, _| { let button = style.button.style_for(state); Label::new( "Accept", button.text.clone(), ) .contained() .with_style(button.container) }, ) .with_cursor_style(CursorStyle::PointingHand) .on_click(MouseButton::Left, { let notification = notification.clone(); move |_, view, cx| { view.respond_to_notification( notification.clone(), true, cx, ); } }), ]) .flex_float() .into_any(), ) } else { None }), ) .flex(1.0, true), ) .contained() .with_style(container) .into_any() }) .with_cursor_style(if can_navigate { CursorStyle::PointingHand } else { CursorStyle::default() }) .on_click(MouseButton::Left, { let notification = notification.clone(); move |_, this, cx| this.did_click_notification(¬ification, cx) }) .into_any(), ) } fn present_notification( &self, entry: &NotificationEntry, cx: &AppContext, ) -> Option { 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(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(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, cx: &mut ViewContext, ) { 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(|this, mut cx| async move { cx.background().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, cx: &mut ViewContext) { if let Notification::ChannelMessageMention { message_id, channel_id, .. } = notification.clone() { if let Some(workspace) = self.workspace.upgrade(cx) { cx.app_context().defer(move |cx| { workspace.update(cx, |workspace, cx| { if let Some(panel) = workspace.focus_panel::(cx) { panel.update(cx, |panel, cx| { panel .select_channel(channel_id, Some(message_id), cx) .detach_and_log_err(cx); }); } }); }); } } } fn is_showing_notification(&self, notification: &Notification, cx: &AppContext) -> bool { if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification { if let Some(workspace) = self.workspace.upgrade(cx) { return workspace .read_with(cx, |workspace, cx| { if let Some(panel) = workspace.panel::(cx) { return panel.read_with(cx, |panel, cx| { panel.is_scrolled_to_bottom() && panel.active_chat().map_or(false, |chat| { chat.read(cx).channel_id == *channel_id }) }); } false }) .unwrap_or_default(); } } false } fn render_sign_in_prompt( &self, theme: &Arc, cx: &mut ViewContext, ) -> AnyElement { enum SignInPromptLabel {} MouseEventHandler::new::(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 render_empty_state( &self, theme: &Arc, _cx: &mut ViewContext, ) -> AnyElement { Label::new( "You have no notifications".to_string(), theme.chat_panel.sign_in_prompt.default.clone(), ) .aligned() .into_any() } fn on_notification_event( &mut self, _: ModelHandle, event: &NotificationEvent, cx: &mut ViewContext, ) { match event { NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx), NotificationEvent::NotificationRemoved { entry } | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx), NotificationEvent::NotificationsUpdated { old_range, new_count, } => { self.notification_list.splice(old_range.clone(), *new_count); cx.notify(); } } } fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext) { if self.is_showing_notification(&entry.notification, cx) { return; } let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx) else { return; }; let notification_id = entry.id; self.current_notification_toast = Some(( notification_id, cx.spawn(|this, mut cx| async move { cx.background().timer(TOAST_DURATION).await; this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx)) .ok(); }), )); self.workspace .update(cx, |workspace, cx| { workspace.dismiss_notification::(0, cx); workspace.show_notification(0, cx, |cx| { let workspace = cx.weak_handle(); cx.add_view(|_| NotificationToast { notification_id, actor, text, workspace, }) }) }) .ok(); } fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext) { if let Some((current_id, _)) = &self.current_notification_toast { if *current_id == notification_id { self.current_notification_toast.take(); self.workspace .update(cx, |workspace, cx| { workspace.dismiss_notification::(0, cx) }) .ok(); } } } fn respond_to_notification( &mut self, notification: Notification, response: bool, cx: &mut ViewContext, ) { self.notification_store.update(cx, |store, cx| { store.respond_to_notification(notification, response, cx); }); } } impl Entity for NotificationPanel { type Event = Event; } impl View for NotificationPanel { fn ui_name() -> &'static str { "NotificationPanel" } fn render(&mut self, cx: &mut ViewContext) -> AnyElement { let theme = theme::current(cx); let style = &theme.notification_panel; let element = if self.client.user_id().is_none() { self.render_sign_in_prompt(&theme, cx) } else if self.notification_list.item_count() == 0 { self.render_empty_state(&theme, cx) } else { Flex::column() .with_child( Flex::row() .with_child(Label::new("Notifications", style.title.text.clone())) .with_child(ui::svg(&style.title_icon).flex_float()) .align_children_center() .contained() .with_style(style.title.container) .constrained() .with_height(style.title_height), ) .with_child( List::new(self.notification_list.clone()) .contained() .with_style(style.list) .flex(1., true), ) .into_any() }; element .contained() .with_style(style.container) .constrained() .with_min_width(150.) .into_any() } fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext) { self.has_focus = true; } fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext) { self.has_focus = false; } } impl Panel for NotificationPanel { fn position(&self, cx: &gpui::WindowContext) -> DockPosition { settings::get::(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) { settings::update_settings_file::( self.fs.clone(), cx, move |settings| settings.dock = Some(position), ); } fn size(&self, cx: &gpui::WindowContext) -> f32 { self.width .unwrap_or_else(|| settings::get::(cx).default_width) } fn set_size(&mut self, size: Option, cx: &mut ViewContext) { self.width = size; self.serialize(cx); cx.notify(); } fn set_active(&mut self, active: bool, cx: &mut ViewContext) { self.active = active; if self.notification_store.read(cx).notification_count() == 0 { cx.emit(Event::Dismissed); } } fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> { (settings::get::(cx).button && self.notification_store.read(cx).notification_count() > 0) .then(|| "icons/bell.svg") } fn icon_tooltip(&self) -> (String, Option>) { ( "Notification Panel".to_string(), Some(Box::new(ToggleFocus)), ) } fn icon_label(&self, cx: &WindowContext) -> Option { 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) } } pub struct NotificationToast { notification_id: u64, actor: Option>, text: String, workspace: WeakViewHandle, } pub enum ToastEvent { Dismiss, } impl NotificationToast { fn focus_notification_panel(&self, cx: &mut AppContext) { let workspace = self.workspace.clone(); let notification_id = self.notification_id; cx.defer(move |cx| { workspace .update(cx, |workspace, cx| { if let Some(panel) = workspace.focus_panel::(cx) { panel.update(cx, |panel, cx| { let store = panel.notification_store.read(cx); if let Some(entry) = store.notification_for_id(notification_id) { panel.did_click_notification(&entry.clone().notification, 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) -> AnyElement { let user = self.actor.clone(); let theme = theme::current(cx).clone(); let theme = &theme.contact_notification; MouseEventHandler::new::(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::(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: &::Event) -> bool { matches!(event, ToastEvent::Dismiss) } } 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(); if date == today { let difference = now - timestamp; if difference >= Duration::from_secs(3600) { format!("{}h", difference.whole_seconds() / 3600) } else if difference >= Duration::from_secs(60) { format!("{}m", difference.whole_seconds() / 60) } else { "just now".to_string() } } else if date.next_day() == Some(today) { format!("yesterday") } else { format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year()) } }