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

@ -5273,10 +5273,28 @@
} }
} }
}, },
"ContactSpoofing__same-name-in-group": {
"message": "$count$ group members have the same name. $link$",
"description": "Shown in the timeline warning when you multiple group members have the same name",
"placeholders": {
"count": {
"content": "$1",
"example": "3"
},
"link": {
"content": "$2",
"example": "Tap to review"
}
}
},
"ContactSpoofing__same-name__link": { "ContactSpoofing__same-name__link": {
"message": "Review request", "message": "Review request",
"description": "Shown in the timeline warning when you have a message request from someone with the same name as someone else" "description": "Shown in the timeline warning when you have a message request from someone with the same name as someone else"
}, },
"ContactSpoofing__same-name-in-group__link": {
"message": "Click to review",
"description": "Shown in the timeline warning when you multiple group members have the same name"
},
"ContactSpoofingReviewDialog__title": { "ContactSpoofingReviewDialog__title": {
"message": "Review request", "message": "Review request",
"description": "Title for the contact name spoofing review dialog" "description": "Title for the contact name spoofing review dialog"
@ -5293,6 +5311,46 @@
"message": "Your contact", "message": "Your contact",
"description": "Header in the contact spoofing review dialog, shown above the \"safe\" user" "description": "Header in the contact spoofing review dialog, shown above the \"safe\" user"
}, },
"ContactSpoofingReviewDialog__group__title": {
"message": "Review members",
"description": "Title for the contact name spoofing review dialog in groups"
},
"ContactSpoofingReviewDialog__group__description": {
"message": "$count$ group members have similar names. Review the members below or choose to take action.",
"description": "Description for the group contact spoofing review dialog"
},
"ContactSpoofingReviewDialog__group__members-header": {
"message": "Members",
"description": "Header in the group contact spoofing review dialog. After this header, there will be a list of members"
},
"ContactSpoofingReviewDialog__group__name-change-info": {
"message": "Recently changed their profile name from $oldName$ to $newName$",
"description": "In the group contact spoofing review dialog, this text is shown when someone has changed their name recently",
"placeholders": {
"oldName": {
"content": "$1",
"example": "Jane Doe"
},
"newName": {
"content": "$2",
"example": "Doe Jane"
}
}
},
"RemoveGroupMemberConfirmation__remove-button": {
"message": "Remove from group",
"description": "When confirming the removal of a group member, show this text in the button"
},
"RemoveGroupMemberConfirmation__description": {
"message": "Remove \"$name$\" from the group?",
"description": "When confirming the removal of a group member, show this text in the dialog",
"placeholders": {
"name": {
"content": "$1",
"example": "Jane Doe"
}
}
},
"CaptchaDialog__title": { "CaptchaDialog__title": {
"message": "Verify to continue messaging", "message": "Verify to continue messaging",
"description": "Header in the captcha dialog" "description": "Header in the captcha dialog"

View File

@ -22,6 +22,11 @@
@include dark-theme { @include dark-theme {
color: $color-gray-05; color: $color-gray-05;
} }
&--callout {
@include font-body-2-italic;
margin: 12px 0;
}
} }
} }
} }

View File

