Group name spoofing warning
This commit is contained in:
parent
51b45ab275
commit
36c15fead4
|
@ -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"
|
||||||
|
|
|
@ -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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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' }),
|
||||||
|
})),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
|
@ -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>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
@ -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
|
// 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} />;
|
||||||
|
});
|
||||||
|
|
|
@ -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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
@ -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',
|
||||||
|
|
|
@ -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',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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' }));
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
|
@ -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();
|
||||||
|
|
|
@ -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',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -0,0 +1,7 @@
|
||||||
|
// Copyright 2021 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
export enum ContactSpoofingType {
|
||||||
|
DirectConversationWithSameTitle,
|
||||||
|
MultipleGroupMembersWithSameTitle,
|
||||||
|
}
|
|
@ -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;
|
||||||
|
};
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
|
|
@ -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;
|
||||||
|
|
Loading…
Reference in New Issue