Profile name spoofing dialog

This commit is contained in:
Evan Hahn 2021-04-21 11:31:12 -05:00 committed by Scott Nonnenberg
parent 814255c10e
commit e7ef3de6d0
21 changed files with 893 additions and 15 deletions

View file

@ -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', () => (
<ContactSpoofingReviewDialog
i18n={i18n}
onBlock={action('onBlock')}
onBlockAndDelete={action('onBlockAndDelete')}
onClose={action('onClose')}
onDelete={action('onDelete')}
onShowContactModal={action('onShowContactModal')}
onUnblock={action('onUnblock')}
possiblyUnsafeConversation={getDefaultConversation()}
safeConversation={getDefaultConversation()}
/>
));

View file

@ -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<PropsType> = ({
i18n,
onBlock,
onBlockAndDelete,
onClose,
onDelete,
onShowContactModal,
onUnblock,
possiblyUnsafeConversation,
safeConversation,
}) => {
assert(
possiblyUnsafeConversation.type === 'direct',
'<ContactSpoofingReviewDialog> expected a direct conversation for the "possibly unsafe" conversation'
);
assert(
safeConversation.type === 'direct',
'<ContactSpoofingReviewDialog> expected a direct conversation for the "safe" conversation'
);
const [messageRequestState, setMessageRequestState] = useState(
MessageRequestState.default
);
if (messageRequestState !== MessageRequestState.default) {
return (
<MessageRequestActionsConfirmation
i18n={i18n}
onBlock={onBlock}
onBlockAndDelete={onBlockAndDelete}
onUnblock={onUnblock}
onDelete={onDelete}
name={possiblyUnsafeConversation.name}
profileName={possiblyUnsafeConversation.profileName}
phoneNumber={possiblyUnsafeConversation.phoneNumber}
title={possiblyUnsafeConversation.title}
conversationType="direct"
state={messageRequestState}
onChangeState={setMessageRequestState}
/>
);
}
return (
<Modal
hasXButton
i18n={i18n}
moduleClassName="module-ContactSpoofingReviewDialog"
onClose={onClose}
title={i18n('ContactSpoofingReviewDialog__title')}
>
<p>{i18n('ContactSpoofingReviewDialog__description')}</p>
<h2>{i18n('ContactSpoofingReviewDialog__possibly-unsafe-title')}</h2>
<ContactSpoofingReviewDialogPerson
conversation={possiblyUnsafeConversation}
i18n={i18n}
>
<div className="module-ContactSpoofingReviewDialog__buttons">
<Button
variant={ButtonVariant.SecondaryDestructive}
onClick={() => {
setMessageRequestState(MessageRequestState.deleting);
}}
>
{i18n('MessageRequests--delete')}
</Button>
<Button
variant={ButtonVariant.SecondaryDestructive}
onClick={() => {
setMessageRequestState(MessageRequestState.blocking);
}}
>
{i18n('MessageRequests--block')}
</Button>
</div>
</ContactSpoofingReviewDialogPerson>
<hr />
<h2>{i18n('ContactSpoofingReviewDialog__safe-title')}</h2>
<ContactSpoofingReviewDialogPerson
conversation={safeConversation}
i18n={i18n}
onClick={() => {
onShowContactModal(safeConversation.id);
}}
/>
</Modal>
);
};

View file

@ -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<PropsType> = ({
children,
conversation,
i18n,
onClick,
}) => {
assert(
conversation.type === 'direct',
'<ContactSpoofingReviewDialogPerson> expected a direct conversation'
);
const contents = (
<>
<Avatar
{...conversation}
conversationType={conversation.type}
size={AvatarSize.FIFTY_TWO}
className="module-ContactSpoofingReviewDialogPerson__avatar"
i18n={i18n}
/>
<div className="module-ContactSpoofingReviewDialogPerson__info">
<ContactName
i18n={i18n}
module="module-ContactSpoofingReviewDialogPerson__info__contact-name"
title={conversation.title}
/>
{conversation.phoneNumber ? (
<div className="module-ContactSpoofingReviewDialogPerson__info__property">
{conversation.phoneNumber}
</div>
) : null}
<div className="module-ContactSpoofingReviewDialogPerson__info__property">
<SharedGroupNames
i18n={i18n}
sharedGroupNames={conversation.sharedGroupNames || []}
/>
</div>
{children}
</div>
</>
);
if (onClick) {
return (
<button
type="button"
className="module-ContactSpoofingReviewDialogPerson"
onClick={onClick}
>
{contents}
</button>
);
}
return (
<div className="module-ContactSpoofingReviewDialogPerson">{contents}</div>
);
};

View file

@ -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> = {}): 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 <Timeline {...props} />;
});
story.add('With "same name" warning', () => {
const props = createProps({
warning: {
safeConversation: getDefaultConversation(),
},
items: [],
});
return <Timeline {...props} />;
});

View file

