macOS: Add support for 12/24-hour time display preferences
This commit is contained in:
@@ -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}`, '')}
|
||||
|
@@ -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;
|
||||
|
Reference in New Issue
Block a user