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