Use Intl.DateTimeFormat instead of moment for date formatting

This commit is contained in:
Josh Perez 2023-03-02 13:43:25 -05:00 committed by GitHub
parent bd40a7fb98
commit 356fb301e1
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
8 changed files with 106 additions and 91 deletions

View file

@ -1880,15 +1880,23 @@
}, },
"timestampFormat_M": { "timestampFormat_M": {
"message": "MMM D", "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": { "timestampFormat__long__today": {
"message": "[Today] LT", "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": { "timestampFormat__long__yesterday": {
"message": "[Yesterday] LT", "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": { "messageBodyTooLong": {
"message": "Message body is too long.", "message": "Message body is too long.",
@ -4881,11 +4889,11 @@
}, },
"TimelineDateHeader--date-in-last-6-months": { "TimelineDateHeader--date-in-last-6-months": {
"message": "ddd, MMM D", "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": { "TimelineDateHeader--date-older-than-6-months": {
"message": "MMM D, YYYY", "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": { "MessageRequestWarning__learn-more": {
"message": "Learn more", "message": "Learn more",

View file

@ -48,6 +48,7 @@ import { consoleLogger } from '../ts/util/consoleLogger';
import type { ThemeSettingType } from '../ts/types/StorageUIKeys'; import type { ThemeSettingType } from '../ts/types/StorageUIKeys';
import { ThemeType } from '../ts/types/Util'; import { ThemeType } from '../ts/types/Util';
import * as Errors from '../ts/types/errors'; import * as Errors from '../ts/types/errors';
import { resolveCanonicalLocales } from '../ts/util/resolveCanonicalLocales';
import './startup_config'; import './startup_config';
@ -1749,7 +1750,10 @@ app.on('ready', async () => {
await setupCrashReports(getLogger); await setupCrashReports(getLogger);
if (!resolvedTranslationsLocale) { if (!resolvedTranslationsLocale) {
preferredSystemLocales = loadPreferredSystemLocales(); preferredSystemLocales = resolveCanonicalLocales(
loadPreferredSystemLocales()
);
logger.info( logger.info(
`app.ready: preferred system locales: ${preferredSystemLocales.join( `app.ready: preferred system locales: ${preferredSystemLocales.join(
', ' ', '

View file

@ -32,6 +32,7 @@ global.window = {
get: key => storageMap.get(key), get: key => storageMap.get(key),
put: async (key, value) => storageMap.set(key, value), put: async (key, value) => storageMap.set(key, value),
}, },
getPreferredSystemLocales: () => ['en'],
}; };
// For ducks/network.getEmptyState() // For ducks/network.getEmptyState()

View file

@ -70,3 +70,5 @@ delete window.testUtilities.prepareTests;
mocha.run(); mocha.run();
})(); })();
window.getPreferredSystemLocales = () => ['en'];

7
ts/Intl.d.ts vendored Normal file
View file

@ -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<string>): Array<string>;
}

View file

@ -4,8 +4,9 @@
import { assert } from 'chai'; import { assert } from 'chai';
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import moment from 'moment'; import moment from 'moment';
import type { LocalizerType } from '../../types/Util';
import { HOUR, DAY } from '../../util/durations'; import { HOUR, DAY } from '../../util/durations';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import { import {
formatDate, formatDate,
@ -35,30 +36,7 @@ describe('timestamp', () => {
}); });
} }
const i18n = ((key: string, values: Array<string> = []): string => { const i18n = setupI18n('en', enMessages);
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;
describe('formatDate', () => { describe('formatDate', () => {
useFakeTimers(); useFakeTimers();
@ -83,7 +61,6 @@ describe('timestamp', () => {
it('returns a formatted timestamp for dates more recent than six months', () => { it('returns a formatted timestamp for dates more recent than six months', () => {
const m = moment().subtract(2, 'months'); const m = moment().subtract(2, 'months');
const result = formatDate(i18n, m); const result = formatDate(i18n, m);
assert.include(result, 'short');
assert.include(result, m.format('ddd')); assert.include(result, m.format('ddd'));
assert.include(result, m.format('MMM')); assert.include(result, m.format('MMM'));
assert.include(result, m.format('D')); assert.include(result, m.format('D'));
@ -91,16 +68,7 @@ describe('timestamp', () => {
}); });
it('returns a formatted timestamp for dates older than six months', () => { it('returns a formatted timestamp for dates older than six months', () => {
assert.strictEqual( assert.strictEqual(formatDate(i18n, moment('2017-03-03')), 'Mar 3, 2017');
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');
}); });
}); });
@ -119,18 +87,12 @@ describe('timestamp', () => {
}); });
it('formats month name, day of month, year, and time for other times', () => { 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( assert.strictEqual(
formatDateTimeLong(i18n, timestamp), formatDateTimeLong(i18n, new Date(956216013000)),
moment(timestamp).format('lll') 'Apr 20, 2000, 7:33 AM'
); );
}); });
}); });
});
describe('formatDateTimeShort', () => { describe('formatDateTimeShort', () => {
useFakeTimers(); useFakeTimers();

View file

@ -0,0 +1,14 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export function resolveCanonicalLocales(locales: Array<string>): Array<string> {
return Intl.getCanonicalLocales(
locales.flatMap(locale => {
try {
return Intl.getCanonicalLocales(locale);
} catch {
return 'en';
}
})
);
}

View file

@ -4,11 +4,8 @@
import type { Moment } from 'moment'; import type { Moment } from 'moment';
import moment from 'moment'; import moment from 'moment';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import * as log from '../logging/log';
import { DAY, HOUR, MINUTE, MONTH, WEEK } from './durations'; import { DAY, HOUR, MINUTE, MONTH, WEEK } from './durations';
const MAX_FORMAT_STRING_LENGTH = 50;
type RawTimestamp = Readonly<number | Date | Moment>; type RawTimestamp = Readonly<number | Date | Moment>;
export function isMoreRecentThan(timestamp: number, delta: number): boolean { export function isMoreRecentThan(timestamp: number, delta: number): boolean {
@ -40,23 +37,6 @@ export const isToday = (rawTimestamp: RawTimestamp): boolean =>
const isYesterday = (rawTimestamp: RawTimestamp): boolean => const isYesterday = (rawTimestamp: RawTimestamp): boolean =>
isSameDay(rawTimestamp, moment().subtract(1, 'day')); 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( export function formatDateTimeShort(
i18n: LocalizerType, i18n: LocalizerType,
rawTimestamp: RawTimestamp rawTimestamp: RawTimestamp
@ -66,6 +46,8 @@ export function formatDateTimeShort(
const now = Date.now(); const now = Date.now();
const diff = now - timestamp; const diff = now - timestamp;
const locale = window.getPreferredSystemLocales();
if (diff < HOUR || isToday(timestamp)) { if (diff < HOUR || isToday(timestamp)) {
return formatTime(i18n, rawTimestamp, now); return formatTime(i18n, rawTimestamp, now);
} }
@ -73,31 +55,57 @@ export function formatDateTimeShort(
const m = moment(timestamp); const m = moment(timestamp);
if (diff < WEEK && m.isSame(now, 'month')) { 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')) { 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( export function formatDateTimeLong(
i18n: LocalizerType, i18n: LocalizerType,
rawTimestamp: RawTimestamp rawTimestamp: RawTimestamp
): string { ): string {
let rawFormatString: string; const locale = window.getPreferredSystemLocales();
if (isToday(rawTimestamp)) { const timestamp = rawTimestamp.valueOf();
rawFormatString = i18n('timestampFormat__long__today');
} else if (isYesterday(rawTimestamp)) {
rawFormatString = i18n('timestampFormat__long__yesterday');
} else {
rawFormatString = 'lll';
}
const formatString = sanitizeFormatString(rawFormatString, 'lll');
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( export function formatTime(
@ -139,13 +147,22 @@ export function formatDate(
return i18n('yesterday'); return i18n('yesterday');
} }
const locale = window.getPreferredSystemLocales();
const m = moment(rawTimestamp); const m = moment(rawTimestamp);
const rawFormatString = const timestamp = rawTimestamp.valueOf();
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');
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);
} }