// 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 } from 'lodash'; import classNames from 'classnames'; import uuid from 'uuid'; 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'; type CheckboxChangeHandlerType = (value: boolean) => unknown; type SelectChangeHandlerType = (value: T) => unknown; export type PropsDataType = { // Settings blockedCount: number; customColors: Record; defaultConversationColor: DefaultConversationColorType; deviceName?: string; hasAudioNotifications?: 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; // Other props hasCustomTitleBar: boolean; initialSpellCheckSetting: boolean; shouldShowStoriesSettings: boolean; // Feature flags isFormattingFlagEnabled: boolean; // Limited support features isAutoDownloadUpdatesSupported: boolean; isAutoLaunchSupported: boolean; isHideMenuBarSupported: boolean; isNotificationAttentionSupported: boolean; isPhoneNumberSharingSupported: boolean; isSyncSupported: boolean; isSystemTraySupported: boolean; isMinimizeToAndStartInSystemTraySupported: boolean; availableCameras: Array< Pick >; } & Omit; 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>; 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; onAutoDownloadUpdateChange: CheckboxChangeHandlerType; onAutoLaunchChange: CheckboxChangeHandlerType; onCallNotificationsChange: CheckboxChangeHandlerType; onCallRingtoneNotificationChange: CheckboxChangeHandlerType; onCountMutedConversationsChange: CheckboxChangeHandlerType; onHasStoriesDisabledChanged: SelectChangeHandlerType; onHideMenuBarChange: CheckboxChangeHandlerType; onIncomingCallNotificationsChange: CheckboxChangeHandlerType; onLastSyncTimeChange: (time: number) => unknown; onMediaCameraPermissionsChange: CheckboxChangeHandlerType; onMediaPermissionsChange: CheckboxChangeHandlerType; onMessageAudioChange: CheckboxChangeHandlerType; onMinimizeToAndStartInSystemTrayChange: CheckboxChangeHandlerType; onMinimizeToSystemTrayChange: CheckboxChangeHandlerType; onNotificationAttentionChange: CheckboxChangeHandlerType; onNotificationContentChange: SelectChangeHandlerType; onNotificationsChange: CheckboxChangeHandlerType; onRelayCallsChange: CheckboxChangeHandlerType; onSelectedCameraChange: SelectChangeHandlerType; onSelectedMicrophoneChange: SelectChangeHandlerType; onSelectedSpeakerChange: SelectChangeHandlerType; onSentMediaQualityChange: SelectChangeHandlerType; onSpellCheckChange: CheckboxChangeHandlerType; onTextFormattingChange: CheckboxChangeHandlerType; onThemeChange: SelectChangeHandlerType; onUniversalExpireTimerChange: SelectChangeHandlerType; onWhoCanSeeMeChange: SelectChangeHandlerType; onWhoCanFindMeChange: SelectChangeHandlerType; onZoomFactorChange: SelectChangeHandlerType; // Localization i18n: LocalizerType; }; export type PropsType = PropsDataType & PropsFunctionType; export type PropsPreloadType = Omit; enum Page { // Accessible through left nav General = 'General', Appearance = 'Appearance', Chats = 'Chats', Calls = 'Calls', Notifications = 'Notifications', Privacy = 'Privacy', // Sub pages ChatColor = 'ChatColor', PNP = 'PNP', } 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, availableMicrophones, availableSpeakers, blockedCount, closeSettings, customColors, defaultConversationColor, deviceName = '', doDeleteAllData, doneRendering, editCustomColor, executeMenuRole, getConversationsWithCustomColor, hasAudioNotifications, 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, isFormattingFlagEnabled, isHideMenuBarSupported, isPhoneNumberSharingSupported, isNotificationAttentionSupported, isSyncSupported, isSystemTraySupported, isMinimizeToAndStartInSystemTraySupported, hasCustomTitleBar, lastSyncTime, makeSyncRequest, notificationContent, onAudioNotificationsChange, onAutoDownloadUpdateChange, onAutoLaunchChange, onCallNotificationsChange, onCallRingtoneNotificationChange, onCountMutedConversationsChange, onHasStoriesDisabledChanged, onHideMenuBarChange, onIncomingCallNotificationsChange, onLastSyncTimeChange, onMediaCameraPermissionsChange, onMediaPermissionsChange, onMessageAudioChange, onMinimizeToAndStartInSystemTrayChange, onMinimizeToSystemTrayChange, onNotificationAttentionChange, onNotificationContentChange, onNotificationsChange, onRelayCallsChange, onSelectedCameraChange, onSelectedMicrophoneChange, onSelectedSpeakerChange, onSentMediaQualityChange, onSpellCheckChange, onTextFormattingChange, onThemeChange, onUniversalExpireTimerChange, onWhoCanSeeMeChange, onWhoCanFindMeChange, onZoomFactorChange, removeCustomColor, removeCustomColorOnConversations, resetAllChatColors, resetDefaultChatColor, selectedCamera, selectedMicrophone, selectedSpeaker, sentMediaQualitySetting, setGlobalDefaultConversationColor, shouldShowStoriesSettings, themeSetting, universalExpireTimer = DurationInSeconds.ZERO, whoCanFindMe, whoCanSeeMe, zoomFactor, }: PropsType): JSX.Element { const storiesId = useUniqueId(); const themeSelectId = useUniqueId(); const zoomSelectId = useUniqueId(); const [confirmDelete, setConfirmDelete] = useState(false); const [confirmStoriesOff, setConfirmStoriesOff] = useState(false); const [page, setPage] = useState(Page.General); const [showSyncFailed, setShowSyncFailed] = useState(false); const [nowSyncing, setNowSyncing] = useState(false); const [showDisappearingTimerDialog, setShowDisappearingTimerDialog] = useState(false); const theme = useTheme(); useEffect(() => { doneRendering(); }, [doneRendering]); useEscapeHandling(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(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] ); let settings: JSX.Element | undefined; if (page === Page.General) { settings = ( <>
{i18n('icu:Preferences__button--general')}
{isAutoLaunchSupported && ( )} {isHideMenuBarSupported && ( )} {isSystemTraySupported && ( <> {isMinimizeToAndStartInSystemTraySupported && ( )} )} {isAutoDownloadUpdatesSupported && ( )} ); } 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 = ( <>
{i18n('icu:Preferences__button--appearance')}
{i18n('icu:Preferences--theme')} } right={ } /> ); } 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 = ( <>
{i18n('icu:Preferences__button--chats')}
{isFormattingFlagEnabled && ( )} } /> {isSyncSupported && (
{i18n('icu:sync')}
{i18n('icu:syncExplanation')}{' '} {i18n('icu:Preferences--lastSynced', { date: lastSyncDate.toLocaleDateString(), time: lastSyncDate.toLocaleTimeString(), })}
{showSyncFailed && (
{i18n('icu:syncFailed')}
)} } right={
} />
)} ); } else if (page === Page.Calls) { settings = ( <>
{i18n('icu:Preferences__button--calls')}
({ text: localizeDefault(i18n, device.name), value: device.index, })) : [ { text: i18n( 'icu:callingDeviceSelection__select--no-device' ), value: 'undefined', }, ] } value={selectedMicrophone?.index} /> } right={
} /> { 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} /> } /> {shouldShowStoriesSettings && (
{i18n('icu:Stories__settings-toggle--title')}
{i18n('icu:Stories__settings-toggle--description')}
} right={ hasStoriesDisabled ? ( ) : ( ) } />
)}
{i18n('icu:clearDataHeader')}
{i18n('icu:clearDataExplanation')}
} right={
} />
{confirmDelete ? ( { setConfirmDelete(false); }} title={i18n('icu:deleteAllDataHeader')} > {i18n('icu:deleteAllDataBody')} ) : null} {confirmStoriesOff ? ( onHasStoriesDisabledChanged(true), style: 'negative', text: i18n('icu:Preferences__turn-stories-off--action'), }, ]} i18n={i18n} onClose={() => { setConfirmStoriesOff(false); }} > {i18n('icu:Preferences__turn-stories-off--body')} ) : null} ); } else if (page === Page.ChatColor) { settings = ( <>
); } else if (page === Page.PNP) { settings = ( <>
{whoCanSeeMe === PhoneNumberSharingMode.Everybody ? i18n('icu:Preferences__pnp__sharing--description--everyone') : i18n('icu:Preferences__pnp__sharing--description--nobody')}
{whoCanFindMe === PhoneNumberDiscoverability.Discoverable ? i18n( 'icu:Preferences__pnp__discoverability--description--everyone' ) : i18n( 'icu:Preferences__pnp__discoverability--description--nobody' )}
); } return (
{settings}
); } function SettingsRow({ children, title, className, }: { children: ReactNode; title?: string; className?: string; }): JSX.Element { return (
{title && {title}} {children}
); } function Control({ left, onClick, right, }: { left: ReactNode; onClick?: () => unknown; right: ReactNode; }): JSX.Element { const content = ( <>
{left}
{right}
); if (onClick) { return ( ); } return
{content}
; } type SettingsRadioOptionType = Readonly<{ text: string; value: Enum; }>; function SettingsRadio({ value, options, onChange, }: { value: Enum; options: ReadonlyArray>; onChange: (value: Enum) => void; }): JSX.Element { const htmlIds = useMemo(() => { return Array.from({ length: options.length }, () => uuid()); }, [options.length]); return (
{options.map(({ text, value: optionValue }, i) => { const htmlId = htmlIds[i]; return ( ); })}
); } function localizeDefault(i18n: LocalizerType, deviceLabel: string): string { return deviceLabel.toLowerCase().startsWith('default') ? deviceLabel.replace( /default/i, i18n('icu:callingDeviceSelection__select--default') ) : deviceLabel; }