Group name spoofing warning

This commit is contained in:
Evan Hahn 2021-06-01 18:30:25 -05:00 committed by GitHub
parent 51b45ab275
commit 36c15fead4
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
20 changed files with 1312 additions and 215 deletions

View file

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { times } from 'lodash';
import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react';
import { setup as setupI18n } from '../../../js/modules/i18n';
@ -9,6 +10,7 @@ import enMessages from '../../../_locales/en/messages.json';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
import { ContactSpoofingType } from '../../util/contactSpoofing';
const i18n = setupI18n('en', enMessages);
@ -17,16 +19,49 @@ const story = storiesOf(
module
);
story.add('Default', () => (
const getCommonProps = () => ({
i18n,
onBlock: action('onBlock'),
onBlockAndReportSpam: action('onBlockAndReportSpam'),
onClose: action('onClose'),
onDelete: action('onDelete'),
onShowContactModal: action('onShowContactModal'),
onUnblock: action('onUnblock'),
removeMember: action('removeMember'),
});
story.add('Direct conversations with same title', () => (
<ContactSpoofingReviewDialog
i18n={i18n}
onBlock={action('onBlock')}
onBlockAndReportSpam={action('onBlockAndReportSpam')}
onClose={action('onClose')}
onDelete={action('onDelete')}
onShowContactModal={action('onShowContactModal')}
onUnblock={action('onUnblock')}
{...getCommonProps()}
type={ContactSpoofingType.DirectConversationWithSameTitle}
possiblyUnsafeConversation={getDefaultConversation()}
safeConversation={getDefaultConversation()}
/>
));
[false, true].forEach(areWeAdmin => {
story.add(
`Group conversation many group members${
areWeAdmin ? " (and we're an admin)" : ''
}`,
() => (
<ContactSpoofingReviewDialog
{...getCommonProps()}
type={ContactSpoofingType.MultipleGroupMembersWithSameTitle}
areWeAdmin={areWeAdmin}
collisionInfoByTitle={{
Alice: times(2, () => ({
oldName: 'Alicia',
conversation: getDefaultConversation({ title: 'Alice' }),
})),
Bob: times(3, () => ({
conversation: getDefaultConversation({ title: 'Bob' }),
})),
Charlie: times(5, () => ({
conversation: getDefaultConversation({ title: 'Charlie' }),
})),
}}
/>
)
);
});

View file

@ -1,7 +1,13 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent, useState } from 'react';
import React, {
FunctionComponent,
ReactChild,
ReactNode,
useState,
} from 'react';
import { concat, orderBy } from 'lodash';
import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
@ -9,65 +15,318 @@ import {
MessageRequestActionsConfirmation,
MessageRequestState,
} from './MessageRequestActionsConfirmation';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { Modal } from '../Modal';
import { RemoveGroupMemberConfirmationDialog } from './RemoveGroupMemberConfirmationDialog';
import { ContactSpoofingReviewDialogPerson } from './ContactSpoofingReviewDialogPerson';
import { Button, ButtonVariant } from '../Button';
import { Intl } from '../Intl';
import { Emojify } from './Emojify';
import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
type PropsType = {
i18n: LocalizerType;
onBlock: () => unknown;
onBlockAndReportSpam: () => unknown;
onBlock: (conversationId: string) => unknown;
onBlockAndReportSpam: (conversationId: string) => unknown;
onClose: () => void;
onDelete: () => unknown;
onDelete: (conversationId: string) => unknown;
onShowContactModal: (contactId: string) => unknown;
onUnblock: () => unknown;
possiblyUnsafeConversation: ConversationType;
safeConversation: ConversationType;
};
onUnblock: (conversationId: string) => unknown;
removeMember: (conversationId: string) => unknown;
} & (
| {
type: ContactSpoofingType.DirectConversationWithSameTitle;
possiblyUnsafeConversation: ConversationType;
safeConversation: ConversationType;
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
areWeAdmin: boolean;
collisionInfoByTitle: Record<
string,
Array<{
oldName?: string;
conversation: ConversationType;
}>
>;
}
);
export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> = ({
i18n,
onBlock,
onBlockAndReportSpam,
onClose,
onDelete,
onShowContactModal,
onUnblock,
possiblyUnsafeConversation,
safeConversation,
}) => {
assert(
possiblyUnsafeConversation.type === 'direct',
'<ContactSpoofingReviewDialog> expected a direct conversation for the "possibly unsafe" conversation'
);
assert(
safeConversation.type === 'direct',
'<ContactSpoofingReviewDialog> expected a direct conversation for the "safe" conversation'
);
enum ConfirmationStateType {
ConfirmingDelete,
ConfirmingBlock,
ConfirmingGroupRemoval,
}
const [messageRequestState, setMessageRequestState] = useState(
MessageRequestState.default
);
export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> = props => {
const {
i18n,
onBlock,
onBlockAndReportSpam,
onClose,
onDelete,
onShowContactModal,
onUnblock,
removeMember,
} = props;
if (messageRequestState !== MessageRequestState.default) {
return (
<MessageRequestActionsConfirmation
i18n={i18n}
onBlock={onBlock}
onBlockAndReportSpam={onBlockAndReportSpam}
onUnblock={onUnblock}
onDelete={onDelete}
name={possiblyUnsafeConversation.name}
profileName={possiblyUnsafeConversation.profileName}
phoneNumber={possiblyUnsafeConversation.phoneNumber}
title={possiblyUnsafeConversation.title}
conversationType="direct"
state={messageRequestState}
onChangeState={setMessageRequestState}
/>
);
const [confirmationState, setConfirmationState] = useState<
| undefined
| {
type: ConfirmationStateType;
affectedConversation: ConversationType;
}
>();
if (confirmationState) {
const { affectedConversation, type } = confirmationState;
switch (type) {
case ConfirmationStateType.ConfirmingDelete:
case ConfirmationStateType.ConfirmingBlock:
return (
<MessageRequestActionsConfirmation
i18n={i18n}
onBlock={() => {
onBlock(affectedConversation.id);
}}
onBlockAndReportSpam={() => {
onBlockAndReportSpam(affectedConversation.id);
}}
onUnblock={() => {
onUnblock(affectedConversation.id);
}}
onDelete={() => {
onDelete(affectedConversation.id);
}}
name={affectedConversation.name}
profileName={affectedConversation.profileName}
phoneNumber={affectedConversation.phoneNumber}
title={affectedConversation.title}
conversationType="direct"
state={
type === ConfirmationStateType.ConfirmingDelete
? MessageRequestState.deleting
: MessageRequestState.blocking
}
onChangeState={messageRequestState => {
switch (messageRequestState) {
case MessageRequestState.blocking:
setConfirmationState({
type: ConfirmationStateType.ConfirmingBlock,
affectedConversation,
});
break;
case MessageRequestState.deleting:
setConfirmationState({
type: ConfirmationStateType.ConfirmingDelete,
affectedConversation,
});
break;
case MessageRequestState.unblocking:
assert(
false,
'Got unexpected MessageRequestState.unblocking state. Clearing confiration state'
);
setConfirmationState(undefined);
break;
case MessageRequestState.default:
setConfirmationState(undefined);
break;
default:
throw missingCaseError(messageRequestState);
}
}}
/>
);
case ConfirmationStateType.ConfirmingGroupRemoval:
return (
<RemoveGroupMemberConfirmationDialog
conversation={affectedConversation}
i18n={i18n}
onClose={() => {
setConfirmationState(undefined);
}}
onRemove={() => {
removeMember(affectedConversation.id);
}}
/>
);
default:
throw missingCaseError(type);
}
}
let title: string;
let contents: ReactChild;
switch (props.type) {
case ContactSpoofingType.DirectConversationWithSameTitle: {
const { possiblyUnsafeConversation, safeConversation } = props;
assert(
possiblyUnsafeConversation.type === 'direct',
'<ContactSpoofingReviewDialog> expected a direct conversation for the "possibly unsafe" conversation'
);
assert(
safeConversation.type === 'direct',
'<ContactSpoofingReviewDialog> expected a direct conversation for the "safe" conversation'
);
title = i18n('ContactSpoofingReviewDialog__title');
contents = (
<>
<p>{i18n('ContactSpoofingReviewDialog__description')}</p>
<h2>{i18n('ContactSpoofingReviewDialog__possibly-unsafe-title')}</h2>
<ContactSpoofingReviewDialogPerson
conversation={possiblyUnsafeConversation}
i18n={i18n}
>
<div className="module-ContactSpoofingReviewDialog__buttons">
<Button
variant={ButtonVariant.SecondaryDestructive}
onClick={() => {
setConfirmationState({
type: ConfirmationStateType.ConfirmingDelete,
affectedConversation: possiblyUnsafeConversation,
});
}}
>
{i18n('MessageRequests--delete')}
</Button>
<Button
variant={ButtonVariant.SecondaryDestructive}
onClick={() => {
setConfirmationState({
type: ConfirmationStateType.ConfirmingBlock,
affectedConversation: possiblyUnsafeConversation,
});
}}
>
{i18n('MessageRequests--block')}
</Button>
</div>
</ContactSpoofingReviewDialogPerson>
<hr />
<h2>{i18n('ContactSpoofingReviewDialog__safe-title')}</h2>
<ContactSpoofingReviewDialogPerson
conversation={safeConversation}
i18n={i18n}
onClick={() => {
onShowContactModal(safeConversation.id);
}}
/>
</>
);
break;
}
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
const { areWeAdmin, collisionInfoByTitle } = props;
const unsortedConversationInfos = concat(
// This empty array exists to appease Lodash's type definitions.
[],
...Object.values(collisionInfoByTitle)
);
const conversationInfos = orderBy(unsortedConversationInfos, [
// We normally use an `Intl.Collator` to sort by title. We do this instead, as we
// only really care about stability (not perfect ordering).
'title',
'id',
]);
title = i18n('ContactSpoofingReviewDialog__group__title');
contents = (
<>
<p>
{i18n('ContactSpoofingReviewDialog__group__description', [
conversationInfos.length.toString(),
])}
</p>
<h2>{i18n('ContactSpoofingReviewDialog__group__members-header')}</h2>
{conversationInfos.map((conversationInfo, index) => {
let button: ReactNode;
if (areWeAdmin) {
button = (
<Button
variant={ButtonVariant.SecondaryAffirmative}
onClick={() => {
setConfirmationState({
type: ConfirmationStateType.ConfirmingGroupRemoval,
affectedConversation: conversationInfo.conversation,
});
}}
>
{i18n('RemoveGroupMemberConfirmation__remove-button')}
</Button>
);
} else if (conversationInfo.conversation.isBlocked) {
button = (
<Button
variant={ButtonVariant.SecondaryAffirmative}
onClick={() => {
onUnblock(conversationInfo.conversation.id);
}}
>
{i18n('MessageRequests--unblock')}
</Button>
);
} else if (!conversationInfo.conversation.name) {
button = (
<Button
variant={ButtonVariant.SecondaryDestructive}
onClick={() => {
setConfirmationState({
type: ConfirmationStateType.ConfirmingBlock,
affectedConversation: conversationInfo.conversation,
});
}}
>
{i18n('MessageRequests--block')}
</Button>
);
}
const { oldName } = conversationInfo;
const newName =
conversationInfo.conversation.profileName ||
conversationInfo.conversation.title;
return (
<>
{index !== 0 && <hr />}
<ContactSpoofingReviewDialogPerson
key={conversationInfo.conversation.id}
conversation={conversationInfo.conversation}
i18n={i18n}
>
{Boolean(oldName) && oldName !== newName && (
<div className="module-ContactSpoofingReviewDialogPerson__info__property module-ContactSpoofingReviewDialogPerson__info__property--callout">
<Intl
i18n={i18n}
id="ContactSpoofingReviewDialog__group__name-change-info"
components={{
oldName: <Emojify text={oldName} />,
newName: <Emojify text={newName} />,
}}
/>
</div>
)}
{button && (
<div className="module-ContactSpoofingReviewDialog__buttons">
{button}
</div>
)}
</ContactSpoofingReviewDialogPerson>
</>
);
})}
</>
);
break;
}
default:
throw missingCaseError(props);
}
return (
@ -76,42 +335,9 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> = ({
i18n={i18n}
moduleClassName="module-ContactSpoofingReviewDialog"
onClose={onClose}
title={i18n('ContactSpoofingReviewDialog__title')}
title={title}
>
<p>{i18n('ContactSpoofingReviewDialog__description')}</p>
<h2>{i18n('ContactSpoofingReviewDialog__possibly-unsafe-title')}</h2>
<ContactSpoofingReviewDialogPerson
conversation={possiblyUnsafeConversation}
i18n={i18n}
>
<div className="module-ContactSpoofingReviewDialog__buttons">
<Button
variant={ButtonVariant.SecondaryDestructive}
onClick={() => {
setMessageRequestState(MessageRequestState.deleting);
}}
>
{i18n('MessageRequests--delete')}
</Button>
<Button
variant={ButtonVariant.SecondaryDestructive}
onClick={() => {
setMessageRequestState(MessageRequestState.blocking);
}}
>
{i18n('MessageRequests--block')}
</Button>
</div>
</ContactSpoofingReviewDialogPerson>
<hr />
<h2>{i18n('ContactSpoofingReviewDialog__safe-title')}</h2>
<ContactSpoofingReviewDialogPerson
conversation={safeConversation}
i18n={i18n}
onClick={() => {
onShowContactModal(safeConversation.id);
}}
/>
{contents}
</Modal>
);
};

View file

@ -0,0 +1,52 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { FunctionComponent } from 'react';
import { ConversationType } from '../../state/ducks/conversations';
import { LocalizerType } from '../../types/Util';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { Intl } from '../Intl';
import { ContactName } from './ContactName';
type PropsType = {
conversation: ConversationType;
i18n: LocalizerType;
onClose: () => void;
onRemove: () => void;
};
export const RemoveGroupMemberConfirmationDialog: FunctionComponent<PropsType> = ({
conversation,
i18n,
onClose,
onRemove,
}) => (
<ConfirmationDialog
actions={[
{
action: onRemove,
text: i18n('RemoveGroupMemberConfirmation__remove-button'),
style: 'negative',
},
]}
i18n={i18n}
onClose={onClose}
title={
<Intl
i18n={i18n}
id="RemoveGroupMemberConfirmation__description"
components={{
name: (
<ContactName
firstName={conversation.firstName}
i18n={i18n}
title={conversation.title}
/>
),
}}
/>
}
/>
);

View file

@ -2,6 +2,8 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import { times } from 'lodash';
import { v4 as uuid } from 'uuid';
import { storiesOf } from '@storybook/react';
import { text, boolean, number } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions';
@ -15,6 +17,7 @@ import { getDefaultConversation } from '../../test-both/helpers/getDefaultConver
import { LastSeenIndicator } from './LastSeenIndicator';
import { TimelineLoadingRow } from './TimelineLoadingRow';
import { TypingBubble } from './TypingBubble';
import { ContactSpoofingType } from '../../util/contactSpoofing';
const i18n = setupI18n('en', enMessages);
@ -224,6 +227,9 @@ const items: Record<string, TimelineItemType> = {
} as any;
const actions = () => ({
acknowledgeGroupMemberNameCollisions: action(
'acknowledgeGroupMemberNameCollisions'
),
clearChangedMessages: action('clearChangedMessages'),
clearInvitedConversationsForNewlyCreatedGroup: action(
'clearInvitedConversationsForNewlyCreatedGroup'
@ -275,6 +281,7 @@ const actions = () => ({
contactSupport: action('contactSupport'),
closeContactSpoofingReview: action('closeContactSpoofingReview'),
reviewGroupMemberNameCollision: action('reviewGroupMemberNameCollision'),
reviewMessageRequestNameCollision: action(
'reviewMessageRequestNameCollision'
),
@ -283,6 +290,7 @@ const actions = () => ({
onBlockAndReportSpam: action('onBlockAndReportSpam'),
onDelete: action('onDelete'),
onUnblock: action('onUnblock'),
removeMember: action('removeMember'),
unblurAvatar: action('unblurAvatar'),
});
@ -374,7 +382,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
overrideProps.invitedContactsForNewlyCreatedGroup || [],
warning: overrideProps.warning,
id: '',
id: uuid(),
renderItem,
renderLastSeenIndicator,
renderHeroRow,
@ -478,9 +486,10 @@ story.add('With invited contacts for a newly-created group', () => {
return <Timeline {...props} />;
});
story.add('With "same name" warning', () => {
story.add('With "same name in direct conversation" warning', () => {
const props = createProps({
warning: {
type: ContactSpoofingType.DirectConversationWithSameTitle,
safeConversation: getDefaultConversation(),
},
items: [],
@ -488,3 +497,19 @@ story.add('With "same name" warning', () => {
return <Timeline {...props} />;
});
story.add('With "same name in group conversation" warning', () => {
const props = createProps({
warning: {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
acknowledgedGroupNameCollisions: {},
groupNameCollisions: {
Alice: times(2, () => uuid()),
Bob: times(3, () => uuid()),
},
},
items: [],
});
return <Timeline {...props} />;
});

View file

@ -3,7 +3,7 @@
import { debounce, get, isNumber } from 'lodash';
import classNames from 'classnames';
import React, { CSSProperties, ReactNode } from 'react';
import React, { CSSProperties, ReactChild, ReactNode } from 'react';
import {
AutoSizer,
CellMeasurer,
@ -20,6 +20,7 @@ import { GlobalAudioProvider } from '../GlobalAudioContext';
import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations';
import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import { PropsActions as MessageActionsType } from './Message';
import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
@ -27,7 +28,12 @@ import { Intl } from '../Intl';
import { TimelineWarning } from './TimelineWarning';
import { TimelineWarnings } from './TimelineWarnings';
import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
import {
GroupNameCollisionsWithIdsByTitle,
hasUnacknowledgedCollisions,
} from '../../util/groupMemberNameCollisions';
const AT_BOTTOM_THRESHOLD = 15;
const NEAR_BOTTOM_THRESHOLD = 15;
@ -36,9 +42,33 @@ const LOAD_MORE_THRESHOLD = 30;
const SCROLL_DOWN_BUTTON_THRESHOLD = 8;
export const LOAD_COUNTDOWN = 1;
export type WarningType = {
safeConversation: ConversationType;
};
export type WarningType =
| {
type: ContactSpoofingType.DirectConversationWithSameTitle;
safeConversation: ConversationType;
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
acknowledgedGroupNameCollisions: GroupNameCollisionsWithIdsByTitle;
groupNameCollisions: GroupNameCollisionsWithIdsByTitle;
};
export type ContactSpoofingReviewPropType =
| {
type: ContactSpoofingType.DirectConversationWithSameTitle;
possiblyUnsafeConversation: ConversationType;
safeConversation: ConversationType;
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
collisionInfoByTitle: Record<
string,
Array<{
oldName?: string;
conversation: ConversationType;
}>
>;
};
export type PropsDataType = {
haveNewest: boolean;
@ -57,6 +87,7 @@ export type PropsDataType = {
type PropsHousekeepingType = {
id: string;
areWeAdmin?: boolean;
isGroupV1AndDisabled?: boolean;
isIncomingMessageRequest: boolean;
typingContact?: unknown;
@ -66,10 +97,7 @@ type PropsHousekeepingType = {
invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
warning?: WarningType;
contactSpoofingReview?: {
possiblyUnsafeConversation: ConversationType;
safeConversation: ConversationType;
};
contactSpoofingReview?: ContactSpoofingReviewPropType;
i18n: LocalizerType;
@ -90,6 +118,9 @@ type PropsHousekeepingType = {
};
type PropsActionsType = {
acknowledgeGroupMemberNameCollisions: (
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
) => void;
clearChangedMessages: (conversationId: string) => unknown;
clearInvitedConversationsForNewlyCreatedGroup: () => void;
closeContactSpoofingReview: () => void;
@ -98,6 +129,7 @@ type PropsActionsType = {
loadCountdownStart?: number
) => unknown;
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown;
reviewGroupMemberNameCollision: (groupConversationId: string) => void;
reviewMessageRequestNameCollision: (
_: Readonly<{
safeConversationId: string;
@ -109,10 +141,11 @@ type PropsActionsType = {
loadNewerMessages: (messageId: string) => unknown;
loadNewestMessages: (messageId: string, setFocus?: boolean) => unknown;
markMessageRead: (messageId: string) => unknown;
onBlock: () => unknown;
onBlockAndReportSpam: () => unknown;
onDelete: () => unknown;
onUnblock: () => unknown;
onBlock: (conversationId: string) => unknown;
onBlockAndReportSpam: (conversationId: string) => unknown;
onDelete: (conversationId: string) => unknown;
onUnblock: (conversationId: string) => unknown;
removeMember: (conversationId: string) => unknown;
selectMessage: (messageId: string, conversationId: string) => unknown;
clearSelectedMessage: () => unknown;
unblurAvatar: () => void;
@ -172,7 +205,7 @@ type StateType = {
shouldShowScrollDownButton: boolean;
areUnreadBelowCurrentPosition: boolean;
hasDismissedWarning: boolean;
hasDismissedDirectContactSpoofingWarning: boolean;
lastMeasuredWarningHeight: number;
};
@ -215,7 +248,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
prevPropScrollToIndex: scrollToIndex,
shouldShowScrollDownButton: false,
areUnreadBelowCurrentPosition: false,
hasDismissedWarning: false,
hasDismissedDirectContactSpoofingWarning: false,
lastMeasuredWarningHeight: 0,
};
}
@ -892,7 +925,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
// Warnings can increase the size of the first row (adding padding for the floating
// warning), so we recompute it when the warnings change.
const hadWarning = Boolean(
prevProps.warning && !prevState.hasDismissedWarning
prevProps.warning && !prevState.hasDismissedDirectContactSpoofingWarning
);
if (hadWarning !== Boolean(this.getWarning())) {
this.recomputeRowHeights(0);
@ -1159,6 +1192,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
public render(): JSX.Element | null {
const {
acknowledgeGroupMemberNameCollisions,
areWeAdmin,
clearInvitedConversationsForNewlyCreatedGroup,
closeContactSpoofingReview,
contactSpoofingReview,
@ -1172,6 +1207,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
onDelete,
onUnblock,
showContactModal,
removeMember,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision,
} = this.props;
const {
@ -1227,6 +1264,69 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
const warning = this.getWarning();
let timelineWarning: ReactNode;
if (warning) {
let text: ReactChild;
let onClose: () => void;
switch (warning.type) {
case ContactSpoofingType.DirectConversationWithSameTitle:
text = (
<Intl
i18n={i18n}
id="ContactSpoofing__same-name"
components={{
link: (
<TimelineWarning.Link
onClick={() => {
reviewMessageRequestNameCollision({
safeConversationId: warning.safeConversation.id,
});
}}
>
{i18n('ContactSpoofing__same-name__link')}
</TimelineWarning.Link>
),
}}
/>
);
onClose = () => {
this.setState({
hasDismissedDirectContactSpoofingWarning: true,
});
};
break;
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
const { groupNameCollisions } = warning;
text = (
<Intl
i18n={i18n}
id="ContactSpoofing__same-name-in-group"
components={{
count: Object.values(groupNameCollisions)
.reduce(
(result, conversations) => result + conversations.length,
0
)
.toString(),
link: (
<TimelineWarning.Link
onClick={() => {
reviewGroupMemberNameCollision(id);
}}
>
{i18n('ContactSpoofing__same-name-in-group__link')}
</TimelineWarning.Link>
),
}}
/>
);
onClose = () => {
acknowledgeGroupMemberNameCollisions(groupNameCollisions);
};
break;
}
default:
throw missingCaseError(warning);
}
timelineWarning = (
<Measure
bounds
@ -1240,34 +1340,11 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
>
{({ measureRef }) => (
<TimelineWarnings ref={measureRef}>
<TimelineWarning
i18n={i18n}
onClose={() => {
this.setState({ hasDismissedWarning: true });
}}
>
<TimelineWarning i18n={i18n} onClose={onClose}>
<TimelineWarning.IconContainer>
<TimelineWarning.GenericIcon />
</TimelineWarning.IconContainer>
<TimelineWarning.Text>
<Intl
i18n={i18n}
id="ContactSpoofing__same-name"
components={{
link: (
<TimelineWarning.Link
onClick={() => {
reviewMessageRequestNameCollision({
safeConversationId: warning.safeConversation.id,
});
}}
>
{i18n('ContactSpoofing__same-name__link')}
</TimelineWarning.Link>
),
}}
/>
</TimelineWarning.Text>
<TimelineWarning.Text>{text}</TimelineWarning.Text>
</TimelineWarning>
</TimelineWarnings>
)}
@ -1275,6 +1352,47 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
);
}
let contactSpoofingReviewDialog: ReactNode;
if (contactSpoofingReview) {
const commonProps = {
i18n,
onBlock,
onBlockAndReportSpam,
onClose: closeContactSpoofingReview,
onDelete,
onShowContactModal: showContactModal,
onUnblock,
removeMember,
};
switch (contactSpoofingReview.type) {
case ContactSpoofingType.DirectConversationWithSameTitle:
contactSpoofingReviewDialog = (
<ContactSpoofingReviewDialog
{...commonProps}
type={ContactSpoofingType.DirectConversationWithSameTitle}
possiblyUnsafeConversation={
contactSpoofingReview.possiblyUnsafeConversation
}
safeConversation={contactSpoofingReview.safeConversation}
/>
);
break;
case ContactSpoofingType.MultipleGroupMembersWithSameTitle:
contactSpoofingReviewDialog = (
<ContactSpoofingReviewDialog
{...commonProps}
type={ContactSpoofingType.MultipleGroupMembersWithSameTitle}
areWeAdmin={Boolean(areWeAdmin)}
collisionInfoByTitle={contactSpoofingReview.collisionInfoByTitle}
/>
);
break;
default:
throw missingCaseError(contactSpoofingReview);
}
}
return (
<>
<div
@ -1310,32 +1428,31 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
/>
)}
{contactSpoofingReview && (
<ContactSpoofingReviewDialog
i18n={i18n}
onBlock={onBlock}
onBlockAndReportSpam={onBlockAndReportSpam}
onClose={closeContactSpoofingReview}
onDelete={onDelete}
onShowContactModal={showContactModal}
onUnblock={onUnblock}
possiblyUnsafeConversation={
contactSpoofingReview.possiblyUnsafeConversation
}
safeConversation={contactSpoofingReview.safeConversation}
/>
)}
{contactSpoofingReviewDialog}
</>
);
}
private getWarning(): undefined | WarningType {
const { hasDismissedWarning } = this.state;
if (hasDismissedWarning) {
const { warning } = this.props;
if (!warning) {
return undefined;
}
const { warning } = this.props;
return warning;
switch (warning.type) {
case ContactSpoofingType.DirectConversationWithSameTitle: {
const { hasDismissedDirectContactSpoofingWarning } = this.state;
return hasDismissedDirectContactSpoofingWarning ? undefined : warning;
}
case ContactSpoofingType.MultipleGroupMembersWithSameTitle:
return hasUnacknowledgedCollisions(
warning.acknowledgedGroupNameCollisions,
warning.groupNameCollisions
)
? warning
: undefined;
default:
throw missingCaseError(warning);
}
}
}