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