Skip to content
This repository was archived by the owner on Sep 11, 2024. It is now read-only.

Commit 3c52ba0

Browse files
t3chguyrichvdh
andauthored
Use Intl to localise dates and times (#11422)
* Use Intl to generate better internationalised date formats * Get `Yesterday` and `Today` from Intl also * Correct capitalisation blunder * Fix formatTime include weekday * Iterate * Fix tests * use jest setSystemTime * Discard changes to cypress/e2e/settings/general-user-settings-tab.spec.ts * Discard changes to res/css/_components.pcss * Discard changes to res/css/views/elements/_LanguageDropdown.pcss * Discard changes to src/components/views/elements/LanguageDropdown.tsx * Add docs & tests for getDaysArray & getMonthsArray * Discard changes to test/components/structures/__snapshots__/MatrixChat-test.tsx.snap * Consolidate consts * Improve testing & documentation * Update snapshot * Apply suggestions from code review Co-authored-by: Richard van der Hoff <[email protected]> * Iterate * Clarify comments * Update src/DateUtils.ts Co-authored-by: Richard van der Hoff <[email protected]> * Specify hourCycle * Discard changes to test/components/views/settings/devices/DeviceDetails-test.tsx * Update comments --------- Co-authored-by: Richard van der Hoff <[email protected]>
1 parent d4571ae commit 3c52ba0

File tree

21 files changed

+445
-192
lines changed

21 files changed

+445
-192
lines changed

cypress/e2e/editing/editing.spec.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -119,7 +119,7 @@ describe("Editing", () => {
119119
// Assert that the date separator is rendered at the top
120120
cy.get("li:nth-child(1) .mx_DateSeparator").within(() => {
121121
cy.get("h2").within(() => {
122-
cy.findByText("Today");
122+
cy.findByText("today").should("have.css", "text-transform", "capitalize");
123123
});
124124
});
125125

@@ -184,7 +184,7 @@ describe("Editing", () => {
184184
// Assert that the date is rendered
185185
cy.get("li:nth-child(1) .mx_DateSeparator").within(() => {
186186
cy.get("h2").within(() => {
187-
cy.findByText("Today");
187+
cy.findByText("today").should("have.css", "text-transform", "capitalize");
188188
});
189189
});
190190

res/css/views/messages/_DateSeparator.pcss

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,6 +40,7 @@ limitations under the License.
4040
font-size: inherit;
4141
font-weight: inherit;
4242
color: inherit;
43+
text-transform: capitalize;
4344
}
4445

4546
.mx_DateSeparator_jumpToDateMenu {

src/DateUtils.ts

Lines changed: 160 additions & 106 deletions
Original file line numberDiff line numberDiff line change
@@ -18,95 +18,121 @@ limitations under the License.
1818

1919
import { Optional } from "matrix-events-sdk";
2020

21-
import { _t } from "./languageHandler";
21+
import { _t, getUserLanguage } from "./languageHandler";
2222

23-
function getDaysArray(): string[] {
24-
return [_t("Sun"), _t("Mon"), _t("Tue"), _t("Wed"), _t("Thu"), _t("Fri"), _t("Sat")];
25-
}
23+
export const MINUTE_MS = 60000;
24+
export const HOUR_MS = MINUTE_MS * 60;
25+
export const DAY_MS = HOUR_MS * 24;
2626

27-
function getMonthsArray(): string[] {
28-
return [
29-
_t("Jan"),
30-
_t("Feb"),
31-
_t("Mar"),
32-
_t("Apr"),
33-
_t("May"),
34-
_t("Jun"),
35-
_t("Jul"),
36-
_t("Aug"),
37-
_t("Sep"),
38-
_t("Oct"),
39-
_t("Nov"),
40-
_t("Dec"),
41-
];
27+
/**
28+
* Returns array of 7 weekday names, from Sunday to Saturday, internationalised to the user's language.
29+
* @param weekday - format desired "short" | "long" | "narrow"
30+
*/
31+
export function getDaysArray(weekday: Intl.DateTimeFormatOptions["weekday"] = "short"): string[] {
32+
const sunday = 1672574400000; // 2023-01-01 12:00 UTC
33+
const { format } = new Intl.DateTimeFormat(getUserLanguage(), { weekday, timeZone: "UTC" });
34+
return [...Array(7).keys()].map((day) => format(sunday + day * DAY_MS));
4235
}
4336

44-
function pad(n: number): string {
45-
return (n < 10 ? "0" : "") + n;
37+
/**
38+
* Returns array of 12 month names, from January to December, internationalised to the user's language.
39+
* @param month - format desired "numeric" | "2-digit" | "long" | "short" | "narrow"
40+
*/
41+
export function getMonthsArray(month: Intl.DateTimeFormatOptions["month"] = "short"): string[] {
42+
const { format } = new Intl.DateTimeFormat(getUserLanguage(), { month, timeZone: "UTC" });
43+
return [...Array(12).keys()].map((m) => format(Date.UTC(2021, m)));
4644
}
4745

48-
function twelveHourTime(date: Date, showSeconds = false): string {
49-
let hours = date.getHours() % 12;
50-
const minutes = pad(date.getMinutes());
51-
const ampm = date.getHours() >= 12 ? _t("PM") : _t("AM");
52-
hours = hours ? hours : 12; // convert 0 -> 12
53-
if (showSeconds) {
54-
const seconds = pad(date.getSeconds());
55-
return `${hours}:${minutes}:${seconds}${ampm}`;
56-
}
57-
return `${hours}:${minutes}${ampm}`;
46+
// XXX: Ideally we could just specify `hour12: boolean` but it has issues on Chrome in the `en` locale
47+
// https://support.google.com/chrome/thread/29828561?hl=en
48+
function getTwelveHourOptions(showTwelveHour: boolean): Intl.DateTimeFormatOptions {
49+
return {
50+
hourCycle: showTwelveHour ? "h12" : "h23",
51+
};
5852
}
5953

60-
export function formatDate(date: Date, showTwelveHour = false): string {
54+
/**
55+
* Formats a given date to a date & time string.
56+
*
57+
* The output format depends on how far away the given date is from now.
58+
* Will use the browser's default time zone.
59+
* If the date is today it will return a time string excluding seconds. See {@formatTime}.
60+
* If the date is within the last 6 days it will return the name of the weekday along with the time string excluding seconds.
61+
* If the date is within the same year then it will return the weekday, month and day of the month along with the time string excluding seconds.
62+
* Otherwise, it will return a string representing the full date & time in a human friendly manner. See {@formatFullDate}.
63+
* @param date - date object to format
64+
* @param showTwelveHour - whether to use 12-hour rather than 24-hour time. Defaults to `false` (24 hour mode).
65+
* Overrides the default from the locale, whether `true` or `false`.
66+
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
67+
*/
68+
export function formatDate(date: Date, showTwelveHour = false, locale?: string): string {
69+
const _locale = locale ?? getUserLanguage();
6170
const now = new Date();
62-
const days = getDaysArray();
63-
const months = getMonthsArray();
6471
if (date.toDateString() === now.toDateString()) {
65-
return formatTime(date, showTwelveHour);
66-
} else if (now.getTime() - date.getTime() < 6 * 24 * 60 * 60 * 1000) {
67-
// TODO: use standard date localize function provided in counterpart
68-
return _t("%(weekDayName)s %(time)s", {
69-
weekDayName: days[date.getDay()],
70-
time: formatTime(date, showTwelveHour),
71-
});
72+
return formatTime(date, showTwelveHour, _locale);
73+
} else if (now.getTime() - date.getTime() < 6 * DAY_MS) {
74+
// Time is within the last 6 days (or in the future)
75+
return new Intl.DateTimeFormat(_locale, {
76+
...getTwelveHourOptions(showTwelveHour),
77+
weekday: "short",
78+
hour: "numeric",
79+
minute: "2-digit",
80+
}).format(date);
7281
} else if (now.getFullYear() === date.getFullYear()) {
73-
// TODO: use standard date localize function provided in counterpart
74-
return _t("%(weekDayName)s, %(monthName)s %(day)s %(time)s", {
75-
weekDayName: days[date.getDay()],
76-
monthName: months[date.getMonth()],
77-
day: date.getDate(),
78-
time: formatTime(date, showTwelveHour),
79-
});
82+
return new Intl.DateTimeFormat(_locale, {
83+
...getTwelveHourOptions(showTwelveHour),
84+
weekday: "short",
85+
month: "short",
86+
day: "numeric",
87+
hour: "numeric",
88+
minute: "2-digit",
89+
}).format(date);
8090
}
81-
return formatFullDate(date, showTwelveHour);
91+
return formatFullDate(date, showTwelveHour, false, _locale);
8292
}
8393

84-
export function formatFullDateNoTime(date: Date): string {
85-
const days = getDaysArray();
86-
const months = getMonthsArray();
87-
return _t("%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s", {
88-
weekDayName: days[date.getDay()],
89-
monthName: months[date.getMonth()],
90-
day: date.getDate(),
91-
fullYear: date.getFullYear(),
92-
});
94+
/**
95+
* Formats a given date to a human-friendly string with short weekday.
96+
* Will use the browser's default time zone.
97+
* @example "Thu, 17 Nov 2022" in en-GB locale
98+
* @param date - date object to format
99+
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
100+
*/
101+
export function formatFullDateNoTime(date: Date, locale?: string): string {
102+
return new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
103+
weekday: "short",
104+
month: "short",
105+
day: "numeric",
106+
year: "numeric",
107+
}).format(date);
93108
}
94109

95-
export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = true): string {
96-
const days = getDaysArray();
97-
const months = getMonthsArray();
98-
return _t("%(weekDayName)s, %(monthName)s %(day)s %(fullYear)s %(time)s", {
99-
weekDayName: days[date.getDay()],
100-
monthName: months[date.getMonth()],
101-
day: date.getDate(),
102-
fullYear: date.getFullYear(),
103-
time: showSeconds ? formatFullTime(date, showTwelveHour) : formatTime(date, showTwelveHour),
104-
});
110+
/**
111+
* Formats a given date to a date & time string, optionally including seconds.
112+
* Will use the browser's default time zone.
113+
* @example "Thu, 17 Nov 2022, 4:58:32 pm" in en-GB locale with showTwelveHour=true and showSeconds=true
114+
* @param date - date object to format
115+
* @param showTwelveHour - whether to use 12-hour rather than 24-hour time. Defaults to `false` (24 hour mode).
116+
* Overrides the default from the locale, whether `true` or `false`.
117+
* @param showSeconds - whether to include seconds in the time portion of the string
118+
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
119+
*/
120+
export function formatFullDate(date: Date, showTwelveHour = false, showSeconds = true, locale?: string): string {
121+
return new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
122+
...getTwelveHourOptions(showTwelveHour),
123+
weekday: "short",
124+
month: "short",
125+
day: "numeric",
126+
year: "numeric",
127+
hour: "numeric",
128+
minute: "2-digit",
129+
second: showSeconds ? "2-digit" : undefined,
130+
}).format(date);
105131
}
106132

107133
/**
108134
* Formats dates to be compatible with attributes of a `<input type="date">`. Dates
109-
* should be formatted like "2020-06-23" (formatted according to ISO8601)
135+
* should be formatted like "2020-06-23" (formatted according to ISO8601).
110136
*
111137
* @param date The date to format.
112138
* @returns The date string in ISO8601 format ready to be used with an `<input>`
@@ -115,22 +141,44 @@ export function formatDateForInput(date: Date): string {
115141
const year = `${date.getFullYear()}`.padStart(4, "0");
116142
const month = `${date.getMonth() + 1}`.padStart(2, "0");
117143
const day = `${date.getDate()}`.padStart(2, "0");
118-
const dateInputValue = `${year}-${month}-${day}`;
119-
return dateInputValue;
144+
return `${year}-${month}-${day}`;
120145
}
121146

122-
export function formatFullTime(date: Date, showTwelveHour = false): string {
123-
if (showTwelveHour) {
124-
return twelveHourTime(date, true);
125-
}
126-
return pad(date.getHours()) + ":" + pad(date.getMinutes()) + ":" + pad(date.getSeconds());
147+
/**
148+
* Formats a given date to a time string including seconds.
149+
* Will use the browser's default time zone.
150+
* @example "4:58:32 PM" in en-GB locale with showTwelveHour=true
151+
* @example "16:58:32" in en-GB locale with showTwelveHour=false
152+
* @param date - date object to format
153+
* @param showTwelveHour - whether to use 12-hour rather than 24-hour time. Defaults to `false` (24 hour mode).
154+
* Overrides the default from the locale, whether `true` or `false`.
155+
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
156+
*/
157+
export function formatFullTime(date: Date, showTwelveHour = false, locale?: string): string {
158+
return new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
159+
...getTwelveHourOptions(showTwelveHour),
160+
hour: "numeric",
161+
minute: "2-digit",
162+
second: "2-digit",
163+
}).format(date);
127164
}
128165

129-
export function formatTime(date: Date, showTwelveHour = false): string {
130-
if (showTwelveHour) {
131-
return twelveHourTime(date);
132-
}
133-
return pad(date.getHours()) + ":" + pad(date.getMinutes());
166+
/**
167+
* Formats a given date to a time string excluding seconds.
168+
* Will use the browser's default time zone.
169+
* @example "4:58 PM" in en-GB locale with showTwelveHour=true
170+
* @example "16:58" in en-GB locale with showTwelveHour=false
171+
* @param date - date object to format
172+
* @param showTwelveHour - whether to use 12-hour rather than 24-hour time. Defaults to `false` (24 hour mode).
173+
* Overrides the default from the locale, whether `true` or `false`.
174+
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
175+
*/
176+
export function formatTime(date: Date, showTwelveHour = false, locale?: string): string {
177+
return new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
178+
...getTwelveHourOptions(showTwelveHour),
179+
hour: "numeric",
180+
minute: "2-digit",
181+
}).format(date);
134182
}
135183

136184
export function formatSeconds(inSeconds: number): string {
@@ -183,9 +231,8 @@ export function formatTimeLeft(inSeconds: number): string {
183231
});
184232
}
185233

186-
const MILLIS_IN_DAY = 86400000;
187234
function withinPast24Hours(prevDate: Date, nextDate: Date): boolean {
188-
return Math.abs(prevDate.getTime() - nextDate.getTime()) <= MILLIS_IN_DAY;
235+
return Math.abs(prevDate.getTime() - nextDate.getTime()) <= DAY_MS;
189236
}
190237

191238
function withinCurrentDay(prevDate: Date, nextDate: Date): boolean {
@@ -210,28 +257,39 @@ export function wantsDateSeparator(prevEventDate: Optional<Date>, nextEventDate:
210257
}
211258

212259
export function formatFullDateNoDay(date: Date): string {
260+
const locale = getUserLanguage();
213261
return _t("%(date)s at %(time)s", {
214-
date: date.toLocaleDateString().replace(/\//g, "-"),
215-
time: date.toLocaleTimeString().replace(/:/g, "-"),
262+
date: date.toLocaleDateString(locale).replace(/\//g, "-"),
263+
time: date.toLocaleTimeString(locale).replace(/:/g, "-"),
216264
});
217265
}
218266

219267
/**
220-
* Returns an ISO date string without textual description of the date (ie: no "Wednesday" or
221-
* similar)
268+
* Returns an ISO date string without textual description of the date (ie: no "Wednesday" or similar)
222269
* @param date The date to format.
223270
* @returns The date string in ISO format.
224271
*/
225272
export function formatFullDateNoDayISO(date: Date): string {
226273
return date.toISOString();
227274
}
228275

229-
export function formatFullDateNoDayNoTime(date: Date): string {
230-
return date.getFullYear() + "/" + pad(date.getMonth() + 1) + "/" + pad(date.getDate());
276+
/**
277+
* Formats a given date to a string.
278+
* Will use the browser's default time zone.
279+
* @example 17/11/2022 in en-GB locale
280+
* @param date - date object to format
281+
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
282+
*/
283+
export function formatFullDateNoDayNoTime(date: Date, locale?: string): string {
284+
return new Intl.DateTimeFormat(locale ?? getUserLanguage(), {
285+
year: "numeric",
286+
month: "numeric",
287+
day: "numeric",
288+
}).format(date);
231289
}
232290

233291
export function formatRelativeTime(date: Date, showTwelveHour = false): string {
234-
const now = new Date(Date.now());
292+
const now = new Date();
235293
if (withinCurrentDay(date, now)) {
236294
return formatTime(date, showTwelveHour);
237295
} else {
@@ -245,15 +303,11 @@ export function formatRelativeTime(date: Date, showTwelveHour = false): string {
245303
}
246304
}
247305

248-
const MINUTE_MS = 60000;
249-
const HOUR_MS = MINUTE_MS * 60;
250-
const DAY_MS = HOUR_MS * 24;
251-
252306
/**
253-
* Formats duration in ms to human readable string
254-
* Returns value in biggest possible unit (day, hour, min, second)
307+
* Formats duration in ms to human-readable string
308+
* Returns value in the biggest possible unit (day, hour, min, second)
255309
* Rounds values up until unit threshold
256-
* ie. 23:13:57 -> 23h, 24:13:57 -> 1d, 44:56:56 -> 2d
310+
* i.e. 23:13:57 -> 23h, 24:13:57 -> 1d, 44:56:56 -> 2d
257311
*/
258312
export function formatDuration(durationMs: number): string {
259313
if (durationMs >= DAY_MS) {
@@ -269,9 +323,9 @@ export function formatDuration(durationMs: number): string {
269323
}
270324

271325
/**
272-
* Formats duration in ms to human readable string
326+
* Formats duration in ms to human-readable string
273327
* Returns precise value down to the nearest second
274-
* ie. 23:13:57 -> 23h 13m 57s, 44:56:56 -> 1d 20h 56m 56s
328+
* i.e. 23:13:57 -> 23h 13m 57s, 44:56:56 -> 1d 20h 56m 56s
275329
*/
276330
export function formatPreciseDuration(durationMs: number): string {
277331
const days = Math.floor(durationMs / DAY_MS);
@@ -293,13 +347,13 @@ export function formatPreciseDuration(durationMs: number): string {
293347

294348
/**
295349
* Formats a timestamp to a short date
296-
* (eg 25/12/22 in uk locale)
297-
* localised by system locale
350+
* Similar to {@formatFullDateNoDayNoTime} but with 2-digit on day, month, year.
351+
* @example 25/12/22 in en-GB locale
298352
* @param timestamp - epoch timestamp
353+
* @param locale - the locale string to use, in BCP 47 format, defaulting to user's selected application locale
299354
* @returns {string} formattedDate
300355
*/
301-
export const formatLocalDateShort = (timestamp: number): string =>
302-
new Intl.DateTimeFormat(
303-
undefined, // locales
304-
{ day: "2-digit", month: "2-digit", year: "2-digit" },
305-
).format(timestamp);
356+
export const formatLocalDateShort = (timestamp: number, locale?: string): string =>
357+
new Intl.DateTimeFormat(locale ?? getUserLanguage(), { day: "2-digit", month: "2-digit", year: "2-digit" }).format(
358+
timestamp,
359+
);

src/components/views/dialogs/ForwardDialog.tsx

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -216,6 +216,7 @@ const ForwardDialog: React.FC<IProps> = ({ matrixClient: cli, event, permalinkCr
216216
},
217217
event_id: "$9999999999999999999999999999999999999999999",
218218
room_id: event.getRoomId(),
219+
origin_server_ts: event.getTs(),
219220
});
220221
mockEvent.sender = {
221222
name: profileInfo.displayname || userId,

0 commit comments

Comments
 (0)