Retry outbound "normal" messages for up to a day

This commit is contained in:
Evan Hahn 2021-08-31 15:58:39 -05:00 committed by GitHub
parent 62cf51c060
commit a85dd1be36
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
30 changed files with 1414 additions and 603 deletions

View file

@ -29,6 +29,7 @@ import {
CustomColorType,
} from '../types/Colors';
import { MessageModel } from './messages';
import { strictAssert } from '../util/assert';
import { isMuted } from '../util/isMuted';
import { isConversationSMSOnly } from '../util/isConversationSMSOnly';
import { isConversationUnregistered } from '../util/isConversationUnregistered';
@ -82,6 +83,7 @@ import {
isTapToView,
getMessagePropStatus,
} from '../state/selectors/message';
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
import { Deletes } from '../messageModifiers/Deletes';
import { Reactions, ReactionModel } from '../messageModifiers/Reactions';
import { isAnnouncementGroupReady } from '../util/isAnnouncementGroupReady';
@ -119,11 +121,6 @@ const ATTRIBUTES_THAT_DONT_INVALIDATE_PROPS_CACHE = new Set([
'profileLastFetchedAt',
]);
type CustomError = Error & {
identifier?: string;
number?: string;
};
type CachedIdenticon = {
readonly url: string;
readonly content: string;
@ -3111,6 +3108,10 @@ export class ConversationModel extends window.Backbone
);
}
getRecipientConversationIds(): Set<string> {
return new Set(map(this.getMembers(), conversation => conversation.id));
}
async getQuoteAttachment(
attachments?: Array<WhatIsThis>,
preview?: Array<WhatIsThis>,
@ -3261,7 +3262,7 @@ export class ConversationModel extends window.Backbone
},
};
this.sendMessage(undefined, [], undefined, [], sticker);
this.enqueueMessageForSend(undefined, [], undefined, [], sticker);
window.reduxActions.stickers.useSticker(packId, stickerId);
}
@ -3577,7 +3578,7 @@ export class ConversationModel extends window.Backbone
);
}
sendMessage(
async enqueueMessageForSend(
body: string | undefined,
attachments: Array<AttachmentType>,
quote?: QuotedMessageType,
@ -3593,7 +3594,7 @@ export class ConversationModel extends window.Backbone
sendHQImages?: boolean;
timestamp?: number;
} = {}
): void {
): Promise<void> {
if (this.isGroupV1AndDisabled()) {
return;
}
@ -3614,223 +3615,134 @@ export class ConversationModel extends window.Backbone
const destination = this.getSendTarget()!;
const recipients = this.getRecipients();
if (timestamp) {
window.log.info(`sendMessage: Queueing send with timestamp ${timestamp}`);
}
this.queueJob('sendMessage', async () => {
const now = timestamp || Date.now();
const now = timestamp || Date.now();
await this.maybeApplyUniversalTimer(false);
await this.maybeApplyUniversalTimer(false);
const expireTimer = this.get('expireTimer');
const expireTimer = this.get('expireTimer');
window.log.info(
'Sending message to conversation',
this.idForLogging(),
'with timestamp',
now
);
window.log.info(
'Sending message to conversation',
this.idForLogging(),
'with timestamp',
now
);
const recipientMaybeConversations = map(recipients, identifier =>
window.ConversationController.get(identifier)
);
const recipientConversations = filter(
recipientMaybeConversations,
isNotNil
);
const recipientConversationIds = concat(
map(recipientConversations, c => c.id),
[window.ConversationController.getOurConversationIdOrThrow()]
);
const recipientMaybeConversations = map(recipients, identifier =>
window.ConversationController.get(identifier)
);
const recipientConversations = filter(
recipientMaybeConversations,
isNotNil
);
const recipientConversationIds = concat(
map(recipientConversations, c => c.id),
[window.ConversationController.getOurConversationIdOrThrow()]
);
// Here we move attachments to disk
const messageWithSchema = await upgradeMessageSchema({
timestamp: now,
type: 'outgoing',
body,
conversationId: this.id,
quote,
preview,
attachments,
sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now,
expireTimer,
recipients,
sticker,
bodyRanges: mentions,
sendHQImages,
sendStateByConversationId: zipObject(
recipientConversationIds,
repeat({
status: SendStatus.Pending,
updatedAt: now,
})
),
});
if (isDirectConversation(this.attributes)) {
messageWithSchema.destination = destination;
}
const attributes: MessageAttributesType = {
...messageWithSchema,
id: window.getGuid(),
};
const model = new window.Whisper.Message(attributes);
const message = window.MessageController.register(model.id, model);
const dbStart = Date.now();
await window.Signal.Data.saveMessage(message.attributes, {
forceSave: true,
});
const dbDuration = Date.now() - dbStart;
if (dbDuration > SEND_REPORTING_THRESHOLD_MS) {
window.log.info(
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
`db save took ${dbDuration}ms`
);
}
const renderStart = Date.now();
this.addSingleMessage(model);
if (sticker) {
await addStickerPackReference(model.id, sticker.packId);
}
const messageId = message.id;
const draftProperties = dontClearDraft
? {}
: {
draft: null,
draftTimestamp: null,
lastMessage: model.getNotificationText(),
lastMessageStatus: 'sending' as const,
};
this.set({
...draftProperties,
active_at: now,
timestamp: now,
isArchived: false,
});
this.incrementSentMessageCount({ save: false });
const renderDuration = Date.now() - renderStart;
if (renderDuration > SEND_REPORTING_THRESHOLD_MS) {
window.log.info(
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
`render save took ${renderDuration}ms`
);
}
window.Signal.Data.updateConversation(this.attributes);
// We're offline!
if (!window.textsecure.messaging) {
const errors = map(recipientConversationIds, conversationId => {
const error = new Error('Network is not available') as CustomError;
error.name = 'SendMessageNetworkError';
error.identifier = conversationId;
return error;
});
await message.saveErrors([...errors]);
return null;
}
const attachmentsWithData = await Promise.all(
messageWithSchema.attachments?.map(loadAttachmentData) ?? []
);
const {
body: messageBody,
attachments: finalAttachments,
} = window.Whisper.Message.getLongMessageAttachment({
body,
attachments: attachmentsWithData,
now,
});
let profileKey: ArrayBuffer | undefined;
if (this.get('profileSharing')) {
profileKey = await ourProfileKeyService.get();
}
// Special-case the self-send case - we send only a sync message
if (isMe(this.attributes)) {
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments: finalAttachments,
body: messageBody,
// deletedForEveryoneTimestamp
expireTimer,
preview,
profileKey,
quote,
// reaction
recipients: [destination],
sticker,
timestamp: now,
});
return message.sendSyncMessageOnly(dataMessage);
}
const conversationType = this.get('type');
const options = await getSendOptions(this.attributes);
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
let promise;
if (conversationType === Message.GROUP) {
promise = window.Signal.Util.sendToGroup({
groupSendOptions: {
attachments: finalAttachments,
expireTimer,
groupV1: this.getGroupV1Info(),
groupV2: this.getGroupV2Info(),
messageText: messageBody,
preview,
profileKey,
quote,
sticker,
timestamp: now,
mentions,
},
conversation: this,
contentHint: ContentHint.RESENDABLE,
messageId,
sendOptions: options,
sendType: 'message',
});
} else {
promise = window.textsecure.messaging.sendMessageToIdentifier({
identifier: destination,
messageText: messageBody,
attachments: finalAttachments,
quote,
preview,
sticker,
reaction: null,
deletedForEveryoneTimestamp: undefined,
timestamp: now,
expireTimer,
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
profileKey,
options,
});
}
return message.send(
handleMessageSend(promise, {
messageIds: [messageId],
sendType: 'message',
// Here we move attachments to disk
const messageWithSchema = await upgradeMessageSchema({
timestamp: now,
type: 'outgoing',
body,
conversationId: this.id,
quote,
preview,
attachments,
sent_at: now,
received_at: window.Signal.Util.incrementMessageCounter(),
received_at_ms: now,
expireTimer,
recipients,
sticker,
bodyRanges: mentions,
sendHQImages,
sendStateByConversationId: zipObject(
recipientConversationIds,
repeat({
status: SendStatus.Pending,
updatedAt: now,
})
);
),
});
if (isDirectConversation(this.attributes)) {
messageWithSchema.destination = destination;
}
const attributes: MessageAttributesType = {
...messageWithSchema,
id: window.getGuid(),
};
const model = new window.Whisper.Message(attributes);
const message = window.MessageController.register(model.id, model);
message.cachedOutgoingPreviewData = preview;
message.cachedOutgoingQuoteData = quote;
message.cachedOutgoingStickerData = sticker;
const dbStart = Date.now();
strictAssert(
typeof message.attributes.timestamp === 'number',
'Expected a timestamp'
);
await normalMessageSendJobQueue.add(
{ messageId: message.id, conversationId: this.id },
async jobToInsert => {
window.log.info(
`enqueueMessageForSend: saving message ${message.id} and job ${jobToInsert.id}`
);
await window.Signal.Data.saveMessage(message.attributes, {
jobToInsert,
forceSave: true,
});
}
);
const dbDuration = Date.now() - dbStart;
if (dbDuration > SEND_REPORTING_THRESHOLD_MS) {
window.log.info(
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
`db save took ${dbDuration}ms`
);
}
const renderStart = Date.now();
this.addSingleMessage(model);
if (sticker) {
await addStickerPackReference(model.id, sticker.packId);
}
const draftProperties = dontClearDraft
? {}
: {
draft: null,
draftTimestamp: null,
lastMessage: model.getNotificationText(),
lastMessageStatus: 'sending' as const,
};
this.set({
...draftProperties,
active_at: now,
timestamp: now,
isArchived: false,
});
this.incrementSentMessageCount({ save: false });
const renderDuration = Date.now() - renderStart;
if (renderDuration > SEND_REPORTING_THRESHOLD_MS) {
window.log.info(
`ConversationModel(${this.idForLogging()}.sendMessage(${now}): ` +
`render save took ${renderDuration}ms`
);
}
window.Signal.Data.updateConversation(this.attributes);
}
// Is this someone who is a contact, or are we sharing our profile with them?

View file

@ -1,7 +1,7 @@
// Copyright 2020-2021 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isEmpty, isEqual, noop, omit, union } from 'lodash';
import { isEmpty, isEqual, mapValues, noop, omit, union } from 'lodash';
import {
CustomError,
GroupV1Update,
@ -43,7 +43,6 @@ import {
import * as Stickers from '../types/Stickers';
import { AttachmentType, isImage, isVideo } from '../types/Attachment';
import { IMAGE_WEBP, stringToMIMEType } from '../types/MIME';
import { ourProfileKeyService } from '../services/ourProfileKey';
import { ReadStatus } from '../messages/MessageReadStatus';
import {
SendActionType,
@ -112,6 +111,8 @@ import { ViewOnceOpenSyncs } from '../messageModifiers/ViewOnceOpenSyncs';
import * as AttachmentDownloads from '../messageModifiers/AttachmentDownloads';
import * as LinkPreview from '../types/LinkPreview';
import { SignalService as Proto } from '../protobuf';
import { normalMessageSendJobQueue } from '../jobs/normalMessageSendJobQueue';
import type { PreviewType as OutgoingPreviewType } from '../textsecure/SendMessage';
/* eslint-disable camelcase */
/* eslint-disable more/no-then */
@ -134,10 +135,6 @@ const {
} = window.Signal.Types;
const {
deleteExternalMessageFiles,
loadAttachmentData,
loadQuoteData,
loadPreviewData,
loadStickerData,
upgradeMessageSchema,
} = window.Signal.Migrations;
const { getTextWithMentions, GoogleChrome } = window.Signal.Util;
@ -190,6 +187,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
syncPromise?: Promise<CallbackResultType | void>;
cachedOutgoingPreviewData?: Array<OutgoingPreviewType>;
cachedOutgoingQuoteData?: WhatIsThis;
cachedOutgoingStickerData?: WhatIsThis;
initialize(attributes: unknown): void {
if (_.isObject(attributes)) {
this.set(
@ -1200,42 +1203,27 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return window.ConversationController.getOrCreate(source, 'private');
}
// Send infrastructure
// One caller today: event handler for the 'Retry Send' entry in triple-dot menu
async retrySend(): Promise<string | null | void | Array<void>> {
if (!window.textsecure.messaging) {
window.log.error('retrySend: Cannot retry since we are offline!');
return null;
}
async retrySend(): Promise<void> {
const retryOptions = this.get('retryOptions');
this.set({ errors: undefined, retryOptions: undefined });
if (retryOptions) {
if (!window.textsecure.messaging) {
window.log.error('retrySend: Cannot retry since we are offline!');
return;
}
this.unset('errors');
this.unset('retryOptions');
return this.sendUtilityMessageWithRetry(retryOptions);
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conversation = this.getConversation()!;
const currentRecipients = new Set<string>(
conversation
.getRecipients()
.map(identifier =>
window.ConversationController.getConversationId(identifier)
)
.filter(isNotNil)
);
const profileKey = conversation.get('profileSharing')
? await ourProfileKeyService.get()
: undefined;
const currentConversationRecipients = conversation.getRecipientConversationIds();
// Determine retry recipients and get their most up-to-date addressing information
const oldSendStateByConversationId =
this.get('sendStateByConversationId') || {};
const recipients: Array<string> = [];
const newSendStateByConversationId = { ...oldSendStateByConversationId };
for (const [conversationId, sendState] of Object.entries(
oldSendStateByConversationId
@ -1244,15 +1232,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
continue;
}
const isStillInConversation = currentRecipients.has(conversationId);
if (!isStillInConversation) {
continue;
}
const recipient = window.ConversationController.get(
conversationId
)?.getSendTarget();
if (!recipient) {
const recipient = window.ConversationController.get(conversationId);
if (
!recipient ||
(!currentConversationRecipients.has(conversationId) &&
!isMe(recipient.attributes))
) {
continue;
}
@ -1263,133 +1248,15 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
updatedAt: Date.now(),
}
);
recipients.push(recipient);
}
this.set('sendStateByConversationId', newSendStateByConversationId);
await window.Signal.Data.saveMessage(this.attributes);
if (!recipients.length) {
window.log.warn('retrySend: Nobody to send to!');
return undefined;
}
const attachmentsWithData = await Promise.all(
(this.get('attachments') || []).map(loadAttachmentData)
);
const {
body,
attachments,
} = window.Whisper.Message.getLongMessageAttachment({
body: this.get('body'),
attachments: attachmentsWithData,
now: this.get('sent_at'),
});
const quoteWithData = await loadQuoteData(this.get('quote'));
const previewWithData = await loadPreviewData(this.get('preview'));
const stickerWithData = await loadStickerData(this.get('sticker'));
const ourNumber = window.textsecure.storage.user.getNumber();
// Special-case the self-send case - we send only a sync message
if (
recipients.length === 1 &&
(recipients[0] === ourNumber || recipients[0] === this.OUR_UUID)
) {
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments,
body,
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
expireTimer: this.get('expireTimer'),
// flags
mentions: this.get('bodyRanges'),
preview: previewWithData,
profileKey,
quote: quoteWithData,
reaction: null,
recipients,
sticker: stickerWithData,
timestamp: this.get('sent_at'),
});
return this.sendSyncMessageOnly(dataMessage);
}
let promise;
const options = await getSendOptions(conversation.attributes);
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
if (isDirectConversation(conversation.attributes)) {
const [identifier] = recipients;
promise = window.textsecure.messaging.sendMessageToIdentifier({
identifier,
messageText: body,
attachments,
quote: quoteWithData,
preview: previewWithData,
sticker: stickerWithData,
reaction: null,
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
timestamp: this.get('sent_at'),
expireTimer: this.get('expireTimer'),
contentHint: ContentHint.RESENDABLE,
groupId: undefined,
profileKey,
options,
});
} else {
const initialGroupV2 = conversation.getGroupV2Info();
const groupId = conversation.get('groupId');
if (!groupId) {
throw new Error("retrySend: Conversation didn't have groupId");
await normalMessageSendJobQueue.add(
{ messageId: this.id, conversationId: conversation.id },
async jobToInsert => {
await window.Signal.Data.saveMessage(this.attributes, { jobToInsert });
}
const groupV2 = initialGroupV2
? {
...initialGroupV2,
members: recipients,
}
: undefined;
const groupV1 = groupV2
? undefined
: {
id: groupId,
members: recipients,
};
promise = window.Signal.Util.sendToGroup({
groupSendOptions: {
messageText: body,
timestamp: this.get('sent_at'),
attachments,
quote: quoteWithData,
preview: previewWithData,
sticker: stickerWithData,
expireTimer: this.get('expireTimer'),
mentions: this.get('bodyRanges'),
profileKey,
groupV2,
groupV1,
},
conversation,
contentHint: ContentHint.RESENDABLE,
// Important to ensure that we don't consider this recipient list to be the
// entire member list.
isPartialSend: true,
messageId: this.id,
sendOptions: options,
sendType: 'messageRetry',
});
}
return this.send(
handleMessageSend(promise, {
messageIds: [this.id],
sendType: 'messageRetry',
})
);
}
@ -1414,118 +1281,20 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
return isEmpty(withoutMe) || someSendStatus(withoutMe, isSent);
}
// Called when the user ran into an error with a specific user, wants to send to them
// One caller today: ConversationView.forceSend()
async resend(identifier: string): Promise<void | null | Array<void>> {
const error = this.removeOutgoingErrors(identifier);
if (!error) {
window.log.warn(
'resend: requested number was not present in errors. continuing.'
);
}
if (this.isErased()) {
window.log.warn('resend: message is erased; refusing to resend');
return null;
}
const profileKey = undefined;
const attachmentsWithData = await Promise.all(
(this.get('attachments') || []).map(loadAttachmentData)
);
const {
body,
attachments,
} = window.Whisper.Message.getLongMessageAttachment({
body: this.get('body'),
attachments: attachmentsWithData,
now: this.get('sent_at'),
});
const quoteWithData = await loadQuoteData(this.get('quote'));
const previewWithData = await loadPreviewData(this.get('preview'));
const stickerWithData = await loadStickerData(this.get('sticker'));
const ourNumber = window.textsecure.storage.user.getNumber();
// Special-case the self-send case - we send only a sync message
if (identifier === ourNumber || identifier === this.OUR_UUID) {
const dataMessage = await window.textsecure.messaging.getDataMessage({
attachments,
body,
deletedForEveryoneTimestamp: this.get('deletedForEveryoneTimestamp'),
expireTimer: this.get('expireTimer'),
mentions: this.get('bodyRanges'),
preview: previewWithData,
profileKey,
quote: quoteWithData,
reaction: null,
recipients: [identifier],
sticker: stickerWithData,
timestamp: this.get('sent_at'),
});
return this.sendSyncMessageOnly(dataMessage);
}
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
const parentConversation = this.getConversation();
const groupId = parentConversation?.get('groupId');
const recipientConversation = window.ConversationController.get(identifier);
const sendOptions = recipientConversation
? await getSendOptions(recipientConversation.attributes)
: undefined;
const group =
groupId && isGroupV1(parentConversation?.attributes)
? {
id: groupId,
type: Proto.GroupContext.Type.DELIVER,
}
: undefined;
const timestamp = this.get('sent_at');
const contentMessage = await window.textsecure.messaging.getContentMessage({
attachments,
body,
expireTimer: this.get('expireTimer'),
group,
groupV2: parentConversation?.getGroupV2Info(),
preview: previewWithData,
quote: quoteWithData,
mentions: this.get('bodyRanges'),
recipients: [identifier],
sticker: stickerWithData,
timestamp,
});
if (parentConversation) {
const senderKeyInfo = parentConversation.get('senderKeyInfo');
if (senderKeyInfo && senderKeyInfo.distributionId) {
const senderKeyDistributionMessage = await window.textsecure.messaging.getSenderKeyDistributionMessage(
senderKeyInfo.distributionId
);
contentMessage.senderKeyDistributionMessage = senderKeyDistributionMessage.serialize();
}
}
const promise = window.textsecure.messaging.sendMessageProtoAndWait({
timestamp,
recipients: [identifier],
proto: contentMessage,
contentHint: ContentHint.RESENDABLE,
groupId:
groupId && isGroupV2(parentConversation?.attributes)
? groupId
: undefined,
options: sendOptions,
});
return this.send(
handleMessageSend(promise, {
messageIds: [this.id],
sendType: 'messageRetry',
})
/**
* Change any Pending send state to Failed. Note that this will not mark successful
* sends failed.
*/
public markFailed(): void {
const now = Date.now();
this.set(
'sendStateByConversationId',
mapValues(this.get('sendStateByConversationId') || {}, sendState =>
sendStateReducer(sendState, {
type: SendActionType.Failed,
updatedAt: now,
})
)
);
}
@ -1552,7 +1321,8 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
}
async send(
promise: Promise<CallbackResultType | void | null>
promise: Promise<CallbackResultType | void | null>,
saveErrors?: (errors: Array<Error>) => void
): Promise<void | Array<void>> {
const updateLeftPane =
this.getConversation()?.debouncedUpdateLastMessage || noop;
@ -1655,7 +1425,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
window.ConversationController.get(error.identifier) ||
window.ConversationController.get(error.number);
if (conversation) {
if (conversation && !saveErrors) {
const previousSendState = getOwn(
sendStateByConversationId,
conversation.id
@ -1719,8 +1489,12 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
attributesToUpdate.errors = [];
this.set(attributesToUpdate);
// We skip save because we'll save in the next step.
this.saveErrors(errorsToSave, { skipSave: true });
if (saveErrors) {
saveErrors(errorsToSave);
} else {
// We skip save because we'll save in the next step.
this.saveErrors(errorsToSave, { skipSave: true });
}
if (!this.doNotSave) {
await window.Signal.Data.saveMessage(this.attributes);
@ -1734,6 +1508,14 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
await Promise.all(promises);
const isTotalSuccess: boolean =
result.success && !this.get('errors')?.length;
if (isTotalSuccess) {
delete this.cachedOutgoingPreviewData;
delete this.cachedOutgoingQuoteData;
delete this.cachedOutgoingStickerData;
}
updateLeftPane();
}
@ -1779,7 +1561,10 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
throw new Error(`Unsupported retriable type: ${options.type}`);
}
async sendSyncMessageOnly(dataMessage: ArrayBuffer): Promise<void> {
async sendSyncMessageOnly(
dataMessage: ArrayBuffer,
saveErrors?: (errors: Array<Error>) => void
): Promise<void> {
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conv = this.getConversation()!;
this.set({ dataMessage });
@ -1800,9 +1585,16 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
: undefined,
});
} catch (result) {
const errors = (result && result.errors) || [new Error('Unknown error')];
// We don't save because we're about to save below.
this.saveErrors(errors, { skipSave: true });
const resultErrors = result?.errors;
const errors = Array.isArray(resultErrors)
? resultErrors
: [new Error('Unknown error')];
if (saveErrors) {
saveErrors(errors);
} else {
// We don't save because we're about to save below.
this.saveErrors(errors, { skipSave: true });
}
} finally {
await window.Signal.Data.saveMessage(this.attributes);