// Copyright 2020-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; import { noop } from 'lodash'; import classNames from 'classnames'; import { Avatar } from './Avatar'; import type { ActionSpec } from './ConfirmationDialog'; import { ConfirmationDialog } from './ConfirmationDialog'; import { InContactsIcon } from './InContactsIcon'; import { Modal } from './Modal'; import type { ConversationType } from '../state/ducks/conversations'; import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import type { LocalizerType } from '../types/Util'; import { ThemeType } from '../types/Util'; import { isInSystemContacts } from '../util/isInSystemContacts'; import { missingCaseError } from '../util/missingCaseError'; import { ContextMenu } from './ContextMenu'; import { Theme } from '../util/theme'; import { isNotNil } from '../util/isNotNil'; import { MY_STORY_ID } from '../types/Stories'; import type { UUIDStringType } from '../types/UUID'; export enum SafetyNumberChangeSource { Calling = 'Calling', MessageSend = 'MessageSend', Story = 'Story', } enum DialogState { StartingInReview = 'StartingInReview', ExplicitReviewNeeded = 'ExplicitReviewNeeded', ExplicitReviewStep = 'ExplicitReviewStep', ExplicitReviewComplete = 'ExplicitReviewComplete', } export type SafetyNumberProps = { contactID: string; onClose: () => void; }; type StoryContacts = { story?: { name: string; // For My Story or custom distribution lists, conversationId will be our own conversationId: string; // For Group stories, distributionId will not be provided distributionId?: string; }; contacts: Array<ConversationType>; }; export type ContactsByStory = Array<StoryContacts>; export type Props = Readonly<{ confirmText?: string; contacts: ContactsByStory; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; onCancel: () => void; onConfirm: () => void; removeFromStory?: ( distributionId: string, uuids: Array<UUIDStringType> ) => unknown; renderSafetyNumber: (props: SafetyNumberProps) => JSX.Element; theme: ThemeType; }>; function doesRequireExplicitReviewMode(count: number) { return count > 5; } function getStartingDialogState(count: number): DialogState { if (count === 0) { return DialogState.ExplicitReviewComplete; } if (doesRequireExplicitReviewMode(count)) { return DialogState.ExplicitReviewNeeded; } return DialogState.StartingInReview; } export function SafetyNumberChangeDialog({ confirmText, contacts, getPreferredBadge, i18n, onCancel, onConfirm, removeFromStory, renderSafetyNumber, theme, }: Props): JSX.Element { const totalCount = contacts.reduce( (count, item) => count + item.contacts.length, 0 ); const allVerified = contacts.every(item => item.contacts.every(contact => contact.isVerified) ); const [dialogState, setDialogState] = React.useState<DialogState>( getStartingDialogState(totalCount) ); const [selectedContact, setSelectedContact] = React.useState< ConversationType | undefined >(undefined); const cancelButtonRef = React.createRef<HTMLButtonElement>(); React.useEffect(() => { if (cancelButtonRef && cancelButtonRef.current) { cancelButtonRef.current.focus(); } }, [cancelButtonRef, contacts]); React.useEffect(() => { if ( dialogState === DialogState.ExplicitReviewStep && (totalCount === 0 || allVerified) ) { setDialogState(DialogState.ExplicitReviewComplete); } }, [allVerified, dialogState, setDialogState, totalCount]); const onClose = selectedContact ? () => { setSelectedContact(undefined); } : onCancel; if (selectedContact) { return ( <Modal modalName="SafetyNumberChangeDialog" hasXButton i18n={i18n} onClose={onClose} > {renderSafetyNumber({ contactID: selectedContact.id, onClose })} </Modal> ); } if ( dialogState === DialogState.StartingInReview || dialogState === DialogState.ExplicitReviewStep ) { let text: string; if (dialogState === DialogState.ExplicitReviewStep) { text = i18n('safetyNumberChangeDialog_done'); } else if (allVerified || totalCount === 0) { text = confirmText || i18n('safetyNumberChangeDialog_send'); } else { text = confirmText || i18n('sendAnyway'); } return ( <ConfirmationDialog key="SafetyNumberChangeDialog.reviewing" dialogName="SafetyNumberChangeDialog.reviewing" actions={[ { action: () => { if (dialogState === DialogState.ExplicitReviewStep) { setDialogState(DialogState.ExplicitReviewComplete); } else { onConfirm(); } }, text, style: 'affirmative', }, ]} hasXButton i18n={i18n} moduleClassName="module-SafetyNumberChangeDialog__confirm-dialog" noMouseClose onCancel={onClose} onClose={noop} > <div className="module-SafetyNumberChangeDialog__shield-icon" /> <div className="module-SafetyNumberChangeDialog__title"> {i18n('safetyNumberChanges')} </div> <div className="module-SafetyNumberChangeDialog__message"> {i18n('safetyNumberChangeDialog__message')} </div> {contacts.map((section: StoryContacts) => ( <ContactSection key={section.story?.name || 'default'} section={section} getPreferredBadge={getPreferredBadge} i18n={i18n} removeFromStory={removeFromStory} setSelectedContact={setSelectedContact} theme={theme} /> ))} </ConfirmationDialog> ); } let text: string; if (dialogState === DialogState.ExplicitReviewNeeded) { text = confirmText || i18n('sendAnyway'); } else if (dialogState === DialogState.ExplicitReviewComplete) { text = confirmText || i18n('safetyNumberChangeDialog_send'); } else { throw missingCaseError(dialogState); } const actions: Array<ActionSpec> = [ { action: onConfirm, text, style: 'affirmative', }, ]; if (dialogState === DialogState.ExplicitReviewNeeded) { actions.unshift({ action: () => setDialogState(DialogState.ExplicitReviewStep), text: i18n('safetyNumberChangeDialog__review'), }); } return ( <ConfirmationDialog key="SafetyNumberChangeDialog.manyContacts" dialogName="SafetyNumberChangeDialog.manyContacts" actions={actions} hasXButton i18n={i18n} moduleClassName="module-SafetyNumberChangeDialog__confirm-dialog" noMouseClose noDefaultCancelButton={dialogState === DialogState.ExplicitReviewNeeded} onCancel={onClose} onClose={noop} > <div className="module-SafetyNumberChangeDialog__shield-icon" /> <div className="module-SafetyNumberChangeDialog__title"> {i18n('safetyNumberChanges')} </div> <div className={classNames( 'module-SafetyNumberChangeDialog__message', dialogState === DialogState.ExplicitReviewComplete ? 'module-SafetyNumberChangeDialog__message--narrow' : undefined )} > {dialogState === DialogState.ExplicitReviewNeeded ? i18n('icu:safetyNumberChangeDialog__many-contacts', { count: totalCount, }) : i18n('safetyNumberChangeDialog__post-review')} </div> </ConfirmationDialog> ); } function ContactSection({ section, getPreferredBadge, i18n, removeFromStory, setSelectedContact, theme, }: Readonly<{ section: StoryContacts; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; removeFromStory?: ( distributionId: string, uuids: Array<UUIDStringType> ) => unknown; setSelectedContact: (contact: ConversationType) => void; theme: ThemeType; }>) { if (section.contacts.length === 0) { return null; } if (!section.story) { return ( <ul className="module-SafetyNumberChangeDialog__contacts"> {section.contacts.map((contact: ConversationType) => { const shouldShowNumber = Boolean(contact.name || contact.profileName); return ( <ContactRow key={contact.id} contact={contact} getPreferredBadge={getPreferredBadge} i18n={i18n} removeFromStory={removeFromStory} setSelectedContact={setSelectedContact} shouldShowNumber={shouldShowNumber} theme={theme} /> ); })} </ul> ); } const { distributionId } = section.story; const uuids = section.contacts.map(contact => contact.uuid).filter(isNotNil); const sectionName = distributionId === MY_STORY_ID ? i18n('Stories__mine') : section.story.name; return ( <div className="module-SafetyNumberChangeDialog__section"> <div className="module-SafetyNumberChangeDialog__row"> <div className="module-SafetyNumberChangeDialog__row__story-name"> {sectionName} </div> {distributionId && removeFromStory && uuids.length > 1 && ( <SectionButtonWithMenu ariaLabel={i18n('safetyNumberChangeDialog__actions-story', { story: sectionName, })} i18n={i18n} memberCount={uuids.length} storyName={sectionName} theme={theme} removeFromStory={() => { removeFromStory(distributionId, uuids); }} /> )} </div> <ul className="module-SafetyNumberChangeDialog__contacts"> {section.contacts.map((contact: ConversationType) => { const shouldShowNumber = Boolean(contact.name || contact.profileName); return ( <ContactRow key={contact.id} contact={contact} distributionId={distributionId} getPreferredBadge={getPreferredBadge} i18n={i18n} removeFromStory={removeFromStory} setSelectedContact={setSelectedContact} shouldShowNumber={shouldShowNumber} theme={theme} /> ); })} </ul> </div> ); } function SectionButtonWithMenu({ ariaLabel, i18n, removeFromStory, storyName, memberCount, theme, }: Readonly<{ ariaLabel: string; i18n: LocalizerType; removeFromStory: () => unknown; storyName: string; memberCount: number; theme: ThemeType; }>) { const [isConfirming, setIsConfirming] = React.useState<boolean>(false); return ( <> <ContextMenu ariaLabel={ariaLabel} i18n={i18n} menuOptions={[ { icon: 'module-SafetyNumberChangeDialog__menu-icon--delete', label: i18n('safetyNumberChangeDialog__remove-all'), onClick: () => setIsConfirming(true), }, ]} moduleClassName="module-SafetyNumberChangeDialog__row__chevron" theme={theme === ThemeType.dark ? Theme.Dark : Theme.Light} /> {isConfirming && ( <ConfirmationDialog key="SafetyNumberChangeDialog.confirm-remove-all" dialogName="SafetyNumberChangeDialog.confirm-remove-all" actions={[ { action: () => { removeFromStory(); setIsConfirming(false); }, text: i18n('safetyNumberChangeDialog__remove-all'), style: 'affirmative', }, ]} i18n={i18n} noMouseClose onCancel={() => setIsConfirming(false)} onClose={noop} > {i18n('icu:safetyNumberChangeDialog__confirm-remove-all', { story: storyName, count: memberCount, })} </ConfirmationDialog> )} </> ); } function ContactRow({ contact, distributionId, getPreferredBadge, i18n, removeFromStory, setSelectedContact, shouldShowNumber, theme, }: Readonly<{ contact: ConversationType; distributionId?: string; getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; removeFromStory?: ( distributionId: string, uuids: Array<UUIDStringType> ) => unknown; setSelectedContact: (contact: ConversationType) => void; shouldShowNumber: boolean; theme: ThemeType; }>) { const { uuid } = contact; return ( <li className="module-SafetyNumberChangeDialog__row" key={contact.id}> <Avatar acceptedMessageRequest={contact.acceptedMessageRequest} avatarPath={contact.avatarPath} badge={getPreferredBadge(contact.badges)} color={contact.color} conversationType="direct" i18n={i18n} isMe={contact.isMe} phoneNumber={contact.phoneNumber} profileName={contact.profileName} theme={theme} title={contact.title} sharedGroupNames={contact.sharedGroupNames} size={36} unblurredAvatarPath={contact.unblurredAvatarPath} /> <div className="module-SafetyNumberChangeDialog__row--wrapper"> <div className="module-SafetyNumberChangeDialog__row--name"> {contact.title} {isInSystemContacts(contact) && ( <span> {' '} <InContactsIcon i18n={i18n} /> </span> )} </div> {shouldShowNumber || contact.isVerified ? ( <div className="module-SafetyNumberChangeDialog__row--subtitle"> {shouldShowNumber && ( <span className="module-SafetyNumberChangeDialog__rtl-span"> {contact.phoneNumber} </span> )} {shouldShowNumber && contact.isVerified && ( <span className="module-SafetyNumberChangeDialog__rtl-span"> · </span> )} {contact.isVerified && ( <span className="module-SafetyNumberChangeDialog__rtl-span"> {i18n('verified')} </span> )} </div> ) : null} </div> {distributionId && removeFromStory && uuid ? ( <RowButtonWithMenu ariaLabel={i18n('safetyNumberChangeDialog__actions-contact', { contact: contact.title, })} i18n={i18n} theme={theme} removeFromStory={() => removeFromStory(distributionId, [uuid])} verifyContact={() => setSelectedContact(contact)} /> ) : ( <button className="module-SafetyNumberChangeDialog__row__view" onClick={() => { setSelectedContact(contact); }} tabIndex={0} type="button" > {i18n('view')} </button> )} </li> ); } function RowButtonWithMenu({ ariaLabel, i18n, removeFromStory, verifyContact, theme, }: Readonly<{ ariaLabel: string; i18n: LocalizerType; removeFromStory: () => unknown; verifyContact: () => unknown; theme: ThemeType; }>) { return ( <ContextMenu ariaLabel={ariaLabel} i18n={i18n} menuOptions={[ { icon: 'module-SafetyNumberChangeDialog__menu-icon--verify', label: i18n('safetyNumberChangeDialog__verify-number'), onClick: verifyContact, }, { icon: 'module-SafetyNumberChangeDialog__menu-icon--delete', label: i18n('safetyNumberChangeDialog__remove'), onClick: removeFromStory, }, ]} moduleClassName="module-SafetyNumberChangeDialog__row__chevron" theme={theme === ThemeType.dark ? Theme.Dark : Theme.Light} /> ); }