Use Intl.DateTimeFormat instead of moment for date formatting
This commit is contained in:
parent
bd40a7fb98
commit
356fb301e1
8 changed files with 106 additions and 91 deletions
|
@ -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",
|
||||
|
|
|
@ -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(
|
||||
', '
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -70,3 +70,5 @@ delete window.testUtilities.prepareTests;
|
|||
|
||||
mocha.run();
|
||||
})();
|
||||
|
||||
window.getPreferredSystemLocales = () => ['en'];
|
||||
|
|
7
ts/Intl.d.ts
vendored
Normal file
7
ts/Intl.d.ts
vendored
Normal 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>;
|
||||
}
|
|
@ -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();
|
||||
|
|
14
ts/util/resolveCanonicalLocales.ts
Normal file
14
ts/util/resolveCanonicalLocales.ts
Normal 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';
|
||||
}
|
||||
})
|
||||
);
|
||||
}
|
|
@ -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);
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue