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