notification_panel.rs

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