1use crate::{chat_panel::ChatPanel, render_avatar, NotificationPanelSettings};
2use anyhow::Result;
3use channel::ChannelStore;
4use client::{Client, Notification, User, UserStore};
5use collections::HashMap;
6use db::kvp::KEY_VALUE_STORE;
7use futures::StreamExt;
8use gpui::{
9 actions,
10 elements::*,
11 platform::{CursorStyle, MouseButton},
12 serde_json, AnyViewHandle, AppContext, AsyncAppContext, Entity, ModelHandle, Task, View,
13 ViewContext, ViewHandle, WeakViewHandle, WindowContext,
14};
15use notifications::{NotificationEntry, NotificationEvent, NotificationStore};
16use project::Fs;
17use rpc::proto;
18use serde::{Deserialize, Serialize};
19use settings::SettingsStore;
20use std::{sync::Arc, time::Duration};
21use theme::{ui, Theme};
22use time::{OffsetDateTime, UtcOffset};
23use util::{ResultExt, TryFutureExt};
24use workspace::{
25 dock::{DockPosition, Panel},
26 Workspace,
27};
28
29const LOADING_THRESHOLD: usize = 30;
30const MARK_AS_READ_DELAY: Duration = Duration::from_secs(1);
31const TOAST_DURATION: Duration = Duration::from_secs(5);
32const NOTIFICATION_PANEL_KEY: &'static str = "NotificationPanel";
33
34pub struct NotificationPanel {
35 client: Arc<Client>,
36 user_store: ModelHandle<UserStore>,
37 channel_store: ModelHandle<ChannelStore>,
38 notification_store: ModelHandle<NotificationStore>,
39 fs: Arc<dyn Fs>,
40 width: Option<f32>,
41 active: bool,
42 notification_list: ListState<Self>,
43 pending_serialization: Task<Option<()>>,
44 subscriptions: Vec<gpui::Subscription>,
45 workspace: WeakViewHandle<Workspace>,
46 current_notification_toast: Option<(u64, Task<()>)>,
47 local_timezone: UtcOffset,
48 has_focus: bool,
49 mark_as_read_tasks: HashMap<u64, Task<Result<()>>>,
50}
51
52#[derive(Serialize, Deserialize)]
53struct SerializedNotificationPanel {
54 width: Option<f32>,
55}
56
57#[derive(Debug)]
58pub enum Event {
59 DockPositionChanged,
60 Focus,
61 Dismissed,
62}
63
64pub struct NotificationPresenter {
65 pub actor: Option<Arc<client::User>>,
66 pub text: String,
67 pub icon: &'static str,
68 pub needs_response: bool,
69 pub can_navigate: bool,
70}
71
72actions!(notification_panel, [ToggleFocus]);
73
74pub fn init(_cx: &mut AppContext) {}
75
76impl NotificationPanel {
77 pub fn new(workspace: &mut Workspace, cx: &mut ViewContext<Workspace>) -> ViewHandle<Self> {
78 let fs = workspace.app_state().fs.clone();
79 let client = workspace.app_state().client.clone();
80 let user_store = workspace.app_state().user_store.clone();
81 let workspace_handle = workspace.weak_handle();
82
83 cx.add_view(|cx| {
84 let mut status = client.status();
85 cx.spawn(|this, mut cx| async move {
86 while let Some(_) = status.next().await {
87 if this
88 .update(&mut cx, |_, cx| {
89 cx.notify();
90 })
91 .is_err()
92 {
93 break;
94 }
95 }
96 })
97 .detach();
98
99 let mut notification_list =
100 ListState::<Self>::new(0, Orientation::Top, 1000., move |this, ix, cx| {
101 this.render_notification(ix, cx)
102 .unwrap_or_else(|| Empty::new().into_any())
103 });
104 notification_list.set_scroll_handler(|visible_range, count, this, cx| {
105 if count.saturating_sub(visible_range.end) < LOADING_THRESHOLD {
106 if let Some(task) = this
107 .notification_store
108 .update(cx, |store, cx| store.load_more_notifications(false, cx))
109 {
110 task.detach();
111 }
112 }
113 });
114
115 let mut this = Self {
116 fs,
117 client,
118 user_store,
119 local_timezone: cx.platform().local_timezone(),
120 channel_store: ChannelStore::global(cx),
121 notification_store: NotificationStore::global(cx),
122 notification_list,
123 pending_serialization: Task::ready(None),
124 workspace: workspace_handle,
125 has_focus: false,
126 current_notification_toast: None,
127 subscriptions: Vec::new(),
128 active: false,
129 mark_as_read_tasks: HashMap::default(),
130 width: None,
131 };
132
133 let mut old_dock_position = this.position(cx);
134 this.subscriptions.extend([
135 cx.observe(&this.notification_store, |_, _, cx| cx.notify()),
136 cx.subscribe(&this.notification_store, Self::on_notification_event),
137 cx.observe_global::<SettingsStore, _>(move |this: &mut Self, cx| {
138 let new_dock_position = this.position(cx);
139 if new_dock_position != old_dock_position {
140 old_dock_position = new_dock_position;
141 cx.emit(Event::DockPositionChanged);
142 }
143 cx.notify();
144 }),
145 ]);
146 this
147 })
148 }
149
150 pub fn load(
151 workspace: WeakViewHandle<Workspace>,
152 cx: AsyncAppContext,
153 ) -> Task<Result<ViewHandle<Self>>> {
154 cx.spawn(|mut cx| async move {
155 let serialized_panel = if let Some(panel) = cx
156 .background()
157 .spawn(async move { KEY_VALUE_STORE.read_kvp(NOTIFICATION_PANEL_KEY) })
158 .await
159 .log_err()
160 .flatten()
161 {
162 Some(serde_json::from_str::<SerializedNotificationPanel>(&panel)?)
163 } else {
164 None
165 };
166
167 workspace.update(&mut cx, |workspace, cx| {
168 let panel = Self::new(workspace, cx);
169 if let Some(serialized_panel) = serialized_panel {
170 panel.update(cx, |panel, cx| {
171 panel.width = serialized_panel.width;
172 cx.notify();
173 });
174 }
175 panel
176 })
177 })
178 }
179
180 fn serialize(&mut self, cx: &mut ViewContext<Self>) {
181 let width = self.width;
182 self.pending_serialization = cx.background().spawn(
183 async move {
184 KEY_VALUE_STORE
185 .write_kvp(
186 NOTIFICATION_PANEL_KEY.into(),
187 serde_json::to_string(&SerializedNotificationPanel { width })?,
188 )
189 .await?;
190 anyhow::Ok(())
191 }
192 .log_err(),
193 );
194 }
195
196 fn render_notification(
197 &mut self,
198 ix: usize,
199 cx: &mut ViewContext<Self>,
200 ) -> Option<AnyElement<Self>> {
201 let entry = self.notification_store.read(cx).notification_at(ix)?;
202 let notification_id = entry.id;
203 let now = OffsetDateTime::now_utc();
204 let timestamp = entry.timestamp;
205 let NotificationPresenter {
206 actor,
207 text,
208 needs_response,
209 can_navigate,
210 ..
211 } = self.present_notification(entry, cx)?;
212
213 let theme = theme::current(cx);
214 let style = &theme.notification_panel;
215 let response = entry.response;
216 let notification = entry.notification.clone();
217
218 let message_style = if entry.is_read {
219 style.read_text.clone()
220 } else {
221 style.unread_text.clone()
222 };
223
224 if self.active && !entry.is_read {
225 self.did_render_notification(notification_id, ¬ification, cx);
226 }
227
228 enum Decline {}
229 enum Accept {}
230
231 Some(
232 MouseEventHandler::new::<NotificationEntry, _>(ix, cx, |_, cx| {
233 let container = message_style.container;
234
235 Flex::row()
236 .with_children(actor.map(|actor| {
237 render_avatar(actor.avatar.clone(), &style.avatar, style.avatar_container)
238 }))
239 .with_child(
240 Flex::column()
241 .with_child(Text::new(text, message_style.text.clone()))
242 .with_child(
243 Flex::row()
244 .with_child(
245 Label::new(
246 format_timestamp(timestamp, now, self.local_timezone),
247 style.timestamp.text.clone(),
248 )
249 .contained()
250 .with_style(style.timestamp.container),
251 )
252 .with_children(if let Some(is_accepted) = response {
253 Some(
254 Label::new(
255 if is_accepted {
256 "You accepted"
257 } else {
258 "You declined"
259 },
260 style.read_text.text.clone(),
261 )
262 .flex_float()
263 .into_any(),
264 )
265 } else if needs_response {
266 Some(
267 Flex::row()
268 .with_children([
269 MouseEventHandler::new::<Decline, _>(
270 ix,
271 cx,
272 |state, _| {
273 let button =
274 style.button.style_for(state);
275 Label::new(
276 "Decline",
277 button.text.clone(),
278 )
279 .contained()
280 .with_style(button.container)
281 },
282 )
283 .with_cursor_style(CursorStyle::PointingHand)
284 .on_click(MouseButton::Left, {
285 let notification = notification.clone();
286 move |_, view, cx| {
287 view.respond_to_notification(
288 notification.clone(),
289 false,
290 cx,
291 );
292 }
293 }),
294 MouseEventHandler::new::<Accept, _>(
295 ix,
296 cx,
297 |state, _| {
298 let button =
299 style.button.style_for(state);
300 Label::new(
301 "Accept",
302 button.text.clone(),
303 )
304 .contained()
305 .with_style(button.container)
306 },
307 )
308 .with_cursor_style(CursorStyle::PointingHand)
309 .on_click(MouseButton::Left, {
310 let notification = notification.clone();
311 move |_, view, cx| {
312 view.respond_to_notification(
313 notification.clone(),
314 true,
315 cx,
316 );
317 }
318 }),
319 ])
320 .flex_float()
321 .into_any(),
322 )
323 } else {
324 None
325 }),
326 )
327 .flex(1.0, true),
328 )
329 .contained()
330 .with_style(container)
331 .into_any()
332 })
333 .with_cursor_style(if can_navigate {
334 CursorStyle::PointingHand
335 } else {
336 CursorStyle::default()
337 })
338 .on_click(MouseButton::Left, {
339 let notification = notification.clone();
340 move |_, this, cx| this.did_click_notification(¬ification, cx)
341 })
342 .into_any(),
343 )
344 }
345
346 fn present_notification(
347 &self,
348 entry: &NotificationEntry,
349 cx: &AppContext,
350 ) -> Option<NotificationPresenter> {
351 let user_store = self.user_store.read(cx);
352 let channel_store = self.channel_store.read(cx);
353 match entry.notification {
354 Notification::ContactRequest { sender_id } => {
355 let requester = user_store.get_cached_user(sender_id)?;
356 Some(NotificationPresenter {
357 icon: "icons/plus.svg",
358 text: format!("{} wants to add you as a contact", requester.github_login),
359 needs_response: user_store.has_incoming_contact_request(requester.id),
360 actor: Some(requester),
361 can_navigate: false,
362 })
363 }
364 Notification::ContactRequestAccepted { responder_id } => {
365 let responder = user_store.get_cached_user(responder_id)?;
366 Some(NotificationPresenter {
367 icon: "icons/plus.svg",
368 text: format!("{} accepted your contact invite", responder.github_login),
369 needs_response: false,
370 actor: Some(responder),
371 can_navigate: false,
372 })
373 }
374 Notification::ChannelInvitation {
375 ref channel_name,
376 channel_id,
377 inviter_id,
378 } => {
379 let inviter = user_store.get_cached_user(inviter_id)?;
380 Some(NotificationPresenter {
381 icon: "icons/hash.svg",
382 text: format!(
383 "{} invited you to join the #{channel_name} channel",
384 inviter.github_login
385 ),
386 needs_response: channel_store.has_channel_invitation(channel_id),
387 actor: Some(inviter),
388 can_navigate: false,
389 })
390 }
391 Notification::ChannelMessageMention {
392 sender_id,
393 channel_id,
394 message_id,
395 } => {
396 let sender = user_store.get_cached_user(sender_id)?;
397 let channel = channel_store.channel_for_id(channel_id)?;
398 let message = self
399 .notification_store
400 .read(cx)
401 .channel_message_for_id(message_id)?;
402 Some(NotificationPresenter {
403 icon: "icons/conversations.svg",
404 text: format!(
405 "{} mentioned you in #{}:\n{}",
406 sender.github_login, channel.name, message.body,
407 ),
408 needs_response: false,
409 actor: Some(sender),
410 can_navigate: true,
411 })
412 }
413 }
414 }
415
416 fn did_render_notification(
417 &mut self,
418 notification_id: u64,
419 notification: &Notification,
420 cx: &mut ViewContext<Self>,
421 ) {
422 let should_mark_as_read = match notification {
423 Notification::ContactRequestAccepted { .. } => true,
424 Notification::ContactRequest { .. }
425 | Notification::ChannelInvitation { .. }
426 | Notification::ChannelMessageMention { .. } => false,
427 };
428
429 if should_mark_as_read {
430 self.mark_as_read_tasks
431 .entry(notification_id)
432 .or_insert_with(|| {
433 let client = self.client.clone();
434 cx.spawn(|this, mut cx| async move {
435 cx.background().timer(MARK_AS_READ_DELAY).await;
436 client
437 .request(proto::MarkNotificationRead { notification_id })
438 .await?;
439 this.update(&mut cx, |this, _| {
440 this.mark_as_read_tasks.remove(¬ification_id);
441 })?;
442 Ok(())
443 })
444 });
445 }
446 }
447
448 fn did_click_notification(&mut self, notification: &Notification, cx: &mut ViewContext<Self>) {
449 if let Notification::ChannelMessageMention {
450 message_id,
451 channel_id,
452 ..
453 } = notification.clone()
454 {
455 if let Some(workspace) = self.workspace.upgrade(cx) {
456 cx.app_context().defer(move |cx| {
457 workspace.update(cx, |workspace, cx| {
458 if let Some(panel) = workspace.focus_panel::<ChatPanel>(cx) {
459 panel.update(cx, |panel, cx| {
460 panel
461 .select_channel(channel_id, Some(message_id), cx)
462 .detach_and_log_err(cx);
463 });
464 }
465 });
466 });
467 }
468 }
469 }
470
471 fn is_showing_notification(&self, notification: &Notification, cx: &AppContext) -> bool {
472 if let Notification::ChannelMessageMention { channel_id, .. } = ¬ification {
473 if let Some(workspace) = self.workspace.upgrade(cx) {
474 return workspace
475 .read_with(cx, |workspace, cx| {
476 if let Some(panel) = workspace.panel::<ChatPanel>(cx) {
477 return panel.read_with(cx, |panel, cx| {
478 panel.is_scrolled_to_bottom()
479 && panel.active_chat().map_or(false, |chat| {
480 chat.read(cx).channel_id == *channel_id
481 })
482 });
483 }
484 false
485 })
486 .unwrap_or_default();
487 }
488 }
489
490 false
491 }
492
493 fn render_sign_in_prompt(
494 &self,
495 theme: &Arc<Theme>,
496 cx: &mut ViewContext<Self>,
497 ) -> AnyElement<Self> {
498 enum SignInPromptLabel {}
499
500 MouseEventHandler::new::<SignInPromptLabel, _>(0, cx, |mouse_state, _| {
501 Label::new(
502 "Sign in to view your notifications".to_string(),
503 theme
504 .chat_panel
505 .sign_in_prompt
506 .style_for(mouse_state)
507 .clone(),
508 )
509 })
510 .with_cursor_style(CursorStyle::PointingHand)
511 .on_click(MouseButton::Left, move |_, this, cx| {
512 let client = this.client.clone();
513 cx.spawn(|_, cx| async move {
514 client.authenticate_and_connect(true, &cx).log_err().await;
515 })
516 .detach();
517 })
518 .aligned()
519 .into_any()
520 }
521
522 fn render_empty_state(
523 &self,
524 theme: &Arc<Theme>,
525 _cx: &mut ViewContext<Self>,
526 ) -> AnyElement<Self> {
527 Label::new(
528 "You have no notifications".to_string(),
529 theme.chat_panel.sign_in_prompt.default.clone(),
530 )
531 .aligned()
532 .into_any()
533 }
534
535 fn on_notification_event(
536 &mut self,
537 _: ModelHandle<NotificationStore>,
538 event: &NotificationEvent,
539 cx: &mut ViewContext<Self>,
540 ) {
541 match event {
542 NotificationEvent::NewNotification { entry } => self.add_toast(entry, cx),
543 NotificationEvent::NotificationRemoved { entry }
544 | NotificationEvent::NotificationRead { entry } => self.remove_toast(entry.id, cx),
545 NotificationEvent::NotificationsUpdated {
546 old_range,
547 new_count,
548 } => {
549 self.notification_list.splice(old_range.clone(), *new_count);
550 cx.notify();
551 }
552 }
553 }
554
555 fn add_toast(&mut self, entry: &NotificationEntry, cx: &mut ViewContext<Self>) {
556 if self.is_showing_notification(&entry.notification, cx) {
557 return;
558 }
559
560 let Some(NotificationPresenter { actor, text, .. }) = self.present_notification(entry, cx)
561 else {
562 return;
563 };
564
565 let notification_id = entry.id;
566 self.current_notification_toast = Some((
567 notification_id,
568 cx.spawn(|this, mut cx| async move {
569 cx.background().timer(TOAST_DURATION).await;
570 this.update(&mut cx, |this, cx| this.remove_toast(notification_id, cx))
571 .ok();
572 }),
573 ));
574
575 self.workspace
576 .update(cx, |workspace, cx| {
577 workspace.dismiss_notification::<NotificationToast>(0, cx);
578 workspace.show_notification(0, cx, |cx| {
579 let workspace = cx.weak_handle();
580 cx.add_view(|_| NotificationToast {
581 notification_id,
582 actor,
583 text,
584 workspace,
585 })
586 })
587 })
588 .ok();
589 }
590
591 fn remove_toast(&mut self, notification_id: u64, cx: &mut ViewContext<Self>) {
592 if let Some((current_id, _)) = &self.current_notification_toast {
593 if *current_id == notification_id {
594 self.current_notification_toast.take();
595 self.workspace
596 .update(cx, |workspace, cx| {
597 workspace.dismiss_notification::<NotificationToast>(0, cx)
598 })
599 .ok();
600 }
601 }
602 }
603
604 fn respond_to_notification(
605 &mut self,
606 notification: Notification,
607 response: bool,
608 cx: &mut ViewContext<Self>,
609 ) {
610 self.notification_store.update(cx, |store, cx| {
611 store.respond_to_notification(notification, response, cx);
612 });
613 }
614}
615
616impl Entity for NotificationPanel {
617 type Event = Event;
618}
619
620impl View for NotificationPanel {
621 fn ui_name() -> &'static str {
622 "NotificationPanel"
623 }
624
625 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
626 let theme = theme::current(cx);
627 let style = &theme.notification_panel;
628 let element = if self.client.user_id().is_none() {
629 self.render_sign_in_prompt(&theme, cx)
630 } else if self.notification_list.item_count() == 0 {
631 self.render_empty_state(&theme, cx)
632 } else {
633 Flex::column()
634 .with_child(
635 Flex::row()
636 .with_child(Label::new("Notifications", style.title.text.clone()))
637 .with_child(ui::svg(&style.title_icon).flex_float())
638 .align_children_center()
639 .contained()
640 .with_style(style.title.container)
641 .constrained()
642 .with_height(style.title_height),
643 )
644 .with_child(
645 List::new(self.notification_list.clone())
646 .contained()
647 .with_style(style.list)
648 .flex(1., true),
649 )
650 .into_any()
651 };
652 element
653 .contained()
654 .with_style(style.container)
655 .constrained()
656 .with_min_width(150.)
657 .into_any()
658 }
659
660 fn focus_in(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
661 self.has_focus = true;
662 }
663
664 fn focus_out(&mut self, _: AnyViewHandle, _: &mut ViewContext<Self>) {
665 self.has_focus = false;
666 }
667}
668
669impl Panel for NotificationPanel {
670 fn position(&self, cx: &gpui::WindowContext) -> DockPosition {
671 settings::get::<NotificationPanelSettings>(cx).dock
672 }
673
674 fn position_is_valid(&self, position: DockPosition) -> bool {
675 matches!(position, DockPosition::Left | DockPosition::Right)
676 }
677
678 fn set_position(&mut self, position: DockPosition, cx: &mut ViewContext<Self>) {
679 settings::update_settings_file::<NotificationPanelSettings>(
680 self.fs.clone(),
681 cx,
682 move |settings| settings.dock = Some(position),
683 );
684 }
685
686 fn size(&self, cx: &gpui::WindowContext) -> f32 {
687 self.width
688 .unwrap_or_else(|| settings::get::<NotificationPanelSettings>(cx).default_width)
689 }
690
691 fn set_size(&mut self, size: Option<f32>, cx: &mut ViewContext<Self>) {
692 self.width = size;
693 self.serialize(cx);
694 cx.notify();
695 }
696
697 fn set_active(&mut self, active: bool, cx: &mut ViewContext<Self>) {
698 self.active = active;
699 if self.notification_store.read(cx).notification_count() == 0 {
700 cx.emit(Event::Dismissed);
701 }
702 }
703
704 fn icon_path(&self, cx: &gpui::WindowContext) -> Option<&'static str> {
705 (settings::get::<NotificationPanelSettings>(cx).button
706 && self.notification_store.read(cx).notification_count() > 0)
707 .then(|| "icons/bell.svg")
708 }
709
710 fn icon_tooltip(&self) -> (String, Option<Box<dyn gpui::Action>>) {
711 (
712 "Notification Panel".to_string(),
713 Some(Box::new(ToggleFocus)),
714 )
715 }
716
717 fn icon_label(&self, cx: &WindowContext) -> Option<String> {
718 let count = self.notification_store.read(cx).unread_notification_count();
719 if count == 0 {
720 None
721 } else {
722 Some(count.to_string())
723 }
724 }
725
726 fn should_change_position_on_event(event: &Self::Event) -> bool {
727 matches!(event, Event::DockPositionChanged)
728 }
729
730 fn should_close_on_event(event: &Self::Event) -> bool {
731 matches!(event, Event::Dismissed)
732 }
733
734 fn has_focus(&self, _cx: &gpui::WindowContext) -> bool {
735 self.has_focus
736 }
737
738 fn is_focus_event(event: &Self::Event) -> bool {
739 matches!(event, Event::Focus)
740 }
741}
742
743pub struct NotificationToast {
744 notification_id: u64,
745 actor: Option<Arc<User>>,
746 text: String,
747 workspace: WeakViewHandle<Workspace>,
748}
749
750pub enum ToastEvent {
751 Dismiss,
752}
753
754impl NotificationToast {
755 fn focus_notification_panel(&self, cx: &mut AppContext) {
756 let workspace = self.workspace.clone();
757 let notification_id = self.notification_id;
758 cx.defer(move |cx| {
759 workspace
760 .update(cx, |workspace, cx| {
761 if let Some(panel) = workspace.focus_panel::<NotificationPanel>(cx) {
762 panel.update(cx, |panel, cx| {
763 let store = panel.notification_store.read(cx);
764 if let Some(entry) = store.notification_for_id(notification_id) {
765 panel.did_click_notification(&entry.clone().notification, cx);
766 }
767 });
768 }
769 })
770 .ok();
771 })
772 }
773}
774
775impl Entity for NotificationToast {
776 type Event = ToastEvent;
777}
778
779impl View for NotificationToast {
780 fn ui_name() -> &'static str {
781 "ContactNotification"
782 }
783
784 fn render(&mut self, cx: &mut ViewContext<Self>) -> AnyElement<Self> {
785 let user = self.actor.clone();
786 let theme = theme::current(cx).clone();
787 let theme = &theme.contact_notification;
788
789 MouseEventHandler::new::<Self, _>(0, cx, |_, cx| {
790 Flex::row()
791 .with_children(user.and_then(|user| {
792 Some(
793 Image::from_data(user.avatar.clone()?)
794 .with_style(theme.header_avatar)
795 .aligned()
796 .constrained()
797 .with_height(
798 cx.font_cache()
799 .line_height(theme.header_message.text.font_size),
800 )
801 .aligned()
802 .top(),
803 )
804 }))
805 .with_child(
806 Text::new(self.text.clone(), theme.header_message.text.clone())
807 .contained()
808 .with_style(theme.header_message.container)
809 .aligned()
810 .top()
811 .left()
812 .flex(1., true),
813 )
814 .with_child(
815 MouseEventHandler::new::<ToastEvent, _>(0, cx, |state, _| {
816 let style = theme.dismiss_button.style_for(state);
817 Svg::new("icons/x.svg")
818 .with_color(style.color)
819 .constrained()
820 .with_width(style.icon_width)
821 .aligned()
822 .contained()
823 .with_style(style.container)
824 .constrained()
825 .with_width(style.button_width)
826 .with_height(style.button_width)
827 })
828 .with_cursor_style(CursorStyle::PointingHand)
829 .with_padding(Padding::uniform(5.))
830 .on_click(MouseButton::Left, move |_, _, cx| {
831 cx.emit(ToastEvent::Dismiss)
832 })
833 .aligned()
834 .constrained()
835 .with_height(
836 cx.font_cache()
837 .line_height(theme.header_message.text.font_size),
838 )
839 .aligned()
840 .top()
841 .flex_float(),
842 )
843 .contained()
844 })
845 .with_cursor_style(CursorStyle::PointingHand)
846 .on_click(MouseButton::Left, move |_, this, cx| {
847 this.focus_notification_panel(cx);
848 cx.emit(ToastEvent::Dismiss);
849 })
850 .into_any()
851 }
852}
853
854impl workspace::notifications::Notification for NotificationToast {
855 fn should_dismiss_notification_on_event(&self, event: &<Self as Entity>::Event) -> bool {
856 matches!(event, ToastEvent::Dismiss)
857 }
858}
859
860fn format_timestamp(
861 mut timestamp: OffsetDateTime,
862 mut now: OffsetDateTime,
863 local_timezone: UtcOffset,
864) -> String {
865 timestamp = timestamp.to_offset(local_timezone);
866 now = now.to_offset(local_timezone);
867
868 let today = now.date();
869 let date = timestamp.date();
870 if date == today {
871 let difference = now - timestamp;
872 if difference >= Duration::from_secs(3600) {
873 format!("{}h", difference.whole_seconds() / 3600)
874 } else if difference >= Duration::from_secs(60) {
875 format!("{}m", difference.whole_seconds() / 60)
876 } else {
877 "just now".to_string()
878 }
879 } else if date.next_day() == Some(today) {
880 format!("yesterday")
881 } else {
882 format!("{:02}/{}/{}", date.month() as u32, date.day(), date.year())
883 }
884}