import { memo } from "react"; // This component always renders the server time / UTC. // It does not (yet) support rendering some parts in the time zone of the client. // It is also not language-/locale-aware. // For DE locale, usually returns something like "vor 10 Tagen" // `numeric: "auto"` results in additional wordings to be used. // For example, instead of "vor 1 Tag", it will return "gestern" // `style: "long"` will return "vor 6 Minuten" // `style: "short"` will return "vor 6 Min." const relativeDateFormatter = new Intl.RelativeTimeFormat(["de", "en"], { style: "long", numeric: "auto", }); const relativeDateFormatterNarrow = new Intl.RelativeTimeFormat(["de", "en"], { style: "narrow", numeric: "auto", }); const absoluteFormatter = new Intl.DateTimeFormat(["de", "en"], { dateStyle: "long", timeStyle: "long", }); export type RelativeTimestampStyle = "long" | "narrow" | "short"; type ValidUnit = Intl.RelativeTimeFormatUnit & ("year" | "month" | "week" | "day" | "hour" | "minute" | "second"); interface Formatter { format(value: number, unit: ValidUnit): string; } const formatterMap = { long: relativeDateFormatter, narrow: relativeDateFormatterNarrow, short: { // Intl does not offer a meaningful "short" style for relative time. We use our own. format(value: number, unit: ValidUnit) { const v = Math.abs(Math.round(value)); switch (unit) { // The char " " between the number and the unit is a narrow no-break space (U+202F). case "day": return `${v} T`; case "hour": return `${v} Std.`; case "minute": return `${v} min`; case "second": return `${v} s`; case "month": return `${v} M`; case "week": return `${v} W`; case "year": return `${v} J`; } }, }, } satisfies Record; /** * @remarks This component uses api.Timestamp instead of `Date` to avoid re-renders (Dates are objects and therefore compared by reference). */ export interface RelativeTimestampProps { /** * **Unix time in seconds**. * @remarks We use a `number` instead of `Date` because `Date`s are objects and would cause unnecessary re-renders. `number`s are easier to memoize and to compare. */ date: number; /** * **Unix time in seconds**. Does not have a default, so items in a list can all use the same exact date. Use something like `(Date.getTime() / 1000) | 0` to get some date to compare with. * @remarks We use a `number` instead of `Date` because `Date`s are objects and would cause unnecessary re-renders. `number`s are easier to memoize and to compare. */ relativeToDate: number; style?: RelativeTimestampStyle; } export default memo(function RelativeTimestamp({ date, relativeToDate, style, }: RelativeTimestampProps) { const d = new Date(date * 1000); // TODO: We seem to have time zone issues const longDateTime = absoluteFormatter.format(d); const relativeDate = getRelativeDate( d, typeof relativeToDate === "number" ? new Date(relativeToDate * 1000) : relativeToDate, style, ); return ( ); }); const unitsInMs: Record = { year: 365 * 24 * 60 * 60 * 1000, month: (365 * 24 * 60 * 60 * 1000) / 12, week: 7 * 24 * 60 * 60 * 1000, day: 24 * 60 * 60 * 1000, hour: 60 * 60 * 1000, minute: 60 * 1000, second: 1000, }; function getRelativeDate( date: Date, relativeTo: Date, style: RelativeTimestampStyle = "long", ): string { const elapsed = date.getTime() - relativeTo.getTime(); for (const [unit, divisor] of Object.entries(unitsInMs)) { if (Math.abs(elapsed) > divisor || unit === "second") { const formatter = formatterMap[style]; return formatter.format( Math.round(elapsed / divisor), unit as ValidUnit, ); } } return absoluteFormatter.format(date); }