Conversation details changes for PNP

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

View file

@ -1350,6 +1350,18 @@
"icu:showSafetyNumber": {
"messageformat": "View safety number"
},
"icu:AboutContactModal__title": {
"messageformat": "About",
"description": "Title of About modal"
},
"icu:AboutContactModal__signal-connection": {
"messageformat": "Signal Connection",
"description": "Text of a button on About modal leading to an education modal"
},
"icu:AboutContactModal__system-contact": {
"messageformat": "{name} is in your system contacts",
"description": "Text of a row in the About modal describing that the contact is in system contacts"
},
"icu:ContactModal__showSafetyNumber": {
"messageformat": "View safety number",
"description": "Contact modal, label for button to show safety number modal"
@ -5188,7 +5200,7 @@
"description": "Title for the contact name spoofing review dialog in groups"
},
"icu:ContactSpoofingReviewDialog__group__description": {
"messageformat": "{count, plural, one {# group member} other {# group members}} have similar names. Review the members below or choose to take action.",
"messageformat": "{count, plural, one {# group member} other {# group members}} have the same name, review the members below or choose to take action.",
"description": "Description for the group contact spoofing review dialog"
},
"icu:ContactSpoofingReviewDialog__group__multiple-conflicts__description": {
@ -5197,7 +5209,15 @@
},
"icu:ContactSpoofingReviewDialog__group__members-header": {
"messageformat": "Members",
"description": "Header in the group contact spoofing review dialog. After this header, there will be a list of members"
"description": "(Deleted 01/31/2024) Header in the group contact spoofing review dialog. After this header, there will be a list of members"
},
"icu:ContactSpoofingReviewDialog__group__members__no-shared-groups": {
"messageformat": "No other groups in common",
"description": "Informational text displayed next to a contact on ContactSpoofingReviewDialog"
},
"icu:ContactSpoofingReviewDialog__signal-connection": {
"messageformat": "Signal Connection",
"description": "Text of a button on ContactSpoofingReviewDialog leading to an education modal"
},
"icu:ContactSpoofingReviewDialog__group__name-change-info": {
"messageformat": "Recently changed their profile name from {oldName} to {newName}",

View file

@ -0,0 +1,3 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M8.79289 4.29289C9.18342 3.90237 9.81658 3.90237 10.2071 4.29289L17.2071 11.2929C17.5976 11.6834 17.5976 12.3166 17.2071 12.7071L10.2071 19.7071C9.81658 20.0976 9.18342 20.0976 8.79289 19.7071C8.40237 19.3166 8.40237 18.6834 8.79289 18.2929L15.0858 12L8.79289 5.70711C8.40237 5.31658 8.40237 4.68342 8.79289 4.29289Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 485 B

View file

@ -0,0 +1,8 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.94995 1.25C5.87888 1.25 4.19995 2.92893 4.19995 5C4.19995 7.07107 5.87888 8.75 7.94995 8.75C10.021 8.75 11.7 7.07107 11.7 5C11.7 2.92893 10.021 1.25 7.94995 1.25ZM5.94995 5C5.94995 3.89543 6.84538 3 7.94995 3C9.05452 3 9.94995 3.89543 9.94995 5C9.94995 6.10457 9.05452 7 7.94995 7C6.84538 7 5.94995 6.10457 5.94995 5Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M7.94995 15.25C5.87888 15.25 4.19995 16.9289 4.19995 19C4.19995 21.0711 5.87888 22.75 7.94995 22.75C10.021 22.75 11.7 21.0711 11.7 19C11.7 16.9289 10.021 15.25 7.94995 15.25ZM5.94995 19C5.94995 17.8954 6.84538 17 7.94995 17C9.05452 17 9.94995 17.8954 9.94995 19C9.94995 20.1046 9.05452 21 7.94995 21C6.84538 21 5.94995 20.1046 5.94995 19Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M12.3 5C12.3 2.92893 13.979 1.25 16.05 1.25C18.1211 1.25 19.8 2.92893 19.8 5C19.8 7.07107 18.1211 8.75 16.05 8.75C13.979 8.75 12.3 7.07107 12.3 5ZM16.05 3C14.9455 3 14.05 3.89543 14.05 5C14.05 6.10457 14.9455 7 16.05 7C17.1546 7 18.05 6.10457 18.05 5C18.05 3.89543 17.1546 3 16.05 3Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.05 15.25C13.979 15.25 12.3 16.9289 12.3 19C12.3 21.0711 13.979 22.75 16.05 22.75C18.1211 22.75 19.8 21.0711 19.8 19C19.8 16.9289 18.1211 15.25 16.05 15.25ZM14.05 19C14.05 17.8954 14.9455 17 16.05 17C17.1546 17 18.05 17.8954 18.05 19C18.05 20.1046 17.1546 21 16.05 21C14.9455 21 14.05 20.1046 14.05 19Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M16.375 12C16.375 9.92893 18.0539 8.25 20.125 8.25C22.1961 8.25 23.875 9.92893 23.875 12C23.875 14.0711 22.1961 15.75 20.125 15.75C18.0539 15.75 16.375 14.0711 16.375 12ZM20.125 10C19.0204 10 18.125 10.8954 18.125 12C18.125 13.1046 19.0204 14 20.125 14C21.2296 14 22.125 13.1046 22.125 12C22.125 10.8954 21.2296 10 20.125 10Z" fill="black"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M3.875 8.25C1.80393 8.25 0.125 9.92893 0.125 12C0.125 14.0711 1.80393 15.75 3.875 15.75C5.94607 15.75 7.625 14.0711 7.625 12C7.625 9.92893 5.94607 8.25 3.875 8.25ZM1.875 12C1.875 10.8954 2.77043 10 3.875 10C4.97957 10 5.875 10.8954 5.875 12C5.875 13.1046 4.97957 14 3.875 14C2.77043 14 1.875 13.1046 1.875 12Z" fill="black"/>
</svg>

After

Width:  |  Height:  |  Size: 2.3 KiB

View file

@ -250,6 +250,7 @@ $rtl-icon-map: (
'chevron-shallow-right.svg': 'chevron-shallow-left.svg',
'chevron-left-compact-bold.svg': 'chevron-right-compact-bold.svg',
'chevron-right-compact-bold.svg': 'chevron-left-compact-bold.svg',
'chevron-right-bold.svg': 'chevron-left-bold.svg',
'arrow-left.svg': 'arrow-right.svg',
'arrow-right.svg': 'arrow-left.svg',

View file

@ -0,0 +1,101 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.AboutContactModal {
&__headerTitle.module-Modal__headerTitle {
// No padding between header and avatar
padding-block-end: 0;
}
&__body_inner {
display: flex;
flex-direction: column;
gap: 12px;
padding-inline: 8px;
padding-block-end: 20px;
}
&__row {
display: flex;
flex-direction: row;
gap: 12px;
}
&__row--centered {
justify-content: center;
}
&__title {
@include font-title-2;
margin: 0;
margin-block-end: 4px;
}
&__row__icon {
display: inline-block;
height: 20px;
width: 20px;
vertical-align: text-top;
flex-shrink: 0;
@mixin about-modal-icon($url) {
@include light-theme {
@include color-svg($url, $color-black);
}
@include dark-theme {
@include color-svg($url, $color-gray-05);
}
}
&--profile {
@include about-modal-icon('../images/icons/v3/person/person-compact.svg');
}
&--connections {
@include about-modal-icon(
'../images/icons/v3/connections/connections.svg'
);
}
&--person {
@include about-modal-icon(
'../images/icons/v3/person/person-circle-compact.svg'
);
}
&--phone {
@include about-modal-icon('../images/icons/v3/phone/phone-compact.svg');
}
&--group {
@include about-modal-icon('../images/icons/v3/group/group.svg');
}
&--about {
@include about-modal-icon('../images/icons/v3/edit/edit.svg');
}
}
&__signal-connection {
display: flex;
flex-direction: row;
align-items: center;
@include button-reset();
cursor: pointer;
&::after {
content: '';
display: inline-block;
height: 20px;
width: 20px;
@include color-svg(
'../images/icons/v3/chevron/chevron-right-bold.svg',
$color-gray-45
);
}
}
}

View file

@ -0,0 +1,28 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.CollidingAvatars {
position: relative;
width: 36px;
height: 36px;
&__avatar {
position: absolute;
inset-block-start: 0;
inset-inline-start: 0;
}
&__avatar:nth-child(1) {
// See https://developer.mozilla.org/en-US/docs/Web/CSS/clip-path#clip-source
clip-path: var(--clip-path);
}
&__avatar:nth-child(2) {
inset-block-start: 12px;
inset-inline-start: 12px;
}
&__clip_svg {
position: absolute;
}
}

View file

@ -10,8 +10,38 @@
margin-bottom: 16px;
&__name {
@include font-title-2;
@include button-reset();
@include font-title-1;
font-weight: 400;
display: flex;
flex-direction: row;
align-items: baseline;
margin-top: 6px;
cursor: pointer;
}
&__name__chevron {
display: inline-block;
height: 20px;
width: 20px;
// Align with the text
position: relative;
inset-block-start: 2px;
@include light-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-right-bold.svg',
$color-gray-90
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-right-bold.svg',
$color-gray-05
);
}
}
&__info {

View file

@ -17,7 +17,7 @@
}
&__description {
margin-top: 4px;
margin-block: 0 16px;
}
h2 {
@ -29,19 +29,19 @@
hr {
border: 0;
height: 1px;
margin-block: 20px;
margin-block: 12px;
margin-inline: 0;
@include light-theme {
background: $color-gray-05;
}
@include dark-theme {
background: $color-gray-90;
background: $color-gray-75;
}
}
&__buttons {
margin-top: 12px;
margin-top: 4px;
.module-Button:not(:last-child) {
margin-inline-end: 12px;

View file

@ -3,16 +3,21 @@
.module-ContactSpoofingReviewDialogPerson {
display: flex;
padding-block: 8px;
gap: 16px;
&:is(button) {
@include button-reset;
}
&__info {
margin-inline-start: 12px;
display: flex;
flex-direction: column;
gap: 12px;
&__contact-name {
@include font-body-1-bold;
display: block;
}
&__property {
@ -26,10 +31,68 @@
color: $color-gray-05;
}
&--callout {
@include font-body-2-italic;
margin-block: 12px;
margin-inline: 0;
display: flex;
gap: 12px;
&__icon {
display: inline-block;
height: 20px;
width: 20px;
vertical-align: text-top;
flex-shrink: 0;
@mixin contact-spoofing-icon($url) {
@include light-theme {
@include color-svg($url, $color-gray-90);
}
@include dark-theme {
@include color-svg($url, $color-gray-05);
}
}
&--connections {
@include contact-spoofing-icon(
'../images/icons/v3/connections/connections.svg'
);
}
&--person {
@include contact-spoofing-icon(
'../images/icons/v3/person/person.svg'
);
}
&--phone {
@include contact-spoofing-icon(
'../images/icons/v3/phone/phone-compact.svg'
);
}
&--group {
@include contact-spoofing-icon('../images/icons/v3/group/group.svg');
}
}
&__signal-connection {
display: flex;
flex-direction: row;
align-items: center;
@include button-reset();
cursor: pointer;
&::after {
content: '';
display: inline-block;
height: 20px;
width: 20px;
@include color-svg(
'../images/icons/v3/chevron/chevron-right-bold.svg',
$color-gray-45
);
}
}
}
}

View file

@ -2,82 +2,6 @@
// SPDX-License-Identifier: AGPL-3.0-only
.ConversationDetails {
&-header {
&__root {
align-items: center;
display: flex;
flex-direction: column;
margin-block: 0 20px;
margin-inline: 0;
padding-block: 0;
padding-inline: 24px;
text-align: center;
width: 100%;
}
&__root--editable {
@include button-reset();
}
&__root--editable {
cursor: pointer;
}
&__title {
@include font-title-1;
align-items: center;
display: flex;
justify-content: center;
padding-bottom: 8px;
padding-top: 12px;
user-select: text;
}
&__subtitle {
@include font-body-1;
color: $color-gray-60;
justify-content: center;
padding-bottom: 6px;
@include dark-theme {
color: $color-gray-25;
}
&__about,
&__phone-number {
user-select: text;
}
}
&__root--editable &__title {
$icon: '../images/icons/v3/edit/edit.svg';
&::after {
$size: 24px;
content: '';
height: $size;
inset-inline-start: $size + 13px;
margin-inline-start: -$size;
opacity: 0;
position: relative;
transition: opacity 100ms ease-out;
width: $size;
@include light-theme {
@include color-svg($icon, $color-gray-60);
}
@include dark-theme {
@include color-svg($icon, $color-gray-25);
}
}
}
&__root--editable:hover &__title::after {
opacity: 1;
}
}
&__chat-color {
@include color-bubble(32px);
}

View file

@ -0,0 +1,98 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.ConversationDetailsHeader {
align-items: center;
display: flex;
flex-direction: column;
margin-block: 0 20px;
margin-inline: 0;
padding-block: 0;
padding-inline: 24px;
text-align: center;
width: 100%;
&__edit-button,
&__about-button {
@include button-reset();
cursor: pointer;
}
&__title {
@include font-title-1;
font-weight: 400;
align-items: baseline;
display: flex;
justify-content: center;
padding-bottom: 8px;
padding-top: 12px;
user-select: text;
}
&__subtitle {
@include font-body-1;
color: $color-gray-60;
justify-content: center;
padding-bottom: 6px;
@include dark-theme {
color: $color-gray-25;
}
&__about,
&__phone-number {
user-select: text;
}
}
&__edit-button &__title {
$icon: '../images/icons/v3/edit/edit.svg';
&::after {
$size: 24px;
content: '';
height: $size;
inset-inline-start: $size + 13px;
margin-inline-start: -$size;
opacity: 0;
position: relative;
transition: opacity 100ms ease-out;
width: $size;
@include light-theme {
@include color-svg($icon, $color-gray-60);
}
@include dark-theme {
@include color-svg($icon, $color-gray-25);
}
}
}
&__edit-button:hover &__title::after {
opacity: 1;
}
&__about-icon {
display: inline-block;
height: 20px;
width: 20px;
// Align with the text
position: relative;
inset-block-start: 2px;
@include light-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-right-bold.svg',
$color-black
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-right-bold.svg',
$color-gray-05
);
}
}
}

View file

@ -10,6 +10,39 @@
margin-bottom: 12px;
}
&__title {
@include button-reset();
cursor: pointer;
}
&__title span {
@include font-title-1;
font-weight: 400;
}
&__title__chevron {
display: inline-block;
height: 20px;
width: 20px;
// Align with the text
position: relative;
inset-block-start: 2px;
@include light-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-right-bold.svg',
$color-gray-90
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/chevron/chevron-right-bold.svg',
$color-gray-05
);
}
}
&__profile-name {
display: flex;
align-items: center;
@ -17,6 +50,7 @@
@include font-title-2;
margin-bottom: 2px;
margin-top: 0;
@include light-theme {
color: $color-gray-90;
@ -31,7 +65,7 @@
@include font-body-2;
margin-block: 0;
margin-inline: auto;
margin-bottom: 16px;
margin-bottom: 20px;
max-width: 500px;
@include light-theme {
@ -43,70 +77,110 @@
}
}
&__note-to-self {
@include font-body-2;
padding-block: 0;
padding-inline: 16px;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
}
&__membership {
@include font-body-2;
user-select: none;
padding-block: 0;
max-width: 255px;
margin-inline: auto;
padding-block: 16px;
padding-inline: 20px;
padding-inline: 16px;
border-radius: 18px;
border-style: solid;
border-width: 1.5px;
@include light-theme() {
border-color: $color-gray-05;
}
@include dark-theme() {
border-color: $color-gray-80;
}
@include light-theme {
color: $color-gray-60;
color: $color-gray-90;
}
@include dark-theme {
color: $color-gray-25;
color: $color-gray-02;
}
&__chevron {
display: inline-block;
height: 18px;
width: 18px;
vertical-align: text-top;
margin-inline-end: 8px;
@include light-theme {
@include color-svg('../images/icons/v3/group/group.svg', $color-black);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/group/group.svg',
$color-gray-05
);
}
}
&__name {
@include font-body-2-bold;
// Cancel bold
font-weight: normal;
}
}
&__message-request-warning {
@include font-body-2;
&__warning {
line-height: 20px;
&__message {
display: flex;
margin-bottom: 12px;
align-items: center;
justify-content: center;
user-select: none;
@include light-theme {
color: $color-gray-60;
}
@include dark-theme {
color: $color-gray-25;
}
&::before {
&__icon {
content: '';
display: block;
height: 14px;
display: inline-block;
height: 18px;
margin-inline-end: 8px;
width: 14px;
width: 18px;
vertical-align: middle;
@include light-theme {
@include color-svg(
'../images/icons/v3/info/info.svg',
$color-gray-60
$color-gray-90
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/info/info.svg',
$color-gray-25
$color-gray-02
);
}
}
&__learn-more {
@include button-reset();
cursor: pointer;
text-decoration: underline;
}
}
}
&__linkNotification {
@include font-body-2;
margin-top: 15px;
margin-top: 12px;
text-align: center;
user-select: none;

View file

@ -2,27 +2,63 @@
// SPDX-License-Identifier: AGPL-3.0-only
.SignalConnectionsModal {
padding-inline: 8px;
padding-block-end: 20px;
@include dark-theme {
color: $color-gray-05;
}
&__icon {
@include color-svg(
'../images/icons/v3/connections/connections-display.svg',
$color-ultramarine-light
);
@include light-theme {
@include color-svg(
'../images/icons/v3/connections/connections-display.svg',
$color-gray-90
);
}
@include dark-theme {
@include color-svg(
'../images/icons/v3/connections/connections-display.svg',
$color-gray-25
);
}
display: block;
height: 69px;
height: 48px;
margin-block: 0;
margin-inline: auto;
margin-bottom: 24px;
width: 75px;
width: 48px;
}
&__list {
margin-block: 16px;
margin-block: 20px;
margin-inline: 0;
padding-inline-start: 12px;
li {
margin-block: 8px;
display: flex;
gap: 12px;
align-items: center;
list-style: none;
margin-block: 16px;
margin-inline: 0;
}
li::before {
display: block;
content: '';
width: 4px;
height: 14px;
border-radius: 6px;
@include light-theme {
background-color: $color-gray-20;
}
@include dark-theme {
background-color: $color-gray-25;
}
}
}
&__button {

View file

@ -13,7 +13,9 @@
align-items: center;
display: flex;
padding: 16px;
padding-block: 10px;
padding-inline: 16px;
min-height: 56px;
user-select: none;
border-top-width: 1px;
@ -72,4 +74,8 @@
width: 20px;
}
}
&__custom-info {
flex-shrink: 0;
}
}

View file

@ -20,6 +20,7 @@
// New style: components
@import './components/About.scss';
@import './components/AboutContactModal.scss';
@import './components/AddGroupMembersModal.scss';
@import './components/AddUserToAnotherGroupModal.scss';
@import './components/AnnouncementsOnlyGroupBanner.scss';
@ -54,6 +55,7 @@
@import './components/ChatColorPicker.scss';
@import './components/Checkbox.scss';
@import './components/CircleCheckbox.scss';
@import './components/CollidingAvatars.scss';
@import './components/CompositionArea.scss';
@import './components/CompositionRecording.scss';
@import './components/CompositionRecordingDraft.scss';
@ -68,6 +70,7 @@
@import './components/ContactSpoofingReviewDialogPerson.scss';
@import './components/ContextMenu.scss';
@import './components/ConversationDetails.scss';
@import './components/ConversationDetailsHeader.scss';
@import './components/ConversationHeader.scss';
@import './components/ConversationHero.scss';
@import './components/ConversationMergeNotification.scss';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

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

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

View file

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

View file

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

View file

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

View file

@ -154,11 +154,34 @@ export const getAllSignalConnections = createSelector(
conversations.filter(isSignalConnection)
);
export const getConversationsByTitleSelector = createSelector(
export const getSafeConversationWithSameTitle = createSelector(
getAllConversations,
(conversations): ((title: string) => Array<ConversationType>) =>
(title: string) =>
conversations.filter(conversation => conversation.title === title)
(
_state: StateType,
{
possiblyUnsafeConversation,
}: {
possiblyUnsafeConversation: ConversationType;
}
) => possiblyUnsafeConversation,
(conversations, possiblyUnsafeConversation): ConversationType | undefined => {
const conversationsWithSameTitle = conversations.filter(conversation => {
return conversation.title === possiblyUnsafeConversation.title;
});
assertDev(
conversationsWithSameTitle.length,
'Expected at least 1 conversation with the same title (this one)'
);
const safeConversation = conversationsWithSameTitle.find(
otherConversation =>
otherConversation.acceptedMessageRequest &&
otherConversation.type === 'direct' &&
otherConversation.id !== possiblyUnsafeConversation.id
);
return safeConversation;
}
);
export const getSelectedConversationId = createSelector(

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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