@ -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<ConversationType>;
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<PropsType, StateType> {
@ -178,6 +207,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
prevPropScrollToIndex: scrollToIndex,
shouldShowScrollDownButton: false,
areUnreadBelowCurrentPosition: false,
hasDismissedWarning: false,
lastMeasuredWarningHeight: 0,
};
}
@ -554,6 +585,7 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
renderTypingBubble,
updateSharedGroups,
} = this.props;
const { lastMeasuredWarningHeight } = this.state;
const styleWithWidth = {
...style,
@ -562,11 +594,14 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
const row = index;
const oldestUnreadRow = this.getLastSeenIndicatorRow();
const typingBubbleRow = this.getTypingBubbleRow();
let rowContents;
let rowContents: ReactNode;
if (haveOldest && row === 0) {
rowContents = (
<div data-row={row} style={styleWithWidth} role="row">
{this.getWarning() ? (
<div style={{ height: lastMeasuredWarningHeight }} />
) : null}
{renderHeroRow(id, this.resizeHeroRow, updateSharedGroups)}
</div>
);
@ -802,7 +837,10 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
window.unregisterForActive(this.updateWithVisibleRows);
}
public componentDidUpdate(prevProps: PropsType): void {
public componentDidUpdate(
prevProps: Readonly<PropsType>,
prevState: Readonly<StateType>
): void {
const {
id,
clearChangedMessages,
@ -814,6 +852,15 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
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<PropsType, StateType> {
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<PropsType, StateType> {
</AutoSizer>
);
const warning = this.getWarning();
let timelineWarning: ReactNode;
if (warning) {
timelineWarning = (
<Measure
bounds
onResize={({ bounds }) => {
if (!bounds) {
assert(false, 'We should be measuring the bounds');
return;
}
this.setState({ lastMeasuredWarningHeight: bounds.height });
}}
>
{({ measureRef }) => (
<TimelineWarnings ref={measureRef}>
<TimelineWarning
i18n={i18n}
onClose={() => {
this.setState({ hasDismissedWarning: true });
}}
>
<TimelineWarning.IconContainer>
<TimelineWarning.GenericIcon />
</TimelineWarning.IconContainer>
<TimelineWarning.Text>
<Intl
i18n={i18n}
id="ContactSpoofing__same-name"
components={{
link: (
<TimelineWarning.Link
onClick={() => {
reviewMessageRequestNameCollision({
safeConversationId: warning.safeConversation.id,
});
}}
>
{i18n('ContactSpoofing__same-name__link')}
</TimelineWarning.Link>
),
}}
/>
</TimelineWarning.Text>
</TimelineWarning>
</TimelineWarnings>
)}
</Measure>
);
}
return (
<>
<div
@ -1139,6 +1245,8 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
onBlur={this.handleBlur}
onKeyDown={this.handleKeyDown}
>
{timelineWarning}
<GlobalAudioProvider conversationId={id}>
{autoSizer}
</GlobalAudioProvider>
@ -1159,7 +1267,33 @@ export class Timeline extends React.PureComponent<PropsType, StateType> {
onClose={clearInvitedConversationsForNewlyCreatedGroup}
/>
)}
{contactSpoofingReview && (
<ContactSpoofingReviewDialog
i18n={i18n}
onBlock={onBlock}
onBlockAndDelete={onBlockAndDelete}
onClose={closeContactSpoofingReview}
onDelete={onDelete}
onShowContactModal={showContactModal}
onUnblock={onUnblock}
possiblyUnsafeConversation={
contactSpoofingReview.possiblyUnsafeConversation
}
safeConversation={contactSpoofingReview.safeConversation}
/>
)}
</>
);
}
private getWarning(): undefined | WarningType {
const { hasDismissedWarning } = this.state;
if (hasDismissedWarning) {
return undefined;
}
const { warning } = this.props;
return warning;
}
}

View file

@ -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<PropsType>): JSX.Element {
return (
<div className={CLASS_NAME}>
{children}
<button
aria-label={i18n('close')}
className={CLOSE_BUTTON_CLASS_NAME}
onClick={onClose}
type="button"
/>
</div>
);
}
TimelineWarning.IconContainer = ({
children,
}: Readonly<{ children: ReactNode }>): JSX.Element => (
<div className={ICON_CONTAINER_CLASS_NAME}>{children}</div>
);
TimelineWarning.GenericIcon = () => <div className={GENERIC_ICON_CLASS_NAME} />;
TimelineWarning.Text = ({
children,
}: Readonly<{ children: ReactNode }>): JSX.Element => (
<div className={TEXT_CLASS_NAME}>{children}</div>
);
type LinkProps = {
children: ReactNode;
onClick: () => void;
};
TimelineWarning.Link = ({
children,
onClick,
}: Readonly<LinkProps>): JSX.Element => (
<button className={LINK_CLASS_NAME} onClick={onClick} type="button">
{children}
</button>
);

View file

@ -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<HTMLDivElement, PropsType>(
({ children }, ref) => (
<div className={CLASS_NAME} ref={ref}>
{children}
</div>
)
);