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": {
"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",

View file

@ -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(
', '

View file

@ -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()

View file

@ -70,3 +70,5 @@ delete window.testUtilities.prepareTests;
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 * 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> = []): 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,18 +87,12 @@ 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')
formatDateTimeLong(i18n, new Date(956216013000)),
'Apr 20, 2000, 7:33 AM'
);
});
});
});
describe('formatDateTimeShort', () => {
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 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<number | Date | Moment>;
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);
}