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}
51
52#[derive(Serialize, Deserialize)]
53struct SerializedNotificationPanel {
54 width: Option<Pixels>,
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.new_view(|cx: &mut ViewContext<Self>| {
91 let mut status = client.status();
92 cx.spawn(|this, mut cx| async move {
93 while let Some(_) = status.next().await {
94 if this
95 .update(&mut cx, |_, cx| {
96 cx.notify();
97 })
98 .is_err()
99 {
100 break;
101 }
102 }
103 })
104 .detach();
105
106 let view = cx.view().downgrade();
107 let notification_list =
108 ListState::new(0, ListAlignment::Top, px(1000.), move |ix, cx| {
109 view.upgrade()
110 .and_then(|view| {
111 view.update(cx, |this, cx| this.render_notification(ix, cx))
112 })
113 .unwrap_or_else(|| div().into_any())
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_flex()
255 .gap_1()
256 .size_full()
257 .overflow_hidden()
258 .child(Label::new(text.clone()))
259 .child(
260 h_flex()
261 .child(
262 Label::new(format_timestamp(
263 timestamp,
264 now,
265 self.local_timezone,
266 ))
267 .color(Color::Muted),
268 )
269 .children(if let Some(is_accepted) = response {
270 Some(div().flex().flex_grow().justify_end().child(Label::new(
271 if is_accepted {
272 "You accepted"
273 } else {
274 "You declined"
275 },
276 )))
277 } else if needs_response {
278 Some(
279 h_flex()
280 .flex_grow()
281 .justify_end()
282 .child(Button::new("decline", "Decline").on_click({
283 let notification = notification.clone();
284 let view = cx.view().clone();
285 move |_, cx| {
286 view.update(cx, |this, cx| {
287 this.respond_to_notification(
288 notification.clone(),
289 false,
290 cx,
291 )
292 });
293 }
294 }))
295 .child(Button::new("accept", "Accept").on_click({
296 let notification = notification.clone();
297 let view = cx.view().clone();
298 move |_, cx| {
299 view.update(cx, |this, cx| {
300 this.respond_to_notification(
301 notification.clone(),
302 true,
303 cx,
304 )
305 });
306 }
307 })),
308 )
309 } else {
310 None
311 }),
312 ),
313 )
314 .into_any(),
315 )
316 }
317
318 fn present_notification(
319 &self,
320 entry: &NotificationEntry,
321 cx: &AppContext,
322 ) -> Option<NotificationPresenter> {
323 let user_store = self.user_store.read(cx);
324 let channel_store = self.channel_store.read(cx);
325 match entry.notification {
326 Notification::ContactRequest { sender_id } => {
327 let requester = user_store.get_cached_user(sender_id)?;
328 Some(NotificationPresenter {
329 icon: "icons/plus.svg",
330 text: format!("{} wants to add you as a contact", requester.github_login),
331 needs_response: user_store.has_incoming_contact_request(requester.id),
332 actor: Some(requester),
333 can_navigate: false,
334 })
335 }
336 Notification::ContactRequestAccepted { responder_id } => {
337 let responder = user_store.get_cached_user(responder_id)?;
338 Some(NotificationPresenter {
339 icon: "icons/plus.svg",
340 text: format!("{} accepted your contact invite", responder.github_login),
341 needs_response: false,
342 actor: Some(responder),
343 can_navigate: false,
344 })
345 }
346 Notification::ChannelInvitation {
347 ref channel_name,
348 channel_id,
349 inviter_id,
350 } => {
351 let inviter = user_store.get_cached_user(inviter_id)?;
352 Some(NotificationPresenter {
353 icon: "icons/hash.svg",
354 text: format!(
355 "{} invited you to join the #{channel_name} channel",
356 inviter.github_login
357 ),
358 needs_response: channel_store.has_channel_invitation(channel_id),
359 actor: Some(inviter),
360 can_navigate: false,
361 })
362 }
363 Notification::ChannelMessageMention {
364 sender_id,
365 channel_id,
366 message_id,
367 } => {
368 let sender = user_store.get_cached_user(sender_id)?;
369 let channel = channel_store.channel_for_id(channel_id)?;
370 let message = self
371 .notification_store
372 .read(cx)
373 .channel_message_for_id(message_id)?;
374 Some(NotificationPresenter {
375 icon: "icons/conversations.svg",
376 text: format!(
377 "{} mentioned you in #{}:\n{}",
378 sender.github_login, channel.name, message.body,
379 ),
380 needs_response: false,
381 actor: Some(sender),
382 can_navigate: true,
383 })
384 }
385 }
386 }
387
388 fn did_render_notification(
389 &mut self,
390 notification_id: u64,
391 notification: &Notification,
392 cx: &mut ViewContext<Self>,
393 ) {
394 let should_mark_as_read = match notification {
395 Notification::ContactRequestAccepted { .. } => true,
396 Notification::ContactRequest { .. }
397 | Notification::ChannelInvitation { .. }
398 | Notification::ChannelMessageMention { .. } => false,
399 };
400
401 if should_mark_as_read {
402 self.mark_as_read_tasks
403 .entry(notification_id)
404 .or_insert_with(|| {
405 let client = self.client.clone();
406 cx.spawn(|this, mut cx| async move {
407 cx.background_executor().timer(MARK_AS_READ_DELAY).await;
408 client
409 .request(proto::MarkNotificationRead { notification_id })
410 .await?;
411 this.update(&mut cx, |this, _| {
412 this.mark_as_read_tasks.remove(¬ification_id);
413 })?;
414 Ok(())
415 })
416 });
417 }
418 }
419
420 fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext<Self>) {
421 if let Notification::ChannelMessageMention {
422 message_id,
423 channel_id,
424 ..
425 } = notification.clone()
426 {
427 if let Some(workspace) = self.workspace.upgrade() {
428 cx.window_context().defer(move |cx| {
429 workspace.update(cx, |workspace, cx| {
430 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
431 panel.update(cx, |panel, cx| {
432 panel
433 .select_channel(channel_id, Some(message_id), cx)
434 .detach_and_log_err(cx);
435 });
436 }
437 });
438 });
439 }
440 }
441 }
442
443 fn is_showing_notification(&self, notification: &Notification, cx: &ViewContext<Self>) -> bool {
444 if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification {
445 if let Some(workspace) = self.workspace.upgrade() {
446 return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
447 let panel = panel.read(cx);
448 panel.is_scrolled_to_bottom()
449 && panel
450 .active_chat()
451 .map_or(false, |chat| chat.read(cx).channel_id == *channel_id)
452 } else {
453 false
454 };
455 }
456 }
457
458 false
459 }
460
461 fn on_notification_event(
462 &mut self,
463 _: Model<NotificationStore>,
464 event: &NotificationEvent,
465 cx: &mut ViewContext<Self>,
466 ) {
467 match event {
468 NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
469 NotificationEvent::NotificationRemoved { entry }
470 | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx),
471 NotificationEvent::NotificationsUpdated {
472 old_range,
473 new_count,
474 } => {
475 self.notification_list.splice(old_range.clone(), *new_count);
476 cx.notify();
477 }
478 }
479 }
480
481 fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
482 if self.is_showing_notification(&entry.notification, cx) {
483 return;
484 }
485
486 let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
487 else {
488 return;
489 };
490
491 let notification_id = entry.id;
492 self.current_notification_toast = Some((
493 notification_id,
494 cx.spawn(|this, mut cx| async move {
495 cx.background_executor().timer(TOAST_DURATION).await;
496 this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx))
497 .ok();
498 }),
499 ));
500
501 self.workspace
502 .update(cx, |workspace, cx| {
503 workspace.dismiss_notification::<NotificationToast>(0, cx);
504 workspace.show_notification(0, cx, |cx| {
505 let workspace = cx.view().downgrade();
506 cx.new_view(|_| NotificationToast {
507 notification_id,
508 actor,
509 text,
510 workspace,
511 })
512 })
513 })
514 .ok();
515 }
516
517 fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
518 if let Some((current_id, _)) = &self.current_notification_toast {
519 if *current_id == notification_id {
520 self.current_notification_toast.take();
521 self.workspace
522 .update(cx, |workspace, cx| {
523 workspace.dismiss_notification::<NotificationToast>(0, cx)
524 })
525 .ok();
526 }
527 }
528 }
529
530 fn respond_to_notification(
531 &mut self,
532 notification: Notification,
533 response: bool,
534 cx: &mut ViewContext<Self>,
535 ) {
536 self.notification_store.update(cx, |store, cx| {
537 store.respond_to_notification(notification, response, cx);
538 });
539 }
540}
541
542impl Render for NotificationPanel {
543 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
544 v_flex()
545 .size_full()
546 .child(
547 h_flex()
548 .justify_between()
549 .px_2()
550 .py_1()
551 // Match the height of the tab bar so they line up.
552 .h(rems(ui::Tab::CONTAINER_HEIGHT_IN_REMS))
553 .border_b_1()
554 .border_color(cx.theme().colors().border)
555 .child(Label::new("Notifications"))
556 .child(Icon::new(IconName::Envelope)),
557 )
558 .map(|this| {
559 if self.client.user_id().is_none() {
560 this.child(
561 v_flex()
562 .gap_2()
563 .p_4()
564 .child(
565 Button::new("sign_in_prompt_button", "Sign in")
566 .icon_color(Color::Muted)
567 .icon(IconName::Github)
568 .icon_position(IconPosition::Start)
569 .style(ButtonStyle::Filled)
570 .full_width()
571 .on_click({
572 let client = self.client.clone();
573 move |_, cx| {
574 let client = client.clone();
575 cx.spawn(move |cx| async move {
576 client
577 .authenticate_and_connect(true, &cx)
578 .log_err()
579 .await;
580 })
581 .detach()
582 }
583 }),
584 )
585 .child(
586 div().flex().w_full().items_center().child(
587 Label::new("Sign in to view notifications.")
588 .color(Color::Muted)
589 .size(LabelSize::Small),
590 ),
591 ),
592 )
593 } else if self.notification_list.item_count() == 0 {
594 this.child(
595 v_flex().p_4().child(
596 div().flex().w_full().items_center().child(
597 Label::new("You have no notifications.")
598 .color(Color::Muted)
599 .size(LabelSize::Small),
600 ),
601 ),
602 )
603 } else {
604 this.child(list(self.notification_list.clone()).size_full())
605 }
606 })
607 }
608}
609
610impl FocusableView for NotificationPanel {
611 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
612 self.focus_handle.clone()
613 }
614}
615
616impl EventEmitter<Event> for NotificationPanel {}
617impl EventEmitter<PanelEvent> for NotificationPanel {}
618
619impl Panel for NotificationPanel {
620 fn persistent_name() -> &'static str {
621 "NotificationPanel"
622 }
623
624 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
625 NotificationPanelSettings::get_global(cx).dock
626 }
627
628 fn position_is_valid(&self, position: DockPosition) -> bool {
629 matches!(position, DockPosition::Left | DockPosition::Right)
630 }
631
632 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
633 settings::update_settings_file::<NotificationPanelSettings>(
634 self.fs.clone(),
635 cx,
636 move |settings| settings.dock = Some(position),
637 );
638 }
639
640 fn size(&self, cx: &gpui::WindowContext) -> Pixels {
641 self.width
642 .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width)
643 }
644
645 fn set_size(&mut self, size: Option<Pixels>, cx: &mut ViewContext<Self>) {
646 self.width = size;
647 self.serialize(cx);
648 cx.notify();
649 }
650
651 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
652 self.active = active;
653 if self.notification_store.read(cx).notification_count() == 0 {
654 cx.emit(Event::Dismissed);
655 }
656 }
657
658 fn icon(&self, cx: &gpui::WindowContext) -> Option<IconName> {
659 (NotificationPanelSettings::get_global(cx).button
660 && self.notification_store.read(cx).notification_count() > 0)
661 .then(|| IconName::Bell)
662 }
663
664 fn icon_tooltip(&self, _cx: &WindowContext) -> Option<&'static str> {
665 Some("Notification Panel")
666 }
667
668 fn icon_label(&self, cx: &WindowContext) -> Option<String> {
669 let count = self.notification_store.read(cx).unread_notification_count();
670 if count == 0 {
671 None
672 } else {
673 Some(count.to_string())
674 }
675 }
676
677 fn toggle_action(&self) -> Box<dyn gpui::Action> {
678 Box::new(ToggleFocus)
679 }
680}
681
682pub struct NotificationToast {
683 notification_id: u64,
684 actor: Option<Arc<User>>,
685 text: String,
686 workspace: WeakView<Workspace>,
687}
688
689impl NotificationToast {
690 fn focus_notification_panel(&self, cx: &mut ViewContext<Self>) {
691 let workspace = self.workspace.clone();
692 let notification_id = self.notification_id;
693 cx.window_context().defer(move |cx| {
694 workspace
695 .update(cx, |workspace, cx| {
696 if let Some(panel) = workspace.focus_panel::<NotificationPanel>(cx) {
697 panel.update(cx, |panel, cx| {
698 let store = panel.notification_store.read(cx);
699 if let Some(entry) = store.notification_for_id(notification_id) {
700 panel.did_click_notification(&entry.clone().notification, cx);
701 }
702 });
703 }
704 })
705 .ok();
706 })
707 }
708}
709
710impl Render for NotificationToast {
711 fn render(&mut self, cx: &mut ViewContext<Self>) -> impl IntoElement {
712 let user = self.actor.clone();
713
714 h_flex()
715 .id("notification_panel_toast")
716 .elevation_3(cx)
717 .p_2()
718 .gap_2()
719 .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
720 .child(Label::new(self.text.clone()))
721 .child(
722 IconButton::new("close", IconName::Close)
723 .on_click(cx.listener(|_, _, cx| cx.emit(DismissEvent))),
724 )
725 .on_click(cx.listener(|this, _, cx| {
726 this.focus_notification_panel(cx);
727 cx.emit(DismissEvent);
728 }))
729 }
730}
731
732impl EventEmitter<DismissEvent> for NotificationToast {}
733
734fn format_timestamp(
735 mut timestamp: OffsetDateTime,
736 mut now: OffsetDateTime,
737 local_timezone: UtcOffset,
738) -> String {
739 timestamp = timestamp.to_offset(local_timezone);
740 now = now.to_offset(local_timezone);
741
742 let today = now.date();
743 let date = timestamp.date();
744 if date == today {
745 let difference = now - timestamp;
746 if difference >= Duration::from_secs(3600) {
747 format!("{}h", difference.whole_seconds() / 3600)
748 } else if difference >= Duration::from_secs(60) {
749 format!("{}m", difference.whole_seconds() / 60)
750 } else {
751 "just now".to_string()
752 }
753 } else if date.next_day() == Some(today) {
754 format!("yesterday")
755 } else {
756 format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
757 }
758}