notification_panel.rs

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