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