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