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, PanelIconButton},
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_button(&self, _cx: &App) -> PanelIconButton {
663 PanelIconButton {
664 icon: if self.unseen_notifications.is_empty() {
665 IconName::Bell
666 } else {
667 IconName::BellDot
668 },
669 tooltip: "Notification Panel",
670 action: Box::new(ToggleFocus),
671 }
672 }
673
674 fn icon_label(&self, cx: &App) -> Option<String> {
675 if !NotificationPanelSettings::get_global(cx).show_count_badge {
676 return None;
677 }
678 let count = self.notification_store.read(cx).unread_notification_count();
679 if count == 0 {
680 None
681 } else {
682 Some(count.to_string())
683 }
684 }
685
686 fn enabled(&self, cx: &App) -> bool {
687 NotificationPanelSettings::get_global(cx).button
688 }
689
690 fn activation_priority(&self) -> u32 {
691 8
692 }
693}
694
695pub struct NotificationToast {
696 actor: Option<Arc<User>>,
697 text: String,
698 workspace: WeakEntity<Workspace>,
699 focus_handle: FocusHandle,
700}
701
702impl Focusable for NotificationToast {
703 fn focus_handle(&self, _cx: &App) -> FocusHandle {
704 self.focus_handle.clone()
705 }
706}
707
708impl WorkspaceNotification for NotificationToast {}
709
710impl NotificationToast {
711 fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
712 let workspace = self.workspace.clone();
713 window.defer(cx, move |window, cx| {
714 workspace
715 .update(cx, |workspace, cx| {
716 workspace.focus_panel::<NotificationPanel>(window, cx)
717 })
718 .ok();
719 })
720 }
721}
722
723impl Render for NotificationToast {
724 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
725 let user = self.actor.clone();
726
727 let suppress = window.modifiers().shift;
728 let (close_id, close_icon) = if suppress {
729 ("suppress", IconName::Minimize)
730 } else {
731 ("close", IconName::Close)
732 };
733
734 h_flex()
735 .id("notification_panel_toast")
736 .elevation_3(cx)
737 .p_2()
738 .justify_between()
739 .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
740 .child(Label::new(self.text.clone()))
741 .on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify()))
742 .child(
743 IconButton::new(close_id, close_icon)
744 .tooltip(move |_window, cx| {
745 if suppress {
746 Tooltip::for_action(
747 "Suppress.\nClose with click.",
748 &workspace::SuppressNotification,
749 cx,
750 )
751 } else {
752 Tooltip::for_action(
753 "Close.\nSuppress with shift-click",
754 &menu::Cancel,
755 cx,
756 )
757 }
758 })
759 .on_click(cx.listener(move |_, _: &ClickEvent, _, cx| {
760 if suppress {
761 cx.emit(SuppressEvent);
762 } else {
763 cx.emit(DismissEvent);
764 }
765 })),
766 )
767 .on_click(cx.listener(|this, _, window, cx| {
768 this.focus_notification_panel(window, cx);
769 cx.emit(DismissEvent);
770 }))
771 }
772}
773
774impl EventEmitter<DismissEvent> for NotificationToast {}
775impl EventEmitter<SuppressEvent> for NotificationToast {}