// Copyright 2021-2022 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import type { ReactNode } from 'react'; import React, { useEffect, useState, useCallback } from 'react'; import { noop } from 'lodash'; import classNames from 'classnames'; import type { AudioDevice } from 'ringrtc'; import type { MediaDeviceSettings } from '../types/Calling'; import type { ZoomFactorType, NotificationSettingType, } from '../types/Storage.d'; import type { ThemeSettingType } from '../types/StorageUIKeys'; import { Button, ButtonVariant } from './Button'; import { ChatColorPicker } from './ChatColorPicker'; import { Checkbox } from './Checkbox'; import { ConfirmationDialog } from './ConfirmationDialog'; import type { ConversationType } from '../state/ducks/conversations'; import type { ConversationColorType, CustomColorType, DefaultConversationColorType, } from '../types/Colors'; import { DisappearingTimeDialog } from './DisappearingTimeDialog'; import type { LocalizerType, ThemeType } from '../types/Util'; import { PhoneNumberDiscoverability } from '../util/phoneNumberDiscoverability'; import { PhoneNumberSharingMode } from '../util/phoneNumberSharingMode'; import { Select } from './Select'; import { Spinner } from './Spinner'; import { TitleBarContainer } from './TitleBarContainer'; import type { ExecuteMenuRoleType } from './TitleBarContainer'; import { getCustomColorStyle } from '../util/getCustomColorStyle'; import { DEFAULT_DURATIONS_IN_SECONDS, DEFAULT_DURATIONS_SET, format as formatExpirationTimer, } from '../util/expirationTimer'; import { useEscapeHandling } from '../hooks/useEscapeHandling'; import { useUniqueId } from '../hooks/useUniqueId'; import { useTheme } from '../hooks/useTheme'; 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; hasMinimizeToAndStartInSystemTray: boolean; hasMinimizeToSystemTray: boolean; hasNotificationAttention: boolean; hasNotifications: boolean; hasReadReceipts: boolean; hasRelayCalls?: boolean; hasSpellCheck: boolean; hasStoriesDisabled: boolean; hasTypingIndicators: boolean; lastSyncTime?: number; notificationContent: NotificationSettingType; selectedCamera?: string; selectedMicrophone?: AudioDevice; selectedSpeaker?: AudioDevice; themeSetting: ThemeSettingType; universalExpireTimer: number; whoCanFindMe: PhoneNumberDiscoverability; whoCanSeeMe: PhoneNumberSharingMode; zoomFactor: ZoomFactorType; // Other props hasCustomTitleBar: boolean; initialSpellCheckSetting: boolean; shouldShowStoriesSettings: boolean; // Limited support features isAudioNotificationsSupported: boolean; 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; onMinimizeToAndStartInSystemTrayChange: CheckboxChangeHandlerType; onMinimizeToSystemTrayChange: CheckboxChangeHandlerType; onNotificationAttentionChange: CheckboxChangeHandlerType; onNotificationContentChange: SelectChangeHandlerType; onNotificationsChange: CheckboxChangeHandlerType; onRelayCallsChange: CheckboxChangeHandlerType; onSelectedCameraChange: SelectChangeHandlerType; onSelectedMicrophoneChange: SelectChangeHandlerType; onSelectedSpeakerChange: SelectChangeHandlerType; onSpellCheckChange: CheckboxChangeHandlerType; onThemeChange: SelectChangeHandlerType; onUniversalExpireTimerChange: SelectChangeHandlerType; onZoomFactorChange: SelectChangeHandlerType; // Localization i18n: LocalizerType; }; export type PropsType = PropsDataType & PropsFunctionType; enum Page { // Accessible through left nav General = 'General', Appearance = 'Appearance', Chats = 'Chats', Calls = 'Calls', Notifications = 'Notifications', Privacy = 'Privacy', // Sub pages ChatColor = 'ChatColor', } 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 const 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, hasMinimizeToAndStartInSystemTray, hasMinimizeToSystemTray, hasNotificationAttention, hasNotifications, hasReadReceipts, hasRelayCalls, hasSpellCheck, hasStoriesDisabled, hasTypingIndicators, i18n, initialSpellCheckSetting, isAudioNotificationsSupported, isAutoDownloadUpdatesSupported, isAutoLaunchSupported, isHideMenuBarSupported, isPhoneNumberSharingSupported, isNotificationAttentionSupported, isSyncSupported, isSystemTraySupported, isMinimizeToAndStartInSystemTraySupported, hasCustomTitleBar, lastSyncTime, makeSyncRequest, notificationContent, onAudioNotificationsChange, onAutoDownloadUpdateChange, onAutoLaunchChange, onCallNotificationsChange, onCallRingtoneNotificationChange, onCountMutedConversationsChange, onHasStoriesDisabledChanged, onHideMenuBarChange, onIncomingCallNotificationsChange, onLastSyncTimeChange, onMediaCameraPermissionsChange, onMediaPermissionsChange, onMinimizeToAndStartInSystemTrayChange, onMinimizeToSystemTrayChange, onNotificationAttentionChange, onNotificationContentChange, onNotificationsChange, onRelayCallsChange, onSelectedCameraChange, onSelectedMicrophoneChange, onSelectedSpeakerChange, onSpellCheckChange, onThemeChange, onUniversalExpireTimerChange, onZoomFactorChange, removeCustomColor, removeCustomColorOnConversations, resetAllChatColors, resetDefaultChatColor, selectedCamera, selectedMicrophone, selectedSpeaker, setGlobalDefaultConversationColor, shouldShowStoriesSettings, themeSetting, universalExpireTimer = 0, 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 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('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('Preferences__button--appearance')}
{i18n('Preferences--theme')} } right={ } /> ); } else if (page === Page.Chats) { let spellCheckDirtyText: string | undefined; if (initialSpellCheckSetting !== hasSpellCheck) { spellCheckDirtyText = hasSpellCheck ? i18n('spellCheckWillBeEnabled') : i18n('spellCheckWillBeDisabled'); } const lastSyncDate = new Date(lastSyncTime || 0); settings = ( <>
{i18n('Preferences__button--chats')}
{isSyncSupported && (
{i18n('sync')}
{i18n('syncExplanation')}{' '} {i18n('Preferences--lastSynced', { date: lastSyncDate.toLocaleDateString(), time: lastSyncDate.toLocaleTimeString(), })}
{showSyncFailed && (
{i18n('syncFailed')}
)} } right={
} />
)} ); } else if (page === Page.Calls) { settings = ( <>
{i18n('Preferences__button--calls')}
({ text: localizeDefault(i18n, device.name), value: device.index, })) : [ { text: i18n( '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 : -1, text: isCustomDisappearingMessageValue ? formatExpirationTimer(i18n, universalExpireTimer) : i18n('selectedCustomDisappearingTimeOption'), }, ])} value={universalExpireTimer} /> } /> {shouldShowStoriesSettings && (
{i18n('Stories__settings-toggle--title')}
{i18n('Stories__settings-toggle--description')}
} right={ hasStoriesDisabled ? ( ) : ( ) } />
)}
{i18n('clearDataHeader')}
{i18n('clearDataExplanation')}
} right={
} />
{confirmDelete ? ( { setConfirmDelete(false); }} title={i18n('deleteAllDataHeader')} > {i18n('deleteAllDataBody')} ) : null} {confirmStoriesOff ? ( onHasStoriesDisabledChanged(true), style: 'negative', text: i18n('Preferences__turn-stories-off--action'), }, ]} i18n={i18n} onClose={() => { setConfirmStoriesOff(false); }} > {i18n('Preferences__turn-stories-off--body')} ) : null} ); } else if (page === Page.ChatColor) { settings = ( <>
); } return (
{settings}
); }; const SettingsRow = ({ children, title, }: { children: ReactNode; title?: string; }): JSX.Element => { return (
{title &&

{title}

} {children}
); }; const Control = ({ left, onClick, right, }: { left: ReactNode; onClick?: () => unknown; right: ReactNode; }): JSX.Element => { const content = ( <>
{left}
{right}
); if (onClick) { return ( ); } return
{content}
; }; function localizeDefault(i18n: LocalizerType, deviceLabel: string): string { return deviceLabel.toLowerCase().startsWith('default') ? deviceLabel.replace( /default/i, i18n('callingDeviceSelection__select--default') ) : deviceLabel; }