notification_panel.rs

  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, &notification, 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(&notification, 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(&notification_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, .. } = &notification {
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}