551 lines
16 KiB
TypeScript
551 lines
16 KiB
TypeScript
// Copyright 2020 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, AvatarSize } 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={AvatarSize.THIRTY_TWO}
|
|
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}
|
|
/>
|
|
);
|
|
}
|