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 0000000000..5e6254b6d3 Binary files /dev/null and b/images/safety-tips/safety-tip-business.png differ diff --git a/images/safety-tips/safety-tip-crypto.png b/images/safety-tips/safety-tip-crypto.png new file mode 100644 index 0000000000..4209a052b3 Binary files /dev/null and b/images/safety-tips/safety-tip-crypto.png differ diff --git a/images/safety-tips/safety-tip-links.png b/images/safety-tips/safety-tip-links.png new file mode 100644 index 0000000000..44ae839630 Binary files /dev/null and b/images/safety-tips/safety-tip-links.png differ diff --git a/images/safety-tips/safety-tip-vague.png b/images/safety-tips/safety-tip-vague.png new file mode 100644 index 0000000000..a17d2c2051 Binary files /dev/null and b/images/safety-tips/safety-tip-vague.png differ 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",