diff --git a/crates/collab_ui2/src/notification_panel.rs b/crates/collab_ui2/src/notification_panel.rs index 4b5a99a0ed..2e5058d791 100644 --- a/crates/collab_ui2/src/notification_panel.rs +++ b/crates/collab_ui2/src/notification_panel.rs @@ -1,884 +1,711 @@ -// 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()) -// } -// } +use crate::{chat_panel::ChatPanel, 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, div, img, serde_json, svg, AnyElement, AnyView, AppContext, AsyncAppContext, Context, + CursorStyle, Div, Entity, EventEmitter, Flatten, FocusHandle, FocusableView, + InteractiveElement, IntoElement, ListAlignment, ListState, Model, MouseButton, ParentElement, + Render, Stateful, StatefulInteractiveElement, Task, View, ViewContext, VisualContext, WeakView, + WindowContext, +}; +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_stack, v_stack, Avatar, Button, Clickable, Icon, IconButton, IconElement, Label, List}; +use util::{ResultExt, TryFutureExt}; +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: &'static str = "NotificationPanel"; + +pub struct NotificationPanel { + client: Arc, + user_store: Model, + channel_store: Model, + notification_store: Model, + fs: Arc, + width: Option, + active: bool, + notification_list: ListState, + pending_serialization: Task>, + subscriptions: Vec, + workspace: WeakView, + current_notification_toast: Option<(u64, Task<()>)>, + local_timezone: UtcOffset, + focus_handle: FocusHandle, + 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) -> View { + 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.build_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, ListAlignment::Top, 1000., move |this, ix, cx| { + this.render_notification(ix, cx).unwrap_or_else(|| div()) + }); + 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, + focus_handle: cx.focus_handle(), + 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: WeakView, cx: AsyncAppContext) -> Task>> { + cx.spawn(|mut cx| async move { + let serialized_panel = if let Some(panel) = cx + .background_executor() + .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) }) + .await + .log_err() + .flatten() + { + Some(serde_json::from_str::(&panel)?) + } else { + None + }; + + Flatten::flatten(cx.update(|cx| { + workspace.update(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 response = entry.response; + let notification = entry.notification.clone(); + + if self.active && !entry.is_read { + self.did_render_notification(notification_id, ¬ification, cx); + } + + Some( + h_stack() + .children(actor.map(|actor| Avatar::from(actor.avatar.clone()))) + .child( + v_stack().child(Label::new(text)).child( + h_stack() + .child(Label::from(format_timestamp( + timestamp, + now, + self.local_timezone, + ))) + .children(if let Some(is_accepted) = response { + Some(Label::new(if is_accepted { + "You accepted" + } else { + "You declined" + })) + } else if needs_response { + Some( + h_stack() + .child(Button::new("decline", "Decline").on_click({ + let notification = notification.clone(); + let view = cx.view().clone(); + move |_, cx| { + view.update(cx, |this, cx| { + this.respond_to_notification( + notification.clone(), + false, + cx, + ) + }); + } + })) + .child(Button::new("accept", "Accept").on_click({ + let notification = notification.clone(); + let view = cx.view().clone(); + move |_, cx| { + view.update(cx, |this, cx| { + this.respond_to_notification( + notification.clone(), + true, + cx, + ) + }); + } + })), + ) + } else { + None + }), + ), + ), + ) + } + + 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.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() { + 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, cx: &mut ViewContext) -> AnyElement { + Button::new( + "sign_in_prompt_button", + "Sign in to view your notifications", + ) + .on_click({ + let client = self.client.clone(); + |_, cx| { + cx.spawn(|cx| async move { + client.authenticate_and_connect(true, &cx).log_err().await; + }) + .detach() + } + }) + .into_any_element() + } + + fn render_empty_state(&self) -> AnyElement { + Label::new("You have no notifications").into_any_element() + } + + fn on_notification_event( + &mut self, + _: Model, + 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 Render for NotificationPanel { + type Element = AnyElement; + + fn render(&mut self, cx: &mut ViewContext) -> AnyElement { + if self.client.user_id().is_none() { + self.render_sign_in_prompt(cx) + } else if self.notification_list.item_count() == 0 { + self.render_empty_state() + } else { + v_stack() + .child( + h_stack() + .child(Label::new("Notifications")) + .child(IconElement::new(Icon::Envelope)), + ) + // todo!() + // .child( + // List::new() + // ) + .into_any_element() + + // 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() + } + } +} + +impl FocusableView for NotificationPanel { + fn focus_handle(&self, _: &AppContext) -> FocusHandle { + self.focus_handle + } +} + +impl EventEmitter for NotificationPanel {} +impl EventEmitter for NotificationPanel {} + +impl Panel for NotificationPanel { + fn persistent_name() -> &'static str { + "NotificationPanel" + } + + fn position(&self, cx: &gpui::WindowContext) -> DockPosition { + NotificationPanelSettings::get_global(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(|| NotificationPanelSettings::get_global(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(&self, cx: &gpui::WindowContext) -> Option { + (NotificationPanelSettings::get_global(cx).button + && self.notification_store.read(cx).notification_count() > 0) + .then(|| Icon::Bell) + } + + 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 toggle_action(&self) -> Box { + Box::new(ToggleFocus) + } +} + +pub struct NotificationToast { + notification_id: u64, + actor: Option>, + text: String, + workspace: WeakView, +} + +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 Render for NotificationToast { + type Element = Stateful
; + + fn render(&mut self, cx: &mut ViewContext) -> Self::Element { + let user = self.actor.clone(); + + h_stack() + .id("notification_panel_toast") + .children(user.and_then(|user| Some(img(user.avatar.clone()?)))) + .child(Label::new(self.text.clone())) + .child( + IconButton::new("close", Icon::Close) + .on_click(|_, cx| cx.emit(ToastEvent::Dismiss)), + ) + .on_click({ + let this = cx.view().clone(); + |_, cx| { + this.update(cx, |this, cx| this.focus_notification_panel(cx)); + cx.emit(ToastEvent::Dismiss); + } + }) + } +} + +impl EventEmitter for NotificationToast {} + +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()) + } +}