diff --git a/_locales/en/messages.json b/_locales/en/messages.json index da77ee1da..8360d2870 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -5164,5 +5164,35 @@ "ForwardMessageModal--continue": { "message": "Continue", "description": "aria-label for the 'next' button in the forward a message modal dialog" + }, + "ContactSpoofing__same-name": { + "message": "Review requests carefully. Signal found another contact with the same name. $link$", + "description": "Shown in the timeline warning when you have a message request from someone with the same name as someone else", + "placeholders": { + "link": { + "content": "$1", + "example": "Review request" + } + } + }, + "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" + }, + "ContactSpoofingReviewDialog__title": { + "message": "Review request", + "description": "Title for the contact name spoofing review dialog" + }, + "ContactSpoofingReviewDialog__description": { + "message": "If you're not sure who the request is from, review the contacts below and take action.", + "description": "Description for the contact spoofing review dialog" + }, + "ContactSpoofingReviewDialog__possibly-unsafe-title": { + "message": "Request", + "description": "Header in the contact spoofing review dialog, shown above the potentially-unsafe user" + }, + "ContactSpoofingReviewDialog__safe-title": { + "message": "Your contact", + "description": "Header in the contact spoofing review dialog, shown above the \"safe\" user" } } diff --git a/stylesheets/components/ContactSpoofingReviewDialog.scss b/stylesheets/components/ContactSpoofingReviewDialog.scss new file mode 100644 index 000000000..bede0f986 --- /dev/null +++ b/stylesheets/components/ContactSpoofingReviewDialog.scss @@ -0,0 +1,40 @@ +.module-ContactSpoofingReviewDialog { + user-select: none; + + p { + @include font-body-2; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-05; + } + } + + h2 { + @include font-body-1-bold; + } + + hr { + border: 0; + height: 1px; + margin: 16px 0; + + @include light-theme { + background: $color-gray-05; + } + @include dark-theme { + background: $color-gray-90; + } + } + + &__buttons { + margin-top: 8px; + + .module-Button:not(:last-child) { + margin-right: 12px; + } + } +} diff --git a/stylesheets/components/ContactSpoofingReviewDialogPerson.scss b/stylesheets/components/ContactSpoofingReviewDialogPerson.scss new file mode 100644 index 000000000..53f24fa2c --- /dev/null +++ b/stylesheets/components/ContactSpoofingReviewDialogPerson.scss @@ -0,0 +1,27 @@ +.module-ContactSpoofingReviewDialogPerson { + display: flex; + + &:is(button) { + @include button-reset; + } + + &__info { + margin-left: 12px; + + &__contact-name { + @include font-body-1; + } + + &__property { + @include font-body-2; + + @include light-theme { + color: $color-gray-60; + } + + @include dark-theme { + color: $color-gray-05; + } + } + } +} diff --git a/stylesheets/components/TimelineWarning.scss b/stylesheets/components/TimelineWarning.scss new file mode 100644 index 000000000..75870bab4 --- /dev/null +++ b/stylesheets/components/TimelineWarning.scss @@ -0,0 +1,76 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-TimelineWarning { + @mixin icon($icon) { + @include light-theme { + @include color-svg($icon, $color-gray-60); + } + @include dark-theme { + @include color-svg($icon, $color-gray-20); + } + } + + align-items: center; + display: flex; + padding: 16px; + user-select: none; + + border-top-width: 1px; + border-top-style: solid; + &:last-child { + border-bottom-width: 1px; + border-bottom-style: solid; + } + + @include light-theme { + color: $color-gray-65; + background: $color-gray-02; + border-color: $color-gray-15; + } + + @include dark-theme { + color: $color-gray-15; + background: $color-gray-80; + border-color: $color-gray-65; + } + + &__generic-icon { + @include icon('../images/icons/v2/info-outline-24.svg'); + width: 20px; + height: 20px; + } + + &__text { + @include font-body-2; + flex-grow: 1; + margin-left: 12px; + margin-right: 12px; + + &__link { + @include button-reset; + display: inline; + font-weight: bold; + text-decoration: none; + + @include light-theme { + color: $ultramarine-brand-light; + } + @include dark-theme { + color: $color-ios-blue-tint; + } + } + } + + &__close-button { + @include button-reset; + + &::after { + @include icon('../images/icons/v2/x-24.svg'); + content: ''; + display: block; + height: 20px; + width: 20px; + } + } +} diff --git a/stylesheets/components/TimelineWarnings.scss b/stylesheets/components/TimelineWarnings.scss new file mode 100644 index 000000000..4bb641b07 --- /dev/null +++ b/stylesheets/components/TimelineWarnings.scss @@ -0,0 +1,13 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.module-TimelineWarnings { + left: 0; + position: absolute; + top: 0; + width: 100%; + z-index: 1; + + display: flex; + flex-direction: column; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 20e663e2f..79bd15bcc 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -32,6 +32,8 @@ @import './components/Button.scss'; @import './components/ContactPill.scss'; @import './components/ContactPills.scss'; +@import './components/ContactSpoofingReviewDialog.scss'; +@import './components/ContactSpoofingReviewDialogPerson.scss'; @import './components/ConversationHeader.scss'; @import './components/EditConversationAttributesModal.scss'; @import './components/ForwardMessageModal.scss'; @@ -43,3 +45,5 @@ @import './components/SafetyNumberViewer.scss'; @import './components/SearchResultsLoadingFakeHeader.scss'; @import './components/SearchResultsLoadingFakeRow.scss'; +@import './components/TimelineWarning.scss'; +@import './components/TimelineWarnings.scss'; diff --git a/ts/components/Modal.tsx b/ts/components/Modal.tsx index 441fe2a01..da9905eea 100644 --- a/ts/components/Modal.tsx +++ b/ts/components/Modal.tsx @@ -13,6 +13,7 @@ type PropsType = { children: ReactNode; hasXButton?: boolean; i18n: LocalizerType; + moduleClassName?: string; onClose?: () => void; title?: ReactNode; theme?: Theme; @@ -22,6 +23,7 @@ export function Modal({ children, hasXButton, i18n, + moduleClassName, onClose = noop, title, theme, @@ -35,7 +37,8 @@ export function Modal({
{hasHeader && ( diff --git a/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx b/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx new file mode 100644 index 000000000..e63fb8644 --- /dev/null +++ b/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx @@ -0,0 +1,32 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { storiesOf } from '@storybook/react'; +import { setup as setupI18n } from '../../../js/modules/i18n'; +import enMessages from '../../../_locales/en/messages.json'; +import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; + +import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog'; + +const i18n = setupI18n('en', enMessages); + +const story = storiesOf( + 'Components/Conversation/ContactSpoofingReviewDialog', + module +); + +story.add('Default', () => ( + +)); diff --git a/ts/components/conversation/ContactSpoofingReviewDialog.tsx b/ts/components/conversation/ContactSpoofingReviewDialog.tsx new file mode 100644 index 000000000..9521f7f6d --- /dev/null +++ b/ts/components/conversation/ContactSpoofingReviewDialog.tsx @@ -0,0 +1,117 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, useState } from 'react'; + +import { LocalizerType } from '../../types/Util'; +import { ConversationType } from '../../state/ducks/conversations'; +import { + MessageRequestActionsConfirmation, + MessageRequestState, +} from './MessageRequestActionsConfirmation'; + +import { Modal } from '../Modal'; +import { ContactSpoofingReviewDialogPerson } from './ContactSpoofingReviewDialogPerson'; +import { Button, ButtonVariant } from '../Button'; +import { assert } from '../../util/assert'; + +type PropsType = { + i18n: LocalizerType; + onBlock: () => unknown; + onBlockAndDelete: () => unknown; + onClose: () => void; + onDelete: () => unknown; + onShowContactModal: (contactId: string) => unknown; + onUnblock: () => unknown; + possiblyUnsafeConversation: ConversationType; + safeConversation: ConversationType; +}; + +export const ContactSpoofingReviewDialog: FunctionComponent = ({ + i18n, + onBlock, + onBlockAndDelete, + 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' + ); + + const [messageRequestState, setMessageRequestState] = useState( + MessageRequestState.default + ); + + if (messageRequestState !== MessageRequestState.default) { + return ( + + ); + } + + return ( + +

{i18n('ContactSpoofingReviewDialog__description')}

+

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

+ +
+ + +
+
+
+

{i18n('ContactSpoofingReviewDialog__safe-title')}

+ { + onShowContactModal(safeConversation.id); + }} + /> +
+ ); +}; diff --git a/ts/components/conversation/ContactSpoofingReviewDialogPerson.tsx b/ts/components/conversation/ContactSpoofingReviewDialogPerson.tsx new file mode 100644 index 000000000..5103332d0 --- /dev/null +++ b/ts/components/conversation/ContactSpoofingReviewDialogPerson.tsx @@ -0,0 +1,78 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { FunctionComponent, ReactNode } from 'react'; + +import { ConversationType } from '../../state/ducks/conversations'; +import { LocalizerType } from '../../types/Util'; +import { assert } from '../../util/assert'; + +import { Avatar, AvatarSize } from '../Avatar'; +import { ContactName } from './ContactName'; +import { SharedGroupNames } from '../SharedGroupNames'; + +type PropsType = { + children?: ReactNode; + conversation: ConversationType; + i18n: LocalizerType; + onClick?: () => void; +}; + +export const ContactSpoofingReviewDialogPerson: FunctionComponent = ({ + children, + conversation, + i18n, + onClick, +}) => { + assert( + conversation.type === 'direct', + ' expected a direct conversation' + ); + + const contents = ( + <> + +
+ + {conversation.phoneNumber ? ( +
+ {conversation.phoneNumber} +
+ ) : null} +
+ +
+ {children} +
+ + ); + + if (onClick) { + return ( + + ); + } + + return ( +
{contents}
+ ); +}; diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 7dcad2713..8a7a833dd 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -11,6 +11,7 @@ import enMessages from '../../../_locales/en/messages.json'; import { PropsType, Timeline } from './Timeline'; import { TimelineItem, TimelineItemType } from './TimelineItem'; import { ConversationHero } from './ConversationHero'; +import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation'; import { LastSeenIndicator } from './LastSeenIndicator'; import { TimelineLoadingRow } from './TimelineLoadingRow'; import { TypingBubble } from './TypingBubble'; @@ -260,6 +261,16 @@ const actions = () => ({ returnToActiveCall: action('returnToActiveCall'), contactSupport: action('contactSupport'), + + closeContactSpoofingReview: action('closeContactSpoofingReview'), + reviewMessageRequestNameCollision: action( + 'reviewMessageRequestNameCollision' + ), + + onBlock: action('onBlock'), + onBlockAndDelete: action('onBlockAndDelete'), + onDelete: action('onDelete'), + onUnblock: action('onUnblock'), }); const renderItem = (id: string) => ( @@ -330,6 +341,7 @@ const createProps = (overrideProps: Partial = {}): PropsType => ({ undefined, invitedContactsForNewlyCreatedGroup: overrideProps.invitedContactsForNewlyCreatedGroup || [], + warning: overrideProps.warning, id: '', renderItem, @@ -419,3 +431,14 @@ story.add('With invited contacts for a newly-created group', () => { return ; }); + +story.add('With "same name" warning', () => { + const props = createProps({ + warning: { + safeConversation: getDefaultConversation(), + }, + items: [], + }); + + return ; +}); diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index e7759d8b4..efdbbad06 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -1,9 +1,9 @@ -// Copyright 2019-2020 Signal Messenger, LLC +// Copyright 2019-2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import { debounce, get, isNumber } from 'lodash'; import classNames from 'classnames'; -import React, { CSSProperties } from 'react'; +import React, { CSSProperties, ReactNode } from 'react'; import { AutoSizer, CellMeasurer, @@ -11,6 +11,7 @@ import { List, Grid, } from 'react-virtualized'; +import Measure from 'react-measure'; import { ScrollDownButton } from './ScrollDownButton'; @@ -18,10 +19,15 @@ import { GlobalAudioProvider } from '../GlobalAudioContext'; import { LocalizerType } from '../../types/Util'; import { ConversationType } from '../../state/ducks/conversations'; +import { assert } from '../../util/assert'; import { PropsActions as MessageActionsType } from './Message'; import { PropsActions as SafetyNumberActionsType } from './SafetyNumberNotification'; +import { Intl } from '../Intl'; +import { TimelineWarning } from './TimelineWarning'; +import { TimelineWarnings } from './TimelineWarnings'; import { NewlyCreatedGroupInvitedContactsDialog } from '../NewlyCreatedGroupInvitedContactsDialog'; +import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog'; const AT_BOTTOM_THRESHOLD = 15; const NEAR_BOTTOM_THRESHOLD = 15; @@ -30,6 +36,10 @@ const LOAD_MORE_THRESHOLD = 30; const SCROLL_DOWN_BUTTON_THRESHOLD = 8; export const LOAD_COUNTDOWN = 1; +export type WarningType = { + safeConversation: ConversationType; +}; + export type PropsDataType = { haveNewest: boolean; haveOldest: boolean; @@ -54,6 +64,12 @@ type PropsHousekeepingType = { selectedMessageId?: string; invitedContactsForNewlyCreatedGroup: Array; + warning?: WarningType; + contactSpoofingReview?: { + possiblyUnsafeConversation: ConversationType; + safeConversation: ConversationType; + }; + i18n: LocalizerType; renderItem: ( @@ -74,17 +90,27 @@ type PropsHousekeepingType = { type PropsActionsType = { clearChangedMessages: (conversationId: string) => unknown; clearInvitedConversationsForNewlyCreatedGroup: () => void; + closeContactSpoofingReview: () => void; setLoadCountdownStart: ( conversationId: string, loadCountdownStart?: number ) => unknown; setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown; + reviewMessageRequestNameCollision: ( + _: Readonly<{ + safeConversationId: string; + }> + ) => void; loadAndScroll: (messageId: string) => unknown; loadOlderMessages: (messageId: string) => unknown; loadNewerMessages: (messageId: string) => unknown; loadNewestMessages: (messageId: string, setFocus?: boolean) => unknown; markMessageRead: (messageId: string) => unknown; + onBlock: () => unknown; + onBlockAndDelete: () => unknown; + onDelete: () => unknown; + onUnblock: () => unknown; selectMessage: (messageId: string, conversationId: string) => unknown; clearSelectedMessage: () => unknown; updateSharedGroups: () => unknown; @@ -142,6 +168,9 @@ type StateType = { shouldShowScrollDownButton: boolean; areUnreadBelowCurrentPosition: boolean; + + hasDismissedWarning: boolean; + lastMeasuredWarningHeight: number; }; export class Timeline extends React.PureComponent { @@ -178,6 +207,8 @@ export class Timeline extends React.PureComponent { prevPropScrollToIndex: scrollToIndex, shouldShowScrollDownButton: false, areUnreadBelowCurrentPosition: false, + hasDismissedWarning: false, + lastMeasuredWarningHeight: 0, }; } @@ -554,6 +585,7 @@ export class Timeline extends React.PureComponent { renderTypingBubble, updateSharedGroups, } = this.props; + const { lastMeasuredWarningHeight } = this.state; const styleWithWidth = { ...style, @@ -562,11 +594,14 @@ export class Timeline extends React.PureComponent { const row = index; const oldestUnreadRow = this.getLastSeenIndicatorRow(); const typingBubbleRow = this.getTypingBubbleRow(); - let rowContents; + let rowContents: ReactNode; if (haveOldest && row === 0) { rowContents = (
+ {this.getWarning() ? ( +
+ ) : null} {renderHeroRow(id, this.resizeHeroRow, updateSharedGroups)}
); @@ -802,7 +837,10 @@ export class Timeline extends React.PureComponent { window.unregisterForActive(this.updateWithVisibleRows); } - public componentDidUpdate(prevProps: PropsType): void { + public componentDidUpdate( + prevProps: Readonly, + prevState: Readonly + ): void { const { id, clearChangedMessages, @@ -814,6 +852,15 @@ export class Timeline extends React.PureComponent { typingContact, } = this.props; + // 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 + ); + if (hadWarning !== Boolean(this.getWarning())) { + this.recomputeRowHeights(0); + } + // There are a number of situations which can necessitate that we forget about row // heights previously calculated. We reset the minimum number of rows to minimize // unexpected changes to the scroll position. Those changes happen because @@ -1071,11 +1118,19 @@ export class Timeline extends React.PureComponent { public render(): JSX.Element | null { const { clearInvitedConversationsForNewlyCreatedGroup, + closeContactSpoofingReview, + contactSpoofingReview, i18n, id, - items, - isGroupV1AndDisabled, invitedContactsForNewlyCreatedGroup, + isGroupV1AndDisabled, + items, + onBlock, + onBlockAndDelete, + onDelete, + onUnblock, + showContactModal, + reviewMessageRequestNameCollision, } = this.props; const { shouldShowScrollDownButton, @@ -1127,6 +1182,57 @@ export class Timeline extends React.PureComponent { ); + const warning = this.getWarning(); + let timelineWarning: ReactNode; + if (warning) { + timelineWarning = ( + { + if (!bounds) { + assert(false, 'We should be measuring the bounds'); + return; + } + this.setState({ lastMeasuredWarningHeight: bounds.height }); + }} + > + {({ measureRef }) => ( + + { + this.setState({ hasDismissedWarning: true }); + }} + > + + + + + { + reviewMessageRequestNameCollision({ + safeConversationId: warning.safeConversation.id, + }); + }} + > + {i18n('ContactSpoofing__same-name__link')} + + ), + }} + /> + + + + )} + + ); + } + return ( <>
{ onBlur={this.handleBlur} onKeyDown={this.handleKeyDown} > + {timelineWarning} + {autoSizer} @@ -1159,7 +1267,33 @@ export class Timeline extends React.PureComponent { onClose={clearInvitedConversationsForNewlyCreatedGroup} /> )} + + {contactSpoofingReview && ( + + )} ); } + + private getWarning(): undefined | WarningType { + const { hasDismissedWarning } = this.state; + if (hasDismissedWarning) { + return undefined; + } + + const { warning } = this.props; + return warning; + } } diff --git a/ts/components/conversation/TimelineWarning.tsx b/ts/components/conversation/TimelineWarning.tsx new file mode 100644 index 000000000..5cd7c1f6d --- /dev/null +++ b/ts/components/conversation/TimelineWarning.tsx @@ -0,0 +1,65 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { ReactNode } from 'react'; + +import { LocalizerType } from '../../types/Util'; + +const CLASS_NAME = 'module-TimelineWarning'; +const ICON_CONTAINER_CLASS_NAME = `${CLASS_NAME}__icon-container`; +const GENERIC_ICON_CLASS_NAME = `${CLASS_NAME}__generic-icon`; +const TEXT_CLASS_NAME = `${CLASS_NAME}__text`; +const LINK_CLASS_NAME = `${TEXT_CLASS_NAME}__link`; +const CLOSE_BUTTON_CLASS_NAME = `${CLASS_NAME}__close-button`; + +type PropsType = { + children: ReactNode; + i18n: LocalizerType; + onClose: () => void; +}; + +export function TimelineWarning({ + children, + i18n, + onClose, +}: Readonly): JSX.Element { + return ( +
+ {children} +
+ ); +} + +TimelineWarning.IconContainer = ({ + children, +}: Readonly<{ children: ReactNode }>): JSX.Element => ( +
{children}
+); + +TimelineWarning.GenericIcon = () =>
; + +TimelineWarning.Text = ({ + children, +}: Readonly<{ children: ReactNode }>): JSX.Element => ( +
{children}
+); + +type LinkProps = { + children: ReactNode; + onClick: () => void; +}; + +TimelineWarning.Link = ({ + children, + onClick, +}: Readonly): JSX.Element => ( + +); diff --git a/ts/components/conversation/TimelineWarnings.tsx b/ts/components/conversation/TimelineWarnings.tsx new file mode 100644 index 000000000..5f027553a --- /dev/null +++ b/ts/components/conversation/TimelineWarnings.tsx @@ -0,0 +1,18 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { forwardRef, ReactNode } from 'react'; + +const CLASS_NAME = 'module-TimelineWarnings'; + +type PropsType = { + children: ReactNode; +}; + +export const TimelineWarnings = forwardRef( + ({ children }, ref) => ( +
+ {children} +
+ ) +); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 1fc49c05f..c44b57ca1 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -275,6 +275,10 @@ type ComposerStateType = | { isCreating: true; hasError: false } )); +type ContactSpoofingReviewStateType = { + safeConversationId: string; +}; + export type ConversationsStateType = { preJoinConversation?: PreJoinConversationType; invitedConversationIdsForNewlyCreatedGroup?: Array; @@ -289,6 +293,7 @@ export type ConversationsStateType = { selectedConversationPanelDepth: number; showArchived: boolean; composer?: ComposerStateType; + contactSpoofingReview?: ContactSpoofingReviewStateType; // Note: it's very important that both of these locations are always kept up to date messagesLookup: MessageLookupType; @@ -335,6 +340,9 @@ type ClearInvitedConversationsForNewlyCreatedGroupActionType = { type CloseCantAddContactToGroupModalActionType = { type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL'; }; +type CloseContactSpoofingReviewActionType = { + type: 'CLOSE_CONTACT_SPOOFING_REVIEW'; +}; type CloseMaximumGroupSizeModalActionType = { type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL'; }; @@ -512,6 +520,12 @@ export type SelectedConversationChangedActionType = { messageId?: string; }; }; +type ReviewMessageRequestNameCollisionActionType = { + type: 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION'; + payload: { + safeConversationId: string; + }; +}; type ShowInboxActionType = { type: 'SHOW_INBOX'; payload: null; @@ -569,6 +583,7 @@ export type ConversationActionType = | ClearSelectedMessageActionType | ClearUnreadMetricsActionType | CloseCantAddContactToGroupModalActionType + | CloseContactSpoofingReviewActionType | CloseMaximumGroupSizeModalActionType | CloseRecommendedGroupSizeModalActionType | ConversationAddedActionType @@ -587,6 +602,7 @@ export type ConversationActionType = | RemoveAllConversationsActionType | RepairNewestMessageActionType | RepairOldestMessageActionType + | ReviewMessageRequestNameCollisionActionType | ScrollToMessageActionType | SelectedConversationChangedActionType | SetComposeGroupAvatarActionType @@ -617,6 +633,7 @@ export const actions = { clearSelectedMessage, clearUnreadMetrics, closeCantAddContactToGroupModal, + closeContactSpoofingReview, closeRecommendedGroupSizeModal, closeMaximumGroupSizeModal, conversationAdded, @@ -634,6 +651,7 @@ export const actions = { removeAllConversations, repairNewestMessage, repairOldestMessage, + reviewMessageRequestNameCollision, scrollToMessage, selectMessage, setComposeGroupAvatar, @@ -863,6 +881,14 @@ function repairOldestMessage( }; } +function reviewMessageRequestNameCollision( + payload: Readonly<{ + safeConversationId: string; + }> +): ReviewMessageRequestNameCollisionActionType { + return { type: 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION', payload }; +} + function messagesReset( conversationId: string, messages: Array, @@ -977,6 +1003,9 @@ function clearUnreadMetrics( function closeCantAddContactToGroupModal(): CloseCantAddContactToGroupModalActionType { return { type: 'CLOSE_CANT_ADD_CONTACT_TO_GROUP_MODAL' }; } +function closeContactSpoofingReview(): CloseContactSpoofingReviewActionType { + return { type: 'CLOSE_CONTACT_SPOOFING_REVIEW' }; +} function closeMaximumGroupSizeModal(): CloseMaximumGroupSizeModalActionType { return { type: 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL' }; } @@ -1343,6 +1372,10 @@ export function reducer( }; } + if (action.type === 'CLOSE_CONTACT_SPOOFING_REVIEW') { + return omit(state, 'contactSpoofingReview'); + } + if (action.type === 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL') { return closeComposerModal(state, 'maximumGroupSizeModalState' as const); } @@ -1379,7 +1412,8 @@ export function reducer( const { id, data } = payload; const { conversationLookup } = state; - let { showArchived, selectedConversationId } = state; + const { selectedConversationId } = state; + let { showArchived } = state; const existing = conversationLookup[id]; // In the change case we only modify the lookup if we already had that conversation @@ -1387,6 +1421,8 @@ export function reducer( return state; } + const keysToOmit: Array = []; + if (selectedConversationId === id) { // Archived -> Inbox: we go back to the normal inbox view if (existing.isArchived && !data.isArchived) { @@ -1397,12 +1433,16 @@ export function reducer( // behavior - no selected conversation in the left pane, but a conversation show // in the right pane. if (!existing.isArchived && data.isArchived) { - selectedConversationId = undefined; + keysToOmit.push('selectedConversationId'); + } + + if (!existing.isBlocked && data.isBlocked) { + keysToOmit.push('contactSpoofingReview'); } } return { - ...state, + ...omit(state, keysToOmit), selectedConversationId, showArchived, conversationLookup: { @@ -1444,7 +1484,7 @@ export function reducer( : undefined; return { - ...state, + ...omit(state, 'contactSpoofingReview'), selectedConversationId, selectedConversationPanelDepth: 0, messagesLookup: omit(state.messagesLookup, messageIds), @@ -1879,6 +1919,13 @@ export function reducer( }; } + if (action.type === 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION') { + return { + ...state, + contactSpoofingReview: action.payload, + }; + } + if (action.type === 'MESSAGES_ADDED') { const { conversationId, isActive, isNewMessage, messages } = action.payload; const { messagesByConversation, messagesLookup } = state; @@ -2059,7 +2106,7 @@ export function reducer( const { id } = payload; return { - ...state, + ...omit(state, 'contactSpoofingReview'), selectedConversationId: id, }; } diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index dcaa2cf9a..f004256f6 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -89,6 +89,18 @@ export const getConversationsByGroupId = createSelector( } ); +const getAllConversations = createSelector( + getConversationLookup, + (lookup): Array => Object.values(lookup) +); + +export const getConversationsByTitleSelector = createSelector( + getAllConversations, + (conversations): ((title: string) => Array) => ( + title: string + ) => conversations.filter(conversation => conversation.title === title) +); + export const getSelectedConversationId = createSelector( getConversations, (state: ConversationsStateType): string | undefined => { diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index fd9e9924c..1357fa50c 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -5,13 +5,18 @@ import { pick } from 'lodash'; import React from 'react'; import { connect } from 'react-redux'; import { mapDispatchToProps } from '../actions'; -import { Timeline } from '../../components/conversation/Timeline'; +import { + Timeline, + WarningType as TimelineWarningType, +} from '../../components/conversation/Timeline'; import { StateType } from '../reducer'; +import { ConversationType } from '../ducks/conversations'; import { getIntl } from '../selectors/user'; import { getConversationMessagesSelector, getConversationSelector, + getConversationsByTitleSelector, getInvitedContactsForNewlyCreatedGroup, getSelectedMessage, } from '../selectors/conversations'; @@ -24,6 +29,8 @@ import { SmartTimelineLoadingRow } from './TimelineLoadingRow'; import { renderAudioAttachment } from './renderAudioAttachment'; import { renderEmojiPicker } from './renderEmojiPicker'; +import { assert } from '../../util/assert'; + // Workaround: A react component's required properties are filtering up through connect() // https://github.com/DefinitelyTyped/DefinitelyTyped/issues/31363 /* eslint-disable @typescript-eslint/no-explicit-any */ @@ -80,6 +87,60 @@ function renderTypingBubble(id: string): JSX.Element { return ; } +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)' + ); + + const safeConversation = conversationsWithSameTitle.find( + otherConversation => + otherConversation.acceptedMessageRequest && + otherConversation.type === 'direct' && + otherConversation.id !== conversation.id + ); + + return safeConversation ? { safeConversation } : undefined; + } + + return undefined; +}; + +const getContactSpoofingReview = ( + selectedConversationId: string, + state: Readonly +): + | undefined + | { + possiblyUnsafeConversation: ConversationType; + safeConversation: ConversationType; + } => { + const { contactSpoofingReview } = state.conversations; + if (!contactSpoofingReview) { + return undefined; + } + + const conversationSelector = getConversationSelector(state); + return { + possiblyUnsafeConversation: conversationSelector(selectedConversationId), + safeConversation: conversationSelector( + contactSpoofingReview.safeConversationId + ), + }; +}; + const mapStateToProps = (state: StateType, props: ExternalProps) => { const { id, ...actions } = props; @@ -99,6 +160,10 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { state ), selectedMessageId: selectedMessage ? selectedMessage.id : undefined, + + warning: getWarning(conversation, state), + contactSpoofingReview: getContactSpoofingReview(id, state), + i18n: getIntl(state), renderItem, renderLastSeenIndicator, diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index ffbaeba3c..f1884f691 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -28,6 +28,7 @@ import { getMaximumGroupSizeModalState, getPlaceholderContact, getRecommendedGroupSizeModalState, + getConversationsByTitleSelector, getSelectedConversation, getSelectedConversationId, hasGroupCreationError, @@ -1288,6 +1289,35 @@ describe('both/state/selectors/conversations', () => { }); }); + describe('#getConversationsByTitleSelector', () => { + it('returns a selector that finds conversations by title', () => { + const state = { + ...getEmptyRootState(), + conversations: { + ...getEmptyState(), + conversationLookup: { + abc: { ...getDefaultConversation('abc'), title: 'Janet' }, + def: { ...getDefaultConversation('def'), title: 'Janet' }, + geh: { ...getDefaultConversation('geh'), title: 'Rick' }, + }, + }, + }; + + const selector = getConversationsByTitleSelector(state); + + assert.sameMembers( + selector('Janet').map(c => c.id), + ['abc', 'def'] + ); + assert.sameMembers( + selector('Rick').map(c => c.id), + ['geh'] + ); + assert.isEmpty(selector('abc')); + assert.isEmpty(selector('xyz')); + }); + }); + describe('#getSelectedConversationId', () => { it('returns undefined if no conversation is selected', () => { const state = { diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 43795aa8b..99c4e3c94 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -31,6 +31,7 @@ const { clearGroupCreationError, clearInvitedConversationsForNewlyCreatedGroup, closeCantAddContactToGroupModal, + closeContactSpoofingReview, closeMaximumGroupSizeModal, closeRecommendedGroupSizeModal, createGroup, @@ -47,6 +48,7 @@ const { startComposing, showChooseGroupMembers, startSettingGroupMetadata, + reviewMessageRequestNameCollision, toggleConversationInChooseMembers, } = actions; @@ -550,6 +552,29 @@ describe('both/state/ducks/conversations', () => { }); }); + describe('CLOSE_CONTACT_SPOOFING_REVIEW', () => { + it('closes the contact spoofing review modal if it was open', () => { + const state = { + ...getEmptyState(), + contactSpoofingReview: { + safeConversationId: 'abc123', + }, + }; + const action = closeContactSpoofingReview(); + const actual = reducer(state, action); + + assert.isUndefined(actual.contactSpoofingReview); + }); + + it("does nothing if the modal wasn't already open", () => { + const state = getEmptyState(); + const action = closeContactSpoofingReview(); + const actual = reducer(state, action); + + assert.deepEqual(actual, state); + }); + }); + describe('CLOSE_MAXIMUM_GROUP_SIZE_MODAL', () => { it('closes the maximum group size modal if it was open', () => { const state = { @@ -1151,6 +1176,20 @@ describe('both/state/ducks/conversations', () => { }); }); + describe('REVIEW_MESSAGE_REQUEST_NAME_COLLISION', () => { + it('starts reviewing a message request name collision', () => { + const state = getEmptyState(); + const action = reviewMessageRequestNameCollision({ + safeConversationId: 'def', + }); + const actual = reducer(state, action); + + assert.deepEqual(actual.contactSpoofingReview, { + safeConversationId: 'def', + }); + }); + }); + describe('SET_COMPOSE_GROUP_AVATAR', () => { it("can clear the composer's group avatar", () => { const state = { diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index a3fbbf1da..92161a6d3 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -16654,7 +16654,7 @@ "rule": "React-createRef", "path": "ts/components/conversation/Timeline.js", "line": " this.listRef = react_1.default.createRef();", - "lineNumber": 33, + "lineNumber": 39, "reasonCategory": "usageTrusted", "updated": "2019-07-31T00:19:18.696Z", "reasonDetail": "Timeline needs to interact with its child List directly" @@ -16927,4 +16927,4 @@ "updated": "2021-01-08T15:46:32.143Z", "reasonDetail": "Doesn't manipulate the DOM. This is just a function." } -] +] \ No newline at end of file diff --git a/ts/views/conversation_view.ts b/ts/views/conversation_view.ts index 998282a91..7a38c6094 100644 --- a/ts/views/conversation_view.ts +++ b/ts/views/conversation_view.ts @@ -779,6 +779,9 @@ Whisper.ConversationView = Whisper.View.extend({ setupTimeline() { const { id } = this.model; + const messageRequestEnum = + window.textsecure.protobuf.SyncMessage.MessageRequestResponse.Type; + const contactSupport = () => { const baseUrl = 'https://support.signal.org/hc/LOCALE/requests/new?desktop&chat_refreshed'; @@ -950,6 +953,28 @@ Whisper.ConversationView = Whisper.View.extend({ loadAndScroll: this.loadAndScroll.bind(this), loadOlderMessages, markMessageRead, + onBlock: () => { + this.syncMessageRequestResponse('onBlock', messageRequestEnum.BLOCK); + }, + onBlockAndDelete: () => { + this.syncMessageRequestResponse( + 'onBlockAndDelete', + messageRequestEnum.BLOCK_AND_DELETE + ); + }, + onDelete: () => { + this.syncMessageRequestResponse( + 'onDelete', + messageRequestEnum.DELETE + ); + }, + onUnblock: () => { + this.syncMessageRequestResponse( + 'onUnblock', + messageRequestEnum.ACCEPT + ); + }, + onShowContactModal: this.showContactModal.bind(this), scrollToQuotedMessage, updateSharedGroups: this.model.throttledUpdateSharedGroups, }),