Conversation details changes for PNP

Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
Fedor Indutny 2024-02-05 18:13:13 -08:00 committed by GitHub
parent 1a74da0c26
commit eb82ace2de
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
49 changed files with 1660 additions and 699 deletions

View file

@ -36,6 +36,7 @@ export enum AvatarBlur {
export enum AvatarSize {
TWENTY = 20,
TWENTY_FOUR = 24,
TWENTY_EIGHT = 28,
THIRTY_TWO = 32,
THIRTY_SIX = 36,
@ -44,6 +45,7 @@ export enum AvatarSize {
FIFTY_TWO = 52,
EIGHTY = 80,
NINETY_SIX = 96,
TWO_HUNDRED_SIXTEEN = 216,
}
type BadgePlacementType = { bottom: number; right: number };

View file

@ -0,0 +1,29 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { PropsType } from './CollidingAvatars';
import { CollidingAvatars } from './CollidingAvatars';
import { type ComponentMeta } from '../storybook/types';
import { setupI18n } from '../util/setupI18n';
import { getDefaultConversation } from '../test-both/helpers/getDefaultConversation';
import enMessages from '../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const alice = getDefaultConversation();
const bob = getDefaultConversation();
export default {
title: 'Components/CollidingAvatars',
component: CollidingAvatars,
argTypes: {},
args: {
i18n,
conversations: [alice, bob],
},
} satisfies ComponentMeta<PropsType>;
export function Defaults(args: PropsType): JSX.Element {
return <CollidingAvatars {...args} />;
}

View file

@ -0,0 +1,80 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v4 as uuid } from 'uuid';
import React, { useMemo, useCallback } from 'react';
import type { LocalizerType } from '../types/Util';
import type { ConversationType } from '../state/ducks/conversations';
import { Avatar, AvatarSize } from './Avatar';
export type PropsType = Readonly<{
i18n: LocalizerType;
conversations: ReadonlyArray<ConversationType>;
}>;
const MAX_AVATARS = 2;
export function CollidingAvatars({
i18n,
conversations,
}: PropsType): JSX.Element {
const clipId = useMemo(() => uuid(), []);
const onRef = useCallback(
(elem: HTMLDivElement | null): void => {
if (elem) {
// Note that these cannot be set through html attributes
elem.style.setProperty('--clip-path', `url(#${clipId})`);
}
},
[clipId]
);
return (
<div className="CollidingAvatars" ref={onRef}>
{conversations.slice(0, MAX_AVATARS).map(({ id, type, ...convo }) => {
return (
<Avatar
key={id}
className="CollidingAvatars__avatar"
i18n={i18n}
size={AvatarSize.TWENTY_FOUR}
conversationType={type}
badge={undefined}
{...convo}
/>
);
})}
{/*
This clip path is a rectangle with the right-bottom corner cut off
by a circle:
AAAAAAA
AAAAAAA
AAAAA
AAA
AAA
AA
AA
The idea is that we cut a circle away from the top avatar so that there
is a bit of transparent area between two avatars:
AAAAAAA
AAAAAAA
AAAAA
AAA B
AAA BB
AA BBB
AA BBB
See CollidingAvatars.scss for how this clipPath is applied.
*/}
<svg width={0} height={0} className="CollidingAvatars__clip_svg">
<clipPath id={clipId} clipPathUnits="objectBoundingBox">
<path d="M0 0 h1 v0.4166 A0.54166 0.54166 0 0 0.4166 1 H0 Z" />
</clipPath>
</svg>
</div>
);
}

View file

@ -23,6 +23,8 @@ import { ConfirmationDialog } from './ConfirmationDialog';
import { FormattingWarningModal } from './FormattingWarningModal';
import { SendEditWarningModal } from './SendEditWarningModal';
import { SignalConnectionsModal } from './SignalConnectionsModal';
import { AboutContactModal } from './conversation/AboutContactModal';
import type { ExternalPropsType as AboutContactModalPropsType } from './conversation/AboutContactModal';
import { WhatsNewModal } from './WhatsNewModal';
// NOTE: All types should be required for this component so that the smart
@ -73,6 +75,9 @@ export type PropsType = {
// SignalConnectionsModal
isSignalConnectionsVisible: boolean;
toggleSignalConnectionsModal: () => unknown;
// AboutContactModal
aboutContactModalProps: AboutContactModalPropsType | undefined;
toggleAboutContactModal: () => unknown;
// StickerPackPreviewModal
stickerPackPreviewId: string | undefined;
renderStickerPreviewModal: () => JSX.Element | null;
@ -139,6 +144,9 @@ export function GlobalModalContainer({
// SignalConnectionsModal
isSignalConnectionsVisible,
toggleSignalConnectionsModal,
// AboutContactModal
aboutContactModalProps,
toggleAboutContactModal,
// StickerPackPreviewModal
stickerPackPreviewId,
renderStickerPreviewModal,
@ -185,10 +193,6 @@ export function GlobalModalContainer({
return renderAddUserToAnotherGroup();
}
if (contactModalState) {
return renderContactModal();
}
if (editHistoryMessages) {
return renderEditHistoryMessagesModal();
}
@ -252,6 +256,20 @@ export function GlobalModalContainer({
);
}
if (aboutContactModalProps) {
return (
<AboutContactModal
i18n={i18n}
onClose={toggleAboutContactModal}
{...aboutContactModalProps}
/>
);
}
if (contactModalState) {
return renderContactModal();
}
if (isStoriesSettingsVisible) {
return renderStoriesSettings();
}

View file

@ -4,14 +4,13 @@
import React from 'react';
import type { LocalizerType } from '../types/Util';
import { Button, ButtonVariant } from './Button';
import { Intl } from './Intl';
import { Modal } from './Modal';
export type PropsType = {
export type PropsType = Readonly<{
i18n: LocalizerType;
onClose: () => unknown;
};
}>;
export function SignalConnectionsModal({
i18n,
@ -48,12 +47,6 @@ export function SignalConnectionsModal({
<div className="SignalConnectionsModal__description">
{i18n('icu:SignalConnectionsModal__footer')}
</div>
<div className="SignalConnectionsModal__button">
<Button onClick={onClose} variant={ButtonVariant.Primary}>
{i18n('icu:Confirmation--confirm')}
</Button>
</div>
</div>
</Modal>
);

View file

@ -0,0 +1,60 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import type { PropsType } from './AboutContactModal';
import { AboutContactModal } from './AboutContactModal';
import { type ComponentMeta } from '../../storybook/types';
import { setupI18n } from '../../util/setupI18n';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import enMessages from '../../../_locales/en/messages.json';
const i18n = setupI18n('en', enMessages);
const conversation = getDefaultConversation();
const conversationWithAbout = getDefaultConversation({
aboutText: '😀 About Me',
});
const systemContact = getDefaultConversation({
systemGivenName: 'Alice',
phoneNumber: '+1 555 123-4567',
});
export default {
title: 'Components/Conversation/AboutContactModal',
component: AboutContactModal,
argTypes: {
isSignalConnection: { control: { type: 'boolean' } },
},
args: {
i18n,
onClose: action('onClose'),
toggleSignalConnectionsModal: action('toggleSignalConnections'),
updateSharedGroups: action('updateSharedGroups'),
conversation,
isSignalConnection: false,
},
} satisfies ComponentMeta<PropsType>;
export function Defaults(args: PropsType): JSX.Element {
return <AboutContactModal {...args} />;
}
export function WithAbout(args: PropsType): JSX.Element {
return <AboutContactModal {...args} conversation={conversationWithAbout} />;
}
export function SignalConnection(args: PropsType): JSX.Element {
return <AboutContactModal {...args} isSignalConnection />;
}
export function SystemContact(args: PropsType): JSX.Element {
return (
<AboutContactModal
{...args}
conversation={systemContact}
isSignalConnection
/>
);
}

View file

@ -0,0 +1,135 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useCallback, useEffect } from 'react';
import type { ConversationType } from '../../state/ducks/conversations';
import type { LocalizerType } from '../../types/Util';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import { Avatar, AvatarSize } from '../Avatar';
import { Modal } from '../Modal';
import { UserText } from '../UserText';
import { SharedGroupNames } from '../SharedGroupNames';
import { About } from './About';
export type PropsType = Readonly<{
i18n: LocalizerType;
onClose: () => void;
}> &
ExternalPropsType;
export type ExternalPropsType = Readonly<{
conversation: ConversationType;
isSignalConnection: boolean;
toggleSignalConnectionsModal: () => void;
updateSharedGroups: (id: string) => void;
}>;
export function AboutContactModal({
i18n,
conversation,
isSignalConnection,
toggleSignalConnectionsModal,
updateSharedGroups,
onClose,
}: PropsType): JSX.Element {
useEffect(() => {
// Kick off the expensive hydration of the current sharedGroupNames
updateSharedGroups(conversation.id);
}, [conversation.id, updateSharedGroups]);
const onSignalConnectionClick = useCallback(
(ev: React.MouseEvent) => {
ev.preventDefault();
toggleSignalConnectionsModal();
},
[toggleSignalConnectionsModal]
);
return (
<Modal
key="main"
modalName="AboutContactModal"
moduleClassName="AboutContactModal"
hasXButton
i18n={i18n}
onClose={onClose}
>
<div className="AboutContactModal__row AboutContactModal__row--centered">
<Avatar
acceptedMessageRequest={conversation.acceptedMessageRequest}
avatarPath={conversation.avatarPath}
badge={undefined}
color={conversation.color}
conversationType="direct"
i18n={i18n}
isMe={conversation.isMe}
profileName={conversation.profileName}
sharedGroupNames={[]}
size={AvatarSize.TWO_HUNDRED_SIXTEEN}
title={conversation.title}
unblurredAvatarPath={conversation.unblurredAvatarPath}
/>
</div>
<div className="AboutContactModal__row">
<h3 className="AboutContactModal__title">
{i18n('icu:AboutContactModal__title')}
</h3>
</div>
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--profile" />
<UserText text={conversation.title} />
</div>
{conversation.about ? (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--about" />
<About
className="AboutContactModal__about"
text={conversation.about}
/>
</div>
) : null}
{isSignalConnection ? (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--connections" />
<button
type="button"
className="AboutContactModal__signal-connection"
onClick={onSignalConnectionClick}
>
{i18n('icu:AboutContactModal__signal-connection')}
</button>
</div>
) : null}
{isInSystemContacts(conversation) ? (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--person" />
{i18n('icu:AboutContactModal__system-contact', {
name: conversation.firstName || conversation.title,
})}
</div>
) : null}
{conversation.phoneNumber ? (
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--phone" />
<UserText text={conversation.phoneNumber} />
</div>
) : null}
<div className="AboutContactModal__row">
<i className="AboutContactModal__row__icon AboutContactModal__row__icon--group" />
<div>
<SharedGroupNames
i18n={i18n}
sharedGroupNames={conversation.sharedGroupNames || []}
/>
</div>
</div>
</Modal>
);
}

View file

@ -45,6 +45,7 @@ export default {
removeMemberFromGroup: action('removeMemberFromGroup'),
showConversation: action('showConversation'),
theme: ThemeType.light,
toggleAboutContactModal: action('AboutContactModal'),
toggleAdmin: action('toggleAdmin'),
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
updateConversationModelSharedGroups: action(

View file

@ -43,6 +43,7 @@ type PropsActionType = {
removeMemberFromGroup: (conversationId: string, contactId: string) => void;
showConversation: ShowConversationType;
toggleAdmin: (conversationId: string, contactId: string) => void;
toggleAboutContactModal: (conversationId: string) => unknown;
toggleSafetyNumberModal: (conversationId: string) => unknown;
toggleAddUserToAnotherGroupModal: (conversationId: string) => void;
updateConversationModelSharedGroups: (conversationId: string) => void;
@ -77,6 +78,7 @@ export function ContactModal({
removeMemberFromGroup,
showConversation,
theme,
toggleAboutContactModal,
toggleAddUserToAnotherGroupModal,
toggleAdmin,
toggleSafetyNumberModal,
@ -208,9 +210,17 @@ export function ContactModal({
title={contact.title}
unblurredAvatarPath={contact.unblurredAvatarPath}
/>
<div className="ContactModal__name">
<button
type="button"
className="ContactModal__name"
onClick={ev => {
ev.preventDefault();
toggleAboutContactModal(contact.id);
}}
>
<UserText text={contact.title} />
</div>
<i className="ContactModal__name__chevron" />
</button>
<div className="module-about__container">
<About text={contact.about} />
</div>

View file

@ -30,6 +30,7 @@ const getCommonProps = () => ({
i18n,
onClose: action('onClose'),
showContactModal: action('showContactModal'),
toggleSignalConnectionsModal: action('toggleSignalConnectionsModal'),
removeMember: action('removeMember'),
theme: ThemeType.light,
});
@ -39,13 +40,19 @@ export function DirectConversationsWithSameTitle(): JSX.Element {
<ContactSpoofingReviewDialog
{...getCommonProps()}
type={ContactSpoofingType.DirectConversationWithSameTitle}
possiblyUnsafeConversation={getDefaultConversation()}
safeConversation={getDefaultConversation()}
possiblyUnsafe={{
conversation: getDefaultConversation(),
isSignalConnection: false,
}}
safe={{
conversation: getDefaultConversation(),
isSignalConnection: true,
}}
/>
);
}
export function NotAdmin(): JSX.Element {
export function NotAdminMany(): JSX.Element {
return (
<ContactSpoofingReviewDialog
{...getCommonProps()}
@ -57,12 +64,15 @@ export function NotAdmin(): JSX.Element {
collisionInfoByTitle={{
Alice: times(2, () => ({
oldName: 'Alicia',
isSignalConnection: false,
conversation: getDefaultConversation({ title: 'Alice' }),
})),
Bob: times(3, () => ({
isSignalConnection: false,
conversation: getDefaultConversation({ title: 'Bob' }),
})),
Charlie: times(5, () => ({
isSignalConnection: false,
conversation: getDefaultConversation({ title: 'Charlie' }),
})),
}}
@ -70,7 +80,34 @@ export function NotAdmin(): JSX.Element {
);
}
export function Admin(): JSX.Element {
export function NotAdminOne(): JSX.Element {
return (
<ContactSpoofingReviewDialog
{...getCommonProps()}
type={ContactSpoofingType.MultipleGroupMembersWithSameTitle}
group={{
...getDefaultConversation(),
areWeAdmin: false,
}}
collisionInfoByTitle={{
Alice: [
{
oldName: 'Alicia',
isSignalConnection: false,
conversation: getDefaultConversation({ title: 'Alice' }),
},
{
oldName: 'Alice',
isSignalConnection: true,
conversation: getDefaultConversation({ title: 'Alice' }),
},
],
}}
/>
);
}
export function AdminMany(): JSX.Element {
return (
<ContactSpoofingReviewDialog
{...getCommonProps()}
@ -82,15 +119,44 @@ export function Admin(): JSX.Element {
collisionInfoByTitle={{
Alice: times(2, () => ({
oldName: 'Alicia',
isSignalConnection: false,
conversation: getDefaultConversation({ title: 'Alice' }),
})),
Bob: times(3, () => ({
isSignalConnection: false,
conversation: getDefaultConversation({ title: 'Bob' }),
})),
Charlie: times(5, () => ({
isSignalConnection: false,
conversation: getDefaultConversation({ title: 'Charlie' }),
})),
}}
/>
);
}
export function AdminOne(): JSX.Element {
return (
<ContactSpoofingReviewDialog
{...getCommonProps()}
type={ContactSpoofingType.MultipleGroupMembersWithSameTitle}
group={{
...getDefaultConversation(),
areWeAdmin: true,
}}
collisionInfoByTitle={{
Alice: [
{
oldName: 'Alicia',
isSignalConnection: false,
conversation: getDefaultConversation({ title: 'Alice' }),
},
{
isSignalConnection: true,
conversation: getDefaultConversation({ title: 'Alice' }),
},
],
}}
/>
);
}

View file

@ -17,11 +17,35 @@ import { Modal } from '../Modal';
import { RemoveGroupMemberConfirmationDialog } from './RemoveGroupMemberConfirmationDialog';
import { ContactSpoofingReviewDialogPerson } from './ContactSpoofingReviewDialogPerson';
import { Button, ButtonVariant } from '../Button';
import { Intl } from '../Intl';
import { assertDev } from '../../util/assert';
import { missingCaseError } from '../../util/missingCaseError';
import { isInSystemContacts } from '../../util/isInSystemContacts';
import { UserText } from '../UserText';
export type ReviewPropsType = Readonly<
| {
type: ContactSpoofingType.DirectConversationWithSameTitle;
possiblyUnsafe: {
conversation: ConversationType;
isSignalConnection: boolean;
};
safe: {
conversation: ConversationType;
isSignalConnection: boolean;
};
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
group: ConversationType;
collisionInfoByTitle: Record<
string,
Array<{
oldName?: string;
isSignalConnection: boolean;
conversation: ConversationType;
}>
>;
}
>;
export type PropsType = {
conversationId: string;
@ -29,6 +53,7 @@ export type PropsType = {
blockAndReportSpam: (conversationId: string) => unknown;
blockConversation: (conversationId: string) => unknown;
deleteConversation: (conversationId: string) => unknown;
toggleSignalConnectionsModal: () => void;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
onClose: () => void;
@ -38,24 +63,7 @@ export type PropsType = {
memberConversationId: string
) => unknown;
theme: ThemeType;
} & (
| {
type: ContactSpoofingType.DirectConversationWithSameTitle;
possiblyUnsafeConversation: ConversationType;
safeConversation: ConversationType;
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
group: ConversationType;
collisionInfoByTitle: Record<
string,
Array<{
oldName?: string;
conversation: ConversationType;
}>
>;
}
);
} & ReviewPropsType;
enum ConfirmationStateType {
ConfirmingDelete,
@ -70,6 +78,7 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
blockConversation,
conversationId,
deleteConversation,
toggleSignalConnectionsModal,
getPreferredBadge,
i18n,
onClose,
@ -169,13 +178,13 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
switch (props.type) {
case ContactSpoofingType.DirectConversationWithSameTitle: {
const { possiblyUnsafeConversation, safeConversation } = props;
const { possiblyUnsafe, safe } = props;
assertDev(
possiblyUnsafeConversation.type === 'direct',
possiblyUnsafe.conversation.type === 'direct',
'<ContactSpoofingReviewDialog> expected a direct conversation for the "possibly unsafe" conversation'
);
assertDev(
safeConversation.type === 'direct',
safe.conversation.type === 'direct',
'<ContactSpoofingReviewDialog> expected a direct conversation for the "safe" conversation'
);
@ -187,10 +196,13 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
{i18n('icu:ContactSpoofingReviewDialog__possibly-unsafe-title')}
</h2>
<ContactSpoofingReviewDialogPerson
conversation={possiblyUnsafeConversation}
conversation={possiblyUnsafe.conversation}
getPreferredBadge={getPreferredBadge}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
i18n={i18n}
theme={theme}
isSignalConnection={possiblyUnsafe.isSignalConnection}
oldName={undefined}
>
<div className="module-ContactSpoofingReviewDialog__buttons">
<Button
@ -198,7 +210,7 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
onClick={() => {
setConfirmationState({
type: ConfirmationStateType.ConfirmingDelete,
affectedConversation: possiblyUnsafeConversation,
affectedConversation: possiblyUnsafe.conversation,
});
}}
>
@ -209,7 +221,7 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
onClick={() => {
setConfirmationState({
type: ConfirmationStateType.ConfirmingBlock,
affectedConversation: possiblyUnsafeConversation,
affectedConversation: possiblyUnsafe.conversation,
});
}}
>
@ -220,13 +232,16 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
<hr />
<h2>{i18n('icu:ContactSpoofingReviewDialog__safe-title')}</h2>
<ContactSpoofingReviewDialogPerson
conversation={safeConversation}
conversation={safe.conversation}
getPreferredBadge={getPreferredBadge}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
i18n={i18n}
onClick={() => {
showContactModal(safeConversation.id);
showContactModal(safe.conversation.id);
}}
theme={theme}
isSignalConnection={safe.isSignalConnection}
oldName={undefined}
/>
</>
);
@ -257,117 +272,83 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
})}
</p>
{Object.values(collisionInfoByTitle).map(
(conversationInfos, titleIdx) => {
return (
<>
<h2>
{i18n(
'icu:ContactSpoofingReviewDialog__group__members-header'
)}
</h2>
{conversationInfos.map(
(conversationInfo, conversationIdx) => {
let button: ReactNode;
if (group.areWeAdmin) {
button = (
<Button
variant={ButtonVariant.SecondaryAffirmative}
onClick={() => {
setConfirmationState({
type: ConfirmationStateType.ConfirmingGroupRemoval,
affectedConversation:
conversationInfo.conversation,
group,
});
}}
>
{i18n(
'icu:RemoveGroupMemberConfirmation__remove-button'
)}
</Button>
);
} else if (conversationInfo.conversation.isBlocked) {
button = (
<Button
variant={ButtonVariant.SecondaryAffirmative}
onClick={() => {
acceptConversation(
conversationInfo.conversation.id
);
}}
>
{i18n('icu:MessageRequests--unblock')}
</Button>
);
} else if (
!isInSystemContacts(conversationInfo.conversation)
) {
button = (
<Button
variant={ButtonVariant.SecondaryDestructive}
onClick={() => {
setConfirmationState({
type: ConfirmationStateType.ConfirmingBlock,
affectedConversation:
conversationInfo.conversation,
});
}}
>
{i18n('icu:MessageRequests--block')}
</Button>
);
{Object.values(collisionInfoByTitle)
.map((conversationInfos, titleIdx) =>
conversationInfos.map((conversationInfo, conversationIdx) => {
let button: ReactNode;
if (group.areWeAdmin) {
button = (
<Button
variant={ButtonVariant.SecondaryAffirmative}
onClick={() => {
setConfirmationState({
type: ConfirmationStateType.ConfirmingGroupRemoval,
affectedConversation: conversationInfo.conversation,
group,
});
}}
>
{i18n('icu:RemoveGroupMemberConfirmation__remove-button')}
</Button>
);
} else if (conversationInfo.conversation.isBlocked) {
button = (
<Button
variant={ButtonVariant.SecondaryAffirmative}
onClick={() => {
acceptConversation(conversationInfo.conversation.id);
}}
>
{i18n('icu:MessageRequests--unblock')}
</Button>
);
} else if (!isInSystemContacts(conversationInfo.conversation)) {
button = (
<Button
variant={ButtonVariant.SecondaryDestructive}
onClick={() => {
setConfirmationState({
type: ConfirmationStateType.ConfirmingBlock,
affectedConversation: conversationInfo.conversation,
});
}}
>
{i18n('icu:MessageRequests--block')}
</Button>
);
}
const { oldName, isSignalConnection } = conversationInfo;
return (
<>
<ContactSpoofingReviewDialogPerson
key={conversationInfo.conversation.id}
conversation={conversationInfo.conversation}
toggleSignalConnectionsModal={
toggleSignalConnectionsModal
}
const { oldName } = conversationInfo;
const newName =
conversationInfo.conversation.profileName ||
conversationInfo.conversation.title;
let callout: JSX.Element | undefined;
if (oldName && oldName !== newName) {
callout = (
<div className="module-ContactSpoofingReviewDialogPerson__info__property module-ContactSpoofingReviewDialogPerson__info__property--callout">
<Intl
i18n={i18n}
id="icu:ContactSpoofingReviewDialog__group__name-change-info"
components={{
oldName: <UserText text={oldName} />,
newName: <UserText text={newName} />,
}}
/>
</div>
);
}
return (
<>
<ContactSpoofingReviewDialogPerson
key={conversationInfo.conversation.id}
conversation={conversationInfo.conversation}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
theme={theme}
>
{callout}
{button && (
<div className="module-ContactSpoofingReviewDialog__buttons">
{button}
</div>
)}
</ContactSpoofingReviewDialogPerson>
{titleIdx < sharedTitles.length - 1 ||
conversationIdx < conversationInfos.length - 1 ? (
<hr />
) : null}
</>
);
}
)}
</>
);
}
)}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
theme={theme}
oldName={oldName}
isSignalConnection={isSignalConnection}
>
{button && (
<div className="module-ContactSpoofingReviewDialog__buttons">
{button}
</div>
)}
</ContactSpoofingReviewDialogPerson>
{titleIdx < sharedTitles.length - 1 ||
conversationIdx < conversationInfos.length - 1 ? (
<hr />
) : null}
</>
);
})
)
.flat()}
</>
);
break;

View file

@ -0,0 +1,59 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import type { Meta, StoryFn } from '@storybook/react';
import { action } from '@storybook/addon-actions';
import enMessages from '../../../_locales/en/messages.json';
import { setupI18n } from '../../util/setupI18n';
import { ThemeType } from '../../types/Util';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import type { PropsType } from './ContactSpoofingReviewDialogPerson';
import { ContactSpoofingReviewDialogPerson } from './ContactSpoofingReviewDialogPerson';
const i18n = setupI18n('en', enMessages);
export default {
component: ContactSpoofingReviewDialogPerson,
title: 'Components/Conversation/ContactSpoofingReviewDialogPerson',
argTypes: {
oldName: { control: { type: 'text' } },
isSignalConnection: { control: { type: 'boolean' } },
},
args: {
i18n,
onClick: action('onClick'),
toggleSignalConnectionsModal: action('toggleSignalConnectionsModal'),
getPreferredBadge: () => undefined,
conversation: getDefaultConversation(),
theme: ThemeType.light,
oldName: undefined,
isSignalConnection: false,
},
} satisfies Meta<PropsType>;
// eslint-disable-next-line react/function-component-definition
const Template: StoryFn<PropsType> = args => {
return <ContactSpoofingReviewDialogPerson {...args} />;
};
export const Normal = Template.bind({});
export const SignalConnection = Template.bind({});
SignalConnection.args = {
isSignalConnection: true,
};
export const ProfileNameChanged = Template.bind({});
ProfileNameChanged.args = {
oldName: 'Imposter',
};
export const WithSharedGroups = Template.bind({});
WithSharedGroups.args = {
conversation: getDefaultConversation({
sharedGroupNames: ['A', 'B', 'C'],
}),
};

View file

@ -12,15 +12,20 @@ import { assertDev } from '../../util/assert';
import { Avatar, AvatarSize } from '../Avatar';
import { ContactName } from './ContactName';
import { SharedGroupNames } from '../SharedGroupNames';
import { UserText } from '../UserText';
import { Intl } from '../Intl';
type PropsType = {
export type PropsType = Readonly<{
children?: ReactNode;
conversation: ConversationType;
getPreferredBadge: PreferredBadgeSelectorType;
i18n: LocalizerType;
onClick?: () => void;
toggleSignalConnectionsModal: () => void;
theme: ThemeType;
};
oldName: string | undefined;
isSignalConnection: boolean;
}>;
export function ContactSpoofingReviewDialogPerson({
children,
@ -28,13 +33,44 @@ export function ContactSpoofingReviewDialogPerson({
getPreferredBadge,
i18n,
onClick,
toggleSignalConnectionsModal,
theme,
oldName,
isSignalConnection,
}: PropsType): JSX.Element {
assertDev(
conversation.type === 'direct',
'<ContactSpoofingReviewDialogPerson> expected a direct conversation'
);
const newName = conversation.profileName || conversation.title;
let callout: JSX.Element | undefined;
if (oldName && oldName !== newName) {
callout = (
<div className="module-ContactSpoofingReviewDialogPerson__info__property">
<i className="module-ContactSpoofingReviewDialogPerson__info__property__icon module-ContactSpoofingReviewDialogPerson__info__property__icon--person" />
<div>
<Intl
i18n={i18n}
id="icu:ContactSpoofingReviewDialog__group__name-change-info"
components={{
oldName: <UserText text={oldName} />,
newName: <UserText text={newName} />,
}}
/>
</div>
</div>
);
}
const name = (
<ContactName
module="module-ContactSpoofingReviewDialogPerson__info__contact-name"
title={conversation.title}
/>
);
const contents = (
<>
<Avatar
@ -45,40 +81,59 @@ export function ContactSpoofingReviewDialogPerson({
className="module-ContactSpoofingReviewDialogPerson__avatar"
i18n={i18n}
theme={theme}
onClick={onClick}
/>
<div className="module-ContactSpoofingReviewDialogPerson__info">
<ContactName
module="module-ContactSpoofingReviewDialogPerson__info__contact-name"
title={conversation.title}
/>
{onClick ? (
<button
type="button"
className="module-ContactSpoofingReviewDialogPerson"
onClick={onClick}
>
{name}
</button>
) : (
name
)}
{callout}
{conversation.phoneNumber ? (
<div className="module-ContactSpoofingReviewDialogPerson__info__property">
{conversation.phoneNumber}
<i className="module-ContactSpoofingReviewDialogPerson__info__property__icon module-ContactSpoofingReviewDialogPerson__info__property__icon--phone" />
<div>{conversation.phoneNumber}</div>
</div>
) : null}
{isSignalConnection ? (
<div className="module-ContactSpoofingReviewDialogPerson__info__property">
<i className="module-ContactSpoofingReviewDialogPerson__info__property__icon module-ContactSpoofingReviewDialogPerson__info__property__icon--connections" />
<button
type="button"
className="module-ContactSpoofingReviewDialogPerson__info__property__signal-connection"
onClick={toggleSignalConnectionsModal}
>
{i18n('icu:ContactSpoofingReviewDialog__signal-connection')}
</button>
</div>
) : null}
<div className="module-ContactSpoofingReviewDialogPerson__info__property">
<SharedGroupNames
i18n={i18n}
sharedGroupNames={conversation.sharedGroupNames || []}
/>
<i className="module-ContactSpoofingReviewDialogPerson__info__property__icon module-ContactSpoofingReviewDialogPerson__info__property__icon--group" />
<div>
{conversation.sharedGroupNames?.length ? (
<SharedGroupNames
i18n={i18n}
sharedGroupNames={conversation.sharedGroupNames || []}
/>
) : (
i18n(
'icu:ContactSpoofingReviewDialog__group__members__no-shared-groups'
)
)}
</div>
</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

@ -26,6 +26,7 @@ export default {
unblurAvatar: action('unblurAvatar'),
updateSharedGroups: action('updateSharedGroups'),
viewUserStories: action('viewUserStories'),
toggleAboutContactModal: action('toggleAboutContactModal'),
},
} satisfies Meta<Props>;
@ -78,7 +79,7 @@ export const DirectNoGroupsJustPhoneNumber = Template.bind({});
DirectNoGroupsJustPhoneNumber.args = {
phoneNumber: casual.phone,
profileName: '',
title: '',
title: casual.phone,
};
export const DirectNoGroupsNoData = Template.bind({});
@ -86,7 +87,7 @@ DirectNoGroupsNoData.args = {
avatarPath: undefined,
phoneNumber: '',
profileName: '',
title: '',
title: casual.phone,
};
export const DirectNoGroupsNoDataNotAccepted = Template.bind({});

View file

@ -13,7 +13,6 @@ import type { HasStories } from '../../types/Stories';
import type { ViewUserStoriesActionCreatorType } from '../../state/ducks/stories';
import { StoryViewModeType } from '../../types/Stories';
import { ConfirmationDialog } from '../ConfirmationDialog';
import { Button, ButtonSize, ButtonVariant } from '../Button';
import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser';
@ -27,7 +26,6 @@ export type Props = {
isMe: boolean;
isSignalConversation?: boolean;
membersCount?: number;
name?: string;
phoneNumber?: string;
sharedGroupNames?: ReadonlyArray<string>;
unblurAvatar: (conversationId: string) => void;
@ -35,6 +33,7 @@ export type Props = {
updateSharedGroups: (conversationId: string) => unknown;
theme: ThemeType;
viewUserStories: ViewUserStoriesActionCreatorType;
toggleAboutContactModal: (conversationId: string) => unknown;
} & Omit<AvatarProps, 'onClick' | 'size' | 'noteToSelf'>;
const renderMembershipRow = ({
@ -56,22 +55,25 @@ const renderMembershipRow = ({
Required<Pick<Props, 'sharedGroupNames'>> & {
onClickMessageRequestWarning: () => void;
}) => {
const className = 'module-conversation-hero__membership';
if (conversationType !== 'direct') {
return null;
}
if (isMe) {
return <div className={className}>{i18n('icu:noteToSelfHero')}</div>;
return (
<div className="module-conversation-hero__note-to-self">
{i18n('icu:noteToSelfHero')}
</div>
);
}
if (sharedGroupNames.length > 0) {
return (
<div className={className}>
<div className="module-conversation-hero__membership">
<i className="module-conversation-hero__membership__chevron" />
<SharedGroupNames
i18n={i18n}
nameClassName={`${className}__name`}
nameClassName="module-conversation-hero__membership__name"
sharedGroupNames={sharedGroupNames}
/>
</div>
@ -81,21 +83,30 @@ const renderMembershipRow = ({
if (phoneNumber) {
return null;
}
return <div className={className}>{i18n('icu:no-groups-in-common')}</div>;
return (
<div className="module-conversation-hero__membership">
{i18n('icu:no-groups-in-common')}
</div>
);
}
return (
<div className="module-conversation-hero__message-request-warning">
<div className="module-conversation-hero__message-request-warning__message">
{i18n('icu:no-groups-in-common-warning')}
<div className="module-conversation-hero__membership">
<div className="module-conversation-hero__membership__warning">
<i className="module-conversation-hero__membership__warning__icon" />
<span>{i18n('icu:no-groups-in-common-warning')}</span>
&nbsp;
<button
className="module-conversation-hero__membership__warning__learn-more"
type="button"
onClick={ev => {
ev.preventDefault();
onClickMessageRequestWarning();
}}
>
{i18n('icu:MessageRequestWarning__learn-more')}
</button>
</div>
<Button
onClick={onClickMessageRequestWarning}
size={ButtonSize.Small}
variant={ButtonVariant.SecondaryAffirmative}
>
{i18n('icu:MessageRequestWarning__learn-more')}
</Button>
</div>
);
};
@ -115,7 +126,6 @@ export function ConversationHero({
isSignalConversation,
membersCount,
sharedGroupNames = [],
name,
phoneNumber,
profileName,
theme,
@ -124,6 +134,7 @@ export function ConversationHero({
unblurredAvatarPath,
updateSharedGroups,
viewUserStories,
toggleAboutContactModal,
}: Props): JSX.Element {
const [isShowingMessageRequestWarning, setIsShowingMessageRequestWarning] =
useState(false);
@ -158,9 +169,29 @@ export function ConversationHero({
};
}
const phoneNumberOnly = Boolean(
!name && !profileName && conversationType === 'direct'
);
let titleElem: JSX.Element | undefined;
if (isMe) {
titleElem = <>{i18n('icu:noteToSelf')}</>;
} else if (isSignalConversation || conversationType !== 'direct') {
titleElem = (
<ContactName isSignalConversation={isSignalConversation} title={title} />
);
} else if (title) {
titleElem = (
<button
type="button"
className="module-conversation-hero__title"
onClick={ev => {
ev.preventDefault();
toggleAboutContactModal(id);
}}
>
<ContactName title={title} />
<i className="module-conversation-hero__title__chevron" />
</button>
);
}
/* eslint-disable no-nested-ternary */
return (
@ -187,14 +218,7 @@ export function ConversationHero({
title={title}
/>
<h1 className="module-conversation-hero__profile-name">
{isMe ? (
i18n('icu:noteToSelf')
) : (
<ContactName
isSignalConversation={isSignalConversation}
title={title}
/>
)}
{titleElem}
{isMe && <span className="ContactModal__official-badge__large" />}
</h1>
{about && !isMe && (
@ -212,9 +236,7 @@ export function ConversationHero({
/>
) : membersCount != null ? (
i18n('icu:ConversationHero--members', { count: membersCount })
) : phoneNumberOnly ? null : (
phoneNumber
)}
) : null}
</div>
) : null}
{!isSignalConversation &&

View file

@ -13,10 +13,8 @@ import type { PropsType } from './Timeline';
import { Timeline } from './Timeline';
import type { TimelineItemType } from './TimelineItem';
import { TimelineItem } from './TimelineItem';
import { ContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
import { StorybookThemeContext } from '../../../.storybook/StorybookThemeContext';
import { ConversationHero } from './ConversationHero';
import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from '../../state/smart/ContactSpoofingReviewDialog';
import { getDefaultConversation } from '../../test-both/helpers/getDefaultConversation';
import { TypingBubble } from './TypingBubble';
import { ContactSpoofingType } from '../../util/contactSpoofing';
@ -26,9 +24,13 @@ import { ThemeType } from '../../types/Util';
import { TextDirection } from './Message';
import { PaymentEventKind } from '../../types/Payment';
import type { PropsData as TimelineMessageProps } from './TimelineMessage';
import { CollidingAvatars } from '../CollidingAvatars';
const i18n = setupI18n('en', enMessages);
const alice = getDefaultConversation();
const bob = getDefaultConversation();
export default {
title: 'Components/Conversation/Timeline',
argTypes: {},
@ -323,10 +325,7 @@ const actions = () => ({
returnToActiveCall: action('returnToActiveCall'),
closeContactSpoofingReview: action('closeContactSpoofingReview'),
reviewGroupMemberNameCollision: action('reviewGroupMemberNameCollision'),
reviewMessageRequestNameCollision: action(
'reviewMessageRequestNameCollision'
),
reviewConversationNameCollision: action('reviewConversationNameCollision'),
unblurAvatar: action('unblurAvatar'),
@ -375,35 +374,9 @@ const renderItem = ({
/>
);
const renderContactSpoofingReviewDialog = (
props: SmartContactSpoofingReviewDialogPropsType
) => {
const sharedProps = {
acceptConversation: action('acceptConversation'),
blockAndReportSpam: action('blockAndReportSpam'),
blockConversation: action('blockConversation'),
deleteConversation: action('deleteConversation'),
getPreferredBadge: () => undefined,
i18n,
removeMember: action('removeMember'),
showContactModal: action('showContactModal'),
theme: ThemeType.dark,
};
if (props.type === ContactSpoofingType.MultipleGroupMembersWithSameTitle) {
return (
<ContactSpoofingReviewDialog
{...props}
{...sharedProps}
group={{
...getDefaultConversation(),
areWeAdmin: true,
}}
/>
);
}
return <ContactSpoofingReviewDialog {...props} {...sharedProps} />;
const renderContactSpoofingReviewDialog = () => {
// hasContactSpoofingReview is always false in stories
return <div />;
};
const getAbout = () => '👍 Free to chat';
@ -433,6 +406,7 @@ const renderHeroRow = () => {
unblurAvatar={action('unblurAvatar')}
updateSharedGroups={noop}
viewUserStories={action('viewUserStories')}
toggleAboutContactModal={action('toggleAboutContactModal')}
/>
);
}
@ -452,6 +426,9 @@ const renderTypingBubble = () => (
theme={ThemeType.light}
/>
);
const renderCollidingAvatars = () => (
<CollidingAvatars i18n={i18n} conversations={[alice, bob]} />
);
const renderMiniPlayer = () => (
<div>If active, this is where smart mini player would be</div>
);
@ -477,12 +454,14 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
invitedContactsForNewlyCreatedGroup:
overrideProps.invitedContactsForNewlyCreatedGroup || [],
warning: overrideProps.warning,
hasContactSpoofingReview: false,
id: uuid(),
renderItem,
renderHeroRow,
renderMiniPlayer,
renderTypingBubble,
renderCollidingAvatars,
renderContactSpoofingReviewDialog,
isSomeoneTyping: overrideProps.isSomeoneTyping || false,
@ -581,7 +560,9 @@ export function WithSameNameInDirectConversationWarning(): JSX.Element {
const props = useProps({
warning: {
type: ContactSpoofingType.DirectConversationWithSameTitle,
safeConversation: getDefaultConversation(),
// Just to pacify type-script
safeConversationId: '123',
},
items: [],
});
@ -590,6 +571,21 @@ export function WithSameNameInDirectConversationWarning(): JSX.Element {
}
export function WithSameNameInGroupConversationWarning(): JSX.Element {
const props = useProps({
warning: {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
acknowledgedGroupNameCollisions: {},
groupNameCollisions: {
Alice: times(2, () => uuid()),
},
},
items: [],
});
return <Timeline {...props} />;
}
export function WithSameNamesInGroupConversationWarning(): JSX.Element {
const props = useProps({
warning: {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,

View file

@ -58,7 +58,7 @@ const LOAD_NEWER_THRESHOLD = 5;
export type WarningType = ReadonlyDeep<
| {
type: ContactSpoofingType.DirectConversationWithSameTitle;
safeConversation: ConversationType;
safeConversationId: string;
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
@ -67,23 +67,6 @@ export type WarningType = ReadonlyDeep<
}
>;
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;
haveOldest: boolean;
@ -112,7 +95,7 @@ type PropsHousekeepingType = {
shouldShowMiniPlayer: boolean;
warning?: WarningType;
contactSpoofingReview?: ContactSpoofingReviewPropType;
hasContactSpoofingReview: boolean | undefined;
discardMessages: (
_: Readonly<
@ -128,6 +111,9 @@ type PropsHousekeepingType = {
i18n: LocalizerType;
theme: ThemeType;
renderCollidingAvatars: (_: {
conversationIds: ReadonlyArray<string>;
}) => JSX.Element;
renderContactSpoofingReviewDialog: (
props: SmartContactSpoofingReviewDialogPropsType
) => JSX.Element;
@ -167,12 +153,7 @@ export type PropsActionsType = {
setIsNearBottom: (conversationId: string, isNearBottom: boolean) => unknown;
peekGroupCallForTheFirstTime: (conversationId: string) => unknown;
peekGroupCallIfItHasMembers: (conversationId: string) => unknown;
reviewGroupMemberNameCollision: (groupConversationId: string) => void;
reviewMessageRequestNameCollision: (
_: Readonly<{
safeConversationId: string;
}>
) => void;
reviewConversationNameCollision: () => void;
scrollToOldestUnreadMention: (conversationId: string) => unknown;
};
@ -798,7 +779,7 @@ export class Timeline extends React.Component<
acknowledgeGroupMemberNameCollisions,
clearInvitedServiceIdsForNewlyCreatedGroup,
closeContactSpoofingReview,
contactSpoofingReview,
hasContactSpoofingReview,
getPreferredBadge,
getTimestampForMessage,
haveNewest,
@ -811,13 +792,13 @@ export class Timeline extends React.Component<
items,
messageLoadingState,
oldestUnseenIndex,
renderCollidingAvatars,
renderContactSpoofingReviewDialog,
renderHeroRow,
renderItem,
renderMiniPlayer,
renderTypingBubble,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision,
reviewConversationNameCollision,
scrollToOldestUnreadMention,
shouldShowMiniPlayer,
theme,
@ -963,8 +944,14 @@ export class Timeline extends React.Component<
let headerElements: ReactNode;
if (warning || shouldShowMiniPlayer) {
let text: ReactChild | undefined;
let icon: ReactChild | undefined;
let onClose: () => void;
if (warning) {
icon = (
<TimelineWarning.IconContainer>
<TimelineWarning.GenericIcon />
</TimelineWarning.IconContainer>
);
switch (warning.type) {
case ContactSpoofingType.DirectConversationWithSameTitle:
text = (
@ -976,11 +963,7 @@ export class Timeline extends React.Component<
// eslint-disable-next-line react/no-unstable-nested-components
reviewRequestLink: parts => (
<TimelineWarning.Link
onClick={() => {
reviewMessageRequestNameCollision({
safeConversationId: warning.safeConversation.id,
});
}}
onClick={reviewConversationNameCollision}
>
{parts}
</TimelineWarning.Link>
@ -998,24 +981,25 @@ export class Timeline extends React.Component<
const { groupNameCollisions } = warning;
const numberOfSharedNames = Object.keys(groupNameCollisions).length;
const reviewRequestLink: FullJSXType = parts => (
<TimelineWarning.Link
onClick={() => {
reviewGroupMemberNameCollision(id);
}}
>
<TimelineWarning.Link onClick={reviewConversationNameCollision}>
{parts}
</TimelineWarning.Link>
);
if (numberOfSharedNames === 1) {
const [conversationIds] = [...Object.values(groupNameCollisions)];
if (conversationIds.length >= 2) {
icon = (
<TimelineWarning.CustomInfo>
{renderCollidingAvatars({ conversationIds })}
</TimelineWarning.CustomInfo>
);
}
text = (
<Intl
i18n={i18n}
id="icu:ContactSpoofing__same-name-in-group--link"
components={{
count: Object.values(groupNameCollisions).reduce(
(result, conversations) => result + conversations.length,
0
),
count: conversationIds.length,
reviewRequestLink,
}}
/>
@ -1053,9 +1037,7 @@ export class Timeline extends React.Component<
{renderMiniPlayer({ shouldFlow: true })}
{text && (
<TimelineWarning i18n={i18n} onClose={onClose}>
<TimelineWarning.IconContainer>
<TimelineWarning.GenericIcon />
</TimelineWarning.IconContainer>
{icon}
<TimelineWarning.Text>{text}</TimelineWarning.Text>
</TimelineWarning>
)}
@ -1066,33 +1048,11 @@ export class Timeline extends React.Component<
}
let contactSpoofingReviewDialog: ReactNode;
if (contactSpoofingReview) {
const commonProps = {
if (hasContactSpoofingReview) {
contactSpoofingReviewDialog = renderContactSpoofingReviewDialog({
conversationId: id,
onClose: closeContactSpoofingReview,
};
switch (contactSpoofingReview.type) {
case ContactSpoofingType.DirectConversationWithSameTitle:
contactSpoofingReviewDialog = renderContactSpoofingReviewDialog({
...commonProps,
type: ContactSpoofingType.DirectConversationWithSameTitle,
possiblyUnsafeConversation:
contactSpoofingReview.possiblyUnsafeConversation,
safeConversation: contactSpoofingReview.safeConversation,
});
break;
case ContactSpoofingType.MultipleGroupMembersWithSameTitle:
contactSpoofingReviewDialog = renderContactSpoofingReviewDialog({
...commonProps,
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
groupConversationId: id,
collisionInfoByTitle: contactSpoofingReview.collisionInfoByTitle,
});
break;
default:
throw missingCaseError(contactSpoofingReview);
}
});
}
return (

View file

@ -71,3 +71,11 @@ function Link({ children, onClick }: Readonly<LinkProps>): JSX.Element {
}
TimelineWarning.Link = Link;
function CustomInfo({
children,
}: Readonly<{ children: ReactNode }>): JSX.Element {
return <div className="module-TimelineWarning__custom_info">{children}</div>;
}
TimelineWarning.CustomInfo = CustomInfo;

View file

@ -102,6 +102,7 @@ const createProps = (
setMuteExpiration: action('setMuteExpiration'),
userAvatarData: [],
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
toggleAboutContactModal: action('toggleAboutContactModal'),
toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'),
onOutgoingAudioCallInConversation: action(
'onOutgoingAudioCallInConversation'

View file

@ -150,6 +150,7 @@ type ActionProps = {
setMuteExpiration: (id: string, muteExpiresAt: undefined | number) => unknown;
showContactModal: (contactId: string, conversationId?: string) => void;
showConversation: ShowConversationType;
toggleAboutContactModal: (contactId: string) => void;
toggleAddUserToAnotherGroupModal: (contactId?: string) => void;
toggleSafetyNumberModal: (conversationId: string) => unknown;
updateGroupAttributes: (
@ -223,6 +224,7 @@ export function ConversationDetails({
showConversation,
showLightboxWithMedia,
theme,
toggleAboutContactModal,
toggleSafetyNumberModal,
toggleAddUserToAnotherGroupModal,
updateGroupAttributes,
@ -398,6 +400,7 @@ export function ConversationDetails({
);
}}
theme={theme}
toggleAboutContactModal={toggleAboutContactModal}
/>
<div className="ConversationDetails__header-buttons">

View file

@ -45,6 +45,7 @@ function Wrapper(overrideProps: Partial<Props>) {
isGroup
isMe={false}
theme={theme}
toggleAboutContactModal={action('toggleAboutContactModal')}
{...overrideProps}
/>
);
@ -80,7 +81,16 @@ export function EditableNoDescription(): JSX.Element {
}
export function OneOnOne(): JSX.Element {
return <Wrapper isGroup={false} badges={getFakeBadges(3)} />;
return (
<Wrapper
isGroup={false}
badges={getFakeBadges(3)}
conversation={getDefaultConversation({
title: 'Maya Johnson',
type: 'direct',
})}
/>
);
}
export function NoteToSelf(): JSX.Element {

View file

@ -11,7 +11,7 @@ import { GroupDescription } from '../GroupDescription';
import { About } from '../About';
import type { GroupV2Membership } from './ConversationDetailsMembershipList';
import type { LocalizerType, ThemeType } from '../../../types/Util';
import { bemGenerator } from './util';
import { assertDev } from '../../../util/assert';
import { BadgeDialog } from '../../BadgeDialog';
import type { BadgeType } from '../../../badges/types';
import { UserText } from '../../UserText';
@ -26,6 +26,7 @@ export type Props = {
isMe: boolean;
memberships: ReadonlyArray<GroupV2Membership>;
startEditing: (isGroupTitle: boolean) => void;
toggleAboutContactModal: (contactId: string) => void;
theme: ThemeType;
};
@ -34,8 +35,6 @@ enum ConversationDetailsHeaderActiveModal {
ShowingBadges,
}
const bem = bemGenerator('ConversationDetails-header');
export function ConversationDetailsHeader({
areWeASubscriber,
badges,
@ -46,6 +45,7 @@ export function ConversationDetailsHeader({
isMe,
memberships,
startEditing,
toggleAboutContactModal,
theme,
}: Props): JSX.Element {
const [activeModal, setActiveModal] = useState<
@ -75,10 +75,10 @@ export function ConversationDetailsHeader({
} else if (!isMe) {
subtitle = (
<>
<div className={bem('subtitle__about')}>
<div className="ConversationDetailsHeader__subtitle__about">
<About text={conversation.about} />
</div>
<div className={bem('subtitle__phone-number')}>
<div className="ConversationDetailsHeader__subtitle__phone-number">
{conversation.phoneNumber}
</div>
</>
@ -105,15 +105,6 @@ export function ConversationDetailsHeader({
/>
);
const contents = (
<div>
<div className={bem('title')}>
{isMe ? i18n('icu:noteToSelf') : <UserText text={conversation.title} />}
{isMe && <span className="ContactModal__official-badge__large" />}
</div>
</div>
);
let modal: ReactNode;
switch (activeModal) {
case ConversationDetailsHeaderActiveModal.ShowingAvatar:
@ -150,8 +141,13 @@ export function ConversationDetailsHeader({
}
if (canEdit) {
assertDev(isGroup, 'Only groups support editable title');
return (
<div className={bem('root')} data-testid="ConversationDetailsHeader">
<div
className="ConversationDetailsHeader"
data-testid="ConversationDetailsHeader"
>
{modal}
{avatar}
<button
@ -161,12 +157,14 @@ export function ConversationDetailsHeader({
ev.stopPropagation();
startEditing(true);
}}
className={bem('root', 'editable')}
className="ConversationDetailsHeader__edit-button"
>
{contents}
<div className="ConversationDetailsHeader__title">
<UserText text={conversation.title} />
</div>
</button>
{hasNestedButton ? (
<div className={bem('subtitle')}>{subtitle}</div>
<div className="ConversationDetailsHeader__subtitle">{subtitle}</div>
) : (
<button
type="button"
@ -179,21 +177,61 @@ export function ConversationDetailsHeader({
ev.stopPropagation();
startEditing(false);
}}
className={bem('root', 'editable')}
className="ConversationDetailsHeader__edit-button"
>
<div className={bem('subtitle')}>{subtitle}</div>
<div className="ConversationDetailsHeader__subtitle">
{subtitle}
</div>
</button>
)}
</div>
);
}
let title: JSX.Element;
if (isMe) {
title = (
<div className="ConversationDetailsHeader__title">
{i18n('icu:noteToSelf')}
<span className="ContactModal__official-badge__large" />
</div>
);
} else if (isGroup) {
title = (
<div className="ConversationDetailsHeader__title">
<UserText text={conversation.title} />
</div>
);
} else {
title = (
<button
type="button"
onClick={ev => {
ev.preventDefault();
ev.stopPropagation();
toggleAboutContactModal(conversation.id);
}}
className="ConversationDetailsHeader__about-button"
>
<div className="ConversationDetailsHeader__title">
<UserText text={conversation.title} />
<span className="ConversationDetailsHeader__about-icon" />
</div>
</button>
);
}
return (
<div className={bem('root')} data-testid="ConversationDetailsHeader">
<div
className="ConversationDetailsHeader"
data-testid="ConversationDetailsHeader"
>
{modal}
{avatar}
{contents}
<div className={bem('subtitle')}>{subtitle}</div>
{title}
<div className="ConversationDetailsHeader__subtitle">{subtitle}</div>
</div>
);
}

1
ts/model-types.d.ts vendored
View file

@ -381,6 +381,7 @@ export type ConversationAttributesType = {
profileKey?: string;
profileName?: string;
verified?: number;
profileLastUpdatedAt?: number;
profileLastFetchedAt?: number;
pendingUniversalTimer?: string;
pendingRemovedContactNotification?: string;

View file

@ -3185,6 +3185,8 @@ export class ConversationModel extends window.Backbone
const serviceId = this.getServiceId();
if (isDirectConversation(this.attributes) && serviceId) {
this.set({ profileLastUpdatedAt: Date.now() });
void window.ConversationController.getAllGroupsInvolvingServiceId(
serviceId
).then(groups => {

View file

@ -83,7 +83,6 @@ import {
import { isMessageUnread } from '../../util/isMessageUnread';
import { toggleSelectedContactForGroupAddition } from '../../groups/toggleSelectedContactForGroupAddition';
import type { GroupNameCollisionsWithIdsByTitle } from '../../util/groupMemberNameCollisions';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { writeProfile } from '../../services/writeProfile';
import {
getConversationServiceIdsStoppingSend,
@ -237,6 +236,7 @@ export type ConversationType = ReadonlyDeep<
familyName?: string;
firstName?: string;
profileName?: string;
profileLastUpdatedAt?: number;
username?: string;
about?: string;
aboutText?: string;
@ -464,17 +464,6 @@ type ComposerStateType = ReadonlyDeep<
))
>;
type ContactSpoofingReviewStateType = ReadonlyDeep<
| {
type: ContactSpoofingType.DirectConversationWithSameTitle;
safeConversationId: string;
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
groupConversationId: string;
}
>;
// eslint-disable-next-line local-rules/type-alias-readonlydeep -- FIXME
export type ConversationsStateType = Readonly<{
preJoinConversation?: PreJoinConversationType;
@ -502,7 +491,7 @@ export type ConversationsStateType = Readonly<{
showArchived: boolean;
composer?: ComposerStateType;
contactSpoofingReview?: ContactSpoofingReviewStateType;
hasContactSpoofingReview: boolean;
/**
* Each key is a conversation ID. Each value is a value representing the state of
@ -850,17 +839,8 @@ export type TargetedConversationChangedActionType = ReadonlyDeep<{
switchToAssociatedView?: boolean;
};
}>;
type ReviewGroupMemberNameCollisionActionType = ReadonlyDeep<{
type: 'REVIEW_GROUP_MEMBER_NAME_COLLISION';
payload: {
groupConversationId: string;
};
}>;
type ReviewMessageRequestNameCollisionActionType = ReadonlyDeep<{
type: 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION';
payload: {
safeConversationId: string;
};
type ReviewConversationNameCollisionActionType = ReadonlyDeep<{
type: 'REVIEW_CONVERSATION_NAME_COLLISION';
}>;
type ShowInboxActionType = ReadonlyDeep<{
type: 'SHOW_INBOX';
@ -989,8 +969,7 @@ export type ConversationActionType =
| RepairNewestMessageActionType
| RepairOldestMessageActionType
| ReplaceAvatarsActionType
| ReviewGroupMemberNameCollisionActionType
| ReviewMessageRequestNameCollisionActionType
| ReviewConversationNameCollisionActionType
| ScrollToMessageActionType
| TargetedConversationChangedActionType
| SetComposeGroupAvatarActionType
@ -1092,8 +1071,7 @@ export const actions = {
copyMessageText,
retryDeleteForEveryone,
retryMessageSend,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision,
reviewConversationNameCollision,
revokePendingMembershipsFromGroupV2,
saveAttachment,
saveAttachmentFromMessage,
@ -2885,23 +2863,12 @@ function repairOldestMessage(
};
}
function reviewGroupMemberNameCollision(
groupConversationId: string
): ReviewGroupMemberNameCollisionActionType {
function reviewConversationNameCollision(): ReviewConversationNameCollisionActionType {
return {
type: 'REVIEW_GROUP_MEMBER_NAME_COLLISION',
payload: { groupConversationId },
type: 'REVIEW_CONVERSATION_NAME_COLLISION',
};
}
function reviewMessageRequestNameCollision(
payload: Readonly<{
safeConversationId: string;
}>
): ReviewMessageRequestNameCollisionActionType {
return { type: 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION', payload };
}
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type MessageResetOptionsType = {
conversationId: string;
@ -4208,6 +4175,7 @@ export function getEmptyState(): ConversationsStateType {
lastSelectedMessage: undefined,
selectedMessageIds: undefined,
showArchived: false,
hasContactSpoofingReview: false,
targetedConversationPanels: {
isAnimating: false,
wasAnimated: false,
@ -4591,7 +4559,10 @@ export function reducer(
}
if (action.type === 'CLOSE_CONTACT_SPOOFING_REVIEW') {
return omit(state, 'contactSpoofingReview');
return {
...state,
hasContactSpoofingReview: false,
};
}
if (action.type === 'CLOSE_MAXIMUM_GROUP_SIZE_MODAL') {
@ -4713,6 +4684,7 @@ export function reducer(
}
const keysToOmit: Array<keyof ConversationsStateType> = [];
const keyValuesToAdd: { hasContactSpoofingReview?: false } = {};
if (selectedConversationId === id) {
// Archived -> Inbox: we go back to the normal inbox view
@ -4728,12 +4700,13 @@ export function reducer(
}
if (!existing.isBlocked && data.isBlocked) {
keysToOmit.push('contactSpoofingReview');
keyValuesToAdd.hasContactSpoofingReview = false;
}
}
return {
...omit(state, keysToOmit),
...keyValuesToAdd,
selectedConversationId,
showArchived,
conversationLookup: {
@ -4775,7 +4748,8 @@ export function reducer(
: undefined;
return {
...omit(state, 'contactSpoofingReview'),
...state,
hasContactSpoofingReview: false,
selectedConversationId,
targetedConversationPanels: {
isAnimating: false,
@ -5494,23 +5468,10 @@ export function reducer(
};
}
if (action.type === 'REVIEW_GROUP_MEMBER_NAME_COLLISION') {
if (action.type === 'REVIEW_CONVERSATION_NAME_COLLISION') {
return {
...state,
contactSpoofingReview: {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
...action.payload,
},
};
}
if (action.type === 'REVIEW_MESSAGE_REQUEST_NAME_COLLISION') {
return {
...state,
contactSpoofingReview: {
type: ContactSpoofingType.DirectConversationWithSameTitle,
...action.payload,
},
hasContactSpoofingReview: true,
};
}
@ -5683,7 +5644,8 @@ export function reducer(
}
const nextState = {
...omit(state, 'contactSpoofingReview'),
...state,
hasContactSpoofingReview: false,
selectedConversationId: conversationId,
targetedMessage: messageId,
targetedMessageSource: TargetedMessageSource.NavigateToMessage,

View file

@ -77,9 +77,13 @@ type MigrateToGV2PropsType = ReadonlyDeep<{
hasMigrated: boolean;
invitedMemberIds: Array<string>;
}>;
export type AboutContactModalPropsType = ReadonlyDeep<{
contactId: string;
}>;
export type GlobalModalsStateType = ReadonlyDeep<{
addUserToAnotherGroupModalContactId?: string;
aboutContactModalProps?: AboutContactModalPropsType;
authArtCreatorData?: AuthorizeArtCreatorDataType;
contactModalState?: ContactModalStateType;
deleteMessagesProps?: DeleteMessagesPropsType;
@ -130,6 +134,7 @@ export const TOGGLE_PROFILE_EDITOR_ERROR =
const TOGGLE_SAFETY_NUMBER_MODAL = 'globalModals/TOGGLE_SAFETY_NUMBER_MODAL';
const TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL =
'globalModals/TOGGLE_ADD_USER_TO_ANOTHER_GROUP_MODAL';
const TOGGLE_ABOUT_MODAL = 'globalModals/TOGGLE_ABOUT_MODAL';
const TOGGLE_SIGNAL_CONNECTIONS_MODAL =
'globalModals/TOGGLE_SIGNAL_CONNECTIONS_MODAL';
export const SHOW_SEND_ANYWAY_DIALOG = 'globalModals/SHOW_SEND_ANYWAY_DIALOG';
@ -230,6 +235,11 @@ type ToggleAddUserToAnotherGroupModalActionType = ReadonlyDeep<{
payload: string | undefined;
}>;
type ToggleAboutContactModalActionType = ReadonlyDeep<{
type: typeof TOGGLE_ABOUT_MODAL;
payload: AboutContactModalPropsType | undefined;
}>;
type ToggleSignalConnectionsModalActionType = ReadonlyDeep<{
type: typeof TOGGLE_SIGNAL_CONNECTIONS_MODAL;
}>;
@ -372,6 +382,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| ShowUserNotFoundModalActionType
| ShowWhatsNewModalActionType
| StartMigrationToGV2ActionType
| ToggleAboutContactModalActionType
| ToggleAddUserToAnotherGroupModalActionType
| ToggleConfirmationModalActionType
| ToggleDeleteMessagesModalActionType
@ -411,6 +422,7 @@ export const actions = {
showStoriesSettings,
showUserNotFoundModal,
showWhatsNewModal,
toggleAboutContactModal,
toggleAddUserToAnotherGroupModal,
toggleConfirmationModal,
toggleDeleteMessagesModal,
@ -627,6 +639,15 @@ function toggleAddUserToAnotherGroupModal(
};
}
function toggleAboutContactModal(
contactId?: string
): ToggleAboutContactModalActionType {
return {
type: TOGGLE_ABOUT_MODAL,
payload: contactId ? { contactId } : undefined,
};
}
function toggleSignalConnectionsModal(): ToggleSignalConnectionsModalActionType {
return {
type: TOGGLE_SIGNAL_CONNECTIONS_MODAL,
@ -891,6 +912,13 @@ export function reducer(
state: Readonly<GlobalModalsStateType> = getEmptyState(),
action: Readonly<GlobalModalsActionType>
): GlobalModalsStateType {
if (action.type === TOGGLE_ABOUT_MODAL) {
return {
...state,
aboutContactModalProps: action.payload,
};
}
if (action.type === TOGGLE_PROFILE_EDITOR) {
return {
...state,

View file

@ -154,11 +154,34 @@ export const getAllSignalConnections = createSelector(
conversations.filter(isSignalConnection)
);
export const getConversationsByTitleSelector = createSelector(
export const getSafeConversationWithSameTitle = createSelector(
getAllConversations,
(conversations): ((title: string) => Array<ConversationType>) =>
(title: string) =>
conversations.filter(conversation => conversation.title === title)
(
_state: StateType,
{
possiblyUnsafeConversation,
}: {
possiblyUnsafeConversation: ConversationType;
}
) => possiblyUnsafeConversation,
(conversations, possiblyUnsafeConversation): ConversationType | undefined => {
const conversationsWithSameTitle = conversations.filter(conversation => {
return conversation.title === possiblyUnsafeConversation.title;
});
assertDev(
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 !== possiblyUnsafeConversation.id
);
return safeConversation;
}
);
export const getSelectedConversationId = createSelector(

View file

@ -0,0 +1,28 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { CollidingAvatars } from '../../components/CollidingAvatars';
import { getIntl } from '../selectors/user';
import { getConversationSelector } from '../selectors/conversations';
export type PropsType = Readonly<{
conversationIds: ReadonlyArray<string>;
}>;
export function SmartCollidingAvatars({
conversationIds,
}: PropsType): JSX.Element {
const i18n = useSelector(getIntl);
const getConversation = useSelector(getConversationSelector);
const conversations = useMemo(() => {
return conversationIds.map(getConversation).sort((a, b) => {
return (b.profileLastUpdatedAt ?? 0) - (a.profileLastUpdatedAt ?? 0);
});
}, [conversationIds, getConversation]);
return <CollidingAvatars i18n={i18n} conversations={conversations} />;
}

View file

@ -1,48 +1,42 @@
// Copyright 2022 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react';
import React, { useCallback } from 'react';
import { useSelector } from 'react-redux';
import { mapValues } from 'lodash';
import type { StateType } from '../reducer';
import { ContactSpoofingReviewDialog } from '../../components/conversation/ContactSpoofingReviewDialog';
import type { ConversationType } from '../ducks/conversations';
import { useConversationsActions } from '../ducks/conversations';
import type { GetConversationByIdType } from '../selectors/conversations';
import { getConversationSelector } from '../selectors/conversations';
import {
getConversationSelector,
getConversationByServiceIdSelector,
getSafeConversationWithSameTitle,
} from '../selectors/conversations';
import { getOwn } from '../../util/getOwn';
import { assertDev } from '../../util/assert';
import { ContactSpoofingType } from '../../util/contactSpoofing';
import { getGroupMemberships } from '../../util/getGroupMemberships';
import { isSignalConnection } from '../../util/getSignalConnections';
import {
getCollisionsFromMemberships,
invertIdsByTitle,
} from '../../util/groupMemberNameCollisions';
import { useGlobalModalActions } from '../ducks/globalModals';
import { getPreferredBadgeSelector } from '../selectors/badges';
import { getIntl, getTheme } from '../selectors/user';
export type PropsType =
| {
conversationId: string;
onClose: () => void;
} & (
| {
type: ContactSpoofingType.DirectConversationWithSameTitle;
possiblyUnsafeConversation: ConversationType;
safeConversation: ConversationType;
}
| {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle;
groupConversationId: string;
collisionInfoByTitle: Record<
string,
Array<{
oldName?: string;
conversation: ConversationType;
}>
>;
}
);
export type PropsType = Readonly<{
conversationId: string;
onClose: () => void;
}>;
export function SmartContactSpoofingReviewDialog(
props: PropsType
): JSX.Element {
const { type } = props;
): JSX.Element | null {
const { conversationId } = props;
const getConversation = useSelector<StateType, GetConversationByIdType>(
getConversationSelector
@ -55,12 +49,29 @@ export function SmartContactSpoofingReviewDialog(
deleteConversation,
removeMember,
} = useConversationsActions();
const { showContactModal } = useGlobalModalActions();
const { showContactModal, toggleSignalConnectionsModal } =
useGlobalModalActions();
const getPreferredBadge = useSelector(getPreferredBadgeSelector);
const i18n = useSelector(getIntl);
const theme = useSelector(getTheme);
const getConversationByServiceId = useSelector(
getConversationByServiceIdSelector
);
const conversation = getConversation(conversationId);
// Just binding the options argument
const safeConversationSelector = useCallback(
(state: StateType) => {
return getSafeConversationWithSameTitle(state, {
possiblyUnsafeConversation: conversation,
});
},
[conversation]
);
const safeConvo = useSelector(safeConversationSelector);
const sharedProps = {
...props,
acceptConversation,
blockAndReportSpam,
blockConversation,
@ -69,18 +80,65 @@ export function SmartContactSpoofingReviewDialog(
i18n,
removeMember,
showContactModal,
toggleSignalConnectionsModal,
theme,
};
if (type === ContactSpoofingType.MultipleGroupMembersWithSameTitle) {
if (conversation.type === 'group') {
const { memberships } = getGroupMemberships(
conversation,
getConversationByServiceId
);
const groupNameCollisions = getCollisionsFromMemberships(memberships);
const previouslyAcknowledgedTitlesById = invertIdsByTitle(
conversation.acknowledgedGroupNameCollisions
);
const collisionInfoByTitle = mapValues(groupNameCollisions, collisions =>
collisions.map(collision => ({
conversation: collision,
isSignalConnection: isSignalConnection(collision),
oldName: getOwn(previouslyAcknowledgedTitlesById, collision.id),
}))
);
return (
<ContactSpoofingReviewDialog
{...props}
{...sharedProps}
group={getConversation(props.groupConversationId)}
type={ContactSpoofingType.MultipleGroupMembersWithSameTitle}
group={conversation}
collisionInfoByTitle={collisionInfoByTitle}
/>
);
}
return <ContactSpoofingReviewDialog {...props} {...sharedProps} />;
const possiblyUnsafeConvo = conversation;
assertDev(
possiblyUnsafeConvo.type === 'direct',
'DirectConversationWithSameTitle: expects possibly unsafe direct ' +
'conversation'
);
if (!safeConvo) {
return null;
}
const possiblyUnsafe = {
conversation: possiblyUnsafeConvo,
isSignalConnection: isSignalConnection(possiblyUnsafeConvo),
};
const safe = {
conversation: safeConvo,
isSignalConnection: isSignalConnection(safeConvo),
};
return (
<ContactSpoofingReviewDialog
{...sharedProps}
type={ContactSpoofingType.DirectConversationWithSameTitle}
possiblyUnsafe={possiblyUnsafe}
safe={safe}
/>
);
}

View file

@ -6,6 +6,8 @@ import { useSelector } from 'react-redux';
import type { GlobalModalsStateType } from '../ducks/globalModals';
import type { StateType } from '../reducer';
import { isSignalConnection } from '../../util/getSignalConnections';
import type { ExternalPropsType as AboutContactModalPropsType } from '../../components/conversation/AboutContactModal';
import { ErrorModal } from '../../components/ErrorModal';
import { GlobalModalContainer } from '../../components/GlobalModalContainer';
import { SmartAddUserToAnotherGroupModal } from './AddUserToAnotherGroupModal';
@ -19,9 +21,13 @@ import { SmartSendAnywayDialog } from './SendAnywayDialog';
import { SmartShortcutGuideModal } from './ShortcutGuideModal';
import { SmartStickerPreviewModal } from './StickerPreviewModal';
import { SmartStoriesSettingsModal } from './StoriesSettingsModal';
import { getConversationsStoppingSend } from '../selectors/conversations';
import {
getConversationSelector,
getConversationsStoppingSend,
} from '../selectors/conversations';
import { getIntl, getTheme } from '../selectors/user';
import { useGlobalModalActions } from '../ducks/globalModals';
import { useConversationsActions } from '../ducks/conversations';
import { SmartDeleteMessagesModal } from './DeleteMessagesModal';
function renderEditHistoryMessagesModal(): JSX.Element {
@ -62,12 +68,14 @@ function renderShortcutGuideModal(): JSX.Element {
export function SmartGlobalModalContainer(): JSX.Element {
const conversationsStoppingSend = useSelector(getConversationsStoppingSend);
const getConversation = useSelector(getConversationSelector);
const i18n = useSelector(getIntl);
const theme = useSelector(getTheme);
const hasSafetyNumberChangeModal = conversationsStoppingSend.length > 0;
const {
aboutContactModalProps: aboutContactModalRawProps,
addUserToAnotherGroupModalContactId,
authArtCreatorData,
contactModalState,
@ -100,9 +108,24 @@ export function SmartGlobalModalContainer(): JSX.Element {
hideWhatsNewModal,
showFormattingWarningModal,
showSendEditWarningModal,
toggleAboutContactModal,
toggleSignalConnectionsModal,
} = useGlobalModalActions();
const { updateSharedGroups } = useConversationsActions();
let aboutContactModalProps: AboutContactModalPropsType | undefined;
if (aboutContactModalRawProps) {
const conversation = getConversation(aboutContactModalRawProps.contactId);
aboutContactModalProps = {
conversation,
isSignalConnection: isSignalConnection(conversation),
toggleSignalConnectionsModal,
updateSharedGroups,
};
}
const renderAddUserToAnotherGroup = useCallback(() => {
return (
<SmartAddUserToAnotherGroupModal
@ -140,6 +163,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
return (
<GlobalModalContainer
aboutContactModalProps={aboutContactModalProps}
addUserToAnotherGroupModalContactId={addUserToAnotherGroupModalContactId}
contactModalState={contactModalState}
editHistoryMessages={editHistoryMessages}
@ -176,6 +200,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
showSendEditWarningModal={showSendEditWarningModal}
stickerPackPreviewId={stickerPackPreviewId}
theme={theme}
toggleAboutContactModal={toggleAboutContactModal}
toggleSignalConnectionsModal={toggleSignalConnectionsModal}
userNotFoundModalState={userNotFoundModalState}
usernameOnboardingState={usernameOnboardingState}

View file

@ -1,17 +1,14 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEmpty, mapValues, pick } from 'lodash';
import { isEmpty, pick } from 'lodash';
import type { RefObject } from 'react';
import React from 'react';
import { connect } from 'react-redux';
import type { ReadonlyDeep } from 'type-fest';
import { mapDispatchToProps } from '../actions';
import type {
ContactSpoofingReviewPropType,
WarningType as TimelineWarningType,
} from '../../components/conversation/Timeline';
import type { WarningType as TimelineWarningType } from '../../components/conversation/Timeline';
import { Timeline } from '../../components/conversation/Timeline';
import type { StateType } from '../reducer';
import type { ConversationType } from '../ducks/conversations';
@ -22,26 +19,25 @@ import {
getConversationByServiceIdSelector,
getConversationMessagesSelector,
getConversationSelector,
getConversationsByTitleSelector,
getInvitedContactsForNewlyCreatedGroup,
getSafeConversationWithSameTitle,
getTargetedMessage,
} from '../selectors/conversations';
import { selectAudioPlayerActive } from '../selectors/audioPlayer';
import { SmartTimelineItem } from './TimelineItem';
import { SmartCollidingAvatars } from './CollidingAvatars';
import type { PropsType as SmartCollidingAvatarsPropsType } from './CollidingAvatars';
import { SmartContactSpoofingReviewDialog } from './ContactSpoofingReviewDialog';
import type { PropsType as SmartContactSpoofingReviewDialogPropsType } from './ContactSpoofingReviewDialog';
import { SmartTypingBubble } from './TypingBubble';
import { SmartHeroRow } from './HeroRow';
import { getOwn } from '../../util/getOwn';
import { assertDev } 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';
import type { UnreadIndicatorPlacement } from '../../util/timelineUtil';
@ -86,6 +82,12 @@ function renderItem({
);
}
function renderCollidingAvatars(
props: SmartCollidingAvatarsPropsType
): JSX.Element {
return <SmartCollidingAvatars {...props} />;
}
function renderContactSpoofingReviewDialog(
props: SmartContactSpoofingReviewDialogPropsType
): JSX.Element {
@ -109,27 +111,14 @@ const getWarning = (
switch (conversation.type) {
case 'direct':
if (!conversation.acceptedMessageRequest && !conversation.isBlocked) {
const getConversationsWithTitle =
getConversationsByTitleSelector(state);
const conversationsWithSameTitle = getConversationsWithTitle(
conversation.title
);
assertDev(
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 = getSafeConversationWithSameTitle(state, {
possiblyUnsafeConversation: conversation,
});
if (safeConversation) {
return {
type: ContactSpoofingType.DirectConversationWithSameTitle,
safeConversation,
safeConversationId: safeConversation.id,
};
}
}
@ -165,63 +154,6 @@ const getWarning = (
}
};
const getContactSpoofingReview = (
selectedConversationId: string,
state: Readonly<StateType>
): undefined | ContactSpoofingReviewPropType => {
const { contactSpoofingReview } = state.conversations;
if (!contactSpoofingReview) {
return undefined;
}
const conversationSelector = getConversationSelector(state);
const getConversationByServiceId = getConversationByServiceIdSelector(state);
const currentConversation = conversationSelector(selectedConversationId);
switch (contactSpoofingReview.type) {
case ContactSpoofingType.DirectConversationWithSameTitle:
return {
type: ContactSpoofingType.DirectConversationWithSameTitle,
possiblyUnsafeConversation: currentConversation,
safeConversation: conversationSelector(
contactSpoofingReview.safeConversationId
),
};
case ContactSpoofingType.MultipleGroupMembersWithSameTitle: {
assertDev(
currentConversation.type === 'group',
'MultipleGroupMembersWithSameTitle: expects group conversation'
);
const { memberships } = getGroupMemberships(
currentConversation,
getConversationByServiceId
);
const groupNameCollisions = getCollisionsFromMemberships(memberships);
const previouslyAcknowledgedTitlesById = invertIdsByTitle(
currentConversation.acknowledgedGroupNameCollisions
);
const collisionInfoByTitle = mapValues(
groupNameCollisions,
conversations =>
conversations.map(conversation => ({
conversation,
oldName: getOwn(previouslyAcknowledgedTitlesById, conversation.id),
}))
);
return {
type: ContactSpoofingType.MultipleGroupMembersWithSameTitle,
collisionInfoByTitle,
};
}
default:
throw missingCaseError(contactSpoofingReview);
}
};
const mapStateToProps = (state: StateType, props: ExternalProps) => {
const { id } = props;
@ -259,13 +191,14 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
shouldShowMiniPlayer,
warning: getWarning(conversation, state),
contactSpoofingReview: getContactSpoofingReview(id, state),
hasContactSpoofingReview: state.conversations.hasContactSpoofingReview,
getTimestampForMessage,
getPreferredBadge: getPreferredBadgeSelector(state),
i18n: getIntl(state),
theme: getTheme(state),
renderCollidingAvatars,
renderContactSpoofingReviewDialog,
renderHeroRow,
renderItem,

View file

@ -29,7 +29,7 @@ import {
getContactNameColorSelector,
getConversationByIdSelector,
getConversationServiceIdsStoppingSend,
getConversationsByTitleSelector,
getSafeConversationWithSameTitle,
getConversationSelector,
getConversationsStoppingSend,
getFilteredCandidateContactsForNewGroup,
@ -1577,32 +1577,32 @@ describe('both/state/selectors/conversations-extra', () => {
});
});
describe('#getConversationsByTitleSelector', () => {
describe('#getSafeConversationWithSameTitle', () => {
it('returns a selector that finds conversations by title', () => {
const unsafe = { ...makeConversation('abc'), title: 'Janet' };
const safe = { ...makeConversation('def'), title: 'Janet' };
const unique = { ...makeConversation('geh'), title: 'Rick' };
const state = {
...getEmptyRootState(),
conversations: {
...getEmptyState(),
conversationLookup: {
abc: { ...makeConversation('abc'), title: 'Janet' },
def: { ...makeConversation('def'), title: 'Janet' },
geh: { ...makeConversation('geh'), title: 'Rick' },
abc: unsafe,
def: safe,
geh: unique,
},
},
};
const selector = getConversationsByTitleSelector(state);
const janet = getSafeConversationWithSameTitle(state, {
possiblyUnsafeConversation: unsafe,
});
assert.strictEqual(janet, safe);
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'));
const rick = getSafeConversationWithSameTitle(state, {
possiblyUnsafeConversation: unique,
});
assert.strictEqual(rick, undefined);
});
});

View file

@ -34,7 +34,6 @@ import {
updateConversationLookups,
} from '../../../state/ducks/conversations';
import { ReadStatus } from '../../../messages/MessageReadStatus';
import { ContactSpoofingType } from '../../../util/contactSpoofing';
import type { SingleServePromiseIdString } from '../../../services/singleServePromise';
import { CallMode } from '../../../types/Calling';
import { generateAci, getAciFromPrefix } from '../../../types/ServiceId';
@ -75,8 +74,7 @@ const {
repairNewestMessage,
repairOldestMessage,
resetAllChatColors,
reviewGroupMemberNameCollision,
reviewMessageRequestNameCollision,
reviewConversationNameCollision,
setComposeGroupAvatar,
setComposeGroupName,
setComposeSearchTerm,
@ -523,15 +521,12 @@ describe('both/state/ducks/conversations', () => {
it('closes the contact spoofing review modal if it was open', () => {
const state = {
...getEmptyState(),
contactSpoofingReview: {
type: ContactSpoofingType.DirectConversationWithSameTitle as const,
safeConversationId: 'abc123',
},
hasContactSpoofingReview: true,
};
const action = closeContactSpoofingReview();
const actual = reducer(state, action);
assert.isUndefined(actual.contactSpoofingReview);
assert.isFalse(actual.hasContactSpoofingReview);
});
it("does nothing if the modal wasn't already open", () => {
@ -1347,31 +1342,13 @@ describe('both/state/ducks/conversations', () => {
});
});
describe('REVIEW_GROUP_MEMBER_NAME_COLLISION', () => {
it('starts reviewing a group member name collision', () => {
describe('REVIEW_CONVERSATION_NAME_COLLISION', () => {
it('starts reviewing a name collision', () => {
const state = getEmptyState();
const action = reviewGroupMemberNameCollision('abc123');
const action = reviewConversationNameCollision();
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();
const action = reviewMessageRequestNameCollision({
safeConversationId: 'def',
});
const actual = reducer(state, action);
assert.deepEqual(actual.contactSpoofingReview, {
type: ContactSpoofingType.DirectConversationWithSameTitle as const,
safeConversationId: 'def',
});
assert.isTrue(actual.hasContactSpoofingReview);
});
});

View file

@ -210,6 +210,7 @@ export function getConversation(model: ConversationModel): ConversationType {
phoneNumber: getNumber(attributes),
profileName: getProfileName(attributes),
profileSharing: attributes.profileSharing,
profileLastUpdatedAt: attributes.profileLastUpdatedAt,
notSharingPhoneNumber: attributes.notSharingPhoneNumber,
publicParams: attributes.publicParams,
secretParams: attributes.secretParams,