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