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