Skip to content

Commit 9e48465

Browse files
committed
Add time format preference with system, 12, and 24 options
1 parent 8f38791 commit 9e48465

File tree

11 files changed

+187
-8
lines changed

11 files changed

+187
-8
lines changed

_locales/en/messages.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3168,6 +3168,22 @@
31683168
"messageformat": "System",
31693169
"description": "Label text for system theme"
31703170
},
3171+
"icu:Preferences--time-format": {
3172+
"messageformat": "Time format",
3173+
"description": "Header for time format settings"
3174+
},
3175+
"icu:timeFormatSystem": {
3176+
"messageformat": "System",
3177+
"description": "Label text for system locale based time format"
3178+
},
3179+
"icu:timeFormat12Hour": {
3180+
"messageformat": "12-hour",
3181+
"description": "Label text for 12-hour time format"
3182+
},
3183+
"icu:timeFormat24Hour": {
3184+
"messageformat": "24-hour",
3185+
"description": "Label text for 24-hour time format"
3186+
},
31713187
"icu:noteToSelf": {
31723188
"messageformat": "Note to Self",
31733189
"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
@@ -363,6 +363,21 @@ async function getResolvedThemeSetting(
363363
return ThemeType[theme];
364364
}
365365

366+
type HourCycleSettingType = 'system' | '12' | '24';
367+
368+
async function getHourCycleSetting(): Promise<HourCycleSettingType> {
369+
const value = ephemeralConfig.get('hour-cycle-preference');
370+
if (value === '12' || value === '24' || value === 'system') {
371+
log.info('got fast hour-cycle-preference value', value);
372+
return value;
373+
}
374+
375+
// Default to 'system' if setting doesn't exist or is invalid
376+
ephemeralConfig.set('hour-cycle-preference', 'system');
377+
log.info('initializing hour-cycle-preference setting', 'system');
378+
return 'system';
379+
}
380+
366381
type GetBackgroundColorOptionsType = GetThemeSettingOptionsType &
367382
Readonly<{
368383
signalColors?: boolean;
@@ -473,16 +488,28 @@ function getResolvedMessagesLocale(): LocaleType {
473488
return resolvedTranslationsLocale;
474489
}
475490

476-
function getHourCyclePreference(): HourCyclePreference {
477-
if (process.platform !== 'darwin') {
478-
return HourCyclePreference.UnknownPreference;
491+
async function getHourCyclePreference(): Promise<HourCyclePreference> {
492+
const userSetting = await getHourCycleSetting();
493+
if (userSetting === '12') {
494+
return HourCyclePreference.Prefer12;
479495
}
480-
if (systemPreferences.getUserDefault('AppleICUForce24HourTime', 'boolean')) {
496+
if (userSetting === '24') {
481497
return HourCyclePreference.Prefer24;
482498
}
483-
if (systemPreferences.getUserDefault('AppleICUForce12HourTime', 'boolean')) {
484-
return HourCyclePreference.Prefer12;
499+
500+
if (OS.isMacOS()) {
501+
if (
502+
systemPreferences.getUserDefault('AppleICUForce24HourTime', 'boolean')
503+
) {
504+
return HourCyclePreference.Prefer24;
505+
}
506+
if (
507+
systemPreferences.getUserDefault('AppleICUForce12HourTime', 'boolean')
508+
) {
509+
return HourCyclePreference.Prefer12;
510+
}
485511
}
512+
486513
return HourCyclePreference.UnknownPreference;
487514
}
488515

@@ -2109,7 +2136,7 @@ app.on('ready', async () => {
21092136

21102137
localeOverride = await getLocaleOverrideSetting();
21112138

2112-
const hourCyclePreference = getHourCyclePreference();
2139+
const hourCyclePreference = await getHourCyclePreference();
21132140
log.info(`app.ready: hour cycle preference: ${hourCyclePreference}`);
21142141

21152142
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
@@ -494,6 +494,7 @@ export default {
494494
sentMediaQualitySetting: 'standard',
495495
shouldShowUpdateDialog: false,
496496
themeSetting: 'system',
497+
hourCyclePreference: 'system',
497498
theme: ThemeType.light,
498499
universalExpireTimer: DurationInSeconds.HOUR,
499500
whoCanFindMe: PhoneNumberDiscoverability.Discoverable,
@@ -589,6 +590,7 @@ export default {
589590
onStartUpdate: action('onStartUpdate'),
590591
onTextFormattingChange: action('onTextFormattingChange'),
591592
onThemeChange: action('onThemeChange'),
593+
onHourCycleChange: action('onHourCycleChange'),
592594
onToggleNavTabsCollapse: action('onToggleNavTabsCollapse'),
593595
onUniversalExpireTimerChange: action('onUniversalExpireTimerChange'),
594596
onWhoCanSeeMeChange: action('onWhoCanSeeMeChange'),
@@ -670,6 +672,16 @@ export const Appearance = Template.bind({});
670672
Appearance.args = {
671673
settingsLocation: { page: SettingsPage.Appearance },
672674
};
675+
export const Appearance12HourFormat = Template.bind({});
676+
Appearance12HourFormat.args = {
677+
settingsLocation: { page: SettingsPage.Appearance },
678+
hourCyclePreference: '12',
679+
};
680+
export const Appearance24HourFormat = Template.bind({});
681+
Appearance24HourFormat.args = {
682+
settingsLocation: { page: SettingsPage.Appearance },
683+
hourCyclePreference: '24',
684+
};
673685
export const Chats = Template.bind({});
674686
Chats.args = {
675687
settingsLocation: { page: SettingsPage.Chats },

ts/components/Preferences.dom.tsx

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ import type {
7878
SentMediaQualityType,
7979
ThemeType,
8080
} from '../types/Util.std.js';
81+
import type { HourCycleSettingType } from '../util/preload.preload.js';
8182
import type {
8283
BackupMediaDownloadStatusType,
8384
BackupsSubscriptionType,
@@ -167,6 +168,7 @@ export type PropsDataType = {
167168
selectedSpeaker?: AudioDevice;
168169
sentMediaQualitySetting: SentMediaQualitySettingType;
169170
themeSetting: ThemeSettingType | undefined;
171+
hourCyclePreference: HourCycleSettingType | undefined;
170172
universalExpireTimer: DurationInSeconds;
171173
whoCanFindMe: PhoneNumberDiscoverability;
172174
whoCanSeeMe: PhoneNumberSharingMode;
@@ -327,6 +329,7 @@ type PropsFunctionType = {
327329
onSpellCheckChange: CheckboxChangeHandlerType;
328330
onTextFormattingChange: CheckboxChangeHandlerType;
329331
onThemeChange: SelectChangeHandlerType<ThemeType>;
332+
onHourCycleChange: SelectChangeHandlerType<HourCycleSettingType>;
330333
onToggleNavTabsCollapse: (navTabsCollapsed: boolean) => void;
331334
onUniversalExpireTimerChange: SelectChangeHandlerType<number>;
332335
onWhoCanSeeMeChange: SelectChangeHandlerType<PhoneNumberSharingMode>;
@@ -491,6 +494,7 @@ export function Preferences({
491494
onSpellCheckChange,
492495
onTextFormattingChange,
493496
onThemeChange,
497+
onHourCycleChange,
494498
onToggleNavTabsCollapse,
495499
onUniversalExpireTimerChange,
496500
onWhoCanSeeMeChange,
@@ -532,6 +536,7 @@ export function Preferences({
532536
localeOverride,
533537
theme,
534538
themeSetting,
539+
hourCyclePreference,
535540
universalExpireTimer = DurationInSeconds.ZERO,
536541
validateBackup,
537542
whoCanFindMe,
@@ -548,6 +553,7 @@ export function Preferences({
548553
}: PropsType): React.JSX.Element {
549554
const storiesId = useId();
550555
const themeSelectId = useId();
556+
const hourCycleSelectId = useId();
551557
const zoomSelectId = useId();
552558
const languageId = useId();
553559

@@ -1044,6 +1050,36 @@ export function Preferences({
10441050
{i18n('icu:Preferences__LanguageModal__Restart__Description')}
10451051
</ConfirmationDialog>
10461052
)}
1053+
<Control
1054+
icon
1055+
left={
1056+
<label htmlFor={hourCycleSelectId}>
1057+
{i18n('icu:Preferences--time-format')}
1058+
</label>
1059+
}
1060+
right={
1061+
<Select
1062+
id={hourCycleSelectId}
1063+
disabled={hourCyclePreference === undefined}
1064+
onChange={onHourCycleChange}
1065+
options={[
1066+
{
1067+
text: i18n('icu:timeFormatSystem'),
1068+
value: 'system',
1069+
},
1070+
{
1071+
text: i18n('icu:timeFormat12Hour'),
1072+
value: '12',
1073+
},
1074+
{
1075+
text: i18n('icu:timeFormat24Hour'),
1076+
value: '24',
1077+
},
1078+
]}
1079+
value={hourCyclePreference}
1080+
/>
1081+
}
1082+
/>
10471083
<Control
10481084
icon
10491085
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: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,10 @@ 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 {
48+
HourCycleSettingType,
49+
ThemeType,
50+
} from '../../util/preload.preload.js';
4751
import {
4852
parseSystemTraySetting,
4953
shouldMinimizeToSystemTray,
@@ -105,7 +109,6 @@ import type {
105109
StorageAccessType,
106110
ZoomFactorType,
107111
} from '../../types/Storage.d.ts';
108-
import type { ThemeType } from '../../util/preload.preload.js';
109112
import type { WidthBreakpoint } from '../../components/_util.std.js';
110113
import { DialogType } from '../../types/Dialogs.std.js';
111114
import { promptOSAuth } from '../../util/promptOSAuth.preload.js';
@@ -386,6 +389,8 @@ export function SmartPreferences(): React.JSX.Element | null {
386389
React.useState<boolean>();
387390
const [hasSpellCheck, setSpellCheck] = React.useState<boolean>();
388391
const [themeSetting, setThemeSetting] = React.useState<ThemeType>();
392+
const [hourCyclePreference, setHourCyclePreference] =
393+
React.useState<HourCycleSettingType>();
389394

390395
useEffect(() => {
391396
let canceled = false;
@@ -432,6 +437,15 @@ export function SmartPreferences(): React.JSX.Element | null {
432437
};
433438
drop(loadThemeSetting());
434439

440+
const loadHourCyclePreference = async () => {
441+
const value = await window.Events.getHourCyclePreference();
442+
if (canceled) {
443+
return;
444+
}
445+
setHourCyclePreference(value);
446+
};
447+
drop(loadHourCyclePreference());
448+
435449
return () => {
436450
canceled = true;
437451
};
@@ -472,6 +486,10 @@ export function SmartPreferences(): React.JSX.Element | null {
472486
drop(window.Events.setThemeSetting(value));
473487
drop(themeChanged());
474488
};
489+
const onHourCycleChange = (value: HourCycleSettingType) => {
490+
setHourCyclePreference(value);
491+
drop(window.Events.setHourCyclePreference(value));
492+
};
475493

476494
// Async IPC for electron configuration, all can be modified
477495

@@ -931,6 +949,7 @@ export function SmartPreferences(): React.JSX.Element | null {
931949
onSpellCheckChange={onSpellCheckChange}
932950
onTextFormattingChange={onTextFormattingChange}
933951
onThemeChange={onThemeChange}
952+
onHourCycleChange={onHourCycleChange}
934953
onToggleNavTabsCollapse={toggleNavTabsCollapse}
935954
onUniversalExpireTimerChange={onUniversalExpireTimerChange}
936955
onWhoCanFindMeChange={onWhoCanFindMeChange}
@@ -977,6 +996,7 @@ export function SmartPreferences(): React.JSX.Element | null {
977996
startPlaintextExport={startPlaintextExport}
978997
theme={theme}
979998
themeSetting={themeSetting}
999+
hourCyclePreference={hourCyclePreference}
9801000
universalExpireTimer={universalExpireTimer}
9811001
validateBackup={validateBackup}
9821002
whoCanFindMe={whoCanFindMe}
Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,29 @@
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+
it('should only contain three values', () => {
22+
const values = Object.values(HourCyclePreference);
23+
assert.lengthOf(values, 3);
24+
assert.include(values, HourCyclePreference.Prefer24);
25+
assert.include(values, HourCyclePreference.Prefer12);
26+
assert.include(values, HourCyclePreference.UnknownPreference);
27+
});
28+
});
29+
});
Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
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+
});

ts/util/createIPCEvents.preload.ts

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import { fromWebSafeBase64 } from './webSafeBase64.std.js';
3030
import { showConfirmationDialog } from './showConfirmationDialog.dom.js';
3131
import type {
3232
EphemeralSettings,
33+
HourCycleSettingType,
3334
SettingsValuesType,
3435
ThemeType,
3536
} from './preload.preload.js';
@@ -92,6 +93,7 @@ type ValuesWithGetters = Omit<
9293
| 'contentProtection'
9394
| 'systemTraySetting'
9495
| 'themeSetting'
96+
| 'hourCyclePreference'
9597
| 'zoomFactor'
9698
>;
9799

@@ -126,6 +128,7 @@ export type IPCEventsGettersType = {
126128
getContentProtection: () => Promise<boolean>;
127129
getSystemTraySetting: () => Promise<SystemTraySetting>;
128130
getThemeSetting: () => Promise<ThemeType>;
131+
getHourCyclePreference: () => Promise<HourCycleSettingType>;
129132
getZoomFactor: () => Promise<ZoomFactorType>;
130133
// Events
131134
onZoomFactorChange: (callback: ZoomFactorChangeCallback) => void;
@@ -236,6 +239,13 @@ export function createIPCEvents(
236239
setThemeSetting: async (value: ThemeType) => {
237240
await setEphemeralSetting('themeSetting', value);
238241
},
242+
getHourCyclePreference: async () => {
243+
return (await getEphemeralSetting('hourCyclePreference')) ?? 'system';
244+
},
245+
setHourCyclePreference: async (value: HourCycleSettingType) => {
246+
await setEphemeralSetting('hourCyclePreference', value);
247+
window.SignalContext.restartApp();
248+
},
239249

240250
// From IPCEventsCallbacksType
241251
addDarkOverlay: () => {

ts/util/preload.preload.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,12 +24,15 @@ export type SettingType<Value> = Readonly<{
2424

2525
export type ThemeType = 'light' | 'dark' | 'system';
2626

27+
export type HourCycleSettingType = 'system' | '12' | '24';
28+
2729
export type EphemeralSettings = {
2830
localeOverride: string | null;
2931
spellCheck: boolean;
3032
contentProtection: boolean;
3133
systemTraySetting: SystemTraySetting;
3234
themeSetting: ThemeType;
35+
hourCyclePreference: HourCycleSettingType;
3336
};
3437

3538
export type SettingsValuesType = IPCEventsValuesType & EphemeralSettings;

0 commit comments

Comments
 (0)