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