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