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 .icon_color(Color::Muted)
548 .icon(IconName::Github)
549 .icon_position(IconPosition::Start)
550 .style(ButtonStyle::Filled)
551 .full_width()
552 .on_click({
553 let client = self.client.clone();
554 move |_, window, cx| {
555 let client = client.clone();
556 window
557 .spawn(cx, async move |cx| {
558 match client.connect(true, cx).await {
559 util::ConnectionResult::Timeout => {
560 log::error!("Connection timeout");
561 }
562 util::ConnectionResult::ConnectionReset => {
563 log::error!("Connection reset");
564 }
565 util::ConnectionResult::Result(r) => {
566 r.log_err();
567 }
568 }
569 })
570 .detach()
571 }
572 }),
573 )
574 .child(
575 div().flex().w_full().items_center().child(
576 Label::new("Connect to view notifications.")
577 .color(Color::Muted)
578 .size(LabelSize::Small),
579 ),
580 ),
581 )
582 } else if self.notification_list.item_count() == 0 {
583 this.child(
584 v_flex().p_4().child(
585 div().flex().w_full().items_center().child(
586 Label::new("You have no notifications.")
587 .color(Color::Muted)
588 .size(LabelSize::Small),
589 ),
590 ),
591 )
592 } else {
593 this.child(
594 list(
595 self.notification_list.clone(),
596 cx.processor(|this, ix, window, cx| {
597 this.render_notification(ix, window, cx)
598 .unwrap_or_else(|| div().into_any())
599 }),
600 )
601 .size_full(),
602 )
603 }
604 })
605 }
606}
607
608impl Focusable for NotificationPanel {
609 fn focus_handle(&self, _: &App) -> FocusHandle {
610 self.focus_handle.clone()
611 }
612}
613
614impl EventEmitter<Event> for NotificationPanel {}
615impl EventEmitter<PanelEvent> for NotificationPanel {}
616
617impl Panel for NotificationPanel {
618 fn persistent_name() -> &'static str {
619 "NotificationPanel"
620 }
621
622 fn panel_key() -> &'static str {
623 NOTIFICATION_PANEL_KEY
624 }
625
626 fn position(&self, _: &Window, cx: &App) -> DockPosition {
627 NotificationPanelSettings::get_global(cx).dock
628 }
629
630 fn position_is_valid(&self, position: DockPosition) -> bool {
631 matches!(position, DockPosition::Left | DockPosition::Right)
632 }
633
634 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
635 settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
636 settings.notification_panel.get_or_insert_default().dock = Some(position.into())
637 });
638 }
639
640 fn size(&self, _: &Window, cx: &App) -> Pixels {
641 self.width
642 .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width)
643 }
644
645 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
646 self.width = size;
647 self.serialize(cx);
648 cx.notify();
649 }
650
651 fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context<Self>) {
652 self.active = active;
653
654 if self.active {
655 self.unseen_notifications = Vec::new();
656 cx.notify();
657 }
658
659 if self.notification_store.read(cx).notification_count() == 0 {
660 cx.emit(Event::Dismissed);
661 }
662 }
663
664 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
665 let show_button = NotificationPanelSettings::get_global(cx).button;
666 if !show_button {
667 return None;
668 }
669
670 if self.unseen_notifications.is_empty() {
671 return Some(IconName::Bell);
672 }
673
674 Some(IconName::BellDot)
675 }
676
677 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
678 Some("Notification Panel")
679 }
680
681 fn icon_label(&self, _window: &Window, cx: &App) -> Option<String> {
682 let count = self.notification_store.read(cx).unread_notification_count();
683 if count == 0 {
684 None
685 } else {
686 Some(count.to_string())
687 }
688 }
689
690 fn toggle_action(&self) -> Box<dyn gpui::Action> {
691 Box::new(ToggleFocus)
692 }
693
694 fn activation_priority(&self) -> u32 {
695 8
696 }
697}
698
699pub struct NotificationToast {
700 actor: Option<Arc<User>>,
701 text: String,
702 workspace: WeakEntity<Workspace>,
703 focus_handle: FocusHandle,
704}
705
706impl Focusable for NotificationToast {
707 fn focus_handle(&self, _cx: &App) -> FocusHandle {
708 self.focus_handle.clone()
709 }
710}
711
712impl WorkspaceNotification for NotificationToast {}
713
714impl NotificationToast {
715 fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
716 let workspace = self.workspace.clone();
717 window.defer(cx, move |window, cx| {
718 workspace
719 .update(cx, |workspace, cx| {
720 workspace.focus_panel::<NotificationPanel>(window, cx)
721 })
722 .ok();
723 })
724 }
725}
726
727impl Render for NotificationToast {
728 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
729 let user = self.actor.clone();
730
731 let suppress = window.modifiers().shift;
732 let (close_id, close_icon) = if suppress {
733 ("suppress", IconName::Minimize)
734 } else {
735 ("close", IconName::Close)
736 };
737
738 h_flex()
739 .id("notification_panel_toast")
740 .elevation_3(cx)
741 .p_2()
742 .justify_between()
743 .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
744 .child(Label::new(self.text.clone()))
745 .on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify()))
746 .child(
747 IconButton::new(close_id, close_icon)
748 .tooltip(move |_window, cx| {
749 if suppress {
750 Tooltip::for_action(
751 "Suppress.\nClose with click.",
752 &workspace::SuppressNotification,
753 cx,
754 )
755 } else {
756 Tooltip::for_action(
757 "Close.\nSuppress with shift-click",
758 &menu::Cancel,
759 cx,
760 )
761 }
762 })
763 .on_click(cx.listener(move |_, _: &ClickEvent, _, cx| {
764 if suppress {
765 cx.emit(SuppressEvent);
766 } else {
767 cx.emit(DismissEvent);
768 }
769 })),
770 )
771 .on_click(cx.listener(|this, _, window, cx| {
772 this.focus_notification_panel(window, cx);
773 cx.emit(DismissEvent);
774 }))
775 }
776}
777
778impl EventEmitter<DismissEvent> for NotificationToast {}
779impl EventEmitter<SuppressEvent> for NotificationToast {}