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