Init Language Picker

This commit is contained in:
Jamie Kyle 2023-11-06 13:19:23 -08:00 committed by GitHub
parent 754bb02c06
commit 89e66da351
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
25 changed files with 610 additions and 61 deletions

View file

@ -5823,6 +5823,42 @@
"messageformat": "You will no longer be able to share or view stories. Story updates you have recently shared will also be deleted.",
"description": "Confirmation modal body for disabling stories"
},
"icu:Preferences__Language__Label": {
"messageformat": "Language",
"description": "Language setting label"
},
"icu:Preferences__Language__ModalTitle": {
"messageformat": "Language",
"description": "Language setting modal title"
},
"icu:Preferences__Language__SystemLanguage": {
"messageformat": "System Language",
"description": "Option for system language"
},
"icu:Preferences__Language__SearchLanguages": {
"messageformat": "Search languages",
"description": "Placholder for language preference search box"
},
"icu:Preferences__Language__NoResults": {
"messageformat": "No results for “{searchTerm}”",
"description": "When no results are found for language preference search"
},
"icu:Preferences__LanguageModal__Set": {
"messageformat": "Set",
"description": "Button to set language preference"
},
"icu:Preferences__LanguageModal__Restart__Title": {
"messageformat": "Restart Signal to apply",
"description": "Title for restart Signal modal to apply language changes"
},
"icu:Preferences__LanguageModal__Restart__Description": {
"messageformat": "To change the language, the app needs to restart.",
"description": "Description for restart Signal modal to apply language changes"
},
"icu:Preferences__LanguageModal__Restart__Button": {
"messageformat": "Restart",
"description": "Button to restart Signal to apply language changes"
},
"icu:DialogUpdate--version-available": {
"messageformat": "Update to version {version} available",
"description": "Tooltip for new update available"

View file

@ -26,6 +26,7 @@ function getLocaleMessages(locale: string): LocaleMessagesType {
export type LocaleDirection = 'ltr' | 'rtl';
export type LocaleType = {
availableLocales: Array<string>;
i18n: LocalizerType;
name: string;
direction: LocaleDirection;
@ -65,6 +66,7 @@ function getLocaleDirection(
}
function finalize(
availableLocales: Array<string>,
messages: LocaleMessagesType,
backupMessages: LocaleMessagesType,
localeName: string,
@ -80,6 +82,7 @@ function finalize(
logger.info(`locale: Text info direction for ${localeName}: ${direction}`);
return {
availableLocales,
i18n,
name: localeName,
direction,
@ -99,10 +102,12 @@ export function _getAvailableLocales(): Array<string> {
export function load({
preferredSystemLocales,
localeOverride,
hourCyclePreference,
logger,
}: {
preferredSystemLocales: Array<string>;
localeOverride: string | null;
hourCyclePreference: HourCyclePreference;
logger: LoggerType;
}): LocaleType {
@ -117,10 +122,11 @@ export function load({
const availableLocales = _getAvailableLocales();
logger.info('locale: Supported locales:', availableLocales.join(', '));
logger.info('locale: Preferred locales: ', preferredSystemLocales.join(', '));
logger.info('locale: Preferred locales:', preferredSystemLocales.join(', '));
logger.info('locale: Locale Override:', localeOverride);
const matchedLocale = LocaleMatcher.match(
preferredSystemLocales,
localeOverride != null ? [localeOverride] : preferredSystemLocales,
availableLocales,
'en',
{ algorithm: 'best fit' }
@ -132,6 +138,7 @@ export function load({
const englishMessages = getLocaleMessages('en');
return finalize(
availableLocales,
matchedLocaleMessages,
englishMessages,
matchedLocale,

View file

@ -359,6 +359,26 @@ async function getBackgroundColor(
throw missingCaseError(theme);
}
async function getLocaleOverrideSetting(): Promise<string | null> {
const fastValue = ephemeralConfig.get('localeOverride');
// eslint-disable-next-line eqeqeq -- Checking for null explicitly
if (typeof fastValue === 'string' || fastValue === null) {
getLogger().info('got fast localeOverride setting', fastValue);
return fastValue;
}
const json = await sql.sqlCall('getItemById', 'localeOverride');
// Default to `null` if setting doesn't exist yet
const slowValue = typeof json?.value === 'string' ? json.value : null;
ephemeralConfig.set('localeOverride', slowValue);
getLogger().info('got slow localeOverride setting', slowValue);
return slowValue;
}
let systemTrayService: SystemTrayService | undefined;
const systemTraySettingCache = new SystemTraySettingCache(
sql,
@ -1782,11 +1802,15 @@ app.on('ready', async () => {
// Write buffered information into newly created logger.
consoleLogger.writeBufferInto(logger);
sqlInitPromise = initializeSQL(userDataPath);
if (!resolvedTranslationsLocale) {
preferredSystemLocales = resolveCanonicalLocales(
loadPreferredSystemLocales()
);
const localeOverride = await getLocaleOverrideSetting();
const hourCyclePreference = getHourCyclePreference();
logger.info(`app.ready: hour cycle preference: ${hourCyclePreference}`);
@ -1797,13 +1821,12 @@ app.on('ready', async () => {
);
resolvedTranslationsLocale = loadLocale({
preferredSystemLocales,
localeOverride,
hourCyclePreference,
logger: getLogger(),
});
}
sqlInitPromise = initializeSQL(userDataPath);
// First run: configure Signal to minimize to tray. Additionally, on Windows
// enable auto-start with start-in-tray so that starting from a Desktop icon
// would still show the window.
@ -2372,6 +2395,7 @@ ipc.on('get-config', async event => {
const parsed = rendererConfigSchema.safeParse({
name: packageJson.productName,
availableLocales: getResolvedMessagesLocale().availableLocales,
resolvedTranslationsLocale: getResolvedMessagesLocale().name,
resolvedTranslationsLocaleDirection: getResolvedMessagesLocale().direction,
hourCyclePreference: getResolvedMessagesLocale().hourCyclePreference,

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none"><g clip-path="url(#a)"><path fill="#000" fill-rule="evenodd" d="M10 19.063A9.062 9.062 0 1 0 10 .938a9.062 9.062 0 0 0 0 18.125Zm0-16.667c-.148 0-.376.077-.67.41-.294.33-.592.851-.857 1.557-.235.627-.433 1.37-.58 2.2h4.215a12.632 12.632 0 0 0-.581-2.2c-.265-.706-.563-1.227-.856-1.558-.295-.332-.523-.41-.671-.41Zm2.304 5.625H7.696c-.06.63-.092 1.293-.092 1.979 0 .686.032 1.349.092 1.98h4.608c.06-.631.092-1.294.092-1.98 0-.686-.032-1.349-.092-1.98Zm1.464 3.958a22.564 22.564 0 0 0 0-3.958h3.576c.17.63.26 1.294.26 1.979s-.09 1.348-.26 1.98h-3.576Zm-1.66 1.459H7.893c.147.829.345 1.572.58 2.198.265.707.563 1.227.856 1.559.295.332.523.41.671.41.148 0 .376-.078.67-.41.294-.332.592-.852.857-1.559.235-.626.433-1.37.58-2.198Zm.304 3.775c.178-.325.337-.683.48-1.064a14.4 14.4 0 0 0 .695-2.712h3.198a7.628 7.628 0 0 1-4.373 3.777Zm0-14.427a7.627 7.627 0 0 1 4.373 3.776h-3.198a14.4 14.4 0 0 0-.695-2.71 8.425 8.425 0 0 0-.48-1.065ZM7.587 17.215a8.437 8.437 0 0 1-.48-1.065 14.404 14.404 0 0 1-.694-2.712H3.215a7.627 7.627 0 0 0 4.372 3.777ZM6.232 11.98a22.572 22.572 0 0 1 0-3.958H2.656A7.614 7.614 0 0 0 2.396 10c0 .685.09 1.348.26 1.98h3.576Zm.181-5.416c.165-1.01.4-1.928.695-2.712a8.2 8.2 0 0 1 .48-1.064 7.627 7.627 0 0 0-4.373 3.776h3.198Z" clip-rule="evenodd"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h20v20H0z"/></clipPath></defs></svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

View file

@ -21,15 +21,17 @@
}
&__header {
&--with-back-button .module-Modal__title {
text-align: center;
}
}
&__headerTitle {
align-items: center;
display: flex;
justify-content: space-between;
padding-block: 16px 1em;
padding-inline: 16px;
&--with-back-button .module-Modal__title {
text-align: center;
}
}
&__title {

View file

@ -194,6 +194,13 @@
padding-block: 4px;
padding-inline: 24px;
&--icon {
width: 20px;
height: 20px;
flex-shrink: 0;
margin-inline-end: 12px;
}
&--key {
flex-grow: 1;
padding-inline-end: 20px;
@ -292,3 +299,112 @@
}
}
}
.Preferences__LanguageIcon {
@include light-theme {
@include color-svg('../images/icons/v3/globe/globe.svg', $color-gray-75);
}
@include dark-theme {
@include color-svg('../images/icons/v3/globe/globe.svg', $color-gray-15);
}
}
.Preferences__LanguageButton {
text-transform: capitalize;
@include button-reset;
}
.Preferences__LanguageModal {
height: 560px;
.module-Modal__body {
flex-grow: 1;
}
}
.Preferences__LanguageModal__Title {
@include font-body-1-bold;
margin-inline: 8px;
}
.Preferences__LanguageModal__NoResults {
@include font-body-1;
margin: 16px;
text-align: center;
}
.Preferences__LanguageModal__Item {
@include button-reset;
width: 100%;
padding-block: 2px;
padding-inline: 8px;
&:hover {
.Preferences__LanguageModal__Item__Inner {
@include light-theme {
background-color: $color-black-alpha-06;
}
@include dark-theme {
background-color: $color-white-alpha-06;
}
}
}
&:focus {
outline: none;
.Preferences__LanguageModal__Item__Inner {
@include keyboard-mode {
background-color: $color-black-alpha-06;
box-shadow: 0 0 0 2px $color-ultramarine;
}
@include dark-keyboard-mode {
background-color: $color-white-alpha-06;
}
}
}
}
.Preferences__LanguageModal__Item__Inner {
display: flex;
gap: 8px;
align-items: center;
justify-content: center;
padding-block: 5px;
padding-inline: 16px;
border-radius: 8px;
}
.Preferences__LanguageModal__Item__Label {
flex-grow: 1;
}
.Preferences__LanguageModal__Item__Current {
display: block;
text-transform: capitalize;
@include font-body-1;
}
.Preferences__LanguageModal__Item__Check {
display: flex;
height: 20px;
width: 20px;
align-items: center;
justify-content: center;
background: $color-ultramarine;
@include rounded-corners;
&::after {
@include color-svg('../images/icons/v3/check/check.svg', $color-white);
content: '';
height: 14px;
width: 14px;
}
}
.Preferences__LanguageModal__Item__Matching {
display: block;
text-transform: capitalize;
@include font-body-2;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}

View file

@ -972,7 +972,7 @@ export async function startApp(): Promise<void> {
// This one should always be last - it could restart the app
if (window.isBeforeVersion(lastVersion, 'v5.30.0-alpha')) {
await deleteAllLogs();
window.IPC.restart();
window.SignalContext.restartApp();
return;
}
}

View file

@ -30,6 +30,7 @@ type PropsType = {
hasFooterDivider?: boolean;
i18n: LocalizerType;
modalFooter?: JSX.Element;
modalHeaderChildren?: ReactNode;
moduleClassName?: string;
onBackButtonClick?: () => unknown;
onClose?: () => void;
@ -52,6 +53,7 @@ export function Modal({
hasXButton,
i18n,
modalFooter,
modalHeaderChildren,
moduleClassName,
noMouseClose,
onBackButtonClick,
@ -122,6 +124,7 @@ export function Modal({
hasXButton={hasXButton}
i18n={i18n}
modalFooter={modalFooter}
modalHeaderChildren={modalHeaderChildren}
moduleClassName={moduleClassName}
onBackButtonClick={onBackButtonClick}
onClose={close}
@ -162,6 +165,7 @@ export function ModalPage({
hasXButton,
i18n,
modalFooter,
modalHeaderChildren,
moduleClassName,
onBackButtonClick,
onClose,
@ -179,7 +183,9 @@ export function ModalPage({
const [scrolledToBottom, setScrolledToBottom] = useState(false);
const [hasOverflow, setHasOverflow] = useState(false);
const hasHeader = Boolean(hasXButton || title || onBackButtonClick);
const hasHeader = Boolean(
hasXButton || title || modalHeaderChildren || onBackButtonClick
);
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
useScrollObserver(bodyRef, bodyInnerRef, scroll => {
@ -216,37 +222,40 @@ export function ModalPage({
: null
)}
>
{onBackButtonClick && (
<button
aria-label={i18n('icu:back')}
className={getClassName('__back-button')}
onClick={onBackButtonClick}
tabIndex={0}
type="button"
/>
)}
{title && (
<h1
className={classNames(
getClassName('__title'),
hasXButton ? getClassName('__title--with-x-button') : null
)}
>
{title}
</h1>
)}
{hasXButton && !title && (
<div className={getClassName('__title')} />
)}
{hasXButton && (
<button
aria-label={i18n('icu:close')}
className={getClassName('__close-button')}
onClick={onClose}
tabIndex={0}
type="button"
/>
)}
<div className={getClassName('__headerTitle')}>
{onBackButtonClick && (
<button
aria-label={i18n('icu:back')}
className={getClassName('__back-button')}
onClick={onBackButtonClick}
tabIndex={0}
type="button"
/>
)}
{title && (
<h1
className={classNames(
getClassName('__title'),
hasXButton ? getClassName('__title--with-x-button') : null
)}
>
{title}
</h1>
)}
{hasXButton && !title && (
<div className={getClassName('__title')} />
)}
{hasXButton && (
<button
aria-label={i18n('icu:close')}
className={getClassName('__close-button')}
onClick={onClose}
tabIndex={0}
type="button"
/>
)}
</div>
{modalHeaderChildren}
</div>
)}
<div

View file

@ -68,6 +68,7 @@ export default {
label: 'Logitech Webcam (4e72:9058)',
},
],
availableLocales: ['en'],
availableMicrophones,
availableSpeakers,
blockedCount: 0,
@ -108,7 +109,10 @@ export default {
isSystemTraySupported: true,
isMinimizeToAndStartInSystemTraySupported: true,
lastSyncTime: Date.now(),
localeOverride: null,
notificationContent: 'name',
preferredSystemLocales: ['en'],
resolvedLocale: 'en',
selectedCamera:
'dfbe6effe70b0611ba0fdc2a9ea3f39f6cb110e6687948f7e5f016c111b7329c',
selectedMicrophone: availableMicrophones[0],
@ -143,6 +147,7 @@ export default {
onIncomingCallNotificationsChange: action(
'onIncomingCallNotificationsChange'
),
onLocaleChange: action('onLocaleChange'),
onLastSyncTimeChange: action('onLastSyncTimeChange'),
onMediaCameraPermissionsChange: action('onMediaCameraPermissionsChange'),
onMediaPermissionsChange: action('onMediaPermissionsChange'),

View file

@ -10,9 +10,10 @@ import React, {
useRef,
useState,
} from 'react';
import { noop } from 'lodash';
import { noop, partition } from 'lodash';
import classNames from 'classnames';
import uuid from 'uuid';
import * as LocaleMatcher from '@formatjs/intl-localematcher';
import type { MediaDeviceSettings } from '../types/Calling';
import type {
@ -59,6 +60,9 @@ import { useEscapeHandling } from '../hooks/useEscapeHandling';
import { useUniqueId } from '../hooks/useUniqueId';
import { useTheme } from '../hooks/useTheme';
import { focusableSelectors } from '../util/focusableSelectors';
import { Modal } from './Modal';
import { SearchInput } from './SearchInput';
import { removeDiacritics } from '../util/removeDiacritics';
type CheckboxChangeHandlerType = (value: boolean) => unknown;
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
@ -103,6 +107,12 @@ export type PropsDataType = {
whoCanSeeMe: PhoneNumberSharingMode;
zoomFactor: ZoomFactorType;
// Localization
availableLocales: ReadonlyArray<string>;
localeOverride: string | null;
preferredSystemLocales: ReadonlyArray<string>;
resolvedLocale: string;
// Other props
hasCustomTitleBar: boolean;
initialSpellCheckSetting: boolean;
@ -161,6 +171,7 @@ type PropsFunctionType = {
onHideMenuBarChange: CheckboxChangeHandlerType;
onIncomingCallNotificationsChange: CheckboxChangeHandlerType;
onLastSyncTimeChange: (time: number) => unknown;
onLocaleChange: (locale: string | null) => void;
onMediaCameraPermissionsChange: CheckboxChangeHandlerType;
onMediaPermissionsChange: CheckboxChangeHandlerType;
onMessageAudioChange: CheckboxChangeHandlerType;
@ -204,6 +215,46 @@ enum Page {
PNP = 'PNP',
}
enum LanguageDialog {
Selection,
Confirmation,
}
function getLocaleLanguagesWithMultipleRegions(
locales: ReadonlyArray<string>
): Set<string> {
const result = new Set<string>();
const seen = new Set<string>();
for (const locale of locales) {
const { language } = new Intl.Locale(locale);
if (seen.has(language)) {
result.add(language);
} else {
seen.add(language);
}
}
return result;
}
const cache = new Map<string, string>();
function getLanguageLabel(ofLocale: string, inLocale: string) {
const key = `${ofLocale}:${inLocale}`;
const cached = cache.get(key);
if (cached != null) {
return cached;
}
const value =
new Intl.DisplayNames(inLocale, {
type: 'language',
fallback: 'code',
style: 'long',
languageDisplay: 'standard',
}).of(ofLocale) ?? '';
cache.set(key, value);
return value;
}
const DEFAULT_ZOOM_FACTORS = [
{
text: '75%',
@ -230,6 +281,7 @@ const DEFAULT_ZOOM_FACTORS = [
export function Preferences({
addCustomColor,
availableCameras,
availableLocales,
availableMicrophones,
availableSpeakers,
blockedCount,
@ -289,6 +341,7 @@ export function Preferences({
onHideMenuBarChange,
onIncomingCallNotificationsChange,
onLastSyncTimeChange,
onLocaleChange,
onMediaCameraPermissionsChange,
onMediaPermissionsChange,
onMessageAudioChange,
@ -309,16 +362,19 @@ export function Preferences({
onWhoCanSeeMeChange,
onWhoCanFindMeChange,
onZoomFactorChange,
preferredSystemLocales,
removeCustomColor,
removeCustomColorOnConversations,
resetAllChatColors,
resetDefaultChatColor,
resolvedLocale,
selectedCamera,
selectedMicrophone,
selectedSpeaker,
sentMediaQualitySetting,
setGlobalDefaultConversationColor,
shouldShowStoriesSettings,
localeOverride,
themeSetting,
universalExpireTimer = DurationInSeconds.ZERO,
whoCanFindMe,
@ -328,6 +384,7 @@ export function Preferences({
const storiesId = useUniqueId();
const themeSelectId = useUniqueId();
const zoomSelectId = useUniqueId();
const languageId = useUniqueId();
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmStoriesOff, setConfirmStoriesOff] = useState(false);
@ -336,13 +393,31 @@ export function Preferences({
const [nowSyncing, setNowSyncing] = useState(false);
const [showDisappearingTimerDialog, setShowDisappearingTimerDialog] =
useState(false);
const [languageDialog, setLanguageDialog] = useState<LanguageDialog | null>(
null
);
const [selectedLanguageLocale, setSelectedLanguageLocale] = useState<
string | null
>(localeOverride);
const [languageSearchInput, setLanguageSearchInput] = useState('');
const theme = useTheme();
function closeLanguageDialog() {
setLanguageDialog(null);
setSelectedLanguageLocale(localeOverride);
}
useEffect(() => {
doneRendering();
}, [doneRendering]);
useEscapeHandling(closeSettings);
useEscapeHandling(() => {
if (languageDialog != null) {
closeLanguageDialog();
} else {
closeSettings();
}
});
const onZoomSelectChange = useCallback(
(value: string) => {
@ -395,6 +470,82 @@ export function Preferences({
[onSelectedSpeakerChange, availableSpeakers]
);
const localeSearchOptions = useMemo(() => {
const collator = new Intl.Collator(resolvedLocale, { usage: 'sort' });
const languagesWithMultipleRegions =
getLocaleLanguagesWithMultipleRegions(availableLocales);
const availableLocalesOptions = availableLocales
.map(locale => {
const { language } = new Intl.Locale(locale);
const displayLocale = languagesWithMultipleRegions.has(language)
? locale
: language;
const currentLocaleLabel = getLanguageLabel(
displayLocale,
resolvedLocale
);
const matchingLocaleLabel = getLanguageLabel(displayLocale, locale);
return { locale, currentLocaleLabel, matchingLocaleLabel };
})
.sort((a, b) => {
return collator.compare(a.currentLocaleLabel, b.currentLocaleLabel);
});
const [localeOverrideMatches, localeOverrideNonMatches] = partition(
availableLocalesOptions,
option => {
return option.locale === localeOverride;
}
);
const preferredSystemLocaleMatch = LocaleMatcher.match(
preferredSystemLocales as Array<string>, // bad types
availableLocales as Array<string>, // bad types
'en',
{ algorithm: 'best fit' }
);
return [
...localeOverrideMatches,
{
locale: null,
currentLocaleLabel: i18n('icu:Preferences__Language__SystemLanguage'),
matchingLocaleLabel: getLanguageLabel(
preferredSystemLocaleMatch,
preferredSystemLocaleMatch
),
},
...localeOverrideNonMatches,
];
}, [
i18n,
availableLocales,
resolvedLocale,
localeOverride,
preferredSystemLocales,
]);
const localeSearchResults = useMemo(() => {
return localeSearchOptions.filter(option => {
const input = removeDiacritics(languageSearchInput.trim().toLowerCase());
if (input === '') {
return true;
}
function isMatch(value: string) {
return removeDiacritics(value.toLowerCase()).includes(input);
}
return (
isMatch(option.currentLocaleLabel) ||
(option.matchingLocaleLabel && isMatch(option.matchingLocaleLabel))
);
});
}, [localeSearchOptions, languageSearchInput]);
let settings: JSX.Element | undefined;
if (page === Page.General) {
settings = (
@ -504,6 +655,129 @@ export function Preferences({
</div>
<SettingsRow>
<Control
icon="Preferences__LanguageIcon"
left={i18n('icu:Preferences__Language__Label')}
right={
<span
className="Preferences__LanguageButton"
lang={localeOverride ?? resolvedLocale}
>
{localeOverride != null
? getLanguageLabel(localeOverride, resolvedLocale)
: i18n('icu:Preferences__Language__SystemLanguage')}
</span>
}
onClick={() => {
setLanguageDialog(LanguageDialog.Selection);
}}
/>
{languageDialog === LanguageDialog.Selection && (
<Modal
i18n={i18n}
modalName="Preferences__LanguageModal"
moduleClassName="Preferences__LanguageModal"
padded={false}
onClose={closeLanguageDialog}
title={i18n('icu:Preferences__Language__ModalTitle')}
modalHeaderChildren={
<SearchInput
i18n={i18n}
value={languageSearchInput}
placeholder={i18n(
'icu:Preferences__Language__SearchLanguages'
)}
moduleClassName="Preferences__LanguageModal__SearchInput"
onChange={event => {
setLanguageSearchInput(event.currentTarget.value);
}}
/>
}
modalFooter={
<>
<Button
variant={ButtonVariant.Secondary}
onClick={closeLanguageDialog}
>
{i18n('icu:cancel')}
</Button>
<Button
variant={ButtonVariant.Primary}
disabled={selectedLanguageLocale === localeOverride}
onClick={() => {
setLanguageDialog(LanguageDialog.Confirmation);
}}
>
{i18n('icu:Preferences__LanguageModal__Set')}
</Button>
</>
}
>
{localeSearchResults.length === 0 && (
<div className="Preferences__LanguageModal__NoResults">
{i18n('icu:Preferences__Language__NoResults', {
searchTerm: languageSearchInput.trim(),
})}
</div>
)}
{localeSearchResults.map(option => {
const id = `${languageId}:${option.locale ?? 'system'}`;
const isSelected = option.locale === selectedLanguageLocale;
return (
<button
key={id}
type="button"
className="Preferences__LanguageModal__Item"
onClick={() => {
setSelectedLanguageLocale(option.locale);
}}
aria-pressed={isSelected}
>
<span className="Preferences__LanguageModal__Item__Inner">
<span className="Preferences__LanguageModal__Item__Label">
<span className="Preferences__LanguageModal__Item__Current">
{option.currentLocaleLabel}
</span>
{option.matchingLocaleLabel != null && (
<span
lang={option.locale ?? resolvedLocale}
className="Preferences__LanguageModal__Item__Matching"
>
{option.matchingLocaleLabel}
</span>
)}
</span>
{isSelected && (
<span className="Preferences__LanguageModal__Item__Check" />
)}
</span>
</button>
);
})}
</Modal>
)}
{languageDialog === LanguageDialog.Confirmation && (
<ConfirmationDialog
dialogName="Preferences__Language"
i18n={i18n}
title={i18n('icu:Preferences__LanguageModal__Restart__Title')}
onCancel={closeLanguageDialog}
onClose={closeLanguageDialog}
cancelText={i18n('icu:cancel')}
actions={[
{
text: i18n('icu:Preferences__LanguageModal__Restart__Button'),
style: 'affirmative',
action: () => {
onLocaleChange(selectedLanguageLocale);
},
},
]}
>
{i18n('icu:Preferences__LanguageModal__Restart__Description')}
</ConfirmationDialog>
)}
<Control
icon
left={
<label htmlFor={themeSelectId}>
{i18n('icu:Preferences--theme')}
@ -532,6 +806,7 @@ export function Preferences({
}
/>
<Control
icon
left={i18n('icu:showChatColorEditor')}
onClick={() => {
setPage(Page.ChatColor);
@ -548,6 +823,7 @@ export function Preferences({
}
/>
<Control
icon
left={
<label htmlFor={zoomSelectId}>
{i18n('icu:Preferences--zoom')}
@ -1307,6 +1583,7 @@ export function Preferences({
>
{i18n('icu:Preferences__button--notifications')}
</button>
<button
type="button"
className={classNames({
@ -1346,16 +1623,27 @@ function SettingsRow({
}
function Control({
icon,
left,
onClick,
right,
}: {
/** A className or `true` to leave room for icon */
icon?: string | true;
left: ReactNode;
onClick?: () => unknown;
right: ReactNode;
}): JSX.Element {
const content = (
<>
{icon && (
<div
className={classNames(
'Preferences__control--icon',
icon === true ? null : icon
)}
/>
)}
<div className="Preferences__control--key">{left}</div>
<div className="Preferences__control--value">{right}</div>
</>

View file

@ -19,6 +19,7 @@ const EPHEMERAL_NAME_MAP = new Map([
['spellCheck', 'spell-check'],
['systemTraySetting', 'system-tray-setting'],
['themeSetting', 'theme-setting'],
['localeOverride', 'localeOverride'],
]);
type ResponseQueueEntry = Readonly<{
@ -79,6 +80,9 @@ export class SettingsChannel extends EventEmitter {
isEphemeral: true,
});
this.installSetting('localeOverride', {
isEphemeral: true,
});
this.installSetting('notificationSetting');
this.installSetting('notificationDrawAttention');
this.installSetting('audioMessage');

View file

@ -32,5 +32,5 @@ export async function deleteAllData(): Promise<void> {
Errors.toLogFormat(error)
);
}
window.IPC.restart();
window.SignalContext.restartApp();
}

View file

@ -26,6 +26,7 @@ describe('locale', async () => {
) {
const actualLocale = await load({
preferredSystemLocales,
localeOverride: null,
hourCyclePreference: HourCyclePreference.UnknownPreference,
logger,
});

View file

@ -199,6 +199,7 @@ const PLATFORMS = [
describe('createTemplate', () => {
const { i18n } = loadLocale({
preferredSystemLocales: ['en'],
localeOverride: null,
hourCyclePreference: HourCyclePreference.UnknownPreference,
logger: {
fatal: stub().throwsArg(0),

View file

@ -46,6 +46,7 @@ export const rendererConfigSchema = z.object({
installPath: configRequiredStringSchema,
osRelease: configRequiredStringSchema,
osVersion: configRequiredStringSchema,
availableLocales: z.array(configRequiredStringSchema),
resolvedTranslationsLocale: configRequiredStringSchema,
resolvedTranslationsLocaleDirection: z.enum(['ltr', 'rtl']),
hourCyclePreference: HourCyclePreferenceSchema,

View file

@ -56,6 +56,7 @@ export type StorageAccessType = {
'call-system-notification': boolean;
'hide-menu-bar': boolean;
'incoming-call-notification': boolean;
localeOverride: string | null;
'notification-draw-attention': boolean;
'notification-setting': NotificationSettingType;
'read-receipt-setting': boolean;

View file

@ -61,6 +61,7 @@ export type IPCEventsValuesType = {
hideMenuBar: boolean | undefined;
incomingCallNotification: boolean;
lastSyncTime: number | undefined;
localeOverride: string | null;
notificationDrawAttention: boolean;
notificationSetting: NotificationSettingType;
preferredAudioInputDevice: AudioDevice | undefined;
@ -372,6 +373,12 @@ export function createIPCEvents(
return promise;
},
getLocaleOverride: () => {
return window.storage.get('localeOverride') ?? null;
},
setLocaleOverride: async (locale: string | null) => {
await window.storage.put('localeOverride', locale);
},
getNotificationSetting: () =>
window.storage.get('notification-setting', 'message'),
setNotificationSetting: (value: 'message' | 'name' | 'count' | 'off') =>

1
ts/window.d.ts vendored
View file

@ -74,7 +74,6 @@ export type IPCType = {
logAppLoadedEvent?: (options: { processedCount?: number }) => void;
readyForUpdates: () => void;
removeSetupMenuItems: () => unknown;
restart: () => void;
setAutoHideMenuBar: (value: boolean) => void;
setAutoLaunch: (value: boolean) => Promise<void>;
setBadge: (badge: number | 'marked-unread') => void;

View file

@ -41,6 +41,7 @@ export type MinimalSignalContextType = {
executeMenuRole: (role: MenuItemConstructorOptions['role']) => Promise<void>;
getAppInstance: () => string | undefined;
getEnvironment: () => string;
getI18nAvailableLocales: () => ReadonlyArray<string>;
getI18nLocale: LocalizerType['getLocale'];
getI18nLocaleMessages: LocalizerType['getLocaleMessages'];
getResolvedMessagesLocaleDirection: () => LocaleDirection;
@ -53,6 +54,7 @@ export type MinimalSignalContextType = {
getPath: (name: 'userData' | 'home' | 'install') => string;
getVersion: () => string;
nativeThemeListener: NativeThemeType;
restartApp: () => void;
Settings: {
themeSetting: SettingType<IPCEventsValuesType['themeSetting']>;
waitForChange: () => Promise<void>;

View file

@ -99,10 +99,6 @@ const IPC: IPCType = {
}),
readyForUpdates: () => ipc.send('ready-for-updates'),
removeSetupMenuItems: () => ipc.send('remove-setup-menu-items'),
restart: () => {
log.info('restart');
ipc.send('restart');
},
setAutoHideMenuBar: autoHide => ipc.send('set-auto-hide-menu-bar', autoHide),
setAutoLaunch: value => ipc.invoke('set-auto-launch', value),
setBadge: badge => ipc.send('set-badge', badge),

View file

@ -40,6 +40,7 @@ export const MinimalSignalContext: MinimalSignalContextType = {
async getMenuOptions(): Promise<MenuOptionsType> {
return ipcRenderer.invoke('getMenuOptions');
},
getI18nAvailableLocales: () => config.availableLocales,
getI18nLocale: () => config.resolvedTranslationsLocale,
getI18nLocaleMessages: () => localeMessages,
@ -48,8 +49,8 @@ export const MinimalSignalContext: MinimalSignalContextType = {
config.resolvedTranslationsLocaleDirection,
getHourCyclePreference: () => config.hourCyclePreference,
getPreferredSystemLocales: () => config.preferredSystemLocales,
nativeThemeListener: createNativeThemeListener(ipcRenderer, window),
restartApp: () => ipcRenderer.send('restart'),
OS: {
getClassName: () => ipcRenderer.sendSync('OS.getClassName'),
hasCustomTitleBar: () => hasCustomTitleBar,

View file

@ -50,6 +50,7 @@ installSetting('hasStoriesDisabled');
installSetting('hideMenuBar');
installSetting('incomingCallNotification');
installSetting('lastSyncTime');
installSetting('localeOverride');
installSetting('notificationDrawAttention');
installSetting('notificationSetting');
installSetting('spellCheck');

View file

@ -20,6 +20,7 @@ SettingsWindowProps.onRender(
({
addCustomColor,
availableCameras,
availableLocales,
availableMicrophones,
availableSpeakers,
blockedCount,
@ -78,6 +79,7 @@ SettingsWindowProps.onRender(
onHideMenuBarChange,
onIncomingCallNotificationsChange,
onLastSyncTimeChange,
onLocaleChange,
onMediaCameraPermissionsChange,
onMediaPermissionsChange,
onMessageAudioChange,
@ -98,16 +100,19 @@ SettingsWindowProps.onRender(
onWhoCanFindMeChange,
onWhoCanSeeMeChange,
onZoomFactorChange,
preferredSystemLocales,
removeCustomColor,
removeCustomColorOnConversations,
resetAllChatColors,
resetDefaultChatColor,
resolvedLocale,
selectedCamera,
selectedMicrophone,
selectedSpeaker,
sentMediaQualitySetting,
setGlobalDefaultConversationColor,
shouldShowStoriesSettings,
localeOverride,
themeSetting,
universalExpireTimer,
whoCanFindMe,
@ -118,6 +123,7 @@ SettingsWindowProps.onRender(
<Preferences
addCustomColor={addCustomColor}
availableCameras={availableCameras}
availableLocales={availableLocales}
availableMicrophones={availableMicrophones}
availableSpeakers={availableSpeakers}
blockedCount={blockedCount}
@ -167,6 +173,7 @@ SettingsWindowProps.onRender(
isSyncSupported={isSyncSupported}
isSystemTraySupported={isSystemTraySupported}
lastSyncTime={lastSyncTime}
localeOverride={localeOverride}
makeSyncRequest={makeSyncRequest}
notificationContent={notificationContent}
onAudioNotificationsChange={onAudioNotificationsChange}
@ -179,6 +186,7 @@ SettingsWindowProps.onRender(
onHideMenuBarChange={onHideMenuBarChange}
onIncomingCallNotificationsChange={onIncomingCallNotificationsChange}
onLastSyncTimeChange={onLastSyncTimeChange}
onLocaleChange={onLocaleChange}
onMediaCameraPermissionsChange={onMediaCameraPermissionsChange}
onMediaPermissionsChange={onMediaPermissionsChange}
onMessageAudioChange={onMessageAudioChange}
@ -201,10 +209,12 @@ SettingsWindowProps.onRender(
onWhoCanFindMeChange={onWhoCanFindMeChange}
onWhoCanSeeMeChange={onWhoCanSeeMeChange}
onZoomFactorChange={onZoomFactorChange}
preferredSystemLocales={preferredSystemLocales}
removeCustomColorOnConversations={removeCustomColorOnConversations}
removeCustomColor={removeCustomColor}
resetAllChatColors={resetAllChatColors}
resetDefaultChatColor={resetDefaultChatColor}
resolvedLocale={resolvedLocale}
selectedCamera={selectedCamera}
selectedMicrophone={selectedMicrophone}
selectedSpeaker={selectedSpeaker}

View file

@ -46,6 +46,7 @@ const settingSpellCheck = createSetting('spellCheck');
const settingTextFormatting = createSetting('textFormatting');
const settingTheme = createSetting('themeSetting');
const settingSystemTraySetting = createSetting('systemTraySetting');
const settingLocaleOverride = createSetting('localeOverride');
const settingLastSyncTime = createSetting('lastSyncTime');
@ -169,6 +170,7 @@ async function renderPreferences() {
selectedMicrophone,
selectedSpeaker,
sentMediaQualitySetting,
localeOverride,
systemTraySetting,
themeSetting,
universalExpireTimer,
@ -210,6 +212,7 @@ async function renderPreferences() {
selectedMicrophone: settingAudioInput.getValue(),
selectedSpeaker: settingAudioOutput.getValue(),
sentMediaQualitySetting: settingSentMediaQuality.getValue(),
localeOverride: settingLocaleOverride.getValue(),
systemTraySetting: settingSystemTraySetting.getValue(),
themeSetting: settingTheme.getValue(),
universalExpireTimer: settingUniversalExpireTimer.getValue(),
@ -236,9 +239,15 @@ async function renderPreferences() {
settingUniversalExpireTimer.setValue
);
const availableLocales = MinimalSignalContext.getI18nAvailableLocales();
const resolvedLocale = MinimalSignalContext.getI18nLocale();
const preferredSystemLocales =
MinimalSignalContext.getPreferredSystemLocales();
const props = {
// Settings
availableCameras,
availableLocales,
availableMicrophones,
availableSpeakers,
blockedCount,
@ -268,7 +277,10 @@ async function renderPreferences() {
hasTextFormatting,
hasTypingIndicators,
lastSyncTime,
localeOverride,
notificationContent,
preferredSystemLocales,
resolvedLocale,
selectedCamera,
selectedMicrophone,
selectedSpeaker,
@ -347,6 +359,10 @@ async function renderPreferences() {
settingIncomingCallNotification.setValue
),
onLastSyncTimeChange: attachRenderCallback(settingLastSyncTime.setValue),
onLocaleChange: async (locale: string | null) => {
await settingLocaleOverride.setValue(locale);
MinimalSignalContext.restartApp();
},
onMediaCameraPermissionsChange: attachRenderCallback(
settingMediaCameraPermissions.setValue
),

View file

@ -1629,6 +1629,14 @@
"@formatjs/intl-localematcher" "0.2.32"
tslib "^2.4.0"
"@formatjs/ecma402-abstract@1.15.0":
version "1.15.0"
resolved "https://registry.yarnpkg.com/@formatjs/ecma402-abstract/-/ecma402-abstract-1.15.0.tgz#0a285a5dc69889e15d53803bd5036272e23e5a18"
integrity sha512-7bAYAv0w4AIao9DNg0avfOLTCPE9woAgs6SpXuMq11IN3A+l+cq8ghczwqSZBM11myvPSJA7vLn72q0rJ0QK6Q==
dependencies:
"@formatjs/intl-localematcher" "0.2.32"
tslib "^2.4.0"
"@formatjs/fast-memoize@1.2.1":
version "1.2.1"
resolved "https://registry.yarnpkg.com/@formatjs/fast-memoize/-/fast-memoize-1.2.1.tgz#e6f5aee2e4fd0ca5edba6eba7668e2d855e0fc21"
@ -1684,13 +1692,13 @@
"@formatjs/icu-skeleton-parser" "1.3.18"
tslib "^2.4.0"
"@formatjs/icu-messageformat-parser@2.3.1":
version "2.3.1"
resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.3.1.tgz#953080ea5c053bc73bdf55d0a524a3c3c133ae6b"
integrity sha512-knF2AkAKN4Upv4oIiKY4Wd/dLH68TNMPgV/tJMu/T6FP9aQwbv8fpj7U3lkyniPaNVxvia56Gxax8MKOjtxLSQ==
"@formatjs/icu-messageformat-parser@2.4.0":
version "2.4.0"
resolved "https://registry.yarnpkg.com/@formatjs/icu-messageformat-parser/-/icu-messageformat-parser-2.4.0.tgz#e165f3594c68416ce15f63793768251de2a85f88"
integrity sha512-6Dh5Z/gp4F/HovXXu/vmd0If5NbYLB5dZrmhWVNb+BOGOEU3wt7Z/83KY1dtd7IDhAnYHasbmKE1RbTE0J+3hw==
dependencies:
"@formatjs/ecma402-abstract" "1.14.3"
"@formatjs/icu-skeleton-parser" "1.3.18"
"@formatjs/ecma402-abstract" "1.15.0"
"@formatjs/icu-skeleton-parser" "1.4.0"
tslib "^2.4.0"
"@formatjs/icu-skeleton-parser@1.3.13":
@ -1717,6 +1725,14 @@
"@formatjs/ecma402-abstract" "1.11.4"
tslib "^2.1.0"
"@formatjs/icu-skeleton-parser@1.4.0":
version "1.4.0"
resolved "https://registry.yarnpkg.com/@formatjs/icu-skeleton-parser/-/icu-skeleton-parser-1.4.0.tgz#96342eca7c4eef7a309875569e5da973db3465e6"
integrity sha512-Qq347VM616rVLkvN6QsKJELazRyNlbCiN47LdH0Mc5U7E2xV0vatiVhGqd3KFgbc055BvtnUXR7XX60dCGFuWg==
dependencies:
"@formatjs/ecma402-abstract" "1.15.0"
tslib "^2.4.0"
"@formatjs/intl-displaynames@6.1.3":
version "6.1.3"
resolved "https://registry.yarnpkg.com/@formatjs/intl-displaynames/-/intl-displaynames-6.1.3.tgz#c9d283db518cd721c0855e9854bfadb9ba304b6a"
@ -11789,13 +11805,13 @@ intl-messageformat@10.3.1:
tslib "^2.4.0"
intl-messageformat@^10.1.0:
version "10.3.4"
resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.3.4.tgz#20f064c28b46fa6d352a4c4ba5e9bfc597af3eba"
integrity sha512-/FxUIrlbPtuykSNX85CB5sp2FjLVeTmdD7TfRkVFPft2n4FgcSlAcilFytYiFAEmPHc+0PvpLCIPXeaGFzIvOg==
version "10.3.5"
resolved "https://registry.yarnpkg.com/intl-messageformat/-/intl-messageformat-10.3.5.tgz#f55684fc663e62616ad59d3a504ea0cac3f267b7"
integrity sha512-6kPkftF8Jg3XJCkGKa5OD+nYQ+qcSxF4ZkuDdXZ6KGG0VXn+iblJqRFyDdm9VvKcMyC0Km2+JlVQffFM52D0YA==
dependencies:
"@formatjs/ecma402-abstract" "1.14.3"
"@formatjs/ecma402-abstract" "1.15.0"
"@formatjs/fast-memoize" "2.0.1"
"@formatjs/icu-messageformat-parser" "2.3.1"
"@formatjs/icu-messageformat-parser" "2.4.0"
tslib "^2.4.0"
intl-messageformat@^9.3.19:
@ -18836,6 +18852,11 @@ thread-stream@^2.0.0:
dependencies:
real-require "^0.2.0"
throttle-debounce@^3.0.1:
version "3.0.1"
resolved "https://registry.yarnpkg.com/throttle-debounce/-/throttle-debounce-3.0.1.tgz#32f94d84dfa894f786c9a1f290e7a645b6a19abb"
integrity sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==
through2-filter@^3.0.0:
version "3.0.0"
resolved "https://registry.yarnpkg.com/through2-filter/-/through2-filter-3.0.0.tgz#700e786df2367c2c88cd8aa5be4cf9c1e7831254"