diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c8b0cceba..e9ec3e383 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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": { "message": "Review request", "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": { "message": "Review request", "description": "Title for the contact name spoofing review dialog" @@ -5293,6 +5311,46 @@ "message": "Your contact", "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": { "message": "Verify to continue messaging", "description": "Header in the captcha dialog" diff --git a/stylesheets/components/ContactSpoofingReviewDialogPerson.scss b/stylesheets/components/ContactSpoofingReviewDialogPerson.scss index 53f24fa2c..e6d93d6d5 100644 --- a/stylesheets/components/ContactSpoofingReviewDialogPerson.scss +++ b/stylesheets/components/ContactSpoofingReviewDialogPerson.scss @@ -22,6 +22,11 @@ @include dark-theme { color: $color-gray-05; } + + &--callout { + @include font-body-2-italic; + margin: 12px 0; + } } } } diff --git a/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx b/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx index d7942de56..eefab614c 100644 --- a/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx +++ b/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import React from 'react'; +import { times } from 'lodash'; import { action } from '@storybook/addon-actions'; import { storiesOf } from '@storybook/react'; import { setup as setupI18n } from '../../../js/modules/i18n'; @@ -9,6 +10,7 @@ import enMessages from '../../../_locales/en/messages.json'; import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog'; +import { ContactSpoofingType } from '../../util/contactSpoofing'; const i18n = setupI18n('en', enMessages); @@ -17,16 +19,49 @@ const story = storiesOf( module ); -story.add('Default', () => ( +const getCommonProps = () => ({ + i18n, + onBlock: action('onBlock'), + onBlockAndReportSpam: action('onBlockAndReportSpam'), + onClose: action('onClose'), + onDelete: action('onDelete'), + onShowContactModal: action('onShowContactModal'), + onUnblock: action('onUnblock'), + removeMember: action('removeMember'), +}); + +story.add('Direct conversations with same title', () => ( )); + +[false, true].forEach(areWeAdmin => { + story.add( + `Group conversation many group members${ + areWeAdmin ? " (and we're an admin)" : '' + }`, + () => ( + ({ + oldName: 'Alicia', + conversation: getDefaultConversation({ title: 'Alice' }), + })), + Bob: times(3, () => ({ + conversation: getDefaultConversation({ title: 'Bob' }), + })), + Charlie: times(5, () => ({ + conversation: getDefaultConversation({ title: 'Charlie' }), + })), + }} + /> + ) + ); +}); diff --git a/ts/components/conversation/ContactSpoofingReviewDialog.tsx b/ts/components/conversation/ContactSpoofingReviewDialog.tsx index f99306180..103778212 100644 --- a/ts/components/conversation/ContactSpoofingReviewDialog.tsx +++ b/ts/components/conversation/ContactSpoofingReviewDialog.tsx @@ -1,7 +1,13 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { FunctionComponent, useState } from 'react'; +import React, { + FunctionComponent, + ReactChild, + ReactNode, + useState, +} from 'react'; +import { concat, orderBy } from 'lodash'; import { LocalizerType } from '../../types/Util'; import { ConversationType } from '../../state/ducks/conversations'; @@ -9,65 +15,318 @@ import { MessageRequestActionsConfirmation, MessageRequestState, } from './MessageRequestActionsConfirmation'; +import { ContactSpoofingType } from '../../util/contactSpoofing'; import { Modal } from '../Modal'; +import { RemoveGroupMemberConfirmationDialog } from './RemoveGroupMemberConfirmationDialog'; import { ContactSpoofingReviewDialogPerson } from './ContactSpoofingReviewDialogPerson'; import { Button, ButtonVariant } from '../Button'; +import { Intl } from '../Intl'; +import { Emojify } from './Emojify'; import { assert } from '../../util/assert'; +import { missingCaseError } from '../../util/missingCaseError'; type PropsType = { i18n: LocalizerType; - onBlock: () => unknown; - onBlockAndReportSpam: () => unknown; + onBlock: (conversationId: string) => unknown; + onBlockAndReportSpam: (conversationId: string) => unknown; onClose: () => void; - onDelete: () => unknown; + onDelete: (conversationId: string) => unknown; onShowContactModal: (contactId: string) => unknown; - onUnblock: () => unknown; - possiblyUnsafeConversation: ConversationType; - safeConversation: ConversationType; -}; + onUnblock: (conversationId: string) => unknown; + removeMember: (conversationId: string) => unknown; +} & ( + | { + type: ContactSpoofingType.DirectConversationWithSameTitle; + possiblyUnsafeConversation: ConversationType; + safeConversation: ConversationType; + } + | { + type: ContactSpoofingType.MultipleGroupMembersWithSameTitle; + areWeAdmin: boolean; + collisionInfoByTitle: Record< + string, + Array<{ + oldName?: string; + conversation: ConversationType; + }> + >; + } +); -export const ContactSpoofingReviewDialog: FunctionComponent = ({ - i18n, - onBlock, - onBlockAndReportSpam, - onClose, - onDelete, - onShowContactModal, - onUnblock, - possiblyUnsafeConversation, - safeConversation, -}) => { - assert( - possiblyUnsafeConversation.type === 'direct', - ' expected a direct conversation for the "possibly unsafe" conversation' - ); - assert( - safeConversation.type === 'direct', - ' expected a direct conversation for the "safe" conversation' - ); +enum ConfirmationStateType { + ConfirmingDelete, + ConfirmingBlock, + ConfirmingGroupRemoval, +} - const [messageRequestState, setMessageRequestState] = useState( - MessageRequestState.default - ); +export const ContactSpoofingReviewDialog: FunctionComponent = props => { + const { + i18n, + onBlock, + onBlockAndReportSpam, + onClose, + onDelete, + onShowContactModal, + onUnblock, + removeMember, + } = props; - if (messageRequestState !== MessageRequestState.default) { - return ( - - ); + const [confirmationState, setConfirmationState] = useState< + | undefined + | { + type: ConfirmationStateType; + affectedConversation: ConversationType; + } + >(); + + if (confirmationState) { + const { affectedConversation, type } = confirmationState; + switch (type) { + case ConfirmationStateType.ConfirmingDelete: + case ConfirmationStateType.ConfirmingBlock: + return ( + { + 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 ( + { + 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', + ' expected a direct conversation for the "possibly unsafe" conversation' + ); + assert( + safeConversation.type === 'direct', + ' expected a direct conversation for the "safe" conversation' + ); + + title = i18n('ContactSpoofingReviewDialog__title'); + contents = ( + <> +

{i18n('ContactSpoofingReviewDialog__description')}

+

{i18n('ContactSpoofingReviewDialog__possibly-unsafe-title')}

+ +
+ + +
+
+
+

{i18n('ContactSpoofingReviewDialog__safe-title')}

+ { + 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 = ( + <> +

+ {i18n('ContactSpoofingReviewDialog__group__description', [ + conversationInfos.length.toString(), + ])} +

+

{i18n('ContactSpoofingReviewDialog__group__members-header')}

+ {conversationInfos.map((conversationInfo, index) => { + let button: ReactNode; + if (areWeAdmin) { + button = ( + + ); + } else if (conversationInfo.conversation.isBlocked) { + button = ( + + ); + } else if (!conversationInfo.conversation.name) { + button = ( + + ); + } + + const { oldName } = conversationInfo; + const newName = + conversationInfo.conversation.profileName || + conversationInfo.conversation.title; + + return ( + <> + {index !== 0 &&
} + + {Boolean(oldName) && oldName !== newName && ( +
+ , + newName: , + }} + /> +
+ )} + {button && ( +
+ {button} +
+ )} +
+ + ); + })} + + ); + break; + } + default: + throw missingCaseError(props); } return ( @@ -76,42 +335,9 @@ export const ContactSpoofingReviewDialog: FunctionComponent = ({ i18n={i18n} moduleClassName="module-ContactSpoofingReviewDialog" onClose={onClose} - title={i18n('ContactSpoofingReviewDialog__title')} + title={title} > -

{i18n('ContactSpoofingReviewDialog__description')}

-

{i18n('ContactSpoofingReviewDialog__possibly-unsafe-title')}

- -
- - -
-
-
-

{i18n('ContactSpoofingReviewDialog__safe-title')}

- { - onShowContactModal(safeConversation.id); - }} - /> + {contents} ); }; diff --git a/ts/components/conversation/RemoveGroupMemberConfirmationDialog.tsx b/ts/components/conversation/RemoveGroupMemberConfirmationDialog.tsx new file mode 100644 index 000000000..d6ee8597b --- /dev/null +++ b/ts/components/conversation/RemoveGroupMemberConfirmationDialog.tsx @@ -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 = ({ + conversation, + i18n, + onClose, + onRemove, +}) => ( + + ), + }} + /> + } + /> +); diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index dae28f0d2..1292046fa 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -2,6 +2,8 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; +import { times } from 'lodash'; +import { v4 as uuid } from 'uuid'; import { storiesOf } from '@storybook/react'; import { text, boolean, number } from '@storybook/addon-knobs'; import { action } from '@storybook/addon-actions'; @@ -15,6 +17,7 @@ import { getDefaultConversation } from '../../test-both/helpers/getDefaultConver import { LastSeenIndicator } from './LastSeenIndicator'; import { TimelineLoadingRow } from './TimelineLoadingRow'; import { TypingBubble } from './TypingBubble'; +import { ContactSpoofingType } from '../../util/contactSpoofing'; const i18n = setupI18n('en', enMessages); @@ -224,6 +227,9 @@ const items: Record = { } as any; const actions = () => ({ + acknowledgeGroupMemberNameCollisions: action( + 'acknowledgeGroupMemberNameCollisions' + ), clearChangedMessages: action('clearChangedMessages'), clearInvitedConversationsForNewlyCreatedGroup: action( 'clearInvitedConversationsForNewlyCreatedGroup' @@ -275,6 +281,7 @@ const actions = () => ({ contactSupport: action('contactSupport'), closeContactSpoofingReview: action('closeContactSpoofingReview'), + reviewGroupMemberNameCollision: action('reviewGroupMemberNameCollision'), reviewMessageRequestNameCollision: action( 'reviewMessageRequestNameCollision' ), @@ -283,6 +290,7 @@ const actions = () => ({ onBlockAndReportSpam: action('onBlockAndReportSpam'), onDelete: action('onDelete'), onUnblock: action('onUnblock'), + removeMember: action('removeMember'), unblurAvatar: action('unblurAvatar'), }); @@ -374,7 +382,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ overrideProps.invitedContactsForNewlyCreatedGroup || [], warning: overrideProps.warning, - id: '', + id: uuid(), renderItem, renderLastSeenIndicator, renderHeroRow, @@ -478,9 +486,10 @@ story.add('With invited contacts for a newly-created group', () => { return ; }); -story.add('With "same name" warning', () => { +story.add('With "same name in direct conversation" warning', () => { const props = createProps({ warning: { + type: ContactSpoofingType.DirectConversationWithSameTitle, safeConversation: getDefaultConversation(), }, items: [], @@ -488,3 +497,19 @@ story.add('With "same name" warning', () => { return ; }); + +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 ; +}); diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index b719986e8..1a90ca7d7 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -3,7 +3,7 @@ import { debounce, get, isNumber } from 'lodash'; import classNames from 'classnames'; -import React, { CSSProperties, ReactNode } from 'react'; +import React, { CSSProperties, ReactChild, ReactNode } from 'react'; import { AutoSizer, CellMeasurer, @@ -20,6 +20,7 @@ import { GlobalAudioProvider } from '../GlobalAudioContext'; import { LocalizerType } from '../../types/Util'; import { ConversationType } from '../../state/ducks/conversations'; import { assert } from '../../util/assert'; +import { missingCaseError } from '../../util/missingCaseError'; import { PropsActions as MessageActionsType } from './Message'; import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification'; @@ -27,7 +28,12 @@ import { Intl } from '../Intl'; import { TimelineWarning } from './TimelineWarning'; import { TimelineWarnings } from './TimelineWarnings'; import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog'; +import { ContactSpoofingType } from '../../util/contactSpoofing'; import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog'; +import { + GroupNameCollisionsWithIdsByTitle, + hasUnacknowledgedCollisions, +} from '../../util/groupMemberNameCollisions'; const AT_BOTTOM_THRESHOLD = 15; const NEAR_BOTTOM_THRESHOLD = 15; @@ -36,9 +42,33 @@ const LOAD_MORE_THRESHOLD = 30; const SCROLL_DOWN_BUTTON_THRESHOLD = 8; export const LOAD_COUNTDOWN = 1; -export type WarningType = { - safeConversation: ConversationType; -}; +export type WarningType = + | { + type: ContactSpoofingType.DirectConversationWithSameTitle; + safeConversation: ConversationType; + } + | { + type: ContactSpoofingType.MultipleGroupMembersWithSameTitle; + acknowledgedGroupNameCollisions: GroupNameCollisionsWithIdsByTitle; + groupNameCollisions: GroupNameCollisionsWithIdsByTitle; + }; + +export type ContactSpoofingReviewPropType = + | { + type: ContactSpoofingType.DirectConversationWithSameTitle; + possiblyUnsafeConversation: ConversationType; + safeConversation: ConversationType; + } + | { + type: ContactSpoofingType.MultipleGroupMembersWithSameTitle; + collisionInfoByTitle: Record< + string, + Array<{ + oldName?: string; + conversation: ConversationType; + }> + >; + }; export type PropsDataType = { haveNewest: boolean; @@ -57,6 +87,7 @@ export type PropsDataType = { type PropsHousekeepingType = { id: string; + areWeAdmin?: boolean; isGroupV1AndDisabled?: boolean; isIncomingMessageRequest: boolean; typingContact?: unknown; @@ -66,10 +97,7 @@ type PropsHousekeepingType = { invitedContactsForNewlyCreatedGroup: Array; warning?: WarningType; - contactSpoofingReview?: { - possiblyUnsafeConversation: ConversationType; - safeConversation: ConversationType; - }; + contactSpoofingReview?: ContactSpoofingReviewPropType; i18n: LocalizerType; @@ -90,6 +118,9 @@ type PropsHousekeepingType = { }; type PropsActionsType = { + acknowledgeGroupMemberNameCollisions: ( + groupNameCollisions: Readonly + ) => void; clearChangedMessages: (conversationId: string) => unknown; clearInvitedConversationsForNewlyCreatedGroup: () => void; closeContactSpoofingReview: () => void; @@ -98,6 +129,7 @@ type PropsActionsType = { loadCountdownStart?: number ) => unknown; setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown; + reviewGroupMemberNameCollision: (groupConversationId: string) => void; reviewMessageRequestNameCollision: ( _: Readonly<{ safeConversationId: string; @@ -109,10 +141,11 @@ type PropsActionsType = { loadNewerMessages: (messageId: string) => unknown; loadNewestMessages: (messageId: string, setFocus?: boolean) => unknown; markMessageRead: (messageId: string) => unknown; - onBlock: () => unknown; - onBlockAndReportSpam: () => unknown; - onDelete: () => unknown; - onUnblock: () => unknown; + onBlock: (conversationId: string) => unknown; + onBlockAndReportSpam: (conversationId: string) => unknown; + onDelete: (conversationId: string) => unknown; + onUnblock: (conversationId: string) => unknown; + removeMember: (conversationId: string) => unknown; selectMessage: (messageId: string, conversationId: string) => unknown; clearSelectedMessage: () => unknown; unblurAvatar: () => void; @@ -172,7 +205,7 @@ type StateType = { shouldShowScrollDownButton: boolean; areUnreadBelowCurrentPosition: boolean; - hasDismissedWarning: boolean; + hasDismissedDirectContactSpoofingWarning: boolean; lastMeasuredWarningHeight: number; }; @@ -215,7 +248,7 @@ export class Timeline extends React.PureComponent { prevPropScrollToIndex: scrollToIndex, shouldShowScrollDownButton: false, areUnreadBelowCurrentPosition: false, - hasDismissedWarning: false, + hasDismissedDirectContactSpoofingWarning: false, lastMeasuredWarningHeight: 0, }; } @@ -892,7 +925,7 @@ export class Timeline extends React.PureComponent { // Warnings can increase the size of the first row (adding padding for the floating // warning), so we recompute it when the warnings change. const hadWarning = Boolean( - prevProps.warning && !prevState.hasDismissedWarning + prevProps.warning && !prevState.hasDismissedDirectContactSpoofingWarning ); if (hadWarning !== Boolean(this.getWarning())) { this.recomputeRowHeights(0); @@ -1159,6 +1192,8 @@ export class Timeline extends React.PureComponent { public render(): JSX.Element | null { const { + acknowledgeGroupMemberNameCollisions, + areWeAdmin, clearInvitedConversationsForNewlyCreatedGroup, closeContactSpoofingReview, contactSpoofingReview, @@ -1172,6 +1207,8 @@ export class Timeline extends React.PureComponent { onDelete, onUnblock, showContactModal, + removeMember, + reviewGroupMemberNameCollision, reviewMessageRequestNameCollision, } = this.props; const { @@ -1227,6 +1264,69 @@ export class Timeline extends React.PureComponent { const warning = this.getWarning(); let timelineWarning: ReactNode; if (warning) { + let text: ReactChild; + let onClose: () => void; + switch (warning.type) { + case ContactSpoofingType.DirectConversationWithSameTitle: + text = ( + { + reviewMessageRequestNameCollision({ + safeConversationId: warning.safeConversation.id, + }); + }} + > + {i18n('ContactSpoofing__same-name__link')} + + ), + }} + /> + ); + onClose = () => { + this.setState({ + hasDismissedDirectContactSpoofingWarning: true, + }); + }; + break; + case ContactSpoofingType.MultipleGroupMembersWithSameTitle: { + const { groupNameCollisions } = warning; + text = ( + result + conversations.length, + 0 + ) + .toString(), + link: ( + { + reviewGroupMemberNameCollision(id); + }} + > + {i18n('ContactSpoofing__same-name-in-group__link')} + + ), + }} + /> + ); + onClose = () => { + acknowledgeGroupMemberNameCollisions(groupNameCollisions); + }; + break; + } + default: + throw missingCaseError(warning); + } + timelineWarning = ( { > {({ measureRef }) => ( - { - this.setState({ hasDismissedWarning: true }); - }} - > + - - { - reviewMessageRequestNameCollision({ - safeConversationId: warning.safeConversation.id, - }); - }} - > - {i18n('ContactSpoofing__same-name__link')} - - ), - }} - /> - + {text} )} @@ -1275,6 +1352,47 @@ export class Timeline extends React.PureComponent { ); } + let contactSpoofingReviewDialog: ReactNode; + if (contactSpoofingReview) { + const commonProps = { + i18n, + onBlock, + onBlockAndReportSpam, + onClose: closeContactSpoofingReview, + onDelete, + onShowContactModal: showContactModal, + onUnblock, + removeMember, + }; + + switch (contactSpoofingReview.type) { + case ContactSpoofingType.DirectConversationWithSameTitle: + contactSpoofingReviewDialog = ( + + ); + break; + case ContactSpoofingType.MultipleGroupMembersWithSameTitle: + contactSpoofingReviewDialog = ( + + ); + break; + default: + throw missingCaseError(contactSpoofingReview); + } + } + return ( <>
{ /> )} - {contactSpoofingReview && ( - - )} + {contactSpoofingReviewDialog} ); } private getWarning(): undefined | WarningType { - const { hasDismissedWarning } = this.state; - if (hasDismissedWarning) { + const { warning } = this.props; + if (!warning) { return undefined; } - const { warning } = this.props; - return warning; + switch (warning.type) { + case ContactSpoofingType.DirectConversationWithSameTitle: { + const { hasDismissedDirectContactSpoofingWarning } = this.state; + return hasDismissedDirectContactSpoofingWarning ? undefined : warning; + } + case ContactSpoofingType.MultipleGroupMembersWithSameTitle: + return hasUnacknowledgedCollisions( + warning.acknowledgedGroupNameCollisions, + warning.groupNameCollisions + ) + ? warning + : undefined; + default: + throw missingCaseError(warning); + } } } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index 801c46f6e..e7f74a8bb 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -25,6 +25,7 @@ import { MessageModel } from './models/messages'; import { ConversationModel } from './models/conversations'; import { ProfileNameChangeType } from './util/getStringForProfileChange'; import { CapabilitiesType } from './textsecure/WebAPI'; +import { GroupNameCollisionsWithIdsByTitle } from './util/groupMemberNameCollisions'; export type WhatIsThis = any; @@ -298,6 +299,7 @@ export type ConversationAttributesType = { groupInviteLinkPassword?: string; previousGroupV1Id?: string; previousGroupV1Members?: Array; + acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle; // Used only when user is waiting for approval to join via link isTemporary?: boolean; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 47bb789fd..9370c5f77 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -56,6 +56,7 @@ import { sendReadReceiptsFor } from '../util/sendReadReceiptsFor'; import { updateConversationsWithUuidLookup } from '../updateConversationsWithUuidLookup'; import { filter, map, take } from '../util/iterables'; import * as universalExpireTimer from '../util/universalExpireTimer'; +import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -1531,6 +1532,8 @@ export class ConversationModel extends window.Backbone } : { type: 'group' as const, + acknowledgedGroupNameCollisions: + this.get('acknowledgedGroupNameCollisions') || {}, sharedGroupNames: [], }), }; @@ -5231,6 +5234,13 @@ export class ConversationModel extends window.Backbone me.captureChange('pin'); } } + + acknowledgeGroupMemberNameCollisions( + groupNameCollisions: Readonly + ): void { + this.set('acknowledgedGroupNameCollisions', groupNameCollisions); + window.Signal.Data.updateConversation(this.attributes); + } } window.Whisper.Conversation = ConversationModel; diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index cb33b24cc..f18be026a 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -36,6 +36,8 @@ import { getGroupSizeHardLimit, } from '../../groups/limits'; import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition'; +import { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions'; +import { ContactSpoofingType } from '../../util/contactSpoofing'; // State @@ -145,6 +147,7 @@ export type ConversationType = { acceptedMessageRequest: boolean; secretParams?: string; publicParams?: string; + acknowledgedGroupNameCollisions?: GroupNameCollisionsWithIdsByTitle; }; export type ConversationLookupType = { [key: string]: ConversationType; @@ -279,9 +282,15 @@ type ComposerStateType = | { isCreating: true; hasError: false } )); -type ContactSpoofingReviewStateType = { - safeConversationId: string; -}; +type ContactSpoofingReviewStateType = + | { + type: ContactSpoofingType.DirectConversationWithSameTitle; + safeConversationId: string; + } + | { + type: ContactSpoofingType.MultipleGroupMembersWithSameTitle; + groupConversationId: string; + }; export type ConversationsStateType = { preJoinConversation?: PreJoinConversationType; @@ -541,6 +550,12 @@ export type SelectedConversationChangedActionType = { messageId?: string; }; }; +type ReviewGroupMemberNameCollisionActionType = { + type: 'REVIEW_GROUP_MEMBER_NAME_COLLISION'; + payload: { + groupConversationId: string; + }; +}; type ReviewMessageRequestNameCollisionActionType = { type: 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION'; payload: { @@ -624,6 +639,7 @@ export type ConversationActionType = | RemoveAllConversationsActionType | RepairNewestMessageActionType | RepairOldestMessageActionType + | ReviewGroupMemberNameCollisionActionType | ReviewMessageRequestNameCollisionActionType | ScrollToMessageActionType | SelectedConversationChangedActionType @@ -675,6 +691,7 @@ export const actions = { repairNewestMessage, repairOldestMessage, resetAllChatColors, + reviewGroupMemberNameCollision, reviewMessageRequestNameCollision, scrollToMessage, selectMessage, @@ -1001,6 +1018,15 @@ function repairOldestMessage( }; } +function reviewGroupMemberNameCollision( + groupConversationId: string +): ReviewGroupMemberNameCollisionActionType { + return { + type: 'REVIEW_GROUP_MEMBER_NAME_COLLISION', + payload: { groupConversationId }, + }; +} + function reviewMessageRequestNameCollision( payload: Readonly<{ 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') { return { ...state, - contactSpoofingReview: action.payload, + contactSpoofingReview: { + type: ContactSpoofingType.DirectConversationWithSameTitle, + ...action.payload, + }, }; } diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index 9f1c8f611..39392ea5a 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -1,11 +1,12 @@ // Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { pick } from 'lodash'; +import { isEmpty, mapValues, pick } from 'lodash'; import React from 'react'; import { connect } from 'react-redux'; import { mapDispatchToProps } from '../actions'; import { + ContactSpoofingReviewPropType, Timeline, WarningType as TimelineWarningType, } from '../../components/conversation/Timeline'; @@ -14,6 +15,7 @@ import { ConversationType } from '../ducks/conversations'; import { getIntl } from '../selectors/user'; import { + getConversationByIdSelector, getConversationMessagesSelector, getConversationSelector, getConversationsByTitleSelector, @@ -29,7 +31,16 @@ import { SmartTimelineLoadingRow } from './TimelineLoadingRow'; import { renderAudioAttachment } from './renderAudioAttachment'; import { renderEmojiPicker } from './renderEmojiPicker'; +import { getOwn } from '../../util/getOwn'; 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() // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 @@ -90,54 +101,117 @@ const getWarning = ( conversation: Readonly, state: Readonly ): undefined | TimelineWarningType => { - if ( - conversation.type === 'direct' && - !conversation.acceptedMessageRequest && - !conversation.isBlocked - ) { - const getConversationsWithTitle = getConversationsByTitleSelector(state); - const conversationsWithSameTitle = getConversationsWithTitle( - conversation.title - ); - assert( - conversationsWithSameTitle.length, - 'Expected at least 1 conversation with the same title (this one)' - ); + switch (conversation.type) { + case 'direct': + if (!conversation.acceptedMessageRequest && !conversation.isBlocked) { + const getConversationsWithTitle = getConversationsByTitleSelector( + state + ); + const conversationsWithSameTitle = getConversationsWithTitle( + conversation.title + ); + assert( + conversationsWithSameTitle.length, + 'Expected at least 1 conversation with the same title (this one)' + ); - const safeConversation = conversationsWithSameTitle.find( - otherConversation => - otherConversation.acceptedMessageRequest && - otherConversation.type === 'direct' && - otherConversation.id !== conversation.id - ); + const safeConversation = conversationsWithSameTitle.find( + otherConversation => + otherConversation.acceptedMessageRequest && + otherConversation.type === 'direct' && + 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 = ( selectedConversationId: string, state: Readonly -): - | undefined - | { - possiblyUnsafeConversation: ConversationType; - safeConversation: ConversationType; - } => { +): undefined | ContactSpoofingReviewPropType => { const { contactSpoofingReview } = state.conversations; if (!contactSpoofingReview) { return undefined; } const conversationSelector = getConversationSelector(state); - return { - possiblyUnsafeConversation: conversationSelector(selectedConversationId), - safeConversation: conversationSelector( - contactSpoofingReview.safeConversationId - ), - }; + const getConversationById = getConversationByIdSelector(state); + + const currentConversation = conversationSelector(selectedConversationId); + + 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) => { @@ -150,6 +224,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { return { id, ...pick(conversation, [ + 'areWeAdmin', 'unreadCount', 'typingContact', 'isGroupV1AndDisabled', diff --git a/ts/test-both/util/groupMemberNameCollisions_test.ts b/ts/test-both/util/groupMemberNameCollisions_test.ts new file mode 100644 index 000000000..f3a574e09 --- /dev/null +++ b/ts/test-both/util/groupMemberNameCollisions_test.ts @@ -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', + } + ); + }); + }); +}); diff --git a/ts/test-both/util/isConversationNameKnown_test.ts b/ts/test-both/util/isConversationNameKnown_test.ts new file mode 100644 index 000000000..9845d96c1 --- /dev/null +++ b/ts/test-both/util/isConversationNameKnown_test.ts @@ -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' })); + }); + }); +}); diff --git a/ts/test-both/util/iterables_test.ts b/ts/test-both/util/iterables_test.ts index 82a77c4ef..02f307d8d 100644 --- a/ts/test-both/util/iterables_test.ts +++ b/ts/test-both/util/iterables_test.ts @@ -7,6 +7,7 @@ import * as sinon from 'sinon'; import { concat, filter, + groupBy, isIterable, map, 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', () => { it('returns an empty iterable when passed an empty iterable', () => { const fn = sinon.fake(); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 915815ef2..3fa54b6bd 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -23,6 +23,7 @@ import { reducer, updateConversationLookups, } from '../../../state/ducks/conversations'; +import { ContactSpoofingType } from '../../../util/contactSpoofing'; import { CallMode } from '../../../types/Calling'; import * as groups from '../../../groups'; import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; @@ -51,6 +52,7 @@ const { showChooseGroupMembers, startSettingGroupMetadata, resetAllChatColors, + reviewGroupMemberNameCollision, reviewMessageRequestNameCollision, toggleConversationInChooseMembers, } = actions; @@ -537,6 +539,7 @@ describe('both/state/ducks/conversations', () => { const state = { ...getEmptyState(), contactSpoofingReview: { + type: ContactSpoofingType.DirectConversationWithSameTitle as const, 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', () => { it('starts reviewing a message request name collision', () => { const state = getEmptyState(); @@ -1165,6 +1181,7 @@ describe('both/state/ducks/conversations', () => { const actual = reducer(state, action); assert.deepEqual(actual.contactSpoofingReview, { + type: ContactSpoofingType.DirectConversationWithSameTitle as const, safeConversationId: 'def', }); }); diff --git a/ts/util/contactSpoofing.ts b/ts/util/contactSpoofing.ts new file mode 100644 index 000000000..865873ea9 --- /dev/null +++ b/ts/util/contactSpoofing.ts @@ -0,0 +1,7 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +export enum ContactSpoofingType { + DirectConversationWithSameTitle, + MultipleGroupMembersWithSameTitle, +} diff --git a/ts/util/groupMemberNameCollisions.ts b/ts/util/groupMemberNameCollisions.ts new file mode 100644 index 000000000..a46fbae9f --- /dev/null +++ b/ts/util/groupMemberNameCollisions.ts @@ -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>; +export type GroupNameCollisionsWithConversationsByTitle = Record< + string, + Array +>; +export type GroupNameCollisionsWithTitlesById = Record; + +export const dehydrateCollisionsWithConversations = ( + withConversations: Readonly +): 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, + current: Readonly +): 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 +): GroupNameCollisionsWithTitlesById => { + const result: GroupNameCollisionsWithTitlesById = Object.create(null); + Object.entries(idsByTitle).forEach(([title, ids]) => { + ids.forEach(id => { + result[id] = title; + }); + }); + return result; +}; diff --git a/ts/util/isConversationNameKnown.ts b/ts/util/isConversationNameKnown.ts new file mode 100644 index 000000000..428ae3033 --- /dev/null +++ b/ts/util/isConversationNameKnown.ts @@ -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 + > +): 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); + } +} diff --git a/ts/util/iterables.ts b/ts/util/iterables.ts index 68d4df294..e079b063e 100644 --- a/ts/util/iterables.ts +++ b/ts/util/iterables.ts @@ -4,6 +4,8 @@ /* eslint-disable max-classes-per-file */ /* eslint-disable no-restricted-syntax */ +import { getOwn } from './getOwn'; + export function isIterable(value: unknown): value is Iterable { return ( (typeof value === 'object' && value !== null && Symbol.iterator in value) || @@ -88,6 +90,23 @@ class FilterIterator implements Iterator { } } +export function groupBy( + iterable: Iterable, + fn: (value: T) => string +): Record> { + const result: Record> = 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( iterable: Iterable, fn: (value: T) => ResultT diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 570fa417e..7448d0435 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -15,6 +15,7 @@ import { assert } from '../util/assert'; import { maybeParseUrl } from '../util/url'; import { addReportSpamJob } from '../jobs/helpers/addReportSpamJob'; import { reportSpamJobQueue } from '../jobs/reportSpamJobQueue'; +import { GroupNameCollisionsWithIdsByTitle } from '../util/groupMemberNameCollisions'; type GetLinkPreviewImageResult = { data: ArrayBuffer; @@ -598,6 +599,8 @@ Whisper.ConversationView = Whisper.View.extend({ }, setupCompositionArea({ attachmentListEl }: any) { + const { model }: { model: ConversationModel } = this; + const compositionApi = { current: null }; this.compositionApi = compositionApi; @@ -632,19 +635,35 @@ Whisper.ConversationView = Whisper.View.extend({ micCellEl, attachmentListEl, onAccept: () => { - this.syncMessageRequestResponse('onAccept', messageRequestEnum.ACCEPT); + this.syncMessageRequestResponse( + 'onAccept', + model, + messageRequestEnum.ACCEPT + ); }, onBlock: () => { - this.syncMessageRequestResponse('onBlock', messageRequestEnum.BLOCK); + this.syncMessageRequestResponse( + 'onBlock', + model, + messageRequestEnum.BLOCK + ); }, onUnblock: () => { - this.syncMessageRequestResponse('onUnblock', messageRequestEnum.ACCEPT); + this.syncMessageRequestResponse( + 'onUnblock', + model, + messageRequestEnum.ACCEPT + ); }, onDelete: () => { - this.syncMessageRequestResponse('onDelete', messageRequestEnum.DELETE); + this.syncMessageRequestResponse( + 'onDelete', + model, + messageRequestEnum.DELETE + ); }, onBlockAndReportSpam: () => { - this.blockAndReportSpam(); + this.blockAndReportSpam(model); }, onStartGroupMigration: () => this.startMigrationToGV2(), onCancelJoinRequest: async () => { @@ -949,6 +968,21 @@ Whisper.ConversationView = Whisper.View.extend({ 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({ className: 'timeline-wrapper', JSX: window.Signal.State.Roots.createTimeline(window.reduxStore, { @@ -956,31 +990,51 @@ Whisper.ConversationView = Whisper.View.extend({ ...this.getMessageActions(), + acknowledgeGroupMemberNameCollisions: ( + groupNameCollisions: Readonly + ): void => { + const { model }: { model: ConversationModel } = this; + model.acknowledgeGroupMemberNameCollisions(groupNameCollisions); + }, contactSupport, loadNewerMessages, loadNewestMessages: this.loadNewestMessages.bind(this), loadAndScroll: this.loadAndScroll.bind(this), loadOlderMessages, markMessageRead, - onBlock: () => { - this.syncMessageRequestResponse('onBlock', messageRequestEnum.BLOCK); - }, - onBlockAndReportSpam: () => { - this.blockAndReportSpam(); - }, - onDelete: () => { - this.syncMessageRequestResponse( - 'onDelete', - messageRequestEnum.DELETE - ); - }, - onUnblock: () => { - this.syncMessageRequestResponse( - 'onUnblock', - messageRequestEnum.ACCEPT + onBlock: createMessageRequestResponseHandler( + 'onBlock', + messageRequestEnum.BLOCK + ), + onBlockAndReportSpam: (conversationId: string) => { + const conversation = window.ConversationController.get( + conversationId ); + 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), + removeMember: (conversationId: string) => { + const { model }: { model: ConversationModel } = this; + this.longRunningTaskWrapper({ + name: 'removeMember', + task: () => model.removeFromGroupV2(conversationId), + }); + }, scrollToQuotedMessage, unblurAvatar: () => { this.model.unblurAvatar(); @@ -1452,19 +1506,18 @@ Whisper.ConversationView = Whisper.View.extend({ syncMessageRequestResponse( name: string, + model: ConversationModel, messageRequestType: number ): Promise { - const { model }: { model: ConversationModel } = this; return this.longRunningTaskWrapper({ name, task: model.syncMessageRequestResponse.bind(model, messageRequestType), }); }, - blockAndReportSpam(): Promise { + blockAndReportSpam(model: ConversationModel): Promise { const messageRequestEnum = window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; - const { model }: { model: ConversationModel } = this; return this.longRunningTaskWrapper({ name: 'blockAndReportSpam', @@ -3208,7 +3261,11 @@ Whisper.ConversationView = Whisper.View.extend({ }; const onBlock = () => { - this.syncMessageRequestResponse('onBlock', messageRequestEnum.BLOCK); + this.syncMessageRequestResponse( + 'onBlock', + conversation, + messageRequestEnum.BLOCK + ); }; const ACCESS_ENUM = window.textsecure.protobuf.AccessControl.AccessRequired;