diff --git a/ts/jobs/conversationJobQueue.preload.ts b/ts/jobs/conversationJobQueue.preload.ts index 168def3eb6..12460605b0 100644 --- a/ts/jobs/conversationJobQueue.preload.ts +++ b/ts/jobs/conversationJobQueue.preload.ts @@ -57,6 +57,8 @@ import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary.std.js' import { FIBONACCI } from '../util/BackOff.std.js'; import { parseUnknown } from '../util/schemas.std.js'; import { challengeHandler } from '../services/challengeHandler.preload.js'; +import { sendPinMessage } from './helpers/sendPinMessage.preload.js'; +import { sendUnpinMessage } from './helpers/sendUnpinMessage.preload.js'; const globalLogger = createLogger('conversationJobQueue'); @@ -71,6 +73,7 @@ export const conversationQueueJobEnum = z.enum([ 'GroupUpdate', 'NormalMessage', 'NullMessage', + 'PinMessage', 'PollTerminate', 'PollVote', 'ProfileKey', @@ -81,6 +84,7 @@ export const conversationQueueJobEnum = z.enum([ 'SenderKeyDistribution', 'Story', 'Receipts', + 'UnpinMessage', ]); type ConversationQueueJobEnum = z.infer; @@ -198,6 +202,16 @@ const reactionJobDataSchema = z.object({ }); export type ReactionJobData = z.infer; +const pinMessageJobDataSchema = z.object({ + type: z.literal(conversationQueueJobEnum.enum.PinMessage), + conversationId: z.string(), + targetMessageId: z.string(), + targetAuthorAci: aciSchema, + targetSentTimestamp: z.number(), + pinDurationSeconds: z.number().nullable(), +}); +export type PinMessageJobData = z.infer; + const pollVoteJobDataSchema = z.object({ type: z.literal(conversationQueueJobEnum.enum.PollVote), conversationId: z.string(), @@ -270,6 +284,15 @@ const receiptsJobDataSchema = z.object({ }); export type ReceiptsJobData = z.infer; +const unpinMessageJobDataSchema = z.object({ + type: z.literal(conversationQueueJobEnum.enum.UnpinMessage), + conversationId: z.string(), + targetMessageId: z.string(), + targetAuthorAci: aciSchema, + targetSentTimestamp: z.number(), +}); +export type UnpinMessageJobData = z.infer; + export const conversationQueueJobDataSchema = z.union([ callingMessageJobDataSchema, deleteForEveryoneJobDataSchema, @@ -279,6 +302,7 @@ export const conversationQueueJobDataSchema = z.union([ groupUpdateJobDataSchema, normalMessageSendJobDataSchema, nullMessageJobDataSchema, + pinMessageJobDataSchema, pollTerminateJobDataSchema, pollVoteJobDataSchema, profileKeyJobDataSchema, @@ -288,6 +312,7 @@ export const conversationQueueJobDataSchema = z.union([ senderKeyDistributionJobDataSchema, storyJobDataSchema, receiptsJobDataSchema, + unpinMessageJobDataSchema, ]); export type ConversationQueueJobData = z.infer< typeof conversationQueueJobDataSchema @@ -330,6 +355,9 @@ function shouldSendShowCaptcha(type: ConversationQueueJobEnum): boolean { if (type === 'NullMessage') { return false; } + if (type === 'PinMessage') { + return true; + } if (type === 'ProfileKey') { return false; } @@ -362,6 +390,9 @@ function shouldSendShowCaptcha(type: ConversationQueueJobEnum): boolean { if (type === 'Story') { return true; } + if (type === 'UnpinMessage') { + return true; + } throw missingCaseError(type); } @@ -989,6 +1020,9 @@ export class ConversationJobQueue extends JobQueue { case jobSet.Reaction: await sendReaction(conversation, jobBundle, data); break; + case jobSet.PinMessage: + await sendPinMessage(conversation, jobBundle, data); + break; case jobSet.PollTerminate: await sendPollTerminate(conversation, jobBundle, data); break; @@ -1010,6 +1044,9 @@ export class ConversationJobQueue extends JobQueue { case jobSet.Receipts: await sendReceipts(conversation, jobBundle, data); break; + case jobSet.UnpinMessage: + await sendUnpinMessage(conversation, jobBundle, data); + break; default: { // Note: This should never happen, because the zod call in parseData wouldn't // accept data that doesn't look like our type specification. diff --git a/ts/jobs/helpers/createSendMessageJob.preload.ts b/ts/jobs/helpers/createSendMessageJob.preload.ts new file mode 100644 index 0000000000..d04a957327 --- /dev/null +++ b/ts/jobs/helpers/createSendMessageJob.preload.ts @@ -0,0 +1,195 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { ContentHint } from '@signalapp/libsignal-client'; +import type { ConversationModel } from '../../models/conversations.preload.js'; +import { getSendOptions } from '../../util/getSendOptions.preload.js'; +import { sendToGroup } from '../../util/sendToGroup.preload.js'; +import { + isDirectConversation, + isGroupV2, +} from '../../util/whatTypeOfConversation.dom.js'; +import type { ConversationQueueJobBundle } from '../conversationJobQueue.preload.js'; +import { getSendRecipientLists } from './getSendRecipientLists.dom.js'; +import type { SendTypesType } from '../../util/handleMessageSend.preload.js'; +import { handleMessageSend } from '../../util/handleMessageSend.preload.js'; +import type { SharedMessageOptionsType } from '../../textsecure/SendMessage.preload.js'; +import { strictAssert } from '../../util/assert.std.js'; +import { wrapWithSyncMessageSend } from '../../util/wrapWithSyncMessageSend.preload.js'; +import { + handleMultipleSendErrors, + maybeExpandErrors, +} from './handleMultipleSendErrors.std.js'; + +export type SendMessageJobOptions = Readonly<{ + sendName: string; // ex: 'sendExampleMessage' + sendType: SendTypesType; + getMessageId: (data: Data) => string | null; + getMessageOptions: ( + data: Data, + jobTimestamp: number + ) => Omit; +}>; + +export function createSendMessageJob( + options: SendMessageJobOptions +) { + return async function sendMessage( + conversation: ConversationModel, + job: ConversationQueueJobBundle, + data: Data + ): Promise { + const { sendName, sendType, getMessageId, getMessageOptions } = options; + + const logId = `${sendName}(${conversation.idForLogging()}/${job.timestamp})`; + const log = job.log.child(logId); + + if (!job.shouldContinue) { + log.info('Ran out of time, cancelling send'); + return; + } + + const { + allRecipientServiceIds, + recipientServiceIdsWithoutMe, + untrustedServiceIds, + } = getSendRecipientLists({ + log, + conversation, + conversationIds: conversation.getRecipients(), + }); + + if (untrustedServiceIds.length > 0) { + window.reduxActions.conversations.conversationStoppedByMissingVerification( + { + conversationId: conversation.id, + untrustedServiceIds, + } + ); + throw new Error( + `${sendType} blocked because ${untrustedServiceIds.length} ` + + 'conversation(s) were untrusted. Failing this attempt.' + ); + } + + const messageId = getMessageId(data); + const messageOptions = getMessageOptions(data, job.timestamp); + + try { + if (recipientServiceIdsWithoutMe.length === 0) { + const sendOptions = await getSendOptions(conversation.attributes, { + syncMessage: true, + }); + // Only sending a sync to ourselves + await conversation.queueJob( + `conversationQueue/${sendName}/sync`, + async () => { + const encodedDataMessage = await job.messaging.getDataOrEditMessage( + { + ...messageOptions, + recipients: allRecipientServiceIds, + } + ); + + return handleMessageSend( + job.messaging.sendSyncMessage({ + encodedDataMessage, + timestamp: job.timestamp, + destinationE164: conversation.get('e164'), + destinationServiceId: conversation.getServiceId(), + expirationStartTimestamp: null, + isUpdate: false, + options: sendOptions, + urgent: false, + }), + { + messageIds: messageId != null ? [messageId] : [], + sendType, + } + ); + } + ); + } else if (isDirectConversation(conversation.attributes)) { + const recipientServiceId = recipientServiceIdsWithoutMe.at(0); + + if (recipientServiceId == null) { + log.info('Recipient was dropped'); + return; + } + + const sendOptions = await getSendOptions(conversation.attributes); + + await conversation.queueJob( + `conversationQueue/${sendName}/direct`, + () => { + return wrapWithSyncMessageSend({ + conversation, + logId, + messageIds: messageId != null ? [messageId] : [], + send: sender => { + return sender.sendMessageToServiceId({ + serviceId: recipientServiceId, + messageOptions: getMessageOptions(data, job.timestamp), + groupId: undefined, + contentHint: ContentHint.Resendable, + options: sendOptions, + urgent: true, + includePniSignatureMessage: true, + }); + }, + sendType, + timestamp: job.timestamp, + }); + } + ); + } else if (isGroupV2(conversation.attributes)) { + const sendOptions = await getSendOptions(conversation.attributes, { + groupId: conversation.get('groupId'), + }); + const groupV2Info = conversation.getGroupV2Info({ + members: recipientServiceIdsWithoutMe, + }); + strictAssert(groupV2Info, 'Missing groupV2Info'); + + await conversation.queueJob( + `conversationQueue/${sendName}/group`, + abortSignal => { + return wrapWithSyncMessageSend({ + conversation, + logId, + messageIds: messageId != null ? [messageId] : [], + send: () => { + return sendToGroup({ + abortSignal, + contentHint: ContentHint.Resendable, + groupSendOptions: { + groupV2: groupV2Info, + ...getMessageOptions(data, job.timestamp), + }, + messageId: messageId ?? undefined, + sendOptions, + sendTarget: conversation.toSenderKeyTarget(), + sendType, + urgent: true, + }); + }, + sendType, + timestamp: job.timestamp, + }); + } + ); + } else { + throw new Error('Unexpected conversation type'); + } + } catch (error) { + const errors = maybeExpandErrors(error); + await handleMultipleSendErrors({ + errors, + isFinalAttempt: job.isFinalAttempt, + log, + timeRemaining: job.timeRemaining, + toThrow: error, + }); + } + }; +} diff --git a/ts/jobs/helpers/getSendRecipientLists.dom.ts b/ts/jobs/helpers/getSendRecipientLists.dom.ts new file mode 100644 index 0000000000..41fb9e5014 --- /dev/null +++ b/ts/jobs/helpers/getSendRecipientLists.dom.ts @@ -0,0 +1,98 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { ServiceIdString } from '@signalapp/mock-server/src/types'; +import type { ConversationModel } from '../../models/conversations.preload.js'; +import type { LoggerType } from '../../types/Logging.std.js'; +import { isMe } from '../../util/whatTypeOfConversation.dom.js'; +import { isSignalConversation } from '../../util/isSignalConversation.dom.js'; + +export type SendRecipientLists = Readonly<{ + allRecipientServiceIds: Array; + recipientServiceIdsWithoutMe: Array; + untrustedServiceIds: Array; +}>; + +export type GetSendRecipientListsOptions = Readonly<{ + log: LoggerType; + conversationIds: ReadonlyArray; + conversation: ConversationModel; +}>; + +export function getSendRecipientLists( + options: GetSendRecipientListsOptions +): SendRecipientLists { + const { log, conversationIds, conversation } = options; + + const allRecipientServiceIds: Array = []; + const recipientServiceIdsWithoutMe: Array = []; + const untrustedServiceIds: Array = []; + + const memberConversationIds = conversation.getMemberConversationIds(); + + for (const conversationId of conversationIds) { + const recipient = window.ConversationController.get(conversationId); + if (!recipient) { + log.warn( + `getRecipients/${conversationId}: Missing conversation, dropping recipient` + ); + continue; + } + + const logPrefix = `getRecipients/${recipient.idForLogging()}`; + + const sendTarget = recipient.getSendTarget(); + if (sendTarget == null) { + log.warn(`${logPrefix}: Missing send target, dropping recipient`); + continue; + } + + const isRecipientMe = isMe(recipient.attributes); + const isRecipientMember = memberConversationIds.has(conversationId); + + if (!(isRecipientMember || isRecipientMe)) { + log.warn( + `${logPrefix}: Recipient is not a member of conversation, dropping` + ); + continue; + } + + if (recipient.isUntrusted()) { + const serviceId = recipient.getServiceId(); + if (!serviceId) { + log.error( + `${logPrefix}: Recipient is untrusted and missing serviceId, dropping` + ); + continue; + } + untrustedServiceIds.push(serviceId); + continue; + } + + if (recipient.isUnregistered()) { + log.warn(`${logPrefix}: Recipient is unregistered, dropping`); + continue; + } + + if (recipient.isBlocked()) { + log.warn(`${logPrefix}: Recipient is blocked, dropping`); + continue; + } + + if (isSignalConversation(recipient.attributes)) { + log.info(`${logPrefix}: Recipient is Signal conversation, dropping`); + continue; + } + + allRecipientServiceIds.push(sendTarget); + if (!isRecipientMe) { + recipientServiceIdsWithoutMe.push(sendTarget); + } + } + + return { + allRecipientServiceIds, + recipientServiceIdsWithoutMe, + untrustedServiceIds, + }; +} diff --git a/ts/jobs/helpers/sendDeleteForEveryone.preload.ts b/ts/jobs/helpers/sendDeleteForEveryone.preload.ts index 0286f48169..f96c1e9338 100644 --- a/ts/jobs/helpers/sendDeleteForEveryone.preload.ts +++ b/ts/jobs/helpers/sendDeleteForEveryone.preload.ts @@ -199,15 +199,13 @@ export async function sendDeleteForEveryone( sender.sendMessageToServiceId({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion serviceId: conversation.getSendTarget()!, - messageText: undefined, - attachments: [], - deletedForEveryoneTimestamp: targetTimestamp, - timestamp, - expireTimer: undefined, - expireTimerVersion: undefined, + messageOptions: { + deletedForEveryoneTimestamp: targetTimestamp, + timestamp, + profileKey, + }, contentHint, groupId: undefined, - profileKey, options: sendOptions, urgent: true, story, @@ -226,7 +224,8 @@ export async function sendDeleteForEveryone( const groupV2Info = conversation.getGroupV2Info({ members: recipients, }); - if (groupV2Info && isNumber(revision)) { + strictAssert(groupV2Info, 'Missing groupV2Info'); + if (isNumber(revision)) { groupV2Info.revision = revision; } diff --git a/ts/jobs/helpers/sendDeleteStoryForEveryone.preload.ts b/ts/jobs/helpers/sendDeleteStoryForEveryone.preload.ts index 7f44403848..02998a630f 100644 --- a/ts/jobs/helpers/sendDeleteStoryForEveryone.preload.ts +++ b/ts/jobs/helpers/sendDeleteStoryForEveryone.preload.ts @@ -180,17 +180,15 @@ export async function sendDeleteStoryForEveryone( await handleMessageSend( messaging.sendMessageToServiceId({ serviceId, - messageText: undefined, - attachments: [], - deletedForEveryoneTimestamp: targetTimestamp, - timestamp, - expireTimer: undefined, - expireTimerVersion: undefined, - contentHint, + messageOptions: { + deletedForEveryoneTimestamp: targetTimestamp, + timestamp, + profileKey: conversation.get('profileSharing') + ? profileKey + : undefined, + }, groupId: undefined, - profileKey: conversation.get('profileSharing') - ? profileKey - : undefined, + contentHint, options: sendOptions, urgent: true, story: true, diff --git a/ts/jobs/helpers/sendNormalMessage.preload.ts b/ts/jobs/helpers/sendNormalMessage.preload.ts index 872457ff42..e901c6fc4b 100644 --- a/ts/jobs/helpers/sendNormalMessage.preload.ts +++ b/ts/jobs/helpers/sendNormalMessage.preload.ts @@ -340,7 +340,8 @@ export async function sendNormalMessage( const groupV2Info = conversation.getGroupV2Info({ members: recipientServiceIdsWithoutMe, }); - if (groupV2Info && isNumber(revision)) { + strictAssert(groupV2Info, 'Missing groupV2Info'); + if (isNumber(revision)) { groupV2Info.revision = revision; } @@ -358,7 +359,7 @@ export async function sendNormalMessage( deletedForEveryoneTimestamp, expireTimer, groupV2: groupV2Info, - messageText: body, + body, preview, profileKey, quote, @@ -416,27 +417,29 @@ export async function sendNormalMessage( log.info('sending direct message'); innerPromise = messaging.sendMessageToServiceId({ - attachments, - bodyRanges, - contact, - contentHint: ContentHint.Resendable, - deletedForEveryoneTimestamp, - expireTimer, - expireTimerVersion: conversation.getExpireTimerVersion(), - groupId: undefined, serviceId: recipientServiceIdsWithoutMe[0], - messageText: body, + messageOptions: { + attachments, + body, + bodyRanges, + contact, + deletedForEveryoneTimestamp, + expireTimer, + expireTimerVersion: conversation.getExpireTimerVersion(), + preview, + profileKey, + quote, + sticker, + storyContext, + reaction, + targetTimestampForEdit: editedMessageTimestamp + ? targetOfThisEditTimestamp + : undefined, + timestamp: targetTimestamp, + }, + contentHint: ContentHint.Resendable, + groupId: undefined, options: sendOptions, - preview, - profileKey, - quote, - sticker, - storyContext, - reaction, - targetTimestampForEdit: editedMessageTimestamp - ? targetOfThisEditTimestamp - : undefined, - timestamp: targetTimestamp, // Note: 1:1 story replies should not set story=true - they aren't group sends urgent: true, includePniSignatureMessage: true, diff --git a/ts/jobs/helpers/sendNullMessage.preload.ts b/ts/jobs/helpers/sendNullMessage.preload.ts index 3ffb388327..43360a70c6 100644 --- a/ts/jobs/helpers/sendNullMessage.preload.ts +++ b/ts/jobs/helpers/sendNullMessage.preload.ts @@ -25,6 +25,7 @@ import { import { MessageSender } from '../../textsecure/SendMessage.preload.js'; import { sendToGroup } from '../../util/sendToGroup.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import { strictAssert } from '../../util/assert.std.js'; async function clearResetsTracking(idForTracking: string | undefined) { if (!idForTracking) { @@ -101,9 +102,8 @@ export async function sendNullMessage( ); } else { const groupV2Info = conversation.getGroupV2Info(); - if (groupV2Info) { - groupV2Info.revision = 0; - } + strictAssert(groupV2Info, 'Missing groupV2Info'); + groupV2Info.revision = 0; await conversation.queueJob( 'conversationQueue/sendNullMessage/group', @@ -118,7 +118,7 @@ export async function sendNullMessage( deletedForEveryoneTimestamp: undefined, expireTimer: undefined, groupV2: groupV2Info, - messageText: undefined, + body: undefined, preview: [], profileKey: undefined, quote: undefined, diff --git a/ts/jobs/helpers/sendPinMessage.preload.ts b/ts/jobs/helpers/sendPinMessage.preload.ts new file mode 100644 index 0000000000..e2f2e48222 --- /dev/null +++ b/ts/jobs/helpers/sendPinMessage.preload.ts @@ -0,0 +1,22 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only +import type { PinMessageJobData } from '../conversationJobQueue.preload.js'; +import { createSendMessageJob } from './createSendMessageJob.preload.js'; + +export const sendPinMessage = createSendMessageJob({ + sendName: 'sendPinMessage', + sendType: 'pinMessage', + getMessageId(data) { + return data.targetMessageId; + }, + getMessageOptions(data, jobTimestamp) { + return { + timestamp: jobTimestamp, + pinMessage: { + targetAuthorAci: data.targetAuthorAci, + targetSentTimestamp: data.targetSentTimestamp, + pinDurationSeconds: data.pinDurationSeconds, + }, + }; + }, +}); diff --git a/ts/jobs/helpers/sendPollVote.preload.ts b/ts/jobs/helpers/sendPollVote.preload.ts index 16ab7175ac..2b7dc821bd 100644 --- a/ts/jobs/helpers/sendPollVote.preload.ts +++ b/ts/jobs/helpers/sendPollVote.preload.ts @@ -3,7 +3,7 @@ import { ContentHint } from '@signalapp/libsignal-client'; import * as Errors from '../../types/errors.std.js'; -import { isGroupV2, isMe } from '../../util/whatTypeOfConversation.dom.js'; +import { isGroupV2 } from '../../util/whatTypeOfConversation.dom.js'; import { getSendOptions } from '../../util/getSendOptions.preload.js'; import { handleMessageSend } from '../../util/handleMessageSend.preload.js'; import { sendContentMessageToGroup } from '../../util/sendToGroup.preload.js'; @@ -19,8 +19,6 @@ import { SendStatus, type SendStateByConversationId, } from '../../messages/MessageSendState.std.js'; -import type { ServiceIdString } from '../../types/ServiceId.std.js'; -import type { LoggerType } from '../../types/Logging.std.js'; import type { MessagePollVoteType } from '../../types/Polls.dom.js'; import type { ConversationModel } from '../../models/conversations.preload.js'; import type { @@ -29,6 +27,7 @@ import type { } from '../conversationJobQueue.preload.js'; import * as pollVoteUtil from '../../polls/util.std.js'; import { strictAssert } from '../../util/assert.std.js'; +import { getSendRecipientLists } from './getSendRecipientLists.dom.js'; export async function sendPollVote( conversation: ConversationModel, @@ -122,11 +121,15 @@ export async function sendPollVote( const currentOptionIndexes = [...currentPendingVote.optionIndexes]; const currentTimestamp = currentPendingVote.timestamp; - const { recipientServiceIdsWithoutMe, untrustedServiceIds } = getRecipients( - jobLog, - currentPendingVote, - conversation + const unsentConversationIds = Array.from( + pollVoteUtil.getUnsentConversationIds(currentPendingVote) ); + const { recipientServiceIdsWithoutMe, untrustedServiceIds } = + getSendRecipientLists({ + log: jobLog, + conversationIds: unsentConversationIds, + conversation, + }); if (untrustedServiceIds.length) { window.reduxActions.conversations.conversationStoppedByMissingVerification( @@ -145,9 +148,6 @@ export async function sendPollVote( ? await ourProfileKeyService.get() : undefined; - const unsentConversationIds = Array.from( - pollVoteUtil.getUnsentConversationIds(currentPendingVote) - ); const ephemeral = new MessageModel({ ...generateMessageId(incrementMessageCounter()), type: 'outgoing', @@ -213,15 +213,15 @@ export async function sendPollVote( const groupV2Info = conversation.getGroupV2Info({ members: recipientServiceIdsWithoutMe, }); - if (groupV2Info && revision != null) { - groupV2Info.revision = revision; - } - strictAssert( groupV2Info, 'could not get group info from conversation' ); + if (revision != null) { + groupV2Info.revision = revision; + } + const contentMessage = await messaging.getPollVoteContentMessage({ groupV2: groupV2Info, timestamp: currentTimestamp, @@ -338,70 +338,3 @@ export async function sendPollVote( await window.MessageCache.saveMessage(pollMessage.attributes); } } - -function getRecipients( - log: LoggerType, - pendingVote: MessagePollVoteType, - conversation: ConversationModel -): { - allRecipientServiceIds: Array; - recipientServiceIdsWithoutMe: Array; - untrustedServiceIds: Array; -} { - const allRecipientServiceIds: Array = []; - const recipientServiceIdsWithoutMe: Array = []; - const untrustedServiceIds: Array = []; - - const currentConversationRecipients = conversation.getMemberConversationIds(); - - // Only send to recipients who haven't received this vote yet - for (const conversationId of pollVoteUtil.getUnsentConversationIds( - pendingVote - )) { - const recipient = window.ConversationController.get(conversationId); - if (!recipient) { - continue; - } - - const recipientIdentifier = recipient.getSendTarget(); - const isRecipientMe = isMe(recipient.attributes); - - if ( - !recipientIdentifier || - (!currentConversationRecipients.has(conversationId) && !isRecipientMe) - ) { - continue; - } - - if (recipient.isUntrusted()) { - const serviceId = recipient.getServiceId(); - if (!serviceId) { - log.error( - `sendPollVote/getRecipients: Recipient ${recipient.idForLogging()} is untrusted but has no serviceId` - ); - continue; - } - untrustedServiceIds.push(serviceId); - continue; - } - - if (recipient.isUnregistered()) { - continue; - } - - if (recipient.isBlocked()) { - continue; - } - - allRecipientServiceIds.push(recipientIdentifier); - if (!isRecipientMe) { - recipientServiceIdsWithoutMe.push(recipientIdentifier); - } - } - - return { - allRecipientServiceIds, - recipientServiceIdsWithoutMe, - untrustedServiceIds, - }; -} diff --git a/ts/jobs/helpers/sendProfileKey.preload.ts b/ts/jobs/helpers/sendProfileKey.preload.ts index 3f75065b11..64b2801408 100644 --- a/ts/jobs/helpers/sendProfileKey.preload.ts +++ b/ts/jobs/helpers/sendProfileKey.preload.ts @@ -35,6 +35,7 @@ import { import { shouldSendToConversation } from './shouldSendToConversation.preload.js'; import { sendToGroup } from '../../util/sendToGroup.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import { strictAssert } from '../../util/assert.std.js'; const { isNumber } = lodash; @@ -148,7 +149,8 @@ export async function sendProfileKey( } const groupV2Info = conversation.getGroupV2Info(); - if (groupV2Info && isNumber(revision)) { + strictAssert(groupV2Info, 'Missing groupV2Info'); + if (isNumber(revision)) { groupV2Info.revision = revision; } diff --git a/ts/jobs/helpers/sendReaction.preload.ts b/ts/jobs/helpers/sendReaction.preload.ts index bb8822b7f5..fa70237185 100644 --- a/ts/jobs/helpers/sendReaction.preload.ts +++ b/ts/jobs/helpers/sendReaction.preload.ts @@ -17,7 +17,6 @@ import { isSent, SendStatus } from '../../messages/MessageSendState.std.js'; import { getMessageById } from '../../messages/getMessageById.preload.js'; import { isIncoming } from '../../messages/helpers.std.js'; import { - isMe, isDirectConversation, isGroupV2, } from '../../util/whatTypeOfConversation.dom.js'; @@ -26,7 +25,7 @@ import { handleMessageSend } from '../../util/handleMessageSend.preload.js'; import { ourProfileKeyService } from '../../services/ourProfileKey.std.js'; import { canReact, isStory } from '../../state/selectors/message.preload.js'; import { findAndFormatContact } from '../../util/findAndFormatContact.preload.js'; -import type { AciString, ServiceIdString } from '../../types/ServiceId.std.js'; +import type { AciString } from '../../types/ServiceId.std.js'; import { isAciString } from '../../util/isAciString.std.js'; import { handleMultipleSendErrors } from './handleMultipleSendErrors.std.js'; import { incrementMessageCounter } from '../../util/incrementMessageCounter.preload.js'; @@ -38,11 +37,11 @@ import type { } from '../conversationJobQueue.preload.js'; import { isConversationAccepted } from '../../util/isConversationAccepted.preload.js'; import { isConversationUnregistered } from '../../util/isConversationUnregistered.dom.js'; -import type { LoggerType } from '../../types/Logging.std.js'; import { sendToGroup } from '../../util/sendToGroup.preload.js'; import { hydrateStoryContext } from '../../util/hydrateStoryContext.preload.js'; import { send, sendSyncMessageOnly } from '../../messages/send.preload.js'; import { itemStorage } from '../../textsecure/Storage.preload.js'; +import { getSendRecipientLists } from './getSendRecipientLists.dom.js'; const { isNumber } = lodash; @@ -123,11 +122,18 @@ export async function sendReaction( } const expireTimer = messageConversation.get('expireTimer'); + const unsentConversationIds = Array.from( + reactionUtil.getUnsentConversationIds(pendingReaction) + ); const { allRecipientServiceIds, recipientServiceIdsWithoutMe, untrustedServiceIds, - } = getRecipients(log, pendingReaction, conversation); + } = getSendRecipientLists({ + log, + conversationIds: unsentConversationIds, + conversation, + }); if (untrustedServiceIds.length) { window.reduxActions.conversations.conversationStoppedByMissingVerification( @@ -242,19 +248,15 @@ export async function sendReaction( log.info('sending direct reaction message'); promise = messaging.sendMessageToServiceId({ serviceId: recipientServiceIdsWithoutMe[0], - messageText: undefined, - attachments: [], - quote: undefined, - preview: [], - sticker: undefined, - reaction: reactionForSend, - deletedForEveryoneTimestamp: undefined, - timestamp: pendingReaction.timestamp, - expireTimer, - expireTimerVersion: conversation.getExpireTimerVersion(), - contentHint: ContentHint.Resendable, + messageOptions: { + reaction: reactionForSend, + timestamp: pendingReaction.timestamp, + expireTimer, + expireTimerVersion: conversation.getExpireTimerVersion(), + profileKey, + }, groupId: undefined, - profileKey, + contentHint: ContentHint.Resendable, options: sendOptions, urgent: true, includePniSignatureMessage: true, @@ -272,7 +274,8 @@ export async function sendReaction( const groupV2Info = conversation.getGroupV2Info({ members: recipientServiceIdsWithoutMe, }); - if (groupV2Info && isNumber(revision)) { + strictAssert(groupV2Info, 'Missing groupV2Info'); + if (isNumber(revision)) { groupV2Info.revision = revision; } @@ -393,68 +396,6 @@ const setReactions = ( } }; -function getRecipients( - log: LoggerType, - reaction: Readonly, - conversation: ConversationModel -): { - allRecipientServiceIds: Array; - recipientServiceIdsWithoutMe: Array; - untrustedServiceIds: Array; -} { - const allRecipientServiceIds: Array = []; - const recipientServiceIdsWithoutMe: Array = []; - const untrustedServiceIds: Array = []; - - const currentConversationRecipients = conversation.getMemberConversationIds(); - - for (const id of reactionUtil.getUnsentConversationIds(reaction)) { - const recipient = window.ConversationController.get(id); - if (!recipient) { - continue; - } - - const recipientIdentifier = recipient.getSendTarget(); - const isRecipientMe = isMe(recipient.attributes); - - if ( - !recipientIdentifier || - (!currentConversationRecipients.has(id) && !isRecipientMe) - ) { - continue; - } - - if (recipient.isUntrusted()) { - const serviceId = recipient.getServiceId(); - if (!serviceId) { - log.error( - `getRecipients: Untrusted conversation ${recipient.idForLogging()} missing serviceId.` - ); - continue; - } - untrustedServiceIds.push(serviceId); - continue; - } - if (recipient.isUnregistered()) { - continue; - } - if (recipient.isBlocked()) { - continue; - } - - allRecipientServiceIds.push(recipientIdentifier); - if (!isRecipientMe) { - recipientServiceIdsWithoutMe.push(recipientIdentifier); - } - } - - return { - allRecipientServiceIds, - recipientServiceIdsWithoutMe, - untrustedServiceIds, - }; -} - function markReactionFailed( message: MessageModel, pendingReaction: MessageReactionType diff --git a/ts/jobs/helpers/sendUnpinMessage.preload.ts b/ts/jobs/helpers/sendUnpinMessage.preload.ts new file mode 100644 index 0000000000..5def2ff373 --- /dev/null +++ b/ts/jobs/helpers/sendUnpinMessage.preload.ts @@ -0,0 +1,22 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import type { UnpinMessageJobData } from '../conversationJobQueue.preload.js'; +import { createSendMessageJob } from './createSendMessageJob.preload.js'; + +export const sendUnpinMessage = createSendMessageJob({ + sendName: 'sendUnpinMessage', + sendType: 'unpinMessage', + getMessageId(data) { + return data.targetMessageId; + }, + getMessageOptions(data, jobTimestamp) { + return { + timestamp: jobTimestamp, + unpinMessage: { + targetAuthorAci: data.targetAuthorAci, + targetSentTimestamp: data.targetSentTimestamp, + }, + }; + }, +}); diff --git a/ts/textsecure/SendMessage.preload.ts b/ts/textsecure/SendMessage.preload.ts index 9eabd2f9a9..7c5b60bbd8 100644 --- a/ts/textsecure/SendMessage.preload.ts +++ b/ts/textsecure/SendMessage.preload.ts @@ -104,6 +104,10 @@ import type { GroupSendToken } from '../types/GroupSendEndorsements.std.js'; import type { OutgoingPollVote, PollCreateType } from '../types/Polls.dom.js'; import { itemStorage } from './Storage.preload.js'; import { accountManager } from './AccountManager.preload.js'; +import type { + SendPinMessageType, + SendUnpinMessageType, +} from '../types/PinnedMessage.std.js'; const log = createLogger('SendMessage'); @@ -195,61 +199,48 @@ export const singleProtoJobDataSchema = z.object({ export type SingleProtoJobData = z.infer; -export type MessageOptionsType = { +export type SharedMessageOptionsType = Readonly<{ + // required + timestamp: number; + // optional attachments?: ReadonlyArray; body?: string; bodyRanges?: ReadonlyArray; contact?: ReadonlyArray; + deletedForEveryoneTimestamp?: number; expireTimer?: DurationInSeconds; - expireTimerVersion: number | undefined; flags?: number; - group?: { - id: string; - type: number; - }; + groupCallUpdate?: GroupCallUpdateType; groupV2?: GroupV2InfoType; - needsSync?: boolean; - preview?: ReadonlyArray; - profileKey?: Uint8Array; - quote?: OutgoingQuoteType; - recipients: ReadonlyArray; - sticker?: OutgoingStickerType; - reaction?: ReactionType; + pinMessage?: SendPinMessageType; pollVote?: OutgoingPollVote; pollCreate?: PollCreateType; pollTerminate?: Readonly<{ targetTimestamp: number; }>; - deletedForEveryoneTimestamp?: number; - targetTimestampForEdit?: number; - timestamp: number; - groupCallUpdate?: GroupCallUpdateType; - storyContext?: StoryContextType; -}; -export type GroupSendOptionsType = { - attachments?: ReadonlyArray; - bodyRanges?: ReadonlyArray; - contact?: ReadonlyArray; - deletedForEveryoneTimestamp?: number; - targetTimestampForEdit?: number; - expireTimer?: DurationInSeconds; - flags?: number; - groupCallUpdate?: GroupCallUpdateType; - groupV2?: GroupV2InfoType; - messageText?: string; preview?: ReadonlyArray; profileKey?: Uint8Array; quote?: OutgoingQuoteType; reaction?: ReactionType; sticker?: OutgoingStickerType; storyContext?: StoryContextType; - timestamp: number; - pollVote?: OutgoingPollVote; - pollCreate?: PollCreateType; - pollTerminate?: Readonly<{ - targetTimestamp: number; - }>; -}; + targetTimestampForEdit?: number; + unpinMessage?: SendUnpinMessageType; +}>; + +export type MessageOptionsType = Readonly< + SharedMessageOptionsType & { + // Not needed for group messages, lives in group state + expireTimerVersion?: number | undefined; + recipients: ReadonlyArray; + } +>; + +export type GroupMessageOptionsType = Readonly< + SharedMessageOptionsType & { + groupV2: GroupV2InfoType; + } +>; export type PollVoteBuildOptions = Required< Pick @@ -276,15 +267,8 @@ class Message { flags?: number; - group?: { - id: string; - type: number; - }; - groupV2?: GroupV2InfoType; - needsSync?: boolean; - preview?: ReadonlyArray; profileKey?: Uint8Array; @@ -303,6 +287,9 @@ class Message { targetTimestamp: number; }>; + pinMessage?: SendPinMessageType; + unpinMessage?: SendUnpinMessageType; + timestamp: number; dataMessage?: Proto.DataMessage; @@ -323,9 +310,7 @@ class Message { this.expireTimer = options.expireTimer; this.expireTimerVersion = options.expireTimerVersion; this.flags = options.flags; - this.group = options.group; this.groupV2 = options.groupV2; - this.needsSync = options.needsSync; this.preview = options.preview; this.profileKey = options.profileKey; this.quote = options.quote; @@ -340,12 +325,14 @@ class Message { this.storyContext = options.storyContext; // Polls this.pollVote = options.pollVote; + this.pinMessage = options.pinMessage; + this.unpinMessage = options.unpinMessage; if (!(this.recipients instanceof Array)) { throw new Error('Invalid recipient list'); } - if (!this.group && !this.groupV2 && this.recipients.length !== 1) { + if (!this.groupV2 && this.recipients.length !== 1) { throw new Error('Invalid recipient list for non-group'); } @@ -370,28 +357,14 @@ class Message { } } if (this.isEndSession()) { - if ( - this.body != null || - this.group != null || - this.attachments.length !== 0 - ) { + if (this.body != null || this.attachments.length !== 0) { throw new Error('Invalid end session message'); } - } else { - if ( - typeof this.timestamp !== 'number' || - (this.body && typeof this.body !== 'string') - ) { - throw new Error('Invalid message body'); - } - if (this.group) { - if ( - typeof this.group.id !== 'string' || - typeof this.group.type !== 'number' - ) { - throw new Error('Invalid group context'); - } - } + } else if ( + typeof this.timestamp !== 'number' || + (this.body && typeof this.body !== 'string') + ) { + throw new Error('Invalid message body'); } } @@ -682,6 +655,35 @@ class Message { proto.requiredProtocolVersion = Proto.DataMessage.ProtocolVersion.POLLS; } + if (this.pinMessage != null) { + const { targetAuthorAci, targetSentTimestamp, pinDurationSeconds } = + this.pinMessage; + + const pinMessage = new Proto.DataMessage.PinMessage({ + targetAuthorAciBinary: toAciObject(targetAuthorAci).getRawUuidBytes(), + targetSentTimestamp: Long.fromNumber(targetSentTimestamp), + }); + + if (pinDurationSeconds != null) { + pinMessage.pinDurationSeconds = pinDurationSeconds; + } else { + pinMessage.pinDurationForever = true; + } + + proto.pinMessage = pinMessage; + } + + if (this.unpinMessage != null) { + const { targetAuthorAci, targetSentTimestamp } = this.unpinMessage; + + const unpinMessage = new Proto.DataMessage.UnpinMessage({ + targetAuthorAciBinary: toAciObject(targetAuthorAci).getRawUuidBytes(), + targetSentTimestamp: Long.fromNumber(targetSentTimestamp), + }); + + proto.unpinMessage = unpinMessage; + } + this.dataMessage = proto; return proto; } @@ -1107,7 +1109,7 @@ export class MessageSender { } getAttrsFromGroupOptions( - options: Readonly + options: Readonly ): MessageOptionsType { const { attachments, @@ -1118,7 +1120,7 @@ export class MessageSender { flags, groupCallUpdate, groupV2, - messageText, + body, preview, profileKey, quote, @@ -1156,7 +1158,7 @@ export class MessageSender { return { attachments, bodyRanges, - body: messageText, + body, contact, deletedForEveryoneTimestamp, expireTimer, @@ -1389,70 +1391,28 @@ export class MessageSender { // You might wonder why this takes a groupId. models/messages.resend() can send a group // message to just one person. async sendMessageToServiceId({ - attachments, - bodyRanges, - contact, + messageOptions, contentHint, - deletedForEveryoneTimestamp, - expireTimer, - expireTimerVersion, groupId, serviceId, - messageText, options, - preview, - profileKey, - quote, - reaction, - sticker, - storyContext, story, - targetTimestampForEdit, - timestamp, urgent, includePniSignatureMessage, }: Readonly<{ - attachments: ReadonlyArray | undefined; - bodyRanges?: ReadonlyArray; - contact?: ReadonlyArray; - contentHint: number; - deletedForEveryoneTimestamp: number | undefined; - expireTimer: DurationInSeconds | undefined; - expireTimerVersion: number | undefined; - groupId: string | undefined; serviceId: ServiceIdString; - messageText: string | undefined; + groupId: string | undefined; + messageOptions: Omit; + contentHint: number; options?: SendOptionsType; - preview?: ReadonlyArray | undefined; - profileKey?: Uint8Array; - quote?: OutgoingQuoteType; - reaction?: ReactionType; - sticker?: OutgoingStickerType; - storyContext?: StoryContextType; story?: boolean; - targetTimestampForEdit?: number; - timestamp: number; urgent: boolean; includePniSignatureMessage?: boolean; }>): Promise { return this.sendMessage({ messageOptions: { - attachments, - bodyRanges, - body: messageText, - contact, - deletedForEveryoneTimestamp, - expireTimer, - expireTimerVersion, - preview, - profileKey, - quote, - reaction, + ...messageOptions, recipients: [serviceId], - sticker, - storyContext, - targetTimestampForEdit, - timestamp, }, contentHint, groupId, diff --git a/ts/types/PinnedMessage.std.ts b/ts/types/PinnedMessage.std.ts index 902ae95aa7..75b6797d76 100644 --- a/ts/types/PinnedMessage.std.ts +++ b/ts/types/PinnedMessage.std.ts @@ -1,6 +1,7 @@ // Copyright 2025 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import type { AciString } from '@signalapp/mock-server/src/types.js'; import type { MessageAttributesType } from '../model-types.js'; import type { ConversationType } from '../state/ducks/conversations.preload.js'; @@ -21,3 +22,14 @@ export type PinnedMessageRenderData = Readonly<{ sender: ConversationType; message: MessageAttributesType; }>; + +export type SendPinMessageType = Readonly<{ + targetAuthorAci: AciString; + targetSentTimestamp: number; + pinDurationSeconds: number | null; +}>; + +export type SendUnpinMessageType = Readonly<{ + targetAuthorAci: AciString; + targetSentTimestamp: number; +}>; diff --git a/ts/util/handleMessageSend.preload.ts b/ts/util/handleMessageSend.preload.ts index 2f116b4d2b..d77ab5696b 100644 --- a/ts/util/handleMessageSend.preload.ts +++ b/ts/util/handleMessageSend.preload.ts @@ -31,8 +31,10 @@ export const sendTypesEnum = z.enum([ 'expirationTimerUpdate', // non-urgent 'groupChange', // non-urgent 'reaction', + 'pinMessage', 'pollTerminate', 'pollVote', // non-urgent + 'unpinMessage', 'typing', // excluded from send log; non-urgent // Responding to incoming messages, all non-urgent diff --git a/ts/util/sendToGroup.preload.ts b/ts/util/sendToGroup.preload.ts index 8fff8f18dc..6c97fbb584 100644 --- a/ts/util/sendToGroup.preload.ts +++ b/ts/util/sendToGroup.preload.ts @@ -37,7 +37,7 @@ import { isRecord } from './isRecord.std.js'; import { isOlderThan } from './timestamp.std.js'; import type { - GroupSendOptionsType, + GroupMessageOptionsType, SendOptionsType, } from '../textsecure/SendMessage.preload.js'; import { messageSender } from '../textsecure/SendMessage.preload.js'; @@ -137,7 +137,7 @@ export async function sendToGroup({ }: { abortSignal?: AbortSignal; contentHint: number; - groupSendOptions: GroupSendOptionsType; + groupSendOptions: GroupMessageOptionsType; isPartialSend?: boolean; messageId: string | undefined; sendOptions?: SendOptionsType; @@ -931,7 +931,7 @@ export function _shouldFailSend(error: unknown, logId: string): boolean { } function getRecipients( - options: GroupSendOptionsType + options: GroupMessageOptionsType ): ReadonlyArray { if (options.groupV2) { return options.groupV2.members;