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