From 8387f938ebab3f42d801b7f77603b2a5aa42f061 Mon Sep 17 00:00:00 2001 From: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com> Date: Tue, 12 Mar 2024 09:29:31 -0700 Subject: [PATCH] Spam Reporting UI changes --- .storybook/preview.tsx | 4 +- _locales/en/messages.json | 136 ++++++ images/icons/v3/block/block.svg | 2 +- images/icons/v3/spam/spam.svg | 1 + images/icons/v3/thread/thread.svg | 1 + images/safety-tips/safety-tip-business.png | Bin 0 -> 16923 bytes images/safety-tips/safety-tip-crypto.png | Bin 0 -> 22673 bytes images/safety-tips/safety-tip-links.png | Bin 0 -> 12865 bytes images/safety-tips/safety-tip-vague.png | Bin 0 -> 10804 bytes protos/SignalService.proto | 2 + stylesheets/components/ConversationHero.scss | 8 + .../MessageRequestActionsConfirmation.scss | 6 + stylesheets/components/SafetyTipsModal.scss | 220 +++++++++ stylesheets/components/SystemMessage.scss | 12 + stylesheets/manifest.scss | 2 + ts/components/CompositionArea.stories.tsx | 2 +- ts/components/CompositionArea.tsx | 112 +++-- ts/components/CompositionInput.stories.tsx | 56 ++- ts/components/CompositionInput.tsx | 24 +- ts/components/CompositionTextArea.tsx | 13 +- ts/components/ForwardMessagesModal.tsx | 2 +- ts/components/GlobalModalContainer.tsx | 11 + ts/components/MediaEditor.tsx | 22 +- ts/components/Modal.tsx | 14 +- ts/components/SafetyTipsModal.stories.tsx | 24 + ts/components/SafetyTipsModal.tsx | 216 +++++++++ ts/components/StoryCreator.tsx | 10 +- ts/components/StoryViewsNRepliesModal.tsx | 9 +- ts/components/ToastManager.stories.tsx | 2 + ts/components/ToastManager.tsx | 8 + ts/components/conversation/ContactName.tsx | 32 +- .../ContactSpoofingReviewDialog.stories.tsx | 1 + .../ContactSpoofingReviewDialog.tsx | 22 +- .../ConversationHeader.stories.tsx | 15 +- .../conversation/ConversationHeader.tsx | 282 +++++++---- .../conversation/ConversationHero.tsx | 34 ++ ...MandatoryProfileSharingActions.stories.tsx | 53 ++- .../MandatoryProfileSharingActions.tsx | 59 ++- .../MessageRequestActions.stories.tsx | 82 +++- .../conversation/MessageRequestActions.tsx | 114 +++-- .../MessageRequestActionsConfirmation.tsx | 177 +++++-- .../MessageRequestResponseNotification.tsx | 98 ++++ ts/components/conversation/SystemMessage.tsx | 2 + .../conversation/Timeline.stories.tsx | 6 + ts/components/conversation/Timeline.tsx | 4 + .../conversation/TimelineItem.stories.tsx | 4 + ts/components/conversation/TimelineItem.tsx | 25 + ts/jobs/helpers/addReportSpamJob.ts | 7 +- ts/model-types.d.ts | 6 +- ts/models/conversations.ts | 192 ++++---- ts/state/ducks/audioRecorder.ts | 8 +- ts/state/ducks/conversations.ts | 331 +++++++++---- ts/state/ducks/emojis.ts | 5 +- ts/state/ducks/globalModals.ts | 35 ++ ts/state/ducks/preferredReactions.ts | 5 +- ts/state/ducks/stickers.ts | 6 + ts/state/selectors/audioRecorder.ts | 23 + ts/state/selectors/items.ts | 14 + ts/state/selectors/message.ts | 27 ++ ts/state/smart/CompositionArea.tsx | 449 +++++++++++------- ts/state/smart/CompositionTextArea.tsx | 2 +- .../smart/ContactSpoofingReviewDialog.tsx | 2 + ts/state/smart/ConversationHeader.tsx | 32 +- .../CustomizingPreferredReactionsModal.tsx | 2 +- ts/state/smart/EmojiPicker.tsx | 2 +- ts/state/smart/GlobalModalContainer.tsx | 12 + .../MessageRequestActionsConfirmation.tsx | 84 ++++ ts/state/smart/ReactionPicker.tsx | 2 +- ts/state/smart/StoryCreator.tsx | 3 +- ts/state/smart/StoryViewer.tsx | 2 +- ts/state/smart/Timeline.tsx | 3 + ts/state/smart/TimelineItem.tsx | 21 +- ts/test-mock/benchmarks/group_send_bench.ts | 2 +- ts/test-mock/helpers.ts | 44 +- ts/test-mock/pnp/accept_gv2_invite_test.ts | 54 ++- ts/test-mock/pnp/merge_test.ts | 44 +- ts/test-mock/pnp/phone_discovery_test.ts | 11 +- ts/test-mock/pnp/pni_change_test.ts | 61 +-- ts/test-mock/pnp/pni_signature_test.ts | 14 +- ts/test-mock/pnp/send_gv2_invite_test.ts | 5 +- .../jobs/helpers/addReportSpamJob_test.ts | 11 +- ts/types/MessageRequestResponseEvent.ts | 7 + ts/types/Toast.tsx | 2 + ts/util/getAddedByForOurPendingInvitation.ts | 17 + ts/util/getConversation.ts | 1 + ts/util/getNotificationDataForMessage.ts | 31 ++ ts/util/idForLogging.ts | 3 +- ts/util/lint/exceptions.json | 7 + 88 files changed, 2711 insertions(+), 807 deletions(-) create mode 100644 images/icons/v3/spam/spam.svg create mode 100644 images/icons/v3/thread/thread.svg create mode 100644 images/safety-tips/safety-tip-business.png create mode 100644 images/safety-tips/safety-tip-crypto.png create mode 100644 images/safety-tips/safety-tip-links.png create mode 100644 images/safety-tips/safety-tip-vague.png create mode 100644 stylesheets/components/MessageRequestActionsConfirmation.scss create mode 100644 stylesheets/components/SafetyTipsModal.scss create mode 100644 ts/components/SafetyTipsModal.stories.tsx create mode 100644 ts/components/SafetyTipsModal.tsx create mode 100644 ts/components/conversation/MessageRequestResponseNotification.tsx create mode 100644 ts/state/selectors/audioRecorder.ts create mode 100644 ts/state/smart/MessageRequestActionsConfirmation.tsx create mode 100644 ts/types/MessageRequestResponseEvent.ts create mode 100644 ts/util/getAddedByForOurPendingInvitation.ts diff --git a/.storybook/preview.tsx b/.storybook/preview.tsx index d567f5ca21..ad037f6177 100644 --- a/.storybook/preview.tsx +++ b/.storybook/preview.tsx @@ -116,7 +116,6 @@ window.SignalContext = { getHourCyclePreference: () => HourCyclePreference.UnknownPreference, getPreferredSystemLocales: () => ['en'], - getResolvedMessagesLocaleDirection: () => 'ltr', getLocaleOverride: () => null, getLocaleDisplayNames: () => ({ en: { en: 'English' } }), }; @@ -133,6 +132,9 @@ const withGlobalTypesProvider = (Story, context) => { const mode = context.globals.mode; const direction = context.globals.direction ?? 'auto'; + window.SignalContext.getResolvedMessagesLocaleDirection = () => + direction === 'auto' ? 'ltr' : direction; + // Adding it to the body as well so that we can cover modals and other // components that are rendered outside of this decorator container if (theme === 'light') { diff --git a/_locales/en/messages.json b/_locales/en/messages.json index c26230aa10..ae2ec91fac 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -427,6 +427,26 @@ "messageformat": "Select messages", "description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation" }, + "icu:ConversationHeader__MenuItem--Accept": { + "messageformat": "Accept", + "description": "Shown in menu for conversation, allows the user to accept a message request" + }, + "icu:ConversationHeader__MenuItem--Block": { + "messageformat": "Block", + "description": "Shown in menu for conversation, allows the user to block the contact" + }, + "icu:ConversationHeader__MenuItem--Unblock": { + "messageformat": "Unblock", + "description": "Shown in menu for conversation, allows the user to unblock the contact" + }, + "icu:ConversationHeader__MenuItem--ReportSpam": { + "messageformat": "Report Spam", + "description": "Shown in menu for conversation, allows the user to report the conversation as spam" + }, + "icu:ConversationHeader__MenuItem--DeleteChat": { + "messageformat": "Delete Chat", + "description": "Shown in menu for conversation, allows the user to delete the conversation" + }, "icu:ContactListItem__menu": { "messageformat": "Manage Contact", "description": "Shown as aria label for context menu for a contact" @@ -3394,6 +3414,62 @@ "messageformat": "All", "description": "Shown in reaction viewer as the title for the 'all' category" }, + "icu:SafetyTipsModal__Title": { + "messageformat": "Safety Tips", + "description": "Title of the safety tips modal" + }, + "icu:SafetyTipsModal__Description": { + "messageformat": "Be careful when accepting message requests from people you don’t know. Watch out for:", + "description": "Description of the safety tips modal" + }, + "icu:SafetyTipsModal__TipTitle--Crypto": { + "messageformat": "Crypto or money scams", + "description": "Title of the crypto safety tip" + }, + "icu:SafetyTipsModal__TipDescription--Crypto": { + "messageformat": "If someone you don’t know messages about cryptocurrency (like Bitcoin) or a financial opportunity, be careful—it’s likely spam.", + "description": "Description of the crypto safety tip" + }, + "icu:SafetyTipsModal__TipTitle--Vague": { + "messageformat": "Vague or irrelevant messages", + "description": "Title of the vague safety tip" + }, + "icu:SafetyTipsModal__TipDescription--Vague": { + "messageformat": "Spammers often start with a simple message like “Hi” to draw you in. If you respond they may engage you further.", + "description": "Description of the vague safety tip" + }, + "icu:SafetyTipsModal__TipTitle--Links": { + "messageformat": "Messages with links", + "description": "Title of the links safety tip" + }, + "icu:SafetyTipsModal__TipDescription--Links": { + "messageformat": "Be careful of messages from people you don’t know that have links to websites. Never visit links from people you don’t trust.", + "description": "Description of the links safety tip" + }, + "icu:SafetyTipsModal__TipTitle--Business": { + "messageformat": "Fake businesses and institutions", + "description": "Title of the business safety tip" + }, + "icu:SafetyTipsModal__TipDescription--Business": { + "messageformat": "Be careful of businesses or government agencies contacting you. Messages involving tax agencies, couriers, and more can be spam.", + "description": "Description of the business safety tip" + }, + "icu:SafetyTipsModal__DotLabel": { + "messageformat": "Go to page {page, number}", + "description": "Label for the dots in the safety tips modal that when clicked will take you to a specific tip" + }, + "icu:SafetyTipsModal__Button--Previous": { + "messageformat": "Previous tip", + "description": "Button to go to the previous safety tip" + }, + "icu:SafetyTipsModal__Button--Next": { + "messageformat": "Next tip", + "description": "Button to go to the next safety tip" + }, + "icu:SafetyTipsModal__Button--Done": { + "messageformat": "Done", + "description": "Button to close the safety tips modal when you've reached the last tip" + }, "icu:MessageRequests--message-direct": { "messageformat": "Let {name} message you and share your name and photo with them? They won’t know you’ve seen their messages until you accept.", "description": "Shown as the message for a message request in a direct message" @@ -3458,6 +3534,42 @@ "messageformat": "You will no longer receive messages or updates from this group and members won't be able to add you to this group again.", "description": "Shown as the body in the confirmation modal for blocking a group message request" }, + "icu:MessageRequests--reportAndMaybeBlock": { + "messageformat": "Report...", + "description": "Shown as a button to let the user report a message request and maybe block the user" + }, + "icu:MessageRequests--ReportAndMaybeBlockModal-title": { + "messageformat": "Report as spam?", + "description": "Shown as the title in the modal for reporting and maybe blocking a message request" + }, + "icu:MessageRequests--ReportAndMaybeBlockModal-body--direct": { + "messageformat": "Signal will be notified that this person may be sending spam. Signal can’t see the content of any chats.", + "description": "Shown as the body in the modal for reporting and maybe blocking a message request in a direct conversation" + }, + "icu:MessageRequests--ReportAndMaybeBlockModal-body--group--unknown-contact": { + "messageformat": "Signal will be notified that the person who invited you to this group may be sending spam. Signal can’t see the content of any chats.", + "description": "Shown as the body in the modal for reporting and maybe blocking a message request in a group conversation when the contact is unknown" + }, + "icu:MessageRequests--ReportAndMaybeBlockModal-body--group": { + "messageformat": "Signal will be notified that {name}, who invited you to this group, may be sending spam. Signal can’t see the content of any chats.", + "description": "Shown as the body in the modal for reporting and maybe blocking a message request in a group conversation" + }, + "icu:MessageRequests--ReportAndMaybeBlockModal-report": { + "messageformat": "Report Spam", + "description": "Shown as a button to let the user report a message request" + }, + "icu:MessageRequests--ReportAndMaybeBlockModal-reportAndBlock": { + "messageformat": "Report and Block", + "description": "Shown as a button to let the user report a message request and block the user" + }, + "icu:MessageRequests--AcceptedOptionsModal--body": { + "messageformat": "You accepted a message request from {name}. If this was a mistake, you can choose an action below.", + "description": "Shown as the body in the modal for accepting a message request in a direct conversation" + }, + "icu:MessageRequests--report-spam-success-toast": { + "messageformat": "Reported as spam.", + "description": "Shown in a toast when you successfully report a user as spam" + }, "icu:MessageRequests--delete": { "messageformat": "Delete", "description": "Shown as a button to let the user delete any message request" @@ -5242,6 +5354,10 @@ "messageformat": "Learn more", "description": "Shown on the message request warning. Clicking this button will open a dialog with more information" }, + "icu:MessageRequestWarning__safety-tips": { + "messageformat": "Safety Tips", + "description": "Shown on the message request warning. Clicking this button will open a dialog with safety tips" + }, "icu:MessageRequestWarning__dialog__details": { "messageformat": "You have no groups in common with this person. Review requests carefully before accepting to avoid unwanted messages.", "description": "Shown in the message request warning dialog. Gives more information about message requests" @@ -6332,6 +6448,26 @@ "messageformat": "Check your primary device for this payment’s status", "description": "Payment event notification check device label" }, + "icu:MessageRequestResponseNotification__Message--Accepted": { + "messageformat": "You accepted the message request", + "description": "Message request response notification message when the user accepted the message request or unblocked another user" + }, + "icu:MessageRequestResponseNotification__Message--Reported": { + "messageformat": "Reported as spam", + "description": "Message request response notification message when the user reported the message request as spam" + }, + "icu:MessageRequestResponseNotification__Message--Blocked": { + "messageformat": "You blocked this person", + "description": "Message request response notification message when the user blocked another user" + }, + "icu:MessageRequestResponseNotification__Button--Options": { + "messageformat": "Options", + "description": "Message request response notification button to show options" + }, + "icu:MessageRequestResponseNotification__Button--LearnMore": { + "messageformat": "Learn More", + "description": "Message request response notification button to learn more" + }, "icu:SignalConnectionsModal__title": { "messageformat": "Signal Connections", "description": "The phrase/term: 'Signal Connections'" diff --git a/images/icons/v3/block/block.svg b/images/icons/v3/block/block.svg index a2f0d6b3b6..ef97046200 100644 --- a/images/icons/v3/block/block.svg +++ b/images/icons/v3/block/block.svg @@ -1 +1 @@ - \ No newline at end of file + \ No newline at end of file diff --git a/images/icons/v3/spam/spam.svg b/images/icons/v3/spam/spam.svg new file mode 100644 index 0000000000..3a85722974 --- /dev/null +++ b/images/icons/v3/spam/spam.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/icons/v3/thread/thread.svg b/images/icons/v3/thread/thread.svg new file mode 100644 index 0000000000..3bc74c7ccc --- /dev/null +++ b/images/icons/v3/thread/thread.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/images/safety-tips/safety-tip-business.png b/images/safety-tips/safety-tip-business.png new file mode 100644 index 0000000000000000000000000000000000000000..5e6254b6d337893c39e6fe6b5e0acaa4452b9135 GIT binary patch literal 16923 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 literal 0 HcmV?d00001 diff --git a/images/safety-tips/safety-tip-crypto.png b/images/safety-tips/safety-tip-crypto.png new file mode 100644 index 0000000000000000000000000000000000000000..4209a052b3c4962429aba121086814d1b80b4492 GIT binary patch literal 22673 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 literal 0 HcmV?d00001 diff --git a/protos/SignalService.proto b/protos/SignalService.proto index 503d19203a..127ed7eddb 100644 --- a/protos/SignalService.proto +++ b/protos/SignalService.proto @@ -550,6 +550,8 @@ message SyncMessage { DELETE = 2; BLOCK = 3; BLOCK_AND_DELETE = 4; + SPAM = 5; + BLOCK_AND_SPAM = 6; } optional string threadE164 = 1; diff --git a/stylesheets/components/ConversationHero.scss b/stylesheets/components/ConversationHero.scss index a268e12ef1..36fa9cf8f6 100644 --- a/stylesheets/components/ConversationHero.scss +++ b/stylesheets/components/ConversationHero.scss @@ -92,6 +92,14 @@ } } + &__safety-tips-button { + border-radius: 9999px; + padding-block: 6px; + padding-inline: 14px; + margin-top: 12px; + @include font-subtitle; + } + &__membership { @include font-body-2; user-select: none; diff --git a/stylesheets/components/MessageRequestActionsConfirmation.scss b/stylesheets/components/MessageRequestActionsConfirmation.scss new file mode 100644 index 0000000000..459afd165d --- /dev/null +++ b/stylesheets/components/MessageRequestActionsConfirmation.scss @@ -0,0 +1,6 @@ +// Copyright 2023 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +.MessageRequestActionsConfirmation__ModalHost__width-container { + min-width: 480px; +} diff --git a/stylesheets/components/SafetyTipsModal.scss b/stylesheets/components/SafetyTipsModal.scss new file mode 100644 index 0000000000..9d0cd2d4aa --- /dev/null +++ b/stylesheets/components/SafetyTipsModal.scss @@ -0,0 +1,220 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +$SafetyTipsModal__paddingInline: 32px; +$SafetyTipsModal__paddingBlock: 24px; + +.SafetyTipsModal { + .module-Modal__headerTitle { + align-items: start; + } + + .module-Modal__title { + padding-top: 20px; + @include font-title-1; + text-align: center; + } + + .module-Modal__body { + padding-inline: 0; + } + + .module-Modal__button-footer { + padding-block: $SafetyTipsModal__paddingBlock; + padding-inline: $SafetyTipsModal__paddingInline; + } +} + +.SafetyTipsModal__width-container { + max-width: 420px; + width: 95%; +} + +.SafetyTipsModal__Description { + margin: 0; + padding-inline: $SafetyTipsModal__paddingInline; + padding-bottom: 24px; + text-align: center; + @include font-body-1; + @include light-theme { + color: $color-gray-60; + } + @include dark-theme { + color: $color-gray-25; + } +} + +.SafetyTipsModal__Footer { + display: flex; + gap: 16px; +} + +.SafetyTipsModal__Button { + flex: 1; +} + +.SafetyTipsModal__Button--Previous { + &, + &:is(:disabled, [aria-disabled='true']) { + @include any-theme { + background: transparent; + } + @include light-theme { + color: $color-accent-blue; + } + @include dark-theme { + color: $color-white; + } + } + + &:is(:disabled, [aria-disabled='true']) { + opacity: 0.5; + } + + &:not(:disabled):not([aria-disabled='true']) { + &:hover, + &:focus { + @include light-theme { + background: $color-gray-15; + } + @include dark-theme { + background: $color-gray-65; + } + } + + &:active { + @include light-theme { + background: $color-gray-20; + } + @include dark-theme { + background: $color-gray-60; + } + } + } +} + +.SafetyTipsModal__CardWrapper { + display: flex; + flex-direction: row; + gap: $SafetyTipsModal__paddingInline; + overflow: hidden; + scroll-snap-type: x mandatory; + padding-inline: $SafetyTipsModal__paddingInline; +} + +.SafetyTipsModal__Card { + width: 100%; + flex-shrink: 0; + scroll-snap-align: center; + padding-block: 14px 32px; + padding-inline: 12px; + border-radius: 18px; + text-align: center; + @include light-theme { + background: $color-gray-02; + } + @include dark-theme { + background: $color-gray-75; + } +} + +.SafetyTipsModal__CardImage { + width: 100%; + height: auto; + vertical-align: top; + border-radius: 12px; + @include light-theme { + background: white; + } + @include dark-theme { + background: $color-gray-65; + } +} + +.SafetyTipsModal__CardTitle { + margin-block: 14px 0; + @include font-title-2; + @include light-theme { + color: $color-gray-90; + } + @include dark-theme { + color: $color-gray-05; + } +} + +.SafetyTipsModal__CardDescription { + margin-block: 8px 0; + @include font-body-1; + @include light-theme { + color: $color-gray-62; + } + @include dark-theme { + color: $color-gray-20; + } +} + +.SafetyTipsModal__Dots { + display: flex; + justify-content: center; + padding-block: 24px 20px; +} + +.SafetyTipsModal__DotsButton { + @include button-reset; + padding: 4px; + + &::before { + content: ''; + display: block; + width: 8px; + height: 8px; + border-radius: 9999px; + transition: background 100ms ease; + @include light-theme { + background: rgba($color-black, 30%); + } + @include dark-theme { + background: rgba($color-white, 30%); + } + } + + &:not([aria-current]) { + &:hover, + &:focus { + &::before { + @include light-theme { + background: rgba($color-black, 45%); + } + @include dark-theme { + background: rgba($color-white, 45%); + } + } + } + } + + &[aria-current]::before { + @include light-theme { + background: $color-black; + } + @include dark-theme { + background: $color-white; + } + } + + &:focus { + @include keyboard-mode { + &::before { + box-shadow: 0 0 0 2px $color-white, 0 0 0 4px $color-accent-blue; + } + } + @include dark-keyboard-mode { + &::before { + box-shadow: 0 0 0 2px $color-gray-80, 0 0 0 4px $color-accent-blue; + } + } + } +} + +.SafetyTipsModal__DotsButtonLabel { + @include sr-only; +} diff --git a/stylesheets/components/SystemMessage.scss b/stylesheets/components/SystemMessage.scss index 5c704f9069..ee26785ac7 100644 --- a/stylesheets/components/SystemMessage.scss +++ b/stylesheets/components/SystemMessage.scss @@ -247,6 +247,18 @@ '../images/icons/v3/merge/merge-compact.svg' ); } + + &--icon-thread::before { + @include system-message-icon('../images/icons/v3/thread/thread.svg'); + } + + &--icon-spam::before { + @include system-message-icon('../images/icons/v3/spam/spam.svg'); + } + + &--icon-block::before { + @include system-message-icon('../images/icons/v3/block/block.svg'); + } } &--error { diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index 9546dd229d..dc27417885 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -114,6 +114,7 @@ @import './components/MediaQualitySelector.scss'; @import './components/MessageAudio.scss'; @import './components/MessageBody.scss'; +@import './components/MessageRequestActionsConfirmation.scss'; @import './components/MessageTextRenderer.scss'; @import './components/MessageDetail.scss'; @import './components/MiniPlayer.scss'; @@ -133,6 +134,7 @@ @import './components/SafetyNumberChangeDialog.scss'; @import './components/SafetyNumberOnboarding.scss'; @import './components/SafetyNumberViewer.scss'; +@import './components/SafetyTipsModal.scss'; @import './components/ScrollDownButton.scss'; @import './components/SearchInput.scss'; @import './components/SearchResultsLoadingFakeHeader.scss'; diff --git a/ts/components/CompositionArea.stories.tsx b/ts/components/CompositionArea.stories.tsx index bae214541b..955ba424ae 100644 --- a/ts/components/CompositionArea.stories.tsx +++ b/ts/components/CompositionArea.stories.tsx @@ -108,7 +108,7 @@ export default { blockConversation: action('blockConversation'), blockAndReportSpam: action('blockAndReportSpam'), deleteConversation: action('deleteConversation'), - title: '', + conversationName: getDefaultConversation(), // GroupV1 Disabled Actions showGV2MigrationDialog: action('showGV2MigrationDialog'), // GroupV2 diff --git a/ts/components/CompositionArea.tsx b/ts/components/CompositionArea.tsx index 4fb5e6f9a1..cf55506aaa 100644 --- a/ts/components/CompositionArea.tsx +++ b/ts/components/CompositionArea.tsx @@ -1,7 +1,7 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { memo, useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import type { ReadonlyDeep } from 'type-fest'; @@ -43,6 +43,7 @@ import type { AciString } from '../types/ServiceId'; import { AudioCapture } from './conversation/AudioCapture'; import { CompositionUpload } from './CompositionUpload'; import type { + ConversationRemovalStage, ConversationType, PushPanelForConversationActionType, ShowConversationType, @@ -73,16 +74,16 @@ import type { ShowToastAction } from '../state/ducks/toast'; import type { DraftEditMessageType } from '../model-types.d'; export type OwnProps = Readonly<{ - acceptedMessageRequest?: boolean; - removalStage?: 'justNotification' | 'messageRequest'; + acceptedMessageRequest: boolean | null; + removalStage: ConversationRemovalStage | null; addAttachment: ( conversationId: string, attachment: InMemoryAttachmentDraftType ) => unknown; - announcementsOnly?: boolean; - areWeAdmin?: boolean; - areWePending?: boolean; - areWePendingApproval?: boolean; + announcementsOnly: boolean | null; + areWeAdmin: boolean | null; + areWePending: boolean | null; + areWePendingApproval: boolean | null; cancelRecording: () => unknown; completeRecording: ( conversationId: string, @@ -93,29 +94,29 @@ export type OwnProps = Readonly<{ ) => HydratedBodyRangesType | undefined; conversationId: string; discardEditMessage: (id: string) => unknown; - draftEditMessage?: DraftEditMessageType; + draftEditMessage: DraftEditMessageType | null; draftAttachments: ReadonlyArray; - errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType; + errorDialogAudioRecorderType: ErrorDialogAudioRecorderType | null; errorRecording: (e: ErrorDialogAudioRecorderType) => unknown; focusCounter: number; groupAdmins: Array; - groupVersion?: 1 | 2; + groupVersion: 1 | 2 | null; i18n: LocalizerType; imageToBlurHash: typeof imageToBlurHash; isDisabled: boolean; - isFetchingUUID?: boolean; + isFetchingUUID: boolean | null; isFormattingEnabled: boolean; - isGroupV1AndDisabled?: boolean; - isMissingMandatoryProfileSharing?: boolean; - isSignalConversation?: boolean; - lastEditableMessageId?: string; + isGroupV1AndDisabled: boolean | null; + isMissingMandatoryProfileSharing: boolean | null; + isSignalConversation: boolean | null; + lastEditableMessageId: string | null; recordingState: RecordingState; messageCompositionId: string; - shouldHidePopovers?: boolean; - isSMSOnly?: boolean; - left?: boolean; + shouldHidePopovers: boolean | null; + isSMSOnly: boolean | null; + left: boolean | null; linkPreviewLoading: boolean; - linkPreviewResult?: LinkPreviewType; + linkPreviewResult: LinkPreviewType | null; onClearAttachments(conversationId: string): unknown; onCloseLinkPreview(conversationId: string): unknown; platform: string; @@ -149,15 +150,15 @@ export type OwnProps = Readonly<{ voiceNoteAttachment?: InMemoryAttachmentDraftType; } ): unknown; - quotedMessageId?: string; - quotedMessageProps?: ReadonlyDeep< + quotedMessageId: string | null; + quotedMessageProps: null | ReadonlyDeep< Omit< QuoteProps, 'i18n' | 'onClick' | 'onClose' | 'withContentAbove' | 'isCompose' > >; - quotedMessageAuthorAci?: AciString; - quotedMessageSentAt?: number; + quotedMessageAuthorAci: AciString | null; + quotedMessageSentAt: number | null; removeAttachment: (conversationId: string, filePath: string) => unknown; scrollToMessage: (conversationId: string, messageId: string) => unknown; @@ -210,6 +211,7 @@ export type Props = Pick< | 'blessedPacks' | 'recentStickers' | 'clearInstalledStickerPack' + | 'showIntroduction' | 'clearShowIntroduction' | 'showPickerHint' | 'clearShowPickerHint' @@ -220,7 +222,7 @@ export type Props = Pick< pushPanelForConversation: PushPanelForConversationActionType; } & OwnProps; -export function CompositionArea({ +export const CompositionArea = memo(function CompositionArea({ // Base props addAttachment, conversationId, @@ -291,6 +293,7 @@ export function CompositionArea({ recentStickers, clearInstalledStickerPack, sendStickerMessage, + showIntroduction, clearShowIntroduction, showPickerHint, clearShowPickerHint, @@ -301,14 +304,18 @@ export function CompositionArea({ conversationType, groupVersion, isBlocked, + isHidden, + isReported, isMissingMandatoryProfileSharing, left, removalStage, acceptConversation, blockConversation, + reportSpam, blockAndReportSpam, deleteConversation, - title, + conversationName, + addedByName, // GroupV1 Disabled Actions isGroupV1AndDisabled, showGV2MigrationDialog, @@ -356,8 +363,8 @@ export function CompositionArea({ bodyRanges, message, // sent timestamp for the quote - quoteSentAt: quotedMessageSentAt, - quoteAuthorAci: quotedMessageAuthorAci, + quoteSentAt: quotedMessageSentAt ?? undefined, + quoteAuthorAci: quotedMessageAuthorAci ?? undefined, targetMessageId: editedMessageId, }); } else { @@ -469,12 +476,7 @@ export function CompositionArea({ ) { inputApiRef.current.reset(); } - }, [ - messageCompositionId, - sendCounter, - previousMessageCompositionId, - previousSendCounter, - ]); + }, [messageCompositionId, sendCounter, previousMessageCompositionId, previousSendCounter]); const insertEmoji = useCallback( (e: EmojiPickDataType) => { @@ -504,7 +506,7 @@ export function CompositionArea({ inputApiRef.current?.setContents( draftEditMessageBody ?? '', - draftBodyRanges, + draftBodyRanges ?? undefined, true ); }, [draftBodyRanges, draftEditMessageBody, hasEditDraftChanged]); @@ -520,7 +522,11 @@ export function CompositionArea({ return; } - inputApiRef.current?.setContents(draftText, draftBodyRanges, true); + inputApiRef.current?.setContents( + draftText, + draftBodyRanges ?? undefined, + true + ); }, [conversationId, draftBodyRanges, draftText, previousConversationId]); const handleToggleLarge = useCallback(() => { @@ -637,6 +643,7 @@ export function CompositionArea({ onPickSticker={(packId, stickerId) => sendStickerMessage(conversationId, { packId, stickerId }) } + showIntroduction={showIntroduction} clearShowIntroduction={clearShowIntroduction} showPickerHint={showPickerHint} clearShowPickerHint={clearShowPickerHint} @@ -735,16 +742,19 @@ export function CompositionArea({ ) { return ( ); } @@ -788,14 +798,18 @@ export function CompositionArea({ ) { return ( ); } @@ -993,7 +1007,7 @@ export function CompositionArea({ platform={platform} sendCounter={sendCounter} shouldHidePopovers={shouldHidePopovers} - skinTone={skinTone} + skinTone={skinTone ?? null} sortedGroupMembers={sortedGroupMembers} theme={theme} /> @@ -1031,4 +1045,4 @@ export function CompositionArea({ /> ); -} +}); diff --git a/ts/components/CompositionInput.stories.tsx b/ts/components/CompositionInput.stories.tsx index c6cba1c0dd..7c1179fb3e 100644 --- a/ts/components/CompositionInput.stories.tsx +++ b/ts/components/CompositionInput.stories.tsx @@ -21,30 +21,38 @@ export default { args: {}, } satisfies Meta; -const useProps = (overrideProps: Partial = {}): Props => ({ - i18n, - disabled: overrideProps.disabled ?? false, - draftText: overrideProps.draftText || undefined, - draftBodyRanges: overrideProps.draftBodyRanges || [], - clearQuotedMessage: action('clearQuotedMessage'), - getPreferredBadge: () => undefined, - getQuotedMessage: action('getQuotedMessage'), - isFormattingEnabled: - overrideProps.isFormattingEnabled === false - ? overrideProps.isFormattingEnabled - : true, - large: overrideProps.large ?? false, - onCloseLinkPreview: action('onCloseLinkPreview'), - onEditorStateChange: action('onEditorStateChange'), - onPickEmoji: action('onPickEmoji'), - onSubmit: action('onSubmit'), - onTextTooLong: action('onTextTooLong'), - platform: 'darwin', - sendCounter: 0, - sortedGroupMembers: overrideProps.sortedGroupMembers ?? [], - skinTone: overrideProps.skinTone ?? undefined, - theme: React.useContext(StorybookThemeContext), -}); +const useProps = (overrideProps: Partial = {}): Props => { + const conversation = getDefaultConversation(); + return { + i18n, + conversationId: conversation.id, + disabled: overrideProps.disabled ?? false, + draftText: overrideProps.draftText ?? null, + draftEditMessage: overrideProps.draftEditMessage ?? null, + draftBodyRanges: overrideProps.draftBodyRanges || [], + clearQuotedMessage: action('clearQuotedMessage'), + getPreferredBadge: () => undefined, + getQuotedMessage: action('getQuotedMessage'), + isFormattingEnabled: + overrideProps.isFormattingEnabled === false + ? overrideProps.isFormattingEnabled + : true, + large: overrideProps.large ?? false, + onCloseLinkPreview: action('onCloseLinkPreview'), + onEditorStateChange: action('onEditorStateChange'), + onPickEmoji: action('onPickEmoji'), + onSubmit: action('onSubmit'), + onTextTooLong: action('onTextTooLong'), + platform: 'darwin', + sendCounter: 0, + sortedGroupMembers: overrideProps.sortedGroupMembers ?? [], + skinTone: overrideProps.skinTone ?? null, + theme: React.useContext(StorybookThemeContext), + inputApi: null, + shouldHidePopovers: null, + linkPreviewResult: null, + }; +}; export function Default(): JSX.Element { const props = useProps(); diff --git a/ts/components/CompositionInput.tsx b/ts/components/CompositionInput.tsx index 13f549f842..ab6e78c6c9 100644 --- a/ts/components/CompositionInput.tsx +++ b/ts/components/CompositionInput.tsx @@ -96,22 +96,22 @@ export type InputApi = { export type Props = Readonly<{ children?: React.ReactNode; - conversationId?: string; + conversationId: string | null; i18n: LocalizerType; disabled?: boolean; - draftEditMessage?: DraftEditMessageType; + draftEditMessage: DraftEditMessageType | null; getPreferredBadge: PreferredBadgeSelectorType; - large?: boolean; - inputApi?: React.MutableRefObject; + large: boolean | null; + inputApi: React.MutableRefObject | null; isFormattingEnabled: boolean; sendCounter: number; - skinTone?: EmojiPickDataType['skinTone']; - draftText?: string; - draftBodyRanges?: HydratedBodyRangesType; + skinTone: NonNullable | null; + draftText: string | null; + draftBodyRanges: HydratedBodyRangesType | null; moduleClassName?: string; theme: ThemeType; placeholder?: string; - sortedGroupMembers?: ReadonlyArray; + sortedGroupMembers: ReadonlyArray | null; scrollerRef?: React.RefObject; onDirtyChange?(dirty: boolean): unknown; onEditorStateChange?(options: { @@ -132,11 +132,11 @@ export type Props = Readonly<{ ): unknown; onScroll?: (ev: React.UIEvent) => void; platform: string; - shouldHidePopovers?: boolean; + shouldHidePopovers: boolean | null; getQuotedMessage?(): unknown; clearQuotedMessage?(): unknown; linkPreviewLoading?: boolean; - linkPreviewResult?: LinkPreviewType; + linkPreviewResult: LinkPreviewType | null; onCloseLinkPreview?(conversationId: string): unknown; }>; @@ -562,7 +562,7 @@ export function CompositionInput(props: Props): React.ReactElement { onEditorStateChange({ bodyRanges, caretLocation: selection ? selection.index : undefined, - conversationId, + conversationId: conversationId ?? undefined, messageText: text, sendCounter, }); @@ -612,7 +612,7 @@ export function CompositionInput(props: Props): React.ReactElement { React.useEffect(() => { const emojiCompletion = emojiCompletionRef.current; - if (emojiCompletion === undefined || skinTone === undefined) { + if (emojiCompletion == null || skinTone == null) { return; } diff --git a/ts/components/CompositionTextArea.tsx b/ts/components/CompositionTextArea.tsx index b8db89cd2e..8fa90b8c4a 100644 --- a/ts/components/CompositionTextArea.tsx +++ b/ts/components/CompositionTextArea.tsx @@ -19,7 +19,7 @@ import type { PreferredBadgeSelectorType } from '../state/selectors/badges'; import * as grapheme from '../util/grapheme'; export type CompositionTextAreaProps = { - bodyRanges?: HydratedBodyRangesType; + bodyRanges: HydratedBodyRangesType | null; i18n: LocalizerType; isFormattingEnabled: boolean; maxLength?: number; @@ -153,6 +153,17 @@ export function CompositionTextArea({ scrollerRef={scrollerRef} sendCounter={0} theme={theme} + skinTone={skinTone ?? null} + // These do not apply in the forward modal because there isn't + // strictly one conversation + conversationId={null} + sortedGroupMembers={null} + // we don't edit in this context + draftEditMessage={null} + // rendered in the forward modal + linkPreviewResult={null} + // Panels appear behind this modal + shouldHidePopovers={null} />
JSX.Element; + // MessageRequestActionsConfirmation + messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null; + renderMessageRequestActionsConfirmation: () => JSX.Element; // ProfileEditor isProfileEditorVisible: boolean; renderProfileEditor: () => JSX.Element; @@ -130,6 +134,9 @@ export function GlobalModalContainer({ // ForwardMessageModal forwardMessagesProps, renderForwardMessagesModal, + // MessageRequestActionsConfirmation + messageRequestActionsConfirmationProps, + renderMessageRequestActionsConfirmation, // ProfileEditor isProfileEditorVisible, renderProfileEditor, @@ -223,6 +230,10 @@ export function GlobalModalContainer({ return renderForwardMessagesModal(); } + if (messageRequestActionsConfirmationProps) { + return renderMessageRequestActionsConfirmation(); + } + if (isProfileEditorVisible) { return renderProfileEditor(); } diff --git a/ts/components/MediaEditor.tsx b/ts/components/MediaEditor.tsx index f27be7c19b..483a56c2bb 100644 --- a/ts/components/MediaEditor.tsx +++ b/ts/components/MediaEditor.tsx @@ -176,13 +176,12 @@ export function MediaEditor({ const [isEmojiPopperOpen, setEmojiPopperOpen] = useState(false); const [caption, setCaption] = useState(draftText ?? ''); - const [captionBodyRanges, setCaptionBodyRanges] = useState< - DraftBodyRanges | undefined - >(draftBodyRanges); + const [captionBodyRanges, setCaptionBodyRanges] = + useState(draftBodyRanges); const conversationSelector = useSelector(getConversationSelector); const hydratedBodyRanges = useMemo( - () => hydrateRanges(captionBodyRanges, conversationSelector), + () => hydrateRanges(captionBodyRanges ?? undefined, conversationSelector), [captionBodyRanges, conversationSelector] ); @@ -1297,7 +1296,7 @@ export function MediaEditor({
): JSX.Element | null { const { close, isClosed, modalStyles, overlayStyles } = useAnimated( onClose, @@ -132,6 +135,7 @@ export function Modal({ padded={padded} hasHeaderDivider={hasHeaderDivider} hasFooterDivider={hasFooterDivider} + aria-describedby={ariaDescribedBy} > {children} @@ -173,6 +177,7 @@ export function ModalPage({ padded = true, hasHeaderDivider = false, hasFooterDivider = false, + 'aria-describedby': ariaDescribedBy, }: ModalPageProps): JSX.Element { const modalRef = useRef(null); @@ -188,6 +193,8 @@ export function ModalPage({ ); const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName); + const [id] = useState(() => uuid()); + useScrollObserver(bodyRef, bodyInnerRef, scroll => { setScrolled(isScrolled(scroll)); setScrolledToBottom(isScrolledToBottom(scroll)); @@ -198,7 +205,7 @@ export function ModalPage({ <> {/* We don't want the click event to propagate to its container node. */} {/* eslint-disable-next-line max-len */} - {/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */} + {/* eslint-disable-next-line jsx-a11y/click-events-have-key-events, jsx-a11y/no-noninteractive-element-interactions */}
{ event.stopPropagation(); }} @@ -234,6 +245,7 @@ export function ModalPage({ )} {title && (

; + +export function Default(args: SafetyTipsModalProps): JSX.Element { + return ; +} diff --git a/ts/components/SafetyTipsModal.tsx b/ts/components/SafetyTipsModal.tsx new file mode 100644 index 0000000000..cc8294d3fa --- /dev/null +++ b/ts/components/SafetyTipsModal.tsx @@ -0,0 +1,216 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { UIEvent } from 'react'; +import React, { useEffect, useMemo, useRef, useState } from 'react'; +import uuid from 'uuid'; +import type { LocalizerType } from '../types/I18N'; +import { Modal } from './Modal'; +import { Button, ButtonVariant } from './Button'; +import { useReducedMotion } from '../hooks/useReducedMotion'; + +export type SafetyTipsModalProps = Readonly<{ + i18n: LocalizerType; + onClose(): void; +}>; + +export function SafetyTipsModal({ + i18n, + onClose, +}: SafetyTipsModalProps): JSX.Element { + const pages = useMemo(() => { + return [ + { + key: 'crypto', + title: i18n('icu:SafetyTipsModal__TipTitle--Crypto'), + description: i18n('icu:SafetyTipsModal__TipDescription--Crypto'), + imageUrl: 'images/safety-tips/safety-tip-crypto.png', + }, + { + key: 'vague', + title: i18n('icu:SafetyTipsModal__TipTitle--Vague'), + description: i18n('icu:SafetyTipsModal__TipDescription--Vague'), + imageUrl: 'images/safety-tips/safety-tip-vague.png', + }, + { + key: 'links', + title: i18n('icu:SafetyTipsModal__TipTitle--Links'), + description: i18n('icu:SafetyTipsModal__TipDescription--Links'), + imageUrl: 'images/safety-tips/safety-tip-links.png', + }, + { + key: 'business', + title: i18n('icu:SafetyTipsModal__TipTitle--Business'), + description: i18n('icu:SafetyTipsModal__TipDescription--Business'), + imageUrl: 'images/safety-tips/safety-tip-business.png', + }, + ]; + }, [i18n]); + + const [modalId] = useState(() => uuid()); + const [cardWrapperId] = useState(() => uuid()); + + function getCardIdForPage(pageIndex: number) { + return `${cardWrapperId}_${pages[pageIndex].key}`; + } + + const maxPageIndex = pages.length - 1; + const [pageIndex, setPageIndexInner] = useState(0); + const reducedMotion = useReducedMotion(); + const scrollEndTimer = useRef(null); + + const [hasPageIndexChanged, setHasPageIndexChanged] = useState(false); + function setPageIndex(nextPageIndex: number) { + setPageIndexInner(nextPageIndex); + setHasPageIndexChanged(true); + } + + function clearScrollEndTimer() { + if (scrollEndTimer.current != null) { + clearTimeout(scrollEndTimer.current); + scrollEndTimer.current = null; + } + } + + useEffect(() => { + return () => { + clearScrollEndTimer(); + }; + }, []); + + function scrollToPageIndex(nextPageIndex: number) { + clearScrollEndTimer(); + setPageIndex(nextPageIndex); + document.getElementById(getCardIdForPage(nextPageIndex))?.scrollIntoView({ + inline: 'center', + behavior: reducedMotion ? 'instant' : 'smooth', + }); + } + + function handleScroll(event: UIEvent) { + clearScrollEndTimer(); + const { scrollWidth, scrollLeft, clientWidth } = event.currentTarget; + const maxScrollLeft = scrollWidth - clientWidth; + const absScrollLeft = Math.abs(scrollLeft); + const percentScrolled = absScrollLeft / maxScrollLeft; + const scrolledPageIndex = Math.round(percentScrolled * maxPageIndex); + scrollEndTimer.current = setTimeout(() => { + setPageIndex(scrolledPageIndex); + }, 100); + } + + return ( + + + {pageIndex < maxPageIndex ? ( + + ) : ( + + )} + + } + > +

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

+
+
+ {pages.map((page, index) => { + const isCurrentPage = pageIndex === index; + return ( +
+ +

{page.title}

+

+ {page.description} +

+
+ ); + })} +
+
+ {pages.map((page, index) => { + const isCurrentPage = pageIndex === index; + return ( + + ); + })} +
+
+
+ ); +} diff --git a/ts/components/StoryCreator.tsx b/ts/components/StoryCreator.tsx index b7abaa4749..4df76fdb69 100644 --- a/ts/components/StoryCreator.tsx +++ b/ts/components/StoryCreator.tsx @@ -96,7 +96,11 @@ export type PropsType = { > & Pick< MediaEditorPropsType, - 'isFormattingEnabled' | 'onPickEmoji' | 'onTextTooLong' | 'platform' + | 'isFormattingEnabled' + | 'onPickEmoji' + | 'onTextTooLong' + | 'platform' + | 'sortedGroupMembers' >; export function StoryCreator({ @@ -139,6 +143,7 @@ export function StoryCreator({ setMyStoriesToAllSignalConnections, signalConnections, skinTone, + sortedGroupMembers, theme, toggleGroupsForStorySend, toggleSignalConnectionsModal, @@ -272,6 +277,9 @@ export function StoryCreator({ platform={platform} recentStickers={recentStickers} skinTone={skinTone} + sortedGroupMembers={sortedGroupMembers} + draftText={null} + draftBodyRanges={null} /> )} {!file && ( diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index f63859950c..7c05d39feb 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -258,8 +258,15 @@ export function StoryViewsNRepliesModal({ } platform={platform} sendCounter={0} - sortedGroupMembers={sortedGroupMembers} + skinTone={skinTone ?? null} + sortedGroupMembers={sortedGroupMembers ?? null} theme={ThemeType.dark} + conversationId={null} + draftBodyRanges={null} + draftEditMessage={null} + large={null} + shouldHidePopovers={null} + linkPreviewResult={null} > {i18n('icu:Reactions--error')}; } + if (toastType === ToastType.ReportedSpam) { + return ( + + {i18n('icu:MessageRequests--report-spam-success-toast')} + + ); + } + if (toastType === ToastType.ReportedSpamAndBlocked) { return ( diff --git a/ts/components/conversation/ContactName.tsx b/ts/components/conversation/ContactName.tsx index d5ad60f791..019e738970 100644 --- a/ts/components/conversation/ContactName.tsx +++ b/ts/components/conversation/ContactName.tsx @@ -1,21 +1,47 @@ // Copyright 2018 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useMemo } from 'react'; import classNames from 'classnames'; import { Emojify } from './Emojify'; import type { ContactNameColorType } from '../../types/Colors'; import { getClassNamesFor } from '../../util/getClassNamesFor'; +import type { ConversationType } from '../../state/ducks/conversations'; +import { isSignalConversation as getIsSignalConversation } from '../../util/isSignalConversation'; -export type PropsType = { +export type ContactNameData = { contactNameColor?: ContactNameColorType; firstName?: string; isSignalConversation?: boolean; isMe?: boolean; + title: string; +}; + +export function useContactNameData( + conversation: ConversationType | null, + contactNameColor?: ContactNameColorType +): ContactNameData | null { + const { firstName, title, isMe } = conversation ?? {}; + const isSignalConversation = + conversation != null ? getIsSignalConversation(conversation) : null; + return useMemo(() => { + if (title == null || isSignalConversation == null) { + return null; + } + return { + contactNameColor, + firstName, + isSignalConversation, + isMe, + title, + }; + }, [contactNameColor, firstName, isSignalConversation, isMe, title]); +} + +export type PropsType = ContactNameData & { module?: string; preferFirstName?: boolean; - title: string; onClick?: VoidFunction; }; diff --git a/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx b/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx index 1c9ac7da11..f6a2d387be 100644 --- a/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx +++ b/ts/components/conversation/ContactSpoofingReviewDialog.stories.tsx @@ -21,6 +21,7 @@ export default { const getCommonProps = () => ({ acceptConversation: action('acceptConversation'), + reportSpam: action('reportSpam'), blockAndReportSpam: action('blockAndReportSpam'), blockConversation: action('blockConversation'), conversationId: 'some-conversation-id', diff --git a/ts/components/conversation/ContactSpoofingReviewDialog.tsx b/ts/components/conversation/ContactSpoofingReviewDialog.tsx index 194a556313..4921cd1e91 100644 --- a/ts/components/conversation/ContactSpoofingReviewDialog.tsx +++ b/ts/components/conversation/ContactSpoofingReviewDialog.tsx @@ -50,6 +50,7 @@ export type ReviewPropsType = Readonly< export type PropsType = { conversationId: string; acceptConversation: (conversationId: string) => unknown; + reportSpam: (conversationId: string) => unknown; blockAndReportSpam: (conversationId: string) => unknown; blockConversation: (conversationId: string) => unknown; deleteConversation: (conversationId: string) => unknown; @@ -75,6 +76,7 @@ enum ConfirmationStateType { export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element { const { acceptConversation, + reportSpam, blockAndReportSpam, blockConversation, conversationId, @@ -111,19 +113,23 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element { case ConfirmationStateType.ConfirmingBlock: return ( { switch (messageRequestState) { case MessageRequestState.blocking: @@ -138,10 +144,12 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element { affectedConversation, }); break; + case MessageRequestState.reportingAndMaybeBlocking: + case MessageRequestState.acceptedOptions: case MessageRequestState.unblocking: assertDev( false, - 'Got unexpected MessageRequestState.unblocking state. Clearing confiration state' + `Got unexpected MessageRequestState.${MessageRequestState[messageRequestState]} state. Clearing confiration state` ); setConfirmationState(undefined); break; diff --git a/ts/components/conversation/ConversationHeader.stories.tsx b/ts/components/conversation/ConversationHeader.stories.tsx index fb5852b527..bcdb64c1df 100644 --- a/ts/components/conversation/ConversationHeader.stories.tsx +++ b/ts/components/conversation/ConversationHeader.stories.tsx @@ -29,8 +29,15 @@ type ItemsType = Array<{ props: Omit, 'theme'>; }>; +const commonConversation = getDefaultConversation(); const commonProps = { - ...getDefaultConversation(), + ...commonConversation, + conversationId: commonConversation.id, + conversationType: commonConversation.type, + conversationName: commonConversation, + addedByName: null, + isBlocked: commonConversation.isBlocked ?? false, + isReported: commonConversation.isReported ?? false, cannotLeaveBecauseYouAreLastAdmin: false, showBackButton: false, @@ -59,6 +66,12 @@ const commonProps = { setMuteExpiration: action('onSetMuteNotifications'), setPinned: action('setPinned'), viewUserStories: action('viewUserStories'), + + acceptConversation: action('acceptConversation'), + blockAndReportSpam: action('blockAndReportSpam'), + blockConversation: action('blockConversation'), + reportSpam: action('reportSpam'), + deleteConversation: action('deleteConversation'), }; export function PrivateConvo(): JSX.Element { diff --git a/ts/components/conversation/ConversationHeader.tsx b/ts/components/conversation/ConversationHeader.tsx index a3684f8041..768094d0b0 100644 --- a/ts/components/conversation/ConversationHeader.tsx +++ b/ts/components/conversation/ConversationHeader.tsx @@ -41,6 +41,12 @@ import { PanelType } from '../../types/Panels'; import { UserText } from '../UserText'; import { Alert } from '../Alert'; import { SizeObserver } from '../../hooks/useSizeObserver'; +import type { MessageRequestActionsConfirmationBaseProps } from './MessageRequestActionsConfirmation'; +import { + MessageRequestActionsConfirmation, + MessageRequestState, +} from './MessageRequestActionsConfirmation'; +import type { ContactNameData } from './ContactName'; export enum OutgoingCallButtonStyle { None, @@ -60,6 +66,8 @@ export type PropsDataType = { isSelectMode: boolean; isSignalConversation?: boolean; theme: ThemeType; + addedByName: ContactNameData | null; + conversationName: ContactNameData; } & Pick< ConversationType, | 'acceptedMessageRequest' @@ -72,6 +80,8 @@ export type PropsDataType = { | 'groupVersion' | 'id' | 'isArchived' + | 'isBlocked' + | 'isReported' | 'isMe' | 'isPinned' | 'isVerified' @@ -81,6 +91,7 @@ export type PropsDataType = { | 'name' | 'phoneNumber' | 'profileName' + | 'removalStage' | 'sharedGroupNames' | 'title' | 'type' @@ -106,7 +117,7 @@ export type PropsActionsType = { setMuteExpiration: (conversationId: string, seconds: number) => void; setPinned: (conversationId: string, value: boolean) => void; viewUserStories: ViewUserStoriesActionCreatorType; -}; +} & MessageRequestActionsConfirmationBaseProps; export type PropsHousekeepingType = { i18n: LocalizerType; @@ -127,6 +138,7 @@ type StateType = { hasCannotLeaveGroupBecauseYouAreLastAdminAlert: boolean; isNarrow: boolean; modalState: ModalState; + messageRequestState: MessageRequestState; }; const TIMER_ITEM_CLASS = 'module-ConversationHeader__disappearing-timer__item'; @@ -149,6 +161,7 @@ export class ConversationHeader extends React.Component { hasCannotLeaveGroupBecauseYouAreLastAdminAlert: false, isNarrow: false, modalState: ModalState.NothingOpen, + messageRequestState: MessageRequestState.default, }; this.menuTriggerRef = React.createRef(); @@ -156,6 +169,12 @@ export class ConversationHeader extends React.Component { this.showMenuBound = this.showMenu.bind(this); } + private handleMessageRequestStateChange = ( + state: MessageRequestState + ): void => { + this.setState({ messageRequestState: state }); + }; + private showMenu(event: React.MouseEvent): void { if (this.menuTriggerRef.current) { this.menuTriggerRef.current.handleContextClick(event); @@ -328,6 +347,7 @@ export class ConversationHeader extends React.Component { private renderMenu(triggerId: string): ReactNode { const { + acceptConversation, acceptedMessageRequest, canChangeTimer, cannotLeaveBecauseYouAreLastAdmin, @@ -336,6 +356,7 @@ export class ConversationHeader extends React.Component { i18n, id, isArchived, + isBlocked, isMissingMandatoryProfileSharing, isPinned, isSignalConversation, @@ -431,6 +452,7 @@ export class ConversationHeader extends React.Component { {i18n('icu:archiveConversation')} )} + this.setState({ hasDeleteMessagesConfirmation: true }) @@ -491,98 +513,164 @@ export class ConversationHeader extends React.Component { ); }); - return createPortal( - {disableTimerChanges ? null : ( - - {expireDurations} - - )} - - {muteOptions.map(item => ( + {!acceptedMessageRequest && ( + <> + {!isBlocked && ( + { + this.setState({ + messageRequestState: MessageRequestState.blocking, + }); + }} + > + {i18n('icu:ConversationHeader__MenuItem--Block')} + + )} + {isBlocked && ( + { + this.setState({ + messageRequestState: MessageRequestState.unblocking, + }); + }} + > + {i18n('icu:ConversationHeader__MenuItem--Unblock')} + + )} + {!isBlocked && ( + + {i18n('icu:ConversationHeader__MenuItem--Accept')} + + )} { - setMuteExpiration(id, item.value); + this.setState({ + messageRequestState: + MessageRequestState.reportingAndMaybeBlocking, + }); }} > - {item.name} + {i18n('icu:ConversationHeader__MenuItem--ReportSpam')} - ))} - - {!isGroup || hasGV2AdminEnabled ? ( - - pushPanelForConversation({ - type: PanelType.ConversationDetails, - }) - } - > - {isGroup - ? i18n('icu:showConversationDetails') - : i18n('icu:showConversationDetails--direct')} - - ) : null} - pushPanelForConversation({ type: PanelType.AllMedia })} - > - {i18n('icu:viewRecentMedia')} - - - { - toggleSelectMode(true); - }} - > - {i18n('icu:ConversationHeader__menu__selectMessages')} - - - {!markedUnread ? ( - onMarkUnread(id)}> - {i18n('icu:markUnread')} - - ) : null} - {isPinned ? ( - setPinned(id, false)}> - {i18n('icu:unpinConversation')} - - ) : ( - setPinned(id, true)}> - {i18n('icu:pinConversation')} - - )} - {isArchived ? ( - onMoveToInbox(id)}> - {i18n('icu:moveConversationToInbox')} - - ) : ( - onArchive(id)}> - {i18n('icu:archiveConversation')} - - )} - this.setState({ hasDeleteMessagesConfirmation: true })} - > - {i18n('icu:deleteMessagesInConversation')} - - {isGroup && ( - { - if (cannotLeaveBecauseYouAreLastAdmin) { + { this.setState({ - hasCannotLeaveGroupBecauseYouAreLastAdminAlert: true, + messageRequestState: MessageRequestState.deleting, }); - } else { - this.setState({ hasLeaveGroupConfirmation: true }); - } - }} - > - {i18n( - 'icu:ConversationHeader__ContextMenu__LeaveGroupAction__title' + }} + > + {i18n('icu:ConversationHeader__MenuItem--DeleteChat')} + + + )} + {acceptedMessageRequest && ( + <> + {disableTimerChanges ? null : ( + + {expireDurations} + )} - + + {muteOptions.map(item => ( + { + setMuteExpiration(id, item.value); + }} + > + {item.name} + + ))} + + {!isGroup || hasGV2AdminEnabled ? ( + + pushPanelForConversation({ + type: PanelType.ConversationDetails, + }) + } + > + {isGroup + ? i18n('icu:showConversationDetails') + : i18n('icu:showConversationDetails--direct')} + + ) : null} + + pushPanelForConversation({ type: PanelType.AllMedia }) + } + > + {i18n('icu:viewRecentMedia')} + + + { + toggleSelectMode(true); + }} + > + {i18n('icu:ConversationHeader__menu__selectMessages')} + + + {!markedUnread ? ( + onMarkUnread(id)}> + {i18n('icu:markUnread')} + + ) : null} + {isPinned ? ( + setPinned(id, false)}> + {i18n('icu:unpinConversation')} + + ) : ( + setPinned(id, true)}> + {i18n('icu:pinConversation')} + + )} + {isArchived ? ( + onMoveToInbox(id)}> + {i18n('icu:moveConversationToInbox')} + + ) : ( + onArchive(id)}> + {i18n('icu:archiveConversation')} + + )} + { + this.setState({ + messageRequestState: MessageRequestState.blocking, + }); + }} + > + {i18n('icu:ConversationHeader__MenuItem--Block')} + + + this.setState({ hasDeleteMessagesConfirmation: true }) + } + > + {i18n('icu:deleteMessagesInConversation')} + + {isGroup && ( + { + if (cannotLeaveBecauseYouAreLastAdmin) { + this.setState({ + hasCannotLeaveGroupBecauseYouAreLastAdminAlert: true, + }); + } else { + this.setState({ hasLeaveGroupConfirmation: true }); + } + }} + > + {i18n( + 'icu:ConversationHeader__ContextMenu__LeaveGroupAction__title' + )} + + )} + )} , document.body @@ -751,25 +839,35 @@ export class ConversationHeader extends React.Component { public override render(): ReactNode { const { + addedByName, announcementsOnly, areWeAdmin, + conversationName, expireTimer, hasPanelShowing, i18n, id, + isBlocked, + isReported, isSMSOnly, isSignalConversation, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, outgoingCallButtonStyle, setDisappearingMessages, + type, + acceptConversation, + blockAndReportSpam, + blockConversation, + reportSpam, + deleteConversation, } = this.props; if (hasPanelShowing) { return null; } - const { isNarrow, modalState } = this.state; + const { isNarrow, modalState, messageRequestState } = this.state; const triggerId = `conversation-${id}`; let modalNode: ReactNode; @@ -829,6 +927,22 @@ export class ConversationHeader extends React.Component { {this.renderSearchButton()} {this.renderMoreButton(triggerId)} {this.renderMenu(triggerId)} +

)} diff --git a/ts/components/conversation/ConversationHero.tsx b/ts/components/conversation/ConversationHero.tsx index 13bdce1816..61b9048896 100644 --- a/ts/components/conversation/ConversationHero.tsx +++ b/ts/components/conversation/ConversationHero.tsx @@ -15,6 +15,8 @@ import { StoryViewModeType } from '../../types/Stories'; import { ConfirmationDialog } from '../ConfirmationDialog'; import { shouldBlurAvatar } from '../../util/shouldBlurAvatar'; import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser'; +import { Button, ButtonVariant } from '../Button'; +import { SafetyTipsModal } from '../SafetyTipsModal'; export type Props = { about?: string; @@ -42,6 +44,7 @@ const renderMembershipRow = ({ i18n, isMe, onClickMessageRequestWarning, + onToggleSafetyTips, phoneNumber, sharedGroupNames, }: Pick< @@ -54,6 +57,7 @@ const renderMembershipRow = ({ > & Required> & { onClickMessageRequestWarning: () => void; + onToggleSafetyTips: (showSafetyTips: boolean) => void; }) => { if (conversationType !== 'direct') { return null; @@ -67,6 +71,20 @@ const renderMembershipRow = ({ ); } + const safetyTipsButton = ( +
+ +
+ ); + if (sharedGroupNames.length > 0) { return (
@@ -76,6 +94,7 @@ const renderMembershipRow = ({ nameClassName="module-conversation-hero__membership__name" sharedGroupNames={sharedGroupNames} /> + {safetyTipsButton}
); } @@ -86,6 +105,7 @@ const renderMembershipRow = ({ return (
{i18n('icu:no-groups-in-common')} + {safetyTipsButton}
); } @@ -107,6 +127,7 @@ const renderMembershipRow = ({ {i18n('icu:MessageRequestWarning__learn-more')}
+ {safetyTipsButton}
); }; @@ -136,6 +157,7 @@ export function ConversationHero({ viewUserStories, toggleAboutContactModal, }: Props): JSX.Element { + const [isShowingSafetyTips, setIsShowingSafetyTips] = useState(false); const [isShowingMessageRequestWarning, setIsShowingMessageRequestWarning] = useState(false); const closeMessageRequestWarning = () => { @@ -248,6 +270,9 @@ export function ConversationHero({ onClickMessageRequestWarning() { setIsShowingMessageRequestWarning(true); }, + onToggleSafetyTips(showSafetyTips: boolean) { + setIsShowingSafetyTips(showSafetyTips); + }, phoneNumber, sharedGroupNames, })} @@ -277,6 +302,15 @@ export function ConversationHero({ {i18n('icu:MessageRequestWarning__dialog__details')} )} + + {isShowingSafetyTips && ( + { + setIsShowingSafetyTips(false); + }} + /> + )} ); /* eslint-enable no-nested-ternary */ diff --git a/ts/components/conversation/MandatoryProfileSharingActions.stories.tsx b/ts/components/conversation/MandatoryProfileSharingActions.stories.tsx index e0d355e13d..48a5fcac0e 100644 --- a/ts/components/conversation/MandatoryProfileSharingActions.stories.tsx +++ b/ts/components/conversation/MandatoryProfileSharingActions.stories.tsx @@ -8,9 +8,17 @@ import type { Props } from './MandatoryProfileSharingActions'; import { MandatoryProfileSharingActions } from './MandatoryProfileSharingActions'; import { setupI18n } from '../../util/setupI18n'; import enMessages from '../../../_locales/en/messages.json'; +import { + getDefaultConversation, + getDefaultGroup, +} from '../../test-both/helpers/getDefaultConversation'; const i18n = setupI18n('en', enMessages); +type Args = { + conversationType: 'direct' | 'group'; +}; + export default { title: 'Components/Conversation/MandatoryProfileSharingActions', argTypes: { @@ -20,34 +28,43 @@ export default { options: ['direct', 'group'], }, }, - firstName: { control: { type: 'text' } }, - title: { control: { type: 'text' } }, }, args: { - conversationId: '123', - i18n, conversationType: 'direct', - firstName: 'Cayce', - title: 'Cayce Bollard', - acceptConversation: action('acceptConversation'), - blockAndReportSpam: action('blockAndReportSpam'), - blockConversation: action('blockConversation'), - deleteConversation: action('deleteConversation'), }, -} satisfies Meta; +} satisfies Meta; -export function Direct(args: Props): JSX.Element { +function Example(args: Args) { + const conversation = + args.conversationType === 'group' + ? getDefaultGroup() + : getDefaultConversation(); + const addedBy = + args.conversationType === 'group' ? getDefaultConversation() : conversation; return (
- +
); } +export function Direct(args: Props): JSX.Element { + return ; +} + export function Group(args: Props): JSX.Element { - return ( -
- -
- ); + return ; } diff --git a/ts/components/conversation/MandatoryProfileSharingActions.tsx b/ts/components/conversation/MandatoryProfileSharingActions.tsx index ddfef7df11..f831d694a8 100644 --- a/ts/components/conversation/MandatoryProfileSharingActions.tsx +++ b/ts/components/conversation/MandatoryProfileSharingActions.tsx @@ -2,10 +2,9 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import type { PropsType as ContactNameProps } from './ContactName'; import { ContactName } from './ContactName'; import { Button, ButtonVariant } from '../Button'; -import type { Props as MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation'; +import type { MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation'; import { MessageRequestActionsConfirmation, MessageRequestState, @@ -15,17 +14,20 @@ import type { LocalizerType } from '../../types/Util'; export type Props = { i18n: LocalizerType; - firstName?: string; -} & Omit & - Pick< - MessageRequestActionsConfirmationProps, - | 'acceptConversation' - | 'blockAndReportSpam' - | 'blockConversation' - | 'conversationId' - | 'conversationType' - | 'deleteConversation' - >; +} & Pick< + MessageRequestActionsConfirmationProps, + | 'addedByName' + | 'conversationId' + | 'conversationType' + | 'conversationName' + | 'isBlocked' + | 'isReported' + | 'acceptConversation' + | 'reportSpam' + | 'blockAndReportSpam' + | 'blockConversation' + | 'deleteConversation' +>; const learnMoreLink = (parts: Array) => (
) => ( ); export function MandatoryProfileSharingActions({ - acceptConversation, - blockAndReportSpam, - blockConversation, + addedByName, conversationId, conversationType, - deleteConversation, - firstName, + conversationName, i18n, - title, + isBlocked, + isReported, + acceptConversation, + reportSpam, + blockAndReportSpam, + blockConversation, + deleteConversation, }: Props): JSX.Element { const [mrState, setMrState] = React.useState(MessageRequestState.default); @@ -56,7 +61,7 @@ export function MandatoryProfileSharingActions({ key="name" className="module-message-request-actions__message__name" > - + ); @@ -64,19 +69,23 @@ export function MandatoryProfileSharingActions({ <> {mrState !== MessageRequestState.default ? ( { throw new Error( 'Should not be able to unblock from MandatoryProfileSharingActions' ); }} blockConversation={blockConversation} - conversationId={conversationId} deleteConversation={deleteConversation} - i18n={i18n} + reportSpam={reportSpam} blockAndReportSpam={blockAndReportSpam} - title={title} - conversationType={conversationType} - state={mrState} onChangeState={setMrState} /> ) : null} diff --git a/ts/components/conversation/MessageRequestActions.stories.tsx b/ts/components/conversation/MessageRequestActions.stories.tsx index a4411f9084..54ec5a3cec 100644 --- a/ts/components/conversation/MessageRequestActions.stories.tsx +++ b/ts/components/conversation/MessageRequestActions.stories.tsx @@ -4,13 +4,23 @@ import * as React from 'react'; import { action } from '@storybook/addon-actions'; import type { Meta } from '@storybook/react'; -import type { Props } from './MessageRequestActions'; import { MessageRequestActions } from './MessageRequestActions'; import { setupI18n } from '../../util/setupI18n'; import enMessages from '../../../_locales/en/messages.json'; +import { + getDefaultConversation, + getDefaultGroup, +} from '../../test-both/helpers/getDefaultConversation'; const i18n = setupI18n('en', enMessages); +type Args = { + conversationType: 'direct' | 'group'; + isBlocked: boolean; + isHidden: boolean; + isReported: boolean; +}; + export default { title: 'Components/Conversation/MessageRequestActions', argTypes: { @@ -20,19 +30,9 @@ export default { options: ['direct', 'group'], }, }, - firstName: { control: { type: 'text' } }, - title: { control: { type: 'text' } }, }, args: { - conversationId: '123', - i18n, conversationType: 'direct', - firstName: 'Cayce', - title: 'Cayce Bollard', - acceptConversation: action('acceptConversation'), - blockAndReportSpam: action('blockAndReportSpam'), - blockConversation: action('blockConversation'), - deleteConversation: action('deleteConversation'), }, decorators: [ (Story: React.ComponentType): JSX.Element => { @@ -43,20 +43,62 @@ export default { ); }, ], -} satisfies Meta; +} satisfies Meta; -export function Direct(args: Props): JSX.Element { - return ; +function Example(args: Args): JSX.Element { + const conversation = + args.conversationType === 'group' + ? getDefaultGroup() + : getDefaultConversation(); + const addedBy = + args.conversationType === 'group' ? getDefaultConversation() : conversation; + return ( + + ); } -export function DirectBlocked(args: Props): JSX.Element { - return ; +export function Direct(args: Args): JSX.Element { + return ; } -export function Group(args: Props): JSX.Element { - return ; +export function DirectBlocked(args: Args): JSX.Element { + return ; } -export function GroupBlocked(args: Props): JSX.Element { - return ; +export function DirectReported(args: Args): JSX.Element { + return ; +} + +export function DirectBlockedAndReported(args: Args): JSX.Element { + return ; +} + +export function Group(args: Args): JSX.Element { + return ; +} + +export function GroupBlocked(args: Args): JSX.Element { + return ; +} + +export function GroupReported(args: Args): JSX.Element { + return ; +} + +export function GroupBlockedAndReported(args: Args): JSX.Element { + return ; } diff --git a/ts/components/conversation/MessageRequestActions.tsx b/ts/components/conversation/MessageRequestActions.tsx index 0eac3850e2..137e06737e 100644 --- a/ts/components/conversation/MessageRequestActions.tsx +++ b/ts/components/conversation/MessageRequestActions.tsx @@ -2,52 +2,57 @@ // SPDX-License-Identifier: AGPL-3.0-only import * as React from 'react'; -import type { PropsType as ContactNameProps } from './ContactName'; import { ContactName } from './ContactName'; import { Button, ButtonVariant } from '../Button'; -import type { Props as MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation'; +import type { MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation'; import { MessageRequestActionsConfirmation, MessageRequestState, } from './MessageRequestActionsConfirmation'; import { Intl } from '../Intl'; import type { LocalizerType } from '../../types/Util'; +import { strictAssert } from '../../util/assert'; export type Props = { i18n: LocalizerType; - isHidden?: boolean; -} & Omit & - Omit< - MessageRequestActionsConfirmationProps, - 'i18n' | 'state' | 'onChangeState' - >; + isHidden: boolean | null; +} & Omit< + MessageRequestActionsConfirmationProps, + 'i18n' | 'state' | 'onChangeState' +>; export function MessageRequestActions({ + addedByName, + conversationId, + conversationType, + conversationName, + i18n, + isBlocked, + isHidden, + isReported, acceptConversation, blockAndReportSpam, blockConversation, - conversationId, - conversationType, + reportSpam, deleteConversation, - firstName, - i18n, - isHidden, - isBlocked, - title, }: Props): JSX.Element { const [mrState, setMrState] = React.useState(MessageRequestState.default); - const name = ( - - - - ); + const nameValue = + conversationType === 'direct' ? conversationName : addedByName; let message: JSX.Element | undefined; if (conversationType === 'direct') { + strictAssert(nameValue != null, 'nameValue is null'); + const name = ( + + + + ); + if (isBlocked) { message = ( {mrState !== MessageRequestState.default ? ( ) : null}

{message}

- - {isBlocked ? ( - - ) : ( + {!isBlocked && ( )} + {(isReported || isBlocked) && ( + + )} + {!isReported && ( + + )} + {isBlocked && ( + + )} {!isBlocked ? ( + ) + } + /> + )} + {event === MessageRequestResponseEvent.BLOCK && ( + + )} + {event === MessageRequestResponseEvent.SPAM && ( + { + setIsSafetyTipsModalOpen(true); + }} + > + {i18n( + 'icu:MessageRequestResponseNotification__Button--LearnMore' + )} + + } + /> + )} + {isSafetyTipsModalOpen && ( + { + setIsSafetyTipsModalOpen(false); + }} + /> + )} + + ); +} diff --git a/ts/components/conversation/SystemMessage.tsx b/ts/components/conversation/SystemMessage.tsx index 038c607af0..8336636779 100644 --- a/ts/components/conversation/SystemMessage.tsx +++ b/ts/components/conversation/SystemMessage.tsx @@ -16,6 +16,7 @@ export type PropsType = { | 'audio-incoming' | 'audio-missed' | 'audio-outgoing' + | 'block' | 'group' | 'group-access' | 'group-add' @@ -30,6 +31,7 @@ export type PropsType = { | 'phone' | 'profile' | 'safety-number' + | 'spam' | 'session-refresh' | 'thread' | 'timer' diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 2bef612a0a..a90f974a12 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -335,6 +335,10 @@ const actions = () => ({ viewStory: action('viewStory'), onReplyToMessage: action('onReplyToMessage'), + + onOpenMessageRequestActionsConfirmation: action( + 'onOpenMessageRequestActionsConfirmation' + ), }); const renderItem = ({ @@ -350,6 +354,7 @@ const renderItem = ({ getPreferredBadge={() => undefined} id="" isTargeted={false} + isBlocked={false} i18n={i18n} interactionMode="keyboard" isNextItemCallingNotification={false} @@ -442,6 +447,7 @@ const useProps = (overrideProps: Partial = {}): PropsType => ({ getTimestampForMessage: Date.now, haveNewest: overrideProps.haveNewest ?? false, haveOldest: overrideProps.haveOldest ?? false, + isBlocked: false, isConversationSelected: true, isIncomingMessageRequest: overrideProps.isIncomingMessageRequest ?? false, items: overrideProps.items ?? Object.keys(items), diff --git a/ts/components/conversation/Timeline.tsx b/ts/components/conversation/Timeline.tsx index b9bd6e03a8..6bd538f5ee 100644 --- a/ts/components/conversation/Timeline.tsx +++ b/ts/components/conversation/Timeline.tsx @@ -81,6 +81,7 @@ export type PropsDataType = { type PropsHousekeepingType = { id: string; + isBlocked: boolean; isConversationSelected: boolean; isGroupV1AndDisabled?: boolean; isIncomingMessageRequest: boolean; @@ -121,6 +122,7 @@ type PropsHousekeepingType = { containerElementRef: RefObject; containerWidthBreakpoint: WidthBreakpoint; conversationId: string; + isBlocked: boolean; isOldestTimelineItem: boolean; messageId: string; nextMessageId: undefined | string; @@ -786,6 +788,7 @@ export class Timeline extends React.Component< i18n, id, invitedContactsForNewlyCreatedGroup, + isBlocked, isConversationSelected, isGroupV1AndDisabled, items, @@ -928,6 +931,7 @@ export class Timeline extends React.Component< containerElementRef: this.containerRef, containerWidthBreakpoint: widthBreakpoint, conversationId: id, + isBlocked, isOldestTimelineItem: haveOldest && itemIndex === 0, messageId, nextMessageId, diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 1ff5dc5491..fc682299b2 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -59,6 +59,7 @@ const getDefaultProps = () => ({ id: 'asdf', isNextItemCallingNotification: false, isTargeted: false, + isBlocked: false, interactionMode: 'keyboard' as const, theme: ThemeType.light, platform: 'darwin', @@ -118,6 +119,9 @@ const getDefaultProps = () => ({ viewStory: action('viewStory'), onReplyToMessage: action('onReplyToMessage'), + onOpenMessageRequestActionsConfirmation: action( + 'onOpenMessageRequestActionsConfirmation' + ), }); export default { diff --git a/ts/components/conversation/TimelineItem.tsx b/ts/components/conversation/TimelineItem.tsx index af692fb2d0..b8ed969e20 100644 --- a/ts/components/conversation/TimelineItem.tsx +++ b/ts/components/conversation/TimelineItem.tsx @@ -56,6 +56,11 @@ import type { PropsDataType as PhoneNumberDiscoveryNotificationPropsType } from import { PhoneNumberDiscoveryNotification } from './PhoneNumberDiscoveryNotification'; import { SystemMessage } from './SystemMessage'; import { TimelineMessage } from './TimelineMessage'; +import { + MessageRequestResponseNotification, + type MessageRequestResponseNotificationData, +} from './MessageRequestResponseNotification'; +import type { MessageRequestState } from './MessageRequestActionsConfirmation'; type CallHistoryType = { type: 'callHistory'; @@ -137,6 +142,10 @@ type PaymentEventType = { type: 'paymentEvent'; data: Omit; }; +type MessageRequestResponseNotificationType = { + type: 'messageRequestResponse'; + data: MessageRequestResponseNotificationData; +}; export type TimelineItemType = ( | CallHistoryType @@ -159,6 +168,7 @@ export type TimelineItemType = ( | UnsupportedMessageType | VerificationNotificationType | PaymentEventType + | MessageRequestResponseNotificationType ) & { timestamp: number }; type PropsLocalType = { @@ -166,10 +176,12 @@ type PropsLocalType = { conversationId: string; item?: TimelineItemType; id: string; + isBlocked: boolean; isNextItemCallingNotification: boolean; isTargeted: boolean; targetMessage: (messageId: string, conversationId: string) => unknown; shouldRenderDateHeader: boolean; + onOpenMessageRequestActionsConfirmation(state: MessageRequestState): void; platform: string; renderContact: SmartContactRendererType; renderUniversalTimerNotification: () => JSX.Element; @@ -203,9 +215,11 @@ export const TimelineItem = memo(function TimelineItem({ getPreferredBadge, i18n, id, + isBlocked, isNextItemCallingNotification, isTargeted, item, + onOpenMessageRequestActionsConfirmation, onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, platform, @@ -379,6 +393,17 @@ export const TimelineItem = memo(function TimelineItem({ i18n={i18n} /> ); + } else if (item.type === 'messageRequestResponse') { + notification = ( + + ); } else { // Weird, yes, but the idea is to get a compile error when we aren't comprehensive // with our if/else checks above, but also log out the type we don't understand diff --git a/ts/jobs/helpers/addReportSpamJob.ts b/ts/jobs/helpers/addReportSpamJob.ts index 8dfd8de19f..ea7953ebb7 100644 --- a/ts/jobs/helpers/addReportSpamJob.ts +++ b/ts/jobs/helpers/addReportSpamJob.ts @@ -4,9 +4,9 @@ import { assertDev } from '../../util/assert'; import { isDirectConversation } from '../../util/whatTypeOfConversation'; import * as log from '../../logging/log'; -import type { ConversationAttributesType } from '../../model-types.d'; import { isAciString } from '../../util/isAciString'; import type { reportSpamJobQueue } from '../reportSpamJobQueue'; +import type { ConversationType } from '../../state/ducks/conversations'; export async function addReportSpamJob({ conversation, @@ -14,10 +14,7 @@ export async function addReportSpamJob({ jobQueue, }: Readonly<{ conversation: Readonly< - Pick< - ConversationAttributesType, - 'id' | 'type' | 'serviceId' | 'reportingToken' - > + Pick >; getMessageServerGuidsForSpam: ( conversationId: string diff --git a/ts/model-types.d.ts b/ts/model-types.d.ts index daa735883c..ca8c202401 100644 --- a/ts/model-types.d.ts +++ b/ts/model-types.d.ts @@ -32,6 +32,7 @@ import type { AnyPaymentEvent } from './types/Payment'; import AccessRequiredEnum = Proto.AccessControl.AccessRequired; import MemberRoleEnum = Proto.Member.Role; +import type { MessageRequestResponseEvent } from './types/MessageRequestResponseEvent'; export type LastMessageStatus = | 'paused' @@ -156,6 +157,7 @@ export type MessageAttributesType = { logger?: unknown; message?: unknown; messageTimer?: unknown; + messageRequestResponseEvent?: MessageRequestResponseEvent; profileChange?: ProfileNameChangeType; payment?: AnyPaymentEvent; quote?: QuotedMessageType; @@ -192,7 +194,8 @@ export type MessageAttributesType = { | 'universal-timer-notification' | 'contact-removed-notification' | 'title-transition-notification' - | 'verified-change'; + | 'verified-change' + | 'message-request-response-event'; body?: string; attachments?: Array; preview?: Array; @@ -359,6 +362,7 @@ export type ConversationAttributesType = { draftEditMessage?: DraftEditMessageType; hasPostedStory?: boolean; isArchived?: boolean; + isReported?: boolean; name?: string; systemGivenName?: string; systemFamilyName?: string; diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index 79fe1fa1b5..4747a98f49 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -164,6 +164,7 @@ import { incrementMessageCounter } from '../util/incrementMessageCounter'; import OS from '../util/os/osMain'; import { getMessageAuthorText } from '../util/getMessageAuthorText'; import { downscaleOutgoingAttachment } from '../util/attachments'; +import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent'; /* eslint-disable more/no-then */ window.Whisper = window.Whisper || {}; @@ -2118,8 +2119,38 @@ export class ConversationModel extends window.Backbone } while (messages.length > 0); } + async addMessageRequestResponseEventMessage( + event: MessageRequestResponseEvent + ): Promise { + const now = Date.now(); + const message: MessageAttributesType = { + id: generateGuid(), + conversationId: this.id, + type: 'message-request-response-event', + sent_at: now, + received_at: incrementMessageCounter(), + received_at_ms: now, + readStatus: ReadStatus.Read, + seenStatus: SeenStatus.NotApplicable, + timestamp: now, + messageRequestResponseEvent: event, + }; + + const id = await window.Signal.Data.saveMessage(message, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + forceSave: true, + }); + const model = new window.Whisper.Message({ + ...message, + id, + }); + window.MessageCache.toMessageAttributes(model.attributes); + this.trigger('newmessage', model); + drop(this.updateLastMessage()); + } + async applyMessageRequestResponse( - response: number, + response: Proto.SyncMessage.MessageRequestResponse.Type, { fromSync = false, viaStorageServiceSync = false, shouldSave = true } = {} ): Promise { try { @@ -2130,11 +2161,84 @@ export class ConversationModel extends window.Backbone const didResponseChange = response !== currentMessageRequestState; const wasPreviouslyAccepted = this.getAccepted(); + if (didResponseChange) { + if (response === messageRequestEnum.ACCEPT) { + drop( + this.addMessageRequestResponseEventMessage( + MessageRequestResponseEvent.ACCEPT + ) + ); + } + if ( + response === messageRequestEnum.BLOCK || + response === messageRequestEnum.BLOCK_AND_SPAM || + response === messageRequestEnum.BLOCK_AND_DELETE + ) { + drop( + this.addMessageRequestResponseEventMessage( + MessageRequestResponseEvent.BLOCK + ) + ); + } + if ( + response === messageRequestEnum.SPAM || + response === messageRequestEnum.BLOCK_AND_SPAM + ) { + drop( + this.addMessageRequestResponseEventMessage( + MessageRequestResponseEvent.SPAM + ) + ); + } + } + // Apply message request response locally this.set({ messageRequestResponseType: response, }); + const rejectConversation = async ({ + isBlock = false, + isDelete = false, + isSpam = false, + }: { + isBlock?: boolean; + isDelete?: boolean; + isSpam?: boolean; + }) => { + if (isBlock) { + this.block({ viaStorageServiceSync }); + } + + if (isBlock || isDelete) { + this.disableProfileSharing({ viaStorageServiceSync }); + } + + if (isDelete) { + await this.destroyMessages(); + void this.updateLastMessage(); + } + + if (isBlock || isDelete) { + if (isLocalAction) { + window.reduxActions.conversations.onConversationClosed( + this.id, + isBlock + ? 'blocked from message request' + : 'deleted from message request' + ); + + if (isGroupV2(this.attributes)) { + await this.leaveGroupV2(); + } + } + } + + if (isSpam) { + this.set({ isReported: true }); + } + }; + if (response === messageRequestEnum.ACCEPT) { this.unblock({ viaStorageServiceSync }); if (!viaStorageServiceSync) { @@ -2191,53 +2295,15 @@ export class ConversationModel extends window.Backbone } } } else if (response === messageRequestEnum.BLOCK) { - // Block locally, other devices should block upon receiving the sync message - this.block({ viaStorageServiceSync }); - this.disableProfileSharing({ viaStorageServiceSync }); - - if (isLocalAction) { - if (isGroupV2(this.attributes)) { - await this.leaveGroupV2(); - } - } + await rejectConversation({ isBlock: true }); } else if (response === messageRequestEnum.DELETE) { - this.disableProfileSharing({ viaStorageServiceSync }); - - // Delete messages locally, other devices should delete upon receiving - // the sync message - await this.destroyMessages(); - void this.updateLastMessage(); - - if (isLocalAction) { - window.reduxActions.conversations.onConversationClosed( - this.id, - 'deleted from message request' - ); - - if (isGroupV2(this.attributes)) { - await this.leaveGroupV2(); - } - } + await rejectConversation({ isDelete: true }); } else if (response === messageRequestEnum.BLOCK_AND_DELETE) { - // Block locally, other devices should block upon receiving the sync message - this.block({ viaStorageServiceSync }); - this.disableProfileSharing({ viaStorageServiceSync }); - - // Delete messages locally, other devices should delete upon receiving - // the sync message - await this.destroyMessages(); - void this.updateLastMessage(); - - if (isLocalAction) { - window.reduxActions.conversations.onConversationClosed( - this.id, - 'blocked and deleted from message request' - ); - - if (isGroupV2(this.attributes)) { - await this.leaveGroupV2(); - } - } + await rejectConversation({ isBlock: true, isDelete: true }); + } else if (response === messageRequestEnum.SPAM) { + await rejectConversation({ isSpam: true }); + } else if (response === messageRequestEnum.BLOCK_AND_SPAM) { + await rejectConversation({ isBlock: true, isSpam: true }); } } finally { if (shouldSave) { @@ -2492,40 +2558,6 @@ export class ConversationModel extends window.Backbone } } - async syncMessageRequestResponse( - response: number, - { shouldSave = true } = {} - ): Promise { - // In GroupsV2, this may modify the server. We only want to continue if those - // server updates were successful. - await this.applyMessageRequestResponse(response, { shouldSave }); - - const groupId = this.getGroupIdBuffer(); - - if (window.ConversationController.areWePrimaryDevice()) { - log.warn( - 'syncMessageRequestResponse: We are primary device; not sending message request sync' - ); - return; - } - - try { - await singleProtoJobQueue.add( - MessageSender.getMessageRequestResponseSync({ - threadE164: this.get('e164'), - threadAci: this.getAci(), - groupId, - type: response, - }) - ); - } catch (error) { - log.error( - 'syncMessageRequestResponse: Failed to queue sync message', - Errors.toLogFormat(error) - ); - } - } - async safeGetVerified(): Promise { const serviceId = this.getServiceId(); if (!serviceId) { diff --git a/ts/state/ducks/audioRecorder.ts b/ts/state/ducks/audioRecorder.ts index 8b3ced309a..ef1200c2d6 100644 --- a/ts/state/ducks/audioRecorder.ts +++ b/ts/state/ducks/audioRecorder.ts @@ -23,7 +23,7 @@ import { // State -export type AudioPlayerStateType = ReadonlyDeep<{ +export type AudioRecorderStateType = ReadonlyDeep<{ recordingState: RecordingState; errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType; }>; @@ -211,16 +211,16 @@ function errorRecording( // Reducer -export function getEmptyState(): AudioPlayerStateType { +export function getEmptyState(): AudioRecorderStateType { return { recordingState: RecordingState.Idle, }; } export function reducer( - state: Readonly = getEmptyState(), + state: Readonly = getEmptyState(), action: Readonly -): AudioPlayerStateType { +): AudioRecorderStateType { if (action.type === START_RECORDING) { return { ...state, diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index 01ca37ca05..ee422bb5a2 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -179,6 +179,10 @@ import { import type { ChangeNavTabActionType } from './nav'; import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav'; import { sortByMessageOrder } from '../../types/ForwardDraft'; +import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation'; +import { getConversationIdForLogging } from '../../util/idForLogging'; +import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; +import MessageSender from '../../textsecure/SendMessage'; // State @@ -228,6 +232,10 @@ export type DraftPreviewType = ReadonlyDeep<{ bodyRanges?: HydratedBodyRangesType; }>; +export type ConversationRemovalStage = ReadonlyDeep< + 'justNotification' | 'messageRequest' +>; + export type ConversationType = ReadonlyDeep< { id: string; @@ -265,7 +273,9 @@ export type ConversationType = ReadonlyDeep< hideStory?: boolean; isArchived?: boolean; isBlocked?: boolean; - removalStage?: 'justNotification' | 'messageRequest'; + isReported?: boolean; + reportingToken?: string; + removalStage?: ConversationRemovalStage; isGroupV1AndDisabled?: boolean; isPinned?: boolean; isUntrusted?: boolean; @@ -1026,6 +1036,7 @@ export const actions = { acknowledgeGroupMemberNameCollisions, addMembersToGroup, approvePendingMembershipFromGroupV2, + reportSpam, blockAndReportSpam, blockConversation, blockGroupLinkRequests, @@ -3243,68 +3254,195 @@ function revokePendingMembershipsFromGroupV2( }; } +async function syncMessageRequestResponse( + conversationData: ConversationType, + response: Proto.SyncMessage.MessageRequestResponse.Type, + { shouldSave = true } = {} +): Promise { + const conversation = window.ConversationController.get(conversationData.id); + if (!conversation) { + throw new Error( + `syncMessageRequestResponse: No conversation found for conversation ${conversationData.id}` + ); + } + + // In GroupsV2, this may modify the server. We only want to continue if those + // server updates were successful. + await conversation.applyMessageRequestResponse(response, { shouldSave }); + + const groupId = conversation.getGroupIdBuffer(); + + if (window.ConversationController.areWePrimaryDevice()) { + log.warn( + 'syncMessageRequestResponse: We are primary device; not sending message request sync' + ); + return; + } + + try { + await singleProtoJobQueue.add( + MessageSender.getMessageRequestResponseSync({ + threadE164: conversation.get('e164'), + threadAci: conversation.getAci(), + groupId, + type: response, + }) + ); + } catch (error) { + log.error( + 'syncMessageRequestResponse: Failed to queue sync message', + Errors.toLogFormat(error) + ); + } +} + +function getConversationForReportSpam( + conversation: ConversationType +): ConversationType | null { + if (conversation.type === 'group') { + const addedBy = getAddedByForOurPendingInvitation(conversation); + if (addedBy == null) { + log.error( + `getConversationForReportSpam: No addedBy found for ${conversation.id}` + ); + return null; + } + return addedBy; + } + + return conversation; +} + +function reportSpam( + conversationId: string +): ThunkAction { + return async (dispatch, getState) => { + const conversationSelector = getConversationSelector(getState()); + const conversationOrGroup = conversationSelector(conversationId); + if (!conversationOrGroup) { + log.error( + `reportSpam: Expected a conversation to be found for ${conversationId}. Doing nothing.` + ); + return; + } + + const conversation = getConversationForReportSpam(conversationOrGroup); + if (conversation == null) { + return; + } + + const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; + const idForLogging = getConversationIdForLogging(conversation); + + drop( + longRunningTaskWrapper({ + name: 'reportSpam', + idForLogging, + task: async () => { + await Promise.all([ + syncMessageRequestResponse(conversation, messageRequestEnum.SPAM), + addReportSpamJob({ + conversation, + getMessageServerGuidsForSpam: + window.Signal.Data.getMessageServerGuidsForSpam, + jobQueue: reportSpamJobQueue, + }), + ]); + + dispatch({ + type: SHOW_TOAST, + payload: { + toastType: ToastType.ReportedSpam, + }, + }); + }, + }) + ); + }; +} + function blockAndReportSpam( conversationId: string ): ThunkAction { - return async dispatch => { - const conversation = window.ConversationController.get(conversationId); - if (!conversation) { + return async (dispatch, getState) => { + const conversationSelector = getConversationSelector(getState()); + const conversationOrGroup = conversationSelector(conversationId); + if (!conversationOrGroup) { log.error( `blockAndReportSpam: Expected a conversation to be found for ${conversationId}. Doing nothing.` ); return; } + const conversationForSpam = + getConversationForReportSpam(conversationOrGroup); + const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; - const idForLogging = conversation.idForLogging(); + const idForLogging = getConversationIdForLogging(conversationOrGroup); - void longRunningTaskWrapper({ - name: 'blockAndReportSpam', - idForLogging, - task: async () => { - await Promise.all([ - conversation.syncMessageRequestResponse(messageRequestEnum.BLOCK), - addReportSpamJob({ - conversation: conversation.attributes, - getMessageServerGuidsForSpam: - window.Signal.Data.getMessageServerGuidsForSpam, - jobQueue: reportSpamJobQueue, - }), - ]); + drop( + longRunningTaskWrapper({ + name: 'blockAndReportSpam', + idForLogging, + task: async () => { + await Promise.all([ + syncMessageRequestResponse( + conversationOrGroup, + messageRequestEnum.BLOCK_AND_SPAM + ), + conversationForSpam != null && + addReportSpamJob({ + conversation: conversationForSpam, + getMessageServerGuidsForSpam: + window.Signal.Data.getMessageServerGuidsForSpam, + jobQueue: reportSpamJobQueue, + }), + ]); - dispatch({ - type: SHOW_TOAST, - payload: { - toastType: ToastType.ReportedSpamAndBlocked, - }, - }); - }, - }); + dispatch({ + type: SHOW_TOAST, + payload: { + toastType: ToastType.ReportedSpamAndBlocked, + }, + }); + }, + }) + ); }; } -function acceptConversation(conversationId: string): NoopActionType { - const conversation = window.ConversationController.get(conversationId); - if (!conversation) { - throw new Error( - 'acceptConversation: Expected a conversation to be found. Doing nothing' +function acceptConversation( + conversationId: string +): ThunkAction { + return async (dispatch, getState) => { + const conversationSelector = getConversationSelector(getState()); + const conversationOrGroup = conversationSelector(conversationId); + if (!conversationOrGroup) { + throw new Error( + 'acceptConversation: Expected a conversation to be found. Doing nothing' + ); + } + + const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; + const idForLogging = getConversationIdForLogging(conversationOrGroup); + + drop( + longRunningTaskWrapper({ + name: 'acceptConversation', + idForLogging, + task: async () => { + await syncMessageRequestResponse( + conversationOrGroup, + messageRequestEnum.ACCEPT + ); + }, + }) ); - } - const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; - - void longRunningTaskWrapper({ - name: 'acceptConversation', - idForLogging: conversation.idForLogging(), - task: conversation.syncMessageRequestResponse.bind( - conversation, - messageRequestEnum.ACCEPT - ), - }); - - return { - type: 'NOOP', - payload: null, + dispatch({ + type: 'NOOP', + payload: null, + }); }; } @@ -3329,53 +3467,74 @@ function removeConversation(conversationId: string): ShowToastActionType { }; } -function blockConversation(conversationId: string): NoopActionType { - const conversation = window.ConversationController.get(conversationId); - if (!conversation) { - throw new Error( - 'blockConversation: Expected a conversation to be found. Doing nothing' +function blockConversation( + conversationId: string +): ThunkAction { + return (dispatch, getState) => { + const conversationSelector = getConversationSelector(getState()); + const conversation = conversationSelector(conversationId); + + if (!conversation) { + throw new Error( + 'blockConversation: Expected a conversation to be found. Doing nothing' + ); + } + + const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; + const idForLogging = getConversationIdForLogging(conversation); + + drop( + longRunningTaskWrapper({ + name: 'blockConversation', + idForLogging, + task: async () => { + await syncMessageRequestResponse( + conversation, + messageRequestEnum.BLOCK + ); + }, + }) ); - } - const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; - - void longRunningTaskWrapper({ - name: 'blockConversation', - idForLogging: conversation.idForLogging(), - task: conversation.syncMessageRequestResponse.bind( - conversation, - messageRequestEnum.BLOCK - ), - }); - - return { - type: 'NOOP', - payload: null, + dispatch({ + type: 'NOOP', + payload: null, + }); }; } -function deleteConversation(conversationId: string): NoopActionType { - const conversation = window.ConversationController.get(conversationId); - if (!conversation) { - throw new Error( - 'deleteConversation: Expected a conversation to be found. Doing nothing' +function deleteConversation( + conversationId: string +): ThunkAction { + return (dispatch, getState) => { + const conversationSelector = getConversationSelector(getState()); + const conversation = conversationSelector(conversationId); + if (!conversation) { + throw new Error( + 'deleteConversation: Expected a conversation to be found. Doing nothing' + ); + } + + const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; + const idForLogging = getConversationIdForLogging(conversation); + + drop( + longRunningTaskWrapper({ + name: 'deleteConversation', + idForLogging, + task: async () => { + await syncMessageRequestResponse( + conversation, + messageRequestEnum.DELETE + ); + }, + }) ); - } - const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; - - void longRunningTaskWrapper({ - name: 'deleteConversation', - idForLogging: conversation.idForLogging(), - task: conversation.syncMessageRequestResponse.bind( - conversation, - messageRequestEnum.DELETE - ), - }); - - return { - type: 'NOOP', - payload: null, + dispatch({ + type: 'NOOP', + payload: null, + }); }; } diff --git a/ts/state/ducks/emojis.ts b/ts/state/ducks/emojis.ts index e3fad33e80..f90e295da9 100644 --- a/ts/state/ducks/emojis.ts +++ b/ts/state/ducks/emojis.ts @@ -33,8 +33,9 @@ export const actions = { useEmoji, }; -export const useActions = (): BoundActionCreatorsMapObject => - useBoundActions(actions); +export const useEmojisActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); function onUseEmoji({ shortName, diff --git a/ts/state/ducks/globalModals.ts b/ts/state/ducks/globalModals.ts index dc700bfea4..c156f08b28 100644 --- a/ts/state/ducks/globalModals.ts +++ b/ts/state/ducks/globalModals.ts @@ -42,6 +42,7 @@ import { SHOW_TOAST } from './toast'; import type { ShowToastActionType } from './toast'; import { isDownloaded } from '../../types/Attachment'; import type { ButtonVariant } from '../../components/Button'; +import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation'; // State @@ -58,6 +59,10 @@ export type ForwardMessagesPropsType = ReadonlyDeep<{ messages: Array; onForward?: () => void; }>; +export type MessageRequestActionsConfirmationPropsType = ReadonlyDeep<{ + conversationId: string; + state: MessageRequestState; +}>; export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{ promiseUuid: SingleServePromise.SingleServePromiseIdString; source?: SafetyNumberChangeSource; @@ -101,6 +106,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{ isSignalConnectionsVisible: boolean; isStoriesSettingsVisible: boolean; isWhatsNewVisible: boolean; + messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null; usernameOnboardingState: UsernameOnboardingState; profileEditorHasError: boolean; profileEditorInitialEditState: ProfileEditorEditState | undefined; @@ -144,6 +150,8 @@ const SHOW_STICKER_PACK_PREVIEW = 'globalModals/SHOW_STICKER_PACK_PREVIEW'; const CLOSE_STICKER_PACK_PREVIEW = 'globalModals/CLOSE_STICKER_PACK_PREVIEW'; const CLOSE_ERROR_MODAL = 'globalModals/CLOSE_ERROR_MODAL'; export const SHOW_ERROR_MODAL = 'globalModals/SHOW_ERROR_MODAL'; +const TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION = + 'globalModals/TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION'; const SHOW_FORMATTING_WARNING_MODAL = 'globalModals/SHOW_FORMATTING_WARNING_MODAL'; const SHOW_SEND_EDIT_WARNING_MODAL = @@ -316,6 +324,11 @@ export type ShowErrorModalActionType = ReadonlyDeep<{ }; }>; +type ToggleMessageRequestActionsConfirmationActionType = ReadonlyDeep<{ + type: typeof TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION; + payload: MessageRequestActionsConfirmationPropsType | null; +}>; + type CloseShortcutGuideModalActionType = ReadonlyDeep<{ type: typeof CLOSE_SHORTCUT_GUIDE_MODAL; }>; @@ -373,6 +386,7 @@ export type GlobalModalsActionType = ReadonlyDeep< | ShowContactModalActionType | ShowEditHistoryModalActionType | ShowErrorModalActionType + | ToggleMessageRequestActionsConfirmationActionType | ShowFormattingWarningModalActionType | ShowSendAnywayDialogActionType | ShowSendEditWarningModalActionType @@ -414,6 +428,7 @@ export const actions = { showContactModal, showEditHistoryModal, showErrorModal, + toggleMessageRequestActionsConfirmation, showFormattingWarningModal, showSendEditWarningModal, showGV2MigrationDialog, @@ -750,6 +765,18 @@ function showErrorModal({ }; } +function toggleMessageRequestActionsConfirmation( + payload: { + conversationId: string; + state: MessageRequestState; + } | null +): ToggleMessageRequestActionsConfirmationActionType { + return { + type: TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION, + payload, + }; +} + function closeShortcutGuideModal(): CloseShortcutGuideModalActionType { return { type: CLOSE_SHORTCUT_GUIDE_MODAL, @@ -908,6 +935,7 @@ export function getEmptyState(): GlobalModalsStateType { usernameOnboardingState: UsernameOnboardingState.NeverShown, profileEditorHasError: false, profileEditorInitialEditState: undefined, + messageRequestActionsConfirmationProps: null, }; } @@ -1132,6 +1160,13 @@ export function reducer( }; } + if (action.type === TOGGLE_MESSAGE_REQUEST_ACTIONS_CONFIRMATION) { + return { + ...state, + messageRequestActionsConfirmationProps: action.payload, + }; + } + if (action.type === CLOSE_SHORTCUT_GUIDE_MODAL) { return { ...state, diff --git a/ts/state/ducks/preferredReactions.ts b/ts/state/ducks/preferredReactions.ts index dfc1758bf8..c4f08ebfae 100644 --- a/ts/state/ducks/preferredReactions.ts +++ b/ts/state/ducks/preferredReactions.ts @@ -101,8 +101,9 @@ export const actions = { selectDraftEmojiToBeReplaced, }; -export const useActions = (): BoundActionCreatorsMapObject => - useBoundActions(actions); +export const usePreferredReactionsActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); function cancelCustomizePreferredReactionsModal(): CancelCustomizePreferredReactionsModalActionType { return { type: CANCEL_CUSTOMIZE_PREFERRED_REACTIONS_MODAL }; diff --git a/ts/state/ducks/stickers.ts b/ts/state/ducks/stickers.ts index 86ff7d158c..f1549b4cc4 100644 --- a/ts/state/ducks/stickers.ts +++ b/ts/state/ducks/stickers.ts @@ -22,6 +22,8 @@ import { ERASE_STORAGE_SERVICE } from './user'; import type { EraseStorageServiceStateAction } from './user'; import type { NoopActionType } from './noop'; +import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions'; +import { useBoundActions } from '../../hooks/useBoundActions'; const { getRecentStickers, updateStickerLastUsed } = dataInterface; @@ -154,6 +156,10 @@ export const actions = { useSticker, }; +export const useStickersActions = (): BoundActionCreatorsMapObject< + typeof actions +> => useBoundActions(actions); + function removeStickerPack(id: string): StickerPackRemovedAction { return { type: 'stickers/REMOVE_STICKER_PACK', diff --git a/ts/state/selectors/audioRecorder.ts b/ts/state/selectors/audioRecorder.ts new file mode 100644 index 0000000000..737c2d285a --- /dev/null +++ b/ts/state/selectors/audioRecorder.ts @@ -0,0 +1,23 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import { createSelector } from 'reselect'; +import type { StateType } from '../reducer'; +import type { AudioRecorderStateType } from '../ducks/audioRecorder'; + +export function getAudioRecorder(state: StateType): AudioRecorderStateType { + return state.audioRecorder; +} + +export const getErrorDialogAudioRecorderType = createSelector( + getAudioRecorder, + audioRecorder => { + return audioRecorder.errorDialogAudioRecorderType; + } +); + +export const getRecordingState = createSelector( + getAudioRecorder, + audioRecorder => { + return audioRecorder.recordingState; + } +); diff --git a/ts/state/selectors/items.ts b/ts/state/selectors/items.ts index 3ae9bb1fe7..4bd4b2dbe5 100644 --- a/ts/state/selectors/items.ts +++ b/ts/state/selectors/items.ts @@ -228,3 +228,17 @@ export const getNavTabsCollapsed = createSelector( getItems, (state: ItemsStateType): boolean => Boolean(state.navTabsCollapsed ?? false) ); + +export const getShowStickersIntroduction = createSelector( + getItems, + (state: ItemsStateType): boolean => { + return state.showStickersIntroduction ?? false; + } +); + +export const getShowStickerPickerHint = createSelector( + getItems, + (state: ItemsStateType): boolean => { + return state.showStickerPickerHint ?? false; + } +); diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index d01326d3a8..2432970346 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -140,6 +140,7 @@ import { CallMode } from '../../types/Calling'; import { CallDirection } from '../../types/CallDisposition'; import { getCallIdFromEra } from '../../util/callDisposition'; import { LONG_MESSAGE } from '../../types/MIME'; +import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification'; export { isIncoming, isOutgoing, isStory }; @@ -971,6 +972,14 @@ export function getPropsForBubble( }; } + if (isMessageRequestResponse(message)) { + return { + type: 'messageRequestResponse', + data: getPropsForMessageRequestResponse(message), + timestamp, + }; + } + const data = getPropsForMessage(message, options); return { @@ -1461,6 +1470,24 @@ function getPropsForProfileChange( } as ProfileChangeNotificationPropsType; } +// Message Request Response Event + +export function isMessageRequestResponse( + message: MessageAttributesType +): boolean { + return message.type === 'message-request-response-event'; +} + +function getPropsForMessageRequestResponse( + message: MessageAttributesType +): MessageRequestResponseNotificationData { + const { messageRequestResponseEvent } = message; + if (!messageRequestResponseEvent) { + throw new Error('getPropsForMessageRequestResponse: event is missing!'); + } + return { messageRequestResponseEvent }; +} + // Universal Timer Notification // Note: smart, so props not generated here diff --git a/ts/state/smart/CompositionArea.tsx b/ts/state/smart/CompositionArea.tsx index c85a231511..3b990580b6 100644 --- a/ts/state/smart/CompositionArea.tsx +++ b/ts/state/smart/CompositionArea.tsx @@ -1,35 +1,27 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; -import { connect } from 'react-redux'; -import { get } from 'lodash'; - -import { mapDispatchToProps } from '../actions'; -import type { Props as ComponentPropsType } from '../../components/CompositionArea'; +import React, { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; import { CompositionArea } from '../../components/CompositionArea'; -import type { StateType } from '../reducer'; +import { useContactNameData } from '../../components/conversation/ContactName'; import type { DraftBodyRanges, HydratedBodyRangesType, } from '../../types/BodyRange'; -import { isConversationSMSOnly } from '../../util/isConversationSMSOnly'; -import { dropNull } from '../../util/dropNull'; +import { hydrateRanges } from '../../types/BodyRange'; +import { strictAssert } from '../../util/assert'; +import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation'; import { imageToBlurHash } from '../../util/imageToBlurHash'; - +import { isConversationSMSOnly } from '../../util/isConversationSMSOnly'; +import { isSignalConversation } from '../../util/isSignalConversation'; +import type { StateType } from '../reducer'; +import { + getErrorDialogAudioRecorderType, + getRecordingState, +} from '../selectors/audioRecorder'; import { getPreferredBadgeSelector } from '../selectors/badges'; -import { selectRecentEmojis } from '../selectors/emojis'; -import { - getIntl, - getPlatform, - getTheme, - getUserConversationId, -} from '../selectors/user'; -import { - getDefaultConversationColor, - getEmojiSkinTone, - getTextFormattingEnabled, -} from '../selectors/items'; +import { getComposerStateForConversationIdSelector } from '../selectors/composer'; import { getConversationSelector, getGroupAdminsSelector, @@ -38,71 +30,88 @@ import { getSelectedMessageIds, isMissingRequiredProfileSharing, } from '../selectors/conversations'; +import { selectRecentEmojis } from '../selectors/emojis'; +import { + getDefaultConversationColor, + getEmojiSkinTone, + getShowStickerPickerHint, + getShowStickersIntroduction, + getTextFormattingEnabled, +} from '../selectors/items'; import { getPropsForQuote } from '../selectors/message'; import { getBlessedStickerPacks, getInstalledStickerPacks, getKnownStickerPacks, getReceivedStickerPacks, - getRecentlyInstalledStickerPack, getRecentStickers, + getRecentlyInstalledStickerPack, } from '../selectors/stickers'; -import { isSignalConversation } from '../../util/isSignalConversation'; -import { getComposerStateForConversationIdSelector } from '../selectors/composer'; +import { + getIntl, + getPlatform, + getTheme, + getUserConversationId, +} from '../selectors/user'; import type { SmartCompositionRecordingProps } from './CompositionRecording'; import { SmartCompositionRecording } from './CompositionRecording'; import type { SmartCompositionRecordingDraftProps } from './CompositionRecordingDraft'; import { SmartCompositionRecordingDraft } from './CompositionRecordingDraft'; -import { hydrateRanges } from '../../types/BodyRange'; +import { useItemsActions } from '../ducks/items'; +import { useComposerActions } from '../ducks/composer'; +import { useConversationsActions } from '../ducks/conversations'; +import { useAudioRecorderActions } from '../ducks/audioRecorder'; +import { useEmojisActions } from '../ducks/emojis'; +import { useGlobalModalActions } from '../ducks/globalModals'; +import { useStickersActions } from '../ducks/stickers'; +import { useToastActions } from '../ducks/toast'; -type ExternalProps = { - id: string; -}; +function renderSmartCompositionRecording( + recProps: SmartCompositionRecordingProps +) { + return ; +} -export type CompositionAreaPropsType = ExternalProps & ComponentPropsType; +function renderSmartCompositionRecordingDraft( + draftProps: SmartCompositionRecordingDraftProps +) { + return ; +} -const mapStateToProps = (state: StateType, props: ExternalProps) => { - const { id } = props; - const platform = getPlatform(state); - - const shouldHidePopovers = getHasPanelOpen(state); - - const conversationSelector = getConversationSelector(state); +export function SmartCompositionArea({ id }: { id: string }): JSX.Element { + const conversationSelector = useSelector(getConversationSelector); const conversation = conversationSelector(id); - if (!conversation) { - throw new Error(`Conversation id ${id} not found!`); - } + strictAssert(conversation, `Conversation id ${id} not found!`); - const { - announcementsOnly, - areWeAdmin, - draftEditMessage, - draftText, - draftBodyRanges, - } = conversation; - - const receivedPacks = getReceivedStickerPacks(state); - const installedPacks = getInstalledStickerPacks(state); - const blessedPacks = getBlessedStickerPacks(state); - const knownPacks = getKnownStickerPacks(state); - - const installedPack = getRecentlyInstalledStickerPack(state); - - const recentStickers = getRecentStickers(state); - const showIntroduction = get( - state.items, - ['showStickersIntroduction'], - false + const i18n = useSelector(getIntl); + const theme = useSelector(getTheme); + const skinTone = useSelector(getEmojiSkinTone); + const recentEmojis = useSelector(selectRecentEmojis); + const selectedMessageIds = useSelector(getSelectedMessageIds); + const isFormattingEnabled = useSelector(getTextFormattingEnabled); + const lastEditableMessageId = useSelector(getLastEditableMessageId); + const receivedPacks = useSelector(getReceivedStickerPacks); + const installedPacks = useSelector(getInstalledStickerPacks); + const blessedPacks = useSelector(getBlessedStickerPacks); + const knownPacks = useSelector(getKnownStickerPacks); + const platform = useSelector(getPlatform); + const shouldHidePopovers = useSelector(getHasPanelOpen); + const installedPack = useSelector(getRecentlyInstalledStickerPack); + const recentStickers = useSelector(getRecentStickers); + const showStickersIntroduction = useSelector(getShowStickersIntroduction); + const showStickerPickerHint = useSelector(getShowStickerPickerHint); + const recordingState = useSelector(getRecordingState); + const errorDialogAudioRecorderType = useSelector( + getErrorDialogAudioRecorderType ); - const showPickerHint = Boolean( - get(state.items, ['showStickerPickerHint'], false) && - receivedPacks.length > 0 + const getGroupAdmins = useSelector(getGroupAdminsSelector); + const getPreferredBadge = useSelector(getPreferredBadgeSelector); + const composerStateForConversationIdSelector = useSelector( + getComposerStateForConversationIdSelector ); - - const composerStateForConversationIdSelector = - getComposerStateForConversationIdSelector(state); - const composerState = composerStateForConversationIdSelector(id); + const { announcementsOnly, areWeAdmin, draftEditMessage, draftBodyRanges } = + conversation; const { attachments: draftAttachments, focusCounter, @@ -114,6 +123,34 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { shouldSendHighQualityAttachments, } = composerState; + const groupAdmins = useMemo(() => { + return getGroupAdmins(id); + }, [getGroupAdmins, id]); + + const addedBy = useMemo(() => { + if (conversation.type === 'group') { + return getAddedByForOurPendingInvitation(conversation); + } + return null; + }, [conversation]); + + const conversationName = useContactNameData(conversation); + strictAssert(conversationName, 'conversationName is required'); + const addedByName = useContactNameData(addedBy); + + const hydratedDraftBodyRanges = useMemo(() => { + return hydrateRanges(draftBodyRanges, conversationSelector); + }, [conversationSelector, draftBodyRanges]); + + const convertDraftBodyRangesIntoHydrated = useCallback( + ( + bodyRanges: DraftBodyRanges | undefined + ): HydratedBodyRangesType | undefined => { + return hydrateRanges(bodyRanges, conversationSelector); + }, + [conversationSelector] + ); + let { quotedMessage } = composerState; if (!quotedMessage && draftEditMessage?.quote) { quotedMessage = { @@ -122,117 +159,189 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { }; } - const recentEmojis = selectRecentEmojis(state); - - const selectedMessageIds = getSelectedMessageIds(state); - - const isFormattingEnabled = getTextFormattingEnabled(state); - - const lastEditableMessageId = getLastEditableMessageId(state); - - const convertDraftBodyRangesIntoHydrated = ( - bodyRanges: DraftBodyRanges | undefined - ): HydratedBodyRangesType | undefined => { - return hydrateRanges(bodyRanges, conversationSelector); - }; - - return { - // Base - conversationId: id, - draftEditMessage, - focusCounter, - getPreferredBadge: getPreferredBadgeSelector(state), - i18n: getIntl(state), - isDisabled, - isFormattingEnabled, - lastEditableMessageId, - messageCompositionId, - platform, - sendCounter, - shouldHidePopovers, - theme: getTheme(state), - convertDraftBodyRangesIntoHydrated, - - // AudioCapture - errorDialogAudioRecorderType: - state.audioRecorder.errorDialogAudioRecorderType, - recordingState: state.audioRecorder.recordingState, - // AttachmentsList - draftAttachments, - // MediaEditor - imageToBlurHash, - // MediaQualitySelector - shouldSendHighQualityAttachments: - shouldSendHighQualityAttachments !== undefined - ? shouldSendHighQualityAttachments - : window.storage.get('sent-media-quality') === 'high', - // StagedLinkPreview - linkPreviewLoading, - linkPreviewResult, - // Quote - quotedMessageId: quotedMessage?.quote?.messageId, - quotedMessageProps: quotedMessage + const quotedMessageProps = useSelector((state: StateType) => { + return quotedMessage ? getPropsForQuote(quotedMessage, { conversationSelector, ourConversationId: getUserConversationId(state), defaultConversationColor: getDefaultConversationColor(state), }) - : undefined, - quotedMessageAuthorAci: quotedMessage?.quote?.authorAci, - quotedMessageSentAt: quotedMessage?.quote?.id, - // Emojis - recentEmojis, - skinTone: getEmojiSkinTone(state), - // Stickers - receivedPacks, - installedPack, - blessedPacks, - knownPacks, - installedPacks, - recentStickers, - showIntroduction, - showPickerHint, - // Message Requests - ...conversation, - conversationType: conversation.type, - isSMSOnly: Boolean(isConversationSMSOnly(conversation)), - isSignalConversation: isSignalConversation(conversation), - isFetchingUUID: conversation.isFetchingUUID, - isMissingMandatoryProfileSharing: - isMissingRequiredProfileSharing(conversation), - // Groups - announcementsOnly, - areWeAdmin, - groupAdmins: getGroupAdminsSelector(state)(conversation.id), + : undefined; + }); - draftText: dropNull(draftText), - draftBodyRanges: hydrateRanges(draftBodyRanges, conversationSelector), - renderSmartCompositionRecording: ( - recProps: SmartCompositionRecordingProps - ) => { - return ; - }, - renderSmartCompositionRecordingDraft: ( - draftProps: SmartCompositionRecordingDraftProps - ) => { - return ; + const { putItem, removeItem } = useItemsActions(); + + const onSetSkinTone = useCallback( + (tone: number) => { + putItem('skinTone', tone); }, + [putItem] + ); - // Select Mode - selectedMessageIds, - }; -}; + const clearShowIntroduction = useCallback(() => { + removeItem('showStickersIntroduction'); + }, [removeItem]); -const dispatchPropsMap = { - ...mapDispatchToProps, - onSetSkinTone: (tone: number) => mapDispatchToProps.putItem('skinTone', tone), - clearShowIntroduction: () => - mapDispatchToProps.removeItem('showStickersIntroduction'), - clearShowPickerHint: () => - mapDispatchToProps.removeItem('showStickerPickerHint'), - onPickEmoji: mapDispatchToProps.onUseEmoji, -}; + const clearShowPickerHint = useCallback(() => { + removeItem('showStickerPickerHint'); + }, [removeItem]); -const smart = connect(mapStateToProps, dispatchPropsMap); + const { + onTextTooLong, + onCloseLinkPreview, + addAttachment, + removeAttachment, + onClearAttachments, + processAttachments, + setMediaQualitySetting, + setQuoteByMessageId, + cancelJoinRequest, + sendStickerMessage, + sendEditedMessage, + sendMultiMediaMessage, + setComposerFocus, + } = useComposerActions(); + const { + pushPanelForConversation, + discardEditMessage, + acceptConversation, + blockAndReportSpam, + blockConversation, + reportSpam, + deleteConversation, + toggleSelectMode, + scrollToMessage, + setMessageToEdit, + showConversation, + } = useConversationsActions(); + const { cancelRecording, completeRecording, startRecording, errorRecording } = + useAudioRecorderActions(); + const { onUseEmoji } = useEmojisActions(); + const { showGV2MigrationDialog, toggleForwardMessagesModal } = + useGlobalModalActions(); + const { clearInstalledStickerPack } = useStickersActions(); + const { showToast } = useToastActions(); -export const SmartCompositionArea = smart(CompositionArea); + return ( + + ); +} diff --git a/ts/state/smart/CompositionTextArea.tsx b/ts/state/smart/CompositionTextArea.tsx index 53fd1eedef..add9959d49 100644 --- a/ts/state/smart/CompositionTextArea.tsx +++ b/ts/state/smart/CompositionTextArea.tsx @@ -6,7 +6,7 @@ import { useSelector } from 'react-redux'; import type { CompositionTextAreaProps } from '../../components/CompositionTextArea'; import { CompositionTextArea } from '../../components/CompositionTextArea'; import { getIntl, getPlatform } from '../selectors/user'; -import { useActions as useEmojiActions } from '../ducks/emojis'; +import { useEmojisActions as useEmojiActions } from '../ducks/emojis'; import { useItemsActions } from '../ducks/items'; import { getPreferredBadgeSelector } from '../selectors/badges'; import { useComposerActions } from '../ducks/composer'; diff --git a/ts/state/smart/ContactSpoofingReviewDialog.tsx b/ts/state/smart/ContactSpoofingReviewDialog.tsx index 443b2a4df3..b8b22d6f62 100644 --- a/ts/state/smart/ContactSpoofingReviewDialog.tsx +++ b/ts/state/smart/ContactSpoofingReviewDialog.tsx @@ -44,6 +44,7 @@ export function SmartContactSpoofingReviewDialog( const { acceptConversation, + reportSpam, blockAndReportSpam, blockConversation, deleteConversation, @@ -74,6 +75,7 @@ export function SmartContactSpoofingReviewDialog( const sharedProps = { ...props, acceptConversation, + reportSpam, blockAndReportSpam, blockConversation, deleteConversation, diff --git a/ts/state/smart/ConversationHeader.tsx b/ts/state/smart/ConversationHeader.tsx index 8f58178875..9a72002738 100644 --- a/ts/state/smart/ConversationHeader.tsx +++ b/ts/state/smart/ConversationHeader.tsx @@ -1,7 +1,7 @@ // Copyright 2020 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React from 'react'; +import React, { useMemo } from 'react'; import { useSelector } from 'react-redux'; import { pick } from 'lodash'; import type { ConversationType } from '../ducks/conversations'; @@ -37,6 +37,8 @@ import { useStoriesActions } from '../ducks/stories'; import { getCannotLeaveBecauseYouAreLastAdmin } from '../../components/conversation/conversation-details/ConversationDetails'; import { getGroupMemberships } from '../../util/getGroupMemberships'; import { isGroupOrAdhocCallState } from '../../util/isGroupOrAdhocCall'; +import { useContactNameData } from '../../components/conversation/ContactName'; +import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation'; export type OwnProps = { id: string; @@ -108,6 +110,11 @@ export function SmartConversationHeader({ id }: OwnProps): JSX.Element { setMuteExpiration, setPinned, toggleSelectMode, + acceptConversation, + blockAndReportSpam, + blockConversation, + reportSpam, + deleteConversation, } = useConversationsActions(); const { onOutgoingAudioCallInConversation, @@ -129,6 +136,17 @@ export function SmartConversationHeader({ id }: OwnProps): JSX.Element { const selectedMessageIds = useSelector(getSelectedMessageIds); const isSelectMode = selectedMessageIds != null; + const addedBy = useMemo(() => { + if (conversation.type === 'group') { + return getAddedByForOurPendingInvitation(conversation); + } + return null; + }, [conversation]); + + const addedByName = useContactNameData(addedBy); + const conversationName = useContactNameData(conversation); + strictAssert(conversationName, 'conversationName is required'); + return ( ); } diff --git a/ts/state/smart/CustomizingPreferredReactionsModal.tsx b/ts/state/smart/CustomizingPreferredReactionsModal.tsx index 8d9ddaad82..6bf2b01b79 100644 --- a/ts/state/smart/CustomizingPreferredReactionsModal.tsx +++ b/ts/state/smart/CustomizingPreferredReactionsModal.tsx @@ -6,7 +6,7 @@ import { useSelector } from 'react-redux'; import type { StateType } from '../reducer'; import type { LocalizerType } from '../../types/Util'; -import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions'; +import { usePreferredReactionsActions } from '../ducks/preferredReactions'; import { useItemsActions } from '../ducks/items'; import { getIntl } from '../selectors/user'; import { getEmojiSkinTone } from '../selectors/items'; diff --git a/ts/state/smart/EmojiPicker.tsx b/ts/state/smart/EmojiPicker.tsx index c9e56a265f..5cdcec3af5 100644 --- a/ts/state/smart/EmojiPicker.tsx +++ b/ts/state/smart/EmojiPicker.tsx @@ -5,7 +5,7 @@ import * as React from 'react'; import { useSelector } from 'react-redux'; import type { StateType } from '../reducer'; import { useRecentEmojis } from '../selectors/emojis'; -import { useActions as useEmojiActions } from '../ducks/emojis'; +import { useEmojisActions as useEmojiActions } from '../ducks/emojis'; import type { Props as EmojiPickerProps } from '../../components/emoji/EmojiPicker'; import { EmojiPicker } from '../../components/emoji/EmojiPicker'; diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index 729faea044..add27b0515 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -25,6 +25,7 @@ import { getConversationsStoppingSend } from '../selectors/conversations'; import { getIntl, getTheme } from '../selectors/user'; import { useGlobalModalActions } from '../ducks/globalModals'; import { SmartDeleteMessagesModal } from './DeleteMessagesModal'; +import { SmartMessageRequestActionsConfirmation } from './MessageRequestActionsConfirmation'; function renderEditHistoryMessagesModal(): JSX.Element { return ; @@ -50,6 +51,10 @@ function renderForwardMessagesModal(): JSX.Element { return ; } +function renderMessageRequestActionsConfirmation(): JSX.Element { + return ; +} + function renderStoriesSettings(): JSX.Element { return ; } @@ -83,6 +88,7 @@ export function SmartGlobalModalContainer(): JSX.Element { errorModalProps, formattingWarningData, forwardMessagesProps, + messageRequestActionsConfirmationProps, isAuthorizingArtCreator, isProfileEditorVisible, isShortcutGuideModalVisible, @@ -163,6 +169,9 @@ export function SmartGlobalModalContainer(): JSX.Element { deleteMessagesProps={deleteMessagesProps} formattingWarningData={formattingWarningData} forwardMessagesProps={forwardMessagesProps} + messageRequestActionsConfirmationProps={ + messageRequestActionsConfirmationProps + } hasSafetyNumberChangeModal={hasSafetyNumberChangeModal} hideUserNotFoundModal={hideUserNotFoundModal} hideWhatsNewModal={hideWhatsNewModal} @@ -180,6 +189,9 @@ export function SmartGlobalModalContainer(): JSX.Element { renderErrorModal={renderErrorModal} renderDeleteMessagesModal={renderDeleteMessagesModal} renderForwardMessagesModal={renderForwardMessagesModal} + renderMessageRequestActionsConfirmation={ + renderMessageRequestActionsConfirmation + } renderProfileEditor={renderProfileEditor} renderUsernameOnboarding={renderUsernameOnboarding} renderSafetyNumber={renderSafetyNumber} diff --git a/ts/state/smart/MessageRequestActionsConfirmation.tsx b/ts/state/smart/MessageRequestActionsConfirmation.tsx new file mode 100644 index 0000000000..ad3f4d1b4f --- /dev/null +++ b/ts/state/smart/MessageRequestActionsConfirmation.tsx @@ -0,0 +1,84 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { useCallback, useMemo } from 'react'; +import { useSelector } from 'react-redux'; + +import { getIntl } from '../selectors/user'; +import { getGlobalModalsState } from '../selectors/globalModals'; +import { getConversationSelector } from '../selectors/conversations'; +import { useConversationsActions } from '../ducks/conversations'; +import { + MessageRequestActionsConfirmation, + MessageRequestState, +} from '../../components/conversation/MessageRequestActionsConfirmation'; +import { useContactNameData } from '../../components/conversation/ContactName'; +import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation'; +import { strictAssert } from '../../util/assert'; +import { useGlobalModalActions } from '../ducks/globalModals'; + +export function SmartMessageRequestActionsConfirmation(): JSX.Element | null { + const i18n = useSelector(getIntl); + const globalModals = useSelector(getGlobalModalsState); + const { messageRequestActionsConfirmationProps } = globalModals; + strictAssert( + messageRequestActionsConfirmationProps, + 'messageRequestActionsConfirmationProps are required' + ); + const { conversationId, state } = messageRequestActionsConfirmationProps; + strictAssert(state !== MessageRequestState.default, 'state is required'); + const getConversation = useSelector(getConversationSelector); + const conversation = getConversation(conversationId); + const addedBy = useMemo(() => { + if (conversation.type === 'group') { + return getAddedByForOurPendingInvitation(conversation); + } + return null; + }, [conversation]); + + const conversationName = useContactNameData(conversation); + strictAssert(conversationName, 'conversationName is required'); + const addedByName = useContactNameData(addedBy); + + const { + acceptConversation, + blockConversation, + reportSpam, + blockAndReportSpam, + deleteConversation, + } = useConversationsActions(); + const { toggleMessageRequestActionsConfirmation } = useGlobalModalActions(); + + const handleChangeState = useCallback( + (nextState: MessageRequestState) => { + if (nextState === MessageRequestState.default) { + toggleMessageRequestActionsConfirmation(null); + } else { + toggleMessageRequestActionsConfirmation({ + conversationId, + state: nextState, + }); + } + }, + [conversationId, toggleMessageRequestActionsConfirmation] + ); + + return ( + + ); +} diff --git a/ts/state/smart/ReactionPicker.tsx b/ts/state/smart/ReactionPicker.tsx index 8c3d9b33ac..7ccf07dff3 100644 --- a/ts/state/smart/ReactionPicker.tsx +++ b/ts/state/smart/ReactionPicker.tsx @@ -4,7 +4,7 @@ import * as React from 'react'; import { useSelector } from 'react-redux'; import type { StateType } from '../reducer'; -import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions'; +import { usePreferredReactionsActions } from '../ducks/preferredReactions'; import { useItemsActions } from '../ducks/items'; import { getIntl } from '../selectors/user'; diff --git a/ts/state/smart/StoryCreator.tsx b/ts/state/smart/StoryCreator.tsx index 5b425e5c19..b380bc1581 100644 --- a/ts/state/smart/StoryCreator.tsx +++ b/ts/state/smart/StoryCreator.tsx @@ -32,7 +32,7 @@ import { } from '../selectors/items'; import { imageToBlurHash } from '../../util/imageToBlurHash'; import { processAttachment } from '../../util/processAttachment'; -import { useActions as useEmojisActions } from '../ducks/emojis'; +import { useEmojisActions } from '../ducks/emojis'; import { useAudioPlayerActions } from '../ducks/audioPlayer'; import { useComposerActions } from '../ducks/composer'; import { useConversationsActions } from '../ducks/conversations'; @@ -148,6 +148,7 @@ export function SmartStoryCreator(): JSX.Element | null { sendStoryModalOpenStateChanged={sendStoryModalOpenStateChanged} setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections} signalConnections={signalConnections} + sortedGroupMembers={null} skinTone={skinTone} theme={ThemeType.dark} toggleGroupsForStorySend={toggleGroupsForStorySend} diff --git a/ts/state/smart/StoryViewer.tsx b/ts/state/smart/StoryViewer.tsx index 3b7bd339ba..e8a43632c9 100644 --- a/ts/state/smart/StoryViewer.tsx +++ b/ts/state/smart/StoryViewer.tsx @@ -32,7 +32,7 @@ import { isSignalConversation } from '../../util/isSignalConversation'; import { renderEmojiPicker } from './renderEmojiPicker'; import { strictAssert } from '../../util/assert'; import { asyncShouldNeverBeCalled } from '../../util/shouldNeverBeCalled'; -import { useActions as useEmojisActions } from '../ducks/emojis'; +import { useEmojisActions } from '../ducks/emojis'; import { useConversationsActions } from '../ducks/conversations'; import { useRecentEmojis } from '../selectors/emojis'; import { useItemsActions } from '../ducks/items'; diff --git a/ts/state/smart/Timeline.tsx b/ts/state/smart/Timeline.tsx index c07b7896a4..04a29264d2 100644 --- a/ts/state/smart/Timeline.tsx +++ b/ts/state/smart/Timeline.tsx @@ -50,6 +50,7 @@ function renderItem({ containerElementRef, containerWidthBreakpoint, conversationId, + isBlocked, isOldestTimelineItem, messageId, nextMessageId, @@ -61,6 +62,7 @@ function renderItem({ containerElementRef={containerElementRef} containerWidthBreakpoint={containerWidthBreakpoint} conversationId={conversationId} + isBlocked={isBlocked} isOldestTimelineItem={isOldestTimelineItem} messageId={messageId} previousMessageId={previousMessageId} @@ -163,6 +165,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => { 'isGroupV1AndDisabled', 'typingContactIdTimestamps', ]), + isBlocked: conversation.isBlocked ?? false, isConversationSelected: state.conversations.selectedConversationId === id, isIncomingMessageRequest: Boolean( !conversation.acceptedMessageRequest && diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx index 1989eadb3b..0b10e008a1 100644 --- a/ts/state/smart/TimelineItem.tsx +++ b/ts/state/smart/TimelineItem.tsx @@ -2,7 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { RefObject } from 'react'; -import React from 'react'; +import React, { useCallback } from 'react'; import { useSelector } from 'react-redux'; import { TimelineItem } from '../../components/conversation/TimelineItem'; @@ -35,11 +35,13 @@ import { isSameDay } from '../../util/timestamp'; import { renderAudioAttachment } from './renderAudioAttachment'; import { renderEmojiPicker } from './renderEmojiPicker'; import { renderReactionPicker } from './renderReactionPicker'; +import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation'; export type SmartTimelineItemProps = { containerElementRef: RefObject; containerWidthBreakpoint: WidthBreakpoint; conversationId: string; + isBlocked: boolean; isOldestTimelineItem: boolean; messageId: string; nextMessageId: undefined | string; @@ -59,6 +61,7 @@ export function SmartTimelineItem(props: SmartTimelineItemProps): JSX.Element { containerElementRef, containerWidthBreakpoint, conversationId, + isBlocked, isOldestTimelineItem, messageId, nextMessageId, @@ -136,23 +139,27 @@ export function SmartTimelineItem(props: SmartTimelineItemProps): JSX.Element { const { showContactModal, showEditHistoryModal, + toggleMessageRequestActionsConfirmation, toggleDeleteMessagesModal, toggleForwardMessagesModal, toggleSafetyNumberModal, } = useGlobalModalActions(); - const { checkForAccount } = useAccountsActions(); - const { showLightbox, showLightboxForViewOnceMedia } = useLightboxActions(); - const { viewStory } = useStoriesActions(); - const { onOutgoingAudioCallInConversation, onOutgoingVideoCallInConversation, returnToActiveCall, } = useCallingActions(); + const onOpenMessageRequestActionsConfirmation = useCallback( + (state: MessageRequestState) => { + toggleMessageRequestActionsConfirmation({ conversationId, state }); + }, + [conversationId, toggleMessageRequestActionsConfirmation] + ); + return ( => { const item = leftPane .locator( '.module-conversation-list__item--contact-or-conversation' + - `>> text=${LAST_MESSAGE}` + '>> text="You accepted the message request"' ) .first(); await item.click({ timeout: 2 * MINUTE }); diff --git a/ts/test-mock/helpers.ts b/ts/test-mock/helpers.ts index ed8ed68101..395a5ef72f 100644 --- a/ts/test-mock/helpers.ts +++ b/ts/test-mock/helpers.ts @@ -1,7 +1,8 @@ // Copyright 2023 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import type { Locator } from 'playwright'; +import { assert } from 'chai'; +import type { Locator, Page } from 'playwright'; export function bufferToUuid(buffer: Buffer): string { const hex = buffer.toString('hex'); @@ -32,3 +33,44 @@ export async function type(input: Locator, text: string): Promise { // updated with the right value await input.locator(`:text("${currentValue}${text}")`).waitFor(); } + +export async function expectItemsWithText( + items: Locator, + expected: ReadonlyArray +): Promise { + // Wait for each message to appear in case they're not all there yet + for (const [index, message] of expected.entries()) { + const nth = items.nth(index); + // eslint-disable-next-line no-await-in-loop + await nth.waitFor(); + // eslint-disable-next-line no-await-in-loop + const text = await nth.innerText(); + const log = `Expect item at index ${index} to match`; + if (typeof message === 'string') { + assert.strictEqual(text, message, log); + } else { + assert.match(text, message, log); + } + } + + const innerTexts = await items.allInnerTexts(); + assert.deepEqual( + innerTexts.length, + expected.length, + `Expect correct number of items\nActual:\n${innerTexts + .map(text => ` - "${text}"\n`) + .join('')}\nExpected:\n${expected + .map(text => ` - ${text.toString()}\n`) + .join('')}` + ); +} + +export async function expectSystemMessages( + context: Page | Locator, + expected: ReadonlyArray +): Promise { + await expectItemsWithText( + context.locator('.SystemMessage__contents'), + expected + ); +} diff --git a/ts/test-mock/pnp/accept_gv2_invite_test.ts b/ts/test-mock/pnp/accept_gv2_invite_test.ts index 9fe4b84737..b05a8933db 100644 --- a/ts/test-mock/pnp/accept_gv2_invite_test.ts +++ b/ts/test-mock/pnp/accept_gv2_invite_test.ts @@ -13,6 +13,7 @@ import { } from '../../util/libphonenumberInstance'; import { Bootstrap } from '../bootstrap'; import type { App } from '../bootstrap'; +import { expectSystemMessages } from '../helpers'; export const debug = createDebug('mock:test:gv2'); @@ -114,11 +115,13 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { debug('Checking that notifications are present'); await window - .locator(`"${first.profileName} invited you to the group."`) + .locator( + `.SystemMessage:has-text("${first.profileName} invited you to the group.")` + ) .waitFor(); await window .locator( - `"You accepted an invitation to the group from ${first.profileName}."` + `.SystemMessage:has-text("You accepted an invitation to the group from ${first.profileName}.")` ) .waitFor(); @@ -130,7 +133,9 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { assert(group.getPendingMemberByServiceId(desktop.pni)); await window - .locator(`"${second.profileName} invited you to the group."`) + .locator( + `.SystemMessage:has-text("${second.profileName} invited you to the group.")` + ) .waitFor(); debug('Verify that message request state is not visible'); @@ -179,11 +184,11 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { debug('Declining'); await conversationStack - .locator('.module-message-request-actions button >> "Delete"') + .locator('.module-message-request-actions button >> "Block"') .click(); debug('waiting for confirmation modal'); - await window.locator('.module-Modal button >> "Delete and Leave"').click(); + await window.locator('.module-Modal button >> "Block"').click(); group = await phone.waitForGroupUpdate(group); assert.strictEqual(group.revision, 2); @@ -217,7 +222,9 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { debug('Waiting for the PNI invite'); await window - .locator(`text=${first.profileName} invited you to the group.`) + .locator( + `.SystemMessage:has-text("${first.profileName} invited you to the group.")` + ) .waitFor(); debug('Inviting ACI from another contact'); @@ -229,7 +236,9 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { debug('Waiting for the ACI invite'); await window - .locator(`text=${second.profileName} invited you to the group.`) + .locator( + `.SystemMessage:has-text("${second.profileName} invited you to the group.")` + ) .waitFor(); debug('Accepting'); @@ -240,8 +249,7 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { debug('Checking final notification'); await window .locator( - '.SystemMessage >> text=You accepted an invitation to the group from ' + - `${second.profileName}.` + `.SystemMessage:has-text("You accepted an invitation to the group from ${second.profileName}.")` ) .waitFor(); @@ -291,11 +299,11 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { debug('Declining'); await conversationStack - .locator('.module-message-request-actions button >> "Delete"') + .locator('.module-message-request-actions button >> "Block"') .click(); debug('waiting for confirmation modal'); - await window.locator('.module-Modal button >> "Delete and Leave"').click(); + await window.locator('.module-Modal button >> "Block"').click(); group = await phone.waitForGroupUpdate(group); assert.strictEqual(group.revision, 3); @@ -347,13 +355,10 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { sendUpdateTo: [{ device: desktop }], }); - await window - .locator( - '.SystemMessage >> ' + - `text=${second.profileName} accepted an invitation to the group ` + - `from ${first.profileName}.` - ) - .waitFor(); + await expectSystemMessages(window, [ + 'You were added to the group.', + `${second.profileName} accepted an invitation to the group from ${first.profileName}.`, + ]); }); it('should display a e164 for a PNI invite', async () => { @@ -398,7 +403,7 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { } const { e164 } = parsedE164; await window - .locator(`.SystemMessage >> text=You invited ${e164} to the group`) + .locator(`.SystemMessage:has-text("You invited ${e164} to the group")`) .waitFor(); debug('Accepting remote invite'); @@ -408,11 +413,10 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) { }); debug('Waiting for accept notification'); - await window - .locator( - '.SystemMessage >> ' + - `text=${unknownPniContact.profileName} accepted your invitation to the group` - ) - .waitFor(); + await expectSystemMessages(window, [ + 'You were added to the group.', + /^You invited .* to the group\.$/, + `${unknownPniContact.profileName} accepted your invitation to the group.`, + ]); }); }); diff --git a/ts/test-mock/pnp/merge_test.ts b/ts/test-mock/pnp/merge_test.ts index 7a4d375cbc..246373c7e8 100644 --- a/ts/test-mock/pnp/merge_test.ts +++ b/ts/test-mock/pnp/merge_test.ts @@ -14,6 +14,7 @@ import { toUntaggedPni } from '../../types/ServiceId'; import { MY_STORY_ID } from '../../types/Stories'; import { Bootstrap } from '../bootstrap'; import type { App } from '../bootstrap'; +import { expectSystemMessages } from '../helpers'; export const debug = createDebug('mock:test:merge'); @@ -147,13 +148,9 @@ describe('pnp/merge', function (this: Mocha.Suite) { const messages = window.locator('.module-message__text'); assert.strictEqual(await messages.count(), 0, 'message count'); - // No notifications - const notifications = window.locator('.SystemMessage'); - assert.strictEqual( - await notifications.count(), - 0, - 'notification count' - ); + await expectSystemMessages(window, [ + 'You accepted the message request', + ]); } if (withPNIMessage) { @@ -210,20 +207,25 @@ describe('pnp/merge', function (this: Mocha.Suite) { 'message count' ); - // One notification - the merge - const notifications = window.locator('.SystemMessage'); - assert.strictEqual( - await notifications.count(), - withPNIMessage ? 1 : 0, - 'notification count' - ); - - if (withPNIMessage && !pniSignatureVerified) { - const first = await notifications.first(); - assert.match( - await first.innerText(), - /Your message history with ACI Contact and their number .* has been merged./ - ); + if (withPNIMessage) { + if (pniSignatureVerified) { + await expectSystemMessages(window, [ + 'You accepted the message request', + 'You accepted the message request', + /Your message history with ACI Contact and their number .* has been merged\./, + ]); + } else { + await expectSystemMessages(window, [ + 'You accepted the message request', + 'You accepted the message request', + /Your message history with ACI Contact and their number .* has been merged\./, + ]); + } + } else { + await expectSystemMessages(window, [ + 'You accepted the message request', + 'You accepted the message request', + ]); } } }); diff --git a/ts/test-mock/pnp/phone_discovery_test.ts b/ts/test-mock/pnp/phone_discovery_test.ts index fe8d527d33..e4e65b0082 100644 --- a/ts/test-mock/pnp/phone_discovery_test.ts +++ b/ts/test-mock/pnp/phone_discovery_test.ts @@ -12,6 +12,7 @@ import { MY_STORY_ID } from '../../types/Stories'; import { toUntaggedPni } from '../../types/ServiceId'; import { Bootstrap } from '../bootstrap'; import type { App } from '../bootstrap'; +import { expectSystemMessages } from '../helpers'; export const debug = createDebug('mock:test:merge'); @@ -143,12 +144,10 @@ describe('pnp/phone discovery', function (this: Mocha.Suite) { const messages = window.locator('.module-message__text'); assert.strictEqual(await messages.count(), 1, 'message count'); - // One notification - the PhoneNumberDiscovery - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 1, 'notification count'); - - const first = await notifications.first(); - assert.match(await first.innerText(), /.* belongs to ACI Contact/); + await expectSystemMessages(window, [ + 'You accepted the message request', + /.* belongs to ACI Contact/, + ]); } }); }); diff --git a/ts/test-mock/pnp/pni_change_test.ts b/ts/test-mock/pnp/pni_change_test.ts index 34328b1f2a..d845c7f471 100644 --- a/ts/test-mock/pnp/pni_change_test.ts +++ b/ts/test-mock/pnp/pni_change_test.ts @@ -10,6 +10,7 @@ import * as durations from '../../util/durations'; import { generatePni, toUntaggedPni } from '../../types/ServiceId'; import { Bootstrap } from '../bootstrap'; import type { App } from '../bootstrap'; +import { expectSystemMessages } from '../helpers'; export const debug = createDebug('mock:test:pni-change'); @@ -97,8 +98,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) { assert.strictEqual(await messages.count(), 0, 'message count'); // No notifications - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 0, 'notification count'); + await expectSystemMessages(window, ['You accepted the message request']); } debug('Send message to contactA'); @@ -165,11 +165,10 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) { assert.strictEqual(await messages.count(), 1, 'message count'); // Only a PhoneNumberDiscovery notification - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 1, 'notification count'); - - const first = await notifications.first(); - assert.match(await first.innerText(), /.* belongs to ContactA/); + await expectSystemMessages(window, [ + 'You accepted the message request', + /.* belongs to ContactA/, + ]); } }); @@ -199,9 +198,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) { const messages = window.locator('.module-message__text'); assert.strictEqual(await messages.count(), 0, 'message count'); - // No notifications - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 0, 'notification count'); + await expectSystemMessages(window, ['You accepted the message request']); } debug('Send message to contactA'); @@ -268,14 +265,11 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) { assert.strictEqual(await messages.count(), 1, 'message count'); // Two notifications - the safety number change and PhoneNumberDiscovery - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 2, 'notification count'); - - const first = await notifications.first(); - assert.match(await first.innerText(), /.* belongs to ContactA/); - - const second = await notifications.nth(1); - assert.match(await second.innerText(), /Safety Number has changed/); + await expectSystemMessages(window, [ + 'You accepted the message request', + /.* belongs to ContactA/, + /Safety Number has changed/, + ]); } }); @@ -305,9 +299,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) { const messages = window.locator('.module-message__text'); assert.strictEqual(await messages.count(), 0, 'message count'); - // No notifications - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 0, 'notification count'); + await expectSystemMessages(window, ['You accepted the message request']); } debug('Send message to contactA'); @@ -403,15 +395,12 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) { const messages = window.locator('.module-message__text'); assert.strictEqual(await messages.count(), 2, 'message count'); - // Two notifications - the safety number change and PhoneNumberDiscovery - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 2, 'notification count'); - - const first = await notifications.first(); - assert.match(await first.innerText(), /.* belongs to ContactA/); - - const second = await notifications.nth(1); - assert.match(await second.innerText(), /Safety Number has changed/); + // Three notifications - accepted, the safety number change and PhoneNumberDiscovery + await expectSystemMessages(window, [ + 'You accepted the message request', + /.* belongs to ContactA/, + /Safety Number has changed/, + ]); } }); @@ -442,8 +431,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) { assert.strictEqual(await messages.count(), 0, 'message count'); // No notifications - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 0, 'notification count'); + await expectSystemMessages(window, ['You accepted the message request']); } debug('Send message to contactA'); @@ -563,11 +551,10 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) { assert.strictEqual(await messages.count(), 2, 'message count'); // Only a PhoneNumberDiscovery notification - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 1, 'notification count'); - - const first = await notifications.first(); - assert.match(await first.innerText(), /.* belongs to ContactA/); + await expectSystemMessages(window, [ + 'You accepted the message request', + /.* belongs to ContactA/, + ]); } }); }); diff --git a/ts/test-mock/pnp/pni_signature_test.ts b/ts/test-mock/pnp/pni_signature_test.ts index 554c3f7019..2ce00e020b 100644 --- a/ts/test-mock/pnp/pni_signature_test.ts +++ b/ts/test-mock/pnp/pni_signature_test.ts @@ -23,6 +23,7 @@ import { RECEIPT_BATCHER_WAIT_MS, } from '../../types/Receipt'; import { sleep } from '../../util/sleep'; +import { expectSystemMessages } from '../helpers'; export const debug = createDebug('mock:test:pni-signature'); @@ -235,9 +236,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) { const messages = window.locator('.module-message__text'); assert.strictEqual(await messages.count(), 4, 'message count'); - // No notifications - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 0, 'notification count'); + await expectSystemMessages(window, ['You accepted the message request']); } }); @@ -424,11 +423,10 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) { assert.strictEqual(await messages.count(), 3, 'messages'); // Title transition notification - const notifications = window.locator('.SystemMessage'); - assert.strictEqual(await notifications.count(), 1, 'notifications'); - - const first = await notifications.first(); - assert.match(await first.innerText(), /You started this chat with/); + await expectSystemMessages(window, [ + 'You accepted the message request', + /You started this chat with/, + ]); assert.isEmpty(await phone.getOrphanedStorageKeys()); } diff --git a/ts/test-mock/pnp/send_gv2_invite_test.ts b/ts/test-mock/pnp/send_gv2_invite_test.ts index 42f2728e70..f57a8a7227 100644 --- a/ts/test-mock/pnp/send_gv2_invite_test.ts +++ b/ts/test-mock/pnp/send_gv2_invite_test.ts @@ -175,10 +175,7 @@ describe('pnp/send gv2 invite', function (this: Mocha.Suite) { await detailsHeader.locator('button >> "My group"').click(); const modal = window.locator('.module-Modal:has-text("Edit group")'); - - // Group title should be immediately focused. - await modal.type(' (v2)'); - + await modal.locator('input').fill('My group (v2)'); await modal.locator('button >> "Save"').click(); } diff --git a/ts/test-node/jobs/helpers/addReportSpamJob_test.ts b/ts/test-node/jobs/helpers/addReportSpamJob_test.ts index fef0cd66b2..1937dc1ff7 100644 --- a/ts/test-node/jobs/helpers/addReportSpamJob_test.ts +++ b/ts/test-node/jobs/helpers/addReportSpamJob_test.ts @@ -1,21 +1,16 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only - import * as sinon from 'sinon'; import { Job } from '../../../jobs/Job'; -import { generateAci } from '../../../types/ServiceId'; - import { addReportSpamJob } from '../../../jobs/helpers/addReportSpamJob'; +import type { ConversationType } from '../../../state/ducks/conversations'; +import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation'; describe('addReportSpamJob', () => { let getMessageServerGuidsForSpam: sinon.SinonStub; let jobQueue: { add: sinon.SinonStub }; - const conversation = { - id: 'convo', - type: 'private' as const, - serviceId: generateAci(), - }; + const conversation: ConversationType = getDefaultConversation(); beforeEach(() => { getMessageServerGuidsForSpam = sinon.stub().resolves(['abc', 'xyz']); diff --git a/ts/types/MessageRequestResponseEvent.ts b/ts/types/MessageRequestResponseEvent.ts new file mode 100644 index 0000000000..37de581c7c --- /dev/null +++ b/ts/types/MessageRequestResponseEvent.ts @@ -0,0 +1,7 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +export enum MessageRequestResponseEvent { + ACCEPT = 'ACCEPT', + BLOCK = 'BLOCK', + SPAM = 'SPAM', +} diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx index fc0cfdde4d..334bb4a62a 100644 --- a/ts/types/Toast.tsx +++ b/ts/types/Toast.tsx @@ -44,6 +44,7 @@ export enum ToastType { OriginalMessageNotFound = 'OriginalMessageNotFound', PinnedConversationsFull = 'PinnedConversationsFull', ReactionFailed = 'ReactionFailed', + ReportedSpam = 'ReportedSpam', ReportedSpamAndBlocked = 'ReportedSpamAndBlocked', StickerPackInstallFailed = 'StickerPackInstallFailed', StoryMuted = 'StoryMuted', @@ -120,6 +121,7 @@ export type AnyToast = | { toastType: ToastType.OriginalMessageNotFound } | { toastType: ToastType.PinnedConversationsFull } | { toastType: ToastType.ReactionFailed } + | { toastType: ToastType.ReportedSpam } | { toastType: ToastType.ReportedSpamAndBlocked } | { toastType: ToastType.StickerPackInstallFailed } | { toastType: ToastType.StoryMuted } diff --git a/ts/util/getAddedByForOurPendingInvitation.ts b/ts/util/getAddedByForOurPendingInvitation.ts new file mode 100644 index 0000000000..f6ce9406b8 --- /dev/null +++ b/ts/util/getAddedByForOurPendingInvitation.ts @@ -0,0 +1,17 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { ConversationType } from '../state/ducks/conversations'; + +export function getAddedByForOurPendingInvitation( + conversation: ConversationType +): ConversationType | null { + const ourAci = window.storage.user.getCheckedAci(); + const ourPni = window.storage.user.getPni(); + const addedBy = conversation.pendingMemberships?.find( + item => item.serviceId === ourAci || item.serviceId === ourPni + )?.addedByUserId; + if (addedBy == null) { + return null; + } + return window.ConversationController.get(addedBy)?.format() ?? null; +} diff --git a/ts/util/getConversation.ts b/ts/util/getConversation.ts index 3da8e0c0fe..96724f4c1c 100644 --- a/ts/util/getConversation.ts +++ b/ts/util/getConversation.ts @@ -177,6 +177,7 @@ export function getConversation(model: ConversationModel): ConversationType { inboxPosition, isArchived: attributes.isArchived, isBlocked: isBlocked(attributes), + reportingToken: attributes.reportingToken, removalStage: attributes.removalStage, isMe: isMe(attributes), isGroupV1AndDisabled: isGroupV1(attributes), diff --git a/ts/util/getNotificationDataForMessage.ts b/ts/util/getNotificationDataForMessage.ts index da651dc99b..a48604d5d3 100644 --- a/ts/util/getNotificationDataForMessage.ts +++ b/ts/util/getNotificationDataForMessage.ts @@ -45,12 +45,15 @@ import { isTapToView, isUnsupportedMessage, isConversationMerge, + isMessageRequestResponse, } from '../state/selectors/message'; import { getContact, messageHasPaymentEvent, getPaymentEventNotificationText, } from '../messages/helpers'; +import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent'; +import { missingCaseError } from './missingCaseError'; function getNameForNumber(e164: string): string { const conversation = window.ConversationController.get(e164); @@ -177,6 +180,34 @@ export function getNotificationDataForMessage( }; } + if (isMessageRequestResponse(attributes)) { + const { messageRequestResponseEvent: event } = attributes; + strictAssert( + event, + 'getNotificationData: isMessageRequestResponse true, but no messageRequestResponseEvent!' + ); + let text: string; + if (event === MessageRequestResponseEvent.ACCEPT) { + text = window.i18n( + 'icu:MessageRequestResponseNotification__Message--Accepted' + ); + } else if (event === MessageRequestResponseEvent.SPAM) { + text = window.i18n( + 'icu:MessageRequestResponseNotification__Message--Reported' + ); + } else if (event === MessageRequestResponseEvent.BLOCK) { + text = window.i18n( + 'icu:MessageRequestResponseNotification__Message--Blocked' + ); + } else { + throw missingCaseError(event); + } + + return { + text, + }; + } + const { attachments = [] } = attributes; if (isTapToView(attributes)) { diff --git a/ts/util/idForLogging.ts b/ts/util/idForLogging.ts index 345c85343d..91a67f3ce2 100644 --- a/ts/util/idForLogging.ts +++ b/ts/util/idForLogging.ts @@ -12,6 +12,7 @@ import { } from '../messages/helpers'; import { isDirectConversation, isGroupV2 } from './whatTypeOfConversation'; import { getE164 } from './getE164'; +import type { ConversationType } from '../state/ducks/conversations'; export function getMessageIdForLogging( message: Pick< @@ -27,7 +28,7 @@ export function getMessageIdForLogging( } export function getConversationIdForLogging( - conversation: ConversationAttributesType + conversation: ConversationAttributesType | ConversationType ): string { if (isDirectConversation(conversation)) { const { serviceId, pni, id } = conversation; diff --git a/ts/util/lint/exceptions.json b/ts/util/lint/exceptions.json index 3599ea1f9b..bcf438b078 100644 --- a/ts/util/lint/exceptions.json +++ b/ts/util/lint/exceptions.json @@ -3372,6 +3372,13 @@ "updated": "2022-01-04T21:43:17.517Z", "reasonDetail": "Used to change the style in non-production builds." }, + { + "rule": "React-useRef", + "path": "ts/components/SafetyTipsModal.tsx", + "line": " const scrollEndTimer = useRef(null);", + "reasonCategory": "usageTrusted", + "updated": "2024-03-08T01:48:15.330Z" + }, { "rule": "React-useRef", "path": "ts/components/Slider.tsx",