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,
}),