Improve story DOE flow
This commit is contained in:
parent
5e9744d62a
commit
37d383f344
15 changed files with 630 additions and 245 deletions
|
@ -16,6 +16,7 @@ import { sendNormalMessage } from './helpers/sendNormalMessage';
|
||||||
import { sendDirectExpirationTimerUpdate } from './helpers/sendDirectExpirationTimerUpdate';
|
import { sendDirectExpirationTimerUpdate } from './helpers/sendDirectExpirationTimerUpdate';
|
||||||
import { sendGroupUpdate } from './helpers/sendGroupUpdate';
|
import { sendGroupUpdate } from './helpers/sendGroupUpdate';
|
||||||
import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone';
|
import { sendDeleteForEveryone } from './helpers/sendDeleteForEveryone';
|
||||||
|
import { sendDeleteStoryForEveryone } from './helpers/sendDeleteStoryForEveryone';
|
||||||
import { sendProfileKey } from './helpers/sendProfileKey';
|
import { sendProfileKey } from './helpers/sendProfileKey';
|
||||||
import { sendReaction } from './helpers/sendReaction';
|
import { sendReaction } from './helpers/sendReaction';
|
||||||
import { sendStory } from './helpers/sendStory';
|
import { sendStory } from './helpers/sendStory';
|
||||||
|
@ -41,6 +42,7 @@ import type { UUIDStringType } from '../types/UUID';
|
||||||
// these values, you'll likely need to write a database migration.
|
// these values, you'll likely need to write a database migration.
|
||||||
export const conversationQueueJobEnum = z.enum([
|
export const conversationQueueJobEnum = z.enum([
|
||||||
'DeleteForEveryone',
|
'DeleteForEveryone',
|
||||||
|
'DeleteStoryForEveryone',
|
||||||
'DirectExpirationTimerUpdate',
|
'DirectExpirationTimerUpdate',
|
||||||
'GroupUpdate',
|
'GroupUpdate',
|
||||||
'NormalMessage',
|
'NormalMessage',
|
||||||
|
@ -61,6 +63,25 @@ export type DeleteForEveryoneJobData = z.infer<
|
||||||
typeof deleteForEveryoneJobDataSchema
|
typeof deleteForEveryoneJobDataSchema
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
const deleteStoryForEveryoneJobDataSchema = z.object({
|
||||||
|
type: z.literal(conversationQueueJobEnum.enum.DeleteStoryForEveryone),
|
||||||
|
conversationId: z.string(),
|
||||||
|
storyId: z.string(),
|
||||||
|
targetTimestamp: z.number(),
|
||||||
|
updatedStoryRecipients: z
|
||||||
|
.array(
|
||||||
|
z.object({
|
||||||
|
destinationUuid: z.string(),
|
||||||
|
distributionListIds: z.array(z.string()),
|
||||||
|
isAllowedToReply: z.boolean(),
|
||||||
|
})
|
||||||
|
)
|
||||||
|
.optional(),
|
||||||
|
});
|
||||||
|
export type DeleteStoryForEveryoneJobData = z.infer<
|
||||||
|
typeof deleteStoryForEveryoneJobDataSchema
|
||||||
|
>;
|
||||||
|
|
||||||
const expirationTimerUpdateJobDataSchema = z.object({
|
const expirationTimerUpdateJobDataSchema = z.object({
|
||||||
type: z.literal(conversationQueueJobEnum.enum.DirectExpirationTimerUpdate),
|
type: z.literal(conversationQueueJobEnum.enum.DirectExpirationTimerUpdate),
|
||||||
conversationId: z.string(),
|
conversationId: z.string(),
|
||||||
|
@ -120,6 +141,7 @@ export type StoryJobData = z.infer<typeof storyJobDataSchema>;
|
||||||
|
|
||||||
export const conversationQueueJobDataSchema = z.union([
|
export const conversationQueueJobDataSchema = z.union([
|
||||||
deleteForEveryoneJobDataSchema,
|
deleteForEveryoneJobDataSchema,
|
||||||
|
deleteStoryForEveryoneJobDataSchema,
|
||||||
expirationTimerUpdateJobDataSchema,
|
expirationTimerUpdateJobDataSchema,
|
||||||
groupUpdateJobDataSchema,
|
groupUpdateJobDataSchema,
|
||||||
normalMessageSendJobDataSchema,
|
normalMessageSendJobDataSchema,
|
||||||
|
@ -334,6 +356,9 @@ export class ConversationJobQueue extends JobQueue<ConversationQueueJobData> {
|
||||||
case jobSet.DeleteForEveryone:
|
case jobSet.DeleteForEveryone:
|
||||||
await sendDeleteForEveryone(conversation, jobBundle, data);
|
await sendDeleteForEveryone(conversation, jobBundle, data);
|
||||||
break;
|
break;
|
||||||
|
case jobSet.DeleteStoryForEveryone:
|
||||||
|
await sendDeleteStoryForEveryone(conversation, jobBundle, data);
|
||||||
|
break;
|
||||||
case jobSet.DirectExpirationTimerUpdate:
|
case jobSet.DirectExpirationTimerUpdate:
|
||||||
await sendDirectExpirationTimerUpdate(conversation, jobBundle, data);
|
await sendDirectExpirationTimerUpdate(conversation, jobBundle, data);
|
||||||
break;
|
break;
|
||||||
|
|
|
@ -55,15 +55,22 @@ export async function sendDeleteForEveryone(
|
||||||
targetTimestamp,
|
targetTimestamp,
|
||||||
} = data;
|
} = data;
|
||||||
|
|
||||||
|
const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${messageId})`;
|
||||||
|
|
||||||
const message = await getMessageById(messageId);
|
const message = await getMessageById(messageId);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
log.error(`Failed to fetch message ${messageId}. Failing job.`);
|
log.error(`${logId}: Failed to fetch message. Failing job.`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const story = isStory(message.attributes);
|
const story = isStory(message.attributes);
|
||||||
|
if (story && !isGroupV2(conversation.attributes)) {
|
||||||
|
log.error(`${logId}: 1-on-1 Story DOE must use its own job. Failing job`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (!shouldContinue) {
|
if (!shouldContinue) {
|
||||||
log.info('Ran out of time. Giving up on sending delete for everyone');
|
log.info(`${logId}: Ran out of time. Giving up on sending`);
|
||||||
updateMessageWithFailure(message, [new Error('Ran out of time!')], log);
|
updateMessageWithFailure(message, [new Error('Ran out of time!')], log);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -73,8 +80,6 @@ export async function sendDeleteForEveryone(
|
||||||
const contentHint = ContentHint.RESENDABLE;
|
const contentHint = ContentHint.RESENDABLE;
|
||||||
const messageIds = [messageId];
|
const messageIds = [messageId];
|
||||||
|
|
||||||
const logId = `deleteForEveryone/${conversation.idForLogging()}`;
|
|
||||||
|
|
||||||
const deletedForEveryoneSendStatus = message.get(
|
const deletedForEveryoneSendStatus = message.get(
|
||||||
'deletedForEveryoneSendStatus'
|
'deletedForEveryoneSendStatus'
|
||||||
);
|
);
|
||||||
|
@ -97,9 +102,8 @@ export async function sendDeleteForEveryone(
|
||||||
'conversationQueue/sendDeleteForEveryone',
|
'conversationQueue/sendDeleteForEveryone',
|
||||||
async abortSignal => {
|
async abortSignal => {
|
||||||
log.info(
|
log.info(
|
||||||
`Sending deleteForEveryone to conversation ${logId}`,
|
`${logId}: Sending deleteForEveryone with timestamp ${timestamp}` +
|
||||||
`with timestamp ${timestamp}`,
|
`for message ${targetTimestamp}, isStory=${story}`
|
||||||
`for message ${targetTimestamp}`
|
|
||||||
);
|
);
|
||||||
|
|
||||||
let profileKey: Uint8Array | undefined;
|
let profileKey: Uint8Array | undefined;
|
||||||
|
|
306
ts/jobs/helpers/sendDeleteStoryForEveryone.ts
Normal file
306
ts/jobs/helpers/sendDeleteStoryForEveryone.ts
Normal file
|
@ -0,0 +1,306 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import * as Errors from '../../types/errors';
|
||||||
|
import { getSendOptions } from '../../util/getSendOptions';
|
||||||
|
import { isDirectConversation, isMe } from '../../util/whatTypeOfConversation';
|
||||||
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
|
import {
|
||||||
|
handleMultipleSendErrors,
|
||||||
|
maybeExpandErrors,
|
||||||
|
} from './handleMultipleSendErrors';
|
||||||
|
import { ourProfileKeyService } from '../../services/ourProfileKey';
|
||||||
|
|
||||||
|
import type { ConversationModel } from '../../models/conversations';
|
||||||
|
import type {
|
||||||
|
ConversationQueueJobBundle,
|
||||||
|
DeleteStoryForEveryoneJobData,
|
||||||
|
} from '../conversationJobQueue';
|
||||||
|
import { getUntrustedConversationUuids } from './getUntrustedConversationUuids';
|
||||||
|
import { handleMessageSend } from '../../util/handleMessageSend';
|
||||||
|
import { isConversationAccepted } from '../../util/isConversationAccepted';
|
||||||
|
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
|
||||||
|
import { getMessageById } from '../../messages/getMessageById';
|
||||||
|
import { isNotNil } from '../../util/isNotNil';
|
||||||
|
import type { CallbackResultType } from '../../textsecure/Types.d';
|
||||||
|
import type { MessageModel } from '../../models/messages';
|
||||||
|
import { SendMessageProtoError } from '../../textsecure/Errors';
|
||||||
|
import { strictAssert } from '../../util/assert';
|
||||||
|
import type { LoggerType } from '../../types/Logging';
|
||||||
|
import { isStory } from '../../messages/helpers';
|
||||||
|
|
||||||
|
export async function sendDeleteStoryForEveryone(
|
||||||
|
ourConversation: ConversationModel,
|
||||||
|
{
|
||||||
|
isFinalAttempt,
|
||||||
|
messaging,
|
||||||
|
shouldContinue,
|
||||||
|
timestamp,
|
||||||
|
timeRemaining,
|
||||||
|
log,
|
||||||
|
}: ConversationQueueJobBundle,
|
||||||
|
data: DeleteStoryForEveryoneJobData
|
||||||
|
): Promise<void> {
|
||||||
|
const { storyId, targetTimestamp, updatedStoryRecipients } = data;
|
||||||
|
|
||||||
|
const logId = `sendDeleteStoryForEveryone(${storyId})`;
|
||||||
|
|
||||||
|
const message = await getMessageById(storyId);
|
||||||
|
if (!message) {
|
||||||
|
log.error(`${logId}: Failed to fetch message. Failing job.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!shouldContinue) {
|
||||||
|
log.info(`${logId}: Ran out of time. Giving up on sending`);
|
||||||
|
updateMessageWithFailure(message, [new Error('Ran out of time!')], log);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
strictAssert(
|
||||||
|
isMe(ourConversation.attributes),
|
||||||
|
'Story DOE must be sent on our conversaton'
|
||||||
|
);
|
||||||
|
strictAssert(isStory(message.attributes), 'Story message must be a story');
|
||||||
|
|
||||||
|
const sendType = 'deleteForEveryone';
|
||||||
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||||
|
const contentHint = ContentHint.RESENDABLE;
|
||||||
|
|
||||||
|
const deletedForEveryoneSendStatus = message.get(
|
||||||
|
'deletedForEveryoneSendStatus'
|
||||||
|
);
|
||||||
|
strictAssert(
|
||||||
|
deletedForEveryoneSendStatus,
|
||||||
|
`${logId}: message does not have deletedForEveryoneSendStatus`
|
||||||
|
);
|
||||||
|
const recipientIds = Object.entries(deletedForEveryoneSendStatus)
|
||||||
|
.filter(([_, isSent]) => !isSent)
|
||||||
|
.map(([conversationId]) => conversationId);
|
||||||
|
|
||||||
|
const untrustedUuids = getUntrustedConversationUuids(recipientIds);
|
||||||
|
if (untrustedUuids.length) {
|
||||||
|
window.reduxActions.conversations.conversationStoppedByMissingVerification({
|
||||||
|
conversationId: ourConversation.id,
|
||||||
|
untrustedUuids,
|
||||||
|
});
|
||||||
|
throw new Error(
|
||||||
|
`Delete for everyone blocked because ${untrustedUuids.length} ` +
|
||||||
|
'conversation(s) were untrusted. Failing this attempt.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const recipientConversations = recipientIds
|
||||||
|
.map(conversationId => {
|
||||||
|
const conversation = window.ConversationController.get(conversationId);
|
||||||
|
if (!conversation) {
|
||||||
|
log.error(`${logId}: conversation not found for ${conversationId}`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (!isDirectConversation(conversation.attributes)) {
|
||||||
|
log.error(`${logId}: conversation ${conversationId} is not direct`);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isConversationAccepted(conversation.attributes)) {
|
||||||
|
log.info(
|
||||||
|
`${logId}: conversation ${conversation.idForLogging()} ` +
|
||||||
|
'is not accepted; refusing to send'
|
||||||
|
);
|
||||||
|
updateMessageWithFailure(
|
||||||
|
message,
|
||||||
|
[new Error('Message request was not accepted')],
|
||||||
|
log
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (isConversationUnregistered(conversation.attributes)) {
|
||||||
|
log.info(
|
||||||
|
`${logId}: conversation ${conversation.idForLogging()} ` +
|
||||||
|
'is unregistered; refusing to send'
|
||||||
|
);
|
||||||
|
updateMessageWithFailure(
|
||||||
|
message,
|
||||||
|
[new Error('Contact no longer has a Signal account')],
|
||||||
|
log
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (conversation.isBlocked()) {
|
||||||
|
log.info(
|
||||||
|
`${logId}: conversation ${conversation.idForLogging()} ` +
|
||||||
|
'is blocked; refusing to send'
|
||||||
|
);
|
||||||
|
updateMessageWithFailure(
|
||||||
|
message,
|
||||||
|
[new Error('Contact is blocked')],
|
||||||
|
log
|
||||||
|
);
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
return conversation;
|
||||||
|
})
|
||||||
|
.filter(isNotNil);
|
||||||
|
|
||||||
|
const hadSuccessfulSends = doesMessageHaveSuccessfulSends(message);
|
||||||
|
let didSuccessfullySendOne = false;
|
||||||
|
|
||||||
|
// Special case - we have no one to send it to so just send the sync message.
|
||||||
|
if (recipientConversations.length === 0) {
|
||||||
|
didSuccessfullySendOne = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
const profileKey = await ourProfileKeyService.get();
|
||||||
|
|
||||||
|
await Promise.all(
|
||||||
|
recipientConversations.map(conversation => {
|
||||||
|
return conversation.queueJob(
|
||||||
|
'conversationQueue/sendStoryDeleteForEveryone',
|
||||||
|
async () => {
|
||||||
|
log.info(
|
||||||
|
`${logId}: Sending deleteStoryForEveryone with timestamp ${timestamp}`
|
||||||
|
);
|
||||||
|
|
||||||
|
const sendOptions = await getSendOptions(conversation.attributes, {
|
||||||
|
story: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleMessageSend(
|
||||||
|
messaging.sendMessageToIdentifier({
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
|
||||||
|
identifier: conversation.getSendTarget()!,
|
||||||
|
messageText: undefined,
|
||||||
|
attachments: [],
|
||||||
|
deletedForEveryoneTimestamp: targetTimestamp,
|
||||||
|
timestamp,
|
||||||
|
expireTimer: undefined,
|
||||||
|
contentHint,
|
||||||
|
groupId: undefined,
|
||||||
|
profileKey: conversation.get('profileSharing')
|
||||||
|
? profileKey
|
||||||
|
: undefined,
|
||||||
|
options: sendOptions,
|
||||||
|
urgent: true,
|
||||||
|
story: true,
|
||||||
|
}),
|
||||||
|
{
|
||||||
|
messageIds: [storyId],
|
||||||
|
sendType,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
didSuccessfullySendOne = true;
|
||||||
|
|
||||||
|
await updateMessageWithSuccessfulSends(message, {
|
||||||
|
successfulIdentifiers: [conversation.id],
|
||||||
|
});
|
||||||
|
} catch (error: unknown) {
|
||||||
|
if (error instanceof SendMessageProtoError) {
|
||||||
|
await updateMessageWithSuccessfulSends(message, error);
|
||||||
|
}
|
||||||
|
|
||||||
|
const errors = maybeExpandErrors(error);
|
||||||
|
await handleMultipleSendErrors({
|
||||||
|
errors,
|
||||||
|
isFinalAttempt,
|
||||||
|
log,
|
||||||
|
markFailed: () => updateMessageWithFailure(message, errors, log),
|
||||||
|
timeRemaining,
|
||||||
|
toThrow: error,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Send sync message exactly once per job. If any of the sends are successful
|
||||||
|
// and we didn't send the DOE itself before - it is a good time to send the
|
||||||
|
// sync message.
|
||||||
|
if (!hadSuccessfulSends && didSuccessfullySendOne) {
|
||||||
|
log.info(`${logId}: Sending sync message`);
|
||||||
|
const options = await getSendOptions(ourConversation.attributes, {
|
||||||
|
syncMessage: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
const destinationUuid = ourConversation
|
||||||
|
.getCheckedUuid('deleteStoryForEveryone')
|
||||||
|
.toString();
|
||||||
|
|
||||||
|
// Sync message for other devices
|
||||||
|
await handleMessageSend(
|
||||||
|
messaging.sendSyncMessage({
|
||||||
|
destination: undefined,
|
||||||
|
destinationUuid,
|
||||||
|
storyMessageRecipients: updatedStoryRecipients,
|
||||||
|
expirationStartTimestamp: null,
|
||||||
|
isUpdate: true,
|
||||||
|
options,
|
||||||
|
timestamp: message.get('timestamp'),
|
||||||
|
urgent: false,
|
||||||
|
}),
|
||||||
|
{ messageIds: [storyId], sendType }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function doesMessageHaveSuccessfulSends(message: MessageModel): boolean {
|
||||||
|
const map = message.get('deletedForEveryoneSendStatus') ?? {};
|
||||||
|
|
||||||
|
return Object.values(map).some(value => value === true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMessageWithSuccessfulSends(
|
||||||
|
message: MessageModel,
|
||||||
|
result?: CallbackResultType | SendMessageProtoError
|
||||||
|
): Promise<void> {
|
||||||
|
if (!result) {
|
||||||
|
message.set({
|
||||||
|
deletedForEveryoneSendStatus: {},
|
||||||
|
deletedForEveryoneFailed: undefined,
|
||||||
|
});
|
||||||
|
await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
|
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
|
});
|
||||||
|
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const deletedForEveryoneSendStatus = {
|
||||||
|
...message.get('deletedForEveryoneSendStatus'),
|
||||||
|
};
|
||||||
|
|
||||||
|
result.successfulIdentifiers?.forEach(identifier => {
|
||||||
|
const conversation = window.ConversationController.get(identifier);
|
||||||
|
if (!conversation) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
deletedForEveryoneSendStatus[conversation.id] = true;
|
||||||
|
});
|
||||||
|
|
||||||
|
message.set({
|
||||||
|
deletedForEveryoneSendStatus,
|
||||||
|
deletedForEveryoneFailed: undefined,
|
||||||
|
});
|
||||||
|
await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
|
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async function updateMessageWithFailure(
|
||||||
|
message: MessageModel,
|
||||||
|
errors: ReadonlyArray<unknown>,
|
||||||
|
log: LoggerType
|
||||||
|
): Promise<void> {
|
||||||
|
log.error(
|
||||||
|
'updateMessageWithFailure: Setting this set of errors',
|
||||||
|
errors.map(Errors.toLogFormat)
|
||||||
|
);
|
||||||
|
|
||||||
|
message.set({ deletedForEveryoneFailed: true });
|
||||||
|
await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
|
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
|
});
|
||||||
|
}
|
|
@ -13,7 +13,6 @@ import type {
|
||||||
} from '../conversationJobQueue';
|
} from '../conversationJobQueue';
|
||||||
import type { LoggerType } from '../../types/Logging';
|
import type { LoggerType } from '../../types/Logging';
|
||||||
import type { MessageModel } from '../../models/messages';
|
import type { MessageModel } from '../../models/messages';
|
||||||
import type { SenderKeyInfoType } from '../../model-types.d';
|
|
||||||
import type {
|
import type {
|
||||||
SendState,
|
SendState,
|
||||||
SendStateByConversationId,
|
SendStateByConversationId,
|
||||||
|
@ -25,6 +24,7 @@ import {
|
||||||
} from '../../messages/MessageSendState';
|
} from '../../messages/MessageSendState';
|
||||||
import type { UUIDStringType } from '../../types/UUID';
|
import type { UUIDStringType } from '../../types/UUID';
|
||||||
import * as Errors from '../../types/errors';
|
import * as Errors from '../../types/errors';
|
||||||
|
import type { StoryMessageRecipientsType } from '../../types/Stories';
|
||||||
import dataInterface from '../../sql/Client';
|
import dataInterface from '../../sql/Client';
|
||||||
import { SignalService as Proto } from '../../protobuf';
|
import { SignalService as Proto } from '../../protobuf';
|
||||||
import { getMessagesById } from '../../messages/getMessagesById';
|
import { getMessagesById } from '../../messages/getMessagesById';
|
||||||
|
@ -35,9 +35,9 @@ import {
|
||||||
import { handleMessageSend } from '../../util/handleMessageSend';
|
import { handleMessageSend } from '../../util/handleMessageSend';
|
||||||
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
|
import { handleMultipleSendErrors } from './handleMultipleSendErrors';
|
||||||
import { isGroupV2, isMe } from '../../util/whatTypeOfConversation';
|
import { isGroupV2, isMe } from '../../util/whatTypeOfConversation';
|
||||||
import { isNotNil } from '../../util/isNotNil';
|
|
||||||
import { ourProfileKeyService } from '../../services/ourProfileKey';
|
import { ourProfileKeyService } from '../../services/ourProfileKey';
|
||||||
import { sendContentMessageToGroup } from '../../util/sendToGroup';
|
import { sendContentMessageToGroup } from '../../util/sendToGroup';
|
||||||
|
import { distributionListToSendTarget } from '../../util/distributionListToSendTarget';
|
||||||
import { SendMessageChallengeError } from '../../textsecure/Errors';
|
import { SendMessageChallengeError } from '../../textsecure/Errors';
|
||||||
|
|
||||||
export async function sendStory(
|
export async function sendStory(
|
||||||
|
@ -283,8 +283,6 @@ export async function sendStory(
|
||||||
|
|
||||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||||
|
|
||||||
const recipientsSet = new Set(pendingSendRecipientIds);
|
|
||||||
|
|
||||||
const sendOptions = await getSendOptionsForRecipients(
|
const sendOptions = await getSendOptionsForRecipients(
|
||||||
pendingSendRecipientIds,
|
pendingSendRecipientIds,
|
||||||
{ story: true }
|
{ story: true }
|
||||||
|
@ -303,28 +301,11 @@ export async function sendStory(
|
||||||
isGroupV2(conversation.attributes) ||
|
isGroupV2(conversation.attributes) ||
|
||||||
Boolean(distributionList?.allowsReplies);
|
Boolean(distributionList?.allowsReplies);
|
||||||
|
|
||||||
let inMemorySenderKeyInfo = distributionList?.senderKeyInfo;
|
|
||||||
|
|
||||||
const sendTarget = distributionList
|
const sendTarget = distributionList
|
||||||
? {
|
? distributionListToSendTarget(
|
||||||
getGroupId: () => undefined,
|
distributionList,
|
||||||
getMembers: () =>
|
pendingSendRecipientIds
|
||||||
pendingSendRecipientIds
|
)
|
||||||
.map(uuid => window.ConversationController.get(uuid))
|
|
||||||
.filter(isNotNil),
|
|
||||||
hasMember: (uuid: UUIDStringType) => recipientsSet.has(uuid),
|
|
||||||
idForLogging: () => `dl(${receiverId})`,
|
|
||||||
isGroupV2: () => true,
|
|
||||||
isValid: () => true,
|
|
||||||
getSenderKeyInfo: () => inMemorySenderKeyInfo,
|
|
||||||
saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) => {
|
|
||||||
inMemorySenderKeyInfo = senderKeyInfo;
|
|
||||||
await dataInterface.modifyStoryDistribution({
|
|
||||||
...distributionList,
|
|
||||||
senderKeyInfo,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: conversation.toSenderKeyTarget();
|
: conversation.toSenderKeyTarget();
|
||||||
|
|
||||||
const contentMessage = new Proto.Content();
|
const contentMessage = new Proto.Content();
|
||||||
|
@ -530,11 +511,7 @@ export async function sendStory(
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build up the sync message's storyMessageRecipients and send it
|
// Build up the sync message's storyMessageRecipients and send it
|
||||||
const storyMessageRecipients: Array<{
|
const storyMessageRecipients: StoryMessageRecipientsType = [];
|
||||||
destinationUuid: string;
|
|
||||||
distributionListIds: Array<string>;
|
|
||||||
isAllowedToReply: boolean;
|
|
||||||
}> = [];
|
|
||||||
recipientsByUuid.forEach((distributionListIds, destinationUuid) => {
|
recipientsByUuid.forEach((distributionListIds, destinationUuid) => {
|
||||||
storyMessageRecipients.push({
|
storyMessageRecipients.push({
|
||||||
destinationUuid,
|
destinationUuid,
|
||||||
|
|
1
ts/model-types.d.ts
vendored
1
ts/model-types.d.ts
vendored
|
@ -153,6 +153,7 @@ export type MessageAttributesType = {
|
||||||
storyDistributionListId?: string;
|
storyDistributionListId?: string;
|
||||||
storyId?: string;
|
storyId?: string;
|
||||||
storyReplyContext?: StoryReplyContextType;
|
storyReplyContext?: StoryReplyContextType;
|
||||||
|
storyRecipientsVersion?: number;
|
||||||
supportedVersionAtReceive?: unknown;
|
supportedVersionAtReceive?: unknown;
|
||||||
synced?: boolean;
|
synced?: boolean;
|
||||||
unidentifiedDeliveryReceived?: boolean;
|
unidentifiedDeliveryReceived?: boolean;
|
||||||
|
|
|
@ -109,6 +109,7 @@ export function getStoryDataFromMessageAttributes(
|
||||||
'source',
|
'source',
|
||||||
'sourceUuid',
|
'sourceUuid',
|
||||||
'storyDistributionListId',
|
'storyDistributionListId',
|
||||||
|
'storyRecipientsVersion',
|
||||||
'timestamp',
|
'timestamp',
|
||||||
'type',
|
'type',
|
||||||
]),
|
]),
|
||||||
|
|
|
@ -77,6 +77,7 @@ export type StoryDataType = {
|
||||||
| 'storyDistributionListId'
|
| 'storyDistributionListId'
|
||||||
| 'timestamp'
|
| 'timestamp'
|
||||||
| 'type'
|
| 'type'
|
||||||
|
| 'storyRecipientsVersion'
|
||||||
> & {
|
> & {
|
||||||
// don't want the fields to be optional as in MessageAttributesType
|
// don't want the fields to be optional as in MessageAttributesType
|
||||||
expireTimer: DurationInSeconds | undefined;
|
expireTimer: DurationInSeconds | undefined;
|
||||||
|
|
|
@ -1431,11 +1431,7 @@ export default class MessageSender {
|
||||||
urgent: boolean;
|
urgent: boolean;
|
||||||
options?: SendOptionsType;
|
options?: SendOptionsType;
|
||||||
storyMessage?: Proto.StoryMessage;
|
storyMessage?: Proto.StoryMessage;
|
||||||
storyMessageRecipients?: Array<{
|
storyMessageRecipients?: ReadonlyArray<Proto.SyncMessage.Sent.IStoryMessageRecipient>;
|
||||||
destinationUuid: string;
|
|
||||||
distributionListIds: Array<string>;
|
|
||||||
isAllowedToReply: boolean;
|
|
||||||
}>;
|
|
||||||
}>): Promise<CallbackResultType> {
|
}>): Promise<CallbackResultType> {
|
||||||
const myUuid = window.textsecure.storage.user.getCheckedUuid();
|
const myUuid = window.textsecure.storage.user.getCheckedUuid();
|
||||||
|
|
||||||
|
@ -1461,17 +1457,7 @@ export default class MessageSender {
|
||||||
sentMessage.storyMessage = storyMessage;
|
sentMessage.storyMessage = storyMessage;
|
||||||
}
|
}
|
||||||
if (storyMessageRecipients) {
|
if (storyMessageRecipients) {
|
||||||
sentMessage.storyMessageRecipients = storyMessageRecipients.map(
|
sentMessage.storyMessageRecipients = storyMessageRecipients.slice();
|
||||||
recipient => {
|
|
||||||
const storyMessageRecipient =
|
|
||||||
new Proto.SyncMessage.Sent.StoryMessageRecipient();
|
|
||||||
storyMessageRecipient.destinationUuid = recipient.destinationUuid;
|
|
||||||
storyMessageRecipient.distributionListIds =
|
|
||||||
recipient.distributionListIds;
|
|
||||||
storyMessageRecipient.isAllowedToReply = recipient.isAllowedToReply;
|
|
||||||
return storyMessageRecipient;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isUpdate) {
|
if (isUpdate) {
|
||||||
|
|
|
@ -166,3 +166,9 @@ export enum ResolvedSendStatus {
|
||||||
Sending = 'Sending',
|
Sending = 'Sending',
|
||||||
Sent = 'Sent',
|
Sent = 'Sent',
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type StoryMessageRecipientsType = Array<{
|
||||||
|
destinationUuid: string;
|
||||||
|
distributionListIds: Array<string>;
|
||||||
|
isAllowedToReply: boolean;
|
||||||
|
}>;
|
||||||
|
|
|
@ -16,11 +16,21 @@ export enum UUIDKind {
|
||||||
|
|
||||||
export const UUID_BYTE_SIZE = 16;
|
export const UUID_BYTE_SIZE = 16;
|
||||||
|
|
||||||
export const isValidUuid = (value: unknown): value is UUIDStringType =>
|
const UUID_REGEXP =
|
||||||
typeof value === 'string' &&
|
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i;
|
||||||
/^[0-9A-F]{8}-[0-9A-F]{4}-4[0-9A-F]{3}-[89AB][0-9A-F]{3}-[0-9A-F]{12}$/i.test(
|
|
||||||
value
|
export const isValidUuid = (value: unknown): value is UUIDStringType => {
|
||||||
);
|
if (typeof value !== 'string') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Zero UUID is a valid uuid.
|
||||||
|
if (value === '00000000-0000-0000-0000-000000000000') {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return UUID_REGEXP.test(value);
|
||||||
|
};
|
||||||
|
|
||||||
export class UUID {
|
export class UUID {
|
||||||
constructor(protected readonly value: string) {
|
constructor(protected readonly value: string) {
|
||||||
|
|
|
@ -2,12 +2,25 @@
|
||||||
// SPDX-License-Identifier: AGPL-3.0-only
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
import { noop } from 'lodash';
|
import { noop } from 'lodash';
|
||||||
|
|
||||||
|
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
|
||||||
import type { StoryDataType } from '../state/ducks/stories';
|
import type { StoryDataType } from '../state/ducks/stories';
|
||||||
|
import * as Errors from '../types/errors';
|
||||||
|
import type { StoryMessageRecipientsType } from '../types/Stories';
|
||||||
|
import * as log from '../logging/log';
|
||||||
import { DAY } from './durations';
|
import { DAY } from './durations';
|
||||||
import { StoryRecipientUpdateEvent } from '../textsecure/messageReceiverEvents';
|
import { StoryRecipientUpdateEvent } from '../textsecure/messageReceiverEvents';
|
||||||
import { getSendOptions } from './getSendOptions';
|
import {
|
||||||
|
conversationJobQueue,
|
||||||
|
conversationQueueJobEnum,
|
||||||
|
} from '../jobs/conversationJobQueue';
|
||||||
import { onStoryRecipientUpdate } from './onStoryRecipientUpdate';
|
import { onStoryRecipientUpdate } from './onStoryRecipientUpdate';
|
||||||
import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage';
|
import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage';
|
||||||
|
import { isGroupV2 } from './whatTypeOfConversation';
|
||||||
|
import { getMessageById } from '../messages/getMessageById';
|
||||||
|
import { strictAssert } from './assert';
|
||||||
|
import { repeat, zipObject } from './iterables';
|
||||||
|
import { isOlderThan } from './timestamp';
|
||||||
|
|
||||||
export async function deleteStoryForEveryone(
|
export async function deleteStoryForEveryone(
|
||||||
stories: ReadonlyArray<StoryDataType>,
|
stories: ReadonlyArray<StoryDataType>,
|
||||||
|
@ -17,8 +30,31 @@ export async function deleteStoryForEveryone(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Group stories are deleted as regular messages.
|
||||||
|
const sourceConversation = window.ConversationController.get(
|
||||||
|
story.conversationId
|
||||||
|
);
|
||||||
|
if (sourceConversation && isGroupV2(sourceConversation.attributes)) {
|
||||||
|
sendDeleteForEveryoneMessage(sourceConversation.attributes, {
|
||||||
|
deleteForEveryoneDuration: DAY,
|
||||||
|
id: story.messageId,
|
||||||
|
timestamp: story.timestamp,
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const logId = `deleteStoryForEveryone(${story.messageId})`;
|
||||||
|
const message = await getMessageById(story.messageId);
|
||||||
|
if (!message) {
|
||||||
|
throw new Error('Story not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isOlderThan(story.timestamp, DAY)) {
|
||||||
|
throw new Error('Cannot send DOE for a story older than one day');
|
||||||
|
}
|
||||||
|
|
||||||
const conversationIds = new Set(Object.keys(story.sendStateByConversationId));
|
const conversationIds = new Set(Object.keys(story.sendStateByConversationId));
|
||||||
const updatedStoryRecipients = new Map<
|
const newStoryRecipients = new Map<
|
||||||
string,
|
string,
|
||||||
{
|
{
|
||||||
distributionListIds: Set<string>;
|
distributionListIds: Set<string>;
|
||||||
|
@ -32,6 +68,30 @@ export async function deleteStoryForEveryone(
|
||||||
// Remove ourselves from the DOE.
|
// Remove ourselves from the DOE.
|
||||||
conversationIds.delete(ourConversation.id);
|
conversationIds.delete(ourConversation.id);
|
||||||
|
|
||||||
|
// `updatedStoryRecipients` is used to build `storyMessageRecipients` for
|
||||||
|
// a sync message. Put all affected destinationUuids early on so that if
|
||||||
|
// there are no other distribution lists for them - we'd still include an
|
||||||
|
// empty list.
|
||||||
|
Object.entries(story.sendStateByConversationId).forEach(
|
||||||
|
([recipientId, sendState]) => {
|
||||||
|
if (recipientId === ourConversation.id) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const destinationUuid =
|
||||||
|
window.ConversationController.get(recipientId)?.get('uuid');
|
||||||
|
|
||||||
|
if (!destinationUuid) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
newStoryRecipients.set(destinationUuid, {
|
||||||
|
distributionListIds: new Set(),
|
||||||
|
isAllowedToReply: sendState.isAllowedToReplyToStory !== false,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
// Find stories that were sent to other distribution lists so that we don't
|
// Find stories that were sent to other distribution lists so that we don't
|
||||||
// send a DOE request to the members of those lists.
|
// send a DOE request to the members of those lists.
|
||||||
stories.forEach(item => {
|
stories.forEach(item => {
|
||||||
|
@ -62,120 +122,95 @@ export async function deleteStoryForEveryone(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const distributionListIds =
|
|
||||||
updatedStoryRecipients.get(destinationUuid)?.distributionListIds ||
|
|
||||||
new Set();
|
|
||||||
|
|
||||||
// These are the remaining distribution list ids that the user has
|
|
||||||
// access to.
|
|
||||||
updatedStoryRecipients.set(destinationUuid, {
|
|
||||||
distributionListIds: item.storyDistributionListId
|
|
||||||
? new Set([...distributionListIds, item.storyDistributionListId])
|
|
||||||
: distributionListIds,
|
|
||||||
isAllowedToReply:
|
|
||||||
sendStateByConversationId[conversationId].isAllowedToReplyToStory !==
|
|
||||||
false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Remove this conversationId so we don't send the DOE to those that
|
// Remove this conversationId so we don't send the DOE to those that
|
||||||
// still have access.
|
// still have access.
|
||||||
conversationIds.delete(conversationId);
|
conversationIds.delete(conversationId);
|
||||||
|
|
||||||
|
// Build remaining distribution list ids that the user still has
|
||||||
|
// access to.
|
||||||
|
if (item.storyDistributionListId === undefined) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Build complete list of new story recipients (not counting ones that
|
||||||
|
// are in the deleted story).
|
||||||
|
let recipient = newStoryRecipients.get(destinationUuid);
|
||||||
|
if (!recipient) {
|
||||||
|
const isAllowedToReply =
|
||||||
|
sendStateByConversationId[conversationId].isAllowedToReplyToStory;
|
||||||
|
recipient = {
|
||||||
|
distributionListIds: new Set(),
|
||||||
|
isAllowedToReply: isAllowedToReply !== false,
|
||||||
|
};
|
||||||
|
|
||||||
|
newStoryRecipients.set(destinationUuid, recipient);
|
||||||
|
}
|
||||||
|
|
||||||
|
recipient.distributionListIds.add(item.storyDistributionListId);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Include the sync message with the updated storyMessageRecipients list
|
||||||
|
const sender = window.textsecure.messaging;
|
||||||
|
strictAssert(sender, 'messaging has to be initialized');
|
||||||
|
|
||||||
|
const newStoryMessageRecipients: StoryMessageRecipientsType = [];
|
||||||
|
|
||||||
|
newStoryRecipients.forEach((recipientData, destinationUuid) => {
|
||||||
|
newStoryMessageRecipients.push({
|
||||||
|
destinationUuid,
|
||||||
|
distributionListIds: Array.from(recipientData.distributionListIds),
|
||||||
|
isAllowedToReply: recipientData.isAllowedToReply,
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
const destinationUuid = ourConversation
|
||||||
|
.getCheckedUuid('deleteStoryForEveryone')
|
||||||
|
.toString();
|
||||||
|
|
||||||
|
log.info(`${logId}: sending DOE to ${conversationIds.size} conversations`);
|
||||||
|
|
||||||
|
message.set({
|
||||||
|
deletedForEveryoneSendStatus: zipObject(conversationIds, repeat(false)),
|
||||||
|
});
|
||||||
|
|
||||||
// Send the DOE
|
// Send the DOE
|
||||||
conversationIds.forEach(cid => {
|
log.info(`${logId}: enqueing DeleteStoryForEveryone`);
|
||||||
// Don't DOE yourself!
|
|
||||||
if (cid === ourConversation.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const conversation = window.ConversationController.get(cid);
|
try {
|
||||||
|
const jobData: ConversationQueueJobData = {
|
||||||
|
type: conversationQueueJobEnum.enum.DeleteStoryForEveryone,
|
||||||
|
conversationId: ourConversation.id,
|
||||||
|
storyId: story.messageId,
|
||||||
|
targetTimestamp: story.timestamp,
|
||||||
|
updatedStoryRecipients: newStoryMessageRecipients,
|
||||||
|
};
|
||||||
|
await conversationJobQueue.add(jobData, async jobToInsert => {
|
||||||
|
log.info(`${logId}: Deleting message with job ${jobToInsert.id}`);
|
||||||
|
|
||||||
if (!conversation) {
|
await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
return;
|
jobToInsert,
|
||||||
}
|
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
|
|
||||||
sendDeleteForEveryoneMessage(conversation.attributes, {
|
|
||||||
deleteForEveryoneDuration: DAY,
|
|
||||||
id: story.messageId,
|
|
||||||
timestamp: story.timestamp,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
// If it's the last story sent to a distribution list we don't have to send
|
|
||||||
// the sync message, but to be consistent let's build up the updated
|
|
||||||
// storyMessageRecipients and send the sync message.
|
|
||||||
if (!updatedStoryRecipients.size) {
|
|
||||||
Object.entries(story.sendStateByConversationId).forEach(
|
|
||||||
([recipientId, sendState]) => {
|
|
||||||
if (recipientId === ourConversation.id) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const destinationUuid =
|
|
||||||
window.ConversationController.get(recipientId)?.get('uuid');
|
|
||||||
|
|
||||||
if (!destinationUuid) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
updatedStoryRecipients.set(destinationUuid, {
|
|
||||||
distributionListIds: new Set(),
|
|
||||||
isAllowedToReply: sendState.isAllowedToReplyToStory !== false,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Send the sync message with the updated storyMessageRecipients list
|
|
||||||
const sender = window.textsecure.messaging;
|
|
||||||
if (sender) {
|
|
||||||
const options = await getSendOptions(ourConversation.attributes, {
|
|
||||||
syncMessage: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
const storyMessageRecipients: Array<{
|
|
||||||
destinationUuid: string;
|
|
||||||
distributionListIds: Array<string>;
|
|
||||||
isAllowedToReply: boolean;
|
|
||||||
}> = [];
|
|
||||||
|
|
||||||
updatedStoryRecipients.forEach((recipientData, destinationUuid) => {
|
|
||||||
storyMessageRecipients.push({
|
|
||||||
destinationUuid,
|
|
||||||
distributionListIds: Array.from(recipientData.distributionListIds),
|
|
||||||
isAllowedToReply: recipientData.isAllowedToReply,
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
} catch (error) {
|
||||||
const destinationUuid = ourConversation.get('uuid');
|
log.error(
|
||||||
|
`${logId}: Failed to queue delete for everyone`,
|
||||||
if (!destinationUuid) {
|
Errors.toLogFormat(error)
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sync message for other devices
|
|
||||||
sender.sendSyncMessage({
|
|
||||||
destination: undefined,
|
|
||||||
destinationUuid,
|
|
||||||
storyMessageRecipients,
|
|
||||||
expirationStartTimestamp: null,
|
|
||||||
isUpdate: true,
|
|
||||||
options,
|
|
||||||
timestamp: story.timestamp,
|
|
||||||
urgent: false,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Sync message for Desktop
|
|
||||||
const ev = new StoryRecipientUpdateEvent(
|
|
||||||
{
|
|
||||||
destinationUuid,
|
|
||||||
timestamp: story.timestamp,
|
|
||||||
storyMessageRecipients,
|
|
||||||
},
|
|
||||||
noop
|
|
||||||
);
|
);
|
||||||
onStoryRecipientUpdate(ev);
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
log.info(`${logId}: emulating sync message event`);
|
||||||
|
|
||||||
|
// Emulate message for Desktop (this will call deleteForEveryone())
|
||||||
|
const ev = new StoryRecipientUpdateEvent(
|
||||||
|
{
|
||||||
|
destinationUuid,
|
||||||
|
timestamp: story.timestamp,
|
||||||
|
storyMessageRecipients: newStoryMessageRecipients,
|
||||||
|
},
|
||||||
|
noop
|
||||||
|
);
|
||||||
|
onStoryRecipientUpdate(ev);
|
||||||
}
|
}
|
||||||
|
|
38
ts/util/distributionListToSendTarget.ts
Normal file
38
ts/util/distributionListToSendTarget.ts
Normal file
|
@ -0,0 +1,38 @@
|
||||||
|
// Copyright 2022 Signal Messenger, LLC
|
||||||
|
// SPDX-License-Identifier: AGPL-3.0-only
|
||||||
|
|
||||||
|
import type { UUIDStringType } from '../types/UUID';
|
||||||
|
import type { SenderKeyInfoType } from '../model-types.d';
|
||||||
|
import dataInterface from '../sql/Client';
|
||||||
|
import type { StoryDistributionType } from '../sql/Interface';
|
||||||
|
import type { SenderKeyTargetType } from './sendToGroup';
|
||||||
|
import { isNotNil } from './isNotNil';
|
||||||
|
|
||||||
|
export function distributionListToSendTarget(
|
||||||
|
distributionList: StoryDistributionType,
|
||||||
|
pendingSendRecipientIds: ReadonlyArray<string>
|
||||||
|
): SenderKeyTargetType {
|
||||||
|
let inMemorySenderKeyInfo = distributionList?.senderKeyInfo;
|
||||||
|
|
||||||
|
const recipientsSet = new Set(pendingSendRecipientIds);
|
||||||
|
|
||||||
|
return {
|
||||||
|
getGroupId: () => undefined,
|
||||||
|
getMembers: () =>
|
||||||
|
pendingSendRecipientIds
|
||||||
|
.map(uuid => window.ConversationController.get(uuid))
|
||||||
|
.filter(isNotNil),
|
||||||
|
hasMember: (uuid: UUIDStringType) => recipientsSet.has(uuid),
|
||||||
|
idForLogging: () => `dl(${distributionList.id})`,
|
||||||
|
isGroupV2: () => true,
|
||||||
|
isValid: () => true,
|
||||||
|
getSenderKeyInfo: () => inMemorySenderKeyInfo,
|
||||||
|
saveSenderKeyInfo: async (senderKeyInfo: SenderKeyInfoType) => {
|
||||||
|
inMemorySenderKeyInfo = senderKeyInfo;
|
||||||
|
await dataInterface.modifyStoryDistribution({
|
||||||
|
...distributionList,
|
||||||
|
senderKeyInfo,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
|
@ -8,10 +8,7 @@ import * as log from '../logging/log';
|
||||||
import { Deletes } from '../messageModifiers/Deletes';
|
import { Deletes } from '../messageModifiers/Deletes';
|
||||||
import { SendStatus } from '../messages/MessageSendState';
|
import { SendStatus } from '../messages/MessageSendState';
|
||||||
import { deleteForEveryone } from './deleteForEveryone';
|
import { deleteForEveryone } from './deleteForEveryone';
|
||||||
import {
|
import { getConversationIdForLogging } from './idForLogging';
|
||||||
getConversationIdForLogging,
|
|
||||||
getMessageIdForLogging,
|
|
||||||
} from './idForLogging';
|
|
||||||
import { isStory } from '../state/selectors/message';
|
import { isStory } from '../state/selectors/message';
|
||||||
import { normalizeUuid } from './normalizeUuid';
|
import { normalizeUuid } from './normalizeUuid';
|
||||||
import { queueUpdateMessage } from './messageBatcher';
|
import { queueUpdateMessage } from './messageBatcher';
|
||||||
|
@ -25,8 +22,10 @@ export async function onStoryRecipientUpdate(
|
||||||
|
|
||||||
const conversation = window.ConversationController.get(destinationUuid);
|
const conversation = window.ConversationController.get(destinationUuid);
|
||||||
|
|
||||||
|
const logId = `onStoryRecipientUpdate(${destinationUuid}, ${timestamp})`;
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
log.info(`onStoryRecipientUpdate no conversation for ${destinationUuid}`);
|
log.info(`${logId}: no conversation`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -37,20 +36,16 @@ export async function onStoryRecipientUpdate(
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!targetConversation) {
|
if (!targetConversation) {
|
||||||
log.info('onStoryRecipientUpdate !targetConversation', {
|
log.info(`${logId}: no targetConversation`);
|
||||||
destinationUuid,
|
|
||||||
timestamp,
|
|
||||||
});
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
targetConversation.queueJob('onStoryRecipientUpdate', async () => {
|
targetConversation.queueJob(logId, async () => {
|
||||||
log.info('onStoryRecipientUpdate updating', timestamp);
|
log.info(`${logId}: updating`);
|
||||||
|
|
||||||
// Build up some maps for fast/easy lookups
|
// Build up some maps for fast/easy lookups
|
||||||
const isAllowedToReply = new Map<string, boolean>();
|
const isAllowedToReply = new Map<string, boolean>();
|
||||||
const conversationIdToDistributionListIds = new Map<string, Set<string>>();
|
const distributionListIdToConversationIds = new Map<string, Set<string>>();
|
||||||
data.storyMessageRecipients.forEach(item => {
|
data.storyMessageRecipients.forEach(item => {
|
||||||
const convo = window.ConversationController.get(item.destinationUuid);
|
const convo = window.ConversationController.get(item.destinationUuid);
|
||||||
|
|
||||||
|
@ -58,14 +53,16 @@ export async function onStoryRecipientUpdate(
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
conversationIdToDistributionListIds.set(
|
for (const rawUuid of item.distributionListIds) {
|
||||||
convo.id,
|
const uuid = normalizeUuid(rawUuid, `${logId}.distributionListId`);
|
||||||
new Set(
|
|
||||||
item.distributionListIds.map(uuid =>
|
const existing = distributionListIdToConversationIds.get(uuid);
|
||||||
normalizeUuid(uuid, 'onStoryRecipientUpdate.distributionListId')
|
if (existing === undefined) {
|
||||||
)
|
distributionListIdToConversationIds.set(uuid, new Set([convo.id]));
|
||||||
)
|
} else {
|
||||||
);
|
existing.add(convo.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
isAllowedToReply.set(convo.id, item.isAllowedToReply !== false);
|
isAllowedToReply.set(convo.id, item.isAllowedToReply !== false);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
@ -87,55 +84,60 @@ export async function onStoryRecipientUpdate(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const newConversationIds =
|
||||||
|
distributionListIdToConversationIds.get(storyDistributionListId) ??
|
||||||
|
new Set();
|
||||||
|
|
||||||
const nextSendStateByConversationId = {
|
const nextSendStateByConversationId = {
|
||||||
...sendStateByConversationId,
|
...sendStateByConversationId,
|
||||||
};
|
};
|
||||||
|
|
||||||
conversationIdToDistributionListIds.forEach(
|
// Find conversation ids present in the local send state, but missing
|
||||||
(distributionListIds, conversationId) => {
|
// in the remote state, and remove them from the local state.
|
||||||
const hasDistributionListId = distributionListIds.has(
|
for (const oldId of Object.keys(sendStateByConversationId)) {
|
||||||
storyDistributionListId
|
if (!newConversationIds.has(oldId)) {
|
||||||
);
|
const recipient = window.ConversationController.get(oldId);
|
||||||
|
|
||||||
const recipient = window.ConversationController.get(conversationId);
|
const recipientLogId = recipient
|
||||||
const conversationIdForLogging = recipient
|
|
||||||
? getConversationIdForLogging(recipient.attributes)
|
? getConversationIdForLogging(recipient.attributes)
|
||||||
: conversationId;
|
: oldId;
|
||||||
|
|
||||||
if (
|
log.info(`${logId}: removing`, {
|
||||||
hasDistributionListId &&
|
recipient: recipientLogId,
|
||||||
!sendStateByConversationId[conversationId]
|
messageId: item.id,
|
||||||
) {
|
storyDistributionListId,
|
||||||
log.info('onStoryRecipientUpdate adding', {
|
});
|
||||||
conversationId: conversationIdForLogging,
|
delete nextSendStateByConversationId[oldId];
|
||||||
messageId: getMessageIdForLogging(item),
|
|
||||||
storyDistributionListId,
|
|
||||||
});
|
|
||||||
nextSendStateByConversationId[conversationId] = {
|
|
||||||
isAllowedToReplyToStory: Boolean(
|
|
||||||
isAllowedToReply.get(conversationId)
|
|
||||||
),
|
|
||||||
status: SendStatus.Sent,
|
|
||||||
updatedAt: now,
|
|
||||||
};
|
|
||||||
} else if (
|
|
||||||
sendStateByConversationId[conversationId] &&
|
|
||||||
!hasDistributionListId
|
|
||||||
) {
|
|
||||||
log.info('onStoryRecipientUpdate removing', {
|
|
||||||
conversationId: conversationIdForLogging,
|
|
||||||
messageId: getMessageIdForLogging(item),
|
|
||||||
storyDistributionListId,
|
|
||||||
});
|
|
||||||
delete nextSendStateByConversationId[conversationId];
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
);
|
}
|
||||||
|
|
||||||
|
// Find conversation ids present in the remote send state, but missing in
|
||||||
|
// the local send state, and add them to the local state.
|
||||||
|
for (const newId of newConversationIds) {
|
||||||
|
if (sendStateByConversationId[newId] === undefined) {
|
||||||
|
const recipient = window.ConversationController.get(newId);
|
||||||
|
|
||||||
|
const recipientLogId = recipient
|
||||||
|
? getConversationIdForLogging(recipient.attributes)
|
||||||
|
: newId;
|
||||||
|
|
||||||
|
log.info(`${logId}: adding`, {
|
||||||
|
recipient: recipientLogId,
|
||||||
|
messageId: item.id,
|
||||||
|
storyDistributionListId,
|
||||||
|
});
|
||||||
|
nextSendStateByConversationId[newId] = {
|
||||||
|
isAllowedToReplyToStory: Boolean(isAllowedToReply.get(newId)),
|
||||||
|
status: SendStatus.Sent,
|
||||||
|
updatedAt: now,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (isEqual(sendStateByConversationId, nextSendStateByConversationId)) {
|
if (isEqual(sendStateByConversationId, nextSendStateByConversationId)) {
|
||||||
log.info(
|
log.info(`${logId}: sendStateByConversationId does not need update`, {
|
||||||
'onStoryRecipientUpdate: sendStateByConversationId does not need update'
|
messageId: item.id,
|
||||||
);
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -150,8 +152,8 @@ export async function onStoryRecipientUpdate(
|
||||||
(sendStateConversationIds.size === 1 &&
|
(sendStateConversationIds.size === 1 &&
|
||||||
sendStateConversationIds.has(ourConversationId))
|
sendStateConversationIds.has(ourConversationId))
|
||||||
) {
|
) {
|
||||||
log.info('onStoryRecipientUpdate DOE', {
|
log.info(`${logId} DOE`, {
|
||||||
messageId: getMessageIdForLogging(item),
|
messageId: item.id,
|
||||||
storyDistributionListId,
|
storyDistributionListId,
|
||||||
});
|
});
|
||||||
const delAttributes: DeleteAttributesType = {
|
const delAttributes: DeleteAttributesType = {
|
||||||
|
|
|
@ -40,8 +40,7 @@ export async function sendDeleteForEveryoneMessage(
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw new Error('sendDeleteForEveryoneMessage: Cannot find message!');
|
throw new Error('sendDeleteForEveryoneMessage: Cannot find message!');
|
||||||
}
|
}
|
||||||
const messageModel = window.MessageController.register(messageId, message);
|
const idForLogging = getMessageIdForLogging(message.attributes);
|
||||||
const idForLogging = getMessageIdForLogging(messageModel.attributes);
|
|
||||||
|
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
const maxDuration = deleteForEveryoneDuration || THREE_HOURS;
|
const maxDuration = deleteForEveryoneDuration || THREE_HOURS;
|
||||||
|
@ -49,7 +48,7 @@ export async function sendDeleteForEveryoneMessage(
|
||||||
throw new Error(`Cannot send DOE for a message older than ${maxDuration}`);
|
throw new Error(`Cannot send DOE for a message older than ${maxDuration}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
messageModel.set({
|
message.set({
|
||||||
deletedForEveryoneSendStatus: zipObject(
|
deletedForEveryoneSendStatus: zipObject(
|
||||||
getRecipientConversationIds(conversationAttributes),
|
getRecipientConversationIds(conversationAttributes),
|
||||||
repeat(false)
|
repeat(false)
|
||||||
|
@ -79,7 +78,7 @@ export async function sendDeleteForEveryoneMessage(
|
||||||
`sendDeleteForEveryoneMessage: Deleting message ${idForLogging} ` +
|
`sendDeleteForEveryoneMessage: Deleting message ${idForLogging} ` +
|
||||||
`in conversation ${conversationIdForLogging} with job ${jobToInsert.id}`
|
`in conversation ${conversationIdForLogging} with job ${jobToInsert.id}`
|
||||||
);
|
);
|
||||||
await window.Signal.Data.saveMessage(messageModel.attributes, {
|
await window.Signal.Data.saveMessage(message.attributes, {
|
||||||
jobToInsert,
|
jobToInsert,
|
||||||
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
ourUuid: window.textsecure.storage.user.getCheckedUuid().toString(),
|
||||||
});
|
});
|
||||||
|
@ -97,5 +96,5 @@ export async function sendDeleteForEveryoneMessage(
|
||||||
serverTimestamp: Date.now(),
|
serverTimestamp: Date.now(),
|
||||||
fromId: window.ConversationController.getOurConversationIdOrThrow(),
|
fromId: window.ConversationController.getOurConversationIdOrThrow(),
|
||||||
});
|
});
|
||||||
await deleteForEveryone(messageModel, deleteModel);
|
await deleteForEveryone(message, deleteModel);
|
||||||
}
|
}
|
||||||
|
|
|
@ -15,7 +15,7 @@ import { areAllErrorsUnregistered } from '../jobs/helpers/areAllErrorsUnregister
|
||||||
|
|
||||||
export async function wrapWithSyncMessageSend({
|
export async function wrapWithSyncMessageSend({
|
||||||
conversation,
|
conversation,
|
||||||
logId,
|
logId: parentLogId,
|
||||||
messageIds,
|
messageIds,
|
||||||
send,
|
send,
|
||||||
sendType,
|
sendType,
|
||||||
|
@ -28,11 +28,10 @@ export async function wrapWithSyncMessageSend({
|
||||||
sendType: SendTypesType;
|
sendType: SendTypesType;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
|
const logId = `wrapWithSyncMessageSend(${parentLogId}, ${timestamp})`;
|
||||||
const sender = window.textsecure.messaging;
|
const sender = window.textsecure.messaging;
|
||||||
if (!sender) {
|
if (!sender) {
|
||||||
throw new Error(
|
throw new Error(`${logId}: textsecure.messaging is not available!`);
|
||||||
`wrapWithSyncMessageSend/${logId}: textsecure.messaging is not available!`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let response: CallbackResultType | undefined;
|
let response: CallbackResultType | undefined;
|
||||||
|
@ -52,17 +51,13 @@ export async function wrapWithSyncMessageSend({
|
||||||
if (thrown instanceof Error) {
|
if (thrown instanceof Error) {
|
||||||
error = thrown;
|
error = thrown;
|
||||||
} else {
|
} else {
|
||||||
log.error(
|
log.error(`${logId}: Thrown value was not an Error, returning early`);
|
||||||
`wrapWithSyncMessageSend/${logId}: Thrown value was not an Error, returning early`
|
|
||||||
);
|
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!response && !error) {
|
if (!response && !error) {
|
||||||
throw new Error(
|
throw new Error(`${logId}: message send didn't return result or error!`);
|
||||||
`wrapWithSyncMessageSend/${logId}: message send didn't return result or error!`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const dataMessage =
|
const dataMessage =
|
||||||
|
@ -71,11 +66,9 @@ export async function wrapWithSyncMessageSend({
|
||||||
|
|
||||||
if (didSuccessfullySendOne) {
|
if (didSuccessfullySendOne) {
|
||||||
if (!dataMessage) {
|
if (!dataMessage) {
|
||||||
log.error(
|
log.error(`${logId}: dataMessage was not returned by send!`);
|
||||||
`wrapWithSyncMessageSend/${logId}: dataMessage was not returned by send!`
|
|
||||||
);
|
|
||||||
} else {
|
} else {
|
||||||
log.info(`wrapWithSyncMessageSend/${logId}: Sending sync message...`);
|
log.info(`${logId}: Sending sync message... `);
|
||||||
const ourConversation =
|
const ourConversation =
|
||||||
window.ConversationController.getOurConversationOrThrow();
|
window.ConversationController.getOurConversationOrThrow();
|
||||||
const options = await getSendOptions(ourConversation.attributes, {
|
const options = await getSendOptions(ourConversation.attributes, {
|
||||||
|
@ -99,7 +92,8 @@ export async function wrapWithSyncMessageSend({
|
||||||
if (error instanceof Error) {
|
if (error instanceof Error) {
|
||||||
if (areAllErrorsUnregistered(conversation.attributes, error)) {
|
if (areAllErrorsUnregistered(conversation.attributes, error)) {
|
||||||
log.info(
|
log.info(
|
||||||
`wrapWithSyncMessageSend/${logId}: Group send failures were all UnregisteredUserError, returning succcessfully.`
|
`${logId}: Group send failures were all UnregisteredUserError, ` +
|
||||||
|
'returning succcessfully.'
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue