1use crate::{chat_panel::ChatPanel, NotificationPanelSettings};
2use anyhow::Result;
3use channel::ChannelStore;
4use client::{ChannelId, Client, Notification, User, UserStore};
5use collections::HashMap;
6use db::kvp::KEY_VALUE_STORE;
7use futures::StreamExt;
8use gpui::{
9 actions, div, img, list, px, AnyElement, AppContext, AsyncWindowContext, CursorStyle,
10 DismissEvent, Element, EventEmitter, FocusHandle, FocusableView, InteractiveElement,
11 IntoElement, ListAlignment, ListScrollEvent, ListState, Model, ParentElement, Render,
12 StatefulInteractiveElement, Styled, Task, View, ViewContext, VisualContext, WeakView,
13 WindowContext,
14};
15use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
16use project::Fs;
17use rpc::proto;
18use serde::{Deserialize, Serialize};
19use settings::{Settings, SettingsStore};
20use std::{sync::Arc, time::Duration};
21use time::{OffsetDateTime, UtcOffset};
22use ui::{
23 h_flex, prelude::*, v_flex, Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip,
24};
25use util::{ResultExt, TryFutureExt};
26use workspace::notifications::NotificationId;
27use workspace::{
28 dock::{DockPosition, Panel, PanelEvent},
29 Workspace,
30};
31
32const LOADING_THRESHOLD: usize = 30;
33const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);
34const TOAST_DURATION: Duration = Duration::from_secs(5);
35const NOTIFICATION_PANEL_KEY: &str = "NotificationPanel";
36
37pub struct NotificationPanel {
38 client: Arc<Client>,
39 user_store: Model<UserStore>,
40 channel_store: Model<ChannelStore>,
41 notification_store: Model<NotificationStore>,
42 fs: Arc<dyn Fs>,
43 width: Option<Pixels>,
44 active: bool,
45 notification_list: ListState,
46 pending_serialization: Task<Option<()>>,
47 subscriptions: Vec<gpui::Subscription>,
48 workspace: WeakView<Workspace>,
49 current_notification_toast: Option<(u64, Task<()>)>,
50 local_timezone: UtcOffset,
51 focus_handle: FocusHandle,
52 mark_as_read_tasks: HashMap<u64, Task<Result<()>>>,
53 unseen_notifications: Vec<NotificationEntry>,
54}
55
56#[derive(Serialize, Deserialize)]
57struct SerializedNotificationPanel {
58 width: Option<Pixels>,
59}
60
61#[derive(Debug)]
62pub enum Event {
63 DockPositionChanged,
64 Focus,
65 Dismissed,
66}
67
68pub struct NotificationPresenter {
69 pub actor: Option<Arc<client::User>>,
70 pub text: String,
71 pub icon: &'static str,
72 pub needs_response: bool,
73 pub can_navigate: bool,
74}
75
76actions!(notification_panel, [ToggleFocus]);
77
78pub fn init(cx: &mut AppContext) {
79 cx.observe_new_views(|workspace: &mut Workspace, _| {
80 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
81 workspace.toggle_panel_focus::<NotificationPanel>(cx);
82 });
83 })
84 .detach();
85}
86
87impl NotificationPanel {
88 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
89 let fs = workspace.app_state().fs.clone();
90 let client = workspace.app_state().client.clone();
91 let user_store = workspace.app_state().user_store.clone();
92 let workspace_handle = workspace.weak_handle();
93
94 cx.new_view(|cx: &mut ViewContext<Self>| {
95 let mut status = client.status();
96 cx.spawn(|this, mut cx| async move {
97 while (status.next().await).is_some() {
98 if this
99 .update(&mut cx, |_, cx| {
100 cx.notify();
101 })
102 .is_err()
103 {
104 break;
105 }
106 }
107 })
108 .detach();
109
110 let view = cx.view().downgrade();
111 let notification_list =
112 ListState::new(0, ListAlignment::Top, px(1000.), move |ix, cx| {
113 view.upgrade()
114 .and_then(|view| {
115 view.update(cx, |this, cx| this.render_notification(ix, cx))
116 })
117 .unwrap_or_else(|| div().into_any())
118 });
119 notification_list.set_scroll_handler(cx.listener(
120 |this, event: &ListScrollEvent, cx| {
121 if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD {
122 if let Some(task) = this
123 .notification_store
124 .update(cx, |store, cx| store.load_more_notifications(false, cx))
125 {
126 task.detach();
127 }
128 }
129 },
130 ));
131
132 let local_offset = chrono::Local::now().offset().local_minus_utc();
133 let mut this = Self {
134 fs,
135 client,
136 user_store,
137 local_timezone: UtcOffset::from_whole_seconds(local_offset).unwrap(),
138 channel_store: ChannelStore::global(cx),
139 notification_store: NotificationStore::global(cx),
140 notification_list,
141 pending_serialization: Task::ready(None),
142 workspace: workspace_handle,
143 focus_handle: cx.focus_handle(),
144 current_notification_toast: None,
145 subscriptions: Vec::new(),
146 active: false,
147 mark_as_read_tasks: HashMap::default(),
148 width: None,
149 unseen_notifications: Vec::new(),
150 };
151
152 let mut old_dock_position = this.position(cx);
153 this.subscriptions.extend([
154 cx.observe(&this.notification_store, |_, _, cx| cx.notify()),
155 cx.subscribe(&this.notification_store, Self::on_notification_event),
156 cx.observe_global::<SettingsStore>(move |this: &mut Self, cx| {
157 let new_dock_position = this.position(cx);
158 if new_dock_position != old_dock_position {
159 old_dock_position = new_dock_position;
160 cx.emit(Event::DockPositionChanged);
161 }
162 cx.notify();
163 }),
164 ]);
165 this
166 })
167 }
168
169 pub fn load(
170 workspace: WeakView<Workspace>,
171 cx: AsyncWindowContext,
172 ) -> Task<Result<View<Self>>> {
173 cx.spawn(|mut cx| async move {
174 let serialized_panel = if let Some(panel) = cx
175 .background_executor()
176 .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
177 .await
178 .log_err()
179 .flatten()
180 {
181 Some(serde_json::from_str::<SerializedNotificationPanel>(&panel)?)
182 } else {
183 None
184 };
185
186 workspace.update(&mut cx, |workspace, cx| {
187 let panel = Self::new(workspace, cx);
188 if let Some(serialized_panel) = serialized_panel {
189 panel.update(cx, |panel, cx| {
190 panel.width = serialized_panel.width.map(|w| w.round());
191 cx.notify();
192 });
193 }
194 panel
195 })
196 })
197 }
198
199 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
200 let width = self.width;
201 self.pending_serialization = cx.background_executor().spawn(
202 async move {
203 KEY_VALUE_STORE
204 .write_kvp(
205 NOTIFICATION_PANEL_KEY.into(),
206 serde_json::to_string(&SerializedNotificationPanel { width })?,
207 )
208 .await?;
209 anyhow::Ok(())
210 }
211 .log_err(),
212 );
213 }
214
215 fn render_notification(&mut self, ix: usize, cx: &mut ViewContext<Self>) -> Option<AnyElement> {
216 let entry = self.notification_store.read(cx).notification_at(ix)?;
217 let notification_id = entry.id;
218 let now = OffsetDateTime::now_utc();
219 let timestamp = entry.timestamp;
220 let NotificationPresenter {
221 actor,
222 text,
223 needs_response,
224 can_navigate,
225 ..
226 } = self.present_notification(entry, cx)?;
227
228 let response = entry.response;
229 let notification = entry.notification.clone();
230
231 if self.active && !entry.is_read {
232 self.did_render_notification(notification_id, ¬ification, cx);
233 }
234
235 let relative_timestamp = time_format::format_localized_timestamp(
236 timestamp,
237 now,
238 self.local_timezone,
239 time_format::TimestampFormat::Relative,
240 );
241
242 let absolute_timestamp = time_format::format_localized_timestamp(
243 timestamp,
244 now,
245 self.local_timezone,
246 time_format::TimestampFormat::Absolute,
247 );
248
249 Some(
250 div()
251 .id(ix)
252 .flex()
253 .flex_row()
254 .size_full()
255 .px_2()
256 .py_1()
257 .gap_2()
258 .hover(|style| style.bg(cx.theme().colors().element_hover))
259 .when(can_navigate, |el| {
260 el.cursor(CursorStyle::PointingHand).on_click({
261 let notification = notification.clone();
262 cx.listener(move |this, _, cx| {
263 this.did_click_notification(¬ification, cx)
264 })
265 })
266 })
267 .children(actor.map(|actor| {
268 img(actor.avatar_uri.clone())
269 .flex_none()
270 .w_8()
271 .h_8()
272 .rounded_full()
273 }))
274 .child(
275 v_flex()
276 .gap_1()
277 .size_full()
278 .overflow_hidden()
279 .child(Label::new(text.clone()))
280 .child(
281 h_flex()
282 .child(
283 div()
284 .id("notification_timestamp")
285 .hover(|style| {
286 style
287 .bg(cx.theme().colors().element_selected)
288 .rounded_md()
289 })
290 .child(Label::new(relative_timestamp).color(Color::Muted))
291 .tooltip(move |cx| {
292 Tooltip::text(absolute_timestamp.clone(), cx)
293 }),
294 )
295 .children(if let Some(is_accepted) = response {
296 Some(div().flex().flex_grow().justify_end().child(Label::new(
297 if is_accepted {
298 "You accepted"
299 } else {
300 "You declined"
301 },
302 )))
303 } else if needs_response {
304 Some(
305 h_flex()
306 .flex_grow()
307 .justify_end()
308 .child(Button::new("decline", "Decline").on_click({
309 let notification = notification.clone();
310 let view = cx.view().clone();
311 move |_, cx| {
312 view.update(cx, |this, cx| {
313 this.respond_to_notification(
314 notification.clone(),
315 false,
316 cx,
317 )
318 });
319 }
320 }))
321 .child(Button::new("accept", "Accept").on_click({
322 let notification = notification.clone();
323 let view = cx.view().clone();
324 move |_, cx| {
325 view.update(cx, |this, cx| {
326 this.respond_to_notification(
327 notification.clone(),
328 true,
329 cx,
330 )
331 });
332 }
333 })),
334 )
335 } else {
336 None
337 }),
338 ),
339 )
340 .into_any(),
341 )
342 }
343
344 fn present_notification(
345 &self,
346 entry: &NotificationEntry,
347 cx: &AppContext,
348 ) -> Option<NotificationPresenter> {
349 let user_store = self.user_store.read(cx);
350 let channel_store = self.channel_store.read(cx);
351 match entry.notification {
352 Notification::ContactRequest { sender_id } => {
353 let requester = user_store.get_cached_user(sender_id)?;
354 Some(NotificationPresenter {
355 icon: "icons/plus.svg",
356 text: format!("{} wants to add you as a contact", requester.github_login),
357 needs_response: user_store.has_incoming_contact_request(requester.id),
358 actor: Some(requester),
359 can_navigate: false,
360 })
361 }
362 Notification::ContactRequestAccepted { responder_id } => {
363 let responder = user_store.get_cached_user(responder_id)?;
364 Some(NotificationPresenter {
365 icon: "icons/plus.svg",
366 text: format!("{} accepted your contact invite", responder.github_login),
367 needs_response: false,
368 actor: Some(responder),
369 can_navigate: false,
370 })
371 }
372 Notification::ChannelInvitation {
373 ref channel_name,
374 channel_id,
375 inviter_id,
376 } => {
377 let inviter = user_store.get_cached_user(inviter_id)?;
378 Some(NotificationPresenter {
379 icon: "icons/hash.svg",
380 text: format!(
381 "{} invited you to join the #{channel_name} channel",
382 inviter.github_login
383 ),
384 needs_response: channel_store.has_channel_invitation(ChannelId(channel_id)),
385 actor: Some(inviter),
386 can_navigate: false,
387 })
388 }
389 Notification::ChannelMessageMention {
390 sender_id,
391 channel_id,
392 message_id,
393 } => {
394 let sender = user_store.get_cached_user(sender_id)?;
395 let channel = channel_store.channel_for_id(ChannelId(channel_id))?;
396 let message = self
397 .notification_store
398 .read(cx)
399 .channel_message_for_id(message_id)?;
400 Some(NotificationPresenter {
401 icon: "icons/conversations.svg",
402 text: format!(
403 "{} mentioned you in #{}:\n{}",
404 sender.github_login, channel.name, message.body,
405 ),
406 needs_response: false,
407 actor: Some(sender),
408 can_navigate: true,
409 })
410 }
411 }
412 }
413
414 fn did_render_notification(
415 &mut self,
416 notification_id: u64,
417 notification: &Notification,
418 cx: &mut ViewContext<Self>,
419 ) {
420 let should_mark_as_read = match notification {
421 Notification::ContactRequestAccepted { .. } => true,
422 Notification::ContactRequest { .. }
423 | Notification::ChannelInvitation { .. }
424 | Notification::ChannelMessageMention { .. } => false,
425 };
426
427 if should_mark_as_read {
428 self.mark_as_read_tasks
429 .entry(notification_id)
430 .or_insert_with(|| {
431 let client = self.client.clone();
432 cx.spawn(|this, mut cx| async move {
433 cx.background_executor().timer(MARK_AS_READ_DELAY).await;
434 client
435 .request(proto::MarkNotificationRead { notification_id })
436 .await?;
437 this.update(&mut cx, |this, _| {
438 this.mark_as_read_tasks.remove(¬ification_id);
439 })?;
440 Ok(())
441 })
442 });
443 }
444 }
445
446 fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext<Self>) {
447 if let Notification::ChannelMessageMention {
448 message_id,
449 channel_id,
450 ..
451 } = notification.clone()
452 {
453 if let Some(workspace) = self.workspace.upgrade() {
454 cx.window_context().defer(move |cx| {
455 workspace.update(cx, |workspace, cx| {
456 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
457 panel.update(cx, |panel, cx| {
458 panel
459 .select_channel(ChannelId(channel_id), Some(message_id), cx)
460 .detach_and_log_err(cx);
461 });
462 }
463 });
464 });
465 }
466 }
467 }
468
469 fn is_showing_notification(&self, notification: &Notification, cx: &ViewContext<Self>) -> bool {
470 if !self.active {
471 return false;
472 }
473
474 if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification {
475 if let Some(workspace) = self.workspace.upgrade() {
476 return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
477 let panel = panel.read(cx);
478 panel.is_scrolled_to_bottom()
479 && panel
480 .active_chat()
481 .map_or(false, |chat| chat.read(cx).channel_id.0 == *channel_id)
482 } else {
483 false
484 };
485 }
486 }
487
488 false
489 }
490
491 fn on_notification_event(
492 &mut self,
493 _: Model<NotificationStore>,
494 event: &NotificationEvent,
495 cx: &mut ViewContext<Self>,
496 ) {
497 match event {
498 NotificationEvent::NewNotification { entry } => {
499 if !self.is_showing_notification(&entry.notification, cx) {
500 self.unseen_notifications.push(entry.clone());
501 }
502 self.add_toast(entry, cx);
503 }
504 NotificationEvent::NotificationRemoved { entry }
505 | NotificationEvent::NotificationRead { entry } => {
506 self.unseen_notifications.retain(|n| n.id != entry.id);
507 self.remove_toast(entry.id, cx);
508 }
509 NotificationEvent::NotificationsUpdated {
510 old_range,
511 new_count,
512 } => {
513 self.notification_list.splice(old_range.clone(), *new_count);
514 cx.notify();
515 }
516 }
517 }
518
519 fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
520 if self.is_showing_notification(&entry.notification, cx) {
521 return;
522 }
523
524 let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
525 else {
526 return;
527 };
528
529 let notification_id = entry.id;
530 self.current_notification_toast = Some((
531 notification_id,
532 cx.spawn(|this, mut cx| async move {
533 cx.background_executor().timer(TOAST_DURATION).await;
534 this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx))
535 .ok();
536 }),
537 ));
538
539 self.workspace
540 .update(cx, |workspace, cx| {
541 let id = NotificationId::unique::<NotificationToast>();
542
543 workspace.dismiss_notification(&id, cx);
544 workspace.show_notification(id, cx, |cx| {
545 let workspace = cx.view().downgrade();
546 cx.new_view(|_| NotificationToast {
547 notification_id,
548 actor,
549 text,
550 workspace,
551 })
552 })
553 })
554 .ok();
555 }
556
557 fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
558 if let Some((current_id, _)) = &self.current_notification_toast {
559 if *current_id == notification_id {
560 self.current_notification_toast.take();
561 self.workspace
562 .update(cx, |workspace, cx| {
563 let id = NotificationId::unique::<NotificationToast>();
564 workspace.dismiss_notification(&id, cx)
565 })
566 .ok();
567 }
568 }
569 }
570
571 fn respond_to_notification(
572 &mut self,
573 notification: Notification,
574 response: bool,
575 cx: &mut ViewContext<Self>,
576 ) {
577 self.notification_store.update(cx, |store, cx| {
578 store.respond_to_notification(notification, response, cx);
579 });
580 }
581}
582
583impl Render for NotificationPanel {
584 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
585 v_flex()
586 .size_full()
587 .child(
588 h_flex()
589 .justify_between()
590 .px_2()
591 .py_1()
592 // Match the height of the tab bar so they line up.
593 .h(Tab::container_height(cx))
594 .border_b_1()
595 .border_color(cx.theme().colors().border)
596 .child(Label::new("Notifications"))
597 .child(Icon::new(IconName::Envelope)),
598 )
599 .map(|this| {
600 if self.client.user_id().is_none() {
601 this.child(
602 v_flex()
603 .gap_2()
604 .p_4()
605 .child(
606 Button::new("sign_in_prompt_button", "Sign in")
607 .icon_color(Color::Muted)
608 .icon(IconName::Github)
609 .icon_position(IconPosition::Start)
610 .style(ButtonStyle::Filled)
611 .full_width()
612 .on_click({
613 let client = self.client.clone();
614 move |_, cx| {
615 let client = client.clone();
616 cx.spawn(move |cx| async move {
617 client
618 .authenticate_and_connect(true, &cx)
619 .log_err()
620 .await;
621 })
622 .detach()
623 }
624 }),
625 )
626 .child(
627 div().flex().w_full().items_center().child(
628 Label::new("Sign in to view notifications.")
629 .color(Color::Muted)
630 .size(LabelSize::Small),
631 ),
632 ),
633 )
634 } else if self.notification_list.item_count() == 0 {
635 this.child(
636 v_flex().p_4().child(
637 div().flex().w_full().items_center().child(
638 Label::new("You have no notifications.")
639 .color(Color::Muted)
640 .size(LabelSize::Small),
641 ),
642 ),
643 )
644 } else {
645 this.child(list(self.notification_list.clone()).size_full())
646 }
647 })
648 }
649}
650
651impl FocusableView for NotificationPanel {
652 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
653 self.focus_handle.clone()
654 }
655}
656
657impl EventEmitter<Event> for NotificationPanel {}
658impl EventEmitter<PanelEvent> for NotificationPanel {}
659
660impl Panel for NotificationPanel {
661 fn persistent_name() -> &'static str {
662 "NotificationPanel"
663 }
664
665 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
666 NotificationPanelSettings::get_global(cx).dock
667 }
668
669 fn position_is_valid(&self, position: DockPosition) -> bool {
670 matches!(position, DockPosition::Left | DockPosition::Right)
671 }
672
673 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
674 settings::update_settings_file::<NotificationPanelSettings>(
675 self.fs.clone(),
676 cx,
677 move |settings, _| settings.dock = Some(position),
678 );
679 }
680
681 fn size(&self, cx: &gpui::WindowContext) -> Pixels {
682 self.width
683 .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width)
684 }
685
686 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
687 self.width = size;
688 self.serialize(cx);
689 cx.notify();
690 }
691
692 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
693 self.active = active;
694
695 if self.active {
696 self.unseen_notifications = Vec::new();
697 cx.notify();
698 }
699
700 if self.notification_store.read(cx).notification_count() == 0 {
701 cx.emit(Event::Dismissed);
702 }
703 }
704
705 fn icon(&self, cx: &gpui::WindowContext) -> Option<IconName> {
706 let show_button = NotificationPanelSettings::get_global(cx).button;
707 if !show_button {
708 return None;
709 }
710
711 if self.unseen_notifications.is_empty() {
712 return Some(IconName::Bell);
713 }
714
715 Some(IconName::BellDot)
716 }
717
718 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
719 Some("Notification Panel")
720 }
721
722 fn icon_label(&self, cx: &WindowContext) -> Option<String> {
723 let count = self.notification_store.read(cx).unread_notification_count();
724 if count == 0 {
725 None
726 } else {
727 Some(count.to_string())
728 }
729 }
730
731 fn toggle_action(&self) -> Box<dyn gpui::Action> {
732 Box::new(ToggleFocus)
733 }
734}
735
736pub struct NotificationToast {
737 notification_id: u64,
738 actor: Option<Arc<User>>,
739 text: String,
740 workspace: WeakView<Workspace>,
741}
742
743impl NotificationToast {
744 fn focus_notification_panel(&self, cx: &mut ViewContext<Self>) {
745 let workspace = self.workspace.clone();
746 let notification_id = self.notification_id;
747 cx.window_context().defer(move |cx| {
748 workspace
749 .update(cx, |workspace, cx| {
750 if let Some(panel) = workspace.focus_panel::<NotificationPanel>(cx) {
751 panel.update(cx, |panel, cx| {
752 let store = panel.notification_store.read(cx);
753 if let Some(entry) = store.notification_for_id(notification_id) {
754 panel.did_click_notification(&entry.clone().notification, cx);
755 }
756 });
757 }
758 })
759 .ok();
760 })
761 }
762}
763
764impl Render for NotificationToast {
765 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
766 let user = self.actor.clone();
767
768 h_flex()
769 .id("notification_panel_toast")
770 .elevation_3(cx)
771 .p_2()
772 .gap_2()
773 .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
774 .child(Label::new(self.text.clone()))
775 .child(
776 IconButton::new("close", IconName::Close)
777 .on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
778 )
779 .on_click(cx.listener(|this, _, cx| {
780 this.focus_notification_panel(cx);
781 cx.emit(DismissEvent);
782 }))
783 }
784}
785
786impl EventEmitter<DismissEvent> for NotificationToast {}