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, list, px, serde_json, AnyElement, AppContext, AsyncWindowContext, DismissEvent,
10 Div, Element, EventEmitter, FocusHandle, FocusableView, InteractiveElement, IntoElement,
11 ListAlignment, ListScrollEvent, ListState, Model, ParentElement, Render, Stateful,
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::{
23 h_stack, v_stack, Avatar, Button, ButtonLike, Clickable, Disableable, Icon, IconButton,
24 IconElement, Label,
25};
26use util::{ResultExt, TryFutureExt};
27use workspace::{
28 dock::{DockPosition, Panel, PanelEvent},
29 Workspace,
30};
31
32const LOADING_THRESHOLD: usize = 30;
33const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);
34const TOAST_DURATION: Duration = Duration::from_secs(5);
35const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
36
37pub struct NotificationPanel {
38 client: Arc<Client>,
39 user_store: Model<UserStore>,
40 channel_store: Model<ChannelStore>,
41 notification_store: Model<NotificationStore>,
42 fs: Arc<dyn Fs>,
43 width: Option<f32>,
44 active: bool,
45 notification_list: ListState,
46 pending_serialization: Task<Option<()>>,
47 subscriptions: Vec<gpui::Subscription>,
48 workspace: WeakView<Workspace>,
49 current_notification_toast: Option<(u64, Task<()>)>,
50 local_timezone: UtcOffset,
51 focus_handle: FocusHandle,
52 mark_as_read_tasks: HashMap<u64, Task<Result<()>>>,
53}
54
55#[derive(Serialize, Deserialize)]
56struct SerializedNotificationPanel {
57 width: Option<f32>,
58}
59
60#[derive(Debug)]
61pub enum Event {
62 DockPositionChanged,
63 Focus,
64 Dismissed,
65}
66
67pub struct NotificationPresenter {
68 pub actor: Option<Arc<client::User>>,
69 pub text: String,
70 pub icon: &'static str,
71 pub needs_response: bool,
72 pub can_navigate: bool,
73}
74
75actions!(notification_panel, [ToggleFocus]);
76
77pub fn init(cx: &mut AppContext) {
78 cx.observe_new_views(|workspace: &mut Workspace, _| {
79 workspace.register_action(|workspace, _: &ToggleFocus, cx| {
80 workspace.toggle_panel_focus::<NotificationPanel>(cx);
81 });
82 })
83 .detach();
84}
85
86impl NotificationPanel {
87 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> View<Self> {
88 let fs = workspace.app_state().fs.clone();
89 let client = workspace.app_state().client.clone();
90 let user_store = workspace.app_state().user_store.clone();
91 let workspace_handle = workspace.weak_handle();
92
93 cx.build_view(|cx: &mut ViewContext<Self>| {
94 let view = cx.view().clone();
95
96 let mut status = client.status();
97 cx.spawn(|this, mut cx| async move {
98 while let Some(_) = status.next().await {
99 if this
100 .update(&mut cx, |_, cx| {
101 cx.notify();
102 })
103 .is_err()
104 {
105 break;
106 }
107 }
108 })
109 .detach();
110
111 let notification_list =
112 ListState::new(0, ListAlignment::Top, px(1000.), move |ix, cx| {
113 view.update(cx, |this, cx| {
114 this.render_notification(ix, cx)
115 .unwrap_or_else(|| div().into_any())
116 })
117 });
118 notification_list.set_scroll_handler(cx.listener(
119 |this, event: &ListScrollEvent, cx| {
120 if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD {
121 if let Some(task) = this
122 .notification_store
123 .update(cx, |store, cx| store.load_more_notifications(false, cx))
124 {
125 task.detach();
126 }
127 }
128 },
129 ));
130
131 let mut this = Self {
132 fs,
133 client,
134 user_store,
135 local_timezone: cx.local_timezone(),
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 };
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;
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 Some(
233 div()
234 .child(
235 ButtonLike::new(ix)
236 .child(
237 h_stack()
238 .children(actor.map(|actor| Avatar::new(actor.avatar_uri.clone())))
239 .child(
240 v_stack().child(Label::new(text)).child(
241 h_stack()
242 .child(Label::new(format_timestamp(
243 timestamp,
244 now,
245 self.local_timezone,
246 )))
247 .children(if let Some(is_accepted) = response {
248 Some(div().child(Label::new(if is_accepted {
249 "You accepted"
250 } else {
251 "You declined"
252 })))
253 } else if needs_response {
254 Some(
255 h_stack()
256 .child(
257 Button::new("decline", "Decline")
258 .on_click({
259 let notification =
260 notification.clone();
261 let view = cx.view().clone();
262 move |_, cx| {
263 view.update(cx, |this, cx| {
264 this.respond_to_notification(
265 notification.clone(),
266 false,
267 cx,
268 )
269 });
270 }
271 }),
272 )
273 .child(
274 Button::new("accept", "Accept")
275 .on_click({
276 let notification =
277 notification.clone();
278 let view = cx.view().clone();
279 move |_, cx| {
280 view.update(cx, |this, cx| {
281 this.respond_to_notification(
282 notification.clone(),
283 true,
284 cx,
285 )
286 });
287 }
288 }),
289 ),
290 )
291 } else {
292 None
293 }),
294 ),
295 ),
296 )
297 .disabled(!can_navigate)
298 .on_click({
299 let notification = notification.clone();
300 cx.listener(move |this, _, cx| {
301 this.did_click_notification(¬ification, cx)
302 })
303 }),
304 )
305 .into_any(),
306 )
307 }
308
309 fn present_notification(
310 &self,
311 entry: &NotificationEntry,
312 cx: &AppContext,
313 ) -> Option<NotificationPresenter> {
314 let user_store = self.user_store.read(cx);
315 let channel_store = self.channel_store.read(cx);
316 match entry.notification {
317 Notification::ContactRequest { sender_id } => {
318 let requester = user_store.get_cached_user(sender_id)?;
319 Some(NotificationPresenter {
320 icon: "icons/plus.svg",
321 text: format!("{} wants to add you as a contact", requester.github_login),
322 needs_response: user_store.has_incoming_contact_request(requester.id),
323 actor: Some(requester),
324 can_navigate: false,
325 })
326 }
327 Notification::ContactRequestAccepted { responder_id } => {
328 let responder = user_store.get_cached_user(responder_id)?;
329 Some(NotificationPresenter {
330 icon: "icons/plus.svg",
331 text: format!("{} accepted your contact invite", responder.github_login),
332 needs_response: false,
333 actor: Some(responder),
334 can_navigate: false,
335 })
336 }
337 Notification::ChannelInvitation {
338 ref channel_name,
339 channel_id,
340 inviter_id,
341 } => {
342 let inviter = user_store.get_cached_user(inviter_id)?;
343 Some(NotificationPresenter {
344 icon: "icons/hash.svg",
345 text: format!(
346 "{} invited you to join the #{channel_name} channel",
347 inviter.github_login
348 ),
349 needs_response: channel_store.has_channel_invitation(channel_id),
350 actor: Some(inviter),
351 can_navigate: false,
352 })
353 }
354 Notification::ChannelMessageMention {
355 sender_id,
356 channel_id,
357 message_id,
358 } => {
359 let sender = user_store.get_cached_user(sender_id)?;
360 let channel = channel_store.channel_for_id(channel_id)?;
361 let message = self
362 .notification_store
363 .read(cx)
364 .channel_message_for_id(message_id)?;
365 Some(NotificationPresenter {
366 icon: "icons/conversations.svg",
367 text: format!(
368 "{} mentioned you in #{}:\n{}",
369 sender.github_login, channel.name, message.body,
370 ),
371 needs_response: false,
372 actor: Some(sender),
373 can_navigate: true,
374 })
375 }
376 }
377 }
378
379 fn did_render_notification(
380 &mut self,
381 notification_id: u64,
382 notification: &Notification,
383 cx: &mut ViewContext<Self>,
384 ) {
385 let should_mark_as_read = match notification {
386 Notification::ContactRequestAccepted { .. } => true,
387 Notification::ContactRequest { .. }
388 | Notification::ChannelInvitation { .. }
389 | Notification::ChannelMessageMention { .. } => false,
390 };
391
392 if should_mark_as_read {
393 self.mark_as_read_tasks
394 .entry(notification_id)
395 .or_insert_with(|| {
396 let client = self.client.clone();
397 cx.spawn(|this, mut cx| async move {
398 cx.background_executor().timer(MARK_AS_READ_DELAY).await;
399 client
400 .request(proto::MarkNotificationRead { notification_id })
401 .await?;
402 this.update(&mut cx, |this, _| {
403 this.mark_as_read_tasks.remove(¬ification_id);
404 })?;
405 Ok(())
406 })
407 });
408 }
409 }
410
411 fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext<Self>) {
412 if let Notification::ChannelMessageMention {
413 message_id,
414 channel_id,
415 ..
416 } = notification.clone()
417 {
418 if let Some(workspace) = self.workspace.upgrade() {
419 cx.defer(move |_, cx| {
420 workspace.update(cx, |workspace, cx| {
421 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
422 panel.update(cx, |panel, cx| {
423 panel
424 .select_channel(channel_id, Some(message_id), cx)
425 .detach_and_log_err(cx);
426 });
427 }
428 });
429 });
430 }
431 }
432 }
433
434 fn is_showing_notification(&self, notification: &Notification, cx: &ViewContext<Self>) -> bool {
435 if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification {
436 if let Some(workspace) = self.workspace.upgrade() {
437 return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
438 let panel = panel.read(cx);
439 panel.is_scrolled_to_bottom()
440 && panel
441 .active_chat()
442 .map_or(false, |chat| chat.read(cx).channel_id == *channel_id)
443 } else {
444 false
445 };
446 }
447 }
448
449 false
450 }
451
452 fn render_sign_in_prompt(&self) -> AnyElement {
453 Button::new(
454 "sign_in_prompt_button",
455 "Sign in to view your notifications",
456 )
457 .on_click({
458 let client = self.client.clone();
459 move |_, cx| {
460 let client = client.clone();
461 cx.spawn(move |cx| async move {
462 client.authenticate_and_connect(true, &cx).log_err().await;
463 })
464 .detach()
465 }
466 })
467 .into_any_element()
468 }
469
470 fn render_empty_state(&self) -> AnyElement {
471 Label::new("You have no notifications").into_any_element()
472 }
473
474 fn on_notification_event(
475 &mut self,
476 _: Model<NotificationStore>,
477 event: &NotificationEvent,
478 cx: &mut ViewContext<Self>,
479 ) {
480 match event {
481 NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
482 NotificationEvent::NotificationRemoved { entry }
483 | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx),
484 NotificationEvent::NotificationsUpdated {
485 old_range,
486 new_count,
487 } => {
488 self.notification_list.splice(old_range.clone(), *new_count);
489 cx.notify();
490 }
491 }
492 }
493
494 fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
495 if self.is_showing_notification(&entry.notification, cx) {
496 return;
497 }
498
499 let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
500 else {
501 return;
502 };
503
504 let notification_id = entry.id;
505 self.current_notification_toast = Some((
506 notification_id,
507 cx.spawn(|this, mut cx| async move {
508 cx.background_executor().timer(TOAST_DURATION).await;
509 this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx))
510 .ok();
511 }),
512 ));
513
514 self.workspace
515 .update(cx, |workspace, cx| {
516 workspace.dismiss_notification::<NotificationToast>(0, cx);
517 workspace.show_notification(0, cx, |cx| {
518 let workspace = cx.view().downgrade();
519 cx.build_view(|_| NotificationToast {
520 notification_id,
521 actor,
522 text,
523 workspace,
524 })
525 })
526 })
527 .ok();
528 }
529
530 fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
531 if let Some((current_id, _)) = &self.current_notification_toast {
532 if *current_id == notification_id {
533 self.current_notification_toast.take();
534 self.workspace
535 .update(cx, |workspace, cx| {
536 workspace.dismiss_notification::<NotificationToast>(0, cx)
537 })
538 .ok();
539 }
540 }
541 }
542
543 fn respond_to_notification(
544 &mut self,
545 notification: Notification,
546 response: bool,
547 cx: &mut ViewContext<Self>,
548 ) {
549 self.notification_store.update(cx, |store, cx| {
550 store.respond_to_notification(notification, response, cx);
551 });
552 }
553}
554
555impl Render for NotificationPanel {
556 type Element = AnyElement;
557
558 fn render(&mut self, _: &mut ViewContext<Self>) -> AnyElement {
559 if self.client.user_id().is_none() {
560 self.render_sign_in_prompt()
561 } else if self.notification_list.item_count() == 0 {
562 self.render_empty_state()
563 } else {
564 v_stack()
565 .bg(gpui::red())
566 .child(
567 h_stack()
568 .child(Label::new("Notifications"))
569 .child(IconElement::new(Icon::Envelope)),
570 )
571 .child(list(self.notification_list.clone()).size_full())
572 .size_full()
573 .into_any_element()
574 }
575 }
576}
577
578impl FocusableView for NotificationPanel {
579 fn focus_handle(&self, _: &AppContext) -> FocusHandle {
580 self.focus_handle.clone()
581 }
582}
583
584impl EventEmitter<Event> for NotificationPanel {}
585impl EventEmitter<PanelEvent> for NotificationPanel {}
586
587impl Panel for NotificationPanel {
588 fn persistent_name() -> &'static str {
589 "NotificationPanel"
590 }
591
592 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
593 NotificationPanelSettings::get_global(cx).dock
594 }
595
596 fn position_is_valid(&self, position: DockPosition) -> bool {
597 matches!(position, DockPosition::Left | DockPosition::Right)
598 }
599
600 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
601 settings::update_settings_file::<NotificationPanelSettings>(
602 self.fs.clone(),
603 cx,
604 move |settings| settings.dock = Some(position),
605 );
606 }
607
608 fn size(&self, cx: &gpui::WindowContext) -> f32 {
609 self.width
610 .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width)
611 }
612
613 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
614 self.width = size;
615 self.serialize(cx);
616 cx.notify();
617 }
618
619 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
620 self.active = active;
621 if self.notification_store.read(cx).notification_count() == 0 {
622 cx.emit(Event::Dismissed);
623 }
624 }
625
626 fn icon(&self, cx: &gpui::WindowContext) -> Option<Icon> {
627 (NotificationPanelSettings::get_global(cx).button
628 && self.notification_store.read(cx).notification_count() > 0)
629 .then(|| Icon::Bell)
630 }
631
632 fn icon_label(&self, cx: &WindowContext) -> Option<String> {
633 let count = self.notification_store.read(cx).unread_notification_count();
634 if count == 0 {
635 None
636 } else {
637 Some(count.to_string())
638 }
639 }
640
641 fn toggle_action(&self) -> Box<dyn gpui::Action> {
642 Box::new(ToggleFocus)
643 }
644}
645
646pub struct NotificationToast {
647 notification_id: u64,
648 actor: Option<Arc<User>>,
649 text: String,
650 workspace: WeakView<Workspace>,
651}
652
653pub enum ToastEvent {
654 Dismiss,
655}
656
657impl NotificationToast {
658 fn focus_notification_panel(&self, cx: &mut ViewContext<Self>) {
659 let workspace = self.workspace.clone();
660 let notification_id = self.notification_id;
661 cx.defer(move |_, cx| {
662 workspace
663 .update(cx, |workspace, cx| {
664 if let Some(panel) = workspace.focus_panel::<NotificationPanel>(cx) {
665 panel.update(cx, |panel, cx| {
666 let store = panel.notification_store.read(cx);
667 if let Some(entry) = store.notification_for_id(notification_id) {
668 panel.did_click_notification(&entry.clone().notification, cx);
669 }
670 });
671 }
672 })
673 .ok();
674 })
675 }
676}
677
678impl Render for NotificationToast {
679 type Element = Stateful<Div>;
680
681 fn render(&mut self, cx: &mut ViewContext<Self>) -> Self::Element {
682 let user = self.actor.clone();
683
684 h_stack()
685 .id("notification_panel_toast")
686 .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
687 .child(Label::new(self.text.clone()))
688 .child(
689 IconButton::new("close", Icon::Close)
690 .on_click(cx.listener(|_, _, cx| cx.emit(ToastEvent::Dismiss))),
691 )
692 .on_click(cx.listener(|this, _, cx| {
693 this.focus_notification_panel(cx);
694 cx.emit(ToastEvent::Dismiss);
695 }))
696 }
697}
698
699impl EventEmitter<ToastEvent> for NotificationToast {}
700impl EventEmitter<DismissEvent> for NotificationToast {}
701
702fn format_timestamp(
703 mut timestamp: OffsetDateTime,
704 mut now: OffsetDateTime,
705 local_timezone: UtcOffset,
706) -> String {
707 timestamp = timestamp.to_offset(local_timezone);
708 now = now.to_offset(local_timezone);
709
710 let today = now.date();
711 let date = timestamp.date();
712 if date == today {
713 let difference = now - timestamp;
714 if difference >= Duration::from_secs(3600) {
715 format!("{}h", difference.whole_seconds() / 3600)
716 } else if difference >= Duration::from_secs(60) {
717 format!("{}m", difference.whole_seconds() / 60)
718 } else {
719 "just now".to_string()
720 }
721 } else if date.next_day() == Some(today) {
722 format!("yesterday")
723 } else {
724 format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
725 }
726}