macOS: Add support for 12/24-hour time display preferences

This commit is contained in:
Jamie Kyle 2023-07-31 09:23:19 -07:00 committed by GitHub
parent 88858af144
commit 1143c0e9ba
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
17 changed files with 208 additions and 14 deletions

View file

@ -11,6 +11,7 @@ import { ClassyProvider } from '../ts/components/PopperRootContext';
import { StorybookThemeContext } from './StorybookThemeContext'; import { StorybookThemeContext } from './StorybookThemeContext';
import { ThemeType } from '../ts/types/Util'; import { ThemeType } from '../ts/types/Util';
import { setupI18n } from '../ts/util/setupI18n'; import { setupI18n } from '../ts/util/setupI18n';
import { HourCyclePreference } from '../ts/types/I18N';
export const globalTypes = { export const globalTypes = {
mode: { mode: {
@ -38,6 +39,7 @@ export const globalTypes = {
}; };
window.i18n = setupI18n('en', messages); window.i18n = setupI18n('en', messages);
window.getHourCyclePreference = () => HourCyclePreference.UnknownPreference;
const withModeAndThemeProvider = (Story, context) => { const withModeAndThemeProvider = (Story, context) => {
const theme = const theme =

View file

@ -9,7 +9,7 @@ import { z } from 'zod';
import { setupI18n } from '../ts/util/setupI18n'; import { setupI18n } from '../ts/util/setupI18n';
import type { LoggerType } from '../ts/types/Logging'; 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 type { LocalizerType } from '../ts/types/Util';
import * as Errors from '../ts/types/errors'; import * as Errors from '../ts/types/errors';
@ -30,6 +30,7 @@ export type LocaleType = {
name: string; name: string;
direction: LocaleDirection; direction: LocaleDirection;
messages: LocaleMessagesType; messages: LocaleMessagesType;
hourCyclePreference: HourCyclePreference;
}; };
function getLocaleDirection( function getLocaleDirection(
@ -67,8 +68,9 @@ function finalize(
messages: LocaleMessagesType, messages: LocaleMessagesType,
backupMessages: LocaleMessagesType, backupMessages: LocaleMessagesType,
localeName: string, localeName: string,
hourCyclePreference: HourCyclePreference,
logger: LoggerType logger: LoggerType
) { ): LocaleType {
// We start with english, then overwrite that with anything present in locale // We start with english, then overwrite that with anything present in locale
const finalMessages = merge(backupMessages, messages); const finalMessages = merge(backupMessages, messages);
@ -82,6 +84,7 @@ function finalize(
name: localeName, name: localeName,
direction, direction,
messages: finalMessages, messages: finalMessages,
hourCyclePreference,
}; };
} }
@ -96,9 +99,11 @@ export function _getAvailableLocales(): Array<string> {
export function load({ export function load({
preferredSystemLocales, preferredSystemLocales,
hourCyclePreference,
logger, logger,
}: { }: {
preferredSystemLocales: Array<string>; preferredSystemLocales: Array<string>;
hourCyclePreference: HourCyclePreference;
logger: LoggerType; logger: LoggerType;
}): LocaleType { }): LocaleType {
if (preferredSystemLocales == null) { if (preferredSystemLocales == null) {
@ -130,6 +135,7 @@ export function load({
matchedLocaleMessages, matchedLocaleMessages,
englishMessages, englishMessages,
matchedLocale, matchedLocale,
hourCyclePreference,
logger logger
); );
} }

View file

@ -122,6 +122,7 @@ import type { LocaleType } from './locale';
import { load as loadLocale } from './locale'; import { load as loadLocale } from './locale';
import type { LoggerType } from '../ts/types/Logging'; import type { LoggerType } from '../ts/types/Logging';
import { HourCyclePreference } from '../ts/types/I18N';
const STICKER_CREATOR_PARTITION = 'sticker-creator'; const STICKER_CREATOR_PARTITION = 'sticker-creator';
@ -408,6 +409,19 @@ function getResolvedMessagesLocale(): LocaleType {
return resolvedTranslationsLocale; 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 = { type PrepareUrlOptions = {
forCalling?: boolean; forCalling?: boolean;
forCamera?: boolean; forCamera?: boolean;
@ -1686,6 +1700,9 @@ app.on('ready', async () => {
loadPreferredSystemLocales() loadPreferredSystemLocales()
); );
const hourCyclePreference = getHourCyclePreference();
logger.info(`app.ready: hour cycle preference: ${hourCyclePreference}`);
logger.info( logger.info(
`app.ready: preferred system locales: ${preferredSystemLocales.join( `app.ready: preferred system locales: ${preferredSystemLocales.join(
', ' ', '
@ -1693,6 +1710,7 @@ app.on('ready', async () => {
); );
resolvedTranslationsLocale = loadLocale({ resolvedTranslationsLocale = loadLocale({
preferredSystemLocales, preferredSystemLocales,
hourCyclePreference,
logger: getLogger(), logger: getLogger(),
}); });
} }
@ -2264,6 +2282,7 @@ ipc.on('get-config', async event => {
name: packageJson.productName, name: packageJson.productName,
resolvedTranslationsLocale: getResolvedMessagesLocale().name, resolvedTranslationsLocale: getResolvedMessagesLocale().name,
resolvedTranslationsLocaleDirection: getResolvedMessagesLocale().direction, resolvedTranslationsLocaleDirection: getResolvedMessagesLocale().direction,
hourCyclePreference: getResolvedMessagesLocale().hourCyclePreference,
preferredSystemLocales: getPreferredSystemLocales(), preferredSystemLocales: getPreferredSystemLocales(),
version: app.getVersion(), version: app.getVersion(),
buildCreation: config.get<number>('buildCreation'), buildCreation: config.get<number>('buildCreation'),

View file

@ -9,6 +9,7 @@ const { usernames } = require('@signalapp/libsignal-client');
const { Crypto } = require('../ts/context/Crypto'); const { Crypto } = require('../ts/context/Crypto');
const { setEnvironment, Environment } = require('../ts/environment'); const { setEnvironment, Environment } = require('../ts/environment');
const { HourCyclePreference } = require('../ts/types/I18N');
chai.use(chaiAsPromised); chai.use(chaiAsPromised);
@ -35,6 +36,7 @@ global.window = {
put: async (key, value) => storageMap.set(key, value), put: async (key, value) => storageMap.set(key, value),
}, },
getPreferredSystemLocales: () => ['en'], getPreferredSystemLocales: () => ['en'],
getHourCyclePreference: () => HourCyclePreference.UnknownPreference,
}; };
// For ducks/network.getEmptyState() // For ducks/network.getEmptyState()

View file

@ -9,6 +9,7 @@ import { useRestoreFocus } from '../../hooks/useRestoreFocus';
import type { StickerPackType, StickerType } from '../../state/ducks/stickers'; import type { StickerPackType, StickerType } from '../../state/ducks/stickers';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { getAnalogTime } from '../../util/getAnalogTime'; import { getAnalogTime } from '../../util/getAnalogTime';
import { getDateTimeFormatter } from '../../util/formatTimestamp';
export type OwnProps = { export type OwnProps = {
readonly i18n: LocalizerType; readonly i18n: LocalizerType;
@ -322,13 +323,10 @@ export const StickerPicker = React.memo(
className="module-sticker-picker__body__cell module-sticker-picker__time--digital" className="module-sticker-picker__body__cell module-sticker-picker__time--digital"
onClick={() => onPickTimeSticker('digital')} onClick={() => onPickTimeSticker('digital')}
> >
{new Intl.DateTimeFormat( {getDateTimeFormatter({
window.getPreferredSystemLocales(),
{
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
} })
)
.formatToParts(Date.now()) .formatToParts(Date.now())
.filter(x => x.type !== 'dayPeriod') .filter(x => x.type !== 'dayPeriod')
.reduce((acc, { value }) => `${acc}${value}`, '')} .reduce((acc, { value }) => `${acc}${value}`, '')}

View file

@ -4,6 +4,7 @@
import { fabric } from 'fabric'; import { fabric } from 'fabric';
import { customFabricObjectControls } from './util/customFabricObjectControls'; import { customFabricObjectControls } from './util/customFabricObjectControls';
import { moreStyles } from './util/moreStyles'; import { moreStyles } from './util/moreStyles';
import { getDateTimeFormatter } from '../util/formatTimestamp';
export enum DigitalClockStickerStyle { export enum DigitalClockStickerStyle {
White = 'White', White = 'White',
@ -90,7 +91,7 @@ export class MediaEditorFabricDigitalTimeSticker extends fabric.Group {
style: DigitalClockStickerStyle = DigitalClockStickerStyle.White, style: DigitalClockStickerStyle = DigitalClockStickerStyle.White,
options: fabric.IGroupOptions = {} options: fabric.IGroupOptions = {}
) { ) {
const parts = new Intl.DateTimeFormat(window.getPreferredSystemLocales(), { const parts = getDateTimeFormatter({
hour: 'numeric', hour: 'numeric',
minute: 'numeric', minute: 'numeric',
}).formatToParts(timestamp); }).formatToParts(timestamp);

View file

@ -118,6 +118,7 @@ export function getEmptyState(): UserStateType {
isLegacyFormat: intlNotSetup, isLegacyFormat: intlNotSetup,
getLocaleMessages: intlNotSetup, getLocaleMessages: intlNotSetup,
getLocaleDirection: intlNotSetup, getLocaleDirection: intlNotSetup,
getHourCyclePreference: intlNotSetup,
}), }),
interactionMode: 'mouse', interactionMode: 'mouse',
isMainWindowMaximized: false, isMainWindowMaximized: false,

View file

@ -6,6 +6,7 @@ import { stub } from 'sinon';
import * as LocaleMatcher from '@formatjs/intl-localematcher'; import * as LocaleMatcher from '@formatjs/intl-localematcher';
import { load, _getAvailableLocales } from '../../../app/locale'; import { load, _getAvailableLocales } from '../../../app/locale';
import { FAKE_DEFAULT_LOCALE } from '../../../app/spell_check'; import { FAKE_DEFAULT_LOCALE } from '../../../app/spell_check';
import { HourCyclePreference } from '../../types/I18N';
describe('locale', async () => { describe('locale', async () => {
describe('load', () => { describe('load', () => {
@ -23,7 +24,11 @@ describe('locale', async () => {
preferredSystemLocales: Array<string>, preferredSystemLocales: Array<string>,
expectedLocale: string expectedLocale: string
) { ) {
const actualLocale = await load({ preferredSystemLocales, logger }); const actualLocale = await load({
preferredSystemLocales,
hourCyclePreference: HourCyclePreference.UnknownPreference,
logger,
});
assert.strictEqual(actualLocale.name, expectedLocale); assert.strictEqual(actualLocale.name, expectedLocale);
} }

View file

@ -9,6 +9,7 @@ import type { CreateTemplateOptionsType } from '../../../app/menu';
import { createTemplate } from '../../../app/menu'; import { createTemplate } from '../../../app/menu';
import { load as loadLocale } from '../../../app/locale'; import { load as loadLocale } from '../../../app/locale';
import type { MenuListType } from '../../types/menu'; import type { MenuListType } from '../../types/menu';
import { HourCyclePreference } from '../../types/I18N';
const forceUpdate = stub(); const forceUpdate = stub();
const openArtCreator = stub(); const openArtCreator = stub();
@ -198,6 +199,7 @@ const PLATFORMS = [
describe('createTemplate', () => { describe('createTemplate', () => {
const { i18n } = loadLocale({ const { i18n } = loadLocale({
preferredSystemLocales: ['en'], preferredSystemLocales: ['en'],
hourCyclePreference: HourCyclePreference.UnknownPreference,
logger: { logger: {
fatal: stub().throwsArg(0), fatal: stub().throwsArg(0),
error: stub().throwsArg(0), error: stub().throwsArg(0),

View file

@ -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');
});

View file

@ -1,6 +1,7 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { z } from 'zod';
import type { LocalizerType } from './Util'; import type { LocalizerType } from './Util';
export type { LocalizerType } from './Util'; export type { LocalizerType } from './Util';
@ -35,3 +36,11 @@ export type LocaleType = {
i18n: LocalizerType; i18n: LocalizerType;
messages: LocaleMessagesType; 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);

View file

@ -5,6 +5,7 @@ import { z } from 'zod';
import { Environment } from '../environment'; import { Environment } from '../environment';
import { themeSettingSchema } from './StorageUIKeys'; import { themeSettingSchema } from './StorageUIKeys';
import { HourCyclePreferenceSchema } from './I18N';
const environmentSchema = z.nativeEnum(Environment); const environmentSchema = z.nativeEnum(Environment);
@ -46,6 +47,7 @@ export const rendererConfigSchema = z.object({
osVersion: configRequiredStringSchema, osVersion: configRequiredStringSchema,
resolvedTranslationsLocale: configRequiredStringSchema, resolvedTranslationsLocale: configRequiredStringSchema,
resolvedTranslationsLocaleDirection: z.enum(['ltr', 'rtl']), resolvedTranslationsLocaleDirection: z.enum(['ltr', 'rtl']),
hourCyclePreference: HourCyclePreferenceSchema,
preferredSystemLocales: z.array(configRequiredStringSchema), preferredSystemLocales: z.array(configRequiredStringSchema),
name: configRequiredStringSchema, name: configRequiredStringSchema,
nodeVersion: configRequiredStringSchema, nodeVersion: configRequiredStringSchema,

View file

@ -5,7 +5,7 @@ import type { IntlShape } from 'react-intl';
import type { UUIDStringType } from './UUID'; import type { UUIDStringType } from './UUID';
import type { LocaleDirection } from '../../app/locale'; import type { LocaleDirection } from '../../app/locale';
import type { LocaleMessagesType } from './I18N'; import type { HourCyclePreference, LocaleMessagesType } from './I18N';
export type StoryContextType = { export type StoryContextType = {
authorUuid?: UUIDStringType; authorUuid?: UUIDStringType;
@ -28,6 +28,7 @@ export type LocalizerType = {
getLocale(): string; getLocale(): string;
getLocaleMessages(): LocaleMessagesType; getLocaleMessages(): LocaleMessagesType;
getLocaleDirection(): LocaleDirection; getLocaleDirection(): LocaleDirection;
getHourCyclePreference(): HourCyclePreference;
}; };
export enum SentMediaQualityType { export enum SentMediaQualityType {

View file

@ -1,15 +1,92 @@
// Copyright 2023 Signal Messenger, LLC // Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { HourCyclePreference } from '../types/I18N';
import { assertDev } from './assert'; 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<string>,
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<string>,
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<string, Intl.DateTimeFormat>();
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( export function formatTimestamp(
timestamp: number, timestamp: number,
options: Intl.DateTimeFormatOptions options: Intl.DateTimeFormatOptions
): string { ): string {
const locale = window.getPreferredSystemLocales(); const formatter = getDateTimeFormatter(options);
try { try {
return new Intl.DateTimeFormat(locale, options).format(timestamp); return formatter.format(timestamp);
} catch (err) { } catch (err) {
assertDev(false, 'invalid timestamp'); assertDev(false, 'invalid timestamp');
return ''; return '';

View file

@ -105,6 +105,9 @@ export function setupI18n(
localizer.getLocaleDirection = () => { localizer.getLocaleDirection = () => {
return window.getResolvedMessagesLocaleDirection(); return window.getResolvedMessagesLocaleDirection();
}; };
localizer.getHourCyclePreference = () => {
return window.getHourCyclePreference();
};
return localizer; return localizer;
} }

2
ts/window.d.ts vendored
View file

@ -57,6 +57,7 @@ import type { initializeMigrations } from './signal';
import type { RetryPlaceholders } from './util/retryPlaceholders'; import type { RetryPlaceholders } from './util/retryPlaceholders';
import type { PropsPreloadType as PreferencesPropsType } from './components/Preferences'; import type { PropsPreloadType as PreferencesPropsType } from './components/Preferences';
import type { LocaleDirection } from '../app/locale'; import type { LocaleDirection } from '../app/locale';
import type { HourCyclePreference } from './types/I18N';
export { Long } from 'long'; export { Long } from 'long';
@ -196,6 +197,7 @@ declare global {
getHostName: () => string; getHostName: () => string;
getInteractionMode: () => 'mouse' | 'keyboard'; getInteractionMode: () => 'mouse' | 'keyboard';
getResolvedMessagesLocaleDirection: () => LocaleDirection; getResolvedMessagesLocaleDirection: () => LocaleDirection;
getHourCyclePreference: () => HourCyclePreference;
getResolvedMessagesLocale: () => string; getResolvedMessagesLocale: () => string;
getPreferredSystemLocales: () => Array<string>; getPreferredSystemLocales: () => Array<string>;
getServerPublicParams: () => string; getServerPublicParams: () => string;

View file

@ -44,6 +44,7 @@ window.getTitle = () => title;
window.getResolvedMessagesLocale = () => config.resolvedTranslationsLocale; window.getResolvedMessagesLocale = () => config.resolvedTranslationsLocale;
window.getResolvedMessagesLocaleDirection = () => window.getResolvedMessagesLocaleDirection = () =>
config.resolvedTranslationsLocaleDirection; config.resolvedTranslationsLocaleDirection;
window.getHourCyclePreference = () => config.hourCyclePreference;
window.getPreferredSystemLocales = () => config.preferredSystemLocales; window.getPreferredSystemLocales = () => config.preferredSystemLocales;
window.getEnvironment = getEnvironment; window.getEnvironment = getEnvironment;
window.getAppInstance = () => config.appInstance; window.getAppInstance = () => config.appInstance;