Conversation details changes for PNP
Co-authored-by: Scott Nonnenberg <scott@signal.org>
This commit is contained in:
parent
1a74da0c26
commit
eb82ace2de
49 changed files with 1660 additions and 699 deletions
|
@ -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 };
|
||||
|
|
29
ts/components/CollidingAvatars.stories.tsx
Normal file
29
ts/components/CollidingAvatars.stories.tsx
Normal 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} />;
|
||||
}
|
80
ts/components/CollidingAvatars.tsx
Normal file
80
ts/components/CollidingAvatars.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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();
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
60
ts/components/conversation/AboutContactModal.stories.tsx
Normal file
60
ts/components/conversation/AboutContactModal.stories.tsx
Normal 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
|
||||
/>
|
||||
);
|
||||
}
|
135
ts/components/conversation/AboutContactModal.tsx
Normal file
135
ts/components/conversation/AboutContactModal.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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(
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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' }),
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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'],
|
||||
}),
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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({});
|
||||
|
|
|
@ -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>
|
||||
|
||||
<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 &&
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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 (
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -102,6 +102,7 @@ const createProps = (
|
|||
setMuteExpiration: action('setMuteExpiration'),
|
||||
userAvatarData: [],
|
||||
toggleSafetyNumberModal: action('toggleSafetyNumberModal'),
|
||||
toggleAboutContactModal: action('toggleAboutContactModal'),
|
||||
toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'),
|
||||
onOutgoingAudioCallInConversation: action(
|
||||
'onOutgoingAudioCallInConversation'
|
||||
|
|
|
@ -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">
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -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
1
ts/model-types.d.ts
vendored
|
@ -381,6 +381,7 @@ export type ConversationAttributesType = {
|
|||
profileKey?: string;
|
||||
profileName?: string;
|
||||
verified?: number;
|
||||
profileLastUpdatedAt?: number;
|
||||
profileLastFetchedAt?: number;
|
||||
pendingUniversalTimer?: string;
|
||||
pendingRemovedContactNotification?: string;
|
||||
|
|
|
@ -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 => {
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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(
|
||||
|
|
28
ts/state/smart/CollidingAvatars.tsx
Normal file
28
ts/state/smart/CollidingAvatars.tsx
Normal 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} />;
|
||||
}
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
Loading…
Add table
Add a link
Reference in a new issue