Spam Reporting UI changes

Co-authored-by: Jamie Kyle <113370520+jamiebuilds-signal@users.noreply.github.com>
This commit is contained in:
automated-signal 2024-03-12 11:58:04 -05:00 committed by GitHub
parent 4772a4383f
commit e736470f92
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,
getPreferredSystemLocales: () => ['en'],
getResolvedMessagesLocaleDirection: () => 'ltr',
getLocaleOverride: () => null,
getLocaleDisplayNames: () => ({ en: { en: 'English' } }),
};
@ -133,6 +132,9 @@ const withGlobalTypesProvider = (Story, context) => {
const mode = context.globals.mode;
const direction = context.globals.direction ?? 'auto';
window.SignalContext.getResolvedMessagesLocaleDirection = () =>
direction === 'auto' ? 'ltr' : direction;
// Adding it to the body as well so that we can cover modals and other
// components that are rendered outside of this decorator container
if (theme === 'light') {

View file

@ -427,6 +427,26 @@
"messageformat": "Select messages",
"description": "Shown in menu for conversation, allows the user to start selecting multiple messages in the conversation"
},
"icu:ConversationHeader__MenuItem--Accept": {
"messageformat": "Accept",
"description": "Shown in menu for conversation, allows the user to accept a message request"
},
"icu:ConversationHeader__MenuItem--Block": {
"messageformat": "Block",
"description": "Shown in menu for conversation, allows the user to block the contact"
},
"icu:ConversationHeader__MenuItem--Unblock": {
"messageformat": "Unblock",
"description": "Shown in menu for conversation, allows the user to unblock the contact"
},
"icu:ConversationHeader__MenuItem--ReportSpam": {
"messageformat": "Report Spam",
"description": "Shown in menu for conversation, allows the user to report the conversation as spam"
},
"icu:ConversationHeader__MenuItem--DeleteChat": {
"messageformat": "Delete Chat",
"description": "Shown in menu for conversation, allows the user to delete the conversation"
},
"icu:ContactListItem__menu": {
"messageformat": "Manage Contact",
"description": "Shown as aria label for context menu for a contact"
@ -3387,6 +3407,62 @@
"messageformat": "All",
"description": "Shown in reaction viewer as the title for the 'all' category"
},
"icu:SafetyTipsModal__Title": {
"messageformat": "Safety Tips",
"description": "Title of the safety tips modal"
},
"icu:SafetyTipsModal__Description": {
"messageformat": "Be careful when accepting message requests from people you 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": {
"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"
@ -3451,6 +3527,42 @@
"messageformat": "You will no longer receive messages or updates from this group and members won't be able to add you to this group again.",
"description": "Shown as the body in the confirmation modal for blocking a group message request"
},
"icu:MessageRequests--reportAndMaybeBlock": {
"messageformat": "Report...",
"description": "Shown as a button to let the user report a message request and maybe block the user"
},
"icu:MessageRequests--ReportAndMaybeBlockModal-title": {
"messageformat": "Report as spam?",
"description": "Shown as the title in the modal for reporting and maybe blocking a message request"
},
"icu:MessageRequests--ReportAndMaybeBlockModal-body--direct": {
"messageformat": "Signal will be notified that this person may be sending spam. Signal 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": {
"messageformat": "Delete",
"description": "Shown as a button to let the user delete any message request"
@ -5235,6 +5347,10 @@
"messageformat": "Learn more",
"description": "Shown on the message request warning. Clicking this button will open a dialog with more information"
},
"icu:MessageRequestWarning__safety-tips": {
"messageformat": "Safety Tips",
"description": "Shown on the message request warning. Clicking this button will open a dialog with safety tips"
},
"icu:MessageRequestWarning__dialog__details": {
"messageformat": "You have no groups in common with this person. Review requests carefully before accepting to avoid unwanted messages.",
"description": "Shown in the message request warning dialog. Gives more information about message requests"
@ -6319,6 +6435,26 @@
"messageformat": "Check your primary device for this payments status",
"description": "Payment event notification check device label"
},
"icu:MessageRequestResponseNotification__Message--Accepted": {
"messageformat": "You accepted the message request",
"description": "Message request response notification message when the user accepted the message request or unblocked another user"
},
"icu:MessageRequestResponseNotification__Message--Reported": {
"messageformat": "Reported as spam",
"description": "Message request response notification message when the user reported the message request as spam"
},
"icu:MessageRequestResponseNotification__Message--Blocked": {
"messageformat": "You blocked this person",
"description": "Message request response notification message when the user blocked another user"
},
"icu:MessageRequestResponseNotification__Button--Options": {
"messageformat": "Options",
"description": "Message request response notification button to show options"
},
"icu:MessageRequestResponseNotification__Button--LearnMore": {
"messageformat": "Learn More",
"description": "Message request response notification button to learn more"
},
"icu:SignalConnectionsModal__title": {
"messageformat": "Signal Connections",
"description": "The phrase/term: 'Signal Connections'"

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

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

@ -548,6 +548,8 @@ message SyncMessage {
DELETE = 2;
BLOCK = 3;
BLOCK_AND_DELETE = 4;
SPAM = 5;
BLOCK_AND_SPAM = 6;
}
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 {
@include font-body-2;
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'
);
}
&--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 {

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -19,7 +19,7 @@ import type { PreferredBadgeSelectorType } from '../state/selectors/badges';
import * as grapheme from '../util/grapheme';
export type CompositionTextAreaProps = {
bodyRanges?: HydratedBodyRangesType;
bodyRanges: HydratedBodyRangesType | null;
i18n: LocalizerType;
isFormattingEnabled: boolean;
maxLength?: number;
@ -153,6 +153,17 @@ export function CompositionTextArea({
scrollerRef={scrollerRef}
sendCounter={0}
theme={theme}
skinTone={skinTone ?? null}
// These do not apply in the forward modal because there isn't
// strictly one conversation
conversationId={null}
sortedGroupMembers={null}
// we don't edit in this context
draftEditMessage={null}
// rendered in the forward modal
linkPreviewResult={null}
// Panels appear behind this modal
shouldHidePopovers={null}
/>
<div className="CompositionTextArea__emoji">
<EmojiButton

View file

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

View file

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

View file

@ -176,13 +176,12 @@ export function MediaEditor({
const [isEmojiPopperOpen, setEmojiPopperOpen] = useState<boolean>(false);
const [caption, setCaption] = useState(draftText ?? '');
const [captionBodyRanges, setCaptionBodyRanges] = useState<
DraftBodyRanges | undefined
>(draftBodyRanges);
const [captionBodyRanges, setCaptionBodyRanges] =
useState<DraftBodyRanges | null>(draftBodyRanges);
const conversationSelector = useSelector(getConversationSelector);
const hydratedBodyRanges = useMemo(
() => hydrateRanges(captionBodyRanges, conversationSelector),
() => hydrateRanges(captionBodyRanges ?? undefined, conversationSelector),
[captionBodyRanges, conversationSelector]
);
@ -1297,7 +1296,7 @@ export function MediaEditor({
<div className="MediaEditor__tools--input dark-theme">
<CompositionInput
draftText={caption}
draftBodyRanges={hydratedBodyRanges}
draftBodyRanges={hydratedBodyRanges ?? null}
getPreferredBadge={getPreferredBadge}
i18n={i18n}
inputApi={inputApiRef}
@ -1308,6 +1307,7 @@ export function MediaEditor({
setCaptionBodyRanges(bodyRanges);
setCaption(messageText);
}}
skinTone={skinTone ?? null}
onPickEmoji={onPickEmoji}
onSubmit={noop}
onTextTooLong={onTextTooLong}
@ -1316,6 +1316,16 @@ export function MediaEditor({
sendCounter={0}
sortedGroupMembers={sortedGroupMembers}
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
className="StoryViewsNRepliesModal__emoji-button"
@ -1394,7 +1404,7 @@ export function MediaEditor({
contentType: IMAGE_PNG,
data,
caption: caption !== '' ? caption : undefined,
captionBodyRanges,
captionBodyRanges: captionBodyRanges ?? undefined,
blurHash,
});
}}

View file

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

View file

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

View file

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

View file

@ -371,6 +371,14 @@ export function renderToast({
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) {
return (
<Toast onClose={hideToast}>

View file

@ -1,21 +1,47 @@
// Copyright 2018 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useMemo } from 'react';
import classNames from 'classnames';
import { Emojify } from './Emojify';
import type { ContactNameColorType } from '../../types/Colors';
import { getClassNamesFor } from '../../util/getClassNamesFor';
import type { ConversationType } from '../../state/ducks/conversations';
import { isSignalConversation as getIsSignalConversation } from '../../util/isSignalConversation';
export type PropsType = {
export type ContactNameData = {
contactNameColor?: ContactNameColorType;
firstName?: string;
isSignalConversation?: boolean;
isMe?: boolean;
title: string;
};
export function useContactNameData(
conversation: ConversationType | null,
contactNameColor?: ContactNameColorType
): ContactNameData | null {
const { firstName, title, isMe } = conversation ?? {};
const isSignalConversation =
conversation != null ? getIsSignalConversation(conversation) : null;
return useMemo(() => {
if (title == null || isSignalConversation == null) {
return null;
}
return {
contactNameColor,
firstName,
isSignalConversation,
isMe,
title,
};
}, [contactNameColor, firstName, isSignalConversation, isMe, title]);
}
export type PropsType = ContactNameData & {
module?: string;
preferFirstName?: boolean;
title: string;
onClick?: VoidFunction;
};

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,13 +4,23 @@
import * as React from 'react';
import { action } from '@storybook/addon-actions';
import type { Meta } from '@storybook/react';
import type { Props } from './MessageRequestActions';
import { MessageRequestActions } from './MessageRequestActions';
import { setupI18n } from '../../util/setupI18n';
import enMessages from '../../../_locales/en/messages.json';
import {
getDefaultConversation,
getDefaultGroup,
} from '../../test-both/helpers/getDefaultConversation';
const i18n = setupI18n('en', enMessages);
type Args = {
conversationType: 'direct' | 'group';
isBlocked: boolean;
isHidden: boolean;
isReported: boolean;
};
export default {
title: 'Components/Conversation/MessageRequestActions',
argTypes: {
@ -20,19 +30,9 @@ export default {
options: ['direct', 'group'],
},
},
firstName: { control: { type: 'text' } },
title: { control: { type: 'text' } },
},
args: {
conversationId: '123',
i18n,
conversationType: 'direct',
firstName: 'Cayce',
title: 'Cayce Bollard',
acceptConversation: action('acceptConversation'),
blockAndReportSpam: action('blockAndReportSpam'),
blockConversation: action('blockConversation'),
deleteConversation: action('deleteConversation'),
},
decorators: [
(Story: React.ComponentType): JSX.Element => {
@ -43,20 +43,62 @@ export default {
);
},
],
} satisfies Meta<Props>;
} satisfies Meta<Args>;
export function Direct(args: Props): JSX.Element {
return <MessageRequestActions {...args} />;
function Example(args: Args): JSX.Element {
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 {
return <MessageRequestActions {...args} isBlocked />;
export function Direct(args: Args): JSX.Element {
return <Example {...args} />;
}
export function Group(args: Props): JSX.Element {
return <MessageRequestActions {...args} conversationType="group" />;
export function DirectBlocked(args: Args): JSX.Element {
return <Example {...args} isBlocked />;
}
export function GroupBlocked(args: Props): JSX.Element {
return <MessageRequestActions {...args} conversationType="group" isBlocked />;
export function DirectReported(args: Args): JSX.Element {
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
import * as React from 'react';
import type { PropsType as ContactNameProps } from './ContactName';
import { ContactName } from './ContactName';
import { Button, ButtonVariant } from '../Button';
import type { Props as MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation';
import type { MessageRequestActionsConfirmationProps } from './MessageRequestActionsConfirmation';
import {
MessageRequestActionsConfirmation,
MessageRequestState,
} from './MessageRequestActionsConfirmation';
import { Intl } from '../Intl';
import type { LocalizerType } from '../../types/Util';
import { strictAssert } from '../../util/assert';
export type Props = {
i18n: LocalizerType;
isHidden?: boolean;
} & Omit<ContactNameProps, 'module'> &
Omit<
MessageRequestActionsConfirmationProps,
'i18n' | 'state' | 'onChangeState'
>;
isHidden: boolean | null;
} & Omit<
MessageRequestActionsConfirmationProps,
'i18n' | 'state' | 'onChangeState'
>;
export function MessageRequestActions({
addedByName,
conversationId,
conversationType,
conversationName,
i18n,
isBlocked,
isHidden,
isReported,
acceptConversation,
blockAndReportSpam,
blockConversation,
conversationId,
conversationType,
reportSpam,
deleteConversation,
firstName,
i18n,
isHidden,
isBlocked,
title,
}: Props): JSX.Element {
const [mrState, setMrState] = React.useState(MessageRequestState.default);
const name = (
<strong
key="name"
className="module-message-request-actions__message__name"
>
<ContactName firstName={firstName} title={title} preferFirstName />
</strong>
);
const nameValue =
conversationType === 'direct' ? conversationName : addedByName;
let message: JSX.Element | undefined;
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) {
message = (
<Intl
@ -87,39 +92,26 @@ export function MessageRequestActions({
<>
{mrState !== MessageRequestState.default ? (
<MessageRequestActionsConfirmation
addedByName={addedByName}
conversationId={conversationId}
conversationType={conversationType}
conversationName={conversationName}
i18n={i18n}
isBlocked={isBlocked}
isReported={isReported}
state={mrState}
acceptConversation={acceptConversation}
blockAndReportSpam={blockAndReportSpam}
blockConversation={blockConversation}
conversationId={conversationId}
conversationType={conversationType}
reportSpam={reportSpam}
deleteConversation={deleteConversation}
i18n={i18n}
onChangeState={setMrState}
state={mrState}
title={title}
/>
) : null}
<div className="module-message-request-actions">
<p className="module-message-request-actions__message">{message}</p>
<div className="module-message-request-actions__buttons">
<Button
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>
) : (
{!isBlocked && (
<Button
onClick={() => {
setMrState(MessageRequestState.blocking);
@ -129,6 +121,36 @@ export function MessageRequestActions({
{i18n('icu:MessageRequests--block')}
</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 ? (
<Button
onClick={() => acceptConversation(conversationId)}

View file

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

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,9 +4,9 @@
import { assertDev } from '../../util/assert';
import { isDirectConversation } from '../../util/whatTypeOfConversation';
import * as log from '../../logging/log';
import type { ConversationAttributesType } from '../../model-types.d';
import { isAciString } from '../../util/isAciString';
import type { reportSpamJobQueue } from '../reportSpamJobQueue';
import type { ConversationType } from '../../state/ducks/conversations';
export async function addReportSpamJob({
conversation,
@ -14,10 +14,7 @@ export async function addReportSpamJob({
jobQueue,
}: Readonly<{
conversation: Readonly<
Pick<
ConversationAttributesType,
'id' | 'type' | 'serviceId' | 'reportingToken'
>
Pick<ConversationType, 'id' | 'type' | 'serviceId' | 'reportingToken'>
>;
getMessageServerGuidsForSpam: (
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 MemberRoleEnum = Proto.Member.Role;
import type { MessageRequestResponseEvent } from './types/MessageRequestResponseEvent';
export type LastMessageStatus =
| 'paused'
@ -156,6 +157,7 @@ export type MessageAttributesType = {
logger?: unknown;
message?: unknown;
messageTimer?: unknown;
messageRequestResponseEvent?: MessageRequestResponseEvent;
profileChange?: ProfileNameChangeType;
payment?: AnyPaymentEvent;
quote?: QuotedMessageType;
@ -192,7 +194,8 @@ export type MessageAttributesType = {
| 'universal-timer-notification'
| 'contact-removed-notification'
| 'title-transition-notification'
| 'verified-change';
| 'verified-change'
| 'message-request-response-event';
body?: string;
attachments?: Array<AttachmentType>;
preview?: Array<LinkPreviewType>;
@ -359,6 +362,7 @@ export type ConversationAttributesType = {
draftEditMessage?: DraftEditMessageType;
hasPostedStory?: boolean;
isArchived?: boolean;
isReported?: boolean;
name?: string;
systemGivenName?: string;
systemFamilyName?: string;

View file

@ -164,6 +164,7 @@ import { incrementMessageCounter } from '../util/incrementMessageCounter';
import OS from '../util/os/osMain';
import { getMessageAuthorText } from '../util/getMessageAuthorText';
import { downscaleOutgoingAttachment } from '../util/attachments';
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -2115,8 +2116,38 @@ export class ConversationModel extends window.Backbone
} 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(
response: number,
response: Proto.SyncMessage.MessageRequestResponse.Type,
{ fromSync = false, viaStorageServiceSync = false, shouldSave = true } = {}
): Promise<void> {
try {
@ -2127,11 +2158,84 @@ export class ConversationModel extends window.Backbone
const didResponseChange = response !== currentMessageRequestState;
const wasPreviouslyAccepted = this.getAccepted();
if (didResponseChange) {
if (response === messageRequestEnum.ACCEPT) {
drop(
this.addMessageRequestResponseEventMessage(
MessageRequestResponseEvent.ACCEPT
)
);
}
if (
response === messageRequestEnum.BLOCK ||
response === messageRequestEnum.BLOCK_AND_SPAM ||
response === messageRequestEnum.BLOCK_AND_DELETE
) {
drop(
this.addMessageRequestResponseEventMessage(
MessageRequestResponseEvent.BLOCK
)
);
}
if (
response === messageRequestEnum.SPAM ||
response === messageRequestEnum.BLOCK_AND_SPAM
) {
drop(
this.addMessageRequestResponseEventMessage(
MessageRequestResponseEvent.SPAM
)
);
}
}
// Apply message request response locally
this.set({
messageRequestResponseType: response,
});
const rejectConversation = async ({
isBlock = false,
isDelete = false,
isSpam = false,
}: {
isBlock?: boolean;
isDelete?: boolean;
isSpam?: boolean;
}) => {
if (isBlock) {
this.block({ viaStorageServiceSync });
}
if (isBlock || isDelete) {
this.disableProfileSharing({ viaStorageServiceSync });
}
if (isDelete) {
await this.destroyMessages();
void this.updateLastMessage();
}
if (isBlock || isDelete) {
if (isLocalAction) {
window.reduxActions.conversations.onConversationClosed(
this.id,
isBlock
? 'blocked from message request'
: 'deleted from message request'
);
if (isGroupV2(this.attributes)) {
await this.leaveGroupV2();
}
}
}
if (isSpam) {
this.set({ isReported: true });
}
};
if (response === messageRequestEnum.ACCEPT) {
this.unblock({ viaStorageServiceSync });
if (!viaStorageServiceSync) {
@ -2188,53 +2292,15 @@ export class ConversationModel extends window.Backbone
}
}
} else if (response === messageRequestEnum.BLOCK) {
// Block locally, other devices should block upon receiving the sync message
this.block({ viaStorageServiceSync });
this.disableProfileSharing({ viaStorageServiceSync });
if (isLocalAction) {
if (isGroupV2(this.attributes)) {
await this.leaveGroupV2();
}
}
await rejectConversation({ isBlock: true });
} else if (response === messageRequestEnum.DELETE) {
this.disableProfileSharing({ viaStorageServiceSync });
// Delete messages locally, other devices should delete upon receiving
// the sync message
await this.destroyMessages();
void this.updateLastMessage();
if (isLocalAction) {
window.reduxActions.conversations.onConversationClosed(
this.id,
'deleted from message request'
);
if (isGroupV2(this.attributes)) {
await this.leaveGroupV2();
}
}
await rejectConversation({ isDelete: true });
} else if (response === messageRequestEnum.BLOCK_AND_DELETE) {
// Block locally, other devices should block upon receiving the sync message
this.block({ viaStorageServiceSync });
this.disableProfileSharing({ viaStorageServiceSync });
// Delete messages locally, other devices should delete upon receiving
// the sync message
await this.destroyMessages();
void this.updateLastMessage();
if (isLocalAction) {
window.reduxActions.conversations.onConversationClosed(
this.id,
'blocked and deleted from message request'
);
if (isGroupV2(this.attributes)) {
await this.leaveGroupV2();
}
}
await rejectConversation({ isBlock: true, isDelete: true });
} else if (response === messageRequestEnum.SPAM) {
await rejectConversation({ isSpam: true });
} else if (response === messageRequestEnum.BLOCK_AND_SPAM) {
await rejectConversation({ isBlock: true, isSpam: true });
}
} finally {
if (shouldSave) {
@ -2489,40 +2555,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> {
const serviceId = this.getServiceId();
if (!serviceId) {

View file

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

View file

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

View file

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

View file

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

View file

@ -101,8 +101,9 @@ export const actions = {
selectDraftEmojiToBeReplaced,
};
export const useActions = (): BoundActionCreatorsMapObject<typeof actions> =>
useBoundActions(actions);
export const usePreferredReactionsActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
function cancelCustomizePreferredReactionsModal(): CancelCustomizePreferredReactionsModalActionType {
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 { NoopActionType } from './noop';
import type { BoundActionCreatorsMapObject } from '../../hooks/useBoundActions';
import { useBoundActions } from '../../hooks/useBoundActions';
const { getRecentStickers, updateStickerLastUsed } = dataInterface;
@ -154,6 +156,10 @@ export const actions = {
useSticker,
};
export const useStickersActions = (): BoundActionCreatorsMapObject<
typeof actions
> => useBoundActions(actions);
function removeStickerPack(id: string): StickerPackRemovedAction {
return {
type: 'stickers/REMOVE_STICKER_PACK',

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,
(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 { getCallIdFromEra } from '../../util/callDisposition';
import { LONG_MESSAGE } from '../../types/MIME';
import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification';
export { isIncoming, isOutgoing, isStory };
@ -971,6 +972,14 @@ export function getPropsForBubble(
};
}
if (isMessageRequestResponse(message)) {
return {
type: 'messageRequestResponse',
data: getPropsForMessageRequestResponse(message),
timestamp,
};
}
const data = getPropsForMessage(message, options);
return {
@ -1461,6 +1470,24 @@ function getPropsForProfileChange(
} as ProfileChangeNotificationPropsType;
}
// Message Request Response Event
export function isMessageRequestResponse(
message: MessageAttributesType
): boolean {
return message.type === 'message-request-response-event';
}
function getPropsForMessageRequestResponse(
message: MessageAttributesType
): MessageRequestResponseNotificationData {
const { messageRequestResponseEvent } = message;
if (!messageRequestResponseEvent) {
throw new Error('getPropsForMessageRequestResponse: event is missing!');
}
return { messageRequestResponseEvent };
}
// Universal Timer Notification
// Note: smart, so props not generated here

View file

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

View file

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

View file

@ -1,7 +1,7 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import React from 'react';
import React, { useMemo } from 'react';
import { useSelector } from 'react-redux';
import { pick } from 'lodash';
import type { ConversationType } from '../ducks/conversations';
@ -37,6 +37,8 @@ import { useStoriesActions } from '../ducks/stories';
import { getCannotLeaveBecauseYouAreLastAdmin } from '../../components/conversation/conversation-details/ConversationDetails';
import { getGroupMemberships } from '../../util/getGroupMemberships';
import { isGroupOrAdhocCallState } from '../../util/isGroupOrAdhocCall';
import { useContactNameData } from '../../components/conversation/ContactName';
import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation';
export type OwnProps = {
id: string;
@ -108,6 +110,11 @@ export function SmartConversationHeader({ id }: OwnProps): JSX.Element {
setMuteExpiration,
setPinned,
toggleSelectMode,
acceptConversation,
blockAndReportSpam,
blockConversation,
reportSpam,
deleteConversation,
} = useConversationsActions();
const {
onOutgoingAudioCallInConversation,
@ -129,6 +136,17 @@ export function SmartConversationHeader({ id }: OwnProps): JSX.Element {
const selectedMessageIds = useSelector(getSelectedMessageIds);
const isSelectMode = selectedMessageIds != null;
const addedBy = useMemo(() => {
if (conversation.type === 'group') {
return getAddedByForOurPendingInvitation(conversation);
}
return null;
}, [conversation]);
const addedByName = useContactNameData(addedBy);
const conversationName = useContactNameData(conversation);
strictAssert(conversationName, 'conversationName is required');
return (
<ConversationHeader
{...pick(conversation, [
@ -184,6 +202,18 @@ export function SmartConversationHeader({ id }: OwnProps): JSX.Element {
isSelectMode={isSelectMode}
toggleSelectMode={toggleSelectMode}
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 { LocalizerType } from '../../types/Util';
import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions';
import { usePreferredReactionsActions } from '../ducks/preferredReactions';
import { useItemsActions } from '../ducks/items';
import { getIntl } from '../selectors/user';
import { getEmojiSkinTone } from '../selectors/items';

View file

@ -5,7 +5,7 @@ import * as React from 'react';
import { useSelector } from 'react-redux';
import type { StateType } from '../reducer';
import { useRecentEmojis } from '../selectors/emojis';
import { useActions as useEmojiActions } from '../ducks/emojis';
import { useEmojisActions as useEmojiActions } from '../ducks/emojis';
import type { Props as EmojiPickerProps } from '../../components/emoji/EmojiPicker';
import { EmojiPicker } from '../../components/emoji/EmojiPicker';

View file

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

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 { useSelector } from 'react-redux';
import type { StateType } from '../reducer';
import { useActions as usePreferredReactionsActions } from '../ducks/preferredReactions';
import { usePreferredReactionsActions } from '../ducks/preferredReactions';
import { useItemsActions } from '../ducks/items';
import { getIntl } from '../selectors/user';

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,7 +1,8 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { Locator } from 'playwright';
import { assert } from 'chai';
import type { Locator, Page } from 'playwright';
export function bufferToUuid(buffer: Buffer): string {
const hex = buffer.toString('hex');
@ -32,3 +33,44 @@ export async function type(input: Locator, text: string): Promise<void> {
// updated with the right value
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';
import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap';
import { expectSystemMessages } from '../helpers';
export const debug = createDebug('mock:test:gv2');
@ -114,11 +115,13 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
debug('Checking that notifications are present');
await window
.locator(`"${first.profileName} invited you to the group."`)
.locator(
`.SystemMessage:has-text("${first.profileName} invited you to the group.")`
)
.waitFor();
await window
.locator(
`"You accepted an invitation to the group from ${first.profileName}."`
`.SystemMessage:has-text("You accepted an invitation to the group from ${first.profileName}.")`
)
.waitFor();
@ -130,7 +133,9 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
assert(group.getPendingMemberByServiceId(desktop.pni));
await window
.locator(`"${second.profileName} invited you to the group."`)
.locator(
`.SystemMessage:has-text("${second.profileName} invited you to the group.")`
)
.waitFor();
debug('Verify that message request state is not visible');
@ -179,11 +184,11 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
debug('Declining');
await conversationStack
.locator('.module-message-request-actions button >> "Delete"')
.locator('.module-message-request-actions button >> "Block"')
.click();
debug('waiting for confirmation modal');
await window.locator('.module-Modal button >> "Delete and Leave"').click();
await window.locator('.module-Modal button >> "Block"').click();
group = await phone.waitForGroupUpdate(group);
assert.strictEqual(group.revision, 2);
@ -217,7 +222,9 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
debug('Waiting for the PNI invite');
await window
.locator(`text=${first.profileName} invited you to the group.`)
.locator(
`.SystemMessage:has-text("${first.profileName} invited you to the group.")`
)
.waitFor();
debug('Inviting ACI from another contact');
@ -229,7 +236,9 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
debug('Waiting for the ACI invite');
await window
.locator(`text=${second.profileName} invited you to the group.`)
.locator(
`.SystemMessage:has-text("${second.profileName} invited you to the group.")`
)
.waitFor();
debug('Accepting');
@ -240,8 +249,7 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
debug('Checking final notification');
await window
.locator(
'.SystemMessage >> text=You accepted an invitation to the group from ' +
`${second.profileName}.`
`.SystemMessage:has-text("You accepted an invitation to the group from ${second.profileName}.")`
)
.waitFor();
@ -291,11 +299,11 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
debug('Declining');
await conversationStack
.locator('.module-message-request-actions button >> "Delete"')
.locator('.module-message-request-actions button >> "Block"')
.click();
debug('waiting for confirmation modal');
await window.locator('.module-Modal button >> "Delete and Leave"').click();
await window.locator('.module-Modal button >> "Block"').click();
group = await phone.waitForGroupUpdate(group);
assert.strictEqual(group.revision, 3);
@ -347,13 +355,10 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
sendUpdateTo: [{ device: desktop }],
});
await window
.locator(
'.SystemMessage >> ' +
`text=${second.profileName} accepted an invitation to the group ` +
`from ${first.profileName}.`
)
.waitFor();
await expectSystemMessages(window, [
'You were added to the group.',
`${second.profileName} accepted an invitation to the group from ${first.profileName}.`,
]);
});
it('should display a e164 for a PNI invite', async () => {
@ -398,7 +403,7 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
}
const { e164 } = parsedE164;
await window
.locator(`.SystemMessage >> text=You invited ${e164} to the group`)
.locator(`.SystemMessage:has-text("You invited ${e164} to the group")`)
.waitFor();
debug('Accepting remote invite');
@ -408,11 +413,10 @@ describe('pnp/accept gv2 invite', function (this: Mocha.Suite) {
});
debug('Waiting for accept notification');
await window
.locator(
'.SystemMessage >> ' +
`text=${unknownPniContact.profileName} accepted your invitation to the group`
)
.waitFor();
await expectSystemMessages(window, [
'You were added to the group.',
/^You invited .* to the group\.$/,
`${unknownPniContact.profileName} accepted your invitation to the group.`,
]);
});
});

View file

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

View file

@ -12,6 +12,7 @@ import { MY_STORY_ID } from '../../types/Stories';
import { toUntaggedPni } from '../../types/ServiceId';
import { Bootstrap } from '../bootstrap';
import type { App } from '../bootstrap';
import { expectSystemMessages } from '../helpers';
export const debug = createDebug('mock:test:merge');
@ -143,12 +144,10 @@ describe('pnp/phone discovery', function (this: Mocha.Suite) {
const messages = window.locator('.module-message__text');
assert.strictEqual(await messages.count(), 1, 'message count');
// One notification - the PhoneNumberDiscovery
const notifications = window.locator('.SystemMessage');
assert.strictEqual(await notifications.count(), 1, 'notification count');
const first = await notifications.first();
assert.match(await first.innerText(), /.* belongs to ACI Contact/);
await expectSystemMessages(window, [
'You accepted the message request',
/.* belongs to ACI Contact/,
]);
}
});
});

View file

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

View file

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

View file

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

View file

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

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',
PinnedConversationsFull = 'PinnedConversationsFull',
ReactionFailed = 'ReactionFailed',
ReportedSpam = 'ReportedSpam',
ReportedSpamAndBlocked = 'ReportedSpamAndBlocked',
StickerPackInstallFailed = 'StickerPackInstallFailed',
StoryMuted = 'StoryMuted',
@ -120,6 +121,7 @@ export type AnyToast =
| { toastType: ToastType.OriginalMessageNotFound }
| { toastType: ToastType.PinnedConversationsFull }
| { toastType: ToastType.ReactionFailed }
| { toastType: ToastType.ReportedSpam }
| { toastType: ToastType.ReportedSpamAndBlocked }
| { toastType: ToastType.StickerPackInstallFailed }
| { toastType: ToastType.StoryMuted }

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,
isArchived: attributes.isArchived,
isBlocked: isBlocked(attributes),
reportingToken: attributes.reportingToken,
removalStage: attributes.removalStage,
isMe: isMe(attributes),
isGroupV1AndDisabled: isGroupV1(attributes),

View file

@ -45,12 +45,15 @@ import {
isTapToView,
isUnsupportedMessage,
isConversationMerge,
isMessageRequestResponse,
} from '../state/selectors/message';
import {
getContact,
messageHasPaymentEvent,
getPaymentEventNotificationText,
} from '../messages/helpers';
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
import { missingCaseError } from './missingCaseError';
function getNameForNumber(e164: string): string {
const conversation = window.ConversationController.get(e164);
@ -177,6 +180,34 @@ export function getNotificationDataForMessage(
};
}
if (isMessageRequestResponse(attributes)) {
const { messageRequestResponseEvent: event } = attributes;
strictAssert(
event,
'getNotificationData: isMessageRequestResponse true, but no messageRequestResponseEvent!'
);
let text: string;
if (event === MessageRequestResponseEvent.ACCEPT) {
text = window.i18n(
'icu:MessageRequestResponseNotification__Message--Accepted'
);
} else if (event === MessageRequestResponseEvent.SPAM) {
text = window.i18n(
'icu:MessageRequestResponseNotification__Message--Reported'
);
} else if (event === MessageRequestResponseEvent.BLOCK) {
text = window.i18n(
'icu:MessageRequestResponseNotification__Message--Blocked'
);
} else {
throw missingCaseError(event);
}
return {
text,
};
}
const { attachments = [] } = attributes;
if (isTapToView(attributes)) {

View file

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

View file

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