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