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