From 356fb301e17df5294cbb354bb4fb0ad76f1a0052 Mon Sep 17 00:00:00 2001 From: Josh Perez <60019601+josh-signal@users.noreply.github.com> Date: Thu, 2 Mar 2023 13:43:25 -0500 Subject: [PATCH] Use Intl.DateTimeFormat instead of moment for date formatting --- _locales/en/messages.json | 18 ++++-- app/main.ts | 6 +- test/setup-test-node.js | 1 + test/test.js | 2 + ts/Intl.d.ts | 7 +++ ts/test-both/util/timestamp_test.ts | 54 +++------------- ts/util/resolveCanonicalLocales.ts | 14 +++++ ts/util/timestamp.ts | 95 +++++++++++++++++------------ 8 files changed, 106 insertions(+), 91 deletions(-) create mode 100644 ts/Intl.d.ts create mode 100644 ts/util/resolveCanonicalLocales.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f9f1da6c56c8..70dd4cb1de24 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1880,15 +1880,23 @@ }, "timestampFormat_M": { "message": "MMM D", - "description": "Timestamp format string for displaying month and day (but not the year) of a date within the current year, ex: use 'MMM D' for 'Aug 8', or 'D MMM' for '8 Aug'." + "description": "(deleted 01/25/2023) Timestamp format string for displaying month and day (but not the year) of a date within the current year, ex: use 'MMM D' for 'Aug 8', or 'D MMM' for '8 Aug'." + }, + "timestampFormat__long--today": { + "message": "Today $time$", + "description": "Timestamp format string for displaying \"Today\" and the time" + }, + "timestampFormat__long--yesterday": { + "message": "Yesterday $time$", + "description": "Timestamp format string for displaying \"Yesterday\" and the time" }, "timestampFormat__long__today": { "message": "[Today] LT", - "description": "Timestamp format string for displaying \"Today\" and the time" + "description": "(deleted 01/25/2023) Timestamp format string for displaying \"Today\" and the time" }, "timestampFormat__long__yesterday": { "message": "[Yesterday] LT", - "description": "Timestamp format string for displaying \"Yesterday\" and the time" + "description": "(deleted 01/25/2023) Timestamp format string for displaying \"Yesterday\" and the time" }, "messageBodyTooLong": { "message": "Message body is too long.", @@ -4881,11 +4889,11 @@ }, "TimelineDateHeader--date-in-last-6-months": { "message": "ddd, MMM D", - "description": "Moment.js format for date headers in the message timeline, for dates <6 months old. See https://momentjs.com/docs/#/displaying/format/." + "description": "(deleted 01/25/2023) Moment.js format for date headers in the message timeline, for dates <6 months old. See https://momentjs.com/docs/#/displaying/format/." }, "TimelineDateHeader--date-older-than-6-months": { "message": "MMM D, YYYY", - "description": "Moment.js format for date headers in the message timeline, for dates >=6 months old. See https://momentjs.com/docs/#/displaying/format/." + "description": "(deleted 01/25/2023) Moment.js format for date headers in the message timeline, for dates >=6 months old. See https://momentjs.com/docs/#/displaying/format/." }, "MessageRequestWarning__learn-more": { "message": "Learn more", diff --git a/app/main.ts b/app/main.ts index 4f57ca82886e..2cd1773fd266 100644 --- a/app/main.ts +++ b/app/main.ts @@ -48,6 +48,7 @@ import { consoleLogger } from '../ts/util/consoleLogger'; import type { ThemeSettingType } from '../ts/types/StorageUIKeys'; import { ThemeType } from '../ts/types/Util'; import * as Errors from '../ts/types/errors'; +import { resolveCanonicalLocales } from '../ts/util/resolveCanonicalLocales'; import './startup_config'; @@ -1749,7 +1750,10 @@ app.on('ready', async () => { await setupCrashReports(getLogger); if (!resolvedTranslationsLocale) { - preferredSystemLocales = loadPreferredSystemLocales(); + preferredSystemLocales = resolveCanonicalLocales( + loadPreferredSystemLocales() + ); + logger.info( `app.ready: preferred system locales: ${preferredSystemLocales.join( ', ' diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 41c0d5c97916..727ed051b9bd 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -32,6 +32,7 @@ global.window = { get: key => storageMap.get(key), put: async (key, value) => storageMap.set(key, value), }, + getPreferredSystemLocales: () => ['en'], }; // For ducks/network.getEmptyState() diff --git a/test/test.js b/test/test.js index ae6352062192..d82d45ccd424 100644 --- a/test/test.js +++ b/test/test.js @@ -70,3 +70,5 @@ delete window.testUtilities.prepareTests; mocha.run(); })(); + +window.getPreferredSystemLocales = () => ['en']; diff --git a/ts/Intl.d.ts b/ts/Intl.d.ts new file mode 100644 index 000000000000..03ef6bd2b437 --- /dev/null +++ b/ts/Intl.d.ts @@ -0,0 +1,7 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +// https://github.com/microsoft/TypeScript/issues/29129 +declare namespace Intl { + function getCanonicalLocales(locales: string | Array): Array; +} diff --git a/ts/test-both/util/timestamp_test.ts b/ts/test-both/util/timestamp_test.ts index 86315cdc37cd..2c41a48c526f 100644 --- a/ts/test-both/util/timestamp_test.ts +++ b/ts/test-both/util/timestamp_test.ts @@ -4,8 +4,9 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; import moment from 'moment'; -import type { LocalizerType } from '../../types/Util'; import { HOUR, DAY } from '../../util/durations'; +import { setupI18n } from '../../util/setupI18n'; +import enMessages from '../../../_locales/en/messages.json'; import { formatDate, @@ -35,30 +36,7 @@ describe('timestamp', () => { }); } - const i18n = ((key: string, values: Array = []): string => { - switch (key) { - case 'today': - return 'Today'; - case 'yesterday': - return 'Yesterday'; - case 'TimelineDateHeader--date-in-last-6-months': - return '[short] ddd, MMM D'; - case 'TimelineDateHeader--date-older-than-6-months': - return '[long] MMM D, YYYY'; - case 'timestampFormat__long__today': - return '[Today] LT'; - case 'timestampFormat__long__yesterday': - return '[Yesterday] LT'; - case 'justNow': - return 'Now'; - case 'minutesAgo': - return `${values[0]}m`; - case 'timestampFormat_M': - return 'MMM D'; - default: - throw new Error(`Unexpected key ${key}`); - } - }) as LocalizerType; + const i18n = setupI18n('en', enMessages); describe('formatDate', () => { useFakeTimers(); @@ -83,7 +61,6 @@ describe('timestamp', () => { it('returns a formatted timestamp for dates more recent than six months', () => { const m = moment().subtract(2, 'months'); const result = formatDate(i18n, m); - assert.include(result, 'short'); assert.include(result, m.format('ddd')); assert.include(result, m.format('MMM')); assert.include(result, m.format('D')); @@ -91,16 +68,7 @@ describe('timestamp', () => { }); it('returns a formatted timestamp for dates older than six months', () => { - assert.strictEqual( - formatDate(i18n, moment('2017-03-03')), - 'long Mar 3, 2017' - ); - }); - - it('returns a formatted timestamp if the i18n strings are too long', () => { - const longI18n = ((_: string) => - Array(50).fill('MMM').join(' ')) as LocalizerType; - assert.include(formatDate(longI18n, moment('2017-03-03')), '2017'); + assert.strictEqual(formatDate(i18n, moment('2017-03-03')), 'Mar 3, 2017'); }); }); @@ -119,16 +87,10 @@ describe('timestamp', () => { }); it('formats month name, day of month, year, and time for other times', () => { - [ - moment().add(1, 'week'), - moment().subtract(1, 'week'), - moment().subtract(1, 'year'), - ].forEach(timestamp => { - assert.strictEqual( - formatDateTimeLong(i18n, timestamp), - moment(timestamp).format('lll') - ); - }); + assert.strictEqual( + formatDateTimeLong(i18n, new Date(956216013000)), + 'Apr 20, 2000, 7:33 AM' + ); }); }); diff --git a/ts/util/resolveCanonicalLocales.ts b/ts/util/resolveCanonicalLocales.ts new file mode 100644 index 000000000000..3517f5820043 --- /dev/null +++ b/ts/util/resolveCanonicalLocales.ts @@ -0,0 +1,14 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export function resolveCanonicalLocales(locales: Array): Array { + return Intl.getCanonicalLocales( + locales.flatMap(locale => { + try { + return Intl.getCanonicalLocales(locale); + } catch { + return 'en'; + } + }) + ); +} diff --git a/ts/util/timestamp.ts b/ts/util/timestamp.ts index c95d36114055..efa4d64c392b 100644 --- a/ts/util/timestamp.ts +++ b/ts/util/timestamp.ts @@ -4,11 +4,8 @@ import type { Moment } from 'moment'; import moment from 'moment'; import type { LocalizerType } from '../types/Util'; -import * as log from '../logging/log'; import { DAY, HOUR, MINUTE, MONTH, WEEK } from './durations'; -const MAX_FORMAT_STRING_LENGTH = 50; - type RawTimestamp = Readonly; export function isMoreRecentThan(timestamp: number, delta: number): boolean { @@ -40,23 +37,6 @@ export const isToday = (rawTimestamp: RawTimestamp): boolean => const isYesterday = (rawTimestamp: RawTimestamp): boolean => isSameDay(rawTimestamp, moment().subtract(1, 'day')); -// This sanitization is probably unnecessary, but we do it just in case someone translates -// a super long format string and causes performance issues. -function sanitizeFormatString( - rawFormatString: string, - fallback: string -): string { - if (rawFormatString.length > MAX_FORMAT_STRING_LENGTH) { - log.error( - `Format string ${JSON.stringify( - rawFormatString - )} is too long. Falling back to ${fallback}` - ); - return fallback; - } - return rawFormatString; -} - export function formatDateTimeShort( i18n: LocalizerType, rawTimestamp: RawTimestamp @@ -66,6 +46,8 @@ export function formatDateTimeShort( const now = Date.now(); const diff = now - timestamp; + const locale = window.getPreferredSystemLocales(); + if (diff < HOUR || isToday(timestamp)) { return formatTime(i18n, rawTimestamp, now); } @@ -73,31 +55,57 @@ export function formatDateTimeShort( const m = moment(timestamp); if (diff < WEEK && m.isSame(now, 'month')) { - return m.format('ddd'); + return new Intl.DateTimeFormat(locale, { weekday: 'short' }).format( + timestamp + ); } if (m.isSame(now, 'year')) { - return m.format(i18n('timestampFormat_M') || 'MMM D'); + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + }).format(timestamp); } - return m.format('ll'); + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(timestamp); } export function formatDateTimeLong( i18n: LocalizerType, rawTimestamp: RawTimestamp ): string { - let rawFormatString: string; - if (isToday(rawTimestamp)) { - rawFormatString = i18n('timestampFormat__long__today'); - } else if (isYesterday(rawTimestamp)) { - rawFormatString = i18n('timestampFormat__long__yesterday'); - } else { - rawFormatString = 'lll'; - } - const formatString = sanitizeFormatString(rawFormatString, 'lll'); + const locale = window.getPreferredSystemLocales(); + const timestamp = rawTimestamp.valueOf(); - return moment(rawTimestamp).format(formatString); + if (isToday(rawTimestamp)) { + return i18n('timestampFormat__long--today', [ + new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: 'numeric', + }).format(timestamp), + ]); + } + + if (isYesterday(rawTimestamp)) { + return i18n('timestampFormat__long--yesterday', [ + new Intl.DateTimeFormat(locale, { + hour: 'numeric', + minute: 'numeric', + }).format(timestamp), + ]); + } + + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + hour: 'numeric', + minute: 'numeric', + month: 'short', + year: 'numeric', + }).format(timestamp); } export function formatTime( @@ -139,13 +147,22 @@ export function formatDate( return i18n('yesterday'); } + const locale = window.getPreferredSystemLocales(); const m = moment(rawTimestamp); - const rawFormatString = - Math.abs(m.diff(Date.now())) < 6 * MONTH - ? i18n('TimelineDateHeader--date-in-last-6-months') - : i18n('TimelineDateHeader--date-older-than-6-months'); - const formatString = sanitizeFormatString(rawFormatString, 'LL'); + const timestamp = rawTimestamp.valueOf(); - return m.format(formatString); + if (Math.abs(m.diff(Date.now())) < 6 * MONTH) { + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + weekday: 'short', + }).format(timestamp); + } + + return new Intl.DateTimeFormat(locale, { + day: 'numeric', + month: 'short', + year: 'numeric', + }).format(timestamp); }