From eb82ace2de99263c8ba751dffe692398d345e61e Mon Sep 17 00:00:00 2001 From: Fedor Indutny <79877362+indutny-signal@users.noreply.github.com> Date: Mon, 5 Feb 2024 18:13:13 -0800 Subject: [PATCH] Conversation details changes for PNP Co-authored-by: Scott Nonnenberg --- _locales/en/messages.json | 24 +- .../icons/v3/chevron/chevron-right-bold.svg | 3 + images/icons/v3/connections/connections.svg | 8 + stylesheets/_mixins.scss | 1 + stylesheets/components/AboutContactModal.scss | 101 +++++++ stylesheets/components/CollidingAvatars.scss | 28 ++ stylesheets/components/ContactModal.scss | 32 ++- .../ContactSpoofingReviewDialog.scss | 8 +- .../ContactSpoofingReviewDialogPerson.scss | 73 ++++- .../components/ConversationDetails.scss | 76 ------ .../components/ConversationDetailsHeader.scss | 98 +++++++ stylesheets/components/ConversationHero.scss | 134 +++++++-- .../components/SignalConnectionsModal.scss | 52 +++- stylesheets/components/TimelineWarning.scss | 8 +- stylesheets/manifest.scss | 3 + ts/components/Avatar.tsx | 2 + ts/components/CollidingAvatars.stories.tsx | 29 ++ ts/components/CollidingAvatars.tsx | 80 ++++++ ts/components/GlobalModalContainer.tsx | 26 +- ts/components/SignalConnectionsModal.tsx | 11 +- .../AboutContactModal.stories.tsx | 60 ++++ .../conversation/AboutContactModal.tsx | 135 +++++++++ .../conversation/ContactModal.stories.tsx | 1 + ts/components/conversation/ContactModal.tsx | 14 +- .../ContactSpoofingReviewDialog.stories.tsx | 74 ++++- .../ContactSpoofingReviewDialog.tsx | 257 ++++++++---------- ...tactSpoofingReviewDialogPerson.stories.tsx | 59 ++++ .../ContactSpoofingReviewDialogPerson.tsx | 101 +++++-- .../conversation/ConversationHero.stories.tsx | 5 +- .../conversation/ConversationHero.tsx | 88 +++--- .../conversation/Timeline.stories.tsx | 68 +++-- ts/components/conversation/Timeline.tsx | 100 ++----- .../conversation/TimelineWarning.tsx | 8 + .../ConversationDetails.stories.tsx | 1 + .../ConversationDetails.tsx | 3 + .../ConversationDetailsHeader.stories.tsx | 12 +- .../ConversationDetailsHeader.tsx | 84 ++++-- ts/model-types.d.ts | 1 + ts/models/conversations.ts | 2 + ts/state/ducks/conversations.ts | 82 ++---- ts/state/ducks/globalModals.ts | 28 ++ ts/state/selectors/conversations.ts | 31 ++- ts/state/smart/CollidingAvatars.tsx | 28 ++ .../smart/ContactSpoofingReviewDialog.tsx | 122 ++++++--- ts/state/smart/GlobalModalContainer.tsx | 27 +- ts/state/smart/Timeline.tsx | 101 ++----- .../state/selectors/conversations_test.ts | 32 +-- .../state/ducks/conversations_test.ts | 37 +-- ts/util/getConversation.ts | 1 + 49 files changed, 1660 insertions(+), 699 deletions(-) create mode 100644 images/icons/v3/chevron/chevron-right-bold.svg create mode 100644 images/icons/v3/connections/connections.svg create mode 100644 stylesheets/components/AboutContactModal.scss create mode 100644 stylesheets/components/CollidingAvatars.scss create mode 100644 stylesheets/components/ConversationDetailsHeader.scss create mode 100644 ts/components/CollidingAvatars.stories.tsx create mode 100644 ts/components/CollidingAvatars.tsx create mode 100644 ts/components/conversation/AboutContactModal.stories.tsx create mode 100644 ts/components/conversation/AboutContactModal.tsx create mode 100644 ts/components/conversation/ContactSpoofingReviewDialogPerson.stories.tsx create mode 100644 ts/state/smart/CollidingAvatars.tsx diff --git a/_locales/en/messages.json b/_locales/en/messages.json index ddbc942c47..0fbadd01bf 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -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}", diff --git a/images/icons/v3/chevron/chevron-right-bold.svg b/images/icons/v3/chevron/chevron-right-bold.svg new file mode 100644 index 0000000000..197d1c8949 --- /dev/null +++ b/images/icons/v3/chevron/chevron-right-bold.svg @@ -0,0 +1,3 @@ + + + diff --git a/images/icons/v3/connections/connections.svg b/images/icons/v3/connections/connections.svg new file mode 100644 index 0000000000..39d8de8fd6 --- /dev/null +++ b/images/icons/v3/connections/connections.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/stylesheets/_mixins.scss b/stylesheets/_mixins.scss index 532b8940dc..15a5687f0f 100644 --- a/stylesheets/_mixins.scss +++ b/stylesheets/_mixins.scss @@ -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', diff --git a/stylesheets/components/AboutContactModal.scss b/stylesheets/components/AboutContactModal.scss new file mode 100644 index 0000000000..7fce3965a1 --- /dev/null +++ b/stylesheets/components/AboutContactModal.scss @@ -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 + ); + } + } +} diff --git a/stylesheets/components/CollidingAvatars.scss b/stylesheets/components/CollidingAvatars.scss new file mode 100644 index 0000000000..d29fc447a6 --- /dev/null +++ b/stylesheets/components/CollidingAvatars.scss @@ -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; + } +} diff --git a/stylesheets/components/ContactModal.scss b/stylesheets/components/ContactModal.scss index a9c1835e1a..06cd018852 100644 --- a/stylesheets/components/ContactModal.scss +++ b/stylesheets/components/ContactModal.scss @@ -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 { diff --git a/stylesheets/components/ContactSpoofingReviewDialog.scss b/stylesheets/components/ContactSpoofingReviewDialog.scss index 2f24f24db8..c63a8d8253 100644 --- a/stylesheets/components/ContactSpoofingReviewDialog.scss +++ b/stylesheets/components/ContactSpoofingReviewDialog.scss @@ -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; diff --git a/stylesheets/components/ContactSpoofingReviewDialogPerson.scss b/stylesheets/components/ContactSpoofingReviewDialogPerson.scss index df48621522..0a025164fb 100644 --- a/stylesheets/components/ContactSpoofingReviewDialogPerson.scss +++ b/stylesheets/components/ContactSpoofingReviewDialogPerson.scss @@ -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 + ); + } } } } diff --git a/stylesheets/components/ConversationDetails.scss b/stylesheets/components/ConversationDetails.scss index 6eb3d63209..7b8f898f67 100644 --- a/stylesheets/components/ConversationDetails.scss +++ b/stylesheets/components/ConversationDetails.scss @@ -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); } diff --git a/stylesheets/components/ConversationDetailsHeader.scss b/stylesheets/components/ConversationDetailsHeader.scss new file mode 100644 index 0000000000..09f8377cf4 --- /dev/null +++ b/stylesheets/components/ConversationDetailsHeader.scss @@ -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 + ); + } + } +} diff --git a/stylesheets/components/ConversationHero.scss b/stylesheets/components/ConversationHero.scss index afe0002097..a268e12ef1 100644 --- a/stylesheets/components/ConversationHero.scss +++ b/stylesheets/components/ConversationHero.scss @@ -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; diff --git a/stylesheets/components/SignalConnectionsModal.scss b/stylesheets/components/SignalConnectionsModal.scss index 328e01e7dc..fb3dbe7f7c 100644 --- a/stylesheets/components/SignalConnectionsModal.scss +++ b/stylesheets/components/SignalConnectionsModal.scss @@ -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 { diff --git a/stylesheets/components/TimelineWarning.scss b/stylesheets/components/TimelineWarning.scss index 49f661c030..c71ae06d49 100644 --- a/stylesheets/components/TimelineWarning.scss +++ b/stylesheets/components/TimelineWarning.scss @@ -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; + } } diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index ced2867385..597fa0c043 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -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'; diff --git a/ts/components/Avatar.tsx b/ts/components/Avatar.tsx index d376c15fb5..5537042bbb 100644 --- a/ts/components/Avatar.tsx +++ b/ts/components/Avatar.tsx @@ -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 }; diff --git a/ts/components/CollidingAvatars.stories.tsx b/ts/components/CollidingAvatars.stories.tsx new file mode 100644 index 0000000000..4d5cff1d5d --- /dev/null +++ b/ts/components/CollidingAvatars.stories.tsx @@ -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; + +export function Defaults(args: PropsType): JSX.Element { + return ; +} diff --git a/ts/components/CollidingAvatars.tsx b/ts/components/CollidingAvatars.tsx new file mode 100644 index 0000000000..84b12e2afb --- /dev/null +++ b/ts/components/CollidingAvatars.tsx @@ -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; +}>; + +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 ( +
+ {conversations.slice(0, MAX_AVATARS).map(({ id, type, ...convo }) => { + return ( + + ); + })} + {/* + 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. + */} + + + + + +
+ ); +} diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index 95cf132164..ef246ea5dc 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -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 ( + + ); + } + + if (contactModalState) { + return renderContactModal(); + } + if (isStoriesSettingsVisible) { return renderStoriesSettings(); } diff --git a/ts/components/SignalConnectionsModal.tsx b/ts/components/SignalConnectionsModal.tsx index 552c6e53ba..d9f34f8975 100644 --- a/ts/components/SignalConnectionsModal.tsx +++ b/ts/components/SignalConnectionsModal.tsx @@ -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({
{i18n('icu:SignalConnectionsModal__footer')}
- -
- -
); diff --git a/ts/components/conversation/AboutContactModal.stories.tsx b/ts/components/conversation/AboutContactModal.stories.tsx new file mode 100644 index 0000000000..b7535e9c8c --- /dev/null +++ b/ts/components/conversation/AboutContactModal.stories.tsx @@ -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; + +export function Defaults(args: PropsType): JSX.Element { + return ; +} + +export function WithAbout(args: PropsType): JSX.Element { + return ; +} + +export function SignalConnection(args: PropsType): JSX.Element { + return ; +} + +export function SystemContact(args: PropsType): JSX.Element { + return ( + + ); +} diff --git a/ts/components/conversation/AboutContactModal.tsx b/ts/components/conversation/AboutContactModal.tsx new file mode 100644 index 0000000000..3942a7287c --- /dev/null +++ b/ts/components/conversation/AboutContactModal.tsx @@ -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 ( + +
+ +
+ +
+

+ {i18n('icu:AboutContactModal__title')} +

+
+ +
+ + +
+ + {conversation.about ? ( +
+ + +
+ ) : null} + + {isSignalConnection ? ( +
+ + +
+ ) : null} + + {isInSystemContacts(conversation) ? ( +
+ + {i18n('icu:AboutContactModal__system-contact', { + name: conversation.firstName || conversation.title, + })} +
+ ) : null} + + {conversation.phoneNumber ? ( +
+ + +
+ ) : null} + +
+ +
+ +
+
+
+ ); +} diff --git a/ts/components/conversation/ContactModal.stories.tsx b/ts/components/conversation/ContactModal.stories.tsx index a5af37e00e..5ba86bdc7b 100644 --- a/ts/components/conversation/ContactModal.stories.tsx +++ b/ts/components/conversation/ContactModal.stories.tsx @@ -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( diff --git a/ts/components/conversation/ContactModal.tsx b/ts/components/conversation/ContactModal.tsx index 31d296ce04..781c669edc 100644 --- a/ts/components/conversation/ContactModal.tsx +++ b/ts/components/conversation/ContactModal.tsx @@ -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} /> -
+
+ +
diff --git a/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx b/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx index 77533746bb..d1c74bcfe8 100644 --- a/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx +++ b/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx @@ -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 { ); } -export function NotAdmin(): JSX.Element { +export function NotAdminMany(): JSX.Element { return ( ({ 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 ( + + ); +} + +export function AdminMany(): JSX.Element { return ( ({ 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 ( + + ); +} diff --git a/ts/components/conversation/ContactSpoofingReviewDialog.tsx b/ts/components/conversation/ContactSpoofingReviewDialog.tsx index c3e45d5f51..d130ac7f87 100644 --- a/ts/components/conversation/ContactSpoofingReviewDialog.tsx +++ b/ts/components/conversation/ContactSpoofingReviewDialog.tsx @@ -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', ' expected a direct conversation for the "possibly unsafe" conversation' ); assertDev( - safeConversation.type === 'direct', + safe.conversation.type === 'direct', ' 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')}
- ); - } else if (conversationInfo.conversation.isBlocked) { - button = ( - - ); - } else if ( - !isInSystemContacts(conversationInfo.conversation) - ) { - button = ( - - ); + {Object.values(collisionInfoByTitle) + .map((conversationInfos, titleIdx) => + conversationInfos.map((conversationInfo, conversationIdx) => { + let button: ReactNode; + if (group.areWeAdmin) { + button = ( + + ); + } else if (conversationInfo.conversation.isBlocked) { + button = ( + + ); + } else if (!isInSystemContacts(conversationInfo.conversation)) { + button = ( + + ); + } + + const { oldName, isSignalConnection } = conversationInfo; + + return ( + <> + - , - newName: , - }} - /> -
- ); - } - - return ( - <> - - {callout} - {button && ( -
- {button} -
- )} -
- {titleIdx < sharedTitles.length - 1 || - conversationIdx < conversationInfos.length - 1 ? ( -
- ) : null} - - ); - } - )} - - ); - } - )} + getPreferredBadge={getPreferredBadge} + i18n={i18n} + theme={theme} + oldName={oldName} + isSignalConnection={isSignalConnection} + > + {button && ( +
+ {button} +
+ )} +
+ {titleIdx < sharedTitles.length - 1 || + conversationIdx < conversationInfos.length - 1 ? ( +
+ ) : null} + + ); + }) + ) + .flat()} ); break; diff --git a/ts/components/conversation/ContactSpoofingReviewDialogPerson.stories.tsx b/ts/components/conversation/ContactSpoofingReviewDialogPerson.stories.tsx new file mode 100644 index 0000000000..7a7a4bcc36 --- /dev/null +++ b/ts/components/conversation/ContactSpoofingReviewDialogPerson.stories.tsx @@ -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; + +// eslint-disable-next-line react/function-component-definition +const Template: StoryFn = args => { + return ; +}; + +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'], + }), +}; diff --git a/ts/components/conversation/ContactSpoofingReviewDialogPerson.tsx b/ts/components/conversation/ContactSpoofingReviewDialogPerson.tsx index f2ef4b9687..93564da0c0 100644 --- a/ts/components/conversation/ContactSpoofingReviewDialogPerson.tsx +++ b/ts/components/conversation/ContactSpoofingReviewDialogPerson.tsx @@ -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', ' expected a direct conversation' ); + const newName = conversation.profileName || conversation.title; + + let callout: JSX.Element | undefined; + if (oldName && oldName !== newName) { + callout = ( +
+ +
+ , + newName: , + }} + /> +
+
+ ); + } + + const name = ( + + ); + const contents = ( <>
- + {onClick ? ( + + ) : ( + name + )} + {callout} {conversation.phoneNumber ? (
- {conversation.phoneNumber} + +
{conversation.phoneNumber}
+
+ ) : null} + {isSignalConnection ? ( +
+ +
) : null}
- + +
+ {conversation.sharedGroupNames?.length ? ( + + ) : ( + i18n( + 'icu:ContactSpoofingReviewDialog__group__members__no-shared-groups' + ) + )} +
{children}
); - if (onClick) { - return ( - - ); - } - return (
{contents}
); diff --git a/ts/components/conversation/ConversationHero.stories.tsx b/ts/components/conversation/ConversationHero.stories.tsx index b578a473f2..dd5b20776e 100644 --- a/ts/components/conversation/ConversationHero.stories.tsx +++ b/ts/components/conversation/ConversationHero.stories.tsx @@ -26,6 +26,7 @@ export default { unblurAvatar: action('unblurAvatar'), updateSharedGroups: action('updateSharedGroups'), viewUserStories: action('viewUserStories'), + toggleAboutContactModal: action('toggleAboutContactModal'), }, } satisfies Meta; @@ -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({}); diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx index 24c9d3d3a9..13bdce1816 100644 --- a/ts/components/conversation/ConversationHero.tsx +++ b/ts/components/conversation/ConversationHero.tsx @@ -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; unblurAvatar: (conversationId: string) => void; @@ -35,6 +33,7 @@ export type Props = { updateSharedGroups: (conversationId: string) => unknown; theme: ThemeType; viewUserStories: ViewUserStoriesActionCreatorType; + toggleAboutContactModal: (conversationId: string) => unknown; } & Omit; const renderMembershipRow = ({ @@ -56,22 +55,25 @@ const renderMembershipRow = ({ Required> & { onClickMessageRequestWarning: () => void; }) => { - const className = 'module-conversation-hero__membership'; - if (conversationType !== 'direct') { return null; } if (isMe) { - return
{i18n('icu:noteToSelfHero')}
; + return ( +
+ {i18n('icu:noteToSelfHero')} +
+ ); } if (sharedGroupNames.length > 0) { return ( -
+
+
@@ -81,21 +83,30 @@ const renderMembershipRow = ({ if (phoneNumber) { return null; } - return
{i18n('icu:no-groups-in-common')}
; + return ( +
+ {i18n('icu:no-groups-in-common')} +
+ ); } return ( -
-
- {i18n('icu:no-groups-in-common-warning')} +
+
+ + {i18n('icu:no-groups-in-common-warning')} +   +
-
); }; @@ -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 = ( + + ); + } else if (title) { + titleElem = ( + + ); + } /* eslint-disable no-nested-ternary */ return ( @@ -187,14 +218,7 @@ export function ConversationHero({ title={title} />

- {isMe ? ( - i18n('icu:noteToSelf') - ) : ( - - )} + {titleElem} {isMe && }

{about && !isMe && ( @@ -212,9 +236,7 @@ export function ConversationHero({ /> ) : membersCount != null ? ( i18n('icu:ConversationHero--members', { count: membersCount }) - ) : phoneNumberOnly ? null : ( - phoneNumber - )} + ) : null}
) : null} {!isSignalConversation && diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index b8c1e2d8a9..0670893258 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -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 ( - - ); - } - - return ; +const renderContactSpoofingReviewDialog = () => { + // hasContactSpoofingReview is always false in stories + return
; }; 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 = () => ( + +); const renderMiniPlayer = () => (
If active, this is where smart mini player would be
); @@ -477,12 +454,14 @@ const useProps = (overrideProps: Partial = {}): 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 ; +} + +export function WithSameNamesInGroupConversationWarning(): JSX.Element { const props = useProps({ warning: { type: ContactSpoofingType.MultipleGroupMembersWithSameTitle, diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index 8509dcab30..d0717eefcc 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -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; + }) => 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 = ( + + + + ); 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 => ( { - reviewMessageRequestNameCollision({ - safeConversationId: warning.safeConversation.id, - }); - }} + onClick={reviewConversationNameCollision} > {parts} @@ -998,24 +981,25 @@ export class Timeline extends React.Component< const { groupNameCollisions } = warning; const numberOfSharedNames = Object.keys(groupNameCollisions).length; const reviewRequestLink: FullJSXType = parts => ( - { - reviewGroupMemberNameCollision(id); - }} - > + {parts} ); if (numberOfSharedNames === 1) { + const [conversationIds] = [...Object.values(groupNameCollisions)]; + if (conversationIds.length >= 2) { + icon = ( + + {renderCollidingAvatars({ conversationIds })} + + ); + } text = ( result + conversations.length, - 0 - ), + count: conversationIds.length, reviewRequestLink, }} /> @@ -1053,9 +1037,7 @@ export class Timeline extends React.Component< {renderMiniPlayer({ shouldFlow: true })} {text && ( - - - + {icon} {text} )} @@ -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 ( diff --git a/ts/components/conversation/TimelineWarning.tsx b/ts/components/conversation/TimelineWarning.tsx index c66c2b4220..299243a5cc 100644 --- a/ts/components/conversation/TimelineWarning.tsx +++ b/ts/components/conversation/TimelineWarning.tsx @@ -71,3 +71,11 @@ function Link({ children, onClick }: Readonly): JSX.Element { } TimelineWarning.Link = Link; + +function CustomInfo({ + children, +}: Readonly<{ children: ReactNode }>): JSX.Element { + return
{children}
; +} + +TimelineWarning.CustomInfo = CustomInfo; diff --git a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx index 002454fdb2..74043e1882 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.stories.tsx @@ -102,6 +102,7 @@ const createProps = ( setMuteExpiration: action('setMuteExpiration'), userAvatarData: [], toggleSafetyNumberModal: action('toggleSafetyNumberModal'), + toggleAboutContactModal: action('toggleAboutContactModal'), toggleAddUserToAnotherGroupModal: action('toggleAddUserToAnotherGroup'), onOutgoingAudioCallInConversation: action( 'onOutgoingAudioCallInConversation' diff --git a/ts/components/conversation/conversation-details/ConversationDetails.tsx b/ts/components/conversation/conversation-details/ConversationDetails.tsx index 95e8997c62..22e4d45650 100644 --- a/ts/components/conversation/conversation-details/ConversationDetails.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetails.tsx @@ -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} />
diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx index 606fd20247..a73c8a75e1 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.stories.tsx @@ -45,6 +45,7 @@ function Wrapper(overrideProps: Partial) { isGroup isMe={false} theme={theme} + toggleAboutContactModal={action('toggleAboutContactModal')} {...overrideProps} /> ); @@ -80,7 +81,16 @@ export function EditableNoDescription(): JSX.Element { } export function OneOnOne(): JSX.Element { - return ; + return ( + + ); } export function NoteToSelf(): JSX.Element { diff --git a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx index 1b63701a21..6788f9b051 100644 --- a/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx +++ b/ts/components/conversation/conversation-details/ConversationDetailsHeader.tsx @@ -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; 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 = ( <> -
+
-
+
{conversation.phoneNumber}
@@ -105,15 +105,6 @@ export function ConversationDetailsHeader({ /> ); - const contents = ( -
-
- {isMe ? i18n('icu:noteToSelf') : } - {isMe && } -
-
- ); - 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 ( -
+
{modal} {avatar} {hasNestedButton ? ( -
{subtitle}
+
{subtitle}
) : ( )}
); } + let title: JSX.Element; + + if (isMe) { + title = ( +
+ {i18n('icu:noteToSelf')} + +
+ ); + } else if (isGroup) { + title = ( +
+ +
+ ); + } else { + title = ( + + ); + } + return ( -
+
{modal} {avatar} - {contents} -
{subtitle}
+ {title} +
{subtitle}
); } diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index cb931935f3..4001685f56 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -381,6 +381,7 @@ export type ConversationAttributesType = { profileKey?: string; profileName?: string; verified?: number; + profileLastUpdatedAt?: number; profileLastFetchedAt?: number; pendingUniversalTimer?: string; pendingRemovedContactNotification?: string; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 37e55ad4bb..d6f6582350 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -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 => { diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 4927281a3e..f94dfcce53 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -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 = []; + 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, diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index aa1b18e28b..db3092e493 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -77,9 +77,13 @@ type MigrateToGV2PropsType = ReadonlyDeep<{ hasMigrated: boolean; invitedMemberIds: Array; }>; +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 = getEmptyState(), action: Readonly ): GlobalModalsStateType { + if (action.type === TOGGLE_ABOUT_MODAL) { + return { + ...state, + aboutContactModalProps: action.payload, + }; + } + if (action.type === TOGGLE_PROFILE_EDITOR) { return { ...state, diff --git a/ts/state/selectors/conversations.ts b/ts/state/selectors/conversations.ts index 2ca7328578..add175017f 100644 --- a/ts/state/selectors/conversations.ts +++ b/ts/state/selectors/conversations.ts @@ -154,11 +154,34 @@ export const getAllSignalConnections = createSelector( conversations.filter(isSignalConnection) ); -export const getConversationsByTitleSelector = createSelector( +export const getSafeConversationWithSameTitle = createSelector( getAllConversations, - (conversations): ((title: string) => Array) => - (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( diff --git a/ts/state/smart/CollidingAvatars.tsx b/ts/state/smart/CollidingAvatars.tsx new file mode 100644 index 0000000000..483886111e --- /dev/null +++ b/ts/state/smart/CollidingAvatars.tsx @@ -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; +}>; + +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 ; +} diff --git a/ts/state/smart/ContactSpoofingReviewDialog.tsx b/ts/state/smart/ContactSpoofingReviewDialog.tsx index c7c46a96e3..39e7283f69 100644 --- a/ts/state/smart/ContactSpoofingReviewDialog.tsx +++ b/ts/state/smart/ContactSpoofingReviewDialog.tsx @@ -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( 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 ( ); } - return ; + 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 ( + + ); } diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index 21ac55c122..3458047e31 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -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 ( ; +} + 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 -): 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, diff --git a/ts/test-both/state/selectors/conversations_test.ts b/ts/test-both/state/selectors/conversations_test.ts index 1ab71eaabc..af3d245ede 100644 --- a/ts/test-both/state/selectors/conversations_test.ts +++ b/ts/test-both/state/selectors/conversations_test.ts @@ -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); }); }); diff --git a/ts/test-electron/state/ducks/conversations_test.ts b/ts/test-electron/state/ducks/conversations_test.ts index 7e30187a50..4faca52365 100644 --- a/ts/test-electron/state/ducks/conversations_test.ts +++ b/ts/test-electron/state/ducks/conversations_test.ts @@ -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); }); }); diff --git a/ts/util/getConversation.ts b/ts/util/getConversation.ts index 3a5c968518..9fc325fbce 100644 --- a/ts/util/getConversation.ts +++ b/ts/util/getConversation.ts @@ -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,