From 1143c0e9ba13293b3478742f3f4f95e7f3bbee51 Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Mon, 31 Jul 2023 09:23:19 -0700 Subject: [PATCH] macOS: Add support for 12/24-hour time display preferences --- .storybook/preview.tsx | 2 + app/locale.ts | 10 ++- app/main.ts | 19 +++++ test/setup-test-node.js | 2 + ts/components/stickers/StickerPicker.tsx | 12 ++- .../MediaEditorFabricDigitalTimeSticker.ts | 3 +- ts/state/ducks/user.ts | 1 + ts/test-node/app/locale_test.ts | 7 +- ts/test-node/app/menu_test.ts | 2 + ts/test-node/util/formatTimestamp_test.ts | 63 +++++++++++++++ ts/types/I18N.ts | 9 +++ ts/types/RendererConfig.ts | 2 + ts/types/Util.ts | 3 +- ts/util/formatTimestamp.ts | 81 ++++++++++++++++++- ts/util/setupI18n.tsx | 3 + ts/window.d.ts | 2 + ts/windows/main/phase1-ipc.ts | 1 + 17 files changed, 208 insertions(+), 14 deletions(-) create mode 100644 ts/test-node/util/formatTimestamp_test.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index a067109d4..ae3ec18f1 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -11,6 +11,7 @@ import { ClassyProvider } from '../ts/components/PopperRootContext'; import { StorybookThemeContext } from './StorybookThemeContext'; import { ThemeType } from '../ts/types/Util'; import { setupI18n } from '../ts/util/setupI18n'; +import { HourCyclePreference } from '../ts/types/I18N'; export const globalTypes = { mode: { @@ -38,6 +39,7 @@ export const globalTypes = { }; window.i18n = setupI18n('en', messages); +window.getHourCyclePreference = () => HourCyclePreference.UnknownPreference; const withModeAndThemeProvider = (Story, context) => { const theme = diff --git a/app/locale.ts b/app/locale.ts index 4ba2b37ef..59697ea5b 100644 --- a/app/locale.ts +++ b/app/locale.ts @@ -9,7 +9,7 @@ import { z } from 'zod'; import { setupI18n } from '../ts/util/setupI18n'; import type { LoggerType } from '../ts/types/Logging'; -import type { LocaleMessagesType } from '../ts/types/I18N'; +import type { HourCyclePreference, LocaleMessagesType } from '../ts/types/I18N'; import type { LocalizerType } from '../ts/types/Util'; import * as Errors from '../ts/types/errors'; @@ -30,6 +30,7 @@ export type LocaleType = { name: string; direction: LocaleDirection; messages: LocaleMessagesType; + hourCyclePreference: HourCyclePreference; }; function getLocaleDirection( @@ -67,8 +68,9 @@ function finalize( messages: LocaleMessagesType, backupMessages: LocaleMessagesType, localeName: string, + hourCyclePreference: HourCyclePreference, logger: LoggerType -) { +): LocaleType { // We start with english, then overwrite that with anything present in locale const finalMessages = merge(backupMessages, messages); @@ -82,6 +84,7 @@ function finalize( name: localeName, direction, messages: finalMessages, + hourCyclePreference, }; } @@ -96,9 +99,11 @@ export function _getAvailableLocales(): Array { export function load({ preferredSystemLocales, + hourCyclePreference, logger, }: { preferredSystemLocales: Array; + hourCyclePreference: HourCyclePreference; logger: LoggerType; }): LocaleType { if (preferredSystemLocales == null) { @@ -130,6 +135,7 @@ export function load({ matchedLocaleMessages, englishMessages, matchedLocale, + hourCyclePreference, logger ); } diff --git a/app/main.ts b/app/main.ts index 37c24c14a..3e1eeb73e 100644 --- a/app/main.ts +++ b/app/main.ts @@ -122,6 +122,7 @@ import type { LocaleType } from './locale'; import { load as loadLocale } from './locale'; import type { LoggerType } from '../ts/types/Logging'; +import { HourCyclePreference } from '../ts/types/I18N'; const STICKER_CREATOR_PARTITION = 'sticker-creator'; @@ -408,6 +409,19 @@ function getResolvedMessagesLocale(): LocaleType { return resolvedTranslationsLocale; } +function getHourCyclePreference(): HourCyclePreference { + if (process.platform !== 'darwin') { + return HourCyclePreference.UnknownPreference; + } + if (systemPreferences.getUserDefault('AppleICUForce24HourTime', 'boolean')) { + return HourCyclePreference.Prefer24; + } + if (systemPreferences.getUserDefault('AppleICUForce12HourTime', 'boolean')) { + return HourCyclePreference.Prefer12; + } + return HourCyclePreference.UnknownPreference; +} + type PrepareUrlOptions = { forCalling?: boolean; forCamera?: boolean; @@ -1686,6 +1700,9 @@ app.on('ready', async () => { loadPreferredSystemLocales() ); + const hourCyclePreference = getHourCyclePreference(); + logger.info(`app.ready: hour cycle preference: ${hourCyclePreference}`); + logger.info( `app.ready: preferred system locales: ${preferredSystemLocales.join( ', ' @@ -1693,6 +1710,7 @@ app.on('ready', async () => { ); resolvedTranslationsLocale = loadLocale({ preferredSystemLocales, + hourCyclePreference, logger: getLogger(), }); } @@ -2264,6 +2282,7 @@ ipc.on('get-config', async event => { name: packageJson.productName, resolvedTranslationsLocale: getResolvedMessagesLocale().name, resolvedTranslationsLocaleDirection: getResolvedMessagesLocale().direction, + hourCyclePreference: getResolvedMessagesLocale().hourCyclePreference, preferredSystemLocales: getPreferredSystemLocales(), version: app.getVersion(), buildCreation: config.get('buildCreation'), diff --git a/test/setup-test-node.js b/test/setup-test-node.js index 64b2820d6..53f8703fe 100644 --- a/test/setup-test-node.js +++ b/test/setup-test-node.js @@ -9,6 +9,7 @@ const { usernames } = require('@signalapp/libsignal-client'); const { Crypto } = require('../ts/context/Crypto'); const { setEnvironment, Environment } = require('../ts/environment'); +const { HourCyclePreference } = require('../ts/types/I18N'); chai.use(chaiAsPromised); @@ -35,6 +36,7 @@ global.window = { put: async (key, value) => storageMap.set(key, value), }, getPreferredSystemLocales: () => ['en'], + getHourCyclePreference: () => HourCyclePreference.UnknownPreference, }; // For ducks/network.getEmptyState() diff --git a/ts/components/stickers/StickerPicker.tsx b/ts/components/stickers/StickerPicker.tsx index c25dbd1f4..8e3d28dec 100644 --- a/ts/components/stickers/StickerPicker.tsx +++ b/ts/components/stickers/StickerPicker.tsx @@ -9,6 +9,7 @@ import { useRestoreFocus } from '../../hooks/useRestoreFocus'; import type { StickerPackType, StickerType } from '../../state/ducks/stickers'; import type { LocalizerType } from '../../types/Util'; import { getAnalogTime } from '../../util/getAnalogTime'; +import { getDateTimeFormatter } from '../../util/formatTimestamp'; export type OwnProps = { readonly i18n: LocalizerType; @@ -322,13 +323,10 @@ export const StickerPicker = React.memo( className="module-sticker-picker__body__cell module-sticker-picker__time--digital" onClick={() => onPickTimeSticker('digital')} > - {new Intl.DateTimeFormat( - window.getPreferredSystemLocales(), - { - hour: 'numeric', - minute: 'numeric', - } - ) + {getDateTimeFormatter({ + hour: 'numeric', + minute: 'numeric', + }) .formatToParts(Date.now()) .filter(x => x.type !== 'dayPeriod') .reduce((acc, { value }) => `${acc}${value}`, '')} diff --git a/ts/mediaEditor/MediaEditorFabricDigitalTimeSticker.ts b/ts/mediaEditor/MediaEditorFabricDigitalTimeSticker.ts index d6b856015..0ac3d2358 100644 --- a/ts/mediaEditor/MediaEditorFabricDigitalTimeSticker.ts +++ b/ts/mediaEditor/MediaEditorFabricDigitalTimeSticker.ts @@ -4,6 +4,7 @@ import { fabric } from 'fabric'; import { customFabricObjectControls } from './util/customFabricObjectControls'; import { moreStyles } from './util/moreStyles'; +import { getDateTimeFormatter } from '../util/formatTimestamp'; export enum DigitalClockStickerStyle { White = 'White', @@ -90,7 +91,7 @@ export class MediaEditorFabricDigitalTimeSticker extends fabric.Group { style: DigitalClockStickerStyle = DigitalClockStickerStyle.White, options: fabric.IGroupOptions = {} ) { - const parts = new Intl.DateTimeFormat(window.getPreferredSystemLocales(), { + const parts = getDateTimeFormatter({ hour: 'numeric', minute: 'numeric', }).formatToParts(timestamp); diff --git a/ts/state/ducks/user.ts b/ts/state/ducks/user.ts index 093cc042c..482f6e211 100644 --- a/ts/state/ducks/user.ts +++ b/ts/state/ducks/user.ts @@ -118,6 +118,7 @@ export function getEmptyState(): UserStateType { isLegacyFormat: intlNotSetup, getLocaleMessages: intlNotSetup, getLocaleDirection: intlNotSetup, + getHourCyclePreference: intlNotSetup, }), interactionMode: 'mouse', isMainWindowMaximized: false, diff --git a/ts/test-node/app/locale_test.ts b/ts/test-node/app/locale_test.ts index 6578f54bf..5c342abad 100644 --- a/ts/test-node/app/locale_test.ts +++ b/ts/test-node/app/locale_test.ts @@ -6,6 +6,7 @@ import { stub } from 'sinon'; import * as LocaleMatcher from '@formatjs/intl-localematcher'; import { load, _getAvailableLocales } from '../../../app/locale'; import { FAKE_DEFAULT_LOCALE } from '../../../app/spell_check'; +import { HourCyclePreference } from '../../types/I18N'; describe('locale', async () => { describe('load', () => { @@ -23,7 +24,11 @@ describe('locale', async () => { preferredSystemLocales: Array, expectedLocale: string ) { - const actualLocale = await load({ preferredSystemLocales, logger }); + const actualLocale = await load({ + preferredSystemLocales, + hourCyclePreference: HourCyclePreference.UnknownPreference, + logger, + }); assert.strictEqual(actualLocale.name, expectedLocale); } diff --git a/ts/test-node/app/menu_test.ts b/ts/test-node/app/menu_test.ts index 04ef83443..41c5788a6 100644 --- a/ts/test-node/app/menu_test.ts +++ b/ts/test-node/app/menu_test.ts @@ -9,6 +9,7 @@ import type { CreateTemplateOptionsType } from '../../../app/menu'; import { createTemplate } from '../../../app/menu'; import { load as loadLocale } from '../../../app/locale'; import type { MenuListType } from '../../types/menu'; +import { HourCyclePreference } from '../../types/I18N'; const forceUpdate = stub(); const openArtCreator = stub(); @@ -198,6 +199,7 @@ const PLATFORMS = [ describe('createTemplate', () => { const { i18n } = loadLocale({ preferredSystemLocales: ['en'], + hourCyclePreference: HourCyclePreference.UnknownPreference, logger: { fatal: stub().throwsArg(0), error: stub().throwsArg(0), diff --git a/ts/test-node/util/formatTimestamp_test.ts b/ts/test-node/util/formatTimestamp_test.ts new file mode 100644 index 000000000..7fb87c320 --- /dev/null +++ b/ts/test-node/util/formatTimestamp_test.ts @@ -0,0 +1,63 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { assert } from 'chai'; +import * as sinon from 'sinon'; +import { formatTimestamp } from '../../util/formatTimestamp'; +import { HourCyclePreference } from '../../types/I18N'; + +const min = new Date(2023, 0, 1, 0).getTime(); +const max = new Date(2023, 0, 1, 23).getTime(); + +describe('formatTimestamp', () => { + let sandbox: sinon.SinonSandbox; + let localesStub: sinon.SinonStub; + let hourCycleStub: sinon.SinonStub; + + beforeEach(() => { + sandbox = sinon.createSandbox(); + localesStub = sandbox.stub(window, 'getPreferredSystemLocales'); + hourCycleStub = sandbox.stub(window, 'getHourCyclePreference'); + }); + + afterEach(() => { + sandbox.restore(); + }); + + function testCase( + locale: string, + preference: HourCyclePreference, + time: number, + expected: string + ) { + const timeFmt = new Intl.DateTimeFormat('en', { + timeStyle: 'medium', + }).format(time); + it(`should format with locale: ${locale} (${HourCyclePreference[preference]}) @ ${timeFmt})`, () => { + localesStub.returns([locale]); + hourCycleStub.returns(preference); + assert.equal(formatTimestamp(time, { timeStyle: 'medium' }), expected); + }); + } + + testCase('en', HourCyclePreference.UnknownPreference, min, '12:00:00 AM'); + testCase('en', HourCyclePreference.UnknownPreference, max, '11:00:00 PM'); + testCase('en', HourCyclePreference.Prefer12, min, '12:00:00 AM'); + testCase('en', HourCyclePreference.Prefer12, max, '11:00:00 PM'); + testCase('en', HourCyclePreference.Prefer24, min, '00:00:00'); + testCase('en', HourCyclePreference.Prefer24, max, '23:00:00'); + + testCase('nb', HourCyclePreference.UnknownPreference, min, '00:00:00'); + testCase('nb', HourCyclePreference.UnknownPreference, max, '23:00:00'); + testCase('nb', HourCyclePreference.Prefer12, min, '12:00:00 a.m.'); + testCase('nb', HourCyclePreference.Prefer12, max, '11:00:00 p.m.'); + testCase('nb', HourCyclePreference.Prefer24, min, '00:00:00'); + testCase('nb', HourCyclePreference.Prefer24, max, '23:00:00'); + + testCase('ja', HourCyclePreference.UnknownPreference, min, '0:00:00'); + testCase('ja', HourCyclePreference.UnknownPreference, max, '23:00:00'); + testCase('ja', HourCyclePreference.Prefer12, min, 'εˆε‰0:00:00'); + testCase('ja', HourCyclePreference.Prefer12, max, '午後11:00:00'); + testCase('ja', HourCyclePreference.Prefer24, min, '0:00:00'); + testCase('ja', HourCyclePreference.Prefer24, max, '23:00:00'); +}); diff --git a/ts/types/I18N.ts b/ts/types/I18N.ts index db2614fc1..225483657 100644 --- a/ts/types/I18N.ts +++ b/ts/types/I18N.ts @@ -1,6 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { z } from 'zod'; import type { LocalizerType } from './Util'; export type { LocalizerType } from './Util'; @@ -35,3 +36,11 @@ export type LocaleType = { i18n: LocalizerType; messages: LocaleMessagesType; }; + +export enum HourCyclePreference { + Prefer24 = 'Prefer24', // either h23 or h24 + Prefer12 = 'Prefer12', // either h11 or h12 + UnknownPreference = 'UnknownPreference', +} + +export const HourCyclePreferenceSchema = z.nativeEnum(HourCyclePreference); diff --git a/ts/types/RendererConfig.ts b/ts/types/RendererConfig.ts index 5d9579621..13bf3dcd6 100644 --- a/ts/types/RendererConfig.ts +++ b/ts/types/RendererConfig.ts @@ -5,6 +5,7 @@ import { z } from 'zod'; import { Environment } from '../environment'; import { themeSettingSchema } from './StorageUIKeys'; +import { HourCyclePreferenceSchema } from './I18N'; const environmentSchema = z.nativeEnum(Environment); @@ -46,6 +47,7 @@ export const rendererConfigSchema = z.object({ osVersion: configRequiredStringSchema, resolvedTranslationsLocale: configRequiredStringSchema, resolvedTranslationsLocaleDirection: z.enum(['ltr', 'rtl']), + hourCyclePreference: HourCyclePreferenceSchema, preferredSystemLocales: z.array(configRequiredStringSchema), name: configRequiredStringSchema, nodeVersion: configRequiredStringSchema, diff --git a/ts/types/Util.ts b/ts/types/Util.ts index 279e23b47..84ff4542e 100644 --- a/ts/types/Util.ts +++ b/ts/types/Util.ts @@ -5,7 +5,7 @@ import type { IntlShape } from 'react-intl'; import type { UUIDStringType } from './UUID'; import type { LocaleDirection } from '../../app/locale'; -import type { LocaleMessagesType } from './I18N'; +import type { HourCyclePreference, LocaleMessagesType } from './I18N'; export type StoryContextType = { authorUuid?: UUIDStringType; @@ -28,6 +28,7 @@ export type LocalizerType = { getLocale(): string; getLocaleMessages(): LocaleMessagesType; getLocaleDirection(): LocaleDirection; + getHourCyclePreference(): HourCyclePreference; }; export enum SentMediaQualityType { diff --git a/ts/util/formatTimestamp.ts b/ts/util/formatTimestamp.ts index 832ce8f02..c0606977a 100644 --- a/ts/util/formatTimestamp.ts +++ b/ts/util/formatTimestamp.ts @@ -1,15 +1,92 @@ // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { HourCyclePreference } from '../types/I18N'; import { assertDev } from './assert'; +function getOptionsWithPreferences( + options: Intl.DateTimeFormatOptions +): Intl.DateTimeFormatOptions { + const hourCyclePreference = window.getHourCyclePreference(); + if (options.hour12 != null) { + return options; + } + if (hourCyclePreference === HourCyclePreference.Prefer12) { + return { ...options, hour12: true }; + } + if (hourCyclePreference === HourCyclePreference.Prefer24) { + return { ...options, hour12: false }; + } + return options; +} + +/** + * Chrome doesn't implement hour12 correctly + */ +function fixBuggyOptions( + locales: Array, + options: Intl.DateTimeFormatOptions +): Intl.DateTimeFormatOptions { + const resolvedOptions = new Intl.DateTimeFormat( + locales, + options + ).resolvedOptions(); + const resolvedLocale = new Intl.Locale(resolvedOptions.locale); + let { hourCycle } = resolvedOptions; + // Most languages should use either h24 or h12 + if (hourCycle === 'h24') { + hourCycle = 'h23'; + } + if (hourCycle === 'h11') { + hourCycle = 'h12'; + } + // Only Japanese should use h11 when using hour12 time + if (hourCycle === 'h12' && resolvedLocale.language === 'ja') { + hourCycle = 'h11'; + } + return { + ...options, + hour12: undefined, + hourCycle, + }; +} + +function getCacheKey( + locales: Array, + options: Intl.DateTimeFormatOptions +) { + return `${locales.join(',')}:${Object.keys(options) + .sort() + // eslint-disable-next-line @typescript-eslint/no-explicit-any + .map(key => `${key}=${(options as any)[key]}`) + .join(',')}`; +} + +const formatterCache = new Map(); + +export function getDateTimeFormatter( + options: Intl.DateTimeFormatOptions +): Intl.DateTimeFormat { + const locales = window.getPreferredSystemLocales(); + const optionsWithPreferences = getOptionsWithPreferences(options); + const cacheKey = getCacheKey(locales, optionsWithPreferences); + const cachedFormatter = formatterCache.get(cacheKey); + if (cachedFormatter) { + return cachedFormatter; + } + const fixedOptions = fixBuggyOptions(locales, optionsWithPreferences); + const formatter = new Intl.DateTimeFormat(locales, fixedOptions); + formatterCache.set(cacheKey, formatter); + return formatter; +} + export function formatTimestamp( timestamp: number, options: Intl.DateTimeFormatOptions ): string { - const locale = window.getPreferredSystemLocales(); + const formatter = getDateTimeFormatter(options); try { - return new Intl.DateTimeFormat(locale, options).format(timestamp); + return formatter.format(timestamp); } catch (err) { assertDev(false, 'invalid timestamp'); return ''; diff --git a/ts/util/setupI18n.tsx b/ts/util/setupI18n.tsx index a62cb384c..4301e739b 100644 --- a/ts/util/setupI18n.tsx +++ b/ts/util/setupI18n.tsx @@ -105,6 +105,9 @@ export function setupI18n( localizer.getLocaleDirection = () => { return window.getResolvedMessagesLocaleDirection(); }; + localizer.getHourCyclePreference = () => { + return window.getHourCyclePreference(); + }; return localizer; } diff --git a/ts/window.d.ts b/ts/window.d.ts index e1b1b4659..3f048a879 100644 --- a/ts/window.d.ts +++ b/ts/window.d.ts @@ -57,6 +57,7 @@ import type { initializeMigrations } from './signal'; import type { RetryPlaceholders } from './util/retryPlaceholders'; import type { PropsPreloadType as PreferencesPropsType } from './components/Preferences'; import type { LocaleDirection } from '../app/locale'; +import type { HourCyclePreference } from './types/I18N'; export { Long } from 'long'; @@ -196,6 +197,7 @@ declare global { getHostName: () => string; getInteractionMode: () => 'mouse' | 'keyboard'; getResolvedMessagesLocaleDirection: () => LocaleDirection; + getHourCyclePreference: () => HourCyclePreference; getResolvedMessagesLocale: () => string; getPreferredSystemLocales: () => Array; getServerPublicParams: () => string; diff --git a/ts/windows/main/phase1-ipc.ts b/ts/windows/main/phase1-ipc.ts index cfca601c0..fd03f7fbf 100644 --- a/ts/windows/main/phase1-ipc.ts +++ b/ts/windows/main/phase1-ipc.ts @@ -44,6 +44,7 @@ window.getTitle = () => title; window.getResolvedMessagesLocale = () => config.resolvedTranslationsLocale; window.getResolvedMessagesLocaleDirection = () => config.resolvedTranslationsLocaleDirection; +window.getHourCyclePreference = () => config.hourCyclePreference; window.getPreferredSystemLocales = () => config.preferredSystemLocales; window.getEnvironment = getEnvironment; window.getAppInstance = () => config.appInstance;