Support sending pin/unpin messages

This commit is contained in:
Jamie
2025-12-08 15:00:10 -08:00
committed by GitHub
parent d61f96a1c1
commit f1aef55d0c
16 changed files with 548 additions and 324 deletions

View File

@@ -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<typeof conversationQueueJobEnum>;
@@ -198,6 +202,16 @@ const reactionJobDataSchema = z.object({
});
export type ReactionJobData = z.infer<typeof reactionJobDataSchema>;
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<typeof pinMessageJobDataSchema>;
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<typeof receiptsJobDataSchema>;
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<typeof unpinMessageJobDataSchema>;
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<ConversationQueueJobData> {
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<ConversationQueueJobData> {
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.

View File

@@ -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<Data> = Readonly<{
sendName: string; // ex: 'sendExampleMessage'
sendType: SendTypesType;
getMessageId: (data: Data) => string | null;
getMessageOptions: (
data: Data,
jobTimestamp: number
) => Omit<SharedMessageOptionsType, 'recipients'>;
}>;
export function createSendMessageJob<Data>(
options: SendMessageJobOptions<Data>
) {
return async function sendMessage(
conversation: ConversationModel,
job: ConversationQueueJobBundle,
data: Data
): Promise<void> {
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,
});
}
};
}

View File

@@ -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<ServiceIdString>;
recipientServiceIdsWithoutMe: Array<ServiceIdString>;
untrustedServiceIds: Array<ServiceIdString>;
}>;
export type GetSendRecipientListsOptions = Readonly<{
log: LoggerType;
conversationIds: ReadonlyArray<string>;
conversation: ConversationModel;
}>;
export function getSendRecipientLists(
options: GetSendRecipientListsOptions
): SendRecipientLists {
const { log, conversationIds, conversation } = options;
const allRecipientServiceIds: Array<ServiceIdString> = [];
const recipientServiceIdsWithoutMe: Array<ServiceIdString> = [];
const untrustedServiceIds: Array<ServiceIdString> = [];
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,
};
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -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<PinMessageJobData>({
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,
},
};
},
});

View File

@@ -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<ServiceIdString>;
recipientServiceIdsWithoutMe: Array<ServiceIdString>;
untrustedServiceIds: Array<ServiceIdString>;
} {
const allRecipientServiceIds: Array<ServiceIdString> = [];
const recipientServiceIdsWithoutMe: Array<ServiceIdString> = [];
const untrustedServiceIds: Array<ServiceIdString> = [];
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,
};
}

View File

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

View File

@@ -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<MessageReactionType>,
conversation: ConversationModel
): {
allRecipientServiceIds: Array<ServiceIdString>;
recipientServiceIdsWithoutMe: Array<ServiceIdString>;
untrustedServiceIds: Array<ServiceIdString>;
} {
const allRecipientServiceIds: Array<ServiceIdString> = [];
const recipientServiceIdsWithoutMe: Array<ServiceIdString> = [];
const untrustedServiceIds: Array<ServiceIdString> = [];
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

View File

@@ -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<UnpinMessageJobData>({
sendName: 'sendUnpinMessage',
sendType: 'unpinMessage',
getMessageId(data) {
return data.targetMessageId;
},
getMessageOptions(data, jobTimestamp) {
return {
timestamp: jobTimestamp,
unpinMessage: {
targetAuthorAci: data.targetAuthorAci,
targetSentTimestamp: data.targetSentTimestamp,
},
};
},
});

View File

@@ -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<typeof singleProtoJobDataSchema>;
export type MessageOptionsType = {
export type SharedMessageOptionsType = Readonly<{
// required
timestamp: number;
// optional
attachments?: ReadonlyArray<Proto.IAttachmentPointer>;
body?: string;
bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
deletedForEveryoneTimestamp?: number;
expireTimer?: DurationInSeconds;
expireTimerVersion: number | undefined;
flags?: number;
group?: {
id: string;
type: number;
};
groupCallUpdate?: GroupCallUpdateType;
groupV2?: GroupV2InfoType;
needsSync?: boolean;
preview?: ReadonlyArray<OutgoingLinkPreviewType>;
profileKey?: Uint8Array;
quote?: OutgoingQuoteType;
recipients: ReadonlyArray<ServiceIdString>;
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<Proto.IAttachmentPointer>;
bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
deletedForEveryoneTimestamp?: number;
targetTimestampForEdit?: number;
expireTimer?: DurationInSeconds;
flags?: number;
groupCallUpdate?: GroupCallUpdateType;
groupV2?: GroupV2InfoType;
messageText?: string;
preview?: ReadonlyArray<OutgoingLinkPreviewType>;
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<ServiceIdString>;
}
>;
export type GroupMessageOptionsType = Readonly<
SharedMessageOptionsType & {
groupV2: GroupV2InfoType;
}
>;
export type PollVoteBuildOptions = Required<
Pick<MessageOptionsType, 'groupV2' | 'timestamp' | 'pollVote'>
@@ -276,15 +267,8 @@ class Message {
flags?: number;
group?: {
id: string;
type: number;
};
groupV2?: GroupV2InfoType;
needsSync?: boolean;
preview?: ReadonlyArray<OutgoingLinkPreviewType>;
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<GroupSendOptionsType>
options: Readonly<GroupMessageOptionsType>
): 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<Proto.IAttachmentPointer> | undefined;
bodyRanges?: ReadonlyArray<RawBodyRange>;
contact?: ReadonlyArray<EmbeddedContactWithUploadedAvatar>;
contentHint: number;
deletedForEveryoneTimestamp: number | undefined;
expireTimer: DurationInSeconds | undefined;
expireTimerVersion: number | undefined;
groupId: string | undefined;
serviceId: ServiceIdString;
messageText: string | undefined;
groupId: string | undefined;
messageOptions: Omit<MessageOptionsType, 'recipients'>;
contentHint: number;
options?: SendOptionsType;
preview?: ReadonlyArray<OutgoingLinkPreviewType> | undefined;
profileKey?: Uint8Array;
quote?: OutgoingQuoteType;
reaction?: ReactionType;
sticker?: OutgoingStickerType;
storyContext?: StoryContextType;
story?: boolean;
targetTimestampForEdit?: number;
timestamp: number;
urgent: boolean;
includePniSignatureMessage?: boolean;
}>): Promise<CallbackResultType> {
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,

View File

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

View File

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

View File

@@ -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<ServiceIdString> {
if (options.groupV2) {
return options.groupV2.members;