@ -2,6 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React from 'react';
import { times } from 'lodash';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { setup as setupI18n } from '../../../js/modules/i18n'; 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 { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog'; import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
import { ContactSpoofingType } from '../../util/contactSpoofing';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -17,16 +19,49 @@ const story = storiesOf(
module 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 <ContactSpoofingReviewDialog
i18n={i18n} {...getCommonProps()}
onBlock={action('onBlock')} type={ContactSpoofingType.DirectConversationWithSameTitle}
onBlockAndReportSpam={action('onBlockAndReportSpam')}
onClose={action('onClose')}
onDelete={action('onDelete')}
onShowContactModal={action('onShowContactModal')}
onUnblock={action('onUnblock')}
possiblyUnsafeConversation={getDefaultConversation()} possiblyUnsafeConversation={getDefaultConversation()}
safeConversation={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 // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations'; import { ConversationType } from '../../state/ducks/conversations';
@ -9,65 +15,318 @@ import {
MessageRequestActionsConfirmation, MessageRequestActionsConfirmation,
MessageRequestState, MessageRequestState,
} from './MessageRequestActionsConfirmation'; } from './MessageRequestActionsConfirmation';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { Modal } from '../Modal'; import { Modal } from '../Modal';
import { RemoveGroupMemberConfirmationDialog } from './RemoveGroupMemberConfirmationDialog';
import { ContactSpoofingReviewDialogPerson } from './ContactSpoofingReviewDialogPerson'; import { ContactSpoofingReviewDialogPerson } from './ContactSpoofingReviewDialogPerson';
import { Button, ButtonVariant } from '../Button'; import { Button, ButtonVariant } from '../Button';
import { Intl } from '../Intl';
import { Emojify } from './Emojify';
import { assert } from '../../util/assert'; import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
type PropsType = { type PropsType = {
i18n: LocalizerType; i18n: LocalizerType;
onBlock: () => unknown; onBlock: (conversationId: string) => unknown;
onBlockAndReportSpam: () => unknown; onBlockAndReportSpam: (conversationId: string) => unknown;
onClose: () => void; onClose: () => void;
onDelete: () => unknown; onDelete: (conversationId: string) => unknown;
onShowContactModal: (contactId: string) => unknown; onShowContactModal: (contactId: string) => unknown;
onUnblock: () => unknown; onUnblock: (conversationId: string) => unknown;
possiblyUnsafeConversation: ConversationType; removeMember: (conversationId: string) => unknown;
safeConversation: ConversationType; } & (
}; | {
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> = ({ enum ConfirmationStateType {
i18n, ConfirmingDelete,
onBlock, ConfirmingBlock,
onBlockAndReportSpam, ConfirmingGroupRemoval,
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'
);
const [messageRequestState, setMessageRequestState] = useState( export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> = props => {
MessageRequestState.default const {
); i18n,
onBlock,
onBlockAndReportSpam,
onClose,
onDelete,
onShowContactModal,
onUnblock,
removeMember,
} = props;
if (messageRequestState !== MessageRequestState.default) { const [confirmationState, setConfirmationState] = useState<
return ( | undefined
<MessageRequestActionsConfirmation | {
i18n={i18n} type: ConfirmationStateType;
onBlock={onBlock} affectedConversation: ConversationType;
onBlockAndReportSpam={onBlockAndReportSpam} }
onUnblock={onUnblock} >();
onDelete={onDelete}
name={possiblyUnsafeConversation.name} if (confirmationState) {
profileName={possiblyUnsafeConversation.profileName} const { affectedConversation, type } = confirmationState;
phoneNumber={possiblyUnsafeConversation.phoneNumber} switch (type) {
title={possiblyUnsafeConversation.title} case ConfirmationStateType.ConfirmingDelete:
conversationType="direct" case ConfirmationStateType.ConfirmingBlock:
state={messageRequestState} return (
onChangeState={setMessageRequestState} <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 ( return (
@ -76,42 +335,9 @@ export const ContactSpoofingReviewDialog: FunctionComponent<PropsType> = ({
i18n={i18n} i18n={i18n}
moduleClassName="module-ContactSpoofingReviewDialog" moduleClassName="module-ContactSpoofingReviewDialog"
onClose={onClose} onClose={onClose}
title={i18n('ContactSpoofingReviewDialog__title')} title={title}
> >
<p>{i18n('ContactSpoofingReviewDialog__description')}</p> {contents}
<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);
}}
/>
</Modal> </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 // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import { times } from 'lodash';
import { v4 as uuid } from 'uuid';
import { storiesOf } from '@storybook/react'; import { storiesOf } from '@storybook/react';
import { text, boolean, number } from '@storybook/addon-knobs'; import { text, boolean, number } from '@storybook/addon-knobs';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
@ -15,6 +17,7 @@ import { getDefaultConversation } from '../../test-both/helpers/getDefaultConver
import { LastSeenIndicator } from './LastSeenIndicator'; import { LastSeenIndicator } from './LastSeenIndicator';
import { TimelineLoadingRow } from './TimelineLoadingRow'; import { TimelineLoadingRow } from './TimelineLoadingRow';
import { TypingBubble } from './TypingBubble'; import { TypingBubble } from './TypingBubble';
import { ContactSpoofingType } from '../../util/contactSpoofing';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
@ -224,6 +227,9 @@ const items: Record<string, TimelineItemType> = {
} as any; } as any;
const actions = () => ({ const actions = () => ({
acknowledgeGroupMemberNameCollisions: action(
'acknowledgeGroupMemberNameCollisions'
),
clearChangedMessages: action('clearChangedMessages'), clearChangedMessages: action('clearChangedMessages'),
clearInvitedConversationsForNewlyCreatedGroup: action( clearInvitedConversationsForNewlyCreatedGroup: action(
'clearInvitedConversationsForNewlyCreatedGroup' 'clearInvitedConversationsForNewlyCreatedGroup'
@ -275,6 +281,7 @@ const actions = () => ({
contactSupport: action('contactSupport'), contactSupport: action('contactSupport'),
closeContactSpoofingReview: action('closeContactSpoofingReview'), closeContactSpoofingReview: action('closeContactSpoofingReview'),
reviewGroupMemberNameCollision: action('reviewGroupMemberNameCollision'),
reviewMessageRequestNameCollision: action( reviewMessageRequestNameCollision: action(
'reviewMessageRequestNameCollision' 'reviewMessageRequestNameCollision'
), ),
@ -283,6 +290,7 @@ const actions = () => ({
onBlockAndReportSpam: action('onBlockAndReportSpam'), onBlockAndReportSpam: action('onBlockAndReportSpam'),
onDelete: action('onDelete'), onDelete: action('onDelete'),
onUnblock: action('onUnblock'), onUnblock: action('onUnblock'),
removeMember: action('removeMember'),
unblurAvatar: action('unblurAvatar'), unblurAvatar: action('unblurAvatar'),
}); });
@ -374,7 +382,7 @@ const createProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
overrideProps.invitedContactsForNewlyCreatedGroup || [], overrideProps.invitedContactsForNewlyCreatedGroup || [],
warning: overrideProps.warning, warning: overrideProps.warning,
id: '', id: uuid(),
renderItem, renderItem,
renderLastSeenIndicator, renderLastSeenIndicator,
renderHeroRow, renderHeroRow,
@ -478,9 +486,10 @@ story.add('With invited contacts for a newly-created group', () => {
return <Timeline {...props} />; return <Timeline {...props} />;
}); });
story.add('With "same name" warning', () => { story.add('With "same name in direct conversation" warning', () => {
const props = createProps({ const props = createProps({
warning: { warning: {
type: ContactSpoofingType.DirectConversationWithSameTitle,
safeConversation: getDefaultConversation(), safeConversation: getDefaultConversation(),
}, },
items: [], items: [],
@ -488,3 +497,19 @@ story.add('With "same name" warning', () => {
return <Timeline {...props} />; 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 { debounce, get, isNumber } from 'lodash';
import classNames from 'classnames'; import classNames from 'classnames';
import React, { CSSProperties, ReactNode } from 'react'; import React, { CSSProperties, ReactChild, ReactNode } from 'react';
import { import {
AutoSizer, AutoSizer,
CellMeasurer, CellMeasurer,
@ -20,6 +20,7 @@ import { GlobalAudioProvider } from '../GlobalAudioContext';
import { LocalizerType } from '../../types/Util'; import { LocalizerType } from '../../types/Util';
import { ConversationType } from '../../state/ducks/conversations'; import { ConversationType } from '../../state/ducks/conversations';
import { assert } from '../../util/assert'; import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import { PropsActions as MessageActionsType } from './Message'; import { PropsActions as MessageActionsType } from './Message';
import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification'; import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification';
@ -27,7 +28,12 @@ import { Intl } from '../Intl';
import { TimelineWarning } from './TimelineWarning'; import { TimelineWarning } from './TimelineWarning';
import { TimelineWarnings } from './TimelineWarnings'; import { TimelineWarnings } from './TimelineWarnings';
import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog'; import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog'; import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
import {
GroupNameCollisionsWithIdsByTitle,
hasUnacknowledgedCollisions,
} from '../../util/groupMemberNameCollisions';
const AT_BOTTOM_THRESHOLD = 15; const AT_BOTTOM_THRESHOLD = 15;
const NEAR_BOTTOM_THRESHOLD = 15; const NEAR_BOTTOM_THRESHOLD = 15;
@ -36,9 +42,33 @@ const LOAD_MORE_THRESHOLD = 30;
const SCROLL_DOWN_BUTTON_THRESHOLD = 8; const SCROLL_DOWN_BUTTON_THRESHOLD = 8;
export const LOAD_COUNTDOWN = 1; export const LOAD_COUNTDOWN = 1;
export type WarningType = { export type WarningType =
safeConversation: ConversationType; | {
}; 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 = { export type PropsDataType = {
haveNewest: boolean; haveNewest: boolean;
@ -57,6 +87,7 @@ export type PropsDataType = {
type PropsHousekeepingType = { type PropsHousekeepingType = {
id: string; id: string;
areWeAdmin?: boolean;
isGroupV1AndDisabled?: boolean; isGroupV1AndDisabled?: boolean;
isIncomingMessageRequest: boolean; isIncomingMessageRequest: boolean;
typingContact?: unknown; typingContact?: unknown;
@ -66,10 +97,7 @@ type PropsHousekeepingType = {
invitedContactsForNewlyCreatedGroup: Array<ConversationType>; invitedContactsForNewlyCreatedGroup: Array<ConversationType>;
warning?: WarningType; warning?: WarningType;
contactSpoofingReview?: { contactSpoofingReview?: ContactSpoofingReviewPropType;
possiblyUnsafeConversation: ConversationType;
safeConversation: ConversationType;
};
i18n: LocalizerType; i18n: LocalizerType;
@ -90,6 +118,9 @@ type PropsHousekeepingType = {
}; };
type PropsActionsType = { type PropsActionsType = {
acknowledgeGroupMemberNameCollisions: (
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
) => void;
clearChangedMessages: (conversationId: string) => unknown; clearChangedMessages: (conversationId: string) => unknown;
clearInvitedConversationsForNewlyCreatedGroup: () => void; clearInvitedConversationsForNewlyCreatedGroup: () => void;
closeContactSpoofingReview: () => void; closeContactSpoofingReview: () => void;
@ -98,6 +129,7 @@ type PropsActionsType = {
loadCountdownStart?: number loadCountdownStart?: number
) => unknown; ) => unknown;
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown; setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown;
reviewGroupMemberNameCollision: (groupConversationId: string) => void;
reviewMessageRequestNameCollision: ( reviewMessageRequestNameCollision: (
_: Readonly<{ _: Readonly<{
safeConversationId: string; safeConversationId: string;
@ -109,10 +141,11 @@ type PropsActionsType = {
loadNewerMessages: (messageId: string) => unknown; loadNewerMessages: (messageId: string) => unknown;
loadNewestMessages: (messageId: string, setFocus?: boolean) => unknown; loadNewestMessages: (messageId: string, setFocus?: boolean) => unknown;
markMessageRead: (messageId: string) => unknown; markMessageRead: (messageId: string) => unknown;
onBlock: () => unknown; onBlock: (conversationId: string) => unknown;
onBlockAndReportSpam: () => unknown; onBlockAndReportSpam: (conversationId: string) => unknown;
onDelete: () => unknown; onDelete: (conversationId: string) => unknown;
onUnblock: () => unknown; onUnblock: (conversationId: string) => unknown;
removeMember: (conversationId: string) => unknown;
selectMessage: (messageId: string, conversationId: string) => unknown; selectMessage: (messageId: string, conversationId: string) => unknown;
clearSelectedMessage: () => unknown; clearSelectedMessage: () => unknown;
unblurAvatar: () => void; unblurAvatar: () => void;
@ -172,7 +205,7 @@ type StateType = {
shouldShowScrollDownButton: boolean; shouldShowScrollDownButton: boolean;
areUnreadBelowCurrentPosition: boolean; areUnreadBelowCurrentPosition: boolean;
hasDismissedWarning: boolean; hasDismissedDirectContactSpoofingWarning: boolean;
lastMeasuredWarningHeight: number; lastMeasuredWarningHeight: number;
}; };
@ -215,7 +248,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
prevPropScrollToIndex: scrollToIndex, prevPropScrollToIndex: scrollToIndex,
shouldShowScrollDownButton: false, shouldShowScrollDownButton: false,
areUnreadBelowCurrentPosition: false, areUnreadBelowCurrentPosition: false,
hasDismissedWarning: false, hasDismissedDirectContactSpoofingWarning: false,
lastMeasuredWarningHeight: 0, 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 // Warnings can increase the size of the first row (adding padding for the floating
// warning), so we recompute it when the warnings change. // warning), so we recompute it when the warnings change.
const hadWarning = Boolean( const hadWarning = Boolean(
prevProps.warning && !prevState.hasDismissedWarning prevProps.warning && !prevState.hasDismissedDirectContactSpoofingWarning
); );
if (hadWarning !== Boolean(this.getWarning())) { if (hadWarning !== Boolean(this.getWarning())) {
this.recomputeRowHeights(0); this.recomputeRowHeights(0);
@ -1159,6 +1192,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
public render(): JSX.Element | null { public render(): JSX.Element | null {
const { const {
acknowledgeGroupMemberNameCollisions,
areWeAdmin,
clearInvitedConversationsForNewlyCreatedGroup, clearInvitedConversationsForNewlyCreatedGroup,
closeContactSpoofingReview, closeContactSpoofingReview,
contactSpoofingReview, contactSpoofingReview,
@ -1172,6 +1207,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
onDelete, onDelete,
onUnblock, onUnblock,
showContactModal, showContactModal,
removeMember,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision, reviewMessageRequestNameCollision,
} = this.props; } = this.props;
const { const {
@ -1227,6 +1264,69 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
const warning = this.getWarning(); const warning = this.getWarning();
let timelineWarning: ReactNode; let timelineWarning: ReactNode;
if (warning) { 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 = ( timelineWarning = (
<Measure <Measure
bounds bounds
@ -1240,34 +1340,11 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
> >
{({ measureRef }) => ( {({ measureRef }) => (
<TimelineWarnings ref={measureRef}> <TimelineWarnings ref={measureRef}>
<TimelineWarning <TimelineWarning i18n={i18n} onClose={onClose}>
i18n={i18n}
onClose={() => {
this.setState({ hasDismissedWarning: true });
}}
>
<TimelineWarning.IconContainer> <TimelineWarning.IconContainer>
<TimelineWarning.GenericIcon /> <TimelineWarning.GenericIcon />
</TimelineWarning.IconContainer> </TimelineWarning.IconContainer>
<TimelineWarning.Text> <TimelineWarning.Text>{text}</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> </TimelineWarning>
</TimelineWarnings> </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 ( return (
<> <>
<div <div
@ -1310,32 +1428,31 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
/> />
)} )}
{contactSpoofingReview && ( {contactSpoofingReviewDialog}
<ContactSpoofingReviewDialog
i18n={i18n}
onBlock={onBlock}
onBlockAndReportSpam={onBlockAndReportSpam}
onClose={closeContactSpoofingReview}
onDelete={onDelete}
onShowContactModal={showContactModal}
onUnblock={onUnblock}
possiblyUnsafeConversation={
contactSpoofingReview.possiblyUnsafeConversation
}
safeConversation={contactSpoofingReview.safeConversation}
/>
)}
</> </>
); );
} }
private getWarning(): undefined | WarningType { private getWarning(): undefined | WarningType {
const { hasDismissedWarning } = this.state; const { warning } = this.props;
if (hasDismissedWarning) { if (!warning) {
return undefined; return undefined;
} }
const { warning } = this.props; switch (warning.type) {
return warning; 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);
}
} }
} }

2
ts/model-types.d.ts vendored
View File

@ -25,6 +25,7 @@ import { MessageModel } from './models/messages';
import { ConversationModel } from './models/conversations'; import { ConversationModel } from './models/conversations';
import { ProfileNameChangeType } from './util/getStringForProfileChange'; import { ProfileNameChangeType } from './util/getStringForProfileChange';
import { CapabilitiesType } from './textsecure/WebAPI'; import { CapabilitiesType } from './textsecure/WebAPI';
import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions';
export type WhatIsThis = any; export type WhatIsThis = any;
@ -298,6 +299,7 @@ export type ConversationAttributesType = {
groupInviteLinkPassword?: string; groupInviteLinkPassword?: string;
previousGroupV1Id?: string; previousGroupV1Id?: string;
previousGroupV1Members?: Array<string>; previousGroupV1Members?: Array<string>;
acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle;
// Used only when user is waiting for approval to join via link // Used only when user is waiting for approval to join via link
isTemporary?: boolean; isTemporary?: boolean;

View File

@ -56,6 +56,7 @@ import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor';
import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup';
import { filter, map, take } from '../util/iterables'; import { filter, map, take } from '../util/iterables';
import * as universalExpireTimer from '../util/universalExpireTimer'; import * as universalExpireTimer from '../util/universalExpireTimer';
import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -1531,6 +1532,8 @@ export class ConversationModel extends window.Backbone
} }
: { : {
type: 'group' as const, type: 'group' as const,
acknowledgedGroupNameCollisions:
this.get('acknowledgedGroupNameCollisions') || {},
sharedGroupNames: [], sharedGroupNames: [],
}), }),
}; };
@ -5231,6 +5234,13 @@ export class ConversationModel extends window.Backbone
me.captureChange('pin'); me.captureChange('pin');
} }
} }
acknowledgeGroupMemberNameCollisions(
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
): void {
this.set('acknowledgedGroupNameCollisions', groupNameCollisions);
window.Signal.Data.updateConversation(this.attributes);
}
} }
window.Whisper.Conversation = ConversationModel; window.Whisper.Conversation = ConversationModel;

View File

@ -36,6 +36,8 @@ import {
getGroupSizeHardLimit, getGroupSizeHardLimit,
} from '../../groups/limits'; } from '../../groups/limits';
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition'; import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing';
// State // State
@ -145,6 +147,7 @@ export type ConversationType = {
acceptedMessageRequest: boolean; acceptedMessageRequest: boolean;
secretParams?: string; secretParams?: string;
publicParams?: string; publicParams?: string;
acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle;
}; };
export type ConversationLookupType = { export type ConversationLookupType = {
[key: string]: ConversationType; [key: string]: ConversationType;
@ -279,9 +282,15 @@ type ComposerStateType =
| { isCreating: true; hasError: false } | { isCreating: true; hasError: false }
)); ));
type ContactSpoofingReviewStateType = { type ContactSpoofingReviewStateType =
safeConversationId: string; | {
}; type: ContactSpoofingType.DirectConversationWithSameTitle;
safeConversationId: string;
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
groupConversationId: string;
};
export type ConversationsStateType = { export type ConversationsStateType = {
preJoinConversation?: PreJoinConversationType; preJoinConversation?: PreJoinConversationType;
@ -541,6 +550,12 @@ export type SelectedConversationChangedActionType = {
messageId?: string; messageId?: string;
}; };
}; };
type ReviewGroupMemberNameCollisionActionType = {
type: 'REVIEW_GROUP_MEMBER_NAME_COLLISION';
payload: {
groupConversationId: string;
};
};
type ReviewMessageRequestNameCollisionActionType = { type ReviewMessageRequestNameCollisionActionType = {
type: 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION'; type: 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION';
payload: { payload: {
@ -624,6 +639,7 @@ export type ConversationActionType =
| RemoveAllConversationsActionType | RemoveAllConversationsActionType
| RepairNewestMessageActionType | RepairNewestMessageActionType
| RepairOldestMessageActionType | RepairOldestMessageActionType
| ReviewGroupMemberNameCollisionActionType
| ReviewMessageRequestNameCollisionActionType | ReviewMessageRequestNameCollisionActionType
| ScrollToMessageActionType | ScrollToMessageActionType
| SelectedConversationChangedActionType | SelectedConversationChangedActionType
@ -675,6 +691,7 @@ export const actions = {
repairNewestMessage, repairNewestMessage,
repairOldestMessage, repairOldestMessage,
resetAllChatColors, resetAllChatColors,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision, reviewMessageRequestNameCollision,
scrollToMessage, scrollToMessage,
selectMessage, selectMessage,
@ -1001,6 +1018,15 @@ function repairOldestMessage(
}; };
} }
function reviewGroupMemberNameCollision(
groupConversationId: string
): ReviewGroupMemberNameCollisionActionType {
return {
type: 'REVIEW_GROUP_MEMBER_NAME_COLLISION',
payload: { groupConversationId },
};
}
function reviewMessageRequestNameCollision( function reviewMessageRequestNameCollision(
payload: Readonly<{ payload: Readonly<{
safeConversationId: string; safeConversationId: string;
@ -2040,10 +2066,23 @@ export function reducer(
}; };
} }
if (action.type === 'REVIEW_GROUP_MEMBER_NAME_COLLISION') {
return {
...state,
contactSpoofingReview: {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
...action.payload,
},
};
}
if (action.type === 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION') { if (action.type === 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION') {
return { return {
...state, ...state,
contactSpoofingReview: action.payload, contactSpoofingReview: {
type: ContactSpoofingType.DirectConversationWithSameTitle,
...action.payload,
},
}; };
} }

View File

@ -1,11 +1,12 @@
// Copyright 2019-2021 Signal Messenger, LLC // Copyright 2019-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import { pick } from 'lodash'; import { isEmpty, mapValues, pick } from 'lodash';
import React from 'react'; import React from 'react';
import { connect } from 'react-redux'; import { connect } from 'react-redux';
import { mapDispatchToProps } from '../actions'; import { mapDispatchToProps } from '../actions';
import { import {
ContactSpoofingReviewPropType,
Timeline, Timeline,
WarningType as TimelineWarningType, WarningType as TimelineWarningType,
} from '../../components/conversation/Timeline'; } from '../../components/conversation/Timeline';
@ -14,6 +15,7 @@ import { ConversationType } from '../ducks/conversations';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { import {
getConversationByIdSelector,
getConversationMessagesSelector, getConversationMessagesSelector,
getConversationSelector, getConversationSelector,
getConversationsByTitleSelector, getConversationsByTitleSelector,
@ -29,7 +31,16 @@ import { SmartTimelineLoadingRow } from './TimelineLoadingRow';
import { renderAudioAttachment } from './renderAudioAttachment'; import { renderAudioAttachment } from './renderAudioAttachment';
import { renderEmojiPicker } from './renderEmojiPicker'; import { renderEmojiPicker } from './renderEmojiPicker';
import { getOwn } from '../../util/getOwn';
import { assert } from '../../util/assert'; import { assert } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import { getGroupMemberships } from '../../util/getGroupMemberships';
import {
dehydrateCollisionsWithConversations,
getCollisionsFromMemberships,
invertIdsByTitle,
} from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing';
// Workaround: A react component's required properties are filtering up through connect() // Workaround: A react component's required properties are filtering up through connect()
// https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363
@ -90,54 +101,117 @@ const getWarning = (
conversation: Readonly<ConversationType>, conversation: Readonly<ConversationType>,
state: Readonly<StateType> state: Readonly<StateType>
): undefined | TimelineWarningType => { ): undefined | TimelineWarningType => {
if ( switch (conversation.type) {
conversation.type === 'direct' && case 'direct':
!conversation.acceptedMessageRequest && if (!conversation.acceptedMessageRequest && !conversation.isBlocked) {
!conversation.isBlocked const getConversationsWithTitle = getConversationsByTitleSelector(
) { state
const getConversationsWithTitle = getConversationsByTitleSelector(state); );
const conversationsWithSameTitle = getConversationsWithTitle( const conversationsWithSameTitle = getConversationsWithTitle(
conversation.title conversation.title
); );
assert( assert(
conversationsWithSameTitle.length, conversationsWithSameTitle.length,
'Expected at least 1 conversation with the same title (this one)' 'Expected at least 1 conversation with the same title (this one)'
); );
const safeConversation = conversationsWithSameTitle.find( const safeConversation = conversationsWithSameTitle.find(
otherConversation => otherConversation =>
otherConversation.acceptedMessageRequest && otherConversation.acceptedMessageRequest &&
otherConversation.type === 'direct' && otherConversation.type === 'direct' &&
otherConversation.id !== conversation.id otherConversation.id !== conversation.id
); );
return safeConversation ? { safeConversation } : undefined; if (safeConversation) {
return {
type: ContactSpoofingType.DirectConversationWithSameTitle,
safeConversation,
};
}
}
return undefined;
case 'group': {
if (conversation.left || conversation.groupVersion !== 2) {
return undefined;
}
const getConversationById = getConversationByIdSelector(state);
const { memberships } = getGroupMemberships(
conversation,
getConversationById
);
const groupNameCollisions = getCollisionsFromMemberships(memberships);
const hasGroupMembersWithSameName = !isEmpty(groupNameCollisions);
if (hasGroupMembersWithSameName) {
return {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
acknowledgedGroupNameCollisions:
conversation.acknowledgedGroupNameCollisions || {},
groupNameCollisions: dehydrateCollisionsWithConversations(
groupNameCollisions
),
};
}
return undefined;
}
default:
throw missingCaseError(conversation.type);
} }
return undefined;
}; };
const getContactSpoofingReview = ( const getContactSpoofingReview = (
selectedConversationId: string, selectedConversationId: string,
state: Readonly<StateType> state: Readonly<StateType>
): ): undefined | ContactSpoofingReviewPropType => {
| undefined
| {
possiblyUnsafeConversation: ConversationType;
safeConversation: ConversationType;
} => {
const { contactSpoofingReview } = state.conversations; const { contactSpoofingReview } = state.conversations;
if (!contactSpoofingReview) { if (!contactSpoofingReview) {
return undefined; return undefined;
} }
const conversationSelector = getConversationSelector(state); const conversationSelector = getConversationSelector(state);
return { const getConversationById = getConversationByIdSelector(state);
possiblyUnsafeConversation: conversationSelector(selectedConversationId),
safeConversation: conversationSelector( const currentConversation = conversationSelector(selectedConversationId);
contactSpoofingReview.safeConversationId
), switch (contactSpoofingReview.type) {
}; case ContactSpoofingType.DirectConversationWithSameTitle:
return {
type: ContactSpoofingType.DirectConversationWithSameTitle,
possiblyUnsafeConversation: currentConversation,
safeConversation: conversationSelector(
contactSpoofingReview.safeConversationId
),
};
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
const { memberships } = getGroupMemberships(
currentConversation,
getConversationById
);
const groupNameCollisions = getCollisionsFromMemberships(memberships);
const previouslyAcknowledgedTitlesById = invertIdsByTitle(
currentConversation.acknowledgedGroupNameCollisions || {}
);
const collisionInfoByTitle = mapValues(
groupNameCollisions,
conversations =>
conversations.map(conversation => ({
conversation,
oldName: getOwn(previouslyAcknowledgedTitlesById, conversation.id),
}))
);
return {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
collisionInfoByTitle,
};
}
default:
throw missingCaseError(contactSpoofingReview);
}
}; };
const mapStateToProps = (state: StateType, props: ExternalProps) => { const mapStateToProps = (state: StateType, props: ExternalProps) => {
@ -150,6 +224,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
return { return {
id, id,
...pick(conversation, [ ...pick(conversation, [
'areWeAdmin',
'unreadCount', 'unreadCount',
'typingContact', 'typingContact',
'isGroupV1AndDisabled', 'isGroupV1AndDisabled',

View File

@ -0,0 +1,181 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { getDefaultConversation } from '../helpers/getDefaultConversation';
import {
dehydrateCollisionsWithConversations,
getCollisionsFromMemberships,
hasUnacknowledgedCollisions,
invertIdsByTitle,
} from '../../util/groupMemberNameCollisions';
describe('group member name collision utilities', () => {
describe('dehydrateCollisionsWithConversations', () => {
it('turns conversations into "plain" IDs', () => {
const conversation1 = getDefaultConversation();
const conversation2 = getDefaultConversation();
const conversation3 = getDefaultConversation();
const conversation4 = getDefaultConversation();
const result = dehydrateCollisionsWithConversations({
Alice: [conversation1, conversation2],
Bob: [conversation3, conversation4],
});
assert.deepEqual(result, {
Alice: [conversation1.id, conversation2.id],
Bob: [conversation3.id, conversation4.id],
});
});
});
describe('getCollisionsFromMemberships', () => {
it('finds collisions by title, omitting some conversations', () => {
const alice1 = getDefaultConversation({
profileName: 'Alice',
title: 'Alice',
});
const alice2 = getDefaultConversation({
profileName: 'Alice',
title: 'Alice',
});
const bob1 = getDefaultConversation({
profileName: 'Bob',
title: 'Bob',
});
const bob2 = getDefaultConversation({
profileName: 'Bob',
title: 'Bob',
});
const bob3 = getDefaultConversation({
profileName: 'Bob',
title: 'Bob',
});
const ignoredBob = getDefaultConversation({
e164: undefined,
title: 'Bob',
});
const charlie = getDefaultConversation({
profileName: 'Charlie',
title: 'Charlie',
});
const me = getDefaultConversation({
isMe: true,
profileName: 'Alice',
title: 'Alice',
});
const memberships = [
alice1,
alice2,
bob1,
bob2,
bob3,
ignoredBob,
charlie,
me,
].map(member => ({ member }));
const result = getCollisionsFromMemberships(memberships);
assert.deepEqual(result, {
Alice: [alice1, alice2],
Bob: [bob1, bob2, bob3],
});
});
});
describe('hasUnacknowledgedCollisions', () => {
it('returns false if the collisions are identical', () => {
assert.isFalse(hasUnacknowledgedCollisions({}, {}));
assert.isFalse(
hasUnacknowledgedCollisions(
{ Alice: ['abc', 'def'] },
{ Alice: ['abc', 'def'] }
)
);
assert.isFalse(
hasUnacknowledgedCollisions(
{ Alice: ['abc', 'def'] },
{ Alice: ['def', 'abc'] }
)
);
});
it('returns false if the acknowledged collisions are a superset of the current collisions', () => {
assert.isFalse(
hasUnacknowledgedCollisions({ Alice: ['abc', 'def'] }, {})
);
assert.isFalse(
hasUnacknowledgedCollisions(
{ Alice: ['abc', 'def', 'geh'] },
{ Alice: ['abc', 'geh'] }
)
);
assert.isFalse(
hasUnacknowledgedCollisions(
{ Alice: ['abc', 'def'], Bob: ['ghi', 'jkl'] },
{ Alice: ['abc', 'def'] }
)
);
});
it('returns true if the current collisions has a title that was not acknowledged', () => {
assert.isTrue(
hasUnacknowledgedCollisions(
{ Alice: ['abc', 'def'], Bob: ['ghi', 'jkl'] },
{
Alice: ['abc', 'def'],
Bob: ['ghi', 'jkl'],
Charlie: ['mno', 'pqr'],
}
)
);
assert.isTrue(
hasUnacknowledgedCollisions(
{ Alice: ['abc', 'def'], Bob: ['ghi', 'jkl'] },
{
Alice: ['abc', 'def'],
Charlie: ['mno', 'pqr'],
}
)
);
});
it('returns true if any title has a new ID', () => {
assert.isTrue(
hasUnacknowledgedCollisions(
{ Alice: ['abc', 'def'] },
{ Alice: ['abc', 'def', 'ghi'] }
)
);
assert.isTrue(
hasUnacknowledgedCollisions(
{ Alice: ['abc', 'def'] },
{ Alice: ['abc', 'ghi'] }
)
);
});
});
describe('invertIdsByTitle', () => {
it('returns an empty object if passed no IDs', () => {
assert.deepEqual(invertIdsByTitle({}), {});
assert.deepEqual(invertIdsByTitle({ Alice: [] }), {});
});
it('returns an object with ID keys and title values', () => {
assert.deepEqual(
invertIdsByTitle({ Alice: ['abc', 'def'], Bob: ['ghi', 'jkl', 'mno'] }),
{
abc: 'Alice',
def: 'Alice',
ghi: 'Bob',
jkl: 'Bob',
mno: 'Bob',
}
);
});
});
});

View File

@ -0,0 +1,56 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { isConversationNameKnown } from '../../util/isConversationNameKnown';
describe('isConversationNameKnown', () => {
describe('for direct conversations', () => {
it('returns true if the conversation has a name', () => {
assert.isTrue(
isConversationNameKnown({
type: 'direct',
name: 'Jane Doe',
})
);
});
it('returns true if the conversation has a profile name', () => {
assert.isTrue(
isConversationNameKnown({
type: 'direct',
profileName: 'Jane Doe',
})
);
});
it('returns true if the conversation has an E164', () => {
assert.isTrue(
isConversationNameKnown({
type: 'direct',
e164: '+16505551234',
})
);
});
it('returns false if the conversation has none of the above', () => {
assert.isFalse(isConversationNameKnown({ type: 'direct' }));
});
});
describe('for group conversations', () => {
it('returns true if the conversation has a name', () => {
assert.isTrue(
isConversationNameKnown({
type: 'group',
name: 'Tahoe Trip',
})
);
});
it('returns true if the conversation lacks a name', () => {
assert.isFalse(isConversationNameKnown({ type: 'group' }));
});
});
});

View File

@ -7,6 +7,7 @@ import * as sinon from 'sinon';
import { import {
concat, concat,
filter, filter,
groupBy,
isIterable, isIterable,
map, map,
size, size,
@ -210,6 +211,31 @@ describe('iterable utilities', () => {
}); });
}); });
describe('groupBy', () => {
it('returns an empty object if passed an empty iterable', () => {
const fn = sinon.fake();
assert.deepEqual(groupBy([], fn), {});
assert.deepEqual(groupBy(new Set(), fn), {});
sinon.assert.notCalled(fn);
});
it('returns a map of groups', () => {
assert.deepEqual(
groupBy(
['apple', 'aardvark', 'orange', 'orange', 'zebra'],
str => str[0]
),
{
a: ['apple', 'aardvark'],
o: ['orange', 'orange'],
z: ['zebra'],
}
);
});
});
describe('map', () => { describe('map', () => {
it('returns an empty iterable when passed an empty iterable', () => { it('returns an empty iterable when passed an empty iterable', () => {
const fn = sinon.fake(); const fn = sinon.fake();

View File

@ -23,6 +23,7 @@ import {
reducer, reducer,
updateConversationLookups, updateConversationLookups,
} from '../../../state/ducks/conversations'; } from '../../../state/ducks/conversations';
import { ContactSpoofingType } from '../../../util/contactSpoofing';
import { CallMode } from '../../../types/Calling'; import { CallMode } from '../../../types/Calling';
import * as groups from '../../../groups'; import * as groups from '../../../groups';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
@ -51,6 +52,7 @@ const {
showChooseGroupMembers, showChooseGroupMembers,
startSettingGroupMetadata, startSettingGroupMetadata,
resetAllChatColors, resetAllChatColors,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision, reviewMessageRequestNameCollision,
toggleConversationInChooseMembers, toggleConversationInChooseMembers,
} = actions; } = actions;
@ -537,6 +539,7 @@ describe('both/state/ducks/conversations', () => {
const state = { const state = {
...getEmptyState(), ...getEmptyState(),
contactSpoofingReview: { contactSpoofingReview: {
type: ContactSpoofingType.DirectConversationWithSameTitle as const,
safeConversationId: 'abc123', safeConversationId: 'abc123',
}, },
}; };
@ -1156,6 +1159,19 @@ describe('both/state/ducks/conversations', () => {
}); });
}); });
describe('REVIEW_GROUP_MEMBER_NAME_COLLISION', () => {
it('starts reviewing a group member name collision', () => {
const state = getEmptyState();
const action = reviewGroupMemberNameCollision('abc123');
const actual = reducer(state, action);
assert.deepEqual(actual.contactSpoofingReview, {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle as const,
groupConversationId: 'abc123',
});
});
});
describe('REVIEW_MESSAGE_REQUEST_NAME_COLLISION', () => { describe('REVIEW_MESSAGE_REQUEST_NAME_COLLISION', () => {
it('starts reviewing a message request name collision', () => { it('starts reviewing a message request name collision', () => {
const state = getEmptyState(); const state = getEmptyState();
@ -1165,6 +1181,7 @@ describe('both/state/ducks/conversations', () => {
const actual = reducer(state, action); const actual = reducer(state, action);
assert.deepEqual(actual.contactSpoofingReview, { assert.deepEqual(actual.contactSpoofingReview, {
type: ContactSpoofingType.DirectConversationWithSameTitle as const,
safeConversationId: 'def', safeConversationId: 'def',
}); });
}); });

View File

@ -0,0 +1,7 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
export enum ContactSpoofingType {
DirectConversationWithSameTitle,
MultipleGroupMembersWithSameTitle,
}

View File

@ -0,0 +1,68 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { mapValues, pickBy } from 'lodash';
import { groupBy, map, filter } from './iterables';
import { getOwn } from './getOwn';
import { ConversationType } from '../state/ducks/conversations';
import { isConversationNameKnown } from './isConversationNameKnown';
export type GroupNameCollisionsWithIdsByTitle = Record<string, Array<string>>;
export type GroupNameCollisionsWithConversationsByTitle = Record<
string,
Array<ConversationType>
>;
export type GroupNameCollisionsWithTitlesById = Record<string, string>;
export const dehydrateCollisionsWithConversations = (
withConversations: Readonly<GroupNameCollisionsWithConversationsByTitle>
): GroupNameCollisionsWithIdsByTitle =>
mapValues(withConversations, conversations => conversations.map(c => c.id));
export function getCollisionsFromMemberships(
memberships: Iterable<{ member: ConversationType }>
): GroupNameCollisionsWithConversationsByTitle {
const members = map(memberships, membership => membership.member);
const candidateMembers = filter(
members,
member => !member.isMe && isConversationNameKnown(member)
);
const groupedByTitle = groupBy(candidateMembers, member => member.title);
// This cast is here because `pickBy` returns a `Partial`, which is incompatible with
// `Record`. [This demonstates the problem][0], but I don't believe it's an actual
// issue in the code.
//
// Alternatively, we could filter undefined keys or something like that.
//
// [0]: https://www.typescriptlang.org/play?#code/C4TwDgpgBAYg9nKBeKAFAhgJ2AS3QGwB4AlCAYzkwBNCBnYTHAOwHMAaKJgVwFsAjCJgB8QgNwAoCk3pQAZgC5YCZFADeUABY5FAVigBfCeNCQoAISwrSFanQbN2nXgOESpMvoouYVs0UA
return (pickBy(
groupedByTitle,
group => group.length >= 2
) as unknown) as GroupNameCollisionsWithConversationsByTitle;
}
/**
* Returns `true` if the user should see a group member name collision warning, and
* `false` otherwise. Users should see these warnings if any collisions appear that they
* haven't dismissed.
*/
export const hasUnacknowledgedCollisions = (
previous: Readonly<GroupNameCollisionsWithIdsByTitle>,
current: Readonly<GroupNameCollisionsWithIdsByTitle>
): boolean =>
Object.entries(current).some(([title, currentIds]) => {
const previousIds = new Set(getOwn(previous, title) || []);
return currentIds.some(currentId => !previousIds.has(currentId));
});
export const invertIdsByTitle = (
idsByTitle: Readonly<GroupNameCollisionsWithIdsByTitle>
): GroupNameCollisionsWithTitlesById => {
const result: GroupNameCollisionsWithTitlesById = Object.create(null);
Object.entries(idsByTitle).forEach(([title, ids]) => {
ids.forEach(id => {
result[id] = title;
});
});
return result;
};

View File

@ -0,0 +1,22 @@
// Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { ConversationType } from '../state/ducks/conversations';
import { missingCaseError } from './missingCaseError';
export function isConversationNameKnown(
conversation: Readonly<
Pick<ConversationType, 'e164' | 'name' | 'profileName' | 'type'>
>
): boolean {
switch (conversation.type) {
case 'direct':
return Boolean(
conversation.name || conversation.profileName || conversation.e164
);
case 'group':
return Boolean(conversation.name);
default:
throw missingCaseError(conversation.type);
}
}

View File

@ -4,6 +4,8 @@
/* eslint-disable max-classes-per-file */ /* eslint-disable max-classes-per-file */
/* eslint-disable no-restricted-syntax */ /* eslint-disable no-restricted-syntax */
import { getOwn } from './getOwn';
export function isIterable(value: unknown): value is Iterable<unknown> { export function isIterable(value: unknown): value is Iterable<unknown> {
return ( return (
(typeof value === 'object' && value !== null && Symbol.iterator in value) || (typeof value === 'object' && value !== null && Symbol.iterator in value) ||
@ -88,6 +90,23 @@ class FilterIterator<T> implements Iterator<T> {
} }
} }
export function groupBy<T>(
iterable: Iterable<T>,
fn: (value: T) => string
): Record<string, Array<T>> {
const result: Record<string, Array<T>> = Object.create(null);
for (const value of iterable) {
const key = fn(value);
const existingGroup = getOwn(result, key);
if (existingGroup) {
existingGroup.push(value);
} else {
result[key] = [value];
}
}
return result;
}
export function map<T, ResultT>( export function map<T, ResultT>(
iterable: Iterable<T>, iterable: Iterable<T>,
fn: (value: T) => ResultT fn: (value: T) => ResultT

View File

@ -15,6 +15,7 @@ import { assert } from '../util/assert';
import { maybeParseUrl } from '../util/url'; import { maybeParseUrl } from '../util/url';
import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob'; import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob';
import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue'; import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue';
import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions';
type GetLinkPreviewImageResult = { type GetLinkPreviewImageResult = {
data: ArrayBuffer; data: ArrayBuffer;
@ -598,6 +599,8 @@ Whisper.ConversationView = Whisper.View.extend({
}, },
setupCompositionArea({ attachmentListEl }: any) { setupCompositionArea({ attachmentListEl }: any) {
const { model }: { model: ConversationModel } = this;
const compositionApi = { current: null }; const compositionApi = { current: null };
this.compositionApi = compositionApi; this.compositionApi = compositionApi;
@ -632,19 +635,35 @@ Whisper.ConversationView = Whisper.View.extend({
micCellEl, micCellEl,
attachmentListEl, attachmentListEl,
onAccept: () => { onAccept: () => {
this.syncMessageRequestResponse('onAccept', messageRequestEnum.ACCEPT); this.syncMessageRequestResponse(
'onAccept',
model,
messageRequestEnum.ACCEPT
);
}, },
onBlock: () => { onBlock: () => {
this.syncMessageRequestResponse('onBlock', messageRequestEnum.BLOCK); this.syncMessageRequestResponse(
'onBlock',
model,
messageRequestEnum.BLOCK
);
}, },
onUnblock: () => { onUnblock: () => {
this.syncMessageRequestResponse('onUnblock', messageRequestEnum.ACCEPT); this.syncMessageRequestResponse(
'onUnblock',
model,
messageRequestEnum.ACCEPT
);
}, },
onDelete: () => { onDelete: () => {
this.syncMessageRequestResponse('onDelete', messageRequestEnum.DELETE); this.syncMessageRequestResponse(
'onDelete',
model,
messageRequestEnum.DELETE
);
}, },
onBlockAndReportSpam: () => { onBlockAndReportSpam: () => {
this.blockAndReportSpam(); this.blockAndReportSpam(model);
}, },
onStartGroupMigration: () => this.startMigrationToGV2(), onStartGroupMigration: () => this.startMigrationToGV2(),
onCancelJoinRequest: async () => { onCancelJoinRequest: async () => {
@ -949,6 +968,21 @@ Whisper.ConversationView = Whisper.View.extend({
await this.model.markRead(message.get('received_at')); await this.model.markRead(message.get('received_at'));
}; };
const createMessageRequestResponseHandler = (
name: string,
enumValue: number
): ((conversationId: string) => void) => conversationId => {
const conversation = window.ConversationController.get(conversationId);
if (!conversation) {
assert(
false,
`Expected a conversation to be found in ${name}. Doing nothing`
);
return;
}
this.syncMessageRequestResponse(name, conversation, enumValue);
};
this.timelineView = new Whisper.ReactWrapperView({ this.timelineView = new Whisper.ReactWrapperView({
className: 'timeline-wrapper', className: 'timeline-wrapper',
JSX: window.Signal.State.Roots.createTimeline(window.reduxStore, { JSX: window.Signal.State.Roots.createTimeline(window.reduxStore, {
@ -956,31 +990,51 @@ Whisper.ConversationView = Whisper.View.extend({
...this.getMessageActions(), ...this.getMessageActions(),
acknowledgeGroupMemberNameCollisions: (
groupNameCollisions: Readonly<GroupNameCollisionsWithIdsByTitle>
): void => {
const { model }: { model: ConversationModel } = this;
model.acknowledgeGroupMemberNameCollisions(groupNameCollisions);
},
contactSupport, contactSupport,
loadNewerMessages, loadNewerMessages,
loadNewestMessages: this.loadNewestMessages.bind(this), loadNewestMessages: this.loadNewestMessages.bind(this),
loadAndScroll: this.loadAndScroll.bind(this), loadAndScroll: this.loadAndScroll.bind(this),
loadOlderMessages, loadOlderMessages,
markMessageRead, markMessageRead,
onBlock: () => { onBlock: createMessageRequestResponseHandler(
this.syncMessageRequestResponse('onBlock', messageRequestEnum.BLOCK); 'onBlock',
}, messageRequestEnum.BLOCK
onBlockAndReportSpam: () => { ),
this.blockAndReportSpam(); onBlockAndReportSpam: (conversationId: string) => {
}, const conversation = window.ConversationController.get(
onDelete: () => { conversationId
this.syncMessageRequestResponse(
'onDelete',
messageRequestEnum.DELETE
);
},
onUnblock: () => {
this.syncMessageRequestResponse(
'onUnblock',
messageRequestEnum.ACCEPT
); );
if (!conversation) {
assert(
false,
'Expected a conversation to be found in onBlockAndReportSpam. Doing nothing'
);
return;
}
this.blockAndReportSpam(conversation);
}, },
onDelete: createMessageRequestResponseHandler(
'onDelete',
messageRequestEnum.DELETE
),
onUnblock: createMessageRequestResponseHandler(
'onUnblock',
messageRequestEnum.ACCEPT
),
onShowContactModal: this.showContactModal.bind(this), onShowContactModal: this.showContactModal.bind(this),
removeMember: (conversationId: string) => {
const { model }: { model: ConversationModel } = this;
this.longRunningTaskWrapper({
name: 'removeMember',
task: () => model.removeFromGroupV2(conversationId),
});
},
scrollToQuotedMessage, scrollToQuotedMessage,
unblurAvatar: () => { unblurAvatar: () => {
this.model.unblurAvatar(); this.model.unblurAvatar();
@ -1452,19 +1506,18 @@ Whisper.ConversationView = Whisper.View.extend({
syncMessageRequestResponse( syncMessageRequestResponse(
name: string, name: string,
model: ConversationModel,
messageRequestType: number messageRequestType: number
): Promise<void> { ): Promise<void> {
const { model }: { model: ConversationModel } = this;
return this.longRunningTaskWrapper({ return this.longRunningTaskWrapper({
name, name,
task: model.syncMessageRequestResponse.bind(model, messageRequestType), task: model.syncMessageRequestResponse.bind(model, messageRequestType),
}); });
}, },
blockAndReportSpam(): Promise<void> { blockAndReportSpam(model: ConversationModel): Promise<void> {
const messageRequestEnum = const messageRequestEnum =
window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type;
const { model }: { model: ConversationModel } = this;
return this.longRunningTaskWrapper({ return this.longRunningTaskWrapper({
name: 'blockAndReportSpam', name: 'blockAndReportSpam',
@ -3208,7 +3261,11 @@ Whisper.ConversationView = Whisper.View.extend({
}; };
const onBlock = () => { const onBlock = () => {
this.syncMessageRequestResponse('onBlock', messageRequestEnum.BLOCK); this.syncMessageRequestResponse(
'onBlock',
conversation,
messageRequestEnum.BLOCK
);
}; };
const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired; const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;