5.3 KB197 lines
Blame
1/**
2 * Copyright (c) Meta Platforms, Inc. and affiliates.
3 *
4 * This source code is licensed under the MIT license found in the
5 * LICENSE file in the root directory of this source tree.
6 */
7
8import {getCurrentLanguage, useCurrentLang} from './i18n';
9
10/**
11 * Originally adapted from https://github.com/azer/relative-date.
12 */
13const SECOND = 1000;
14const MINUTE = SECOND * 60;
15const HOUR = MINUTE * 60;
16const DAY = HOUR * 24;
17const WEEK = DAY * 7;
18const YEAR = DAY * 365;
19const MONTH = YEAR / 12;
20
21type NumberFormat = [ms: number, name: string] | [ms: number, name: string, relative: number];
22
23const shortFormats: Array<NumberFormat> = [
24 [MINUTE * 0.7, 'now'],
25 [MINUTE * 1.5, '1m'],
26 [MINUTE * 60, 'm', MINUTE],
27 [HOUR * 1.5, '1h'],
28 [DAY, 'h', HOUR],
29 [DAY * 2, '1d'],
30 [DAY * 7, 'd', DAY],
31 [WEEK * 1.5, '1w'],
32 [MONTH, 'w', WEEK],
33 [MONTH * 1.5, '1mo'],
34 [YEAR, 'mo', MONTH],
35 [YEAR * 1.5, '1y'],
36 [Number.MAX_VALUE, 'y', YEAR],
37];
38
39const longFormatsRelative: Array<NumberFormat> = [
40 [MINUTE * 0.7, 'less than a minute'],
41 [MINUTE * 1.5, 'one minute'],
42 [MINUTE * 60, 'minutes', MINUTE],
43 [HOUR * 1.5, 'one hour'],
44 [DAY, 'hours', HOUR],
45 [DAY * 2, 'one day'],
46 [DAY * 7, 'days', DAY],
47 [WEEK * 1.5, 'one week'],
48 [MONTH, 'weeks', WEEK],
49 [MONTH * 1.5, 'one month'],
50 [YEAR, 'months', MONTH],
51 [YEAR * 1.5, 'one year'],
52 [Number.MAX_VALUE, 'years', YEAR],
53];
54
55const longFormats: Array<NumberFormat> = [
56 [MINUTE * 0.7, 'just now'],
57 [MINUTE * 1.5, 'a minute ago'],
58 [MINUTE * 60, 'minutes ago', MINUTE],
59 [HOUR * 1.5, 'an hour ago'],
60 [DAY, 'hours ago', HOUR],
61 [DAY * 2, 'yesterday'],
62 [DAY * 7, 'days ago', DAY],
63 [WEEK * 1.5, 'a week ago'],
64 [MONTH, 'weeks ago', WEEK],
65 [MONTH * 1.5, 'a month ago'],
66 [YEAR, 'months ago', MONTH],
67 [YEAR * 1.5, 'a year ago'],
68 [Number.MAX_VALUE, 'years ago', YEAR],
69];
70
71const longFormatsNumbers: Array<NumberFormat> = [
72 [MINUTE * 0.7, 'just now'],
73 [MINUTE * 1.5, '1 minute ago'],
74 [MINUTE * 60, 'minutes ago', MINUTE],
75 [HOUR * 1.5, '1 hour ago'],
76 [DAY, 'hours ago', HOUR],
77 [DAY * 2, 'yesterday'],
78 [DAY * 7, 'days ago', DAY],
79 [WEEK * 1.5, '1 week ago'],
80 [MONTH, 'weeks ago', WEEK],
81 [MONTH * 1.5, '1 month ago'],
82 [YEAR, 'months ago', MONTH],
83 [YEAR * 1.5, '1 year ago'],
84 [Number.MAX_VALUE, 'years ago', YEAR],
85];
86
87const units = {
88 year: 24 * 60 * 60 * 1000 * 365,
89 month: (24 * 60 * 60 * 1000 * 365) / 12,
90 day: 24 * 60 * 60 * 1000,
91 hour: 60 * 60 * 1000,
92 minute: 60 * 1000,
93};
94
95/**
96 * Format date into relative string format.
97 * If currentLanguage is 'en', uses hard-coded time abbreviations for maximum shortness.
98 * Other languages use currently Intl.RelativeTimeFormat if available.
99 * if currentLanguage is 'en':
100 * ```
101 * relativeDate(new Date()) -> 'just now'
102 * relativeDate(new Date() - 120000) -> '2m ago'
103 * ```
104 * if currentLanguage is 'de':
105 * ```
106 * relativeDate(new Date()) -> 'just now'
107 * relativeDate(new Date() - 60000) -> 'now'
108 * ```
109 */
110export function relativeDate(
111 input_: number | Date,
112 options: {
113 reference?: number | Date;
114 useShortVariant?: boolean;
115 useNumbersOnly?: boolean;
116 useRelativeForm?: boolean;
117 },
118): string {
119 let input = input_;
120 let reference = options.reference;
121 if (input instanceof Date) {
122 input = input.getTime();
123 }
124 if (!reference) {
125 reference = now();
126 }
127 if (reference instanceof Date) {
128 reference = reference.getTime();
129 }
130
131 const delta = reference - input;
132 const absDelta = Math.abs(delta);
133
134 // Use Intl.RelativeTimeFormat for non-en locales, if available.
135 if (getCurrentLanguage() != 'en' && typeof Intl !== 'undefined') {
136 for (const unit of Object.keys(units) as Array<keyof typeof units>) {
137 if (absDelta > units[unit] || unit == 'minute') {
138 return new Intl.RelativeTimeFormat(getCurrentLanguage(), {
139 style: options.useShortVariant ? 'narrow' : 'short',
140 numeric: 'auto',
141 }).format(-Math.round(delta / units[unit]), unit);
142 }
143 }
144 }
145
146 const formats = options.useRelativeForm
147 ? longFormatsRelative
148 : options.useShortVariant
149 ? shortFormats
150 : options.useNumbersOnly
151 ? longFormatsNumbers
152 : longFormats;
153 for (const [limit, relativeFormat, remainder] of formats) {
154 if (absDelta < limit) {
155 if (typeof remainder === 'number') {
156 return (
157 Math.round(delta / remainder) + (options.useShortVariant ? '' : ' ') + relativeFormat
158 );
159 } else {
160 return relativeFormat;
161 }
162 }
163 }
164
165 throw new Error('This should never be reached.');
166}
167
168/**
169 * React component version of {@link relativeDate}.
170 * Re-renders if the current language changes.
171 */
172export function RelativeDate({
173 date,
174 reference,
175 useShortVariant,
176 useNumbersOnly,
177 useRelativeForm,
178}: {
179 date: number | Date;
180 reference?: number | Date;
181 useShortVariant?: boolean;
182 useNumbersOnly?: boolean;
183 useRelativeForm?: boolean;
184}) {
185 useCurrentLang();
186 return <>{relativeDate(date, {reference, useShortVariant, useNumbersOnly, useRelativeForm})}</>;
187}
188
189/** Get "now" for relativeDate use-case. Can be overridden by "?now=unixtime" in ISL browser environments. */
190function now(): number {
191 const forceNowStr =
192 typeof window === 'undefined'
193 ? undefined
194 : (window as {relativeDateNowOverride?: number}).relativeDateNowOverride;
195 return forceNowStr == null ? Date.now() : forceNowStr;
196}
197