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