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