Spam Reporting UI changes

This commit is contained in:
Jamie Kyle 2024-03-12 09:29:31 -07:00 committed by GitHub
parent e031d136a1
commit 8387f938eb
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
88 changed files with 2711 additions and 807 deletions

View file

@ -116,7 +116,6 @@ window.SignalContext = {
getHourCyclePreference: () => HourCyclePreference.UnknownPreference, getHourCyclePreference: () => HourCyclePreference.UnknownPreference,
getPreferredSystemLocales: () => ['en'], getPreferredSystemLocales: () => ['en'],
getResolvedMessagesLocaleDirection: () => 'ltr',
getLocaleOverride: () => null, getLocaleOverride: () => null,
getLocaleDisplayNames: () => ({ en: { en: 'English' } }), getLocaleDisplayNames: () => ({ en: { en: 'English' } }),
}; };
@ -133,6 +132,9 @@ const withGlobalTypesProvider = (Story, context) => {
const mode = context.globals.mode; const mode = context.globals.mode;
const direction = context.globals.direction ?? 'auto'; 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 // Adding it to the body as well so that we can cover modals and other
// components that are rendered outside of this decorator container // components that are rendered outside of this decorator container
if (theme === 'light') { if (theme === 'light') {

View file

@ -427,6 +427,26 @@
"messageformat": "Select messages", "messageformat": "Select messages",
"description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation" "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": { "icu:ContactListItem__menu": {
"messageformat": "Manage Contact", "messageformat": "Manage Contact",
"description": "Shown as aria label for context menu for a contact" "description": "Shown as aria label for context menu for a contact"
@ -3394,6 +3414,62 @@
"messageformat": "All", "messageformat": "All",
"description": "Shown in reaction viewer as the title for the 'all' category" "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 dont 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 dont know messages about cryptocurrency (like Bitcoin) or a financial opportunity, be careful—its 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 dont know that have links to websites. Never visit links from people you dont 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": { "icu:MessageRequests--message-direct": {
"messageformat": "Let {name} message you and share your name and photo with them? They wont know youve seen their messages until you accept.", "messageformat": "Let {name} message you and share your name and photo with them? They wont know youve seen their messages until you accept.",
"description": "Shown as the message for a message request in a direct message" "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.", "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" "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 cant 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 cant 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 cant 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": { "icu:MessageRequests--delete": {
"messageformat": "Delete", "messageformat": "Delete",
"description": "Shown as a button to let the user delete any message request" "description": "Shown as a button to let the user delete any message request"
@ -5242,6 +5354,10 @@
"messageformat": "Learn more", "messageformat": "Learn more",
"description": "Shown on the message request warning. Clicking this button will open a dialog with more information" "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": { "icu:MessageRequestWarning__dialog__details": {
"messageformat": "You have no groups in common with this person. Review requests carefully before accepting to avoid unwanted messages.", "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" "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 payments status", "messageformat": "Check your primary device for this payments status",
"description": "Payment event notification check device label" "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": { "icu:SignalConnectionsModal__title": {
"messageformat": "Signal Connections", "messageformat": "Signal Connections",
"description": "The phrase/term: 'Signal Connections'" "description": "The phrase/term: 'Signal Connections'"

View file

@ -1 +1 @@
<svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><g clip-path="url(#block__a)"><path d="M10 .938a9.063 9.063 0 1 0 0 18.125A9.063 9.063 0 0 0 10 .938ZM2.396 10A7.604 7.604 0 0 1 14.83 4.127L4.127 14.831A7.573 7.573 0 0 1 2.396 10Zm2.762 5.863L15.863 5.158A7.604 7.604 0 0 1 5.157 15.864Z" fill="#000"/></g><defs><clipPath id="block__a"><path fill="#fff" d="M0 0h20v20H0z"/></clipPath></defs></svg> <svg width="20" height="20" fill="none" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M10 1.354a8.646 8.646 0 1 0 0 17.292 8.646 8.646 0 0 0 0-17.292ZM2.813 10A7.187 7.187 0 0 1 14.54 4.428L4.428 14.541A7.158 7.158 0 0 1 2.813 10Zm2.646 5.572A7.187 7.187 0 0 0 15.571 5.459L5.46 15.572Z" fill="#000"/></svg>

Before

Width:  |  Height:  |  Size: 423 B

After

Width:  |  Height:  |  Size: 345 B

Before After
Before After

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none"><path fill="#000" d="M10 5.417a.995.995 0 0 0-.993 1.071l.353 4.586a.642.642 0 0 0 1.28 0l.352-4.586A.996.996 0 0 0 10 5.417Zm0 7.291a1.042 1.042 0 1 0 0 2.084 1.042 1.042 0 0 0 0-2.084Z"/><path fill="#000" fill-rule="evenodd" d="M12.589 1.354H7.41c-.635 0-1.245.253-1.694.702l-3.66 3.661a2.396 2.396 0 0 0-.702 1.694v5.178c0 .635.253 1.245.702 1.694l3.661 3.661c.45.45 1.059.702 1.694.702h5.178c.635 0 1.245-.253 1.694-.702l3.661-3.661c.45-.45.702-1.059.702-1.694V7.41c0-.635-.253-1.245-.702-1.694l-3.661-3.661a2.396 2.396 0 0 0-1.694-.702ZM7.41 2.812h5.178c.248 0 .487.1.663.275l3.66 3.661a.938.938 0 0 1 .276.663v5.178a.938.938 0 0 1-.275.663l-3.661 3.66a.938.938 0 0 1-.663.276H7.41a.938.938 0 0 1-.663-.275l-3.661-3.661a.937.937 0 0 1-.275-.663V7.41c0-.249.1-.487.275-.663l3.661-3.661a.937.937 0 0 1 .663-.275Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 919 B

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" fill="none"><path fill="#000" d="M6.667 2.812a4.27 4.27 0 0 0-3.268 7.021c.19.226.3.527.273.848l-.142 1.685 1.333-.926c.276-.192.608-.244.909-.18.19.04.384.069.583.083.006.504.066.995.174 1.468a5.749 5.749 0 0 1-.94-.1l-1.772 1.232c-.796.553-1.876-.071-1.795-1.037l.186-2.224a5.73 5.73 0 1 1 9.548-6.232 6.93 6.93 0 0 0-1.398.485 4.269 4.269 0 0 0-3.691-2.123Z"/><path fill="#000" fill-rule="evenodd" d="M13.333 5.52a5.73 5.73 0 0 1 4.468 9.317l.192 2.29c.081.965-1 1.59-1.796 1.036l-1.836-1.276a5.73 5.73 0 1 1-1.028-11.366Zm4.271 5.73a4.27 4.27 0 1 0-3.413 4.185c.297-.06.625-.008.898.182l1.395.97-.147-1.75c-.026-.32.083-.62.271-.846a4.25 4.25 0 0 0 .996-2.741Z" clip-rule="evenodd"/></svg>

After

Width:  |  Height:  |  Size: 756 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 22 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 13 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

View file

@ -550,6 +550,8 @@ message SyncMessage {
DELETE = 2; DELETE = 2;
BLOCK = 3; BLOCK = 3;
BLOCK_AND_DELETE = 4; BLOCK_AND_DELETE = 4;
SPAM = 5;
BLOCK_AND_SPAM = 6;
} }
optional string threadE164 = 1; optional string threadE164 = 1;

View file

@ -92,6 +92,14 @@
} }
} }
&__safety-tips-button {
border-radius: 9999px;
padding-block: 6px;
padding-inline: 14px;
margin-top: 12px;
@include font-subtitle;
}
&__membership { &__membership {
@include font-body-2; @include font-body-2;
user-select: none; user-select: none;

View file

@ -0,0 +1,6 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
.MessageRequestActionsConfirmation__ModalHost__width-container {
min-width: 480px;
}

View file

@ -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;
}

View file

@ -247,6 +247,18 @@
'../images/icons/v3/merge/merge-compact.svg' '../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 { &--error {

View file

@ -114,6 +114,7 @@
@import './components/MediaQualitySelector.scss'; @import './components/MediaQualitySelector.scss';
@import './components/MessageAudio.scss'; @import './components/MessageAudio.scss';
@import './components/MessageBody.scss'; @import './components/MessageBody.scss';
@import './components/MessageRequestActionsConfirmation.scss';
@import './components/MessageTextRenderer.scss'; @import './components/MessageTextRenderer.scss';
@import './components/MessageDetail.scss'; @import './components/MessageDetail.scss';
@import './components/MiniPlayer.scss'; @import './components/MiniPlayer.scss';
@ -133,6 +134,7 @@
@import './components/SafetyNumberChangeDialog.scss'; @import './components/SafetyNumberChangeDialog.scss';
@import './components/SafetyNumberOnboarding.scss'; @import './components/SafetyNumberOnboarding.scss';
@import './components/SafetyNumberViewer.scss'; @import './components/SafetyNumberViewer.scss';
@import './components/SafetyTipsModal.scss';
@import './components/ScrollDownButton.scss'; @import './components/ScrollDownButton.scss';
@import './components/SearchInput.scss'; @import './components/SearchInput.scss';
@import './components/SearchResultsLoadingFakeHeader.scss'; @import './components/SearchResultsLoadingFakeHeader.scss';

View file

@ -108,7 +108,7 @@ export default {
blockConversation: action('blockConversation'), blockConversation: action('blockConversation'),
blockAndReportSpam: action('blockAndReportSpam'), blockAndReportSpam: action('blockAndReportSpam'),
deleteConversation: action('deleteConversation'), deleteConversation: action('deleteConversation'),
title: '', conversationName: getDefaultConversation(),
// GroupV1 Disabled Actions // GroupV1 Disabled Actions
showGV2MigrationDialog: action('showGV2MigrationDialog'), showGV2MigrationDialog: action('showGV2MigrationDialog'),
// GroupV2 // GroupV2

View file

@ -1,7 +1,7 @@
// Copyright 2019 Signal Messenger, LLC // Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 classNames from 'classnames';
import type { ReadonlyDeep } from 'type-fest'; import type { ReadonlyDeep } from 'type-fest';
@ -43,6 +43,7 @@ import type { AciString } from '../types/ServiceId';
import { AudioCapture } from './conversation/AudioCapture'; import { AudioCapture } from './conversation/AudioCapture';
import { CompositionUpload } from './CompositionUpload'; import { CompositionUpload } from './CompositionUpload';
import type { import type {
ConversationRemovalStage,
ConversationType, ConversationType,
PushPanelForConversationActionType, PushPanelForConversationActionType,
ShowConversationType, ShowConversationType,
@ -73,16 +74,16 @@ import type { ShowToastAction } from '../state/ducks/toast';
import type { DraftEditMessageType } from '../model-types.d'; import type { DraftEditMessageType } from '../model-types.d';
export type OwnProps = Readonly<{ export type OwnProps = Readonly<{
acceptedMessageRequest?: boolean; acceptedMessageRequest: boolean | null;
removalStage?: 'justNotification' | 'messageRequest'; removalStage: ConversationRemovalStage | null;
addAttachment: ( addAttachment: (
conversationId: string, conversationId: string,
attachment: InMemoryAttachmentDraftType attachment: InMemoryAttachmentDraftType
) => unknown; ) => unknown;
announcementsOnly?: boolean; announcementsOnly: boolean | null;
areWeAdmin?: boolean; areWeAdmin: boolean | null;
areWePending?: boolean; areWePending: boolean | null;
areWePendingApproval?: boolean; areWePendingApproval: boolean | null;
cancelRecording: () => unknown; cancelRecording: () => unknown;
completeRecording: ( completeRecording: (
conversationId: string, conversationId: string,
@ -93,29 +94,29 @@ export type OwnProps = Readonly<{
) => HydratedBodyRangesType | undefined; ) => HydratedBodyRangesType | undefined;
conversationId: string; conversationId: string;
discardEditMessage: (id: string) => unknown; discardEditMessage: (id: string) => unknown;
draftEditMessage?: DraftEditMessageType; draftEditMessage: DraftEditMessageType | null;
draftAttachments: ReadonlyArray<AttachmentDraftType>; draftAttachments: ReadonlyArray<AttachmentDraftType>;
errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType; errorDialogAudioRecorderType: ErrorDialogAudioRecorderType | null;
errorRecording: (e: ErrorDialogAudioRecorderType) => unknown; errorRecording: (e: ErrorDialogAudioRecorderType) => unknown;
focusCounter: number; focusCounter: number;
groupAdmins: Array<ConversationType>; groupAdmins: Array<ConversationType>;
groupVersion?: 1 | 2; groupVersion: 1 | 2 | null;
i18n: LocalizerType; i18n: LocalizerType;
imageToBlurHash: typeof imageToBlurHash; imageToBlurHash: typeof imageToBlurHash;
isDisabled: boolean; isDisabled: boolean;
isFetchingUUID?: boolean; isFetchingUUID: boolean | null;
isFormattingEnabled: boolean; isFormattingEnabled: boolean;
isGroupV1AndDisabled?: boolean; isGroupV1AndDisabled: boolean | null;
isMissingMandatoryProfileSharing?: boolean; isMissingMandatoryProfileSharing: boolean | null;
isSignalConversation?: boolean; isSignalConversation: boolean | null;
lastEditableMessageId?: string; lastEditableMessageId: string | null;
recordingState: RecordingState; recordingState: RecordingState;
messageCompositionId: string; messageCompositionId: string;
shouldHidePopovers?: boolean; shouldHidePopovers: boolean | null;
isSMSOnly?: boolean; isSMSOnly: boolean | null;
left?: boolean; left: boolean | null;
linkPreviewLoading: boolean; linkPreviewLoading: boolean;
linkPreviewResult?: LinkPreviewType; linkPreviewResult: LinkPreviewType | null;
onClearAttachments(conversationId: string): unknown; onClearAttachments(conversationId: string): unknown;
onCloseLinkPreview(conversationId: string): unknown; onCloseLinkPreview(conversationId: string): unknown;
platform: string; platform: string;
@ -149,15 +150,15 @@ export type OwnProps = Readonly<{
voiceNoteAttachment?: InMemoryAttachmentDraftType; voiceNoteAttachment?: InMemoryAttachmentDraftType;
} }
): unknown; ): unknown;
quotedMessageId?: string; quotedMessageId: string | null;
quotedMessageProps?: ReadonlyDeep< quotedMessageProps: null | ReadonlyDeep<
Omit< Omit<
QuoteProps, QuoteProps,
'i18n' | 'onClick' | 'onClose' | 'withContentAbove' | 'isCompose' 'i18n' | 'onClick' | 'onClose' | 'withContentAbove' | 'isCompose'
> >
>; >;
quotedMessageAuthorAci?: AciString; quotedMessageAuthorAci: AciString | null;
quotedMessageSentAt?: number; quotedMessageSentAt: number | null;
removeAttachment: (conversationId: string, filePath: string) => unknown; removeAttachment: (conversationId: string, filePath: string) => unknown;
scrollToMessage: (conversationId: string, messageId: string) => unknown; scrollToMessage: (conversationId: string, messageId: string) => unknown;
@ -210,6 +211,7 @@ export type Props = Pick<
| 'blessedPacks' | 'blessedPacks'
| 'recentStickers' | 'recentStickers'
| 'clearInstalledStickerPack' | 'clearInstalledStickerPack'
| 'showIntroduction'
| 'clearShowIntroduction' | 'clearShowIntroduction'
| 'showPickerHint' | 'showPickerHint'
| 'clearShowPickerHint' | 'clearShowPickerHint'
@ -220,7 +222,7 @@ export type Props = Pick<
pushPanelForConversation: PushPanelForConversationActionType; pushPanelForConversation: PushPanelForConversationActionType;
} & OwnProps; } & OwnProps;
export function CompositionArea({ export const CompositionArea = memo(function CompositionArea({
// Base props // Base props
addAttachment, addAttachment,
conversationId, conversationId,
@ -291,6 +293,7 @@ export function CompositionArea({
recentStickers, recentStickers,
clearInstalledStickerPack, clearInstalledStickerPack,
sendStickerMessage, sendStickerMessage,
showIntroduction,
clearShowIntroduction, clearShowIntroduction,
showPickerHint, showPickerHint,
clearShowPickerHint, clearShowPickerHint,
@ -301,14 +304,18 @@ export function CompositionArea({
conversationType, conversationType,
groupVersion, groupVersion,
isBlocked, isBlocked,
isHidden,
isReported,
isMissingMandatoryProfileSharing, isMissingMandatoryProfileSharing,
left, left,
removalStage, removalStage,
acceptConversation, acceptConversation,
blockConversation, blockConversation,
reportSpam,
blockAndReportSpam, blockAndReportSpam,
deleteConversation, deleteConversation,
title, conversationName,
addedByName,
// GroupV1 Disabled Actions // GroupV1 Disabled Actions
isGroupV1AndDisabled, isGroupV1AndDisabled,
showGV2MigrationDialog, showGV2MigrationDialog,
@ -356,8 +363,8 @@ export function CompositionArea({
bodyRanges, bodyRanges,
message, message,
// sent timestamp for the quote // sent timestamp for the quote
quoteSentAt: quotedMessageSentAt, quoteSentAt: quotedMessageSentAt ?? undefined,
quoteAuthorAci: quotedMessageAuthorAci, quoteAuthorAci: quotedMessageAuthorAci ?? undefined,
targetMessageId: editedMessageId, targetMessageId: editedMessageId,
}); });
} else { } else {
@ -469,12 +476,7 @@ export function CompositionArea({
) { ) {
inputApiRef.current.reset(); inputApiRef.current.reset();
} }
}, [ }, [messageCompositionId, sendCounter, previousMessageCompositionId, previousSendCounter]);
messageCompositionId,
sendCounter,
previousMessageCompositionId,
previousSendCounter,
]);
const insertEmoji = useCallback( const insertEmoji = useCallback(
(e: EmojiPickDataType) => { (e: EmojiPickDataType) => {
@ -504,7 +506,7 @@ export function CompositionArea({
inputApiRef.current?.setContents( inputApiRef.current?.setContents(
draftEditMessageBody ?? '', draftEditMessageBody ?? '',
draftBodyRanges, draftBodyRanges ?? undefined,
true true
); );
}, [draftBodyRanges, draftEditMessageBody, hasEditDraftChanged]); }, [draftBodyRanges, draftEditMessageBody, hasEditDraftChanged]);
@ -520,7 +522,11 @@ export function CompositionArea({
return; return;
} }
inputApiRef.current?.setContents(draftText, draftBodyRanges, true); inputApiRef.current?.setContents(
draftText,
draftBodyRanges ?? undefined,
true
);
}, [conversationId, draftBodyRanges, draftText, previousConversationId]); }, [conversationId, draftBodyRanges, draftText, previousConversationId]);
const handleToggleLarge = useCallback(() => { const handleToggleLarge = useCallback(() => {
@ -637,6 +643,7 @@ export function CompositionArea({
onPickSticker={(packId, stickerId) => onPickSticker={(packId, stickerId) =>
sendStickerMessage(conversationId, { packId, stickerId }) sendStickerMessage(conversationId, { packId, stickerId })
} }
showIntroduction={showIntroduction}
clearShowIntroduction={clearShowIntroduction} clearShowIntroduction={clearShowIntroduction}
showPickerHint={showPickerHint} showPickerHint={showPickerHint}
clearShowPickerHint={clearShowPickerHint} clearShowPickerHint={clearShowPickerHint}
@ -735,16 +742,19 @@ export function CompositionArea({
) { ) {
return ( return (
<MessageRequestActions <MessageRequestActions
acceptConversation={acceptConversation} addedByName={addedByName}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
conversationId={conversationId}
conversationType={conversationType} conversationType={conversationType}
deleteConversation={deleteConversation} conversationId={conversationId}
conversationName={conversationName}
i18n={i18n} i18n={i18n}
isBlocked={isBlocked} isBlocked={isBlocked}
isHidden={removalStage !== undefined} isHidden={isHidden}
title={title} isReported={isReported}
acceptConversation={acceptConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
deleteConversation={deleteConversation}
/> />
); );
} }
@ -788,14 +798,18 @@ export function CompositionArea({
) { ) {
return ( return (
<MandatoryProfileSharingActions <MandatoryProfileSharingActions
acceptConversation={acceptConversation} addedByName={addedByName}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
conversationId={conversationId} conversationId={conversationId}
conversationType={conversationType} conversationType={conversationType}
deleteConversation={deleteConversation} conversationName={conversationName}
i18n={i18n} i18n={i18n}
title={title} isBlocked={isBlocked}
isReported={isReported}
acceptConversation={acceptConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
deleteConversation={deleteConversation}
/> />
); );
} }
@ -993,7 +1007,7 @@ export function CompositionArea({
platform={platform} platform={platform}
sendCounter={sendCounter} sendCounter={sendCounter}
shouldHidePopovers={shouldHidePopovers} shouldHidePopovers={shouldHidePopovers}
skinTone={skinTone} skinTone={skinTone ?? null}
sortedGroupMembers={sortedGroupMembers} sortedGroupMembers={sortedGroupMembers}
theme={theme} theme={theme}
/> />
@ -1031,4 +1045,4 @@ export function CompositionArea({
/> />
</div> </div>
); );
} });

View file

@ -21,30 +21,38 @@ export default {
args: {}, args: {},
} satisfies Meta<Props>; } satisfies Meta<Props>;
const useProps = (overrideProps: Partial<Props> = {}): Props => ({ const useProps = (overrideProps: Partial<Props> = {}): Props => {
i18n, const conversation = getDefaultConversation();
disabled: overrideProps.disabled ?? false, return {
draftText: overrideProps.draftText || undefined, i18n,
draftBodyRanges: overrideProps.draftBodyRanges || [], conversationId: conversation.id,
clearQuotedMessage: action('clearQuotedMessage'), disabled: overrideProps.disabled ?? false,
getPreferredBadge: () => undefined, draftText: overrideProps.draftText ?? null,
getQuotedMessage: action('getQuotedMessage'), draftEditMessage: overrideProps.draftEditMessage ?? null,
isFormattingEnabled: draftBodyRanges: overrideProps.draftBodyRanges || [],
overrideProps.isFormattingEnabled === false clearQuotedMessage: action('clearQuotedMessage'),
? overrideProps.isFormattingEnabled getPreferredBadge: () => undefined,
: true, getQuotedMessage: action('getQuotedMessage'),
large: overrideProps.large ?? false, isFormattingEnabled:
onCloseLinkPreview: action('onCloseLinkPreview'), overrideProps.isFormattingEnabled === false
onEditorStateChange: action('onEditorStateChange'), ? overrideProps.isFormattingEnabled
onPickEmoji: action('onPickEmoji'), : true,
onSubmit: action('onSubmit'), large: overrideProps.large ?? false,
onTextTooLong: action('onTextTooLong'), onCloseLinkPreview: action('onCloseLinkPreview'),
platform: 'darwin', onEditorStateChange: action('onEditorStateChange'),
sendCounter: 0, onPickEmoji: action('onPickEmoji'),
sortedGroupMembers: overrideProps.sortedGroupMembers ?? [], onSubmit: action('onSubmit'),
skinTone: overrideProps.skinTone ?? undefined, onTextTooLong: action('onTextTooLong'),
theme: React.useContext(StorybookThemeContext), 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 { export function Default(): JSX.Element {
const props = useProps(); const props = useProps();

View file

@ -96,22 +96,22 @@ export type InputApi = {
export type Props = Readonly<{ export type Props = Readonly<{
children?: React.ReactNode; children?: React.ReactNode;
conversationId?: string; conversationId: string | null;
i18n: LocalizerType; i18n: LocalizerType;
disabled?: boolean; disabled?: boolean;
draftEditMessage?: DraftEditMessageType; draftEditMessage: DraftEditMessageType | null;
getPreferredBadge: PreferredBadgeSelectorType; getPreferredBadge: PreferredBadgeSelectorType;
large?: boolean; large: boolean | null;
inputApi?: React.MutableRefObject<InputApi | undefined>; inputApi: React.MutableRefObject<InputApi | undefined> | null;
isFormattingEnabled: boolean; isFormattingEnabled: boolean;
sendCounter: number; sendCounter: number;
skinTone?: EmojiPickDataType['skinTone']; skinTone: NonNullable<EmojiPickDataType['skinTone']> | null;
draftText?: string; draftText: string | null;
draftBodyRanges?: HydratedBodyRangesType; draftBodyRanges: HydratedBodyRangesType | null;
moduleClassName?: string; moduleClassName?: string;
theme: ThemeType; theme: ThemeType;
placeholder?: string; placeholder?: string;
sortedGroupMembers?: ReadonlyArray<ConversationType>; sortedGroupMembers: ReadonlyArray<ConversationType> | null;
scrollerRef?: React.RefObject<HTMLDivElement>; scrollerRef?: React.RefObject<HTMLDivElement>;
onDirtyChange?(dirty: boolean): unknown; onDirtyChange?(dirty: boolean): unknown;
onEditorStateChange?(options: { onEditorStateChange?(options: {
@ -132,11 +132,11 @@ export type Props = Readonly<{
): unknown; ): unknown;
onScroll?: (ev: React.UIEvent<HTMLElement>) => void; onScroll?: (ev: React.UIEvent<HTMLElement>) => void;
platform: string; platform: string;
shouldHidePopovers?: boolean; shouldHidePopovers: boolean | null;
getQuotedMessage?(): unknown; getQuotedMessage?(): unknown;
clearQuotedMessage?(): unknown; clearQuotedMessage?(): unknown;
linkPreviewLoading?: boolean; linkPreviewLoading?: boolean;
linkPreviewResult?: LinkPreviewType; linkPreviewResult: LinkPreviewType | null;
onCloseLinkPreview?(conversationId: string): unknown; onCloseLinkPreview?(conversationId: string): unknown;
}>; }>;
@ -562,7 +562,7 @@ export function CompositionInput(props: Props): React.ReactElement {
onEditorStateChange({ onEditorStateChange({
bodyRanges, bodyRanges,
caretLocation: selection ? selection.index : undefined, caretLocation: selection ? selection.index : undefined,
conversationId, conversationId: conversationId ?? undefined,
messageText: text, messageText: text,
sendCounter, sendCounter,
}); });
@ -612,7 +612,7 @@ export function CompositionInput(props: Props): React.ReactElement {
React.useEffect(() => { React.useEffect(() => {
const emojiCompletion = emojiCompletionRef.current; const emojiCompletion = emojiCompletionRef.current;
if (emojiCompletion === undefined || skinTone === undefined) { if (emojiCompletion == null || skinTone == null) {
return; return;
} }

View file

@ -19,7 +19,7 @@ import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import * as grapheme from '../util/grapheme'; import * as grapheme from '../util/grapheme';
export type CompositionTextAreaProps = { export type CompositionTextAreaProps = {
bodyRanges?: HydratedBodyRangesType; bodyRanges: HydratedBodyRangesType | null;
i18n: LocalizerType; i18n: LocalizerType;
isFormattingEnabled: boolean; isFormattingEnabled: boolean;
maxLength?: number; maxLength?: number;
@ -153,6 +153,17 @@ export function CompositionTextArea({
scrollerRef={scrollerRef} scrollerRef={scrollerRef}
sendCounter={0} sendCounter={0}
theme={theme} 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}
/> />
<div className="CompositionTextArea__emoji"> <div className="CompositionTextArea__emoji">
<EmojiButton <EmojiButton

View file

@ -470,7 +470,7 @@ function ForwardMessageEditor({
) : null} ) : null}
<RenderCompositionTextArea <RenderCompositionTextArea
bodyRanges={draft.bodyRanges} bodyRanges={draft.bodyRanges ?? null}
draftText={draft.messageBody ?? ''} draftText={draft.messageBody ?? ''}
onChange={onChange} onChange={onChange}
onSubmit={onSubmit} onSubmit={onSubmit}

View file

@ -9,6 +9,7 @@ import type {
EditHistoryMessagesType, EditHistoryMessagesType,
FormattingWarningDataType, FormattingWarningDataType,
ForwardMessagesPropsType, ForwardMessagesPropsType,
MessageRequestActionsConfirmationPropsType,
SafetyNumberChangedBlockingDataType, SafetyNumberChangedBlockingDataType,
SendEditWarningDataType, SendEditWarningDataType,
UserNotFoundModalStateType, UserNotFoundModalStateType,
@ -59,6 +60,9 @@ export type PropsType = {
// ForwardMessageModal // ForwardMessageModal
forwardMessagesProps: ForwardMessagesPropsType | undefined; forwardMessagesProps: ForwardMessagesPropsType | undefined;
renderForwardMessagesModal: () => JSX.Element; renderForwardMessagesModal: () => JSX.Element;
// MessageRequestActionsConfirmation
messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null;
renderMessageRequestActionsConfirmation: () => JSX.Element;
// ProfileEditor // ProfileEditor
isProfileEditorVisible: boolean; isProfileEditorVisible: boolean;
renderProfileEditor: () => JSX.Element; renderProfileEditor: () => JSX.Element;
@ -130,6 +134,9 @@ export function GlobalModalContainer({
// ForwardMessageModal // ForwardMessageModal
forwardMessagesProps, forwardMessagesProps,
renderForwardMessagesModal, renderForwardMessagesModal,
// MessageRequestActionsConfirmation
messageRequestActionsConfirmationProps,
renderMessageRequestActionsConfirmation,
// ProfileEditor // ProfileEditor
isProfileEditorVisible, isProfileEditorVisible,
renderProfileEditor, renderProfileEditor,
@ -223,6 +230,10 @@ export function GlobalModalContainer({
return renderForwardMessagesModal(); return renderForwardMessagesModal();
} }
if (messageRequestActionsConfirmationProps) {
return renderMessageRequestActionsConfirmation();
}
if (isProfileEditorVisible) { if (isProfileEditorVisible) {
return renderProfileEditor(); return renderProfileEditor();
} }

View file

@ -176,13 +176,12 @@ export function MediaEditor({
const [isEmojiPopperOpen, setEmojiPopperOpen] = useState<boolean>(false); const [isEmojiPopperOpen, setEmojiPopperOpen] = useState<boolean>(false);
const [caption, setCaption] = useState(draftText ?? ''); const [caption, setCaption] = useState(draftText ?? '');
const [captionBodyRanges, setCaptionBodyRanges] = useState< const [captionBodyRanges, setCaptionBodyRanges] =
DraftBodyRanges | undefined useState<DraftBodyRanges | null>(draftBodyRanges);
>(draftBodyRanges);
const conversationSelector = useSelector(getConversationSelector); const conversationSelector = useSelector(getConversationSelector);
const hydratedBodyRanges = useMemo( const hydratedBodyRanges = useMemo(
() => hydrateRanges(captionBodyRanges, conversationSelector), () => hydrateRanges(captionBodyRanges ?? undefined, conversationSelector),
[captionBodyRanges, conversationSelector] [captionBodyRanges, conversationSelector]
); );
@ -1297,7 +1296,7 @@ export function MediaEditor({
<div className="MediaEditor__tools--input dark-theme"> <div className="MediaEditor__tools--input dark-theme">
<CompositionInput <CompositionInput
draftText={caption} draftText={caption}
draftBodyRanges={hydratedBodyRanges} draftBodyRanges={hydratedBodyRanges ?? null}
getPreferredBadge={getPreferredBadge} getPreferredBadge={getPreferredBadge}
i18n={i18n} i18n={i18n}
inputApi={inputApiRef} inputApi={inputApiRef}
@ -1308,6 +1307,7 @@ export function MediaEditor({
setCaptionBodyRanges(bodyRanges); setCaptionBodyRanges(bodyRanges);
setCaption(messageText); setCaption(messageText);
}} }}
skinTone={skinTone ?? null}
onPickEmoji={onPickEmoji} onPickEmoji={onPickEmoji}
onSubmit={noop} onSubmit={noop}
onTextTooLong={onTextTooLong} onTextTooLong={onTextTooLong}
@ -1316,6 +1316,16 @@ export function MediaEditor({
sendCounter={0} sendCounter={0}
sortedGroupMembers={sortedGroupMembers} sortedGroupMembers={sortedGroupMembers}
theme={ThemeType.dark} theme={ThemeType.dark}
// Only needed for state updates and we need to override those
conversationId={null}
// Cannot enter media editor while editing
draftEditMessage={null}
// We don't use the large editor mode
large={null}
// panels do not appear over the media editor
shouldHidePopovers={null}
// link previews not displayed with media
linkPreviewResult={null}
> >
<EmojiButton <EmojiButton
className="StoryViewsNRepliesModal__emoji-button" className="StoryViewsNRepliesModal__emoji-button"
@ -1394,7 +1404,7 @@ export function MediaEditor({
contentType: IMAGE_PNG, contentType: IMAGE_PNG,
data, data,
caption: caption !== '' ? caption : undefined, caption: caption !== '' ? caption : undefined,
captionBodyRanges, captionBodyRanges: captionBodyRanges ?? undefined,
blurHash, blurHash,
}); });
}} }}

View file

@ -7,6 +7,7 @@ import classNames from 'classnames';
import { noop } from 'lodash'; import { noop } from 'lodash';
import { animated } from '@react-spring/web'; import { animated } from '@react-spring/web';
import { v4 as uuid } from 'uuid';
import type { LocalizerType } from '../types/Util'; import type { LocalizerType } from '../types/Util';
import { ModalHost } from './ModalHost'; import { ModalHost } from './ModalHost';
import type { Theme } from '../util/theme'; import type { Theme } from '../util/theme';
@ -37,6 +38,7 @@ type PropsType = {
title?: ReactNode; title?: ReactNode;
useFocusTrap?: boolean; useFocusTrap?: boolean;
padded?: boolean; padded?: boolean;
['aria-describedby']?: string;
}; };
export type ModalPropsType = PropsType & { export type ModalPropsType = PropsType & {
@ -65,6 +67,7 @@ export function Modal({
hasFooterDivider = false, hasFooterDivider = false,
noTransform = false, noTransform = false,
padded = true, padded = true,
'aria-describedby': ariaDescribedBy,
}: Readonly<ModalPropsType>): JSX.Element | null { }: Readonly<ModalPropsType>): JSX.Element | null {
const { close, isClosed, modalStyles, overlayStyles } = useAnimated( const { close, isClosed, modalStyles, overlayStyles } = useAnimated(
onClose, onClose,
@ -132,6 +135,7 @@ export function Modal({
padded={padded} padded={padded}
hasHeaderDivider={hasHeaderDivider} hasHeaderDivider={hasHeaderDivider}
hasFooterDivider={hasFooterDivider} hasFooterDivider={hasFooterDivider}
aria-describedby={ariaDescribedBy}
> >
{children} {children}
</ModalPage> </ModalPage>
@ -173,6 +177,7 @@ export function ModalPage({
padded = true, padded = true,
hasHeaderDivider = false, hasHeaderDivider = false,
hasFooterDivider = false, hasFooterDivider = false,
'aria-describedby': ariaDescribedBy,
}: ModalPageProps): JSX.Element { }: ModalPageProps): JSX.Element {
const modalRef = useRef<HTMLDivElement | null>(null); const modalRef = useRef<HTMLDivElement | null>(null);
@ -188,6 +193,8 @@ export function ModalPage({
); );
const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName); const getClassName = getClassNamesFor(BASE_CLASS_NAME, moduleClassName);
const [id] = useState(() => uuid());
useScrollObserver(bodyRef, bodyInnerRef, scroll => { useScrollObserver(bodyRef, bodyInnerRef, scroll => {
setScrolled(isScrolled(scroll)); setScrolled(isScrolled(scroll));
setScrolledToBottom(isScrolledToBottom(scroll)); setScrolledToBottom(isScrolledToBottom(scroll));
@ -198,7 +205,7 @@ export function ModalPage({
<> <>
{/* We don't want the click event to propagate to its container node. */} {/* We don't want the click event to propagate to its container node. */}
{/* eslint-disable-next-line max-len */} {/* 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 */}
<div <div
className={classNames( className={classNames(
getClassName(''), getClassName(''),
@ -209,6 +216,10 @@ export function ModalPage({
hasFooterDivider && getClassName('--footer-divider') hasFooterDivider && getClassName('--footer-divider')
)} )}
ref={modalRef} ref={modalRef}
role="dialog"
tabIndex={-1}
aria-labelledby={title ? `${id}-title` : undefined}
aria-describedby={ariaDescribedBy}
onClick={event => { onClick={event => {
event.stopPropagation(); event.stopPropagation();
}} }}
@ -234,6 +245,7 @@ export function ModalPage({
)} )}
{title && ( {title && (
<h1 <h1
id={`${id}-title`}
className={classNames( className={classNames(
getClassName('__title'), getClassName('__title'),
hasXButton ? getClassName('__title--with-x-button') : null hasXButton ? getClassName('__title--with-x-button') : null

View file

@ -0,0 +1,24 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import { action } from '@storybook/addon-actions';
import enMessages from '../../_locales/en/messages.json';
import type { ComponentMeta } from '../storybook/types';
import { setupI18n } from '../util/setupI18n';
import type { SafetyTipsModalProps } from './SafetyTipsModal';
import { SafetyTipsModal } from './SafetyTipsModal';
const i18n = setupI18n('en', enMessages);
export default {
title: 'Components/SafetyTipsModal',
component: SafetyTipsModal,
args: {
i18n,
onClose: action('onClose'),
},
} satisfies ComponentMeta<SafetyTipsModalProps>;
export function Default(args: SafetyTipsModalProps): JSX.Element {
return <SafetyTipsModal {...args} />;
}

View file

@ -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<NodeJS.Timeout | null>(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 (
<Modal
i18n={i18n}
modalName="SafetyTipsModal"
moduleClassName="SafetyTipsModal"
noMouseClose
hasXButton
padded={false}
title={i18n('icu:SafetyTipsModal__Title')}
onClose={onClose}
aria-describedby={`${modalId}-description`}
modalFooter={
<>
<Button
className="SafetyTipsModal__Button SafetyTipsModal__Button--Previous"
variant={ButtonVariant.SecondaryAffirmative}
aria-disabled={pageIndex === 0}
aria-controls={cardWrapperId}
onClick={() => {
if (pageIndex === 0) {
return;
}
scrollToPageIndex(pageIndex - 1);
}}
>
{i18n('icu:SafetyTipsModal__Button--Previous')}
</Button>
{pageIndex < maxPageIndex ? (
<Button
className="SafetyTipsModal__Button SafetyTipsModal__Button--Next"
variant={ButtonVariant.Primary}
aria-controls={cardWrapperId}
onClick={() => {
if (pageIndex === maxPageIndex) {
return;
}
scrollToPageIndex(pageIndex + 1);
}}
>
{i18n('icu:SafetyTipsModal__Button--Next')}
</Button>
) : (
<Button
className="SafetyTipsModal__Button SafetyTipsModal__Button--Next"
variant={ButtonVariant.Primary}
onClick={onClose}
>
{i18n('icu:SafetyTipsModal__Button--Done')}
</Button>
)}
</>
}
>
<p className="SafetyTipsModal__Description" id={`${modalId}-description`}>
{i18n('icu:SafetyTipsModal__Description')}
</p>
<div>
<div
className="SafetyTipsModal__CardWrapper"
id={cardWrapperId}
aria-live={hasPageIndexChanged ? 'assertive' : undefined}
aria-atomic
onScroll={handleScroll}
>
{pages.map((page, index) => {
const isCurrentPage = pageIndex === index;
return (
<div
id={getCardIdForPage(index)}
key={page.key}
className="SafetyTipsModal__Card"
aria-hidden={!isCurrentPage}
>
<img
role="presentation"
alt=""
className="SafetyTipsModal__CardImage"
src={page.imageUrl}
width={664}
height={314}
/>
<h2 className="SafetyTipsModal__CardTitle">{page.title}</h2>
<p className="SafetyTipsModal__CardDescription">
{page.description}
</p>
</div>
);
})}
</div>
<div className="SafetyTipsModal__Dots">
{pages.map((page, index) => {
const isCurrentPage = pageIndex === index;
return (
<button
key={page.key}
className="SafetyTipsModal__DotsButton"
type="button"
aria-controls={cardWrapperId}
aria-current={isCurrentPage ? 'step' : undefined}
onClick={() => {
scrollToPageIndex(index);
}}
>
<div className="SafetyTipsModal__DotsButtonLabel">
{i18n('icu:SafetyTipsModal__DotLabel', {
page: index + 1,
})}
</div>
</button>
);
})}
</div>
</div>
</Modal>
);
}

View file

@ -96,7 +96,11 @@ export type PropsType = {
> & > &
Pick< Pick<
MediaEditorPropsType, MediaEditorPropsType,
'isFormattingEnabled' | 'onPickEmoji' | 'onTextTooLong' | 'platform' | 'isFormattingEnabled'
| 'onPickEmoji'
| 'onTextTooLong'
| 'platform'
| 'sortedGroupMembers'
>; >;
export function StoryCreator({ export function StoryCreator({
@ -139,6 +143,7 @@ export function StoryCreator({
setMyStoriesToAllSignalConnections, setMyStoriesToAllSignalConnections,
signalConnections, signalConnections,
skinTone, skinTone,
sortedGroupMembers,
theme, theme,
toggleGroupsForStorySend, toggleGroupsForStorySend,
toggleSignalConnectionsModal, toggleSignalConnectionsModal,
@ -272,6 +277,9 @@ export function StoryCreator({
platform={platform} platform={platform}
recentStickers={recentStickers} recentStickers={recentStickers}
skinTone={skinTone} skinTone={skinTone}
sortedGroupMembers={sortedGroupMembers}
draftText={null}
draftBodyRanges={null}
/> />
)} )}
{!file && ( {!file && (

View file

@ -258,8 +258,15 @@ export function StoryViewsNRepliesModal({
} }
platform={platform} platform={platform}
sendCounter={0} sendCounter={0}
sortedGroupMembers={sortedGroupMembers} skinTone={skinTone ?? null}
sortedGroupMembers={sortedGroupMembers ?? null}
theme={ThemeType.dark} theme={ThemeType.dark}
conversationId={null}
draftBodyRanges={null}
draftEditMessage={null}
large={null}
shouldHidePopovers={null}
linkPreviewResult={null}
> >
<EmojiButton <EmojiButton
className="StoryViewsNRepliesModal__emoji-button" className="StoryViewsNRepliesModal__emoji-button"

View file

@ -121,6 +121,8 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.PinnedConversationsFull }; return { toastType: ToastType.PinnedConversationsFull };
case ToastType.ReactionFailed: case ToastType.ReactionFailed:
return { toastType: ToastType.ReactionFailed }; return { toastType: ToastType.ReactionFailed };
case ToastType.ReportedSpam:
return { toastType: ToastType.ReportedSpam };
case ToastType.ReportedSpamAndBlocked: case ToastType.ReportedSpamAndBlocked:
return { toastType: ToastType.ReportedSpamAndBlocked }; return { toastType: ToastType.ReportedSpamAndBlocked };
case ToastType.StickerPackInstallFailed: case ToastType.StickerPackInstallFailed:

View file

@ -371,6 +371,14 @@ export function renderToast({
return <Toast onClose={hideToast}>{i18n('icu:Reactions--error')}</Toast>; return <Toast onClose={hideToast}>{i18n('icu:Reactions--error')}</Toast>;
} }
if (toastType === ToastType.ReportedSpam) {
return (
<Toast onClose={hideToast}>
{i18n('icu:MessageRequests--report-spam-success-toast')}
</Toast>
);
}
if (toastType === ToastType.ReportedSpamAndBlocked) { if (toastType === ToastType.ReportedSpamAndBlocked) {
return ( return (
<Toast onClose={hideToast}> <Toast onClose={hideToast}>

View file

@ -1,21 +1,47 @@
// Copyright 2018 Signal Messenger, LLC // Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useMemo } from 'react';
import classNames from 'classnames'; import classNames from 'classnames';
import { Emojify } from './Emojify'; import { Emojify } from './Emojify';
import type { ContactNameColorType } from '../../types/Colors'; import type { ContactNameColorType } from '../../types/Colors';
import { getClassNamesFor } from '../../util/getClassNamesFor'; 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; contactNameColor?: ContactNameColorType;
firstName?: string; firstName?: string;
isSignalConversation?: boolean; isSignalConversation?: boolean;
isMe?: 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; module?: string;
preferFirstName?: boolean; preferFirstName?: boolean;
title: string;
onClick?: VoidFunction; onClick?: VoidFunction;
}; };

View file

@ -21,6 +21,7 @@ export default {
const getCommonProps = () => ({ const getCommonProps = () => ({
acceptConversation: action('acceptConversation'), acceptConversation: action('acceptConversation'),
reportSpam: action('reportSpam'),
blockAndReportSpam: action('blockAndReportSpam'), blockAndReportSpam: action('blockAndReportSpam'),
blockConversation: action('blockConversation'), blockConversation: action('blockConversation'),
conversationId: 'some-conversation-id', conversationId: 'some-conversation-id',

View file

@ -50,6 +50,7 @@ export type ReviewPropsType = Readonly<
export type PropsType = { export type PropsType = {
conversationId: string; conversationId: string;
acceptConversation: (conversationId: string) => unknown; acceptConversation: (conversationId: string) => unknown;
reportSpam: (conversationId: string) => unknown;
blockAndReportSpam: (conversationId: string) => unknown; blockAndReportSpam: (conversationId: string) => unknown;
blockConversation: (conversationId: string) => unknown; blockConversation: (conversationId: string) => unknown;
deleteConversation: (conversationId: string) => unknown; deleteConversation: (conversationId: string) => unknown;
@ -75,6 +76,7 @@ enum ConfirmationStateType {
export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element { export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
const { const {
acceptConversation, acceptConversation,
reportSpam,
blockAndReportSpam, blockAndReportSpam,
blockConversation, blockConversation,
conversationId, conversationId,
@ -111,19 +113,23 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
case ConfirmationStateType.ConfirmingBlock: case ConfirmationStateType.ConfirmingBlock:
return ( return (
<MessageRequestActionsConfirmation <MessageRequestActionsConfirmation
acceptConversation={acceptConversation} addedByName={affectedConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
conversationId={affectedConversation.id} conversationId={affectedConversation.id}
conversationType="direct" conversationType={affectedConversation.type}
deleteConversation={deleteConversation} conversationName={affectedConversation}
i18n={i18n} i18n={i18n}
title={affectedConversation.title} isBlocked={affectedConversation.isBlocked ?? false}
isReported={affectedConversation.isReported ?? false}
state={ state={
type === ConfirmationStateType.ConfirmingDelete type === ConfirmationStateType.ConfirmingDelete
? MessageRequestState.deleting ? MessageRequestState.deleting
: MessageRequestState.blocking : MessageRequestState.blocking
} }
acceptConversation={acceptConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
deleteConversation={deleteConversation}
onChangeState={messageRequestState => { onChangeState={messageRequestState => {
switch (messageRequestState) { switch (messageRequestState) {
case MessageRequestState.blocking: case MessageRequestState.blocking:
@ -138,10 +144,12 @@ export function ContactSpoofingReviewDialog(props: PropsType): JSX.Element {
affectedConversation, affectedConversation,
}); });
break; break;
case MessageRequestState.reportingAndMaybeBlocking:
case MessageRequestState.acceptedOptions:
case MessageRequestState.unblocking: case MessageRequestState.unblocking:
assertDev( assertDev(
false, false,
'Got unexpected MessageRequestState.unblocking state. Clearing confiration state' `Got unexpected MessageRequestState.${MessageRequestState[messageRequestState]} state. Clearing confiration state`
); );
setConfirmationState(undefined); setConfirmationState(undefined);
break; break;

View file

@ -29,8 +29,15 @@ type ItemsType = Array<{
props: Omit<ComponentProps<typeof ConversationHeader>, 'theme'>; props: Omit<ComponentProps<typeof ConversationHeader>, 'theme'>;
}>; }>;
const commonConversation = getDefaultConversation();
const commonProps = { const commonProps = {
...getDefaultConversation(), ...commonConversation,
conversationId: commonConversation.id,
conversationType: commonConversation.type,
conversationName: commonConversation,
addedByName: null,
isBlocked: commonConversation.isBlocked ?? false,
isReported: commonConversation.isReported ?? false,
cannotLeaveBecauseYouAreLastAdmin: false, cannotLeaveBecauseYouAreLastAdmin: false,
showBackButton: false, showBackButton: false,
@ -59,6 +66,12 @@ const commonProps = {
setMuteExpiration: action('onSetMuteNotifications'), setMuteExpiration: action('onSetMuteNotifications'),
setPinned: action('setPinned'), setPinned: action('setPinned'),
viewUserStories: action('viewUserStories'), viewUserStories: action('viewUserStories'),
acceptConversation: action('acceptConversation'),
blockAndReportSpam: action('blockAndReportSpam'),
blockConversation: action('blockConversation'),
reportSpam: action('reportSpam'),
deleteConversation: action('deleteConversation'),
}; };
export function PrivateConvo(): JSX.Element { export function PrivateConvo(): JSX.Element {

View file

@ -41,6 +41,12 @@ import { PanelType } from '../../types/Panels';
import { UserText } from '../UserText'; import { UserText } from '../UserText';
import { Alert } from '../Alert'; import { Alert } from '../Alert';
import { SizeObserver } from '../../hooks/useSizeObserver'; import { SizeObserver } from '../../hooks/useSizeObserver';
import type { MessageRequestActionsConfirmationBaseProps } from './MessageRequestActionsConfirmation';
import {
MessageRequestActionsConfirmation,
MessageRequestState,
} from './MessageRequestActionsConfirmation';
import type { ContactNameData } from './ContactName';
export enum OutgoingCallButtonStyle { export enum OutgoingCallButtonStyle {
None, None,
@ -60,6 +66,8 @@ export type PropsDataType = {
isSelectMode: boolean; isSelectMode: boolean;
isSignalConversation?: boolean; isSignalConversation?: boolean;
theme: ThemeType; theme: ThemeType;
addedByName: ContactNameData | null;
conversationName: ContactNameData;
} & Pick< } & Pick<
ConversationType, ConversationType,
| 'acceptedMessageRequest' | 'acceptedMessageRequest'
@ -72,6 +80,8 @@ export type PropsDataType = {
| 'groupVersion' | 'groupVersion'
| 'id' | 'id'
| 'isArchived' | 'isArchived'
| 'isBlocked'
| 'isReported'
| 'isMe' | 'isMe'
| 'isPinned' | 'isPinned'
| 'isVerified' | 'isVerified'
@ -81,6 +91,7 @@ export type PropsDataType = {
| 'name' | 'name'
| 'phoneNumber' | 'phoneNumber'
| 'profileName' | 'profileName'
| 'removalStage'
| 'sharedGroupNames' | 'sharedGroupNames'
| 'title' | 'title'
| 'type' | 'type'
@ -106,7 +117,7 @@ export type PropsActionsType = {
setMuteExpiration: (conversationId: string, seconds: number) => void; setMuteExpiration: (conversationId: string, seconds: number) => void;
setPinned: (conversationId: string, value: boolean) => void; setPinned: (conversationId: string, value: boolean) => void;
viewUserStories: ViewUserStoriesActionCreatorType; viewUserStories: ViewUserStoriesActionCreatorType;
}; } & MessageRequestActionsConfirmationBaseProps;
export type PropsHousekeepingType = { export type PropsHousekeepingType = {
i18n: LocalizerType; i18n: LocalizerType;
@ -127,6 +138,7 @@ type StateType = {
hasCannotLeaveGroupBecauseYouAreLastAdminAlert: boolean; hasCannotLeaveGroupBecauseYouAreLastAdminAlert: boolean;
isNarrow: boolean; isNarrow: boolean;
modalState: ModalState; modalState: ModalState;
messageRequestState: MessageRequestState;
}; };
const TIMER_ITEM_CLASS = 'module-ConversationHeader__disappearing-timer__item'; const TIMER_ITEM_CLASS = 'module-ConversationHeader__disappearing-timer__item';
@ -149,6 +161,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
hasCannotLeaveGroupBecauseYouAreLastAdminAlert: false, hasCannotLeaveGroupBecauseYouAreLastAdminAlert: false,
isNarrow: false, isNarrow: false,
modalState: ModalState.NothingOpen, modalState: ModalState.NothingOpen,
messageRequestState: MessageRequestState.default,
}; };
this.menuTriggerRef = React.createRef(); this.menuTriggerRef = React.createRef();
@ -156,6 +169,12 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
this.showMenuBound = this.showMenu.bind(this); this.showMenuBound = this.showMenu.bind(this);
} }
private handleMessageRequestStateChange = (
state: MessageRequestState
): void => {
this.setState({ messageRequestState: state });
};
private showMenu(event: React.MouseEvent<HTMLButtonElement>): void { private showMenu(event: React.MouseEvent<HTMLButtonElement>): void {
if (this.menuTriggerRef.current) { if (this.menuTriggerRef.current) {
this.menuTriggerRef.current.handleContextClick(event); this.menuTriggerRef.current.handleContextClick(event);
@ -328,6 +347,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
private renderMenu(triggerId: string): ReactNode { private renderMenu(triggerId: string): ReactNode {
const { const {
acceptConversation,
acceptedMessageRequest, acceptedMessageRequest,
canChangeTimer, canChangeTimer,
cannotLeaveBecauseYouAreLastAdmin, cannotLeaveBecauseYouAreLastAdmin,
@ -336,6 +356,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
i18n, i18n,
id, id,
isArchived, isArchived,
isBlocked,
isMissingMandatoryProfileSharing, isMissingMandatoryProfileSharing,
isPinned, isPinned,
isSignalConversation, isSignalConversation,
@ -431,6 +452,7 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{i18n('icu:archiveConversation')} {i18n('icu:archiveConversation')}
</MenuItem> </MenuItem>
)} )}
<MenuItem <MenuItem
onClick={() => onClick={() =>
this.setState({ hasDeleteMessagesConfirmation: true }) this.setState({ hasDeleteMessagesConfirmation: true })
@ -491,98 +513,164 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
</MenuItem> </MenuItem>
); );
}); });
return createPortal( return createPortal(
<ContextMenu id={triggerId} rtl={isRTL}> <ContextMenu id={triggerId} rtl={isRTL}>
{disableTimerChanges ? null : ( {!acceptedMessageRequest && (
<SubMenu hoverDelay={1} title={disappearingTitle} rtl={!isRTL}> <>
{expireDurations} {!isBlocked && (
</SubMenu> <MenuItem
)} onClick={() => {
<SubMenu hoverDelay={1} title={muteTitle} rtl={!isRTL}> this.setState({
{muteOptions.map(item => ( messageRequestState: MessageRequestState.blocking,
});
}}
>
{i18n('icu:ConversationHeader__MenuItem--Block')}
</MenuItem>
)}
{isBlocked && (
<MenuItem
onClick={() => {
this.setState({
messageRequestState: MessageRequestState.unblocking,
});
}}
>
{i18n('icu:ConversationHeader__MenuItem--Unblock')}
</MenuItem>
)}
{!isBlocked && (
<MenuItem onClick={acceptConversation}>
{i18n('icu:ConversationHeader__MenuItem--Accept')}
</MenuItem>
)}
<MenuItem <MenuItem
key={item.name}
disabled={item.disabled}
onClick={() => { onClick={() => {
setMuteExpiration(id, item.value); this.setState({
messageRequestState:
MessageRequestState.reportingAndMaybeBlocking,
});
}} }}
> >
{item.name} {i18n('icu:ConversationHeader__MenuItem--ReportSpam')}
</MenuItem> </MenuItem>
))} <MenuItem
</SubMenu> onClick={() => {
{!isGroup || hasGV2AdminEnabled ? (
<MenuItem
onClick={() =>
pushPanelForConversation({
type: PanelType.ConversationDetails,
})
}
>
{isGroup
? i18n('icu:showConversationDetails')
: i18n('icu:showConversationDetails--direct')}
</MenuItem>
) : null}
<MenuItem
onClick={() => pushPanelForConversation({ type: PanelType.AllMedia })}
>
{i18n('icu:viewRecentMedia')}
</MenuItem>
<MenuItem divider />
<MenuItem
onClick={() => {
toggleSelectMode(true);
}}
>
{i18n('icu:ConversationHeader__menu__selectMessages')}
</MenuItem>
<MenuItem divider />
{!markedUnread ? (
<MenuItem onClick={() => onMarkUnread(id)}>
{i18n('icu:markUnread')}
</MenuItem>
) : null}
{isPinned ? (
<MenuItem onClick={() => setPinned(id, false)}>
{i18n('icu:unpinConversation')}
</MenuItem>
) : (
<MenuItem onClick={() => setPinned(id, true)}>
{i18n('icu:pinConversation')}
</MenuItem>
)}
{isArchived ? (
<MenuItem onClick={() => onMoveToInbox(id)}>
{i18n('icu:moveConversationToInbox')}
</MenuItem>
) : (
<MenuItem onClick={() => onArchive(id)}>
{i18n('icu:archiveConversation')}
</MenuItem>
)}
<MenuItem
onClick={() => this.setState({ hasDeleteMessagesConfirmation: true })}
>
{i18n('icu:deleteMessagesInConversation')}
</MenuItem>
{isGroup && (
<MenuItem
onClick={() => {
if (cannotLeaveBecauseYouAreLastAdmin) {
this.setState({ this.setState({
hasCannotLeaveGroupBecauseYouAreLastAdminAlert: true, messageRequestState: MessageRequestState.deleting,
}); });
} else { }}
this.setState({ hasLeaveGroupConfirmation: true }); >
} {i18n('icu:ConversationHeader__MenuItem--DeleteChat')}
}} </MenuItem>
> </>
{i18n( )}
'icu:ConversationHeader__ContextMenu__LeaveGroupAction__title' {acceptedMessageRequest && (
<>
{disableTimerChanges ? null : (
<SubMenu hoverDelay={1} title={disappearingTitle} rtl={!isRTL}>
{expireDurations}
</SubMenu>
)} )}
</MenuItem> <SubMenu hoverDelay={1} title={muteTitle} rtl={!isRTL}>
{muteOptions.map(item => (
<MenuItem
key={item.name}
disabled={item.disabled}
onClick={() => {
setMuteExpiration(id, item.value);
}}
>
{item.name}
</MenuItem>
))}
</SubMenu>
{!isGroup || hasGV2AdminEnabled ? (
<MenuItem
onClick={() =>
pushPanelForConversation({
type: PanelType.ConversationDetails,
})
}
>
{isGroup
? i18n('icu:showConversationDetails')
: i18n('icu:showConversationDetails--direct')}
</MenuItem>
) : null}
<MenuItem
onClick={() =>
pushPanelForConversation({ type: PanelType.AllMedia })
}
>
{i18n('icu:viewRecentMedia')}
</MenuItem>
<MenuItem divider />
<MenuItem
onClick={() => {
toggleSelectMode(true);
}}
>
{i18n('icu:ConversationHeader__menu__selectMessages')}
</MenuItem>
<MenuItem divider />
{!markedUnread ? (
<MenuItem onClick={() => onMarkUnread(id)}>
{i18n('icu:markUnread')}
</MenuItem>
) : null}
{isPinned ? (
<MenuItem onClick={() => setPinned(id, false)}>
{i18n('icu:unpinConversation')}
</MenuItem>
) : (
<MenuItem onClick={() => setPinned(id, true)}>
{i18n('icu:pinConversation')}
</MenuItem>
)}
{isArchived ? (
<MenuItem onClick={() => onMoveToInbox(id)}>
{i18n('icu:moveConversationToInbox')}
</MenuItem>
) : (
<MenuItem onClick={() => onArchive(id)}>
{i18n('icu:archiveConversation')}
</MenuItem>
)}
<MenuItem
onClick={() => {
this.setState({
messageRequestState: MessageRequestState.blocking,
});
}}
>
{i18n('icu:ConversationHeader__MenuItem--Block')}
</MenuItem>
<MenuItem
onClick={() =>
this.setState({ hasDeleteMessagesConfirmation: true })
}
>
{i18n('icu:deleteMessagesInConversation')}
</MenuItem>
{isGroup && (
<MenuItem
onClick={() => {
if (cannotLeaveBecauseYouAreLastAdmin) {
this.setState({
hasCannotLeaveGroupBecauseYouAreLastAdminAlert: true,
});
} else {
this.setState({ hasLeaveGroupConfirmation: true });
}
}}
>
{i18n(
'icu:ConversationHeader__ContextMenu__LeaveGroupAction__title'
)}
</MenuItem>
)}
</>
)} )}
</ContextMenu>, </ContextMenu>,
document.body document.body
@ -751,25 +839,35 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
public override render(): ReactNode { public override render(): ReactNode {
const { const {
addedByName,
announcementsOnly, announcementsOnly,
areWeAdmin, areWeAdmin,
conversationName,
expireTimer, expireTimer,
hasPanelShowing, hasPanelShowing,
i18n, i18n,
id, id,
isBlocked,
isReported,
isSMSOnly, isSMSOnly,
isSignalConversation, isSignalConversation,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
outgoingCallButtonStyle, outgoingCallButtonStyle,
setDisappearingMessages, setDisappearingMessages,
type,
acceptConversation,
blockAndReportSpam,
blockConversation,
reportSpam,
deleteConversation,
} = this.props; } = this.props;
if (hasPanelShowing) { if (hasPanelShowing) {
return null; return null;
} }
const { isNarrow, modalState } = this.state; const { isNarrow, modalState, messageRequestState } = this.state;
const triggerId = `conversation-${id}`; const triggerId = `conversation-${id}`;
let modalNode: ReactNode; let modalNode: ReactNode;
@ -829,6 +927,22 @@ export class ConversationHeader extends React.Component<PropsType, StateType> {
{this.renderSearchButton()} {this.renderSearchButton()}
{this.renderMoreButton(triggerId)} {this.renderMoreButton(triggerId)}
{this.renderMenu(triggerId)} {this.renderMenu(triggerId)}
<MessageRequestActionsConfirmation
i18n={i18n}
conversationId={id}
conversationType={type}
addedByName={addedByName}
conversationName={conversationName}
isBlocked={isBlocked ?? false}
isReported={isReported ?? false}
state={messageRequestState}
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
reportSpam={reportSpam}
deleteConversation={deleteConversation}
onChangeState={this.handleMessageRequestStateChange}
/>
</div> </div>
)} )}
</SizeObserver> </SizeObserver>

View file

@ -15,6 +15,8 @@ import { StoryViewModeType } from '../../types/Stories';
import { ConfirmationDialog } from '../ConfirmationDialog'; import { ConfirmationDialog } from '../ConfirmationDialog';
import { shouldBlurAvatar } from '../../util/shouldBlurAvatar'; import { shouldBlurAvatar } from '../../util/shouldBlurAvatar';
import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser'; import { openLinkInWebBrowser } from '../../util/openLinkInWebBrowser';
import { Button, ButtonVariant } from '../Button';
import { SafetyTipsModal } from '../SafetyTipsModal';
export type Props = { export type Props = {
about?: string; about?: string;
@ -42,6 +44,7 @@ const renderMembershipRow = ({
i18n, i18n,
isMe, isMe,
onClickMessageRequestWarning, onClickMessageRequestWarning,
onToggleSafetyTips,
phoneNumber, phoneNumber,
sharedGroupNames, sharedGroupNames,
}: Pick< }: Pick<
@ -54,6 +57,7 @@ const renderMembershipRow = ({
> & > &
Required<Pick<Props, 'sharedGroupNames'>> & { Required<Pick<Props, 'sharedGroupNames'>> & {
onClickMessageRequestWarning: () => void; onClickMessageRequestWarning: () => void;
onToggleSafetyTips: (showSafetyTips: boolean) => void;
}) => { }) => {
if (conversationType !== 'direct') { if (conversationType !== 'direct') {
return null; return null;
@ -67,6 +71,20 @@ const renderMembershipRow = ({
); );
} }
const safetyTipsButton = (
<div>
<Button
className="module-conversation-hero__safety-tips-button"
variant={ButtonVariant.SecondaryAffirmative}
onClick={() => {
onToggleSafetyTips(true);
}}
>
{i18n('icu:MessageRequestWarning__safety-tips')}
</Button>
</div>
);
if (sharedGroupNames.length > 0) { if (sharedGroupNames.length > 0) {
return ( return (
<div className="module-conversation-hero__membership"> <div className="module-conversation-hero__membership">
@ -76,6 +94,7 @@ const renderMembershipRow = ({
nameClassName="module-conversation-hero__membership__name" nameClassName="module-conversation-hero__membership__name"
sharedGroupNames={sharedGroupNames} sharedGroupNames={sharedGroupNames}
/> />
{safetyTipsButton}
</div> </div>
); );
} }
@ -86,6 +105,7 @@ const renderMembershipRow = ({
return ( return (
<div className="module-conversation-hero__membership"> <div className="module-conversation-hero__membership">
{i18n('icu:no-groups-in-common')} {i18n('icu:no-groups-in-common')}
{safetyTipsButton}
</div> </div>
); );
} }
@ -107,6 +127,7 @@ const renderMembershipRow = ({
{i18n('icu:MessageRequestWarning__learn-more')} {i18n('icu:MessageRequestWarning__learn-more')}
</button> </button>
</div> </div>
{safetyTipsButton}
</div> </div>
); );
}; };
@ -136,6 +157,7 @@ export function ConversationHero({
viewUserStories, viewUserStories,
toggleAboutContactModal, toggleAboutContactModal,
}: Props): JSX.Element { }: Props): JSX.Element {
const [isShowingSafetyTips, setIsShowingSafetyTips] = useState(false);
const [isShowingMessageRequestWarning, setIsShowingMessageRequestWarning] = const [isShowingMessageRequestWarning, setIsShowingMessageRequestWarning] =
useState(false); useState(false);
const closeMessageRequestWarning = () => { const closeMessageRequestWarning = () => {
@ -248,6 +270,9 @@ export function ConversationHero({
onClickMessageRequestWarning() { onClickMessageRequestWarning() {
setIsShowingMessageRequestWarning(true); setIsShowingMessageRequestWarning(true);
}, },
onToggleSafetyTips(showSafetyTips: boolean) {
setIsShowingSafetyTips(showSafetyTips);
},
phoneNumber, phoneNumber,
sharedGroupNames, sharedGroupNames,
})} })}
@ -277,6 +302,15 @@ export function ConversationHero({
{i18n('icu:MessageRequestWarning__dialog__details')} {i18n('icu:MessageRequestWarning__dialog__details')}
</ConfirmationDialog> </ConfirmationDialog>
)} )}
{isShowingSafetyTips && (
<SafetyTipsModal
i18n={i18n}
onClose={() => {
setIsShowingSafetyTips(false);
}}
/>
)}
</> </>
); );
/* eslint-enable no-nested-ternary */ /* eslint-enable no-nested-ternary */

View file

@ -8,9 +8,17 @@ import type { Props } from './MandatoryProfileSharingActions';
import { MandatoryProfileSharingActions } from './MandatoryProfileSharingActions'; import { MandatoryProfileSharingActions } from './MandatoryProfileSharingActions';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import {
getDefaultConversation,
getDefaultGroup,
} from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
type Args = {
conversationType: 'direct' | 'group';
};
export default { export default {
title: 'Components/Conversation/MandatoryProfileSharingActions', title: 'Components/Conversation/MandatoryProfileSharingActions',
argTypes: { argTypes: {
@ -20,34 +28,43 @@ export default {
options: ['direct', 'group'], options: ['direct', 'group'],
}, },
}, },
firstName: { control: { type: 'text' } },
title: { control: { type: 'text' } },
}, },
args: { args: {
conversationId: '123',
i18n,
conversationType: 'direct', conversationType: 'direct',
firstName: 'Cayce',
title: 'Cayce Bollard',
acceptConversation: action('acceptConversation'),
blockAndReportSpam: action('blockAndReportSpam'),
blockConversation: action('blockConversation'),
deleteConversation: action('deleteConversation'),
}, },
} satisfies Meta<Props>; } satisfies Meta<Args>;
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 ( return (
<div style={{ width: '480px' }}> <div style={{ width: '480px' }}>
<MandatoryProfileSharingActions {...args} /> <MandatoryProfileSharingActions
addedByName={addedBy}
conversationType={conversation.type}
conversationId={conversation.id}
conversationName={conversation}
i18n={i18n}
isBlocked={conversation.isBlocked ?? false}
isReported={conversation.isReported ?? false}
acceptConversation={action('acceptConversation')}
blockAndReportSpam={action('blockAndReportSpam')}
blockConversation={action('blockConversation')}
deleteConversation={action('deleteConversation')}
reportSpam={action('reportSpam')}
/>
</div> </div>
); );
} }
export function Direct(args: Props): JSX.Element {
return <Example {...args} conversationType="direct" />;
}
export function Group(args: Props): JSX.Element { export function Group(args: Props): JSX.Element {
return ( return <Example {...args} conversationType="group" />;
<div style={{ width: '480px' }}>
<MandatoryProfileSharingActions {...args} conversationType="group" />
</div>
);
} }

View file

@ -2,10 +2,9 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import type { PropsType as ContactNameProps } from './ContactName';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { Button, ButtonVariant } from '../Button'; import { Button, ButtonVariant } from '../Button';
import type { Props as MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation'; import type { MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation';
import { import {
MessageRequestActionsConfirmation, MessageRequestActionsConfirmation,
MessageRequestState, MessageRequestState,
@ -15,17 +14,20 @@ import type { LocalizerType } from '../../types/Util';
export type Props = { export type Props = {
i18n: LocalizerType; i18n: LocalizerType;
firstName?: string; } & Pick<
} & Omit<ContactNameProps, 'module'> & MessageRequestActionsConfirmationProps,
Pick< | 'addedByName'
MessageRequestActionsConfirmationProps, | 'conversationId'
| 'acceptConversation' | 'conversationType'
| 'blockAndReportSpam' | 'conversationName'
| 'blockConversation' | 'isBlocked'
| 'conversationId' | 'isReported'
| 'conversationType' | 'acceptConversation'
| 'deleteConversation' | 'reportSpam'
>; | 'blockAndReportSpam'
| 'blockConversation'
| 'deleteConversation'
>;
const learnMoreLink = (parts: Array<JSX.Element | string>) => ( const learnMoreLink = (parts: Array<JSX.Element | string>) => (
<a <a
@ -39,15 +41,18 @@ const learnMoreLink = (parts: Array<JSX.Element | string>) => (
); );
export function MandatoryProfileSharingActions({ export function MandatoryProfileSharingActions({
acceptConversation, addedByName,
blockAndReportSpam,
blockConversation,
conversationId, conversationId,
conversationType, conversationType,
deleteConversation, conversationName,
firstName,
i18n, i18n,
title, isBlocked,
isReported,
acceptConversation,
reportSpam,
blockAndReportSpam,
blockConversation,
deleteConversation,
}: Props): JSX.Element { }: Props): JSX.Element {
const [mrState, setMrState] = React.useState(MessageRequestState.default); const [mrState, setMrState] = React.useState(MessageRequestState.default);
@ -56,7 +61,7 @@ export function MandatoryProfileSharingActions({
key="name" key="name"
className="module-message-request-actions__message__name" className="module-message-request-actions__message__name"
> >
<ContactName firstName={firstName} title={title} preferFirstName /> <ContactName {...conversationName} preferFirstName />
</strong> </strong>
); );
@ -64,19 +69,23 @@ export function MandatoryProfileSharingActions({
<> <>
{mrState !== MessageRequestState.default ? ( {mrState !== MessageRequestState.default ? (
<MessageRequestActionsConfirmation <MessageRequestActionsConfirmation
addedByName={addedByName}
conversationId={conversationId}
conversationType={conversationType}
conversationName={conversationName}
i18n={i18n}
isBlocked={isBlocked}
isReported={isReported}
state={mrState}
acceptConversation={() => { acceptConversation={() => {
throw new Error( throw new Error(
'Should not be able to unblock from MandatoryProfileSharingActions' 'Should not be able to unblock from MandatoryProfileSharingActions'
); );
}} }}
blockConversation={blockConversation} blockConversation={blockConversation}
conversationId={conversationId}
deleteConversation={deleteConversation} deleteConversation={deleteConversation}
i18n={i18n} reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam} blockAndReportSpam={blockAndReportSpam}
title={title}
conversationType={conversationType}
state={mrState}
onChangeState={setMrState} onChangeState={setMrState}
/> />
) : null} ) : null}

View file

@ -4,13 +4,23 @@
import * as React from 'react'; import * as React from 'react';
import { action } from '@storybook/addon-actions'; import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react'; import type { Meta } from '@storybook/react';
import type { Props } from './MessageRequestActions';
import { MessageRequestActions } from './MessageRequestActions'; import { MessageRequestActions } from './MessageRequestActions';
import { setupI18n } from '../../util/setupI18n'; import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json'; import enMessages from '../../../_locales/en/messages.json';
import {
getDefaultConversation,
getDefaultGroup,
} from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages); const i18n = setupI18n('en', enMessages);
type Args = {
conversationType: 'direct' | 'group';
isBlocked: boolean;
isHidden: boolean;
isReported: boolean;
};
export default { export default {
title: 'Components/Conversation/MessageRequestActions', title: 'Components/Conversation/MessageRequestActions',
argTypes: { argTypes: {
@ -20,19 +30,9 @@ export default {
options: ['direct', 'group'], options: ['direct', 'group'],
}, },
}, },
firstName: { control: { type: 'text' } },
title: { control: { type: 'text' } },
}, },
args: { args: {
conversationId: '123',
i18n,
conversationType: 'direct', conversationType: 'direct',
firstName: 'Cayce',
title: 'Cayce Bollard',
acceptConversation: action('acceptConversation'),
blockAndReportSpam: action('blockAndReportSpam'),
blockConversation: action('blockConversation'),
deleteConversation: action('deleteConversation'),
}, },
decorators: [ decorators: [
(Story: React.ComponentType): JSX.Element => { (Story: React.ComponentType): JSX.Element => {
@ -43,20 +43,62 @@ export default {
); );
}, },
], ],
} satisfies Meta<Props>; } satisfies Meta<Args>;
export function Direct(args: Props): JSX.Element { function Example(args: Args): JSX.Element {
return <MessageRequestActions {...args} />; const conversation =
args.conversationType === 'group'
? getDefaultGroup()
: getDefaultConversation();
const addedBy =
args.conversationType === 'group' ? getDefaultConversation() : conversation;
return (
<MessageRequestActions
addedByName={addedBy}
conversationType={conversation.type}
conversationId={conversation.id}
conversationName={conversation}
i18n={i18n}
isBlocked={args.isBlocked}
isHidden={args.isHidden}
isReported={args.isReported}
acceptConversation={action('acceptConversation')}
blockAndReportSpam={action('blockAndReportSpam')}
blockConversation={action('blockConversation')}
deleteConversation={action('deleteConversation')}
reportSpam={action('reportSpam')}
/>
);
} }
export function DirectBlocked(args: Props): JSX.Element { export function Direct(args: Args): JSX.Element {
return <MessageRequestActions {...args} isBlocked />; return <Example {...args} />;
} }
export function Group(args: Props): JSX.Element { export function DirectBlocked(args: Args): JSX.Element {
return <MessageRequestActions {...args} conversationType="group" />; return <Example {...args} isBlocked />;
} }
export function GroupBlocked(args: Props): JSX.Element { export function DirectReported(args: Args): JSX.Element {
return <MessageRequestActions {...args} conversationType="group" isBlocked />; return <Example {...args} isReported />;
}
export function DirectBlockedAndReported(args: Args): JSX.Element {
return <Example {...args} isBlocked isReported />;
}
export function Group(args: Args): JSX.Element {
return <Example {...args} conversationType="group" />;
}
export function GroupBlocked(args: Args): JSX.Element {
return <Example {...args} conversationType="group" isBlocked />;
}
export function GroupReported(args: Args): JSX.Element {
return <Example {...args} conversationType="group" isReported />;
}
export function GroupBlockedAndReported(args: Args): JSX.Element {
return <Example {...args} conversationType="group" isBlocked isReported />;
} }

View file

@ -2,52 +2,57 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import type { PropsType as ContactNameProps } from './ContactName';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { Button, ButtonVariant } from '../Button'; import { Button, ButtonVariant } from '../Button';
import type { Props as MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation'; import type { MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation';
import { import {
MessageRequestActionsConfirmation, MessageRequestActionsConfirmation,
MessageRequestState, MessageRequestState,
} from './MessageRequestActionsConfirmation'; } from './MessageRequestActionsConfirmation';
import { Intl } from '../Intl'; import { Intl } from '../Intl';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { strictAssert } from '../../util/assert';
export type Props = { export type Props = {
i18n: LocalizerType; i18n: LocalizerType;
isHidden?: boolean; isHidden: boolean | null;
} & Omit<ContactNameProps, 'module'> & } & Omit<
Omit< MessageRequestActionsConfirmationProps,
MessageRequestActionsConfirmationProps, 'i18n' | 'state' | 'onChangeState'
'i18n' | 'state' | 'onChangeState' >;
>;
export function MessageRequestActions({ export function MessageRequestActions({
addedByName,
conversationId,
conversationType,
conversationName,
i18n,
isBlocked,
isHidden,
isReported,
acceptConversation, acceptConversation,
blockAndReportSpam, blockAndReportSpam,
blockConversation, blockConversation,
conversationId, reportSpam,
conversationType,
deleteConversation, deleteConversation,
firstName,
i18n,
isHidden,
isBlocked,
title,
}: Props): JSX.Element { }: Props): JSX.Element {
const [mrState, setMrState] = React.useState(MessageRequestState.default); const [mrState, setMrState] = React.useState(MessageRequestState.default);
const name = ( const nameValue =
<strong conversationType === 'direct' ? conversationName : addedByName;
key="name"
className="module-message-request-actions__message__name"
>
<ContactName firstName={firstName} title={title} preferFirstName />
</strong>
);
let message: JSX.Element | undefined; let message: JSX.Element | undefined;
if (conversationType === 'direct') { if (conversationType === 'direct') {
strictAssert(nameValue != null, 'nameValue is null');
const name = (
<strong
key="name"
className="module-message-request-actions__message__name"
>
<ContactName {...nameValue} preferFirstName />
</strong>
);
if (isBlocked) { if (isBlocked) {
message = ( message = (
<Intl <Intl
@ -87,39 +92,26 @@ export function MessageRequestActions({
<> <>
{mrState !== MessageRequestState.default ? ( {mrState !== MessageRequestState.default ? (
<MessageRequestActionsConfirmation <MessageRequestActionsConfirmation
addedByName={addedByName}
conversationId={conversationId}
conversationType={conversationType}
conversationName={conversationName}
i18n={i18n}
isBlocked={isBlocked}
isReported={isReported}
state={mrState}
acceptConversation={acceptConversation} acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam} blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation} blockConversation={blockConversation}
conversationId={conversationId} reportSpam={reportSpam}
conversationType={conversationType}
deleteConversation={deleteConversation} deleteConversation={deleteConversation}
i18n={i18n}
onChangeState={setMrState} onChangeState={setMrState}
state={mrState}
title={title}
/> />
) : null} ) : null}
<div className="module-message-request-actions"> <div className="module-message-request-actions">
<p className="module-message-request-actions__message">{message}</p> <p className="module-message-request-actions__message">{message}</p>
<div className="module-message-request-actions__buttons"> <div className="module-message-request-actions__buttons">
<Button {!isBlocked && (
onClick={() => {
setMrState(MessageRequestState.deleting);
}}
variant={ButtonVariant.SecondaryDestructive}
>
{i18n('icu:MessageRequests--delete')}
</Button>
{isBlocked ? (
<Button
onClick={() => {
setMrState(MessageRequestState.unblocking);
}}
variant={ButtonVariant.SecondaryAffirmative}
>
{i18n('icu:MessageRequests--unblock')}
</Button>
) : (
<Button <Button
onClick={() => { onClick={() => {
setMrState(MessageRequestState.blocking); setMrState(MessageRequestState.blocking);
@ -129,6 +121,36 @@ export function MessageRequestActions({
{i18n('icu:MessageRequests--block')} {i18n('icu:MessageRequests--block')}
</Button> </Button>
)} )}
{(isReported || isBlocked) && (
<Button
onClick={() => {
setMrState(MessageRequestState.deleting);
}}
variant={ButtonVariant.SecondaryDestructive}
>
{i18n('icu:MessageRequests--delete')}
</Button>
)}
{!isReported && (
<Button
onClick={() => {
setMrState(MessageRequestState.reportingAndMaybeBlocking);
}}
variant={ButtonVariant.SecondaryDestructive}
>
{i18n('icu:MessageRequests--reportAndMaybeBlock')}
</Button>
)}
{isBlocked && (
<Button
onClick={() => {
setMrState(MessageRequestState.unblocking);
}}
variant={ButtonVariant.SecondaryAffirmative}
>
{i18n('icu:MessageRequests--unblock')}
</Button>
)}
{!isBlocked ? ( {!isBlocked ? (
<Button <Button
onClick={() => acceptConversation(conversationId)} onClick={() => acceptConversation(conversationId)}

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as React from 'react'; import * as React from 'react';
import type { PropsType as ContactNameProps } from './ContactName'; import type { ContactNameData } from './ContactName';
import { ContactName } from './ContactName'; import { ContactName } from './ContactName';
import { ConfirmationDialog } from '../ConfirmationDialog'; import { ConfirmationDialog } from '../ConfirmationDialog';
import { Intl } from '../Intl'; import { Intl } from '../Intl';
@ -12,38 +12,53 @@ export enum MessageRequestState {
blocking, blocking,
deleting, deleting,
unblocking, unblocking,
reportingAndMaybeBlocking,
acceptedOptions,
default, default,
} }
export type Props = { export type MessageRequestActionsConfirmationBaseProps = {
acceptConversation(conversationId: string): unknown; addedByName: ContactNameData | null;
blockAndReportSpam(conversationId: string): unknown;
blockConversation(conversationId: string): unknown;
conversationId: string; conversationId: string;
conversationType: 'group' | 'direct'; conversationType: 'group' | 'direct';
deleteConversation(conversationId: string): unknown; conversationName: ContactNameData;
i18n: LocalizerType; isBlocked: boolean;
isBlocked?: boolean; isReported: boolean;
onChangeState(state: MessageRequestState): unknown; acceptConversation(conversationId: string): void;
state: MessageRequestState; blockAndReportSpam(conversationId: string): void;
} & Omit<ContactNameProps, 'module'>; blockConversation(conversationId: string): void;
reportSpam(conversationId: string): void;
deleteConversation(conversationId: string): void;
};
export type MessageRequestActionsConfirmationProps =
MessageRequestActionsConfirmationBaseProps & {
i18n: LocalizerType;
state: MessageRequestState;
onChangeState(state: MessageRequestState): void;
};
export function MessageRequestActionsConfirmation({ export function MessageRequestActionsConfirmation({
addedByName,
conversationId,
conversationType,
conversationName,
i18n,
isBlocked,
state,
acceptConversation, acceptConversation,
blockAndReportSpam, blockAndReportSpam,
blockConversation, blockConversation,
conversationId, reportSpam,
conversationType,
deleteConversation, deleteConversation,
i18n,
onChangeState, onChangeState,
state, }: MessageRequestActionsConfirmationProps): JSX.Element | null {
title,
}: Props): JSX.Element | null {
if (state === MessageRequestState.blocking) { if (state === MessageRequestState.blocking) {
return ( return (
<ConfirmationDialog <ConfirmationDialog
key="messageRequestActionsConfirmation.blocking"
dialogName="messageRequestActionsConfirmation.blocking" dialogName="messageRequestActionsConfirmation.blocking"
moduleClassName="MessageRequestActionsConfirmation"
i18n={i18n} i18n={i18n}
onClose={() => { onClose={() => {
onChangeState(MessageRequestState.default); onChangeState(MessageRequestState.default);
@ -54,7 +69,13 @@ export function MessageRequestActionsConfirmation({
i18n={i18n} i18n={i18n}
id="icu:MessageRequests--block-direct-confirm-title" id="icu:MessageRequests--block-direct-confirm-title"
components={{ components={{
title: <ContactName key="name" title={title} />, title: (
<ContactName
key="name"
{...conversationName}
preferFirstName
/>
),
}} }}
/> />
) : ( ) : (
@ -62,21 +83,18 @@ export function MessageRequestActionsConfirmation({
i18n={i18n} i18n={i18n}
id="icu:MessageRequests--block-group-confirm-title" id="icu:MessageRequests--block-group-confirm-title"
components={{ components={{
title: <ContactName key="name" title={title} />, title: (
<ContactName
key="name"
{...conversationName}
preferFirstName
/>
),
}} }}
/> />
) )
} }
actions={[ actions={[
...(conversationType === 'direct'
? [
{
text: i18n('icu:MessageRequests--block-and-report-spam'),
action: () => blockAndReportSpam(conversationId),
style: 'negative' as const,
},
]
: []),
{ {
text: i18n('icu:MessageRequests--block'), text: i18n('icu:MessageRequests--block'),
action: () => blockConversation(conversationId), action: () => blockConversation(conversationId),
@ -91,10 +109,62 @@ export function MessageRequestActionsConfirmation({
); );
} }
if (state === MessageRequestState.reportingAndMaybeBlocking) {
return (
<ConfirmationDialog
key="messageRequestActionsConfirmation.reportingAndMaybeBlocking"
dialogName="messageRequestActionsConfirmation.reportingAndMaybeBlocking"
moduleClassName="MessageRequestActionsConfirmation"
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
}}
title={i18n('icu:MessageRequests--ReportAndMaybeBlockModal-title')}
actions={[
...(!isBlocked
? ([
{
text: i18n(
'icu:MessageRequests--ReportAndMaybeBlockModal-reportAndBlock'
),
action: () => blockAndReportSpam(conversationId),
style: 'negative',
},
] as const)
: []),
{
text: i18n('icu:MessageRequests--ReportAndMaybeBlockModal-report'),
action: () => reportSpam(conversationId),
style: 'negative',
},
]}
>
{/* eslint-disable-next-line no-nested-ternary */}
{conversationType === 'direct' ? (
i18n('icu:MessageRequests--ReportAndMaybeBlockModal-body--direct')
) : addedByName == null ? (
i18n(
'icu:MessageRequests--ReportAndMaybeBlockModal-body--group--unknown-contact'
)
) : (
<Intl
i18n={i18n}
id="icu:MessageRequests--ReportAndMaybeBlockModal-body--group"
components={{
name: <ContactName key="name" {...addedByName} preferFirstName />,
}}
/>
)}
</ConfirmationDialog>
);
}
if (state === MessageRequestState.unblocking) { if (state === MessageRequestState.unblocking) {
return ( return (
<ConfirmationDialog <ConfirmationDialog
key="messageRequestActionsConfirmation.unblocking"
dialogName="messageRequestActionsConfirmation.unblocking" dialogName="messageRequestActionsConfirmation.unblocking"
moduleClassName="MessageRequestActionsConfirmation"
i18n={i18n} i18n={i18n}
onClose={() => { onClose={() => {
onChangeState(MessageRequestState.default); onChangeState(MessageRequestState.default);
@ -104,7 +174,9 @@ export function MessageRequestActionsConfirmation({
i18n={i18n} i18n={i18n}
id="icu:MessageRequests--unblock-direct-confirm-title" id="icu:MessageRequests--unblock-direct-confirm-title"
components={{ components={{
name: <ContactName key="name" title={title} />, name: (
<ContactName key="name" {...conversationName} preferFirstName />
),
}} }}
/> />
} }
@ -126,7 +198,9 @@ export function MessageRequestActionsConfirmation({
if (state === MessageRequestState.deleting) { if (state === MessageRequestState.deleting) {
return ( return (
<ConfirmationDialog <ConfirmationDialog
key="messageRequestActionsConfirmation.deleting"
dialogName="messageRequestActionsConfirmation.deleting" dialogName="messageRequestActionsConfirmation.deleting"
moduleClassName="MessageRequestActionsConfirmation"
i18n={i18n} i18n={i18n}
onClose={() => { onClose={() => {
onChangeState(MessageRequestState.default); onChangeState(MessageRequestState.default);
@ -142,7 +216,13 @@ export function MessageRequestActionsConfirmation({
i18n={i18n} i18n={i18n}
id="icu:MessageRequests--delete-group-confirm-title" id="icu:MessageRequests--delete-group-confirm-title"
components={{ components={{
title: <ContactName key="name" title={title} />, title: (
<ContactName
key="name"
{...conversationName}
preferFirstName
/>
),
}} }}
/> />
) )
@ -165,5 +245,42 @@ export function MessageRequestActionsConfirmation({
); );
} }
if (state === MessageRequestState.acceptedOptions) {
return (
<ConfirmationDialog
key="messageRequestActionsConfirmation.acceptedOptions"
dialogName="messageRequestActionsConfirmation.acceptedOptions"
moduleClassName="MessageRequestActionsConfirmation"
i18n={i18n}
onClose={() => {
onChangeState(MessageRequestState.default);
}}
actions={[
{
text: i18n('icu:MessageRequests--reportAndMaybeBlock'),
action: () =>
onChangeState(MessageRequestState.reportingAndMaybeBlocking),
style: 'negative',
},
{
text: i18n('icu:MessageRequests--block'),
action: () => onChangeState(MessageRequestState.blocking),
style: 'negative',
},
]}
>
<Intl
i18n={i18n}
id="icu:MessageRequests--AcceptedOptionsModal--body"
components={{
name: (
<ContactName key="name" {...conversationName} preferFirstName />
),
}}
/>
</ConfirmationDialog>
);
}
return null; return null;
} }

View file

@ -0,0 +1,98 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React, { useState } from 'react';
import type { LocalizerType } from '../../types/I18N';
import { SystemMessage } from './SystemMessage';
import { Button, ButtonSize, ButtonVariant } from '../Button';
import { MessageRequestState } from './MessageRequestActionsConfirmation';
import { SafetyTipsModal } from '../SafetyTipsModal';
import { MessageRequestResponseEvent } from '../../types/MessageRequestResponseEvent';
export type MessageRequestResponseNotificationData = {
messageRequestResponseEvent: MessageRequestResponseEvent;
};
export type MessageRequestResponseNotificationProps =
MessageRequestResponseNotificationData & {
i18n: LocalizerType;
isBlocked: boolean;
onOpenMessageRequestActionsConfirmation(state: MessageRequestState): void;
};
export function MessageRequestResponseNotification({
i18n,
isBlocked,
messageRequestResponseEvent: event,
onOpenMessageRequestActionsConfirmation,
}: MessageRequestResponseNotificationProps): JSX.Element | null {
const [isSafetyTipsModalOpen, setIsSafetyTipsModalOpen] = useState(false);
return (
<>
{event === MessageRequestResponseEvent.ACCEPT && (
<SystemMessage
icon="thread"
contents={i18n(
'icu:MessageRequestResponseNotification__Message--Accepted'
)}
button={
isBlocked ? null : (
<Button
className="MessageRequestResponseNotification__Button"
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
onClick={() => {
onOpenMessageRequestActionsConfirmation(
MessageRequestState.acceptedOptions
);
}}
>
{i18n(
'icu:MessageRequestResponseNotification__Button--Options'
)}
</Button>
)
}
/>
)}
{event === MessageRequestResponseEvent.BLOCK && (
<SystemMessage
icon="block"
contents={i18n(
'icu:MessageRequestResponseNotification__Message--Blocked'
)}
/>
)}
{event === MessageRequestResponseEvent.SPAM && (
<SystemMessage
icon="spam"
contents={i18n(
'icu:MessageRequestResponseNotification__Message--Reported'
)}
button={
<Button
className="MessageRequestResponseNotification__Button"
size={ButtonSize.Small}
variant={ButtonVariant.SystemMessage}
onClick={() => {
setIsSafetyTipsModalOpen(true);
}}
>
{i18n(
'icu:MessageRequestResponseNotification__Button--LearnMore'
)}
</Button>
}
/>
)}
{isSafetyTipsModalOpen && (
<SafetyTipsModal
i18n={i18n}
onClose={() => {
setIsSafetyTipsModalOpen(false);
}}
/>
)}
</>
);
}

View file

@ -16,6 +16,7 @@ export type PropsType = {
| 'audio-incoming' | 'audio-incoming'
| 'audio-missed' | 'audio-missed'
| 'audio-outgoing' | 'audio-outgoing'
| 'block'
| 'group' | 'group'
| 'group-access' | 'group-access'
| 'group-add' | 'group-add'
@ -30,6 +31,7 @@ export type PropsType = {
| 'phone' | 'phone'
| 'profile' | 'profile'
| 'safety-number' | 'safety-number'
| 'spam'
| 'session-refresh' | 'session-refresh'
| 'thread' | 'thread'
| 'timer' | 'timer'

View file

@ -335,6 +335,10 @@ const actions = () => ({
viewStory: action('viewStory'), viewStory: action('viewStory'),
onReplyToMessage: action('onReplyToMessage'), onReplyToMessage: action('onReplyToMessage'),
onOpenMessageRequestActionsConfirmation: action(
'onOpenMessageRequestActionsConfirmation'
),
}); });
const renderItem = ({ const renderItem = ({
@ -350,6 +354,7 @@ const renderItem = ({
getPreferredBadge={() => undefined} getPreferredBadge={() => undefined}
id="" id=""
isTargeted={false} isTargeted={false}
isBlocked={false}
i18n={i18n} i18n={i18n}
interactionMode="keyboard" interactionMode="keyboard"
isNextItemCallingNotification={false} isNextItemCallingNotification={false}
@ -442,6 +447,7 @@ const useProps = (overrideProps: Partial<PropsType> = {}): PropsType => ({
getTimestampForMessage: Date.now, getTimestampForMessage: Date.now,
haveNewest: overrideProps.haveNewest ?? false, haveNewest: overrideProps.haveNewest ?? false,
haveOldest: overrideProps.haveOldest ?? false, haveOldest: overrideProps.haveOldest ?? false,
isBlocked: false,
isConversationSelected: true, isConversationSelected: true,
isIncomingMessageRequest: overrideProps.isIncomingMessageRequest ?? false, isIncomingMessageRequest: overrideProps.isIncomingMessageRequest ?? false,
items: overrideProps.items ?? Object.keys(items), items: overrideProps.items ?? Object.keys(items),

View file

@ -81,6 +81,7 @@ export type PropsDataType = {
type PropsHousekeepingType = { type PropsHousekeepingType = {
id: string; id: string;
isBlocked: boolean;
isConversationSelected: boolean; isConversationSelected: boolean;
isGroupV1AndDisabled?: boolean; isGroupV1AndDisabled?: boolean;
isIncomingMessageRequest: boolean; isIncomingMessageRequest: boolean;
@ -121,6 +122,7 @@ type PropsHousekeepingType = {
containerElementRef: RefObject<HTMLElement>; containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint; containerWidthBreakpoint: WidthBreakpoint;
conversationId: string; conversationId: string;
isBlocked: boolean;
isOldestTimelineItem: boolean; isOldestTimelineItem: boolean;
messageId: string; messageId: string;
nextMessageId: undefined | string; nextMessageId: undefined | string;
@ -786,6 +788,7 @@ export class Timeline extends React.Component<
i18n, i18n,
id, id,
invitedContactsForNewlyCreatedGroup, invitedContactsForNewlyCreatedGroup,
isBlocked,
isConversationSelected, isConversationSelected,
isGroupV1AndDisabled, isGroupV1AndDisabled,
items, items,
@ -928,6 +931,7 @@ export class Timeline extends React.Component<
containerElementRef: this.containerRef, containerElementRef: this.containerRef,
containerWidthBreakpoint: widthBreakpoint, containerWidthBreakpoint: widthBreakpoint,
conversationId: id, conversationId: id,
isBlocked,
isOldestTimelineItem: haveOldest && itemIndex === 0, isOldestTimelineItem: haveOldest && itemIndex === 0,
messageId, messageId,
nextMessageId, nextMessageId,

View file

@ -59,6 +59,7 @@ const getDefaultProps = () => ({
id: 'asdf', id: 'asdf',
isNextItemCallingNotification: false, isNextItemCallingNotification: false,
isTargeted: false, isTargeted: false,
isBlocked: false,
interactionMode: 'keyboard' as const, interactionMode: 'keyboard' as const,
theme: ThemeType.light, theme: ThemeType.light,
platform: 'darwin', platform: 'darwin',
@ -118,6 +119,9 @@ const getDefaultProps = () => ({
viewStory: action('viewStory'), viewStory: action('viewStory'),
onReplyToMessage: action('onReplyToMessage'), onReplyToMessage: action('onReplyToMessage'),
onOpenMessageRequestActionsConfirmation: action(
'onOpenMessageRequestActionsConfirmation'
),
}); });
export default { export default {

View file

@ -56,6 +56,11 @@ import type { PropsDataType as PhoneNumberDiscoveryNotificationPropsType } from
import { PhoneNumberDiscoveryNotification } from './PhoneNumberDiscoveryNotification'; import { PhoneNumberDiscoveryNotification } from './PhoneNumberDiscoveryNotification';
import { SystemMessage } from './SystemMessage'; import { SystemMessage } from './SystemMessage';
import { TimelineMessage } from './TimelineMessage'; import { TimelineMessage } from './TimelineMessage';
import {
MessageRequestResponseNotification,
type MessageRequestResponseNotificationData,
} from './MessageRequestResponseNotification';
import type { MessageRequestState } from './MessageRequestActionsConfirmation';
type CallHistoryType = { type CallHistoryType = {
type: 'callHistory'; type: 'callHistory';
@ -137,6 +142,10 @@ type PaymentEventType = {
type: 'paymentEvent'; type: 'paymentEvent';
data: Omit<PaymentEventNotificationPropsType, 'i18n'>; data: Omit<PaymentEventNotificationPropsType, 'i18n'>;
}; };
type MessageRequestResponseNotificationType = {
type: 'messageRequestResponse';
data: MessageRequestResponseNotificationData;
};
export type TimelineItemType = ( export type TimelineItemType = (
| CallHistoryType | CallHistoryType
@ -159,6 +168,7 @@ export type TimelineItemType = (
| UnsupportedMessageType | UnsupportedMessageType
| VerificationNotificationType | VerificationNotificationType
| PaymentEventType | PaymentEventType
| MessageRequestResponseNotificationType
) & { timestamp: number }; ) & { timestamp: number };
type PropsLocalType = { type PropsLocalType = {
@ -166,10 +176,12 @@ type PropsLocalType = {
conversationId: string; conversationId: string;
item?: TimelineItemType; item?: TimelineItemType;
id: string; id: string;
isBlocked: boolean;
isNextItemCallingNotification: boolean; isNextItemCallingNotification: boolean;
isTargeted: boolean; isTargeted: boolean;
targetMessage: (messageId: string, conversationId: string) => unknown; targetMessage: (messageId: string, conversationId: string) => unknown;
shouldRenderDateHeader: boolean; shouldRenderDateHeader: boolean;
onOpenMessageRequestActionsConfirmation(state: MessageRequestState): void;
platform: string; platform: string;
renderContact: SmartContactRendererType<JSX.Element>; renderContact: SmartContactRendererType<JSX.Element>;
renderUniversalTimerNotification: () => JSX.Element; renderUniversalTimerNotification: () => JSX.Element;
@ -203,9 +215,11 @@ export const TimelineItem = memo(function TimelineItem({
getPreferredBadge, getPreferredBadge,
i18n, i18n,
id, id,
isBlocked,
isNextItemCallingNotification, isNextItemCallingNotification,
isTargeted, isTargeted,
item, item,
onOpenMessageRequestActionsConfirmation,
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
platform, platform,
@ -379,6 +393,17 @@ export const TimelineItem = memo(function TimelineItem({
i18n={i18n} i18n={i18n}
/> />
); );
} else if (item.type === 'messageRequestResponse') {
notification = (
<MessageRequestResponseNotification
{...item.data}
i18n={i18n}
isBlocked={isBlocked}
onOpenMessageRequestActionsConfirmation={
onOpenMessageRequestActionsConfirmation
}
/>
);
} else { } else {
// Weird, yes, but the idea is to get a compile error when we aren't comprehensive // 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 // with our if/else checks above, but also log out the type we don't understand

View file

@ -4,9 +4,9 @@
import { assertDev } from '../../util/assert'; import { assertDev } from '../../util/assert';
import { isDirectConversation } from '../../util/whatTypeOfConversation'; import { isDirectConversation } from '../../util/whatTypeOfConversation';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import type { ConversationAttributesType } from '../../model-types.d';
import { isAciString } from '../../util/isAciString'; import { isAciString } from '../../util/isAciString';
import type { reportSpamJobQueue } from '../reportSpamJobQueue'; import type { reportSpamJobQueue } from '../reportSpamJobQueue';
import type { ConversationType } from '../../state/ducks/conversations';
export async function addReportSpamJob({ export async function addReportSpamJob({
conversation, conversation,
@ -14,10 +14,7 @@ export async function addReportSpamJob({
jobQueue, jobQueue,
}: Readonly<{ }: Readonly<{
conversation: Readonly< conversation: Readonly<
Pick< Pick<ConversationType, 'id' | 'type' | 'serviceId' | 'reportingToken'>
ConversationAttributesType,
'id' | 'type' | 'serviceId' | 'reportingToken'
>
>; >;
getMessageServerGuidsForSpam: ( getMessageServerGuidsForSpam: (
conversationId: string conversationId: string

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

@ -32,6 +32,7 @@ import type { AnyPaymentEvent } from './types/Payment';
import AccessRequiredEnum = Proto.AccessControl.AccessRequired; import AccessRequiredEnum = Proto.AccessControl.AccessRequired;
import MemberRoleEnum = Proto.Member.Role; import MemberRoleEnum = Proto.Member.Role;
import type { MessageRequestResponseEvent } from './types/MessageRequestResponseEvent';
export type LastMessageStatus = export type LastMessageStatus =
| 'paused' | 'paused'
@ -156,6 +157,7 @@ export type MessageAttributesType = {
logger?: unknown; logger?: unknown;
message?: unknown; message?: unknown;
messageTimer?: unknown; messageTimer?: unknown;
messageRequestResponseEvent?: MessageRequestResponseEvent;
profileChange?: ProfileNameChangeType; profileChange?: ProfileNameChangeType;
payment?: AnyPaymentEvent; payment?: AnyPaymentEvent;
quote?: QuotedMessageType; quote?: QuotedMessageType;
@ -192,7 +194,8 @@ export type MessageAttributesType = {
| 'universal-timer-notification' | 'universal-timer-notification'
| 'contact-removed-notification' | 'contact-removed-notification'
| 'title-transition-notification' | 'title-transition-notification'
| 'verified-change'; | 'verified-change'
| 'message-request-response-event';
body?: string; body?: string;
attachments?: Array<AttachmentType>; attachments?: Array<AttachmentType>;
preview?: Array<LinkPreviewType>; preview?: Array<LinkPreviewType>;
@ -359,6 +362,7 @@ export type ConversationAttributesType = {
draftEditMessage?: DraftEditMessageType; draftEditMessage?: DraftEditMessageType;
hasPostedStory?: boolean; hasPostedStory?: boolean;
isArchived?: boolean; isArchived?: boolean;
isReported?: boolean;
name?: string; name?: string;
systemGivenName?: string; systemGivenName?: string;
systemFamilyName?: string; systemFamilyName?: string;

View file

@ -164,6 +164,7 @@ import { incrementMessageCounter } from '../util/incrementMessageCounter';
import OS from '../util/os/osMain'; import OS from '../util/os/osMain';
import { getMessageAuthorText } from '../util/getMessageAuthorText'; import { getMessageAuthorText } from '../util/getMessageAuthorText';
import { downscaleOutgoingAttachment } from '../util/attachments'; import { downscaleOutgoingAttachment } from '../util/attachments';
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
/* eslint-disable more/no-then */ /* eslint-disable more/no-then */
window.Whisper = window.Whisper || {}; window.Whisper = window.Whisper || {};
@ -2118,8 +2119,38 @@ export class ConversationModel extends window.Backbone
} while (messages.length > 0); } while (messages.length > 0);
} }
async addMessageRequestResponseEventMessage(
event: MessageRequestResponseEvent
): Promise<void> {
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( async applyMessageRequestResponse(
response: number, response: Proto.SyncMessage.MessageRequestResponse.Type,
{ fromSync = false, viaStorageServiceSync = false, shouldSave = true } = {} { fromSync = false, viaStorageServiceSync = false, shouldSave = true } = {}
): Promise<void> { ): Promise<void> {
try { try {
@ -2130,11 +2161,84 @@ export class ConversationModel extends window.Backbone
const didResponseChange = response !== currentMessageRequestState; const didResponseChange = response !== currentMessageRequestState;
const wasPreviouslyAccepted = this.getAccepted(); 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 // Apply message request response locally
this.set({ this.set({
messageRequestResponseType: response, 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) { if (response === messageRequestEnum.ACCEPT) {
this.unblock({ viaStorageServiceSync }); this.unblock({ viaStorageServiceSync });
if (!viaStorageServiceSync) { if (!viaStorageServiceSync) {
@ -2191,53 +2295,15 @@ export class ConversationModel extends window.Backbone
} }
} }
} else if (response === messageRequestEnum.BLOCK) { } else if (response === messageRequestEnum.BLOCK) {
// Block locally, other devices should block upon receiving the sync message await rejectConversation({ isBlock: true });
this.block({ viaStorageServiceSync });
this.disableProfileSharing({ viaStorageServiceSync });
if (isLocalAction) {
if (isGroupV2(this.attributes)) {
await this.leaveGroupV2();
}
}
} else if (response === messageRequestEnum.DELETE) { } else if (response === messageRequestEnum.DELETE) {
this.disableProfileSharing({ viaStorageServiceSync }); await rejectConversation({ isDelete: true });
// 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();
}
}
} else if (response === messageRequestEnum.BLOCK_AND_DELETE) { } else if (response === messageRequestEnum.BLOCK_AND_DELETE) {
// Block locally, other devices should block upon receiving the sync message await rejectConversation({ isBlock: true, isDelete: true });
this.block({ viaStorageServiceSync }); } else if (response === messageRequestEnum.SPAM) {
this.disableProfileSharing({ viaStorageServiceSync }); await rejectConversation({ isSpam: true });
} else if (response === messageRequestEnum.BLOCK_AND_SPAM) {
// Delete messages locally, other devices should delete upon receiving await rejectConversation({ isBlock: true, isSpam: true });
// 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();
}
}
} }
} finally { } finally {
if (shouldSave) { if (shouldSave) {
@ -2492,40 +2558,6 @@ export class ConversationModel extends window.Backbone
} }
} }
async syncMessageRequestResponse(
response: number,
{ shouldSave = true } = {}
): Promise<void> {
// 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<number> { async safeGetVerified(): Promise<number> {
const serviceId = this.getServiceId(); const serviceId = this.getServiceId();
if (!serviceId) { if (!serviceId) {

View file

@ -23,7 +23,7 @@ import {
// State // State
export type AudioPlayerStateType = ReadonlyDeep<{ export type AudioRecorderStateType = ReadonlyDeep<{
recordingState: RecordingState; recordingState: RecordingState;
errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType; errorDialogAudioRecorderType?: ErrorDialogAudioRecorderType;
}>; }>;
@ -211,16 +211,16 @@ function errorRecording(
// Reducer // Reducer
export function getEmptyState(): AudioPlayerStateType { export function getEmptyState(): AudioRecorderStateType {
return { return {
recordingState: RecordingState.Idle, recordingState: RecordingState.Idle,
}; };
} }
export function reducer( export function reducer(
state: Readonly<AudioPlayerStateType> = getEmptyState(), state: Readonly<AudioRecorderStateType> = getEmptyState(),
action: Readonly<AudioPlayerActionType> action: Readonly<AudioPlayerActionType>
): AudioPlayerStateType { ): AudioRecorderStateType {
if (action.type === START_RECORDING) { if (action.type === START_RECORDING) {
return { return {
...state, ...state,

View file

@ -179,6 +179,10 @@ import {
import type { ChangeNavTabActionType } from './nav'; import type { ChangeNavTabActionType } from './nav';
import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav'; import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav';
import { sortByMessageOrder } from '../../types/ForwardDraft'; 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 // State
@ -228,6 +232,10 @@ export type DraftPreviewType = ReadonlyDeep<{
bodyRanges?: HydratedBodyRangesType; bodyRanges?: HydratedBodyRangesType;
}>; }>;
export type ConversationRemovalStage = ReadonlyDeep<
'justNotification' | 'messageRequest'
>;
export type ConversationType = ReadonlyDeep< export type ConversationType = ReadonlyDeep<
{ {
id: string; id: string;
@ -265,7 +273,9 @@ export type ConversationType = ReadonlyDeep<
hideStory?: boolean; hideStory?: boolean;
isArchived?: boolean; isArchived?: boolean;
isBlocked?: boolean; isBlocked?: boolean;
removalStage?: 'justNotification' | 'messageRequest'; isReported?: boolean;
reportingToken?: string;
removalStage?: ConversationRemovalStage;
isGroupV1AndDisabled?: boolean; isGroupV1AndDisabled?: boolean;
isPinned?: boolean; isPinned?: boolean;
isUntrusted?: boolean; isUntrusted?: boolean;
@ -1026,6 +1036,7 @@ export const actions = {
acknowledgeGroupMemberNameCollisions, acknowledgeGroupMemberNameCollisions,
addMembersToGroup, addMembersToGroup,
approvePendingMembershipFromGroupV2, approvePendingMembershipFromGroupV2,
reportSpam,
blockAndReportSpam, blockAndReportSpam,
blockConversation, blockConversation,
blockGroupLinkRequests, blockGroupLinkRequests,
@ -3243,68 +3254,195 @@ function revokePendingMembershipsFromGroupV2(
}; };
} }
async function syncMessageRequestResponse(
conversationData: ConversationType,
response: Proto.SyncMessage.MessageRequestResponse.Type,
{ shouldSave = true } = {}
): Promise<void> {
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<void, RootStateType, unknown, ShowToastActionType> {
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( function blockAndReportSpam(
conversationId: string conversationId: string
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> { ): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return async dispatch => { return async (dispatch, getState) => {
const conversation = window.ConversationController.get(conversationId); const conversationSelector = getConversationSelector(getState());
if (!conversation) { const conversationOrGroup = conversationSelector(conversationId);
if (!conversationOrGroup) {
log.error( log.error(
`blockAndReportSpam: Expected a conversation to be found for ${conversationId}. Doing nothing.` `blockAndReportSpam: Expected a conversation to be found for ${conversationId}. Doing nothing.`
); );
return; return;
} }
const conversationForSpam =
getConversationForReportSpam(conversationOrGroup);
const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type; const messageRequestEnum = Proto.SyncMessage.MessageRequestResponse.Type;
const idForLogging = conversation.idForLogging(); const idForLogging = getConversationIdForLogging(conversationOrGroup);
void longRunningTaskWrapper({ drop(
name: 'blockAndReportSpam', longRunningTaskWrapper({
idForLogging, name: 'blockAndReportSpam',
task: async () => { idForLogging,
await Promise.all([ task: async () => {
conversation.syncMessageRequestResponse(messageRequestEnum.BLOCK), await Promise.all([
addReportSpamJob({ syncMessageRequestResponse(
conversation: conversation.attributes, conversationOrGroup,
getMessageServerGuidsForSpam: messageRequestEnum.BLOCK_AND_SPAM
window.Signal.Data.getMessageServerGuidsForSpam, ),
jobQueue: reportSpamJobQueue, conversationForSpam != null &&
}), addReportSpamJob({
]); conversation: conversationForSpam,
getMessageServerGuidsForSpam:
window.Signal.Data.getMessageServerGuidsForSpam,
jobQueue: reportSpamJobQueue,
}),
]);
dispatch({ dispatch({
type: SHOW_TOAST, type: SHOW_TOAST,
payload: { payload: {
toastType: ToastType.ReportedSpamAndBlocked, toastType: ToastType.ReportedSpamAndBlocked,
}, },
}); });
}, },
}); })
);
}; };
} }
function acceptConversation(conversationId: string): NoopActionType { function acceptConversation(
const conversation = window.ConversationController.get(conversationId); conversationId: string
if (!conversation) { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {
throw new Error( return async (dispatch, getState) => {
'acceptConversation: Expected a conversation to be found. Doing nothing' 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; dispatch({
type: 'NOOP',
void longRunningTaskWrapper({ payload: null,
name: 'acceptConversation', });
idForLogging: conversation.idForLogging(),
task: conversation.syncMessageRequestResponse.bind(
conversation,
messageRequestEnum.ACCEPT
),
});
return {
type: 'NOOP',
payload: null,
}; };
} }
@ -3329,53 +3467,74 @@ function removeConversation(conversationId: string): ShowToastActionType {
}; };
} }
function blockConversation(conversationId: string): NoopActionType { function blockConversation(
const conversation = window.ConversationController.get(conversationId); conversationId: string
if (!conversation) { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {
throw new Error( return (dispatch, getState) => {
'blockConversation: Expected a conversation to be found. Doing nothing' 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; dispatch({
type: 'NOOP',
void longRunningTaskWrapper({ payload: null,
name: 'blockConversation', });
idForLogging: conversation.idForLogging(),
task: conversation.syncMessageRequestResponse.bind(
conversation,
messageRequestEnum.BLOCK
),
});
return {
type: 'NOOP',
payload: null,
}; };
} }
function deleteConversation(conversationId: string): NoopActionType { function deleteConversation(
const conversation = window.ConversationController.get(conversationId); conversationId: string
if (!conversation) { ): ThunkAction<void, RootStateType, unknown, NoopActionType> {
throw new Error( return (dispatch, getState) => {
'deleteConversation: Expected a conversation to be found. Doing nothing' 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; dispatch({
type: 'NOOP',
void longRunningTaskWrapper({ payload: null,
name: 'deleteConversation', });
idForLogging: conversation.idForLogging(),
task: conversation.syncMessageRequestResponse.bind(
conversation,
messageRequestEnum.DELETE
),
});
return {
type: 'NOOP',
payload: null,
}; };
} }

View file

@ -33,8 +33,9 @@ export const actions = {
useEmoji, useEmoji,
}; };
export const useActions = (): BoundActionCreatorsMapObject<typeof actions> => export const useEmojisActions = (): BoundActionCreatorsMapObject<
useBoundActions(actions); typeof actions
> => useBoundActions(actions);
function onUseEmoji({ function onUseEmoji({
shortName, shortName,

View file

@ -42,6 +42,7 @@ import { SHOW_TOAST } from './toast';
import type { ShowToastActionType } from './toast'; import type { ShowToastActionType } from './toast';
import { isDownloaded } from '../../types/Attachment'; import { isDownloaded } from '../../types/Attachment';
import type { ButtonVariant } from '../../components/Button'; import type { ButtonVariant } from '../../components/Button';
import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation';
// State // State
@ -58,6 +59,10 @@ export type ForwardMessagesPropsType = ReadonlyDeep<{
messages: Array<ForwardMessagePropsType>; messages: Array<ForwardMessagePropsType>;
onForward?: () => void; onForward?: () => void;
}>; }>;
export type MessageRequestActionsConfirmationPropsType = ReadonlyDeep<{
conversationId: string;
state: MessageRequestState;
}>;
export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{ export type SafetyNumberChangedBlockingDataType = ReadonlyDeep<{
promiseUuid: SingleServePromise.SingleServePromiseIdString; promiseUuid: SingleServePromise.SingleServePromiseIdString;
source?: SafetyNumberChangeSource; source?: SafetyNumberChangeSource;
@ -101,6 +106,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
isSignalConnectionsVisible: boolean; isSignalConnectionsVisible: boolean;
isStoriesSettingsVisible: boolean; isStoriesSettingsVisible: boolean;
isWhatsNewVisible: boolean; isWhatsNewVisible: boolean;
messageRequestActionsConfirmationProps: MessageRequestActionsConfirmationPropsType | null;
usernameOnboardingState: UsernameOnboardingState; usernameOnboardingState: UsernameOnboardingState;
profileEditorHasError: boolean; profileEditorHasError: boolean;
profileEditorInitialEditState: ProfileEditorEditState | undefined; 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_STICKER_PACK_PREVIEW = 'globalModals/CLOSE_STICKER_PACK_PREVIEW';
const CLOSE_ERROR_MODAL = 'globalModals/CLOSE_ERROR_MODAL'; const CLOSE_ERROR_MODAL = 'globalModals/CLOSE_ERROR_MODAL';
export const SHOW_ERROR_MODAL = 'globalModals/SHOW_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 = const SHOW_FORMATTING_WARNING_MODAL =
'globalModals/SHOW_FORMATTING_WARNING_MODAL'; 'globalModals/SHOW_FORMATTING_WARNING_MODAL';
const SHOW_SEND_EDIT_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 CloseShortcutGuideModalActionType = ReadonlyDeep<{
type: typeof CLOSE_SHORTCUT_GUIDE_MODAL; type: typeof CLOSE_SHORTCUT_GUIDE_MODAL;
}>; }>;
@ -373,6 +386,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
| ShowContactModalActionType | ShowContactModalActionType
| ShowEditHistoryModalActionType | ShowEditHistoryModalActionType
| ShowErrorModalActionType | ShowErrorModalActionType
| ToggleMessageRequestActionsConfirmationActionType
| ShowFormattingWarningModalActionType | ShowFormattingWarningModalActionType
| ShowSendAnywayDialogActionType | ShowSendAnywayDialogActionType
| ShowSendEditWarningModalActionType | ShowSendEditWarningModalActionType
@ -414,6 +428,7 @@ export const actions = {
showContactModal, showContactModal,
showEditHistoryModal, showEditHistoryModal,
showErrorModal, showErrorModal,
toggleMessageRequestActionsConfirmation,
showFormattingWarningModal, showFormattingWarningModal,
showSendEditWarningModal, showSendEditWarningModal,
showGV2MigrationDialog, 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 { function closeShortcutGuideModal(): CloseShortcutGuideModalActionType {
return { return {
type: CLOSE_SHORTCUT_GUIDE_MODAL, type: CLOSE_SHORTCUT_GUIDE_MODAL,
@ -908,6 +935,7 @@ export function getEmptyState(): GlobalModalsStateType {
usernameOnboardingState: UsernameOnboardingState.NeverShown, usernameOnboardingState: UsernameOnboardingState.NeverShown,
profileEditorHasError: false, profileEditorHasError: false,
profileEditorInitialEditState: undefined, 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) { if (action.type === CLOSE_SHORTCUT_GUIDE_MODAL) {
return { return {
...state, ...state,

View file

@ -101,8 +101,9 @@ export const actions = {
selectDraftEmojiToBeReplaced, selectDraftEmojiToBeReplaced,
}; };
export const useActions = (): BoundActionCreatorsMapObject<typeof actions> => export const usePreferredReactionsActions = (): BoundActionCreatorsMapObject<
useBoundActions(actions); typeof actions
> => useBoundActions(actions);
function cancelCustomizePreferredReactionsModal(): CancelCustomizePreferredReactionsModalActionType { function cancelCustomizePreferredReactionsModal(): CancelCustomizePreferredReactionsModalActionType {
return { type: CANCEL_CUSTOMIZE_PREFERRED_REACTIONS_MODAL }; return { type: CANCEL_CUSTOMIZE_PREFERRED_REACTIONS_MODAL };

View file

@ -22,6 +22,8 @@ import { ERASE_STORAGE_SERVICE } from './user';
import type { EraseStorageServiceStateAction } from './user'; import type { EraseStorageServiceStateAction } from './user';
import type { NoopActionType } from './noop'; import type { NoopActionType } from './noop';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
const { getRecentStickers, updateStickerLastUsed } = dataInterface; const { getRecentStickers, updateStickerLastUsed } = dataInterface;
@ -154,6 +156,10 @@ export const actions = {
useSticker, useSticker,
}; };
export const useStickersActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
function removeStickerPack(id: string): StickerPackRemovedAction { function removeStickerPack(id: string): StickerPackRemovedAction {
return { return {
type: 'stickers/REMOVE_STICKER_PACK', type: 'stickers/REMOVE_STICKER_PACK',

View file

@ -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;
}
);

View file

@ -228,3 +228,17 @@ export const getNavTabsCollapsed = createSelector(
getItems, getItems,
(state: ItemsStateType): boolean => Boolean(state.navTabsCollapsed ?? false) (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;
}
);

View file

@ -140,6 +140,7 @@ import { CallMode } from '../../types/Calling';
import { CallDirection } from '../../types/CallDisposition'; import { CallDirection } from '../../types/CallDisposition';
import { getCallIdFromEra } from '../../util/callDisposition'; import { getCallIdFromEra } from '../../util/callDisposition';
import { LONG_MESSAGE } from '../../types/MIME'; import { LONG_MESSAGE } from '../../types/MIME';
import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification';
export { isIncoming, isOutgoing, isStory }; 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); const data = getPropsForMessage(message, options);
return { return {
@ -1461,6 +1470,24 @@ function getPropsForProfileChange(
} as ProfileChangeNotificationPropsType; } 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 // Universal Timer Notification
// Note: smart, so props not generated here // Note: smart, so props not generated here

View file

@ -1,35 +1,27 @@
// Copyright 2019 Signal Messenger, LLC // Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useCallback, useMemo } from 'react';
import { connect } from 'react-redux'; import { useSelector } from 'react-redux';
import { get } from 'lodash';
import { mapDispatchToProps } from '../actions';
import type { Props as ComponentPropsType } from '../../components/CompositionArea';
import { CompositionArea } from '../../components/CompositionArea'; import { CompositionArea } from '../../components/CompositionArea';
import type { StateType } from '../reducer'; import { useContactNameData } from '../../components/conversation/ContactName';
import type { import type {
DraftBodyRanges, DraftBodyRanges,
HydratedBodyRangesType, HydratedBodyRangesType,
} from '../../types/BodyRange'; } from '../../types/BodyRange';
import { isConversationSMSOnly } from '../../util/isConversationSMSOnly'; import { hydrateRanges } from '../../types/BodyRange';
import { dropNull } from '../../util/dropNull'; import { strictAssert } from '../../util/assert';
import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation';
import { imageToBlurHash } from '../../util/imageToBlurHash'; 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 { getPreferredBadgeSelector } from '../selectors/badges';
import { selectRecentEmojis } from '../selectors/emojis'; import { getComposerStateForConversationIdSelector } from '../selectors/composer';
import {
getIntl,
getPlatform,
getTheme,
getUserConversationId,
} from '../selectors/user';
import {
getDefaultConversationColor,
getEmojiSkinTone,
getTextFormattingEnabled,
} from '../selectors/items';
import { import {
getConversationSelector, getConversationSelector,
getGroupAdminsSelector, getGroupAdminsSelector,
@ -38,71 +30,88 @@ import {
getSelectedMessageIds, getSelectedMessageIds,
isMissingRequiredProfileSharing, isMissingRequiredProfileSharing,
} from '../selectors/conversations'; } from '../selectors/conversations';
import { selectRecentEmojis } from '../selectors/emojis';
import {
getDefaultConversationColor,
getEmojiSkinTone,
getShowStickerPickerHint,
getShowStickersIntroduction,
getTextFormattingEnabled,
} from '../selectors/items';
import { getPropsForQuote } from '../selectors/message'; import { getPropsForQuote } from '../selectors/message';
import { import {
getBlessedStickerPacks, getBlessedStickerPacks,
getInstalledStickerPacks, getInstalledStickerPacks,
getKnownStickerPacks, getKnownStickerPacks,
getReceivedStickerPacks, getReceivedStickerPacks,
getRecentlyInstalledStickerPack,
getRecentStickers, getRecentStickers,
getRecentlyInstalledStickerPack,
} from '../selectors/stickers'; } from '../selectors/stickers';
import { isSignalConversation } from '../../util/isSignalConversation'; import {
import { getComposerStateForConversationIdSelector } from '../selectors/composer'; getIntl,
getPlatform,
getTheme,
getUserConversationId,
} from '../selectors/user';
import type { SmartCompositionRecordingProps } from './CompositionRecording'; import type { SmartCompositionRecordingProps } from './CompositionRecording';
import { SmartCompositionRecording } from './CompositionRecording'; import { SmartCompositionRecording } from './CompositionRecording';
import type { SmartCompositionRecordingDraftProps } from './CompositionRecordingDraft'; import type { SmartCompositionRecordingDraftProps } from './CompositionRecordingDraft';
import { SmartCompositionRecordingDraft } 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 = { function renderSmartCompositionRecording(
id: string; recProps: SmartCompositionRecordingProps
}; ) {
return <SmartCompositionRecording {...recProps} />;
}
export type CompositionAreaPropsType = ExternalProps & ComponentPropsType; function renderSmartCompositionRecordingDraft(
draftProps: SmartCompositionRecordingDraftProps
) {
return <SmartCompositionRecordingDraft {...draftProps} />;
}
const mapStateToProps = (state: StateType, props: ExternalProps) => { export function SmartCompositionArea({ id }: { id: string }): JSX.Element {
const { id } = props; const conversationSelector = useSelector(getConversationSelector);
const platform = getPlatform(state);
const shouldHidePopovers = getHasPanelOpen(state);
const conversationSelector = getConversationSelector(state);
const conversation = conversationSelector(id); const conversation = conversationSelector(id);
if (!conversation) { strictAssert(conversation, `Conversation id ${id} not found!`);
throw new Error(`Conversation id ${id} not found!`);
}
const { const i18n = useSelector(getIntl);
announcementsOnly, const theme = useSelector(getTheme);
areWeAdmin, const skinTone = useSelector(getEmojiSkinTone);
draftEditMessage, const recentEmojis = useSelector(selectRecentEmojis);
draftText, const selectedMessageIds = useSelector(getSelectedMessageIds);
draftBodyRanges, const isFormattingEnabled = useSelector(getTextFormattingEnabled);
} = conversation; const lastEditableMessageId = useSelector(getLastEditableMessageId);
const receivedPacks = useSelector(getReceivedStickerPacks);
const receivedPacks = getReceivedStickerPacks(state); const installedPacks = useSelector(getInstalledStickerPacks);
const installedPacks = getInstalledStickerPacks(state); const blessedPacks = useSelector(getBlessedStickerPacks);
const blessedPacks = getBlessedStickerPacks(state); const knownPacks = useSelector(getKnownStickerPacks);
const knownPacks = getKnownStickerPacks(state); const platform = useSelector(getPlatform);
const shouldHidePopovers = useSelector(getHasPanelOpen);
const installedPack = getRecentlyInstalledStickerPack(state); const installedPack = useSelector(getRecentlyInstalledStickerPack);
const recentStickers = useSelector(getRecentStickers);
const recentStickers = getRecentStickers(state); const showStickersIntroduction = useSelector(getShowStickersIntroduction);
const showIntroduction = get( const showStickerPickerHint = useSelector(getShowStickerPickerHint);
state.items, const recordingState = useSelector(getRecordingState);
['showStickersIntroduction'], const errorDialogAudioRecorderType = useSelector(
false getErrorDialogAudioRecorderType
); );
const showPickerHint = Boolean( const getGroupAdmins = useSelector(getGroupAdminsSelector);
get(state.items, ['showStickerPickerHint'], false) && const getPreferredBadge = useSelector(getPreferredBadgeSelector);
receivedPacks.length > 0 const composerStateForConversationIdSelector = useSelector(
getComposerStateForConversationIdSelector
); );
const composerStateForConversationIdSelector =
getComposerStateForConversationIdSelector(state);
const composerState = composerStateForConversationIdSelector(id); const composerState = composerStateForConversationIdSelector(id);
const { announcementsOnly, areWeAdmin, draftEditMessage, draftBodyRanges } =
conversation;
const { const {
attachments: draftAttachments, attachments: draftAttachments,
focusCounter, focusCounter,
@ -114,6 +123,34 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
shouldSendHighQualityAttachments, shouldSendHighQualityAttachments,
} = composerState; } = 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; let { quotedMessage } = composerState;
if (!quotedMessage && draftEditMessage?.quote) { if (!quotedMessage && draftEditMessage?.quote) {
quotedMessage = { quotedMessage = {
@ -122,117 +159,189 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
}; };
} }
const recentEmojis = selectRecentEmojis(state); const quotedMessageProps = useSelector((state: StateType) => {
return quotedMessage
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
? getPropsForQuote(quotedMessage, { ? getPropsForQuote(quotedMessage, {
conversationSelector, conversationSelector,
ourConversationId: getUserConversationId(state), ourConversationId: getUserConversationId(state),
defaultConversationColor: getDefaultConversationColor(state), defaultConversationColor: getDefaultConversationColor(state),
}) })
: undefined, : 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),
draftText: dropNull(draftText), const { putItem, removeItem } = useItemsActions();
draftBodyRanges: hydrateRanges(draftBodyRanges, conversationSelector),
renderSmartCompositionRecording: ( const onSetSkinTone = useCallback(
recProps: SmartCompositionRecordingProps (tone: number) => {
) => { putItem('skinTone', tone);
return <SmartCompositionRecording {...recProps} />;
},
renderSmartCompositionRecordingDraft: (
draftProps: SmartCompositionRecordingDraftProps
) => {
return <SmartCompositionRecordingDraft {...draftProps} />;
}, },
[putItem]
);
// Select Mode const clearShowIntroduction = useCallback(() => {
selectedMessageIds, removeItem('showStickersIntroduction');
}; }, [removeItem]);
};
const dispatchPropsMap = { const clearShowPickerHint = useCallback(() => {
...mapDispatchToProps, removeItem('showStickerPickerHint');
onSetSkinTone: (tone: number) => mapDispatchToProps.putItem('skinTone', tone), }, [removeItem]);
clearShowIntroduction: () =>
mapDispatchToProps.removeItem('showStickersIntroduction'),
clearShowPickerHint: () =>
mapDispatchToProps.removeItem('showStickerPickerHint'),
onPickEmoji: mapDispatchToProps.onUseEmoji,
};
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 (
<CompositionArea
// Base
conversationId={id}
draftEditMessage={draftEditMessage ?? null}
focusCounter={focusCounter}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
isDisabled={isDisabled}
isFormattingEnabled={isFormattingEnabled}
lastEditableMessageId={lastEditableMessageId ?? null}
messageCompositionId={messageCompositionId}
platform={platform}
sendCounter={sendCounter}
shouldHidePopovers={shouldHidePopovers}
theme={theme}
convertDraftBodyRangesIntoHydrated={convertDraftBodyRangesIntoHydrated}
onTextTooLong={onTextTooLong}
pushPanelForConversation={pushPanelForConversation}
discardEditMessage={discardEditMessage}
onCloseLinkPreview={onCloseLinkPreview}
// AudioCapture
errorDialogAudioRecorderType={errorDialogAudioRecorderType ?? null}
recordingState={recordingState}
cancelRecording={cancelRecording}
completeRecording={completeRecording}
startRecording={startRecording}
errorRecording={errorRecording}
// AttachmentsList
draftAttachments={draftAttachments}
addAttachment={addAttachment}
removeAttachment={removeAttachment}
onClearAttachments={onClearAttachments}
processAttachments={processAttachments}
// MediaEditor
imageToBlurHash={imageToBlurHash}
// MediaQualitySelector
shouldSendHighQualityAttachments={
shouldSendHighQualityAttachments !== undefined
? shouldSendHighQualityAttachments
: window.storage.get('sent-media-quality') === 'high'
}
setMediaQualitySetting={setMediaQualitySetting}
// StagedLinkPreview
linkPreviewLoading={linkPreviewLoading}
linkPreviewResult={linkPreviewResult ?? null}
// Quote
quotedMessageId={quotedMessage?.quote?.messageId ?? null}
quotedMessageProps={quotedMessageProps ?? null}
quotedMessageAuthorAci={quotedMessage?.quote?.authorAci ?? null}
quotedMessageSentAt={quotedMessage?.quote?.id ?? null}
setQuoteByMessageId={setQuoteByMessageId}
// Emojis
recentEmojis={recentEmojis}
skinTone={skinTone}
onPickEmoji={onUseEmoji}
// Stickers
receivedPacks={receivedPacks}
installedPack={installedPack}
blessedPacks={blessedPacks}
knownPacks={knownPacks}
installedPacks={installedPacks}
recentStickers={recentStickers}
showIntroduction={showStickersIntroduction}
showPickerHint={showStickerPickerHint}
// Message Requests
acceptedMessageRequest={conversation.acceptedMessageRequest ?? null}
removalStage={conversation.removalStage ?? null}
addedByName={addedByName}
conversationName={conversationName}
conversationType={conversation.type}
isBlocked={conversation.isBlocked ?? false}
isReported={conversation.isReported ?? false}
isHidden={conversation.removalStage != null}
isSMSOnly={Boolean(isConversationSMSOnly(conversation))}
isSignalConversation={isSignalConversation(conversation)}
isFetchingUUID={conversation.isFetchingUUID ?? null}
isMissingMandatoryProfileSharing={isMissingRequiredProfileSharing(
conversation
)}
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
reportSpam={reportSpam}
deleteConversation={deleteConversation}
// Groups
groupVersion={conversation.groupVersion ?? null}
isGroupV1AndDisabled={conversation.isGroupV1AndDisabled ?? null}
left={conversation.left ?? null}
announcementsOnly={announcementsOnly ?? null}
areWeAdmin={areWeAdmin ?? null}
areWePending={conversation.areWePending ?? null}
areWePendingApproval={conversation.areWePendingApproval ?? null}
groupAdmins={groupAdmins}
draftText={conversation.draftText ?? null}
draftBodyRanges={hydratedDraftBodyRanges ?? null}
renderSmartCompositionRecording={renderSmartCompositionRecording}
renderSmartCompositionRecordingDraft={
renderSmartCompositionRecordingDraft
}
showGV2MigrationDialog={showGV2MigrationDialog}
cancelJoinRequest={cancelJoinRequest}
sortedGroupMembers={conversation.sortedGroupMembers ?? null}
// Select Mode
selectedMessageIds={selectedMessageIds}
toggleSelectMode={toggleSelectMode}
toggleForwardMessagesModal={toggleForwardMessagesModal}
// Dispatch
onSetSkinTone={onSetSkinTone}
clearShowIntroduction={clearShowIntroduction}
clearInstalledStickerPack={clearInstalledStickerPack}
clearShowPickerHint={clearShowPickerHint}
showToast={showToast}
sendStickerMessage={sendStickerMessage}
sendEditedMessage={sendEditedMessage}
sendMultiMediaMessage={sendMultiMediaMessage}
scrollToMessage={scrollToMessage}
setComposerFocus={setComposerFocus}
setMessageToEdit={setMessageToEdit}
showConversation={showConversation}
/>
);
}

View file

@ -6,7 +6,7 @@ import { useSelector } from 'react-redux';
import type { CompositionTextAreaProps } from '../../components/CompositionTextArea'; import type { CompositionTextAreaProps } from '../../components/CompositionTextArea';
import { CompositionTextArea } from '../../components/CompositionTextArea'; import { CompositionTextArea } from '../../components/CompositionTextArea';
import { getIntl, getPlatform } from '../selectors/user'; 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 { useItemsActions } from '../ducks/items';
import { getPreferredBadgeSelector } from '../selectors/badges'; import { getPreferredBadgeSelector } from '../selectors/badges';
import { useComposerActions } from '../ducks/composer'; import { useComposerActions } from '../ducks/composer';

View file

@ -44,6 +44,7 @@ export function SmartContactSpoofingReviewDialog(
const { const {
acceptConversation, acceptConversation,
reportSpam,
blockAndReportSpam, blockAndReportSpam,
blockConversation, blockConversation,
deleteConversation, deleteConversation,
@ -74,6 +75,7 @@ export function SmartContactSpoofingReviewDialog(
const sharedProps = { const sharedProps = {
...props, ...props,
acceptConversation, acceptConversation,
reportSpam,
blockAndReportSpam, blockAndReportSpam,
blockConversation, blockConversation,
deleteConversation, deleteConversation,

View file

@ -1,7 +1,7 @@
// Copyright 2020 Signal Messenger, LLC // Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import React from 'react'; import React, { useMemo } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { pick } from 'lodash'; import { pick } from 'lodash';
import type { ConversationType } from '../ducks/conversations'; import type { ConversationType } from '../ducks/conversations';
@ -37,6 +37,8 @@ import { useStoriesActions } from '../ducks/stories';
import { getCannotLeaveBecauseYouAreLastAdmin } from '../../components/conversation/conversation-details/ConversationDetails'; import { getCannotLeaveBecauseYouAreLastAdmin } from '../../components/conversation/conversation-details/ConversationDetails';
import { getGroupMemberships } from '../../util/getGroupMemberships'; import { getGroupMemberships } from '../../util/getGroupMemberships';
import { isGroupOrAdhocCallState } from '../../util/isGroupOrAdhocCall'; import { isGroupOrAdhocCallState } from '../../util/isGroupOrAdhocCall';
import { useContactNameData } from '../../components/conversation/ContactName';
import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation';
export type OwnProps = { export type OwnProps = {
id: string; id: string;
@ -108,6 +110,11 @@ export function SmartConversationHeader({ id }: OwnProps): JSX.Element {
setMuteExpiration, setMuteExpiration,
setPinned, setPinned,
toggleSelectMode, toggleSelectMode,
acceptConversation,
blockAndReportSpam,
blockConversation,
reportSpam,
deleteConversation,
} = useConversationsActions(); } = useConversationsActions();
const { const {
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
@ -129,6 +136,17 @@ export function SmartConversationHeader({ id }: OwnProps): JSX.Element {
const selectedMessageIds = useSelector(getSelectedMessageIds); const selectedMessageIds = useSelector(getSelectedMessageIds);
const isSelectMode = selectedMessageIds != null; 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 ( return (
<ConversationHeader <ConversationHeader
{...pick(conversation, [ {...pick(conversation, [
@ -184,6 +202,18 @@ export function SmartConversationHeader({ id }: OwnProps): JSX.Element {
isSelectMode={isSelectMode} isSelectMode={isSelectMode}
toggleSelectMode={toggleSelectMode} toggleSelectMode={toggleSelectMode}
viewUserStories={viewUserStories} viewUserStories={viewUserStories}
// MessageRequestActionsConfirmation
addedByName={addedByName}
conversationId={id}
conversationType={conversation.type}
conversationName={conversationName}
isBlocked={conversation.isBlocked ?? false}
isReported={conversation.isReported ?? false}
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
reportSpam={reportSpam}
deleteConversation={deleteConversation}
/> />
); );
} }

View file

@ -6,7 +6,7 @@ import { useSelector } from 'react-redux';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import type { LocalizerType } from '../../types/Util'; import type { LocalizerType } from '../../types/Util';
import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions'; import { usePreferredReactionsActions } from '../ducks/preferredReactions';
import { useItemsActions } from '../ducks/items'; import { useItemsActions } from '../ducks/items';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';
import { getEmojiSkinTone } from '../selectors/items'; import { getEmojiSkinTone } from '../selectors/items';

View file

@ -5,7 +5,7 @@ import * as React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import { useRecentEmojis } from '../selectors/emojis'; 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 type { Props as EmojiPickerProps } from '../../components/emoji/EmojiPicker';
import { EmojiPicker } from '../../components/emoji/EmojiPicker'; import { EmojiPicker } from '../../components/emoji/EmojiPicker';

View file

@ -25,6 +25,7 @@ import { getConversationsStoppingSend } from '../selectors/conversations';
import { getIntl, getTheme } from '../selectors/user'; import { getIntl, getTheme } from '../selectors/user';
import { useGlobalModalActions } from '../ducks/globalModals'; import { useGlobalModalActions } from '../ducks/globalModals';
import { SmartDeleteMessagesModal } from './DeleteMessagesModal'; import { SmartDeleteMessagesModal } from './DeleteMessagesModal';
import { SmartMessageRequestActionsConfirmation } from './MessageRequestActionsConfirmation';
function renderEditHistoryMessagesModal(): JSX.Element { function renderEditHistoryMessagesModal(): JSX.Element {
return <SmartEditHistoryMessagesModal />; return <SmartEditHistoryMessagesModal />;
@ -50,6 +51,10 @@ function renderForwardMessagesModal(): JSX.Element {
return <SmartForwardMessagesModal />; return <SmartForwardMessagesModal />;
} }
function renderMessageRequestActionsConfirmation(): JSX.Element {
return <SmartMessageRequestActionsConfirmation />;
}
function renderStoriesSettings(): JSX.Element { function renderStoriesSettings(): JSX.Element {
return <SmartStoriesSettingsModal />; return <SmartStoriesSettingsModal />;
} }
@ -83,6 +88,7 @@ export function SmartGlobalModalContainer(): JSX.Element {
errorModalProps, errorModalProps,
formattingWarningData, formattingWarningData,
forwardMessagesProps, forwardMessagesProps,
messageRequestActionsConfirmationProps,
isAuthorizingArtCreator, isAuthorizingArtCreator,
isProfileEditorVisible, isProfileEditorVisible,
isShortcutGuideModalVisible, isShortcutGuideModalVisible,
@ -163,6 +169,9 @@ export function SmartGlobalModalContainer(): JSX.Element {
deleteMessagesProps={deleteMessagesProps} deleteMessagesProps={deleteMessagesProps}
formattingWarningData={formattingWarningData} formattingWarningData={formattingWarningData}
forwardMessagesProps={forwardMessagesProps} forwardMessagesProps={forwardMessagesProps}
messageRequestActionsConfirmationProps={
messageRequestActionsConfirmationProps
}
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal} hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
hideUserNotFoundModal={hideUserNotFoundModal} hideUserNotFoundModal={hideUserNotFoundModal}
hideWhatsNewModal={hideWhatsNewModal} hideWhatsNewModal={hideWhatsNewModal}
@ -180,6 +189,9 @@ export function SmartGlobalModalContainer(): JSX.Element {
renderErrorModal={renderErrorModal} renderErrorModal={renderErrorModal}
renderDeleteMessagesModal={renderDeleteMessagesModal} renderDeleteMessagesModal={renderDeleteMessagesModal}
renderForwardMessagesModal={renderForwardMessagesModal} renderForwardMessagesModal={renderForwardMessagesModal}
renderMessageRequestActionsConfirmation={
renderMessageRequestActionsConfirmation
}
renderProfileEditor={renderProfileEditor} renderProfileEditor={renderProfileEditor}
renderUsernameOnboarding={renderUsernameOnboarding} renderUsernameOnboarding={renderUsernameOnboarding}
renderSafetyNumber={renderSafetyNumber} renderSafetyNumber={renderSafetyNumber}

View file

@ -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 (
<MessageRequestActionsConfirmation
i18n={i18n}
conversationId={conversation.id}
conversationType={conversation.type}
conversationName={conversationName}
addedByName={addedByName}
isBlocked={conversation.isBlocked ?? false}
isReported={conversation.isReported ?? false}
acceptConversation={acceptConversation}
blockConversation={blockConversation}
reportSpam={reportSpam}
blockAndReportSpam={blockAndReportSpam}
deleteConversation={deleteConversation}
state={state}
onChangeState={handleChangeState}
/>
);
}

View file

@ -4,7 +4,7 @@
import * as React from 'react'; import * as React from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import type { StateType } from '../reducer'; import type { StateType } from '../reducer';
import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions'; import { usePreferredReactionsActions } from '../ducks/preferredReactions';
import { useItemsActions } from '../ducks/items'; import { useItemsActions } from '../ducks/items';
import { getIntl } from '../selectors/user'; import { getIntl } from '../selectors/user';

View file

@ -32,7 +32,7 @@ import {
} from '../selectors/items'; } from '../selectors/items';
import { imageToBlurHash } from '../../util/imageToBlurHash'; import { imageToBlurHash } from '../../util/imageToBlurHash';
import { processAttachment } from '../../util/processAttachment'; import { processAttachment } from '../../util/processAttachment';
import { useActions as useEmojisActions } from '../ducks/emojis'; import { useEmojisActions } from '../ducks/emojis';
import { useAudioPlayerActions } from '../ducks/audioPlayer'; import { useAudioPlayerActions } from '../ducks/audioPlayer';
import { useComposerActions } from '../ducks/composer'; import { useComposerActions } from '../ducks/composer';
import { useConversationsActions } from '../ducks/conversations'; import { useConversationsActions } from '../ducks/conversations';
@ -148,6 +148,7 @@ export function SmartStoryCreator(): JSX.Element | null {
sendStoryModalOpenStateChanged={sendStoryModalOpenStateChanged} sendStoryModalOpenStateChanged={sendStoryModalOpenStateChanged}
setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections} setMyStoriesToAllSignalConnections={setMyStoriesToAllSignalConnections}
signalConnections={signalConnections} signalConnections={signalConnections}
sortedGroupMembers={null}
skinTone={skinTone} skinTone={skinTone}
theme={ThemeType.dark} theme={ThemeType.dark}
toggleGroupsForStorySend={toggleGroupsForStorySend} toggleGroupsForStorySend={toggleGroupsForStorySend}

View file

@ -32,7 +32,7 @@ import { isSignalConversation } from '../../util/isSignalConversation';
import { renderEmojiPicker } from './renderEmojiPicker'; import { renderEmojiPicker } from './renderEmojiPicker';
import { strictAssert } from '../../util/assert'; import { strictAssert } from '../../util/assert';
import { asyncShouldNeverBeCalled } from '../../util/shouldNeverBeCalled'; import { asyncShouldNeverBeCalled } from '../../util/shouldNeverBeCalled';
import { useActions as useEmojisActions } from '../ducks/emojis'; import { useEmojisActions } from '../ducks/emojis';
import { useConversationsActions } from '../ducks/conversations'; import { useConversationsActions } from '../ducks/conversations';
import { useRecentEmojis } from '../selectors/emojis'; import { useRecentEmojis } from '../selectors/emojis';
import { useItemsActions } from '../ducks/items'; import { useItemsActions } from '../ducks/items';

View file

@ -50,6 +50,7 @@ function renderItem({
containerElementRef, containerElementRef,
containerWidthBreakpoint, containerWidthBreakpoint,
conversationId, conversationId,
isBlocked,
isOldestTimelineItem, isOldestTimelineItem,
messageId, messageId,
nextMessageId, nextMessageId,
@ -61,6 +62,7 @@ function renderItem({
containerElementRef={containerElementRef} containerElementRef={containerElementRef}
containerWidthBreakpoint={containerWidthBreakpoint} containerWidthBreakpoint={containerWidthBreakpoint}
conversationId={conversationId} conversationId={conversationId}
isBlocked={isBlocked}
isOldestTimelineItem={isOldestTimelineItem} isOldestTimelineItem={isOldestTimelineItem}
messageId={messageId} messageId={messageId}
previousMessageId={previousMessageId} previousMessageId={previousMessageId}
@ -163,6 +165,7 @@ const mapStateToProps = (state: StateType, props: ExternalProps) => {
'isGroupV1AndDisabled', 'isGroupV1AndDisabled',
'typingContactIdTimestamps', 'typingContactIdTimestamps',
]), ]),
isBlocked: conversation.isBlocked ?? false,
isConversationSelected: state.conversations.selectedConversationId === id, isConversationSelected: state.conversations.selectedConversationId === id,
isIncomingMessageRequest: Boolean( isIncomingMessageRequest: Boolean(
!conversation.acceptedMessageRequest && !conversation.acceptedMessageRequest &&

View file

@ -2,7 +2,7 @@
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import type { RefObject } from 'react'; import type { RefObject } from 'react';
import React from 'react'; import React, { useCallback } from 'react';
import { useSelector } from 'react-redux'; import { useSelector } from 'react-redux';
import { TimelineItem } from '../../components/conversation/TimelineItem'; import { TimelineItem } from '../../components/conversation/TimelineItem';
@ -35,11 +35,13 @@ import { isSameDay } from '../../util/timestamp';
import { renderAudioAttachment } from './renderAudioAttachment'; import { renderAudioAttachment } from './renderAudioAttachment';
import { renderEmojiPicker } from './renderEmojiPicker'; import { renderEmojiPicker } from './renderEmojiPicker';
import { renderReactionPicker } from './renderReactionPicker'; import { renderReactionPicker } from './renderReactionPicker';
import type { MessageRequestState } from '../../components/conversation/MessageRequestActionsConfirmation';
export type SmartTimelineItemProps = { export type SmartTimelineItemProps = {
containerElementRef: RefObject<HTMLElement>; containerElementRef: RefObject<HTMLElement>;
containerWidthBreakpoint: WidthBreakpoint; containerWidthBreakpoint: WidthBreakpoint;
conversationId: string; conversationId: string;
isBlocked: boolean;
isOldestTimelineItem: boolean; isOldestTimelineItem: boolean;
messageId: string; messageId: string;
nextMessageId: undefined | string; nextMessageId: undefined | string;
@ -59,6 +61,7 @@ export function SmartTimelineItem(props: SmartTimelineItemProps): JSX.Element {
containerElementRef, containerElementRef,
containerWidthBreakpoint, containerWidthBreakpoint,
conversationId, conversationId,
isBlocked,
isOldestTimelineItem, isOldestTimelineItem,
messageId, messageId,
nextMessageId, nextMessageId,
@ -136,23 +139,27 @@ export function SmartTimelineItem(props: SmartTimelineItemProps): JSX.Element {
const { const {
showContactModal, showContactModal,
showEditHistoryModal, showEditHistoryModal,
toggleMessageRequestActionsConfirmation,
toggleDeleteMessagesModal, toggleDeleteMessagesModal,
toggleForwardMessagesModal, toggleForwardMessagesModal,
toggleSafetyNumberModal, toggleSafetyNumberModal,
} = useGlobalModalActions(); } = useGlobalModalActions();
const { checkForAccount } = useAccountsActions(); const { checkForAccount } = useAccountsActions();
const { showLightbox, showLightboxForViewOnceMedia } = useLightboxActions(); const { showLightbox, showLightboxForViewOnceMedia } = useLightboxActions();
const { viewStory } = useStoriesActions(); const { viewStory } = useStoriesActions();
const { const {
onOutgoingAudioCallInConversation, onOutgoingAudioCallInConversation,
onOutgoingVideoCallInConversation, onOutgoingVideoCallInConversation,
returnToActiveCall, returnToActiveCall,
} = useCallingActions(); } = useCallingActions();
const onOpenMessageRequestActionsConfirmation = useCallback(
(state: MessageRequestState) => {
toggleMessageRequestActionsConfirmation({ conversationId, state });
},
[conversationId, toggleMessageRequestActionsConfirmation]
);
return ( return (
<TimelineItem <TimelineItem
item={item} item={item}
@ -175,6 +182,7 @@ export function SmartTimelineItem(props: SmartTimelineItemProps): JSX.Element {
showEditHistoryModal={showEditHistoryModal} showEditHistoryModal={showEditHistoryModal}
i18n={i18n} i18n={i18n}
interactionMode={interactionMode} interactionMode={interactionMode}
isBlocked={isBlocked}
theme={theme} theme={theme}
platform={platform} platform={platform}
blockGroupLinkRequests={blockGroupLinkRequests} blockGroupLinkRequests={blockGroupLinkRequests}
@ -188,6 +196,9 @@ export function SmartTimelineItem(props: SmartTimelineItemProps): JSX.Element {
pushPanelForConversation={pushPanelForConversation} pushPanelForConversation={pushPanelForConversation}
reactToMessage={reactToMessage} reactToMessage={reactToMessage}
copyMessageText={copyMessageText} copyMessageText={copyMessageText}
onOpenMessageRequestActionsConfirmation={
onOpenMessageRequestActionsConfirmation
}
onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation} onOutgoingAudioCallInConversation={onOutgoingAudioCallInConversation}
onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation} onOutgoingVideoCallInConversation={onOutgoingVideoCallInConversation}
retryDeleteForEveryone={retryDeleteForEveryone} retryDeleteForEveryone={retryDeleteForEveryone}

View file

@ -114,7 +114,7 @@ Bootstrap.benchmark(async (bootstrap: Bootstrap): Promise<void> => {
const item = leftPane const item = leftPane
.locator( .locator(
'.module-conversation-list__item--contact-or-conversation' + '.module-conversation-list__item--contact-or-conversation' +
`>> text=${LAST_MESSAGE}` '>> text="You accepted the message request"'
) )
.first(); .first();
await item.click({ timeout: 2 * MINUTE }); await item.click({ timeout: 2 * MINUTE });

View file

@ -1,7 +1,8 @@
// Copyright 2023 Signal Messenger, LLC // Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // 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 { export function bufferToUuid(buffer: Buffer): string {
const hex = buffer.toString('hex'); const hex = buffer.toString('hex');
@ -32,3 +33,44 @@ export async function type(input: Locator, text: string): Promise<void> {
// updated with the right value // updated with the right value
await input.locator(`:text("${currentValue}${text}")`).waitFor(); await input.locator(`:text("${currentValue}${text}")`).waitFor();
} }
export async function expectItemsWithText(
items: Locator,
expected: ReadonlyArray<string | RegExp>
): Promise<void> {
// 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<string | RegExp>
): Promise<void> {
await expectItemsWithText(
context.locator('.SystemMessage__contents'),
expected
);
}

View file

@ -13,6 +13,7 @@ import {
} from '../../util/libphonenumberInstance'; } from '../../util/libphonenumberInstance';
import { Bootstrap } from '../bootstrap'; import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap'; import type { App } from '../bootstrap';
import { expectSystemMessages } from '../helpers';
export const debug = createDebug('mock:test:gv2'); 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'); debug('Checking that notifications are present');
await window await window
.locator(`"${first.profileName} invited you to the group."`) .locator(
`.SystemMessage:has-text("${first.profileName} invited you to the group.")`
)
.waitFor(); .waitFor();
await window await window
.locator( .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(); .waitFor();
@ -130,7 +133,9 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
assert(group.getPendingMemberByServiceId(desktop.pni)); assert(group.getPendingMemberByServiceId(desktop.pni));
await window await window
.locator(`"${second.profileName} invited you to the group."`) .locator(
`.SystemMessage:has-text("${second.profileName} invited you to the group.")`
)
.waitFor(); .waitFor();
debug('Verify that message request state is not visible'); debug('Verify that message request state is not visible');
@ -179,11 +184,11 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
debug('Declining'); debug('Declining');
await conversationStack await conversationStack
.locator('.module-message-request-actions button >> "Delete"') .locator('.module-message-request-actions button >> "Block"')
.click(); .click();
debug('waiting for confirmation modal'); 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); group = await phone.waitForGroupUpdate(group);
assert.strictEqual(group.revision, 2); assert.strictEqual(group.revision, 2);
@ -217,7 +222,9 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
debug('Waiting for the PNI invite'); debug('Waiting for the PNI invite');
await window await window
.locator(`text=${first.profileName} invited you to the group.`) .locator(
`.SystemMessage:has-text("${first.profileName} invited you to the group.")`
)
.waitFor(); .waitFor();
debug('Inviting ACI from another contact'); 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'); debug('Waiting for the ACI invite');
await window await window
.locator(`text=${second.profileName} invited you to the group.`) .locator(
`.SystemMessage:has-text("${second.profileName} invited you to the group.")`
)
.waitFor(); .waitFor();
debug('Accepting'); debug('Accepting');
@ -240,8 +249,7 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
debug('Checking final notification'); debug('Checking final notification');
await window await window
.locator( .locator(
'.SystemMessage >> text=You accepted an invitation to the group from ' + `.SystemMessage:has-text("You accepted an invitation to the group from ${second.profileName}.")`
`${second.profileName}.`
) )
.waitFor(); .waitFor();
@ -291,11 +299,11 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
debug('Declining'); debug('Declining');
await conversationStack await conversationStack
.locator('.module-message-request-actions button >> "Delete"') .locator('.module-message-request-actions button >> "Block"')
.click(); .click();
debug('waiting for confirmation modal'); 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); group = await phone.waitForGroupUpdate(group);
assert.strictEqual(group.revision, 3); assert.strictEqual(group.revision, 3);
@ -347,13 +355,10 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
sendUpdateTo: [{ device: desktop }], sendUpdateTo: [{ device: desktop }],
}); });
await window await expectSystemMessages(window, [
.locator( 'You were added to the group.',
'.SystemMessage >> ' + `${second.profileName} accepted an invitation to the group from ${first.profileName}.`,
`text=${second.profileName} accepted an invitation to the group ` + ]);
`from ${first.profileName}.`
)
.waitFor();
}); });
it('should display a e164 for a PNI invite', async () => { 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; const { e164 } = parsedE164;
await window await window
.locator(`.SystemMessage >> text=You invited ${e164} to the group`) .locator(`.SystemMessage:has-text("You invited ${e164} to the group")`)
.waitFor(); .waitFor();
debug('Accepting remote invite'); debug('Accepting remote invite');
@ -408,11 +413,10 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
}); });
debug('Waiting for accept notification'); debug('Waiting for accept notification');
await window await expectSystemMessages(window, [
.locator( 'You were added to the group.',
'.SystemMessage >> ' + /^You invited .* to the group\.$/,
`text=${unknownPniContact.profileName} accepted your invitation to the group` `${unknownPniContact.profileName} accepted your invitation to the group.`,
) ]);
.waitFor();
}); });
}); });

View file

@ -14,6 +14,7 @@ import { toUntaggedPni } from '../../types/ServiceId';
import { MY_STORY_ID } from '../../types/Stories'; import { MY_STORY_ID } from '../../types/Stories';
import { Bootstrap } from '../bootstrap'; import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap'; import type { App } from '../bootstrap';
import { expectSystemMessages } from '../helpers';
export const debug = createDebug('mock:test:merge'); 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'); const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 0, 'message count'); assert.strictEqual(await messages.count(), 0, 'message count');
// No notifications await expectSystemMessages(window, [
const notifications = window.locator('.SystemMessage'); 'You accepted the message request',
assert.strictEqual( ]);
await notifications.count(),
0,
'notification count'
);
} }
if (withPNIMessage) { if (withPNIMessage) {
@ -210,20 +207,25 @@ describe('pnp/merge', function (this: Mocha.Suite) {
'message count' 'message count'
); );
// One notification - the merge if (withPNIMessage) {
const notifications = window.locator('.SystemMessage'); if (pniSignatureVerified) {
assert.strictEqual( await expectSystemMessages(window, [
await notifications.count(), 'You accepted the message request',
withPNIMessage ? 1 : 0, 'You accepted the message request',
'notification count' /Your message history with ACI Contact and their number .* has been merged\./,
); ]);
} else {
if (withPNIMessage && !pniSignatureVerified) { await expectSystemMessages(window, [
const first = await notifications.first(); 'You accepted the message request',
assert.match( 'You accepted the message request',
await first.innerText(), /Your message history with ACI Contact and their number .* has been merged\./,
/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',
]);
} }
} }
}); });

View file

@ -12,6 +12,7 @@ import { MY_STORY_ID } from '../../types/Stories';
import { toUntaggedPni } from '../../types/ServiceId'; import { toUntaggedPni } from '../../types/ServiceId';
import { Bootstrap } from '../bootstrap'; import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap'; import type { App } from '../bootstrap';
import { expectSystemMessages } from '../helpers';
export const debug = createDebug('mock:test:merge'); 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'); const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 1, 'message count'); assert.strictEqual(await messages.count(), 1, 'message count');
// One notification - the PhoneNumberDiscovery await expectSystemMessages(window, [
const notifications = window.locator('.SystemMessage'); 'You accepted the message request',
assert.strictEqual(await notifications.count(), 1, 'notification count'); /.* belongs to ACI Contact/,
]);
const first = await notifications.first();
assert.match(await first.innerText(), /.* belongs to ACI Contact/);
} }
}); });
}); });

View file

@ -10,6 +10,7 @@ import * as durations from '../../util/durations';
import { generatePni, toUntaggedPni } from '../../types/ServiceId'; import { generatePni, toUntaggedPni } from '../../types/ServiceId';
import { Bootstrap } from '../bootstrap'; import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap'; import type { App } from '../bootstrap';
import { expectSystemMessages } from '../helpers';
export const debug = createDebug('mock:test:pni-change'); 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'); assert.strictEqual(await messages.count(), 0, 'message count');
// No notifications // No notifications
const notifications = window.locator('.SystemMessage'); await expectSystemMessages(window, ['You accepted the message request']);
assert.strictEqual(await notifications.count(), 0, 'notification count');
} }
debug('Send message to contactA'); 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'); assert.strictEqual(await messages.count(), 1, 'message count');
// Only a PhoneNumberDiscovery notification // Only a PhoneNumberDiscovery notification
const notifications = window.locator('.SystemMessage'); await expectSystemMessages(window, [
assert.strictEqual(await notifications.count(), 1, 'notification count'); 'You accepted the message request',
/.* belongs to ContactA/,
const first = await notifications.first(); ]);
assert.match(await first.innerText(), /.* belongs to ContactA/);
} }
}); });
@ -199,9 +198,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
const messages = window.locator('.module-message__text'); const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 0, 'message count'); assert.strictEqual(await messages.count(), 0, 'message count');
// No notifications await expectSystemMessages(window, ['You accepted the message request']);
const notifications = window.locator('.SystemMessage');
assert.strictEqual(await notifications.count(), 0, 'notification count');
} }
debug('Send message to contactA'); 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'); assert.strictEqual(await messages.count(), 1, 'message count');
// Two notifications - the safety number change and PhoneNumberDiscovery // Two notifications - the safety number change and PhoneNumberDiscovery
const notifications = window.locator('.SystemMessage'); await expectSystemMessages(window, [
assert.strictEqual(await notifications.count(), 2, 'notification count'); 'You accepted the message request',
/.* belongs to ContactA/,
const first = await notifications.first(); /Safety Number has changed/,
assert.match(await first.innerText(), /.* belongs to ContactA/); ]);
const second = await notifications.nth(1);
assert.match(await second.innerText(), /Safety Number has changed/);
} }
}); });
@ -305,9 +299,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
const messages = window.locator('.module-message__text'); const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 0, 'message count'); assert.strictEqual(await messages.count(), 0, 'message count');
// No notifications await expectSystemMessages(window, ['You accepted the message request']);
const notifications = window.locator('.SystemMessage');
assert.strictEqual(await notifications.count(), 0, 'notification count');
} }
debug('Send message to contactA'); debug('Send message to contactA');
@ -403,15 +395,12 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
const messages = window.locator('.module-message__text'); const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 2, 'message count'); assert.strictEqual(await messages.count(), 2, 'message count');
// Two notifications - the safety number change and PhoneNumberDiscovery // Three notifications - accepted, the safety number change and PhoneNumberDiscovery
const notifications = window.locator('.SystemMessage'); await expectSystemMessages(window, [
assert.strictEqual(await notifications.count(), 2, 'notification count'); 'You accepted the message request',
/.* belongs to ContactA/,
const first = await notifications.first(); /Safety Number has changed/,
assert.match(await first.innerText(), /.* belongs to ContactA/); ]);
const second = await notifications.nth(1);
assert.match(await second.innerText(), /Safety Number has changed/);
} }
}); });
@ -442,8 +431,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
assert.strictEqual(await messages.count(), 0, 'message count'); assert.strictEqual(await messages.count(), 0, 'message count');
// No notifications // No notifications
const notifications = window.locator('.SystemMessage'); await expectSystemMessages(window, ['You accepted the message request']);
assert.strictEqual(await notifications.count(), 0, 'notification count');
} }
debug('Send message to contactA'); 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'); assert.strictEqual(await messages.count(), 2, 'message count');
// Only a PhoneNumberDiscovery notification // Only a PhoneNumberDiscovery notification
const notifications = window.locator('.SystemMessage'); await expectSystemMessages(window, [
assert.strictEqual(await notifications.count(), 1, 'notification count'); 'You accepted the message request',
/.* belongs to ContactA/,
const first = await notifications.first(); ]);
assert.match(await first.innerText(), /.* belongs to ContactA/);
} }
}); });
}); });

View file

@ -23,6 +23,7 @@ import {
RECEIPT_BATCHER_WAIT_MS, RECEIPT_BATCHER_WAIT_MS,
} from '../../types/Receipt'; } from '../../types/Receipt';
import { sleep } from '../../util/sleep'; import { sleep } from '../../util/sleep';
import { expectSystemMessages } from '../helpers';
export const debug = createDebug('mock:test:pni-signature'); 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'); const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 4, 'message count'); assert.strictEqual(await messages.count(), 4, 'message count');
// No notifications await expectSystemMessages(window, ['You accepted the message request']);
const notifications = window.locator('.SystemMessage');
assert.strictEqual(await notifications.count(), 0, 'notification count');
} }
}); });
@ -424,11 +423,10 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
assert.strictEqual(await messages.count(), 3, 'messages'); assert.strictEqual(await messages.count(), 3, 'messages');
// Title transition notification // Title transition notification
const notifications = window.locator('.SystemMessage'); await expectSystemMessages(window, [
assert.strictEqual(await notifications.count(), 1, 'notifications'); 'You accepted the message request',
/You started this chat with/,
const first = await notifications.first(); ]);
assert.match(await first.innerText(), /You started this chat with/);
assert.isEmpty(await phone.getOrphanedStorageKeys()); assert.isEmpty(await phone.getOrphanedStorageKeys());
} }

View file

@ -175,10 +175,7 @@ describe('pnp/send gv2 invite', function (this: Mocha.Suite) {
await detailsHeader.locator('button >> "My group"').click(); await detailsHeader.locator('button >> "My group"').click();
const modal = window.locator('.module-Modal:has-text("Edit group")'); const modal = window.locator('.module-Modal:has-text("Edit group")');
await modal.locator('input').fill('My group (v2)');
// Group title should be immediately focused.
await modal.type(' (v2)');
await modal.locator('button >> "Save"').click(); await modal.locator('button >> "Save"').click();
} }

View file

@ -1,21 +1,16 @@
// Copyright 2021 Signal Messenger, LLC // Copyright 2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only // SPDX-License-Identifier: AGPL-3.0-only
import * as sinon from 'sinon'; import * as sinon from 'sinon';
import { Job } from '../../../jobs/Job'; import { Job } from '../../../jobs/Job';
import { generateAci } from '../../../types/ServiceId';
import { addReportSpamJob } from '../../../jobs/helpers/addReportSpamJob'; import { addReportSpamJob } from '../../../jobs/helpers/addReportSpamJob';
import type { ConversationType } from '../../../state/ducks/conversations';
import { getDefaultConversation } from '../../../test-both/helpers/getDefaultConversation';
describe('addReportSpamJob', () => { describe('addReportSpamJob', () => {
let getMessageServerGuidsForSpam: sinon.SinonStub; let getMessageServerGuidsForSpam: sinon.SinonStub;
let jobQueue: { add: sinon.SinonStub }; let jobQueue: { add: sinon.SinonStub };
const conversation = { const conversation: ConversationType = getDefaultConversation();
id: 'convo',
type: 'private' as const,
serviceId: generateAci(),
};
beforeEach(() => { beforeEach(() => {
getMessageServerGuidsForSpam = sinon.stub().resolves(['abc', 'xyz']); getMessageServerGuidsForSpam = sinon.stub().resolves(['abc', 'xyz']);

View file

@ -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',
}

View file

@ -44,6 +44,7 @@ export enum ToastType {
OriginalMessageNotFound = 'OriginalMessageNotFound', OriginalMessageNotFound = 'OriginalMessageNotFound',
PinnedConversationsFull = 'PinnedConversationsFull', PinnedConversationsFull = 'PinnedConversationsFull',
ReactionFailed = 'ReactionFailed', ReactionFailed = 'ReactionFailed',
ReportedSpam = 'ReportedSpam',
ReportedSpamAndBlocked = 'ReportedSpamAndBlocked', ReportedSpamAndBlocked = 'ReportedSpamAndBlocked',
StickerPackInstallFailed = 'StickerPackInstallFailed', StickerPackInstallFailed = 'StickerPackInstallFailed',
StoryMuted = 'StoryMuted', StoryMuted = 'StoryMuted',
@ -120,6 +121,7 @@ export type AnyToast =
| { toastType: ToastType.OriginalMessageNotFound } | { toastType: ToastType.OriginalMessageNotFound }
| { toastType: ToastType.PinnedConversationsFull } | { toastType: ToastType.PinnedConversationsFull }
| { toastType: ToastType.ReactionFailed } | { toastType: ToastType.ReactionFailed }
| { toastType: ToastType.ReportedSpam }
| { toastType: ToastType.ReportedSpamAndBlocked } | { toastType: ToastType.ReportedSpamAndBlocked }
| { toastType: ToastType.StickerPackInstallFailed } | { toastType: ToastType.StickerPackInstallFailed }
| { toastType: ToastType.StoryMuted } | { toastType: ToastType.StoryMuted }

View file

@ -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;
}

View file

@ -177,6 +177,7 @@ export function getConversation(model: ConversationModel): ConversationType {
inboxPosition, inboxPosition,
isArchived: attributes.isArchived, isArchived: attributes.isArchived,
isBlocked: isBlocked(attributes), isBlocked: isBlocked(attributes),
reportingToken: attributes.reportingToken,
removalStage: attributes.removalStage, removalStage: attributes.removalStage,
isMe: isMe(attributes), isMe: isMe(attributes),
isGroupV1AndDisabled: isGroupV1(attributes), isGroupV1AndDisabled: isGroupV1(attributes),

View file

@ -45,12 +45,15 @@ import {
isTapToView, isTapToView,
isUnsupportedMessage, isUnsupportedMessage,
isConversationMerge, isConversationMerge,
isMessageRequestResponse,
} from '../state/selectors/message'; } from '../state/selectors/message';
import { import {
getContact, getContact,
messageHasPaymentEvent, messageHasPaymentEvent,
getPaymentEventNotificationText, getPaymentEventNotificationText,
} from '../messages/helpers'; } from '../messages/helpers';
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
import { missingCaseError } from './missingCaseError';
function getNameForNumber(e164: string): string { function getNameForNumber(e164: string): string {
const conversation = window.ConversationController.get(e164); 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; const { attachments = [] } = attributes;
if (isTapToView(attributes)) { if (isTapToView(attributes)) {

View file

@ -12,6 +12,7 @@ import {
} from '../messages/helpers'; } from '../messages/helpers';
import { isDirectConversation, isGroupV2 } from './whatTypeOfConversation'; import { isDirectConversation, isGroupV2 } from './whatTypeOfConversation';
import { getE164 } from './getE164'; import { getE164 } from './getE164';
import type { ConversationType } from '../state/ducks/conversations';
export function getMessageIdForLogging( export function getMessageIdForLogging(
message: Pick< message: Pick<
@ -27,7 +28,7 @@ export function getMessageIdForLogging(
} }
export function getConversationIdForLogging( export function getConversationIdForLogging(
conversation: ConversationAttributesType conversation: ConversationAttributesType | ConversationType
): string { ): string {
if (isDirectConversation(conversation)) { if (isDirectConversation(conversation)) {
const { serviceId, pni, id } = conversation; const { serviceId, pni, id } = conversation;

View file

@ -3372,6 +3372,13 @@
"updated": "2022-01-04T21:43:17.517Z", "updated": "2022-01-04T21:43:17.517Z",
"reasonDetail": "Used to change the style in non-production builds." "reasonDetail": "Used to change the style in non-production builds."
}, },
{
"rule": "React-useRef",
"path": "ts/components/SafetyTipsModal.tsx",
"line": " const scrollEndTimer = useRef<NodeJS.Timeout | null>(null);",
"reasonCategory": "usageTrusted",
"updated": "2024-03-08T01:48:15.330Z"
},
{ {
"rule": "React-useRef", "rule": "React-useRef",
"path": "ts/components/Slider.tsx", "path": "ts/components/Slider.tsx",