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 { 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 =

View File

@ -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<string> {
export function load({
preferredSystemLocales,
hourCyclePreference,
logger,
}: {
preferredSystemLocales: Array<string>;
hourCyclePreference: HourCyclePreference;
logger: LoggerType;
}): LocaleType {
if (preferredSystemLocales == null) {
@ -130,6 +135,7 @@ export function load({
matchedLocaleMessages,
englishMessages,
matchedLocale,
hourCyclePreference,
logger
);
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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<string>,
expectedLocale: string
) {
const actualLocale = await load({ preferredSystemLocales, logger });
const actualLocale = await load({
preferredSystemLocales,
hourCyclePreference: HourCyclePreference.UnknownPreference,
logger,
});
assert.strictEqual(actualLocale.name, expectedLocale);
}

View File

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

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
// 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);

View File

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

View File

@ -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 {

View File

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

View File

@ -105,6 +105,9 @@ export function setupI18n(
localizer.getLocaleDirection = () => {
return window.getResolvedMessagesLocaleDirection();
};
localizer.getHourCyclePreference = () => {
return window.getHourCyclePreference();
};
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 { 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<string>;
getServerPublicParams: () => string;

View File

@ -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;