Skip to content

Commit afae456

Browse files
committed
Add time format preference with system, 12, and 24 options
1 parent 1b03cc4 commit afae456

File tree

11 files changed

+184
-7
lines changed

11 files changed

+184
-7
lines changed

_locales/en/messages.json

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3012,6 +3012,22 @@
30123012
"messageformat": "System",
30133013
"description": "Label text for system theme"
30143014
},
3015+
"icu:Preferences--time-format": {
3016+
"messageformat": "Time format",
3017+
"description": "Header for time format settings"
3018+
},
3019+
"icu:timeFormatSystem": {
3020+
"messageformat": "System",
3021+
"description": "Label text for system locale based time format"
3022+
},
3023+
"icu:timeFormat12Hour": {
3024+
"messageformat": "12-hour",
3025+
"description": "Label text for 12-hour time format"
3026+
},
3027+
"icu:timeFormat24Hour": {
3028+
"messageformat": "24-hour",
3029+
"description": "Label text for 24-hour time format"
3030+
},
30153031
"icu:noteToSelf": {
30163032
"messageformat": "Note to Self",
30173033
"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

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

20832110
localeOverride = await getLocaleOverrideSetting();
20842111

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

20882115
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,
@@ -591,6 +592,7 @@ export default {
591592
onStartUpdate: action('onStartUpdate'),
592593
onTextFormattingChange: action('onTextFormattingChange'),
593594
onThemeChange: action('onThemeChange'),
595+
onHourCycleChange: action('onHourCycleChange'),
594596
onToggleNavTabsCollapse: action('onToggleNavTabsCollapse'),
595597
onUniversalExpireTimerChange: action('onUniversalExpireTimerChange'),
596598
onWhoCanSeeMeChange: action('onWhoCanSeeMeChange'),
@@ -666,6 +668,16 @@ export const Appearance = Template.bind({});
666668
Appearance.args = {
667669
settingsLocation: { page: SettingsPage.Appearance },
668670
};
671+
export const Appearance12HourFormat = Template.bind({});
672+
Appearance12HourFormat.args = {
673+
settingsLocation: { page: SettingsPage.Appearance },
674+
hourCyclePreference: '12',
675+
};
676+
export const Appearance24HourFormat = Template.bind({});
677+
Appearance24HourFormat.args = {
678+
settingsLocation: { page: SettingsPage.Appearance },
679+
hourCyclePreference: '24',
680+
};
669681
export const Chats = Template.bind({});
670682
Chats.args = {
671683
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,
@@ -393,6 +394,8 @@ export function SmartPreferences(): JSX.Element | null {
393394
React.useState<boolean>();
394395
const [hasSpellCheck, setSpellCheck] = React.useState<boolean>();
395396
const [themeSetting, setThemeSetting] = React.useState<ThemeType>();
397+
const [hourCyclePreference, setHourCyclePreference] =
398+
React.useState<HourCycleSettingType>();
396399

397400
useEffect(() => {
398401
let canceled = false;
@@ -439,6 +442,15 @@ export function SmartPreferences(): JSX.Element | null {
439442
};
440443
drop(loadThemeSetting());
441444

445+
const loadHourCyclePreference = async () => {
446+
const value = await window.Events.getHourCyclePreference();
447+
if (canceled) {
448+
return;
449+
}
450+
setHourCyclePreference(value);
451+
};
452+
drop(loadHourCyclePreference());
453+
442454
return () => {
443455
canceled = true;
444456
};
@@ -479,6 +491,10 @@ export function SmartPreferences(): JSX.Element | null {
479491
drop(window.Events.setThemeSetting(value));
480492
drop(themeChanged());
481493
};
494+
const onHourCycleChange = (value: HourCycleSettingType) => {
495+
setHourCyclePreference(value);
496+
drop(window.Events.setHourCyclePreference(value));
497+
};
482498

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

@@ -907,6 +923,7 @@ export function SmartPreferences(): JSX.Element | null {
907923
onSpellCheckChange={onSpellCheckChange}
908924
onTextFormattingChange={onTextFormattingChange}
909925
onThemeChange={onThemeChange}
926+
onHourCycleChange={onHourCycleChange}
910927
onToggleNavTabsCollapse={toggleNavTabsCollapse}
911928
onUniversalExpireTimerChange={onUniversalExpireTimerChange}
912929
onWhoCanFindMeChange={onWhoCanFindMeChange}
@@ -952,6 +969,7 @@ export function SmartPreferences(): JSX.Element | null {
952969
startPlaintextExport={startPlaintextExport}
953970
theme={theme}
954971
themeSetting={themeSetting}
972+
hourCyclePreference={hourCyclePreference}
955973
universalExpireTimer={universalExpireTimer}
956974
validateBackup={validateBackup}
957975
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)