notification_panel.rs

  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, &notification, 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(&notification, 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(&notification_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, .. } = &notification {
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}