Skip to content

Commit 3c27657

Browse files
committed
Add time format preference with system, 12, and 24 options
1 parent 90c0fc7 commit 3c27657

File tree

12 files changed

+230
-7
lines changed

12 files changed

+230
-7
lines changed

_locales/en/messages.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2948,6 +2948,22 @@
29482948
"messageformat": "System",
29492949
"description": "Label text for system theme"
29502950
},
2951+
"icu:Preferences--time-format": {
2952+
"messageformat": "Time format",
2953+
"description": "Header for time format settings"
2954+
},
2955+
"icu:timeFormatSystem": {
2956+
"messageformat": "System",
2957+
"description": "Label text for system locale based time format"
2958+
},
2959+
"icu:timeFormat12Hour": {
2960+
"messageformat": "12-hour",
2961+
"description": "Label text for 12-hour time format"
2962+
},
2963+
"icu:timeFormat24Hour": {
2964+
"messageformat": "24-hour",
2965+
"description": "Label text for 24-hour time format"
2966+
},
29512967
"icu:noteToSelf": {
29522968
"messageformat": "Note to Self",
29532969
"description": "Name for the conversation with your own phone number"

app/main.main.ts

Lines changed: 34 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -360,6 +360,21 @@ async function getResolvedThemeSetting(
360360
return ThemeType[theme];
361361
}
362362

363+
type HourCycleSettingType = 'system' | '12' | '24';
364+
365+
async function getHourCycleSetting(): Promise<HourCycleSettingType> {
366+
const value = ephemeralConfig.get('hour-cycle-preference');
367+
if (value === '12' || value === '24' || value === 'system') {
368+
log.info('got fast hour-cycle-preference value', value);
369+
return value;
370+
}
371+
372+
// Default to 'system' if setting doesn't exist or is invalid
373+
ephemeralConfig.set('hour-cycle-preference', 'system');
374+
log.info('initializing hour-cycle-preference setting', 'system');
375+
return 'system';
376+
}
377+
363378
type GetBackgroundColorOptionsType = GetThemeSettingOptionsType &
364379
Readonly<{
365380
signalColors?: boolean;
@@ -470,16 +485,28 @@ function getResolvedMessagesLocale(): LocaleType {
470485
return resolvedTranslationsLocale;
471486
}
472487

473-
function getHourCyclePreference(): HourCyclePreference {
474-
if (process.platform !== 'darwin') {
475-
return HourCyclePreference.UnknownPreference;
488+
async function getHourCyclePreference(): Promise<HourCyclePreference> {
489+
const userSetting = await getHourCycleSetting();
490+
if (userSetting === '12') {
491+
return HourCyclePreference.Prefer12;
476492
}
477-
if (systemPreferences.getUserDefault('AppleICUForce24HourTime', 'boolean')) {
493+
if (userSetting === '24') {
478494
return HourCyclePreference.Prefer24;
479495
}
480-
if (systemPreferences.getUserDefault('AppleICUForce12HourTime', 'boolean')) {
481-
return HourCyclePreference.Prefer12;
496+
497+
if (OS.isMacOS()) {
498+
if (
499+
systemPreferences.getUserDefault('AppleICUForce24HourTime', 'boolean')
500+
) {
501+
return HourCyclePreference.Prefer24;
502+
}
503+
if (
504+
systemPreferences.getUserDefault('AppleICUForce12HourTime', 'boolean')
505+
) {
506+
return HourCyclePreference.Prefer12;
507+
}
482508
}
509+
483510
return HourCyclePreference.UnknownPreference;
484511
}
485512

@@ -2063,7 +2090,7 @@ app.on('ready', async () => {
20632090

20642091
localeOverride = await getLocaleOverrideSetting();
20652092

2066-
const hourCyclePreference = getHourCyclePreference();
2093+
const hourCyclePreference = await getHourCyclePreference();
20672094
log.info(`app.ready: hour cycle preference: ${hourCyclePreference}`);
20682095

20692096
log.info('app.ready: preferred system locales:', preferredSystemLocales);

ts/components/Preferences.dom.stories.tsx

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -495,6 +495,7 @@ export default {
495495
sentMediaQualitySetting: 'standard',
496496
shouldShowUpdateDialog: false,
497497
themeSetting: 'system',
498+
hourCyclePreference: 'system',
498499
theme: ThemeType.light,
499500
universalExpireTimer: DurationInSeconds.HOUR,
500501
whoCanFindMe: PhoneNumberDiscoverability.Discoverable,
@@ -592,6 +593,7 @@ export default {
592593
onStartUpdate: action('onStartUpdate'),
593594
onTextFormattingChange: action('onTextFormattingChange'),
594595
onThemeChange: action('onThemeChange'),
596+
onHourCycleChange: action('onHourCycleChange'),
595597
onToggleNavTabsCollapse: action('onToggleNavTabsCollapse'),
596598
onUniversalExpireTimerChange: action('onUniversalExpireTimerChange'),
597599
onWhoCanSeeMeChange: action('onWhoCanSeeMeChange'),
@@ -667,6 +669,16 @@ export const Appearance = Template.bind({});
667669
Appearance.args = {
668670
settingsLocation: { page: SettingsPage.Appearance },
669671
};
672+
export const Appearance12HourFormat = Template.bind({});
673+
Appearance12HourFormat.args = {
674+
settingsLocation: { page: SettingsPage.Appearance },
675+
hourCyclePreference: '12',
676+
};
677+
export const Appearance24HourFormat = Template.bind({});
678+
Appearance24HourFormat.args = {
679+
settingsLocation: { page: SettingsPage.Appearance },
680+
hourCyclePreference: '24',
681+
};
670682
export const Chats = Template.bind({});
671683
Chats.args = {
672684
settingsLocation: { page: SettingsPage.Chats },

ts/components/Preferences.dom.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ import type {
7777
SentMediaQualityType,
7878
ThemeType,
7979
} from '../types/Util.std.js';
80+
import type { HourCycleSettingType } from '../util/preload.preload.js';
8081
import type {
8182
BackupMediaDownloadStatusType,
8283
BackupsSubscriptionType,
@@ -162,6 +163,7 @@ export type PropsDataType = {
162163
selectedSpeaker?: AudioDevice;
163164
sentMediaQualitySetting: SentMediaQualitySettingType;
164165
themeSetting: ThemeSettingType | undefined;
166+
hourCyclePreference: HourCycleSettingType | undefined;
165167
universalExpireTimer: DurationInSeconds;
166168
whoCanFindMe: PhoneNumberDiscoverability;
167169
whoCanSeeMe: PhoneNumberSharingMode;
@@ -320,6 +322,7 @@ type PropsFunctionType = {
320322
onSpellCheckChange: CheckboxChangeHandlerType;
321323
onTextFormattingChange: CheckboxChangeHandlerType;
322324
onThemeChange: SelectChangeHandlerType<ThemeType>;
325+
onHourCycleChange: SelectChangeHandlerType<HourCycleSettingType>;
323326
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
324327
onUniversalExpireTimerChange: SelectChangeHandlerType<number>;
325328
onWhoCanSeeMeChange: SelectChangeHandlerType<PhoneNumberSharingMode>;
@@ -486,6 +489,7 @@ export function Preferences({
486489
onSpellCheckChange,
487490
onTextFormattingChange,
488491
onThemeChange,
492+
onHourCycleChange,
489493
onToggleNavTabsCollapse,
490494
onUniversalExpireTimerChange,
491495
onWhoCanSeeMeChange,
@@ -526,6 +530,7 @@ export function Preferences({
526530
localeOverride,
527531
theme,
528532
themeSetting,
533+
hourCyclePreference,
529534
universalExpireTimer = DurationInSeconds.ZERO,
530535
validateBackup,
531536
whoCanFindMe,
@@ -539,6 +544,7 @@ export function Preferences({
539544
}: PropsType): JSX.Element {
540545
const storiesId = useId();
541546
const themeSelectId = useId();
547+
const hourCycleSelectId = useId();
542548
const zoomSelectId = useId();
543549
const languageId = useId();
544550

@@ -1035,6 +1041,36 @@ export function Preferences({
10351041
{i18n('icu:Preferences__LanguageModal__Restart__Description')}
10361042
</ConfirmationDialog>
10371043
)}
1044+
<Control
1045+
icon
1046+
left={
1047+
<label htmlFor={hourCycleSelectId}>
1048+
{i18n('icu:Preferences--time-format')}
1049+
</label>
1050+
}
1051+
right={
1052+
<Select
1053+
id={hourCycleSelectId}
1054+
disabled={hourCyclePreference === undefined}
1055+
onChange={onHourCycleChange}
1056+
options={[
1057+
{
1058+
text: i18n('icu:timeFormatSystem'),
1059+
value: 'system',
1060+
},
1061+
{
1062+
text: i18n('icu:timeFormat12Hour'),
1063+
value: '12',
1064+
},
1065+
{
1066+
text: i18n('icu:timeFormat24Hour'),
1067+
value: '24',
1068+
},
1069+
]}
1070+
value={hourCyclePreference}
1071+
/>
1072+
}
1073+
/>
10381074
<Control
10391075
icon
10401076
left={

ts/main/settingsChannel.main.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,7 @@ export class SettingsChannel extends EventEmitter {
4949
this.#installEphemeralSetting('localeOverride');
5050
this.#installEphemeralSetting('spellCheck');
5151
this.#installEphemeralSetting('contentProtection');
52+
this.#installEphemeralSetting('hourCyclePreference');
5253

5354
installPermissionsHandler({ session: session.defaultSession, userConfig });
5455

ts/state/smart/Preferences.preload.tsx

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@ import OS from '../../util/os/osPreload.preload.js';
4444
import { themeChanged } from '../../shims/themeChanged.dom.js';
4545
import * as Settings from '../../types/Settings.std.js';
4646
import * as universalExpireTimerUtil from '../../util/universalExpireTimer.preload.js';
47+
import type { HourCycleSettingType } from '../../util/preload.preload.js';
4748
import {
4849
parseSystemTraySetting,
4950
shouldMinimizeToSystemTray,
@@ -392,6 +393,8 @@ export function SmartPreferences(): JSX.Element | null {
392393
React.useState<boolean>();
393394
const [hasSpellCheck, setSpellCheck] = React.useState<boolean>();
394395
const [themeSetting, setThemeSetting] = React.useState<ThemeType>();
396+
const [hourCyclePreference, setHourCyclePreference] =
397+
React.useState<HourCycleSettingType>();
395398

396399
useEffect(() => {
397400
let canceled = false;
@@ -438,6 +441,15 @@ export function SmartPreferences(): JSX.Element | null {
438441
};
439442
drop(loadThemeSetting());
440443

444+
const loadHourCyclePreference = async () => {
445+
const value = await window.Events.getHourCyclePreference();
446+
if (canceled) {
447+
return;
448+
}
449+
setHourCyclePreference(value);
450+
};
451+
drop(loadHourCyclePreference());
452+
441453
return () => {
442454
canceled = true;
443455
};
@@ -478,6 +490,10 @@ export function SmartPreferences(): JSX.Element | null {
478490
drop(window.Events.setThemeSetting(value));
479491
drop(themeChanged());
480492
};
493+
const onHourCycleChange = (value: HourCycleSettingType) => {
494+
setHourCyclePreference(value);
495+
drop(window.Events.setHourCyclePreference(value));
496+
};
481497

482498
// Async IPC for electron configuration, all can be modified
483499

@@ -906,6 +922,7 @@ export function SmartPreferences(): JSX.Element | null {
906922
onSpellCheckChange={onSpellCheckChange}
907923
onTextFormattingChange={onTextFormattingChange}
908924
onThemeChange={onThemeChange}
925+
onHourCycleChange={onHourCycleChange}
909926
onToggleNavTabsCollapse={toggleNavTabsCollapse}
910927
onUniversalExpireTimerChange={onUniversalExpireTimerChange}
911928
onWhoCanFindMeChange={onWhoCanFindMeChange}
@@ -951,6 +968,7 @@ export function SmartPreferences(): JSX.Element | null {
951968
startPlaintextExport={startPlaintextExport}
952969
theme={theme}
953970
themeSetting={themeSetting}
971+
hourCyclePreference={hourCyclePreference}
954972
universalExpireTimer={universalExpireTimer}
955973
validateBackup={validateBackup}
956974
whoCanFindMe={whoCanFindMe}

ts/test-electron/util/formatTimestamp_test.preload.ts

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,52 @@ describe('formatTimestamp', () => {
8484
testCase('ja', HourCyclePreference.Prefer24, min, '0:00:00');
8585
testCase('ja', HourCyclePreference.Prefer24, max, '23:00:00');
8686

87+
describe('user preference override', () => {
88+
it('forces 12-hour format when user selects 12-hour', () => {
89+
localesStub.returns(['nb']); // Norwegian Bokmål normally uses 24-hour
90+
localeOverrideStub.returns(null);
91+
hourCycleStub.returns(HourCyclePreference.Prefer12);
92+
assert.equal(
93+
formatTimestamp(min, { timeStyle: 'medium' }),
94+
'12:00:00 a.m.'
95+
);
96+
assert.equal(
97+
formatTimestamp(max, { timeStyle: 'medium' }),
98+
'11:00:00 p.m.'
99+
);
100+
});
101+
102+
it('forces 24-hour format when user selects 24-hour', () => {
103+
localesStub.returns(['en']); // English normally uses 12-hour
104+
localeOverrideStub.returns(null);
105+
hourCycleStub.returns(HourCyclePreference.Prefer24);
106+
assert.equal(formatTimestamp(min, { timeStyle: 'medium' }), '00:00:00');
107+
assert.equal(formatTimestamp(max, { timeStyle: 'medium' }), '23:00:00');
108+
});
109+
110+
it('respects locale default when user leaves it as system (24-hour)', () => {
111+
localesStub.returns(['nb']);
112+
localeOverrideStub.returns(null);
113+
hourCycleStub.returns(HourCyclePreference.UnknownPreference);
114+
assert.equal(formatTimestamp(min, { timeStyle: 'medium' }), '00:00:00');
115+
assert.equal(formatTimestamp(max, { timeStyle: 'medium' }), '23:00:00');
116+
});
117+
118+
it('respects locale default when user leaves it as system (12-hour)', () => {
119+
localesStub.returns(['en']);
120+
localeOverrideStub.returns(null);
121+
hourCycleStub.returns(HourCyclePreference.UnknownPreference);
122+
assert.equal(
123+
formatTimestamp(min, { timeStyle: 'medium' }),
124+
'12:00:00 AM'
125+
);
126+
assert.equal(
127+
formatTimestamp(max, { timeStyle: 'medium' }),
128+
'11:00:00 PM'
129+
);
130+
});
131+
});
132+
87133
describe('formatDate', () => {
88134
beforeEach(() => {
89135
localesStub.returns(['en']);
Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
// Copyright 2025 Signal Messenger, LLC
2+
// SPDX-License-Identifier: AGPL-3.0-only
3+
4+
import { assert } from 'chai';
5+
import { HourCyclePreference } from '../../types/I18N.std.js';
6+
7+
describe('HourCyclePreference', () => {
8+
describe('enum values', () => {
9+
it('should have Prefer24 value', () => {
10+
assert.equal(HourCyclePreference.Prefer24, 'Prefer24');
11+
});
12+
13+
it('should have Prefer12 value', () => {
14+
assert.equal(HourCyclePreference.Prefer12, 'Prefer12');
15+
});
16+
17+
it('should have UnknownPreference value', () => {
18+
assert.equal(HourCyclePreference.UnknownPreference, 'UnknownPreference');
19+
});
20+
});
21+
});
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
// Copyright 2025 Signal Messenger, LLC
2+
// SPDX-License-Identifier: AGPL-3.0-only
3+
4+
import { assert } from 'chai';
5+
import type { HourCycleSettingType } from '../../util/preload.preload.js';
6+
7+
describe('HourCycleSettingType', () => {
8+
describe('valid setting values', () => {
9+
it('should accept "system" as a valid setting', () => {
10+
const setting: HourCycleSettingType = 'system';
11+
assert.equal(setting, 'system');
12+
});
13+
14+
it('should accept "12" as a valid setting', () => {
15+
const setting: HourCycleSettingType = '12';
16+
assert.equal(setting, '12');
17+
});
18+
19+
it('should accept "24" as a valid setting', () => {
20+
const setting: HourCycleSettingType = '24';
21+
assert.equal(setting, '24');
22+
});
23+
24+
it('should distinguish between all three settings', () => {
25+
const settings: Array<HourCycleSettingType> = ['system', '12', '24'];
26+
assert.lengthOf(settings, 3);
27+
assert.include(settings, 'system');
28+
assert.include(settings, '12');
29+
assert.include(settings, '24');
30+
});
31+
});
32+
});

0 commit comments

Comments
 (0)