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