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", cx).on_click(
323 {
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 ))
337 .child(Button::new("accept", "Accept", cx).on_click({
338 let notification = notification.clone();
339 let entity = cx.entity().clone();
340 move |_, _, cx| {
341 entity.update(cx, |this, cx| {
342 this.respond_to_notification(
343 notification.clone(),
344 true,
345 cx,
346 )
347 });
348 }
349 })),
350 )
351 } else {
352 None
353 }),
354 ),
355 )
356 .into_any(),
357 )
358 }
359
360 fn present_notification(
361 &self,
362 entry: &NotificationEntry,
363 cx: &App,
364 ) -> Option<NotificationPresenter> {
365 let user_store = self.user_store.read(cx);
366 let channel_store = self.channel_store.read(cx);
367 match entry.notification {
368 Notification::ContactRequest { sender_id } => {
369 let requester = user_store.get_cached_user(sender_id)?;
370 Some(NotificationPresenter {
371 icon: "icons/plus.svg",
372 text: format!("{} wants to add you as a contact", requester.github_login),
373 needs_response: user_store.has_incoming_contact_request(requester.id),
374 actor: Some(requester),
375 can_navigate: false,
376 })
377 }
378 Notification::ContactRequestAccepted { responder_id } => {
379 let responder = user_store.get_cached_user(responder_id)?;
380 Some(NotificationPresenter {
381 icon: "icons/plus.svg",
382 text: format!("{} accepted your contact invite", responder.github_login),
383 needs_response: false,
384 actor: Some(responder),
385 can_navigate: false,
386 })
387 }
388 Notification::ChannelInvitation {
389 ref channel_name,
390 channel_id,
391 inviter_id,
392 } => {
393 let inviter = user_store.get_cached_user(inviter_id)?;
394 Some(NotificationPresenter {
395 icon: "icons/hash.svg",
396 text: format!(
397 "{} invited you to join the #{channel_name} channel",
398 inviter.github_login
399 ),
400 needs_response: channel_store.has_channel_invitation(ChannelId(channel_id)),
401 actor: Some(inviter),
402 can_navigate: false,
403 })
404 }
405 Notification::ChannelMessageMention {
406 sender_id,
407 channel_id,
408 message_id,
409 } => {
410 let sender = user_store.get_cached_user(sender_id)?;
411 let channel = channel_store.channel_for_id(ChannelId(channel_id))?;
412 let message = self
413 .notification_store
414 .read(cx)
415 .channel_message_for_id(message_id)?;
416 Some(NotificationPresenter {
417 icon: "icons/conversations.svg",
418 text: format!(
419 "{} mentioned you in #{}:\n{}",
420 sender.github_login, channel.name, message.body,
421 ),
422 needs_response: false,
423 actor: Some(sender),
424 can_navigate: true,
425 })
426 }
427 }
428 }
429
430 fn did_render_notification(
431 &mut self,
432 notification_id: u64,
433 notification: &Notification,
434 window: &mut Window,
435 cx: &mut Context<Self>,
436 ) {
437 let should_mark_as_read = match notification {
438 Notification::ContactRequestAccepted { .. } => true,
439 Notification::ContactRequest { .. }
440 | Notification::ChannelInvitation { .. }
441 | Notification::ChannelMessageMention { .. } => false,
442 };
443
444 if should_mark_as_read {
445 self.mark_as_read_tasks
446 .entry(notification_id)
447 .or_insert_with(|| {
448 let client = self.client.clone();
449 cx.spawn_in(window, async move |this, cx| {
450 cx.background_executor().timer(MARK_AS_READ_DELAY).await;
451 client
452 .request(proto::MarkNotificationRead { notification_id })
453 .await?;
454 this.update(cx, |this, _| {
455 this.mark_as_read_tasks.remove(¬ification_id);
456 })?;
457 Ok(())
458 })
459 });
460 }
461 }
462
463 fn did_click_notification(
464 &mut self,
465 notification: &Notification,
466 window: &mut Window,
467 cx: &mut Context<Self>,
468 ) {
469 if let Notification::ChannelMessageMention {
470 message_id,
471 channel_id,
472 ..
473 } = notification.clone()
474 {
475 if let Some(workspace) = self.workspace.upgrade() {
476 window.defer(cx, move |window, cx| {
477 workspace.update(cx, |workspace, cx| {
478 if let Some(panel) = workspace.focus_panel::<ChatPanel>(window, cx) {
479 panel.update(cx, |panel, cx| {
480 panel
481 .select_channel(ChannelId(channel_id), Some(message_id), cx)
482 .detach_and_log_err(cx);
483 });
484 }
485 });
486 });
487 }
488 }
489 }
490
491 fn is_showing_notification(&self, notification: &Notification, cx: &mut Context<Self>) -> bool {
492 if !self.active {
493 return false;
494 }
495
496 if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification {
497 if let Some(workspace) = self.workspace.upgrade() {
498 return if let Some(panel) = workspace.read(cx).panel::<ChatPanel>(cx) {
499 let panel = panel.read(cx);
500 panel.is_scrolled_to_bottom()
501 && panel
502 .active_chat()
503 .map_or(false, |chat| chat.read(cx).channel_id.0 == *channel_id)
504 } else {
505 false
506 };
507 }
508 }
509
510 false
511 }
512
513 fn on_notification_event(
514 &mut self,
515 _: &Entity<NotificationStore>,
516 event: &NotificationEvent,
517 window: &mut Window,
518 cx: &mut Context<Self>,
519 ) {
520 match event {
521 NotificationEvent::NewNotification { entry } => {
522 if !self.is_showing_notification(&entry.notification, cx) {
523 self.unseen_notifications.push(entry.clone());
524 }
525 self.add_toast(entry, window, cx);
526 }
527 NotificationEvent::NotificationRemoved { entry }
528 | NotificationEvent::NotificationRead { entry } => {
529 self.unseen_notifications.retain(|n| n.id != entry.id);
530 self.remove_toast(entry.id, cx);
531 }
532 NotificationEvent::NotificationsUpdated {
533 old_range,
534 new_count,
535 } => {
536 self.notification_list.splice(old_range.clone(), *new_count);
537 cx.notify();
538 }
539 }
540 }
541
542 fn add_toast(
543 &mut self,
544 entry: &NotificationEntry,
545 window: &mut Window,
546 cx: &mut Context<Self>,
547 ) {
548 if self.is_showing_notification(&entry.notification, cx) {
549 return;
550 }
551
552 let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
553 else {
554 return;
555 };
556
557 let notification_id = entry.id;
558 self.current_notification_toast = Some((
559 notification_id,
560 cx.spawn_in(window, async move |this, cx| {
561 cx.background_executor().timer(TOAST_DURATION).await;
562 this.update(cx, |this, cx| this.remove_toast(notification_id, cx))
563 .ok();
564 }),
565 ));
566
567 self.workspace
568 .update(cx, |workspace, cx| {
569 let id = NotificationId::unique::<NotificationToast>();
570
571 workspace.dismiss_notification(&id, cx);
572 workspace.show_notification(id, cx, |cx| {
573 let workspace = cx.entity().downgrade();
574 cx.new(|cx| NotificationToast {
575 notification_id,
576 actor,
577 text,
578 workspace,
579 focus_handle: cx.focus_handle(),
580 })
581 })
582 })
583 .ok();
584 }
585
586 fn remove_toast(&mut self, notification_id: u64, cx: &mut Context<Self>) {
587 if let Some((current_id, _)) = &self.current_notification_toast {
588 if *current_id == notification_id {
589 self.current_notification_toast.take();
590 self.workspace
591 .update(cx, |workspace, cx| {
592 let id = NotificationId::unique::<NotificationToast>();
593 workspace.dismiss_notification(&id, cx)
594 })
595 .ok();
596 }
597 }
598 }
599
600 fn respond_to_notification(
601 &mut self,
602 notification: Notification,
603 response: bool,
604
605 cx: &mut Context<Self>,
606 ) {
607 self.notification_store.update(cx, |store, cx| {
608 store.respond_to_notification(notification, response, cx);
609 });
610 }
611}
612
613impl Render for NotificationPanel {
614 fn render(&mut self, _: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
615 v_flex()
616 .size_full()
617 .child(
618 h_flex()
619 .justify_between()
620 .px_2()
621 .py_1()
622 // Match the height of the tab bar so they line up.
623 .h(Tab::container_height(cx))
624 .border_b_1()
625 .border_color(cx.theme().colors().border)
626 .child(Label::new("Notifications"))
627 .child(Icon::new(IconName::Envelope)),
628 )
629 .map(|this| {
630 if !self.client.status().borrow().is_connected() {
631 this.child(
632 v_flex()
633 .gap_2()
634 .p_4()
635 .child(
636 Button::new("connect_prompt_button", "Connect", cx)
637 .icon_color(Color::Muted)
638 .icon(IconName::Github)
639 .icon_position(IconPosition::Start)
640 .style(ButtonStyle::Filled)
641 .full_width()
642 .on_click({
643 let client = self.client.clone();
644 move |_, window, cx| {
645 let client = client.clone();
646 window
647 .spawn(cx, async move |cx| {
648 match client.connect(true, &cx).await {
649 util::ConnectionResult::Timeout => {
650 log::error!("Connection timeout");
651 }
652 util::ConnectionResult::ConnectionReset => {
653 log::error!("Connection reset");
654 }
655 util::ConnectionResult::Result(r) => {
656 r.log_err();
657 }
658 }
659 })
660 .detach()
661 }
662 }),
663 )
664 .child(
665 div().flex().w_full().items_center().child(
666 Label::new("Connect to view notifications.")
667 .color(Color::Muted)
668 .size(LabelSize::Small),
669 ),
670 ),
671 )
672 } else if self.notification_list.item_count() == 0 {
673 this.child(
674 v_flex().p_4().child(
675 div().flex().w_full().items_center().child(
676 Label::new("You have no notifications.")
677 .color(Color::Muted)
678 .size(LabelSize::Small),
679 ),
680 ),
681 )
682 } else {
683 this.child(
684 list(
685 self.notification_list.clone(),
686 cx.processor(|this, ix, window, cx| {
687 this.render_notification(ix, window, cx)
688 .unwrap_or_else(|| div().into_any())
689 }),
690 )
691 .size_full(),
692 )
693 }
694 })
695 }
696}
697
698impl Focusable for NotificationPanel {
699 fn focus_handle(&self, _: &App) -> FocusHandle {
700 self.focus_handle.clone()
701 }
702}
703
704impl EventEmitter<Event> for NotificationPanel {}
705impl EventEmitter<PanelEvent> for NotificationPanel {}
706
707impl Panel for NotificationPanel {
708 fn persistent_name() -> &'static str {
709 "NotificationPanel"
710 }
711
712 fn position(&self, _: &Window, cx: &App) -> DockPosition {
713 NotificationPanelSettings::get_global(cx).dock
714 }
715
716 fn position_is_valid(&self, position: DockPosition) -> bool {
717 matches!(position, DockPosition::Left | DockPosition::Right)
718 }
719
720 fn set_position(&mut self, position: DockPosition, _: &mut Window, cx: &mut Context<Self>) {
721 settings::update_settings_file::<NotificationPanelSettings>(
722 self.fs.clone(),
723 cx,
724 move |settings, _| settings.dock = Some(position),
725 );
726 }
727
728 fn size(&self, _: &Window, cx: &App) -> Pixels {
729 self.width
730 .unwrap_or_else(|| NotificationPanelSettings::get_global(cx).default_width)
731 }
732
733 fn set_size(&mut self, size: Option<Pixels>, _: &mut Window, cx: &mut Context<Self>) {
734 self.width = size;
735 self.serialize(cx);
736 cx.notify();
737 }
738
739 fn set_active(&mut self, active: bool, _: &mut Window, cx: &mut Context<Self>) {
740 self.active = active;
741
742 if self.active {
743 self.unseen_notifications = Vec::new();
744 cx.notify();
745 }
746
747 if self.notification_store.read(cx).notification_count() == 0 {
748 cx.emit(Event::Dismissed);
749 }
750 }
751
752 fn icon(&self, _: &Window, cx: &App) -> Option<IconName> {
753 let show_button = NotificationPanelSettings::get_global(cx).button;
754 if !show_button {
755 return None;
756 }
757
758 if self.unseen_notifications.is_empty() {
759 return Some(IconName::Bell);
760 }
761
762 Some(IconName::BellDot)
763 }
764
765 fn icon_tooltip(&self, _window: &Window, _cx: &App) -> Option<&'static str> {
766 Some("Notification Panel")
767 }
768
769 fn icon_label(&self, _window: &Window, cx: &App) -> Option<String> {
770 let count = self.notification_store.read(cx).unread_notification_count();
771 if count == 0 {
772 None
773 } else {
774 Some(count.to_string())
775 }
776 }
777
778 fn toggle_action(&self) -> Box<dyn gpui::Action> {
779 Box::new(ToggleFocus)
780 }
781
782 fn activation_priority(&self) -> u32 {
783 8
784 }
785}
786
787pub struct NotificationToast {
788 notification_id: u64,
789 actor: Option<Arc<User>>,
790 text: String,
791 workspace: WeakEntity<Workspace>,
792 focus_handle: FocusHandle,
793}
794
795impl Focusable for NotificationToast {
796 fn focus_handle(&self, _cx: &App) -> FocusHandle {
797 self.focus_handle.clone()
798 }
799}
800
801impl WorkspaceNotification for NotificationToast {}
802
803impl NotificationToast {
804 fn focus_notification_panel(&self, window: &mut Window, cx: &mut Context<Self>) {
805 let workspace = self.workspace.clone();
806 let notification_id = self.notification_id;
807 window.defer(cx, move |window, cx| {
808 workspace
809 .update(cx, |workspace, cx| {
810 if let Some(panel) = workspace.focus_panel::<NotificationPanel>(window, cx) {
811 panel.update(cx, |panel, cx| {
812 let store = panel.notification_store.read(cx);
813 if let Some(entry) = store.notification_for_id(notification_id) {
814 panel.did_click_notification(
815 &entry.clone().notification,
816 window,
817 cx,
818 );
819 }
820 });
821 }
822 })
823 .ok();
824 })
825 }
826}
827
828impl Render for NotificationToast {
829 fn render(&mut self, window: &mut Window, cx: &mut Context<Self>) -> impl IntoElement {
830 let user = self.actor.clone();
831
832 let suppress = window.modifiers().shift;
833 let (close_id, close_icon) = if suppress {
834 ("suppress", IconName::Minimize)
835 } else {
836 ("close", IconName::Close)
837 };
838
839 h_flex()
840 .id("notification_panel_toast")
841 .elevation_3(cx)
842 .p_2()
843 .justify_between()
844 .children(user.map(|user| Avatar::new(user.avatar_uri.clone())))
845 .child(Label::new(self.text.clone()))
846 .on_modifiers_changed(cx.listener(|_, _, _, cx| cx.notify()))
847 .child(
848 IconButton::new(close_id, close_icon)
849 .tooltip(move |window, cx| {
850 if suppress {
851 Tooltip::for_action(
852 "Suppress.\nClose with click.",
853 &workspace::SuppressNotification,
854 window,
855 cx,
856 )
857 } else {
858 Tooltip::for_action(
859 "Close.\nSuppress with shift-click",
860 &menu::Cancel,
861 window,
862 cx,
863 )
864 }
865 })
866 .on_click(cx.listener(move |_, _: &ClickEvent, _, cx| {
867 if suppress {
868 cx.emit(SuppressEvent);
869 } else {
870 cx.emit(DismissEvent);
871 }
872 })),
873 )
874 .on_click(cx.listener(|this, _, window, cx| {
875 this.focus_notification_panel(window, cx);
876 cx.emit(DismissEvent);
877 }))
878 }
879}
880
881impl EventEmitter<DismissEvent> for NotificationToast {}
882impl EventEmitter<SuppressEvent> for NotificationToast {}