notification_panel.rs

  1use crate::NotificationPanelSettings;
  2use anyhow::Result;
  3use channel::ChannelStore;
  4use client::{ChannelId, Client, Notification, User, UserStore};
  5use collections::HashMap;
  6use db::kvp::KEY_VALUE_STORE;
  7use futures::StreamExt;
  8use gpui::{
  9    AnyElement, App, AsyncWindowContext, ClickEvent, Context, DismissEvent, Element, Entity,
 10    EventEmitter, FocusHandle, Focusable, InteractiveElement, IntoElement, ListAlignment,
 11    ListScrollEvent, ListState, ParentElement, Render, StatefulInteractiveElement, Styled, Task,
 12    WeakEntity, Window, actions, div, img, list, px,
 13};
 14use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
 15use project::Fs;
 16use rpc::proto;
 17use serde::{Deserialize, Serialize};
 18use settings::{Settings, SettingsStore};
 19use std::{sync::Arc, time::Duration};
 20use time::{OffsetDateTime, UtcOffset};
 21use ui::{
 22    Avatar, Button, Icon, IconButton, IconName, Label, Tab, Tooltip, h_flex, prelude::*, v_flex,
 23};
 24use util::{ResultExt, TryFutureExt};
 25use workspace::notifications::{
 26    Notification as WorkspaceNotification, NotificationId, SuppressEvent,
 27};
 28use workspace::{
 29    Workspace,
 30    dock::{DockPosition, Panel, PanelEvent},
 31};
 32
 33const LOADING_THRESHOLD: usize = 30;
 34const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);
 35const TOAST_DURATION: Duration = Duration::from_secs(5);
 36const NOTIFICATION_PANEL_KEY: &str = "NotificationPanel";
 37
 38pub struct NotificationPanel {
 39    client: Arc<Client>,
 40    user_store: Entity<UserStore>,
 41    channel_store: Entity<ChannelStore>,
 42    notification_store: Entity<NotificationStore>,
 43    fs: Arc<dyn Fs>,
 44    width: Option<Pixels>,
 45    active: bool,
 46    notification_list: ListState,
 47    pending_serialization: Task<Option<()>>,
 48    subscriptions: Vec<gpui::Subscription>,
 49    workspace: WeakEntity<Workspace>,
 50    current_notification_toast: Option<(u64, Task<()>)>,
 51    local_timezone: UtcOffset,
 52    focus_handle: FocusHandle,
 53    mark_as_read_tasks: HashMap<u64, Task<Result<()>>>,
 54    unseen_notifications: Vec<NotificationEntry>,
 55}
 56
 57#[derive(Serialize, Deserialize)]
 58struct SerializedNotificationPanel {
 59    width: Option<Pixels>,
 60}
 61
 62#[derive(Debug)]
 63pub enum Event {
 64    DockPositionChanged,
 65    Focus,
 66    Dismissed,
 67}
 68
 69pub struct NotificationPresenter {
 70    pub actor: Option<Arc<client::User>>,
 71    pub text: String,
 72    pub icon: &'static str,
 73    pub needs_response: bool,
 74}
 75
 76actions!(
 77    notification_panel,
 78    [
 79        /// Toggles focus on the notification panel.
 80        ToggleFocus
 81    ]
 82);
 83
 84pub fn init(cx: &mut App) {
 85    cx.observe_new(|workspace: &mut Workspace, _, _| {
 86        workspace.register_action(|workspace, _: &ToggleFocus, window, cx| {
 87            workspace.toggle_panel_focus::<NotificationPanel>(window, cx);
 88        });
 89    })
 90    .detach();
 91}
 92
 93impl NotificationPanel {
 94    pub fn new(
 95        workspace: &mut Workspace,
 96        window: &mut Window,
 97        cx: &mut Context<Workspace>,
 98    ) -> Entity<Self> {
 99        let fs = workspace.app_state().fs.clone();
100        let client = workspace.app_state().client.clone();
101        let user_store = workspace.app_state().user_store.clone();
102        let workspace_handle = workspace.weak_handle();
103
104        cx.new(|cx| {
105            let mut status = client.status();
106            cx.spawn_in(window, async move |this, cx| {
107                while (status.next().await).is_some() {
108                    if this
109                        .update(cx, |_: &mut Self, cx| {
110                            cx.notify();
111                        })
112                        .is_err()
113                    {
114                        break;
115                    }
116                }
117            })
118            .detach();
119
120            let notification_list = ListState::new(0, ListAlignment::Top, px(1000.));
121            notification_list.set_scroll_handler(cx.listener(
122                |this, event: &ListScrollEvent, _, cx| {
123                    if event.count.saturating_sub(event.visible_range.end) < LOADING_THRESHOLD
124                        && let Some(task) = this
125                            .notification_store
126                            .update(cx, |store, cx| store.load_more_notifications(false, cx))
127                    {
128                        task.detach();
129                    }
130                },
131            ));
132
133            let local_offset = chrono::Local::now().offset().local_minus_utc();
134            let mut this = Self {
135                fs,
136                client,
137                user_store,
138                local_timezone: UtcOffset::from_whole_seconds(local_offset).unwrap(),
139                channel_store: ChannelStore::global(cx),
140                notification_store: NotificationStore::global(cx),
141                notification_list,
142                pending_serialization: Task::ready(None),
143                workspace: workspace_handle,
144                focus_handle: cx.focus_handle(),
145                current_notification_toast: None,
146                subscriptions: Vec::new(),
147                active: false,
148                mark_as_read_tasks: HashMap::default(),
149                width: None,
150                unseen_notifications: Vec::new(),
151            };
152
153            let mut old_dock_position = this.position(window, cx);
154            this.subscriptions.extend([
155                cx.observe(&this.notification_store, |_, _, cx| cx.notify()),
156                cx.subscribe_in(
157                    &this.notification_store,
158                    window,
159                    Self::on_notification_event,
160                ),
161                cx.observe_global_in::<SettingsStore>(
162                    window,
163                    move |this: &mut Self, window, cx| {
164                        let new_dock_position = this.position(window, cx);
165                        if new_dock_position != old_dock_position {
166                            old_dock_position = new_dock_position;
167                            cx.emit(Event::DockPositionChanged);
168                        }
169                        cx.notify();
170                    },
171                ),
172            ]);
173            this
174        })
175    }
176
177    pub fn load(
178        workspace: WeakEntity<Workspace>,
179        cx: AsyncWindowContext,
180    ) -> Task<Result<Entity<Self>>> {
181        cx.spawn(async move |cx| {
182            let serialized_panel = if let Some(panel) = cx
183                .background_spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
184                .await
185                .log_err()
186                .flatten()
187            {
188                Some(serde_json::from_str::<SerializedNotificationPanel>(&panel)?)
189            } else {
190                None
191            };
192
193            workspace.update_in(cx, |workspace, window, cx| {
194                let panel = Self::new(workspace, window, cx);
195                if let Some(serialized_panel) = serialized_panel {
196                    panel.update(cx, |panel, cx| {
197                        panel.width = serialized_panel.width.map(|w| w.round());
198                        cx.notify();
199                    });
200                }
201                panel
202            })
203        })
204    }
205
206    fn serialize(&mut self, cx: &mut Context<Self>) {
207        let width = self.width;
208        self.pending_serialization = cx.background_spawn(
209            async move {
210                KEY_VALUE_STORE
211                    .write_kvp(
212                        NOTIFICATION_PANEL_KEY.into(),
213                        serde_json::to_string(&SerializedNotificationPanel { width })?,
214                    )
215                    .await?;
216                anyhow::Ok(())
217            }
218            .log_err(),
219        );
220    }
221
222    fn render_notification(
223        &mut self,
224        ix: usize,
225        window: &mut Window,
226        cx: &mut Context<Self>,
227    ) -> Option<AnyElement> {
228        let entry = self.notification_store.read(cx).notification_at(ix)?;
229        let notification_id = entry.id;
230        let now = OffsetDateTime::now_utc();
231        let timestamp = entry.timestamp;
232        let NotificationPresenter {
233            actor,
234            text,
235            needs_response,
236            ..
237        } = self.present_notification(entry, cx)?;
238
239        let response = entry.response;
240        let notification = entry.notification.clone();
241
242        if self.active && !entry.is_read {
243            self.did_render_notification(notification_id, &notification, window, cx);
244        }
245
246        let relative_timestamp = time_format::format_localized_timestamp(
247            timestamp,
248            now,
249            self.local_timezone,
250            time_format::TimestampFormat::Relative,
251        );
252
253        let absolute_timestamp = time_format::format_localized_timestamp(
254            timestamp,
255            now,
256            self.local_timezone,
257            time_format::TimestampFormat::Absolute,
258        );
259
260        Some(
261            div()
262                .id(ix)
263                .flex()
264                .flex_row()
265                .size_full()
266                .px_2()
267                .py_1()
268                .gap_2()
269                .hover(|style| style.bg(cx.theme().colors().element_hover))
270                .children(actor.map(|actor| {
271                    img(actor.avatar_uri.clone())
272                        .flex_none()
273                        .w_8()
274                        .h_8()
275                        .rounded_full()
276                }))
277                .child(
278                    v_flex()
279                        .gap_1()
280                        .size_full()
281                        .overflow_hidden()
282                        .child(Label::new(text))
283                        .child(
284                            h_flex()
285                                .child(
286                                    div()
287                                        .id("notification_timestamp")
288                                        .hover(|style| {
289                                            style
290                                                .bg(cx.theme().colors().element_selected)
291                                                .rounded_sm()
292                                        })
293                                        .child(Label::new(relative_timestamp).color(Color::Muted))
294                                        .tooltip(move |_, cx| {
295                                            Tooltip::simple(absolute_timestamp.clone(), cx)
296                                        }),
297                                )
298                                .children(if let Some(is_accepted) = response {
299                                    Some(div().flex().flex_grow().justify_end().child(Label::new(
300                                        if is_accepted {
301                                            "You accepted"
302                                        } else {
303                                            "You declined"
304                                        },
305                                    )))
306                                } else if needs_response {
307                                    Some(
308                                        h_flex()
309                                            .flex_grow()
310                                            .justify_end()
311                                            .child(Button::new("decline", "Decline").on_click({
312                                                let notification = notification.clone();
313                                                let entity = cx.entity();
314                                                move |_, _, cx| {
315                                                    entity.update(cx, |this, cx| {
316                                                        this.respond_to_notification(
317                                                            notification.clone(),
318                                                            false,
319                                                            cx,
320                                                        )
321                                                    });
322                                                }
323                                            }))
324                                            .child(Button::new("accept", "Accept").on_click({
325                                                let notification = notification.clone();
326                                                let entity = cx.entity();
327                                                move |_, _, cx| {
328                                                    entity.update(cx, |this, cx| {
329                                                        this.respond_to_notification(
330                                                            notification.clone(),
331                                                            true,
332                                                            cx,
333                                                        )
334                                                    });
335                                                }
336                                            })),
337                                    )
338                                } else {
339                                    None
340                                }),
341                        ),
342                )
343                .into_any(),
344        )
345    }
346
347    fn present_notification(
348        &self,
349        entry: &NotificationEntry,
350        cx: &App,
351    ) -> Option<NotificationPresenter> {
352        let user_store = self.user_store.read(cx);
353        let channel_store = self.channel_store.read(cx);
354        match entry.notification {
355            Notification::ContactRequest { sender_id } => {
356                let requester = user_store.get_cached_user(sender_id)?;
357                Some(NotificationPresenter {
358                    icon: "icons/plus.svg",
359                    text: format!("{} wants to add you as a contact", requester.github_login),
360                    needs_response: user_store.has_incoming_contact_request(requester.id),
361                    actor: Some(requester),
362                })
363            }
364            Notification::ContactRequestAccepted { responder_id } => {
365                let responder = user_store.get_cached_user(responder_id)?;
366                Some(NotificationPresenter {
367                    icon: "icons/plus.svg",
368                    text: format!("{} accepted your contact invite", responder.github_login),
369                    needs_response: false,
370                    actor: Some(responder),
371                })
372            }
373            Notification::ChannelInvitation {
374                ref channel_name,
375                channel_id,
376                inviter_id,
377            } => {
378                let inviter = user_store.get_cached_user(inviter_id)?;
379                Some(NotificationPresenter {
380                    icon: "icons/hash.svg",
381                    text: format!(
382                        "{} invited you to join the #{channel_name} channel",
383                        inviter.github_login
384                    ),
385                    needs_response: channel_store.has_channel_invitation(ChannelId(channel_id)),
386                    actor: Some(inviter),
387                })
388            }
389        }
390    }
391
392    fn did_render_notification(
393        &mut self,
394        notification_id: u64,
395        notification: &Notification,
396        window: &mut Window,
397        cx: &mut Context<Self>,
398    ) {
399        let should_mark_as_read = match notification {
400            Notification::ContactRequestAccepted { .. } => true,
401            Notification::ContactRequest { .. } | Notification::ChannelInvitation { .. } => false,
402        };
403
404        if should_mark_as_read {
405            self.mark_as_read_tasks
406                .entry(notification_id)
407                .or_insert_with(|| {
408                    let client = self.client.clone();
409                    cx.spawn_in(window, async move |this, cx| {
410                        cx.background_executor().timer(MARK_AS_READ_DELAY).await;
411                        client
412                            .request(proto::MarkNotificationRead { notification_id })
413                            .await?;
414                        this.update(cx, |this, _| {
415                            this.mark_as_read_tasks.remove(&notification_id);
416                        })?;
417                        Ok(())
418                    })
419                });
420        }
421    }
422
423    fn on_notification_event(
424        &mut self,
425        _: &Entity<NotificationStore>,
426        event: &NotificationEvent,
427        window: &mut Window,
428        cx: &mut Context<Self>,
429    ) {
430        match event {
431            NotificationEvent::NewNotification { entry } => {
432                self.unseen_notifications.push(entry.clone());
433                self.add_toast(entry, window, cx);
434            }
435            NotificationEvent::NotificationRemoved { entry }
436            | NotificationEvent::NotificationRead { entry } => {
437                self.unseen_notifications.retain(|n| n.id != entry.id);
438                self.remove_toast(entry.id, cx);
439            }
440            NotificationEvent::NotificationsUpdated {
441                old_range,
442                new_count,
443            } => {
444                self.notification_list.splice(old_range.clone(), *new_count);
445                cx.notify();
446            }
447        }
448    }
449
450    fn add_toast(
451        &mut self,
452        entry: &NotificationEntry,
453        window: &mut Window,
454        cx: &mut Context<Self>,
455    ) {
456        let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
457        else {
458            return;
459        };
460
461        let notification_id = entry.id;
462        self.current_notification_toast = Some((
463            notification_id,
464            cx.spawn_in(window, async move |this, cx| {
465                cx.background_executor().timer(TOAST_DURATION).await;
466                this.update(cx, |this, cx| this.remove_toast(notification_id, cx))
467                    .ok();
468            }),
469        ));
470
471        self.workspace
472            .update(cx, |workspace, cx| {
473                let id = NotificationId::unique::<NotificationToast>();
474
475                workspace.dismiss_notification(&id, cx);
476                workspace.show_notification(id, cx, |cx| {
477                    let workspace = cx.entity().downgrade();
478                    cx.new(|cx| NotificationToast {
479                        actor,
480                        text,
481                        workspace,
482                        focus_handle: cx.focus_handle(),
483                    })
484                })
485            })
486            .ok();
487    }
488
489    fn remove_toast(&mut self, notification_id: u64, cx: &mut Context<Self>) {
490        if let Some((current_id, _)) = &self.current_notification_toast
491            && *current_id == notification_id
492        {
493            self.current_notification_toast.take();
494            self.workspace
495                .update(cx, |workspace, cx| {
496                    let id = NotificationId::unique::<NotificationToast>();
497                    workspace.dismiss_notification(&id, cx)
498                })
499                .ok();
500        }
501    }
502
503    fn respond_to_notification(
504        &mut self,
505        notification: Notification,
506        response: bool,
507
508        cx: &mut Context<Self>,
509    ) {
510        self.notification_store.update(cx, |store, cx| {
511            store.respond_to_notification(notification, response, cx);
512        });
513    }
514}
515
516impl Render for NotificationPanel {
517    fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
518        v_flex()
519            .size_full()
520            .child(
521                h_flex()
522                    .justify_between()
523                    .px_2()
524                    .py_1()
525                    // Match the height of the tab bar so they line up.
526                    .h(Tab::container_height(cx))
527                    .border_b_1()
528                    .border_color(cx.theme().colors().border)
529                    .child(Label::new("Notifications"))
530                    .child(Icon::new(IconName::Envelope)),
531            )
532            .map(|this| {
533                if !self.client.status().borrow().is_connected() {
534                    this.child(
535                        v_flex()
536                            .gap_2()
537                            .p_4()
538                            .child(
539                                Button::new("connect_prompt_button", "Connect")
540                                    .icon_color(Color::Muted)
541                                    .icon(IconName::Github)
542                                    .icon_position(IconPosition::Start)
543                                    .style(ButtonStyle::Filled)
544                                    .full_width()
545                                    .on_click({
546                                        let client = self.client.clone();
547                                        move |_, window, cx| {
548                                            let client = client.clone();
549                                            window
550                                                .spawn(cx, async move |cx| {
551                                                    match client.connect(true, cx).await {
552                                                        util::ConnectionResult::Timeout => {
553                                                            log::error!("Connection timeout");
554                                                        }
555                                                        util::ConnectionResult::ConnectionReset => {
556                                                            log::error!("Connection reset");
557                                                        }
558                                                        util::ConnectionResult::Result(r) => {
559                                                            r.log_err();
560                                                        }
561                                                    }
562                                                })
563                                                .detach()
564                                        }
565                                    }),
566                            )
567                            .child(
568                                div().flex().w_full().items_center().child(
569                                    Label::new("Connect to view notifications.")
570                                        .color(Color::Muted)
571                                        .size(LabelSize::Small),
572                                ),
573                            ),
574                    )
575                } else if self.notification_list.item_count() == 0 {
576                    this.child(
577                        v_flex().p_4().child(
578                            div().flex().w_full().items_center().child(
579                                Label::new("You have no notifications.")
580                                    .color(Color::Muted)
581                                    .size(LabelSize::Small),
582                            ),
583                        ),
584                    )
585                } else {
586                    this.child(
587                        list(
588                            self.notification_list.clone(),
589                            cx.processor(|this, ix, window, cx| {
590                                this.render_notification(ix, window, cx)
591                                    .unwrap_or_else(|| div().into_any())
592                            }),
593                        )
594                        .size_full(),
595                    )
596                }
597            })
598    }
599}
600
601impl Focusable for NotificationPanel {
602    fn focus_handle(&self, _: &App) -> FocusHandle {
603        self.focus_handle.clone()
604    }
605}
606
607impl EventEmitter<Event> for NotificationPanel {}
608impl EventEmitter<PanelEvent> for NotificationPanel {}
609
610impl Panel for NotificationPanel {
611    fn persistent_name() -> &'static str {
612        "NotificationPanel"
613    }
614
615    fn position(&self, _: &Window, cx: &App) -> DockPosition {
616        NotificationPanelSettings::get_global(cx).dock
617    }
618
619    fn position_is_valid(&self, position: DockPosition) -> bool {
620        matches!(position, DockPosition::Left | DockPosition::Right)
621    }
622
623    fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
624        settings::update_settings_file(self.fs.clone(), cx, move |settings, _| {
625            settings.notification_panel.get_or_insert_default().dock = Some(position.into())
626        });
627    }
628
629    fn size(&self, _: &Window, cx: &App) -> Pixels {
630        self.width
631            .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width)
632    }
633
634    fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
635        self.width = size;
636        self.serialize(cx);
637        cx.notify();
638    }
639
640    fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context<Self>) {
641        self.active = active;
642
643        if self.active {
644            self.unseen_notifications = Vec::new();
645            cx.notify();
646        }
647
648        if self.notification_store.read(cx).notification_count() == 0 {
649            cx.emit(Event::Dismissed);
650        }
651    }
652
653    fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
654        let show_button = NotificationPanelSettings::get_global(cx).button;
655        if !show_button {
656            return None;
657        }
658
659        if self.unseen_notifications.is_empty() {
660            return Some(IconName::Bell);
661        }
662
663        Some(IconName::BellDot)
664    }
665
666    fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
667        Some("Notification Panel")
668    }
669
670    fn icon_label(&self, _window: &Window, cx: &App) -> Option<String> {
671        let count = self.notification_store.read(cx).unread_notification_count();
672        if count == 0 {
673            None
674        } else {
675            Some(count.to_string())
676        }
677    }
678
679    fn toggle_action(&self) -> Box<dyn gpui::Action> {
680        Box::new(ToggleFocus)
681    }
682
683    fn activation_priority(&self) -> u32 {
684        8
685    }
686}
687
688pub struct NotificationToast {
689    actor: Option<Arc<User>>,
690    text: String,
691    workspace: WeakEntity<Workspace>,
692    focus_handle: FocusHandle,
693}
694
695impl Focusable for NotificationToast {
696    fn focus_handle(&self, _cx: &App) -> FocusHandle {
697        self.focus_handle.clone()
698    }
699}
700
701impl WorkspaceNotification for NotificationToast {}
702
703impl NotificationToast {
704    fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
705        let workspace = self.workspace.clone();
706        window.defer(cx, move |window, cx| {
707            workspace
708                .update(cx, |workspace, cx| {
709                    workspace.focus_panel::<NotificationPanel>(window, cx)
710                })
711                .ok();
712        })
713    }
714}
715
716impl Render for NotificationToast {
717    fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
718        let user = self.actor.clone();
719
720        let suppress = window.modifiers().shift;
721        let (close_id, close_icon) = if suppress {
722            ("suppress", IconName::Minimize)
723        } else {
724            ("close", IconName::Close)
725        };
726
727        h_flex()
728            .id("notification_panel_toast")
729            .elevation_3(cx)
730            .p_2()
731            .justify_between()
732            .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
733            .child(Label::new(self.text.clone()))
734            .on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify()))
735            .child(
736                IconButton::new(close_id, close_icon)
737                    .tooltip(move |window, cx| {
738                        if suppress {
739                            Tooltip::for_action(
740                                "Suppress.\nClose with click.",
741                                &workspace::SuppressNotification,
742                                window,
743                                cx,
744                            )
745                        } else {
746                            Tooltip::for_action(
747                                "Close.\nSuppress with shift-click",
748                                &menu::Cancel,
749                                window,
750                                cx,
751                            )
752                        }
753                    })
754                    .on_click(cx.listener(move |_, _: &ClickEvent, _, cx| {
755                        if suppress {
756                            cx.emit(SuppressEvent);
757                        } else {
758                            cx.emit(DismissEvent);
759                        }
760                    })),
761            )
762            .on_click(cx.listener(|this, _, window, cx| {
763                this.focus_notification_panel(window, cx);
764                cx.emit(DismissEvent);
765            }))
766    }
767}
768
769impl EventEmitter<DismissEvent> for NotificationToast {}
770impl EventEmitter<SuppressEvent> for NotificationToast {}