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