You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
131 lines
3.5 KiB
131 lines
3.5 KiB
import {
|
|
Tooltip,
|
|
TooltipContent,
|
|
TooltipPortal,
|
|
TooltipProvider,
|
|
TooltipTrigger,
|
|
} from "@radix-ui/react-tooltip";
|
|
import { useEffect, useMemo, useState } from "react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
export interface TimeAgoProps {
|
|
timestamp: number | Date;
|
|
locale?: string;
|
|
tooltipOptions?: Intl.DateTimeFormatOptions;
|
|
className?: string;
|
|
}
|
|
|
|
const TIME_UNITS: Array<[Intl.RelativeTimeFormatUnit, number]> = [
|
|
["year", 31536000],
|
|
["month", 2592000],
|
|
["day", 86400],
|
|
["hour", 3600],
|
|
["minute", 60],
|
|
["second", 1],
|
|
];
|
|
|
|
const getRelativeTimeParts = (
|
|
date: Date | number,
|
|
): { value: number; unit: Intl.RelativeTimeFormatUnit } => {
|
|
const diffInSeconds = (new Date(date).getTime() - Date.now()) / 1000;
|
|
|
|
for (const [unit, secondsInUnit] of TIME_UNITS) {
|
|
if (Math.abs(diffInSeconds) >= secondsInUnit) {
|
|
const value = Math.round(diffInSeconds / secondsInUnit);
|
|
return { value, unit };
|
|
}
|
|
}
|
|
|
|
return { value: Math.round(diffInSeconds), unit: "second" };
|
|
};
|
|
|
|
const UPDATE_INTERVALS: Partial<Record<Intl.RelativeTimeFormatUnit, number>> = {
|
|
// For long-term units, an hourly update is more than sufficient.
|
|
year: 1000 * 60 * 60,
|
|
month: 1000 * 60 * 60,
|
|
|
|
// When the unit is 'day', check hourly to catch the change to the next day.
|
|
day: 1000 * 60 * 60,
|
|
|
|
// When the unit is 'hour', check every thiry seconds to catch the change to the next hour.
|
|
hour: 1000 * 30,
|
|
|
|
// When the unit is 'minute', a 15-second check is a good balance.
|
|
minute: 1000 * 15,
|
|
|
|
// For 'second', a 3-second check keeps it feeling "live" without being excessive.
|
|
second: 1000 * 3,
|
|
};
|
|
|
|
export const TimeAgo = ({
|
|
timestamp,
|
|
locale: localeProp,
|
|
tooltipOptions,
|
|
className,
|
|
}: TimeAgoProps) => {
|
|
const { i18n } = useTranslation();
|
|
const [timeAgo, setTimeAgo] = useState<string>("");
|
|
|
|
const locale = useMemo(
|
|
() =>
|
|
localeProp ||
|
|
i18n.language ||
|
|
(typeof navigator !== "undefined" ? navigator.language : "en-US"),
|
|
[localeProp, i18n.language],
|
|
);
|
|
|
|
const date = useMemo(() => new Date(timestamp), [timestamp]);
|
|
|
|
const fullDate = useMemo(() => {
|
|
const defaultOptions: Intl.DateTimeFormatOptions = {
|
|
dateStyle: "full",
|
|
timeStyle: "medium",
|
|
};
|
|
const formatter = new Intl.DateTimeFormat(locale, {
|
|
...defaultOptions,
|
|
...tooltipOptions,
|
|
});
|
|
return formatter.format(date);
|
|
}, [date, locale, tooltipOptions]);
|
|
|
|
useEffect(() => {
|
|
const rtf = new Intl.RelativeTimeFormat(locale, { numeric: "auto" });
|
|
let timerId: ReturnType<typeof setTimeout>;
|
|
|
|
const update = () => {
|
|
const { value, unit } = getRelativeTimeParts(date);
|
|
setTimeAgo(rtf.format(value, unit));
|
|
|
|
const interval = UPDATE_INTERVALS[unit] || 60000;
|
|
timerId = globalThis.setTimeout(update, interval);
|
|
};
|
|
|
|
update();
|
|
|
|
return () => {
|
|
clearTimeout(timerId);
|
|
};
|
|
}, [date, locale]);
|
|
|
|
return (
|
|
<TooltipProvider>
|
|
<Tooltip>
|
|
<TooltipTrigger asChild>
|
|
<time dateTime={date.toISOString()} className={className}>
|
|
{timeAgo}
|
|
</time>
|
|
</TooltipTrigger>
|
|
<TooltipPortal>
|
|
<TooltipContent
|
|
className="rounded-md bg-slate-800 px-3 py-1.5 text-sm text-white shadow-md animate-in fade-in-0 zoom-in-95"
|
|
side="top"
|
|
align="center"
|
|
sideOffset={5}
|
|
>
|
|
{fullDate}
|
|
</TooltipContent>
|
|
</TooltipPortal>
|
|
</Tooltip>
|
|
</TooltipProvider>
|
|
);
|
|
};
|
|
|