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