Group name spoofing warning
This commit is contained in:
parent
51b45ab275
commit
36c15fead4
20 changed files with 1312 additions and 215 deletions
|
@ -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' }),
|
||||
})),
|
||||
}}
|
||||
/>
|
||||
)
|
||||
);
|
||||
});
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
|
@ -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}
|
||||
/>
|
||||
),
|
||||
}}
|
||||
/>
|
||||
}
|
||||
/>
|
||||
);
|
|
@ -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} />;
|
||||
});
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue