macOS: Add support for 12/24-hour time display preferences
This commit is contained in:
parent
88858af144
commit
1143c0e9ba
17 changed files with 208 additions and 14 deletions
|
@ -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 =
|
||||
|
|
|
@ -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
|
||||
);
|
||||
}
|
||||
|
|
19
app/main.ts
19
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<number>('buildCreation'),
|
||||
|
|
|
@ -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()
|
||||
|
|
|
@ -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(),
|
||||
{
|
||||
{getDateTimeFormatter({
|
||||
hour: 'numeric',
|
||||
minute: 'numeric',
|
||||
}
|
||||
)
|
||||
})
|
||||
.formatToParts(Date.now())
|
||||
.filter(x => x.type !== 'dayPeriod')
|
||||
.reduce((acc, { value }) => `${acc}${value}`, '')}
|
||||
|
|
|
@ -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);
|
||||
|
|
|
@ -118,6 +118,7 @@ export function getEmptyState(): UserStateType {
|
|||
isLegacyFormat: intlNotSetup,
|
||||
getLocaleMessages: intlNotSetup,
|
||||
getLocaleDirection: intlNotSetup,
|
||||
getHourCyclePreference: intlNotSetup,
|
||||
}),
|
||||
interactionMode: 'mouse',
|
||||
isMainWindowMaximized: false,
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
@ -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),
|
||||
|
|
63
ts/test-node/util/formatTimestamp_test.ts
Normal file
63
ts/test-node/util/formatTimestamp_test.ts
Normal 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');
|
||||
});
|
|
@ -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);
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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 '';
|
||||
|
|
|
@ -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
2
ts/window.d.ts
vendored
|
@ -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;
|
||||
|
|
|
@ -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;
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue