signal-desktop/ts/components/Preferences.tsx
2023-12-19 18:19:10 -08:00

1700 lines
55 KiB
TypeScript

// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { AudioDevice } from '@signalapp/ringrtc';
import type { ReactNode } from 'react';
import React, {
useCallback,
useEffect,
useMemo,
useRef,
useState,
} from 'react';
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 {
NotificationSettingType,
SentMediaQualitySettingType,
ZoomFactorType,
} from '../types/Storage.d';
import type { ThemeSettingType } from '../types/StorageUIKeys';
import type { ConversationType } from '../state/ducks/conversations';
import type {
ConversationColorType,
CustomColorType,
DefaultConversationColorType,
} from '../types/Colors';
import type {
LocalizerType,
SentMediaQualityType,
ThemeType,
} from '../types/Util';
import type { ExecuteMenuRoleType } from './TitleBarContainer';
import { Button, ButtonVariant } from './Button';
import { ChatColorPicker } from './ChatColorPicker';
import { Checkbox } from './Checkbox';
import {
CircleCheckbox,
Variant as CircleCheckboxVariant,
} from './CircleCheckbox';
import { ConfirmationDialog } from './ConfirmationDialog';
import { DisappearingTimeDialog } from './DisappearingTimeDialog';
import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability';
import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode';
import { Select } from './Select';
import { Spinner } from './Spinner';
import { TitleBarContainer } from './TitleBarContainer';
import { getCustomColorStyle } from '../util/getCustomColorStyle';
import {
DEFAULT_DURATIONS_IN_SECONDS,
DEFAULT_DURATIONS_SET,
format as formatExpirationTimer,
} from '../util/expirationTimer';
import { DurationInSeconds } from '../util/durations';
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';
import { assertDev } from '../util/assert';
type CheckboxChangeHandlerType = (value: boolean) => unknown;
type SelectChangeHandlerType<T = string | number> = (value: T) => unknown;
export type PropsDataType = {
// Settings
blockedCount: number;
customColors: Record<string, CustomColorType>;
defaultConversationColor: DefaultConversationColorType;
deviceName?: string;
hasAudioNotifications?: boolean;
hasAutoConvertEmoji: boolean;
hasAutoDownloadUpdate: boolean;
hasAutoLaunch: boolean;
hasCallNotifications: boolean;
hasCallRingtoneNotification: boolean;
hasCountMutedConversations: boolean;
hasHideMenuBar?: boolean;
hasIncomingCallNotifications: boolean;
hasLinkPreviews: boolean;
hasMediaCameraPermissions: boolean;
hasMediaPermissions: boolean;
hasMessageAudio: boolean;
hasMinimizeToAndStartInSystemTray: boolean;
hasMinimizeToSystemTray: boolean;
hasNotificationAttention: boolean;
hasNotifications: boolean;
hasReadReceipts: boolean;
hasRelayCalls?: boolean;
hasSpellCheck: boolean;
hasStoriesDisabled: boolean;
hasTextFormatting: boolean;
hasTypingIndicators: boolean;
lastSyncTime?: number;
notificationContent: NotificationSettingType;
selectedCamera?: string;
selectedMicrophone?: AudioDevice;
selectedSpeaker?: AudioDevice;
sentMediaQualitySetting: SentMediaQualitySettingType;
themeSetting: ThemeSettingType;
universalExpireTimer: DurationInSeconds;
whoCanFindMe: PhoneNumberDiscoverability;
whoCanSeeMe: PhoneNumberSharingMode;
zoomFactor: ZoomFactorType;
// Localization
availableLocales: ReadonlyArray<string>;
localeOverride: string | null;
preferredSystemLocales: ReadonlyArray<string>;
resolvedLocale: string;
// Other props
hasCustomTitleBar: boolean;
initialSpellCheckSetting: boolean;
// Limited support features
isAutoDownloadUpdatesSupported: boolean;
isAutoLaunchSupported: boolean;
isHideMenuBarSupported: boolean;
isNotificationAttentionSupported: boolean;
isPhoneNumberSharingSupported: boolean;
isSyncSupported: boolean;
isSystemTraySupported: boolean;
isMinimizeToAndStartInSystemTraySupported: boolean;
availableCameras: Array<
Pick<MediaDeviceInfo, 'deviceId' | 'groupId' | 'kind' | 'label'>
>;
} & Omit<MediaDeviceSettings, 'availableCameras'>;
type PropsFunctionType = {
// Other props
addCustomColor: (color: CustomColorType) => unknown;
closeSettings: () => unknown;
doDeleteAllData: () => unknown;
doneRendering: () => unknown;
editCustomColor: (colorId: string, color: CustomColorType) => unknown;
executeMenuRole: ExecuteMenuRoleType;
getConversationsWithCustomColor: (
colorId: string
) => Promise<Array<ConversationType>>;
makeSyncRequest: () => unknown;
removeCustomColor: (colorId: string) => unknown;
removeCustomColorOnConversations: (colorId: string) => unknown;
resetAllChatColors: () => unknown;
resetDefaultChatColor: () => unknown;
setGlobalDefaultConversationColor: (
color: ConversationColorType,
customColorData?: {
id: string;
value: CustomColorType;
}
) => unknown;
// Change handlers
onAudioNotificationsChange: CheckboxChangeHandlerType;
onAutoConvertEmojiChange: CheckboxChangeHandlerType;
onAutoDownloadUpdateChange: CheckboxChangeHandlerType;
onAutoLaunchChange: CheckboxChangeHandlerType;
onCallNotificationsChange: CheckboxChangeHandlerType;
onCallRingtoneNotificationChange: CheckboxChangeHandlerType;
onCountMutedConversationsChange: CheckboxChangeHandlerType;
onHasStoriesDisabledChanged: SelectChangeHandlerType<boolean>;
onHideMenuBarChange: CheckboxChangeHandlerType;
onIncomingCallNotificationsChange: CheckboxChangeHandlerType;
onLastSyncTimeChange: (time: number) => unknown;
onLocaleChange: (locale: string | null) => void;
onMediaCameraPermissionsChange: CheckboxChangeHandlerType;
onMediaPermissionsChange: CheckboxChangeHandlerType;
onMessageAudioChange: CheckboxChangeHandlerType;
onMinimizeToAndStartInSystemTrayChange: CheckboxChangeHandlerType;
onMinimizeToSystemTrayChange: CheckboxChangeHandlerType;
onNotificationAttentionChange: CheckboxChangeHandlerType;
onNotificationContentChange: SelectChangeHandlerType<NotificationSettingType>;
onNotificationsChange: CheckboxChangeHandlerType;
onRelayCallsChange: CheckboxChangeHandlerType;
onSelectedCameraChange: SelectChangeHandlerType<string | undefined>;
onSelectedMicrophoneChange: SelectChangeHandlerType<AudioDevice | undefined>;
onSelectedSpeakerChange: SelectChangeHandlerType<AudioDevice | undefined>;
onSentMediaQualityChange: SelectChangeHandlerType<SentMediaQualityType>;
onSpellCheckChange: CheckboxChangeHandlerType;
onTextFormattingChange: CheckboxChangeHandlerType;
onThemeChange: SelectChangeHandlerType<ThemeType>;
onUniversalExpireTimerChange: SelectChangeHandlerType<number>;
onWhoCanSeeMeChange: SelectChangeHandlerType<PhoneNumberSharingMode>;
onWhoCanFindMeChange: SelectChangeHandlerType<PhoneNumberDiscoverability>;
onZoomFactorChange: SelectChangeHandlerType<ZoomFactorType>;
// Localization
i18n: LocalizerType;
};
export type PropsType = PropsDataType & PropsFunctionType;
export type PropsPreloadType = Omit<PropsType, 'i18n'>;
enum Page {
// Accessible through left nav
General = 'General',
Appearance = 'Appearance',
Chats = 'Chats',
Calls = 'Calls',
Notifications = 'Notifications',
Privacy = 'Privacy',
// Sub pages
ChatColor = 'ChatColor',
PNP = 'PNP',
}
enum LanguageDialog {
Selection,
Confirmation,
}
const DEFAULT_ZOOM_FACTORS = [
{
text: '75%',
value: 0.75,
},
{
text: '100%',
value: 1,
},
{
text: '125%',
value: 1.25,
},
{
text: '150%',
value: 1.5,
},
{
text: '200%',
value: 2,
},
];
export function Preferences({
addCustomColor,
availableCameras,
availableLocales,
availableMicrophones,
availableSpeakers,
blockedCount,
closeSettings,
customColors,
defaultConversationColor,
deviceName = '',
doDeleteAllData,
doneRendering,
editCustomColor,
executeMenuRole,
getConversationsWithCustomColor,
hasAudioNotifications,
hasAutoConvertEmoji,
hasAutoDownloadUpdate,
hasAutoLaunch,
hasCallNotifications,
hasCallRingtoneNotification,
hasCountMutedConversations,
hasHideMenuBar,
hasIncomingCallNotifications,
hasLinkPreviews,
hasMediaCameraPermissions,
hasMediaPermissions,
hasMessageAudio,
hasMinimizeToAndStartInSystemTray,
hasMinimizeToSystemTray,
hasNotificationAttention,
hasNotifications,
hasReadReceipts,
hasRelayCalls,
hasSpellCheck,
hasStoriesDisabled,
hasTextFormatting,
hasTypingIndicators,
i18n,
initialSpellCheckSetting,
isAutoDownloadUpdatesSupported,
isAutoLaunchSupported,
isHideMenuBarSupported,
isPhoneNumberSharingSupported,
isNotificationAttentionSupported,
isSyncSupported,
isSystemTraySupported,
isMinimizeToAndStartInSystemTraySupported,
hasCustomTitleBar,
lastSyncTime,
makeSyncRequest,
notificationContent,
onAudioNotificationsChange,
onAutoConvertEmojiChange,
onAutoDownloadUpdateChange,
onAutoLaunchChange,
onCallNotificationsChange,
onCallRingtoneNotificationChange,
onCountMutedConversationsChange,
onHasStoriesDisabledChanged,
onHideMenuBarChange,
onIncomingCallNotificationsChange,
onLastSyncTimeChange,
onLocaleChange,
onMediaCameraPermissionsChange,
onMediaPermissionsChange,
onMessageAudioChange,
onMinimizeToAndStartInSystemTrayChange,
onMinimizeToSystemTrayChange,
onNotificationAttentionChange,
onNotificationContentChange,
onNotificationsChange,
onRelayCallsChange,
onSelectedCameraChange,
onSelectedMicrophoneChange,
onSelectedSpeakerChange,
onSentMediaQualityChange,
onSpellCheckChange,
onTextFormattingChange,
onThemeChange,
onUniversalExpireTimerChange,
onWhoCanSeeMeChange,
onWhoCanFindMeChange,
onZoomFactorChange,
preferredSystemLocales,
removeCustomColor,
removeCustomColorOnConversations,
resetAllChatColors,
resetDefaultChatColor,
resolvedLocale,
selectedCamera,
selectedMicrophone,
selectedSpeaker,
sentMediaQualitySetting,
setGlobalDefaultConversationColor,
localeOverride,
themeSetting,
universalExpireTimer = DurationInSeconds.ZERO,
whoCanFindMe,
whoCanSeeMe,
zoomFactor,
}: PropsType): JSX.Element {
const storiesId = useUniqueId();
const themeSelectId = useUniqueId();
const zoomSelectId = useUniqueId();
const languageId = useUniqueId();
const [confirmDelete, setConfirmDelete] = useState(false);
const [confirmStoriesOff, setConfirmStoriesOff] = useState(false);
const [page, setPage] = useState<Page>(Page.General);
const [showSyncFailed, setShowSyncFailed] = useState(false);
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(() => {
if (languageDialog != null) {
closeLanguageDialog();
} else {
closeSettings();
}
});
const onZoomSelectChange = useCallback(
(value: string) => {
const number = parseFloat(value);
onZoomFactorChange(number as unknown as ZoomFactorType);
},
[onZoomFactorChange]
);
const onAudioInputSelectChange = useCallback(
(value: string) => {
if (value === 'undefined') {
onSelectedMicrophoneChange(undefined);
} else {
onSelectedMicrophoneChange(availableMicrophones[parseInt(value, 10)]);
}
},
[onSelectedMicrophoneChange, availableMicrophones]
);
const selectors = useMemo(() => focusableSelectors.join(','), []);
const settingsPaneRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
const settingsPane = settingsPaneRef.current;
if (!settingsPane) {
return;
}
const elements = settingsPane.querySelectorAll<
| HTMLAnchorElement
| HTMLButtonElement
| HTMLInputElement
| HTMLSelectElement
| HTMLTextAreaElement
>(selectors);
if (!elements.length) {
return;
}
elements[0]?.focus();
}, [page, selectors]);
const onAudioOutputSelectChange = useCallback(
(value: string) => {
if (value === 'undefined') {
onSelectedSpeakerChange(undefined);
} else {
onSelectedSpeakerChange(availableSpeakers[parseInt(value, 10)]);
}
},
[onSelectedSpeakerChange, availableSpeakers]
);
const localeDisplayNames = window.SignalContext.getLocaleDisplayNames();
const getLocaleDisplayName = useCallback(
(inLocale: string, ofLocale: string): string => {
const displayName = localeDisplayNames[inLocale]?.[ofLocale];
assertDev(
displayName != null,
`Locale display name in ${inLocale} of ${ofLocale} does not exist`
);
return (
displayName ??
new Intl.DisplayNames(inLocale, {
type: 'language',
languageDisplay: 'standard',
style: 'long',
fallback: 'code',
}).of(ofLocale)
);
},
[localeDisplayNames]
);
const localeSearchOptions = useMemo(() => {
const collator = new Intl.Collator('en', { usage: 'sort' });
const availableLocalesOptions = availableLocales
.map(locale => {
const currentLocaleLabel = getLocaleDisplayName(resolvedLocale, locale);
const matchingLocaleLabel = getLocaleDisplayName(locale, locale);
return { locale, currentLocaleLabel, matchingLocaleLabel };
})
.sort((a, b) => {
return collator.compare(a.locale, b.locale);
});
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: getLocaleDisplayName(
preferredSystemLocaleMatch,
preferredSystemLocaleMatch
),
},
...localeOverrideNonMatches,
];
}, [
i18n,
availableLocales,
resolvedLocale,
localeOverride,
preferredSystemLocales,
getLocaleDisplayName,
]);
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 = (
<>
<div className="Preferences__title">
<div className="Preferences__title--header">
{i18n('icu:Preferences__button--general')}
</div>
</div>
<SettingsRow>
<Control
left={i18n('icu:Preferences--device-name')}
right={deviceName}
/>
</SettingsRow>
<SettingsRow title={i18n('icu:Preferences--system')}>
{isAutoLaunchSupported && (
<Checkbox
checked={hasAutoLaunch}
label={i18n('icu:autoLaunchDescription')}
moduleClassName="Preferences__checkbox"
name="autoLaunch"
onChange={onAutoLaunchChange}
/>
)}
{isHideMenuBarSupported && (
<Checkbox
checked={hasHideMenuBar}
label={i18n('icu:hideMenuBar')}
moduleClassName="Preferences__checkbox"
name="hideMenuBar"
onChange={onHideMenuBarChange}
/>
)}
{isSystemTraySupported && (
<>
<Checkbox
checked={hasMinimizeToSystemTray}
label={i18n('icu:SystemTraySetting__minimize-to-system-tray')}
moduleClassName="Preferences__checkbox"
name="system-tray-setting-minimize-to-system-tray"
onChange={onMinimizeToSystemTrayChange}
/>
{isMinimizeToAndStartInSystemTraySupported && (
<Checkbox
checked={hasMinimizeToAndStartInSystemTray}
disabled={!hasMinimizeToSystemTray}
label={i18n(
'icu:SystemTraySetting__minimize-to-and-start-in-system-tray'
)}
moduleClassName="Preferences__checkbox"
name="system-tray-setting-minimize-to-and-start-in-system-tray"
onChange={onMinimizeToAndStartInSystemTrayChange}
/>
)}
</>
)}
</SettingsRow>
<SettingsRow title={i18n('icu:permissions')}>
<Checkbox
checked={hasMediaPermissions}
label={i18n('icu:mediaPermissionsDescription')}
moduleClassName="Preferences__checkbox"
name="mediaPermissions"
onChange={onMediaPermissionsChange}
/>
<Checkbox
checked={hasMediaCameraPermissions}
label={i18n('icu:mediaCameraPermissionsDescription')}
moduleClassName="Preferences__checkbox"
name="mediaCameraPermissions"
onChange={onMediaCameraPermissionsChange}
/>
</SettingsRow>
{isAutoDownloadUpdatesSupported && (
<SettingsRow title={i18n('icu:Preferences--updates')}>
<Checkbox
checked={hasAutoDownloadUpdate}
label={i18n('icu:Preferences__download-update')}
moduleClassName="Preferences__checkbox"
name="autoDownloadUpdate"
onChange={onAutoDownloadUpdateChange}
/>
</SettingsRow>
)}
</>
);
} else if (page === Page.Appearance) {
let zoomFactors = DEFAULT_ZOOM_FACTORS;
if (!zoomFactors.some(({ value }) => value === zoomFactor)) {
zoomFactors = [
...zoomFactors,
{
text: `${Math.round(zoomFactor * 100)}%`,
value: zoomFactor,
},
].sort((a, b) => a.value - b.value);
}
settings = (
<>
<div className="Preferences__title">
<div className="Preferences__title--header">
{i18n('icu:Preferences__button--appearance')}
</div>
</div>
<SettingsRow>
<Control
icon="Preferences__LanguageIcon"
left={i18n('icu:Preferences__Language__Label')}
right={
<span
className="Preferences__LanguageButton"
lang={localeOverride ?? resolvedLocale}
>
{localeOverride != null
? getLocaleDisplayName(resolvedLocale, localeOverride)
: 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')}
</label>
}
right={
<Select
id={themeSelectId}
onChange={onThemeChange}
options={[
{
text: i18n('icu:themeSystem'),
value: 'system',
},
{
text: i18n('icu:themeLight'),
value: 'light',
},
{
text: i18n('icu:themeDark'),
value: 'dark',
},
]}
value={themeSetting}
/>
}
/>
<Control
icon
left={i18n('icu:showChatColorEditor')}
onClick={() => {
setPage(Page.ChatColor);
}}
right={
<div
className={`ConversationDetails__chat-color ConversationDetails__chat-color--${defaultConversationColor.color}`}
style={{
...getCustomColorStyle(
defaultConversationColor.customColorData?.value
),
}}
/>
}
/>
<Control
icon
left={
<label htmlFor={zoomSelectId}>
{i18n('icu:Preferences--zoom')}
</label>
}
right={
<Select
id={zoomSelectId}
onChange={onZoomSelectChange}
options={zoomFactors}
value={zoomFactor}
/>
}
/>
</SettingsRow>
</>
);
} else if (page === Page.Chats) {
let spellCheckDirtyText: string | undefined;
if (initialSpellCheckSetting !== hasSpellCheck) {
spellCheckDirtyText = hasSpellCheck
? i18n('icu:spellCheckWillBeEnabled')
: i18n('icu:spellCheckWillBeDisabled');
}
const lastSyncDate = new Date(lastSyncTime || 0);
settings = (
<>
<div className="Preferences__title">
<div className="Preferences__title--header">
{i18n('icu:Preferences__button--chats')}
</div>
</div>
<SettingsRow title={i18n('icu:Preferences__button--chats')}>
<Checkbox
checked={hasSpellCheck}
description={spellCheckDirtyText}
label={i18n('icu:spellCheckDescription')}
moduleClassName="Preferences__checkbox"
name="spellcheck"
onChange={onSpellCheckChange}
/>
<Checkbox
checked={hasTextFormatting}
label={i18n('icu:textFormattingDescription')}
moduleClassName="Preferences__checkbox"
name="textFormatting"
onChange={onTextFormattingChange}
/>
<Checkbox
checked={hasLinkPreviews}
description={i18n('icu:Preferences__link-previews--description')}
disabled
label={i18n('icu:Preferences__link-previews--title')}
moduleClassName="Preferences__checkbox"
name="linkPreviews"
onChange={noop}
/>
<Checkbox
checked={hasAutoConvertEmoji}
description={i18n(
'icu:Preferences__auto-convert-emoji--description'
)}
label={i18n('icu:Preferences__auto-convert-emoji--title')}
moduleClassName="Preferences__checkbox"
name="autoConvertEmoji"
onChange={onAutoConvertEmojiChange}
/>
<Control
left={i18n('icu:Preferences__sent-media-quality')}
right={
<Select
onChange={onSentMediaQualityChange}
options={[
{
text: i18n('icu:sentMediaQualityStandard'),
value: 'standard',
},
{
text: i18n('icu:sentMediaQualityHigh'),
value: 'high',
},
]}
value={sentMediaQualitySetting}
/>
}
/>
</SettingsRow>
{isSyncSupported && (
<SettingsRow>
<Control
left={
<>
<div>{i18n('icu:sync')}</div>
<div className="Preferences__description">
{i18n('icu:syncExplanation')}{' '}
{i18n('icu:Preferences--lastSynced', {
date: lastSyncDate.toLocaleDateString(),
time: lastSyncDate.toLocaleTimeString(),
})}
</div>
{showSyncFailed && (
<div className="Preferences__description Preferences__description--error">
{i18n('icu:syncFailed')}
</div>
)}
</>
}
right={
<div className="Preferences__right-button">
<Button
aria-label={
nowSyncing ? i18n('icu:syncing') : i18n('icu:syncNow')
}
aria-live="polite"
disabled={nowSyncing}
onClick={async () => {
setShowSyncFailed(false);
setNowSyncing(true);
try {
await makeSyncRequest();
onLastSyncTimeChange(Date.now());
} catch (err) {
setShowSyncFailed(true);
} finally {
setNowSyncing(false);
}
}}
variant={ButtonVariant.SecondaryAffirmative}
>
{nowSyncing ? (
<Spinner svgSize="small" />
) : (
i18n('icu:syncNow')
)}
</Button>
</div>
}
/>
</SettingsRow>
)}
</>
);
} else if (page === Page.Calls) {
settings = (
<>
<div className="Preferences__title">
<div className="Preferences__title--header">
{i18n('icu:Preferences__button--calls')}
</div>
</div>
<SettingsRow title={i18n('icu:calling')}>
<Checkbox
checked={hasIncomingCallNotifications}
label={i18n('icu:incomingCallNotificationDescription')}
moduleClassName="Preferences__checkbox"
name="incomingCallNotification"
onChange={onIncomingCallNotificationsChange}
/>
<Checkbox
checked={hasCallRingtoneNotification}
label={i18n('icu:callRingtoneNotificationDescription')}
moduleClassName="Preferences__checkbox"
name="callRingtoneNotification"
onChange={onCallRingtoneNotificationChange}
/>
</SettingsRow>
<SettingsRow title={i18n('icu:Preferences__devices')}>
<Control
left={
<>
<label className="Preferences__select-title" htmlFor="video">
{i18n('icu:callingDeviceSelection__label--video')}
</label>
<Select
ariaLabel={i18n('icu:callingDeviceSelection__label--video')}
disabled={!availableCameras.length}
moduleClassName="Preferences__select"
name="video"
onChange={onSelectedCameraChange}
options={
availableCameras.length
? availableCameras.map(device => ({
text: localizeDefault(i18n, device.label),
value: device.deviceId,
}))
: [
{
text: i18n(
'icu:callingDeviceSelection__select--no-device'
),
value: 'undefined',
},
]
}
value={selectedCamera}
/>
</>
}
right={<div />}
/>
<Control
left={
<>
<label
className="Preferences__select-title"
htmlFor="audio-input"
>
{i18n('icu:callingDeviceSelection__label--audio-input')}
</label>
<Select
ariaLabel={i18n(
'icu:callingDeviceSelection__label--audio-input'
)}
disabled={!availableMicrophones.length}
moduleClassName="Preferences__select"
name="audio-input"
onChange={onAudioInputSelectChange}
options={
availableMicrophones.length
? availableMicrophones.map(device => ({
text: localizeDefault(i18n, device.name),
value: device.index,
}))
: [
{
text: i18n(
'icu:callingDeviceSelection__select--no-device'
),
value: 'undefined',
},
]
}
value={selectedMicrophone?.index}
/>
</>
}
right={<div />}
/>
<Control
left={
<>
<label
className="Preferences__select-title"
htmlFor="audio-output"
>
{i18n('icu:callingDeviceSelection__label--audio-output')}
</label>
<Select
ariaLabel={i18n(
'icu:callingDeviceSelection__label--audio-output'
)}
disabled={!availableSpeakers.length}
moduleClassName="Preferences__select"
name="audio-output"
onChange={onAudioOutputSelectChange}
options={
availableSpeakers.length
? availableSpeakers.map(device => ({
text: localizeDefault(i18n, device.name),
value: device.index,
}))
: [
{
text: i18n(
'icu:callingDeviceSelection__select--no-device'
),
value: 'undefined',
},
]
}
value={selectedSpeaker?.index}
/>
</>
}
right={<div />}
/>
</SettingsRow>
<SettingsRow title={i18n('icu:Preferences--advanced')}>
<Checkbox
checked={hasRelayCalls}
description={i18n('icu:alwaysRelayCallsDetail')}
label={i18n('icu:alwaysRelayCallsDescription')}
moduleClassName="Preferences__checkbox"
name="relayCalls"
onChange={onRelayCallsChange}
/>
</SettingsRow>
</>
);
} else if (page === Page.Notifications) {
settings = (
<>
<div className="Preferences__title">
<div className="Preferences__title--header">
{i18n('icu:Preferences__button--notifications')}
</div>
</div>
<SettingsRow>
<Checkbox
checked={hasNotifications}
label={i18n('icu:Preferences__enable-notifications')}
moduleClassName="Preferences__checkbox"
name="notifications"
onChange={onNotificationsChange}
/>
<Checkbox
checked={hasCallNotifications}
label={i18n('icu:callSystemNotificationDescription')}
moduleClassName="Preferences__checkbox"
name="callSystemNotification"
onChange={onCallNotificationsChange}
/>
{isNotificationAttentionSupported && (
<Checkbox
checked={hasNotificationAttention}
label={i18n('icu:notificationDrawAttention')}
moduleClassName="Preferences__checkbox"
name="notificationDrawAttention"
onChange={onNotificationAttentionChange}
/>
)}
<Checkbox
checked={hasCountMutedConversations}
label={i18n('icu:countMutedConversationsDescription')}
moduleClassName="Preferences__checkbox"
name="countMutedConversations"
onChange={onCountMutedConversationsChange}
/>
</SettingsRow>
<SettingsRow>
<Control
left={i18n('icu:Preferences--notification-content')}
right={
<Select
ariaLabel={i18n('icu:Preferences--notification-content')}
disabled={!hasNotifications}
onChange={onNotificationContentChange}
options={[
{
text: i18n('icu:nameAndMessage'),
value: 'message',
},
{
text: i18n('icu:nameOnly'),
value: 'name',
},
{
text: i18n('icu:noNameOrMessage'),
value: 'count',
},
]}
value={notificationContent}
/>
}
/>
</SettingsRow>
<SettingsRow>
<Checkbox
checked={hasAudioNotifications}
label={i18n('icu:audioNotificationDescription')}
moduleClassName="Preferences__checkbox"
name="audioNotification"
onChange={onAudioNotificationsChange}
/>
<Checkbox
checked={hasMessageAudio}
description={i18n('icu:Preferences__message-audio-description')}
label={i18n('icu:Preferences__message-audio-title')}
moduleClassName="Preferences__checkbox"
name="messageAudio"
onChange={onMessageAudioChange}
/>
</SettingsRow>
</>
);
} else if (page === Page.Privacy) {
const isCustomDisappearingMessageValue =
!DEFAULT_DURATIONS_SET.has(universalExpireTimer);
settings = (
<>
<div className="Preferences__title">
<div className="Preferences__title--header">
{i18n('icu:Preferences__button--privacy')}
</div>
</div>
{isPhoneNumberSharingSupported ? (
<button
type="button"
className="Preferences__link"
onClick={() => setPage(Page.PNP)}
>
<h3 className="Preferences__padding">
{i18n('icu:Preferences__pnp__row--title')}
</h3>
<div className="Preferences__padding Preferences__description">
{i18n('icu:Preferences__pnp__row--body')}
</div>
</button>
) : null}
<SettingsRow>
<Control
left={i18n('icu:Preferences--blocked')}
right={i18n('icu:Preferences--blocked-count', {
num: blockedCount,
})}
/>
</SettingsRow>
<SettingsRow title={i18n('icu:Preferences--messaging')}>
<Checkbox
checked={hasReadReceipts}
disabled
label={i18n('icu:Preferences--read-receipts')}
moduleClassName="Preferences__checkbox"
name="readReceipts"
onChange={noop}
/>
<Checkbox
checked={hasTypingIndicators}
disabled
label={i18n('icu:Preferences--typing-indicators')}
moduleClassName="Preferences__checkbox"
name="typingIndicators"
onChange={noop}
/>
<div className="Preferences__padding">
<div className="Preferences__description">
{i18n('icu:Preferences__privacy--description')}
</div>
</div>
</SettingsRow>
{showDisappearingTimerDialog && (
<DisappearingTimeDialog
i18n={i18n}
initialValue={universalExpireTimer}
onClose={() => setShowDisappearingTimerDialog(false)}
onSubmit={onUniversalExpireTimerChange}
/>
)}
<SettingsRow title={i18n('icu:disappearingMessages')}>
<Control
left={
<>
<div>
{i18n('icu:settings__DisappearingMessages__timer__label')}
</div>
<div className="Preferences__description">
{i18n('icu:settings__DisappearingMessages__footer')}
</div>
</>
}
right={
<Select
ariaLabel={i18n(
'icu:settings__DisappearingMessages__timer__label'
)}
onChange={value => {
if (
value === String(universalExpireTimer) ||
value === '-1'
) {
setShowDisappearingTimerDialog(true);
return;
}
onUniversalExpireTimerChange(parseInt(value, 10));
}}
options={DEFAULT_DURATIONS_IN_SECONDS.map(seconds => {
const text = formatExpirationTimer(i18n, seconds, {
capitalizeOff: true,
});
return {
value: seconds,
text,
};
}).concat([
{
value: isCustomDisappearingMessageValue
? universalExpireTimer
: DurationInSeconds.fromSeconds(-1),
text: isCustomDisappearingMessageValue
? formatExpirationTimer(i18n, universalExpireTimer)
: i18n('icu:selectedCustomDisappearingTimeOption'),
},
])}
value={universalExpireTimer}
/>
}
/>
</SettingsRow>
<SettingsRow title={i18n('icu:Stories__title')}>
<Control
left={
<label htmlFor={storiesId}>
<div>{i18n('icu:Stories__settings-toggle--title')}</div>
<div className="Preferences__description">
{i18n('icu:Stories__settings-toggle--description')}
</div>
</label>
}
right={
hasStoriesDisabled ? (
<Button
onClick={() => onHasStoriesDisabledChanged(false)}
variant={ButtonVariant.Secondary}
>
{i18n('icu:Preferences__turn-stories-on')}
</Button>
) : (
<Button
className="Preferences__stories-off"
onClick={() => setConfirmStoriesOff(true)}
variant={ButtonVariant.SecondaryDestructive}
>
{i18n('icu:Preferences__turn-stories-off')}
</Button>
)
}
/>
</SettingsRow>
<SettingsRow>
<Control
left={
<>
<div>{i18n('icu:clearDataHeader')}</div>
<div className="Preferences__description">
{i18n('icu:clearDataExplanation')}
</div>
</>
}
right={
<div className="Preferences__right-button">
<Button
onClick={() => setConfirmDelete(true)}
variant={ButtonVariant.SecondaryDestructive}
>
{i18n('icu:clearDataButton')}
</Button>
</div>
}
/>
</SettingsRow>
{confirmDelete ? (
<ConfirmationDialog
dialogName="Preference.deleteAllData"
actions={[
{
action: doDeleteAllData,
style: 'negative',
text: i18n('icu:clearDataButton'),
},
]}
i18n={i18n}
onClose={() => {
setConfirmDelete(false);
}}
title={i18n('icu:deleteAllDataHeader')}
>
{i18n('icu:deleteAllDataBody')}
</ConfirmationDialog>
) : null}
{confirmStoriesOff ? (
<ConfirmationDialog
dialogName="Preference.turnStoriesOff"
actions={[
{
action: () => onHasStoriesDisabledChanged(true),
style: 'negative',
text: i18n('icu:Preferences__turn-stories-off--action'),
},
]}
i18n={i18n}
onClose={() => {
setConfirmStoriesOff(false);
}}
>
{i18n('icu:Preferences__turn-stories-off--body')}
</ConfirmationDialog>
) : null}
</>
);
} else if (page === Page.ChatColor) {
settings = (
<>
<div className="Preferences__title">
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={() => setPage(Page.Appearance)}
type="button"
/>
<div className="Preferences__title--header">
{i18n('icu:ChatColorPicker__menu-title')}
</div>
</div>
<ChatColorPicker
customColors={customColors}
getConversationsWithCustomColor={getConversationsWithCustomColor}
i18n={i18n}
isGlobal
selectedColor={defaultConversationColor.color}
selectedCustomColor={defaultConversationColor.customColorData || {}}
// actions
addCustomColor={addCustomColor}
colorSelected={noop}
editCustomColor={editCustomColor}
removeCustomColor={removeCustomColor}
removeCustomColorOnConversations={removeCustomColorOnConversations}
resetAllChatColors={resetAllChatColors}
resetDefaultChatColor={resetDefaultChatColor}
setGlobalDefaultConversationColor={setGlobalDefaultConversationColor}
/>
</>
);
} else if (page === Page.PNP) {
settings = (
<>
<div className="Preferences__title">
<button
aria-label={i18n('icu:goBack')}
className="Preferences__back-icon"
onClick={() => setPage(Page.Privacy)}
type="button"
/>
<div className="Preferences__title--header">
{i18n('icu:Preferences__pnp--page-title')}
</div>
</div>
<SettingsRow
title={i18n('icu:Preferences__pnp__sharing--title')}
className={classNames('Preferences__settings-row--pnp-sharing', {
'Preferences__settings-row--pnp-sharing--nobody':
whoCanSeeMe === PhoneNumberSharingMode.Nobody,
})}
>
<SettingsRadio
onChange={onWhoCanSeeMeChange}
options={[
{
text: i18n('icu:Preferences__pnp__sharing__everyone'),
value: PhoneNumberSharingMode.Everybody,
},
{
text: i18n('icu:Preferences__pnp__sharing__nobody'),
value: PhoneNumberSharingMode.Nobody,
},
]}
value={whoCanSeeMe}
/>
<div className="Preferences__padding">
<div className="Preferences__description">
{whoCanSeeMe === PhoneNumberSharingMode.Everybody
? i18n('icu:Preferences__pnp__sharing--description--everyone')
: i18n('icu:Preferences__pnp__sharing--description--nobody')}
</div>
</div>
</SettingsRow>
<SettingsRow
title={i18n('icu:Preferences__pnp__discoverability--title')}
>
<SettingsRadio
onChange={onWhoCanFindMeChange}
options={[
{
text: i18n('icu:Preferences__pnp__discoverability__everyone'),
value: PhoneNumberDiscoverability.Discoverable,
},
...(whoCanSeeMe === PhoneNumberSharingMode.Nobody
? [
{
text: i18n(
'icu:Preferences__pnp__discoverability__nobody'
),
value: PhoneNumberDiscoverability.NotDiscoverable,
},
]
: []),
]}
value={whoCanFindMe}
/>
<div className="Preferences__padding">
<div className="Preferences__description">
{whoCanFindMe === PhoneNumberDiscoverability.Discoverable
? i18n(
'icu:Preferences__pnp__discoverability--description--everyone'
)
: i18n(
'icu:Preferences__pnp__discoverability--description--nobody'
)}
</div>
</div>
</SettingsRow>
</>
);
}
return (
<TitleBarContainer
hasCustomTitleBar={hasCustomTitleBar}
theme={theme}
executeMenuRole={executeMenuRole}
>
<div className="module-title-bar-drag-area" />
<div className="Preferences">
<div className="Preferences__page-selector">
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--general': true,
'Preferences__button--selected': page === Page.General,
})}
onClick={() => setPage(Page.General)}
>
{i18n('icu:Preferences__button--general')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--appearance': true,
'Preferences__button--selected':
page === Page.Appearance || page === Page.ChatColor,
})}
onClick={() => setPage(Page.Appearance)}
>
{i18n('icu:Preferences__button--appearance')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--chats': true,
'Preferences__button--selected': page === Page.Chats,
})}
onClick={() => setPage(Page.Chats)}
>
{i18n('icu:Preferences__button--chats')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--calls': true,
'Preferences__button--selected': page === Page.Calls,
})}
onClick={() => setPage(Page.Calls)}
>
{i18n('icu:Preferences__button--calls')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--notifications': true,
'Preferences__button--selected': page === Page.Notifications,
})}
onClick={() => setPage(Page.Notifications)}
>
{i18n('icu:Preferences__button--notifications')}
</button>
<button
type="button"
className={classNames({
Preferences__button: true,
'Preferences__button--privacy': true,
'Preferences__button--selected':
page === Page.Privacy || page === Page.PNP,
})}
onClick={() => setPage(Page.Privacy)}
>
{i18n('icu:Preferences__button--privacy')}
</button>
</div>
<div className="Preferences__settings-pane" ref={settingsPaneRef}>
{settings}
</div>
</div>
</TitleBarContainer>
);
}
function SettingsRow({
children,
title,
className,
}: {
children: ReactNode;
title?: string;
className?: string;
}): JSX.Element {
return (
<fieldset className={classNames('Preferences__settings-row', className)}>
{title && <legend className="Preferences__padding">{title}</legend>}
{children}
</fieldset>
);
}
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>
</>
);
if (onClick) {
return (
<button
className="Preferences__control Preferences__control--clickable"
type="button"
onClick={onClick}
>
{content}
</button>
);
}
return <div className="Preferences__control">{content}</div>;
}
type SettingsRadioOptionType<Enum> = Readonly<{
text: string;
value: Enum;
}>;
function SettingsRadio<Enum>({
value,
options,
onChange,
}: {
value: Enum;
options: ReadonlyArray<SettingsRadioOptionType<Enum>>;
onChange: (value: Enum) => void;
}): JSX.Element {
const htmlIds = useMemo(() => {
return Array.from({ length: options.length }, () => uuid());
}, [options.length]);
return (
<div className="Preferences__padding">
{options.map(({ text, value: optionValue }, i) => {
const htmlId = htmlIds[i];
return (
<label
className="Preferences__settings-radio__label"
key={htmlId}
htmlFor={htmlId}
>
<CircleCheckbox
isRadio
variant={CircleCheckboxVariant.Small}
id={htmlId}
checked={value === optionValue}
onChange={() => onChange(optionValue)}
/>
{text}
</label>
);
})}
</div>
);
}
function localizeDefault(i18n: LocalizerType, deviceLabel: string): string {
return deviceLabel.toLowerCase().startsWith('default')
? deviceLabel.replace(
/default/i,
i18n('icu:callingDeviceSelection__select--default')
)
: deviceLabel;
}