notification_panel.rs

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