Use minimal replacement class for MessageModel

This commit is contained in:
Scott Nonnenberg 2025-01-10 08:18:32 +10:00 committed by GitHub
parent 6b00cf756e
commit f846678b90
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
95 changed files with 3919 additions and 4457 deletions

View file

@ -15,6 +15,7 @@ import { migrateAllMessages } from './messages/migrateMessageData';
import { SECOND } from './util/durations';
import { isSignalRoute } from './util/signalRoutes';
import { strictAssert } from './util/assert';
import { MessageModel } from './models/messages';
type ResolveType = (data: unknown) => void;
@ -142,12 +143,7 @@ export function getCI({ deviceName }: GetCIOptionsType): CIType {
[sentAt]
);
return messages.map(
m =>
window.MessageCache.__DEPRECATED$register(
m.id,
m,
'CI.getMessagesBySentAt'
).attributes
m => window.MessageCache.register(new MessageModel(m)).attributes
);
}

View file

@ -16,6 +16,7 @@ import { stats } from '../util/benchmark/stats';
import type { StatsType } from '../util/benchmark/stats';
import type { MessageAttributesType } from '../model-types.d';
import * as log from '../logging/log';
import { postSaveUpdates } from '../util/cleanup';
const BUFFER_DELAY_MS = 50;
@ -90,6 +91,7 @@ export async function populateConversationWithMessages({
await DataWriter.saveMessages(messages, {
forceSave: true,
ourAci,
postSaveUpdates,
});
conversation.set('active_at', Date.now());

View file

@ -203,6 +203,9 @@ import {
maybeQueueDeviceNameFetch,
onDeviceNameChangeSync,
} from './util/onDeviceNameChangeSync';
import { postSaveUpdates } from './util/cleanup';
import { handleDataMessage } from './messages/handleDataMessage';
import { MessageModel } from './models/messages';
export function isOverHourIntoPast(timestamp: number): boolean {
return isNumber(timestamp) && isOlderThan(timestamp, HOUR);
@ -1421,7 +1424,7 @@ export async function startApp(): Promise<void> {
void badgeImageFileDownloader.checkForFilesToDownload();
initializeExpiringMessageService(singleProtoJobQueue);
initializeExpiringMessageService();
log.info('Blocked uuids cleanup: starting...');
const blockedUuids = window.storage.get(BLOCKED_UUIDS_ID, []);
@ -1473,6 +1476,7 @@ export async function startApp(): Promise<void> {
await DataWriter.saveMessages(newMessageAttributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
log.info('Expiration start timestamp cleanup: complete');
@ -2627,7 +2631,7 @@ export async function startApp(): Promise<void> {
}
// Don't wait for handleDataMessage, as it has its own per-conversation queueing
drop(message.handleDataMessage(data.message, event.confirm));
drop(handleDataMessage(message, data.message, event.confirm));
}
async function onProfileKey({
@ -2803,7 +2807,7 @@ export async function startApp(): Promise<void> {
unidentifiedDeliveries,
};
return new window.Whisper.Message(partialMessage);
return new MessageModel(partialMessage);
}
// Works with 'sent' and 'message' data sent from MessageReceiver
@ -3021,7 +3025,7 @@ export async function startApp(): Promise<void> {
// Don't wait for handleDataMessage, as it has its own per-conversation queueing
drop(
message.handleDataMessage(data.message, event.confirm, {
handleDataMessage(message, data.message, event.confirm, {
data,
})
);
@ -3060,7 +3064,7 @@ export async function startApp(): Promise<void> {
type: data.message.isStory ? 'story' : 'incoming',
unidentifiedDeliveryReceived: data.unidentifiedDeliveryReceived,
};
return new window.Whisper.Message(partialMessage);
return new MessageModel(partialMessage);
}
// Returns `false` if this message isn't a group call message.

View file

@ -102,6 +102,8 @@ import {
} from './util/groupSendEndorsements';
import { getProfile } from './util/getProfile';
import { generateMessageId } from './util/generateMessageId';
import { postSaveUpdates } from './util/cleanup';
import { MessageModel } from './models/messages';
type AccessRequiredEnum = Proto.AccessControl.AccessRequired;
@ -253,7 +255,7 @@ export type GroupV2ChangeDetailType =
export type GroupV2ChangeType = {
from?: ServiceIdString;
details: Array<GroupV2ChangeDetailType>;
details: ReadonlyArray<GroupV2ChangeDetailType>;
};
export type GroupFields = {
@ -2016,7 +2018,7 @@ export async function createGroupV2(
revision: groupV2Info.revision,
});
const createdTheGroupMessage: MessageAttributesType = {
const createdTheGroupMessage = new MessageModel({
...generateMessageId(incrementMessageCounter()),
schemaVersion: MAX_MESSAGE_SCHEMA,
@ -2032,17 +2034,12 @@ export async function createGroupV2(
from: ourAci,
details: [{ type: 'create' }],
},
};
await DataWriter.saveMessages([createdTheGroupMessage], {
forceSave: true,
ourAci,
});
window.MessageCache.__DEPRECATED$register(
createdTheGroupMessage.id,
new window.Whisper.Message(createdTheGroupMessage),
'createGroupV2'
);
conversation.trigger('newmessage', createdTheGroupMessage);
await window.MessageCache.saveMessage(createdTheGroupMessage, {
forceSave: true,
});
window.MessageCache.register(createdTheGroupMessage);
drop(conversation.onNewMessage(createdTheGroupMessage));
if (expireTimer) {
await conversation.updateExpirationTimer(expireTimer, {
@ -3442,6 +3439,7 @@ async function appendChangeMessages(
log.info(`appendChangeMessages/${logId}: updating ${first.id}`);
await DataWriter.saveMessage(first, {
ourAci,
postSaveUpdates,
// We don't use forceSave here because this is an update of existing
// message.
@ -3453,6 +3451,7 @@ async function appendChangeMessages(
await DataWriter.saveMessages(rest, {
ourAci,
forceSave: true,
postSaveUpdates,
});
} else {
log.info(
@ -3461,15 +3460,13 @@ async function appendChangeMessages(
await DataWriter.saveMessages(mergedMessages, {
ourAci,
forceSave: true,
postSaveUpdates,
});
}
let newMessages = 0;
for (const changeMessage of mergedMessages) {
const existing = window.MessageCache.__DEPRECATED$getById(
changeMessage.id,
'appendChangeMessages'
);
const existing = window.MessageCache.getById(changeMessage.id);
// Update existing message
if (existing) {
@ -3481,12 +3478,8 @@ async function appendChangeMessages(
continue;
}
window.MessageCache.__DEPRECATED$register(
changeMessage.id,
new window.Whisper.Message(changeMessage),
'appendChangeMessages'
);
conversation.trigger('newmessage', changeMessage);
const model = window.MessageCache.register(new MessageModel(changeMessage));
drop(conversation.onNewMessage(model));
newMessages += 1;
}

View file

@ -24,7 +24,7 @@ import {
AttachmentVariant,
mightBeOnBackupTier,
} from '../types/Attachment';
import { __DEPRECATED$getMessageById } from '../messages/getMessageById';
import { getMessageById } from '../messages/getMessageById';
import {
KIBIBYTE,
getMaximumIncomingAttachmentSizeInKb,
@ -52,6 +52,7 @@ import {
} from '../AttachmentCrypto';
import { safeParsePartial } from '../util/schemas';
import { createBatcher } from '../util/batcher';
import { postSaveUpdates } from '../util/cleanup';
export enum AttachmentDownloadUrgency {
IMMEDIATE = 'immediate',
@ -327,10 +328,7 @@ async function runDownloadAttachmentJob({
const jobIdForLogging = getJobIdForLogging(job);
const logId = `AttachmentDownloadManager/runDownloadAttachmentJob/${jobIdForLogging}`;
const message = await __DEPRECATED$getMessageById(
job.messageId,
'runDownloadAttachmentJob'
);
const message = await getMessageById(job.messageId);
if (!message) {
log.error(`${logId} message not found`);
@ -430,6 +428,7 @@ async function runDownloadAttachmentJob({
// is good
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
}

View file

@ -28,7 +28,7 @@ import { getUntrustedConversationServiceIds } from './getUntrustedConversationSe
import { handleMessageSend } from '../../util/handleMessageSend';
import { isConversationAccepted } from '../../util/isConversationAccepted';
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { getMessageById } from '../../messages/getMessageById';
import { isNotNil } from '../../util/isNotNil';
import type { CallbackResultType } from '../../textsecure/Types.d';
import type { MessageModel } from '../../models/messages';
@ -38,6 +38,7 @@ import type { LoggerType } from '../../types/Logging';
import type { ServiceIdString } from '../../types/ServiceId';
import { isStory } from '../../messages/helpers';
import { sendToGroup } from '../../util/sendToGroup';
import { postSaveUpdates } from '../../util/cleanup';
export async function sendDeleteForEveryone(
conversation: ConversationModel,
@ -60,7 +61,7 @@ export async function sendDeleteForEveryone(
const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${messageId})`;
const message = await __DEPRECATED$getMessageById(messageId, logId);
const message = await getMessageById(messageId);
if (!message) {
log.error(`${logId}: Failed to fetch message. Failing job.`);
return;
@ -307,6 +308,7 @@ async function updateMessageWithSuccessfulSends(
});
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
return;
@ -330,6 +332,7 @@ async function updateMessageWithSuccessfulSends(
});
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
@ -346,5 +349,6 @@ async function updateMessageWithFailure(
message.set({ deletedForEveryoneFailed: true });
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}

View file

@ -21,7 +21,7 @@ import { getUntrustedConversationServiceIds } from './getUntrustedConversationSe
import { handleMessageSend } from '../../util/handleMessageSend';
import { isConversationAccepted } from '../../util/isConversationAccepted';
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { getMessageById } from '../../messages/getMessageById';
import { isNotNil } from '../../util/isNotNil';
import type { CallbackResultType } from '../../textsecure/Types.d';
import type { MessageModel } from '../../models/messages';
@ -29,6 +29,7 @@ import { SendMessageProtoError } from '../../textsecure/Errors';
import { strictAssert } from '../../util/assert';
import type { LoggerType } from '../../types/Logging';
import { isStory } from '../../messages/helpers';
import { postSaveUpdates } from '../../util/cleanup';
export async function sendDeleteStoryForEveryone(
ourConversation: ConversationModel,
@ -46,10 +47,7 @@ export async function sendDeleteStoryForEveryone(
const logId = `sendDeleteStoryForEveryone(${storyId})`;
const message = await __DEPRECATED$getMessageById(
storyId,
'sendDeleteStoryForEveryone'
);
const message = await getMessageById(storyId);
if (!message) {
log.error(`${logId}: Failed to fetch message. Failing job.`);
return;
@ -284,6 +282,7 @@ async function updateMessageWithSuccessfulSends(
});
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
return;
@ -307,6 +306,7 @@ async function updateMessageWithSuccessfulSends(
});
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
@ -323,5 +323,6 @@ async function updateMessageWithFailure(
message.set({ deletedForEveryoneFailed: true });
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}

View file

@ -8,7 +8,7 @@ import { DataWriter } from '../../sql/Client';
import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert';
import type { MessageModel } from '../../models/messages';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { getMessageById } from '../../messages/getMessageById';
import type { ConversationModel } from '../../models/conversations';
import { isGroup, isGroupV2, isMe } from '../../util/whatTypeOfConversation';
import { getSendOptions } from '../../util/getSendOptions';
@ -56,6 +56,13 @@ import {
import { getMessageSentTimestamp } from '../../util/getMessageSentTimestamp';
import { isSignalConversation } from '../../util/isSignalConversation';
import { isBodyTooLong, trimBody } from '../../util/longAttachment';
import {
markFailed,
saveErrorsOnMessage,
} from '../../test-node/util/messageFailures';
import { getMessageIdForLogging } from '../../util/idForLogging';
import { postSaveUpdates } from '../../util/cleanup';
import { send, sendSyncMessageOnly } from '../../messages/send';
const MAX_CONCURRENT_ATTACHMENT_UPLOADS = 5;
@ -73,10 +80,7 @@ export async function sendNormalMessage(
const { Message } = window.Signal.Types;
const { messageId, revision, editedMessageTimestamp } = data;
const message = await __DEPRECATED$getMessageById(
messageId,
'sendNormalMessage'
);
const message = await getMessageById(messageId);
if (!message) {
log.info(
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending it`
@ -84,7 +88,9 @@ export async function sendNormalMessage(
return;
}
const messageConversation = message.getConversation();
const messageConversation = window.ConversationController.get(
message.get('conversationId')
);
if (messageConversation !== conversation) {
log.error(
`Message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
@ -106,7 +112,7 @@ export async function sendNormalMessage(
return;
}
if (message.isErased() || message.get('deletedForEveryone')) {
if (message.get('isErased') || message.get('deletedForEveryone')) {
log.info(`message ${messageId} was erased. Giving up on sending it`);
return;
}
@ -285,7 +291,7 @@ export async function sendNormalMessage(
timestamp: targetTimestamp,
reaction,
});
messageSendPromise = message.sendSyncMessageOnly({
messageSendPromise = sendSyncMessageOnly(message, {
dataMessage,
saveErrors,
targetTimestamp,
@ -407,7 +413,7 @@ export async function sendNormalMessage(
});
}
messageSendPromise = message.send({
messageSendPromise = send(message, {
promise: handleMessageSend(innerPromise, {
messageIds: [messageId],
sendType: 'message',
@ -657,14 +663,13 @@ async function getMessageSendData({
uploadQueue,
}),
uploadMessageSticker(message, uploadQueue),
storyId
? __DEPRECATED$getMessageById(storyId, 'sendNormalMessage')
: undefined,
storyId ? getMessageById(storyId) : undefined,
]);
// Save message after uploading attachments
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
const storyReaction = message.get('storyReaction');
@ -732,7 +737,7 @@ async function uploadSingleAttachment({
const uploaded = await uploadAttachment(withData);
// Add digest to the attachment
const logId = `uploadSingleAttachment(${message.idForLogging()}`;
const logId = `uploadSingleAttachment(${getMessageIdForLogging(message.attributes)}`;
const oldAttachments = getPropForTimestamp({
log,
message: message.attributes,
@ -788,7 +793,7 @@ async function uploadLongMessageAttachment({
const uploaded = await uploadAttachment(withData);
// Add digest to the attachment
const logId = `uploadLongMessageAttachment(${message.idForLogging()}`;
const logId = `uploadLongMessageAttachment(${getMessageIdForLogging(message.attributes)}`;
const oldAttachment = getPropForTimestamp({
log,
message: message.attributes,
@ -872,7 +877,7 @@ async function uploadMessageQuote({
);
// Update message with attachment digests
const logId = `uploadMessageQuote(${message.idForLogging()}`;
const logId = `uploadMessageQuote(${getMessageIdForLogging(message.attributes)}`;
const oldQuote = getPropForTimestamp({
log,
message: message.attributes,
@ -980,7 +985,7 @@ async function uploadMessagePreviews({
);
// Update message with attachment digests
const logId = `uploadMessagePreviews(${message.idForLogging()}`;
const logId = `uploadMessagePreviews(${getMessageIdForLogging(message.attributes)}`;
const oldPreview = getPropForTimestamp({
log,
message: message.attributes,
@ -1043,7 +1048,7 @@ async function uploadMessageSticker(
);
// Add digest to the attachment
const logId = `uploadMessageSticker(${message.idForLogging()}`;
const logId = `uploadMessageSticker(${getMessageIdForLogging(message.attributes)}`;
const existingSticker = message.get('sticker');
strictAssert(
existingSticker?.data !== undefined,
@ -1054,12 +1059,14 @@ async function uploadMessageSticker(
existingSticker.data.path === startingSticker?.data?.path,
`${logId}: Sticker was uploaded, but message has a different sticker`
);
message.set('sticker', {
message.set({
sticker: {
...existingSticker,
data: {
...existingSticker.data,
...copyCdnFields(uploaded),
},
},
});
return {
@ -1111,7 +1118,7 @@ async function uploadMessageContacts(
);
// Add digest to the attachment
const logId = `uploadMessageContacts(${message.idForLogging()}`;
const logId = `uploadMessageContacts(${getMessageIdForLogging(message.attributes)}`;
const oldContact = message.get('contact');
strictAssert(oldContact, `${logId}: Contacts are gone after upload`);
@ -1148,7 +1155,7 @@ async function uploadMessageContacts(
},
};
});
message.set('contact', newContact);
message.set({ contact: newContact });
return uploadedContacts;
}
@ -1162,10 +1169,9 @@ async function markMessageFailed({
message: MessageModel;
targetTimestamp: number;
}): Promise<void> {
message.markFailed(targetTimestamp);
void message.saveErrors(errors, { skipSave: true });
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
markFailed(message, targetTimestamp);
await saveErrorsOnMessage(message, errors, {
skipSave: false,
});
}

View file

@ -7,14 +7,14 @@ import * as Errors from '../../types/errors';
import { strictAssert } from '../../util/assert';
import { repeat, zipObject } from '../../util/iterables';
import type { CallbackResultType } from '../../textsecure/Types.d';
import type { MessageModel } from '../../models/messages';
import { MessageModel } from '../../models/messages';
import type { MessageReactionType } from '../../model-types.d';
import type { ConversationModel } from '../../models/conversations';
import { DataWriter } from '../../sql/Client';
import * as reactionUtil from '../../reactions/util';
import { isSent, SendStatus } from '../../messages/MessageSendState';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { getMessageById } from '../../messages/getMessageById';
import { isIncoming } from '../../messages/helpers';
import {
isMe,
@ -41,6 +41,9 @@ import { isConversationAccepted } from '../../util/isConversationAccepted';
import { isConversationUnregistered } from '../../util/isConversationUnregistered';
import type { LoggerType } from '../../types/Logging';
import { sendToGroup } from '../../util/sendToGroup';
import { hydrateStoryContext } from '../../util/hydrateStoryContext';
import { postSaveUpdates } from '../../util/cleanup';
import { send, sendSyncMessageOnly } from '../../messages/send';
export async function sendReaction(
conversation: ConversationModel,
@ -61,7 +64,7 @@ export async function sendReaction(
const ourConversationId =
window.ConversationController.getOurConversationIdOrThrow();
const message = await __DEPRECATED$getMessageById(messageId, 'sendReaction');
const message = await getMessageById(messageId);
if (!message) {
log.info(
`message ${messageId} was not found, maybe because it was deleted. Giving up on sending its reactions`
@ -87,7 +90,10 @@ export async function sendReaction(
if (!canReact(message.attributes, ourConversationId, findAndFormatContact)) {
log.info(`could not react to ${messageId}. Removing this pending reaction`);
markReactionFailed(message, pendingReaction);
await DataWriter.saveMessage(message.attributes, { ourAci });
await DataWriter.saveMessage(message.attributes, {
ourAci,
postSaveUpdates,
});
return;
}
@ -96,7 +102,10 @@ export async function sendReaction(
`reacting to message ${messageId} ran out of time. Giving up on sending it`
);
markReactionFailed(message, pendingReaction);
await DataWriter.saveMessage(message.attributes, { ourAci });
await DataWriter.saveMessage(message.attributes, {
ourAci,
postSaveUpdates,
});
return;
}
@ -108,7 +117,9 @@ export async function sendReaction(
let originalError: Error | undefined;
try {
const messageConversation = message.getConversation();
const messageConversation = window.ConversationController.get(
message.get('conversationId')
);
if (messageConversation !== conversation) {
log.error(
`message conversation '${messageConversation?.idForLogging()}' does not match job conversation ${conversation.idForLogging()}`
@ -158,7 +169,7 @@ export async function sendReaction(
targetAuthorAci,
remove: !emoji,
};
const ephemeralMessageForReactionSend = new window.Whisper.Message({
const ephemeralMessageForReactionSend = new MessageModel({
...generateMessageId(incrementMessageCounter()),
type: 'outgoing',
conversationId: conversation.get('id'),
@ -173,14 +184,13 @@ export async function sendReaction(
})
),
});
// Adds the reaction's attributes to the message cache so that we can
// safely `set` on it later.
window.MessageCache.toMessageAttributes(
ephemeralMessageForReactionSend.attributes
);
ephemeralMessageForReactionSend.doNotSave = true;
// Adds the reaction's attributes to the message cache so that we can
// safely `set` on it later.
window.MessageCache.register(ephemeralMessageForReactionSend);
let didFullySend: boolean;
const successfulConversationIds = new Set<string>();
@ -199,7 +209,7 @@ export async function sendReaction(
recipients: allRecipientServiceIds,
timestamp: pendingReaction.timestamp,
});
await ephemeralMessageForReactionSend.sendSyncMessageOnly({
await sendSyncMessageOnly(ephemeralMessageForReactionSend, {
dataMessage,
saveErrors,
targetTimestamp: pendingReaction.timestamp,
@ -292,7 +302,7 @@ export async function sendReaction(
);
}
await ephemeralMessageForReactionSend.send({
await send(ephemeralMessageForReactionSend, {
promise: handleMessageSend(promise, {
messageIds: [messageId],
sendType: 'reaction',
@ -334,19 +344,16 @@ export async function sendReaction(
if (!ephemeralMessageForReactionSend.doNotSave) {
const reactionMessage = ephemeralMessageForReactionSend;
await reactionMessage.hydrateStoryContext(message.attributes, {
await hydrateStoryContext(reactionMessage.id, message.attributes, {
shouldSave: false,
});
await DataWriter.saveMessage(reactionMessage.attributes, {
ourAci,
forceSave: true,
postSaveUpdates,
});
window.MessageCache.__DEPRECATED$register(
reactionMessage.id,
reactionMessage,
'sendReaction'
);
window.MessageCache.register(reactionMessage);
void conversation.addSingleMessage(reactionMessage.attributes);
}
}
@ -375,7 +382,10 @@ export async function sendReaction(
toThrow: originalError || thrownError,
});
} finally {
await DataWriter.saveMessage(message.attributes, { ourAci });
await DataWriter.saveMessage(message.attributes, {
ourAci,
postSaveUpdates,
});
}
}
@ -388,9 +398,9 @@ const setReactions = (
reactions: Array<MessageReactionType>
): void => {
if (reactions.length) {
message.set('reactions', reactions);
message.set({ reactions });
} else {
message.set('reactions', undefined);
message.set({ reactions: undefined });
}
};

View file

@ -39,6 +39,13 @@ import { distributionListToSendTarget } from '../../util/distributionListToSendT
import { uploadAttachment } from '../../util/uploadAttachment';
import { SendMessageChallengeError } from '../../textsecure/Errors';
import type { OutgoingTextAttachmentType } from '../../textsecure/SendMessage';
import {
markFailed,
notifyStorySendFailed,
saveErrorsOnMessage,
} from '../../test-node/util/messageFailures';
import { postSaveUpdates } from '../../util/cleanup';
import { send } from '../../messages/send';
export async function sendStory(
conversation: ConversationModel,
@ -71,14 +78,15 @@ export async function sendStory(
}
const notFound = new Set(messageIds);
const messages = (await getMessagesById(messageIds, 'sendStory')).filter(
message => {
const messages = (await getMessagesById(messageIds)).filter(message => {
notFound.delete(message.id);
const distributionId = message.get('storyDistributionListId');
const logId = `stories.sendStory(${timestamp}/${distributionId})`;
const messageConversation = message.getConversation();
const messageConversation = window.ConversationController.get(
message.get('conversationId')
);
if (messageConversation !== conversation) {
log.error(
`${logId}: Message conversation ` +
@ -97,14 +105,13 @@ export async function sendStory(
return false;
}
if (message.isErased() || message.get('deletedForEveryone')) {
if (message.get('isErased') || message.get('deletedForEveryone')) {
log.info(`${logId}: message was erased. Giving up on sending it`);
return false;
}
return true;
}
);
});
for (const messageId of notFound) {
log.info(
@ -367,7 +374,7 @@ export async function sendStory(
// eslint-disable-next-line no-param-reassign
message.doNotSendSyncMessage = true;
const messageSendPromise = message.send({
const messageSendPromise = send(message, {
promise: handleMessageSend(innerPromise, {
messageIds: [message.id],
sendType: 'story',
@ -535,16 +542,17 @@ export async function sendStory(
}, {} as SendStateByConversationId);
if (hasFailedSends) {
message.notifyStorySendFailed();
notifyStorySendFailed(message);
}
if (isEqual(oldSendStateByConversationId, newSendStateByConversationId)) {
return;
}
message.set('sendStateByConversationId', newSendStateByConversationId);
message.set({ sendStateByConversationId: newSendStateByConversationId });
return DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
})
);
@ -688,10 +696,9 @@ async function markMessageFailed(
message: MessageModel,
errors: Array<Error>
): Promise<void> {
message.markFailed();
void message.saveErrors(errors, { skipSave: true });
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
markFailed(message);
await saveErrorsOnMessage(message, errors, {
skipSave: false,
});
}

View file

@ -6,7 +6,55 @@ import type { AttachmentDownloadJobTypeType } from '../types/AttachmentDownload'
import type { AttachmentType } from '../types/Attachment';
import { getAttachmentSignatureSafe, isDownloaded } from '../types/Attachment';
import { __DEPRECATED$getMessageById } from '../messages/getMessageById';
import { getMessageById } from '../messages/getMessageById';
export async function markAttachmentAsCorrupted(
messageId: string,
attachment: AttachmentType
): Promise<void> {
const message = await getMessageById(messageId);
if (!message) {
return;
}
if (!attachment.path) {
throw new Error(
"Attachment can't be marked as corrupted because it wasn't loaded"
);
}
// We intentionally don't check in quotes/stickers/contacts/... here,
// because this function should be called only for something that can
// be displayed as a generic attachment.
const attachments: ReadonlyArray<AttachmentType> =
message.get('attachments') || [];
let changed = false;
const newAttachments = attachments.map(existing => {
if (existing.path !== attachment.path) {
return existing;
}
changed = true;
return {
...existing,
isCorrupted: true,
};
});
if (!changed) {
throw new Error(
"Attachment can't be marked as corrupted because it wasn't found"
);
}
log.info('markAttachmentAsCorrupted: marking an attachment as corrupted');
message.set({
attachments: newAttachments,
});
}
export async function addAttachmentToMessage(
messageId: string,
@ -15,7 +63,7 @@ export async function addAttachmentToMessage(
{ type }: { type: AttachmentDownloadJobTypeType }
): Promise<void> {
const logPrefix = `${jobLogId}/addAttachmentToMessage`;
const message = await __DEPRECATED$getMessageById(messageId, logPrefix);
const message = await getMessageById(messageId);
if (!message) {
return;

View file

@ -9,6 +9,7 @@ import * as Errors from '../types/errors';
import { deleteForEveryone } from '../util/deleteForEveryone';
import { drop } from '../util/drop';
import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet';
import { MessageModel } from '../models/messages';
export type DeleteAttributesType = {
envelopeId: string;
@ -86,10 +87,8 @@ export async function onDelete(del: DeleteAttributesType): Promise<void> {
return;
}
const message = window.MessageCache.__DEPRECATED$register(
targetMessage.id,
targetMessage,
'Deletes.onDelete'
const message = window.MessageCache.register(
new MessageModel(targetMessage)
);
await deleteForEveryone(message, del);

View file

@ -13,6 +13,7 @@ import {
isAttachmentDownloadQueueEmpty,
registerQueueEmptyCallback,
} from '../util/attachmentDownloadQueue';
import { MessageModel } from '../models/messages';
export type EditAttributesType = {
conversationId: string;
@ -134,10 +135,8 @@ export async function onEdit(edit: EditAttributesType): Promise<void> {
return;
}
const message = window.MessageCache.__DEPRECATED$register(
targetMessage.id,
targetMessage,
'Edits.onEdit'
const message = window.MessageCache.register(
new MessageModel(targetMessage)
);
await handleEditMessage(message.attributes, edit);

View file

@ -32,6 +32,9 @@ import {
RECEIPT_BATCHER_WAIT_MS,
} from '../types/Receipt';
import { drop } from '../util/drop';
import { getMessageById } from '../messages/getMessageById';
import { postSaveUpdates } from '../util/cleanup';
import { MessageModel } from '../models/messages';
const { deleteSentProtoRecipient, removeSyncTaskById } = DataWriter;
@ -78,12 +81,11 @@ const processReceiptBatcher = createWaitBatcher({
> = new Map();
function addReceiptAndTargetMessage(
message: MessageAttributesType,
message: MessageModel,
receipt: MessageReceiptAttributesType
): void {
const existing = receiptsByMessageId.get(message.id);
if (!existing) {
window.MessageCache.toMessageAttributes(message);
receiptsByMessageId.set(message.id, [receipt]);
} else {
existing.push(receipt);
@ -151,9 +153,10 @@ const processReceiptBatcher = createWaitBatcher({
);
if (targetMessages.length) {
targetMessages.forEach(msg =>
addReceiptAndTargetMessage(msg, receipt)
);
targetMessages.forEach(msg => {
const model = window.MessageCache.register(new MessageModel(msg));
addReceiptAndTargetMessage(model, receipt);
});
} else {
// Nope, no target message was found
const { receiptSync } = receipt;
@ -188,53 +191,43 @@ async function processReceiptsForMessage(
}
// Get message from cache or DB
const message = await window.MessageCache.resolveAttributes(
'processReceiptsForMessage',
messageId
const message = await getMessageById(messageId);
if (!message) {
throw new Error(
`processReceiptsForMessage: Failed to find message ${messageId}`
);
}
// Note: it is important to have no `await` in between `resolveAttributes` and
// `setAttributes` since it might overwrite other updates otherwise.
const { updatedMessage, validReceipts, droppedReceipts } =
updateMessageWithReceipts(message, receipts);
const { validReceipts } = await updateMessageWithReceipts(message, receipts);
// Save it to cache & to DB, and remove dropped receipts
await Promise.all([
window.MessageCache.setAttributes({
messageId,
messageAttributes: updatedMessage,
skipSaveToDatabase: false,
}),
Promise.all(droppedReceipts.map(remove)),
]);
const ourAci = window.textsecure.storage.user.getCheckedAci();
await DataWriter.saveMessage(message.attributes, { ourAci, postSaveUpdates });
// Confirm/remove receipts, and delete sent protos
for (const receipt of validReceipts) {
// eslint-disable-next-line no-await-in-loop
await remove(receipt);
drop(addToDeleteSentProtoBatcher(receipt, updatedMessage));
drop(addToDeleteSentProtoBatcher(receipt, message.attributes));
}
// notify frontend listeners
const conversation = window.ConversationController.get(
message.conversationId
message.get('conversationId')
);
conversation?.debouncedUpdateLastMessage?.();
}
function updateMessageWithReceipts(
message: MessageAttributesType,
async function updateMessageWithReceipts(
message: MessageModel,
receipts: Array<MessageReceiptAttributesType>
): {
updatedMessage: MessageAttributesType;
): Promise<{
validReceipts: Array<MessageReceiptAttributesType>;
droppedReceipts: Array<MessageReceiptAttributesType>;
} {
const logId = `updateMessageWithReceipts(timestamp=${message.timestamp})`;
}> {
const logId = `updateMessageWithReceipts(timestamp=${message.get('timestamp')})`;
const droppedReceipts: Array<MessageReceiptAttributesType> = [];
const receiptsToProcess = receipts.filter(receipt => {
if (shouldDropReceipt(receipt, message)) {
if (shouldDropReceipt(receipt, message.attributes)) {
const { receiptSync } = receipt;
log.info(
`${logId}: Dropping a receipt ${receiptSync.type} for sentAt=${receiptSync.messageSentAt}`
@ -257,14 +250,16 @@ function updateMessageWithReceipts(
);
// Generate the updated message synchronously
let updatedMessage: MessageAttributesType = { ...message };
let { attributes } = message;
for (const receipt of receiptsToProcess) {
updatedMessage = {
...updatedMessage,
...updateMessageSendStateWithReceipt(updatedMessage, receipt),
attributes = {
...attributes,
...updateMessageSendStateWithReceipt(attributes, receipt),
};
}
return { updatedMessage, validReceipts: receiptsToProcess, droppedReceipts };
message.set(attributes);
return { validReceipts: receiptsToProcess };
}
const deleteSentProtoBatcher = createWaitBatcher({
@ -310,7 +305,7 @@ function getTargetMessage({
sourceConversationId: string;
messagesMatchingTimestamp: ReadonlyArray<MessageAttributesType>;
targetTimestamp: number;
}): MessageAttributesType | null {
}): MessageModel | null {
if (messagesMatchingTimestamp.length === 0) {
return null;
}
@ -366,7 +361,7 @@ function getTargetMessage({
}
const message = matchingMessages[0];
return window.MessageCache.toMessageAttributes(message);
return window.MessageCache.register(new MessageModel(message));
}
const wasDeliveredWithSealedSender = (
conversationId: string,

View file

@ -1,23 +1,45 @@
// Copyright 2020 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { maxBy } from 'lodash';
import type { AciString } from '../types/ServiceId';
import type {
MessageAttributesType,
MessageReactionType,
ReadonlyMessageAttributesType,
} from '../model-types.d';
import type { MessageModel } from '../models/messages';
import type { ReactionSource } from '../reactions/ReactionSource';
import { DataReader } from '../sql/Client';
import { MessageModel } from '../models/messages';
import { ReactionSource } from '../reactions/ReactionSource';
import { DataReader, DataWriter } from '../sql/Client';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import { getAuthor } from '../messages/helpers';
import { getAuthor, isIncoming, isOutgoing } from '../messages/helpers';
import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet';
import { isMe } from '../util/whatTypeOfConversation';
import { isStory } from '../state/selectors/message';
import { isDirectConversation, isMe } from '../util/whatTypeOfConversation';
import {
getMessagePropStatus,
hasErrors,
isStory,
} from '../state/selectors/message';
import { getPropForTimestamp } from '../util/editHelpers';
import { isSent } from '../messages/MessageSendState';
import { strictAssert } from '../util/assert';
import { repeat, zipObject } from '../util/iterables';
import { getMessageIdForLogging } from '../util/idForLogging';
import { hydrateStoryContext } from '../util/hydrateStoryContext';
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
import { drop } from '../util/drop';
import * as reactionUtil from '../reactions/util';
import { isNewReactionReplacingPrevious } from '../reactions/util';
import { notificationService } from '../services/notifications';
import { ReactionReadStatus } from '../types/Reactions';
import type { ConversationQueueJobData } from '../jobs/conversationJobQueue';
import {
conversationJobQueue,
conversationQueueJobEnum,
} from '../jobs/conversationJobQueue';
import { postSaveUpdates } from '../util/cleanup';
export type ReactionAttributesType = {
emoji: string;
@ -36,24 +58,26 @@ export type ReactionAttributesType = {
receivedAtDate: number;
};
const reactions = new Map<string, ReactionAttributesType>();
const reactionCache = new Map<string, ReactionAttributesType>();
function remove(reaction: ReactionAttributesType): void {
reactions.delete(reaction.envelopeId);
reactionCache.delete(reaction.envelopeId);
reaction.removeFromMessageReceiverCache();
}
export function findReactionsForMessage(
message: ReadonlyMessageAttributesType
): Array<ReactionAttributesType> {
const matchingReactions = Array.from(reactions.values()).filter(reaction => {
const matchingReactions = Array.from(reactionCache.values()).filter(
reaction => {
return isMessageAMatchForReaction({
message,
targetTimestamp: reaction.targetTimestamp,
targetAuthorAci: reaction.targetAuthorAci,
reactionSenderConversationId: reaction.fromId,
});
});
}
);
matchingReactions.forEach(reaction => remove(reaction));
return matchingReactions;
@ -173,7 +197,7 @@ function isMessageAMatchForReaction({
export async function onReaction(
reaction: ReactionAttributesType
): Promise<void> {
reactions.set(reaction.envelopeId, reaction);
reactionCache.set(reaction.envelopeId, reaction);
const logId = `Reactions.onReaction(timestamp=${reaction.timestamp};target=${reaction.targetTimestamp})`;
@ -231,23 +255,21 @@ export async function onReaction(
return;
}
const targetMessageModel = window.MessageCache.__DEPRECATED$register(
targetMessage.id,
targetMessage,
'Reactions.onReaction'
const targetMessageModel = window.MessageCache.register(
new MessageModel(targetMessage)
);
// Use the generated message in ts/background.ts to create a message
// if the reaction is targeted at a story.
if (!isStory(targetMessage)) {
await targetMessageModel.handleReaction(reaction);
await handleReaction(targetMessageModel, reaction);
} else {
const generatedMessage = reaction.generatedMessageForStoryReaction;
strictAssert(
generatedMessage,
'Generated message must exist for story reaction'
);
await generatedMessage.handleReaction(reaction, {
await handleReaction(generatedMessage, reaction, {
storyMessage: targetMessage,
});
}
@ -260,3 +282,324 @@ export async function onReaction(
log.error(`${logId} error:`, Errors.toLogFormat(error));
}
}
export async function handleReaction(
message: MessageModel,
reaction: ReactionAttributesType,
{
storyMessage,
shouldPersist = true,
}: {
storyMessage?: MessageAttributesType;
shouldPersist?: boolean;
} = {}
): Promise<void> {
const { attributes } = message;
if (message.get('deletedForEveryone')) {
return;
}
// We allow you to react to messages with outgoing errors only if it has sent
// successfully to at least one person.
if (
hasErrors(attributes) &&
(isIncoming(attributes) ||
getMessagePropStatus(
attributes,
window.ConversationController.getOurConversationIdOrThrow()
) !== 'partial-sent')
) {
return;
}
const conversation = window.ConversationController.get(
message.attributes.conversationId
);
if (!conversation) {
return;
}
const isFromThisDevice = reaction.source === ReactionSource.FromThisDevice;
const isFromSync = reaction.source === ReactionSource.FromSync;
const isFromSomeoneElse = reaction.source === ReactionSource.FromSomeoneElse;
strictAssert(
isFromThisDevice || isFromSync || isFromSomeoneElse,
'Reaction can only be from this device, from sync, or from someone else'
);
const newReaction: MessageReactionType = {
emoji: reaction.remove ? undefined : reaction.emoji,
fromId: reaction.fromId,
targetTimestamp: reaction.targetTimestamp,
timestamp: reaction.timestamp,
isSentByConversationId: isFromThisDevice
? zipObject(conversation.getMemberConversationIds(), repeat(false))
: undefined,
};
// Reactions to stories are saved as separate messages, and so require a totally
// different codepath.
if (storyMessage) {
if (isFromThisDevice) {
log.info(
'handleReaction: sending story reaction to ' +
`${getMessageIdForLogging(storyMessage)} from this device`
);
} else {
if (isFromSomeoneElse) {
log.info(
'handleReaction: receiving story reaction to ' +
`${getMessageIdForLogging(storyMessage)} from someone else`
);
} else if (isFromSync) {
log.info(
'handleReaction: receiving story reaction to ' +
`${getMessageIdForLogging(storyMessage)} from another device`
);
}
const generatedMessage = reaction.generatedMessageForStoryReaction;
strictAssert(
generatedMessage,
'Story reactions must provide storyReactionMessage'
);
const targetConversation = window.ConversationController.get(
generatedMessage.get('conversationId')
);
strictAssert(
targetConversation,
'handleReaction: targetConversation not found'
);
window.MessageCache.register(generatedMessage);
generatedMessage.set({
expireTimer: isDirectConversation(targetConversation.attributes)
? targetConversation.get('expireTimer')
: undefined,
storyId: storyMessage.id,
storyReaction: {
emoji: reaction.emoji,
targetAuthorAci: reaction.targetAuthorAci,
targetTimestamp: reaction.targetTimestamp,
},
});
await hydrateStoryContext(generatedMessage.id, storyMessage, {
shouldSave: false,
});
// Note: generatedMessage comes with an id, so we have to force this save
await DataWriter.saveMessage(generatedMessage.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
forceSave: true,
postSaveUpdates,
});
log.info('Reactions.onReaction adding reaction to story', {
reactionMessageId: getMessageIdForLogging(generatedMessage.attributes),
storyId: getMessageIdForLogging(storyMessage),
targetTimestamp: reaction.targetTimestamp,
timestamp: reaction.timestamp,
});
window.MessageCache.register(generatedMessage);
if (isDirectConversation(targetConversation.attributes)) {
await targetConversation.addSingleMessage(generatedMessage.attributes);
if (!targetConversation.get('active_at')) {
targetConversation.set({
active_at: generatedMessage.attributes.timestamp,
});
await DataWriter.updateConversation(targetConversation.attributes);
}
}
if (isFromSomeoneElse) {
log.info(
'handleReaction: notifying for story reaction to ' +
`${getMessageIdForLogging(storyMessage)} from someone else`
);
if (
await shouldReplyNotifyUser(
generatedMessage.attributes,
targetConversation
)
) {
drop(targetConversation.notify(generatedMessage.attributes));
}
}
}
} else {
// Reactions to all messages other than stories will update the target message
const previousLength = (message.get('reactions') || []).length;
if (isFromThisDevice) {
log.info(
`handleReaction: sending reaction to ${getMessageIdForLogging(message.attributes)} ` +
'from this device'
);
const reactions = reactionUtil.addOutgoingReaction(
message.get('reactions') || [],
newReaction
);
message.set({ reactions });
} else {
const oldReactions = message.get('reactions') || [];
let reactions: Array<MessageReactionType>;
const oldReaction = oldReactions.find(re =>
isNewReactionReplacingPrevious(re, newReaction)
);
if (oldReaction) {
notificationService.removeBy({
...oldReaction,
messageId: message.id,
});
}
if (reaction.remove) {
log.info(
'handleReaction: removing reaction for message',
getMessageIdForLogging(message.attributes)
);
if (isFromSync) {
reactions = oldReactions.filter(
re =>
!isNewReactionReplacingPrevious(re, newReaction) ||
re.timestamp > reaction.timestamp
);
} else {
reactions = oldReactions.filter(
re => !isNewReactionReplacingPrevious(re, newReaction)
);
}
message.set({ reactions });
} else {
log.info(
'handleReaction: adding reaction for message',
getMessageIdForLogging(message.attributes)
);
let reactionToAdd: MessageReactionType;
if (isFromSync) {
const ourReactions = [
newReaction,
...oldReactions.filter(re => re.fromId === reaction.fromId),
];
reactionToAdd = maxBy(ourReactions, 'timestamp') || newReaction;
} else {
reactionToAdd = newReaction;
}
reactions = oldReactions.filter(
re => !isNewReactionReplacingPrevious(re, reaction)
);
reactions.push(reactionToAdd);
message.set({ reactions });
if (isOutgoing(message.attributes) && isFromSomeoneElse) {
void conversation.notify(message.attributes, reaction);
}
}
}
if (reaction.remove) {
await DataWriter.removeReactionFromConversation({
emoji: reaction.emoji,
fromId: reaction.fromId,
targetAuthorServiceId: reaction.targetAuthorAci,
targetTimestamp: reaction.targetTimestamp,
});
} else {
await DataWriter.addReaction(
{
conversationId: message.get('conversationId'),
emoji: reaction.emoji,
fromId: reaction.fromId,
messageId: message.id,
messageReceivedAt: message.get('received_at'),
targetAuthorAci: reaction.targetAuthorAci,
targetTimestamp: reaction.targetTimestamp,
timestamp: reaction.timestamp,
},
{
readStatus: isFromThisDevice
? ReactionReadStatus.Read
: ReactionReadStatus.Unread,
}
);
}
const currentLength = (message.get('reactions') || []).length;
log.info(
'handleReaction:',
`Done processing reaction for message ${getMessageIdForLogging(message.attributes)}.`,
`Went from ${previousLength} to ${currentLength} reactions.`
);
}
if (isFromThisDevice) {
let jobData: ConversationQueueJobData;
if (storyMessage) {
strictAssert(
newReaction.emoji !== undefined,
'New story reaction must have an emoji'
);
const generatedMessage = reaction.generatedMessageForStoryReaction;
strictAssert(
generatedMessage,
'Story reactions must provide storyReactionmessage'
);
await hydrateStoryContext(generatedMessage.id, message.attributes, {
shouldSave: false,
});
await DataWriter.saveMessage(generatedMessage.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
forceSave: true,
postSaveUpdates,
});
window.MessageCache.register(generatedMessage);
void conversation.addSingleMessage(generatedMessage.attributes);
jobData = {
type: conversationQueueJobEnum.enum.NormalMessage,
conversationId: conversation.id,
messageId: generatedMessage.id,
revision: conversation.get('revision'),
};
} else {
jobData = {
type: conversationQueueJobEnum.enum.Reaction,
conversationId: conversation.id,
messageId: message.id,
revision: conversation.get('revision'),
};
}
if (shouldPersist) {
await conversationJobQueue.add(jobData, async jobToInsert => {
log.info(
`enqueueReactionForSend: saving message ${getMessageIdForLogging(message.attributes)} and job ${
jobToInsert.id
}`
);
await DataWriter.saveMessage(message.attributes, {
jobToInsert,
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
});
} else {
await conversationJobQueue.add(jobData);
}
} else if (shouldPersist && !isStory(message.attributes)) {
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
window.reduxActions.conversations.markOpenConversationRead(conversation.id);
}
}

View file

@ -17,6 +17,8 @@ import { queueUpdateMessage } from '../util/messageBatcher';
import { strictAssert } from '../util/assert';
import { isAciString } from '../util/isAciString';
import { DataReader, DataWriter } from '../sql/Client';
import { markRead } from '../services/MessageUpdater';
import { MessageModel } from '../models/messages';
const { removeSyncTaskById } = DataWriter;
@ -146,11 +148,7 @@ export async function onSync(sync: ReadSyncAttributesType): Promise<void> {
notificationService.removeBy({ messageId: found.id });
const message = window.MessageCache.__DEPRECATED$register(
found.id,
found,
'ReadSyncs.onSync'
);
const message = window.MessageCache.register(new MessageModel(found));
const readAt = Math.min(readSync.readAt, Date.now());
const newestSentAt = readSync.timestamp;
@ -158,11 +156,12 @@ export async function onSync(sync: ReadSyncAttributesType): Promise<void> {
// timer to the time specified by the read sync if it's earlier than
// the previous read time.
if (isMessageUnread(message.attributes)) {
// TODO DESKTOP-1509: use MessageUpdater.markRead once this is TS
message.markRead(readAt, { skipSave: true });
message.set(markRead(message.attributes, readAt, { skipSave: true }));
const updateConversation = async () => {
const conversation = message.getConversation();
const conversation = window.ConversationController.get(
message.get('conversationId')
);
strictAssert(conversation, `${logId}: conversation not found`);
// onReadMessage may result in messages older than this one being
// marked read. We want those messages to have the same expire timer
@ -174,7 +173,9 @@ export async function onSync(sync: ReadSyncAttributesType): Promise<void> {
// only available during initialization
if (StartupQueue.isAvailable()) {
const conversation = message.getConversation();
const conversation = window.ConversationController.get(
message.get('conversationId')
);
strictAssert(
conversation,
`${logId}: conversation not found (StartupQueue)`

View file

@ -7,6 +7,8 @@ import { DataReader } from '../sql/Client';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import { getMessageIdForLogging } from '../util/idForLogging';
import { markViewOnceMessageViewed } from '../services/MessageUpdater';
import { MessageModel } from '../models/messages';
export type ViewOnceOpenSyncAttributesType = {
removeFromMessageReceiverCache: () => unknown;
@ -93,12 +95,8 @@ export async function onSync(
return;
}
const message = window.MessageCache.__DEPRECATED$register(
found.id,
found,
'ViewOnceOpenSyncs.onSync'
);
await message.markViewOnceMessageViewed({ fromSync: true });
const message = window.MessageCache.register(new MessageModel(found));
await markViewOnceMessageViewed(message, { fromSync: true });
viewOnceSyncs.delete(sync.timestamp);
sync.removeFromMessageReceiverCache();

View file

@ -19,6 +19,7 @@ import { queueUpdateMessage } from '../util/messageBatcher';
import { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager';
import { isAciString } from '../util/isAciString';
import { DataReader, DataWriter } from '../sql/Client';
import { MessageModel } from '../models/messages';
export const viewSyncTaskSchema = z.object({
type: z.literal('ViewSync').readonly(),
@ -114,11 +115,7 @@ export async function onSync(sync: ViewSyncAttributesType): Promise<void> {
notificationService.removeBy({ messageId: found.id });
const message = window.MessageCache.__DEPRECATED$register(
found.id,
found,
'ViewSyncs.onSync'
);
const message = window.MessageCache.register(new MessageModel(found));
let didChangeMessage = false;
if (message.get('readStatus') !== ReadStatus.Viewed) {

View file

@ -5,10 +5,6 @@ import { omit } from 'lodash';
import * as log from '../logging/log';
import type { QuotedMessageType } from '../model-types';
import type {
MessageAttributesType,
ReadonlyMessageAttributesType,
} from '../model-types.d';
import { SignalService } from '../protobuf';
import { isGiftBadge, isTapToView } from '../state/selectors/message';
import type { ProcessedQuote } from '../textsecure/Types';
@ -18,16 +14,15 @@ import { getQuoteBodyText } from '../util/getQuoteBodyText';
import { isQuoteAMatch, messageHasPaymentEvent } from './helpers';
import * as Errors from '../types/errors';
import { isDownloadable } from '../types/Attachment';
import type { MessageModel } from '../models/messages';
export type MinimalMessageCache = Readonly<{
findBySentAt(
sentAt: number,
predicate: (attributes: ReadonlyMessageAttributesType) => boolean
): Promise<MessageAttributesType | undefined>;
upgradeSchema(
attributes: MessageAttributesType,
minSchemaVersion: number
): Promise<MessageAttributesType>;
predicate: (attributes: MessageModel) => boolean
): Promise<MessageModel | undefined>;
upgradeSchema(message: MessageModel, minSchemaVersion: number): Promise<void>;
register(message: MessageModel): MessageModel;
}>;
export type CopyQuoteOptionsType = Readonly<{
@ -57,8 +52,11 @@ export const copyFromQuotedMessage = async (
isViewOnce: false,
};
const queryMessage = await messageCache.findBySentAt(id, attributes =>
isQuoteAMatch(attributes, conversationId, result)
const queryMessage = await messageCache.findBySentAt(
id,
(message: MessageModel) => {
return isQuoteAMatch(message.attributes, conversationId, result);
}
);
if (queryMessage == null) {
@ -74,21 +72,19 @@ export const copyFromQuotedMessage = async (
};
export const copyQuoteContentFromOriginal = async (
providedOriginalMessage: MessageAttributesType,
message: MessageModel,
quote: QuotedMessageType,
{ messageCache = window.MessageCache }: CopyQuoteOptionsType = {}
): Promise<void> => {
let originalMessage = providedOriginalMessage;
const { attachments } = quote;
const firstAttachment = attachments ? attachments[0] : undefined;
if (messageHasPaymentEvent(originalMessage)) {
if (messageHasPaymentEvent(message.attributes)) {
// eslint-disable-next-line no-param-reassign
quote.payment = originalMessage.payment;
quote.payment = message.get('payment');
}
if (isTapToView(originalMessage)) {
if (isTapToView(message.attributes)) {
// eslint-disable-next-line no-param-reassign
quote.text = undefined;
// eslint-disable-next-line no-param-reassign
@ -103,7 +99,7 @@ export const copyQuoteContentFromOriginal = async (
return;
}
const isMessageAGiftBadge = isGiftBadge(originalMessage);
const isMessageAGiftBadge = isGiftBadge(message.attributes);
if (isMessageAGiftBadge !== quote.isGiftBadge) {
log.warn(
`copyQuoteContentFromOriginal: Quote.isGiftBadge: ${quote.isGiftBadge}, isGiftBadge(message): ${isMessageAGiftBadge}`
@ -124,18 +120,18 @@ export const copyQuoteContentFromOriginal = async (
quote.isViewOnce = false;
// eslint-disable-next-line no-param-reassign
quote.text = getQuoteBodyText(originalMessage, quote.id);
quote.text = getQuoteBodyText(message.attributes, quote.id);
// eslint-disable-next-line no-param-reassign
quote.bodyRanges = originalMessage.bodyRanges;
quote.bodyRanges = message.attributes.bodyRanges;
if (!firstAttachment || !firstAttachment.contentType) {
return;
}
try {
originalMessage = await messageCache.upgradeSchema(
originalMessage,
await messageCache.upgradeSchema(
message,
window.Signal.Types.Message.VERSION_NEEDED_FOR_DISPLAY
);
} catch (error) {
@ -150,7 +146,7 @@ export const copyQuoteContentFromOriginal = async (
attachments: queryAttachments = [],
preview: queryPreview = [],
sticker,
} = originalMessage;
} = message.attributes;
if (queryAttachments.length > 0) {
const queryFirst = queryAttachments[0];

View file

@ -2,20 +2,15 @@
// SPDX-License-Identifier: AGPL-3.0-only
import * as log from '../logging/log';
import { DataReader } from '../sql/Client';
import type { MessageAttributesType } from '../model-types.d';
import * as Errors from '../types/errors';
import type { MessageModel } from '../models/messages';
import { DataReader } from '../sql/Client';
import { MessageModel } from '../models/messages';
import type { MessageAttributesType } from '../model-types.d';
export async function __DEPRECATED$getMessageById(
messageId: string,
location: string
export async function getMessageById(
messageId: string
): Promise<MessageModel | undefined> {
const innerLocation = `__DEPRECATED$getMessageById/${location}`;
const message = window.MessageCache.__DEPRECATED$getById(
messageId,
innerLocation
);
const message = window.MessageCache.getById(messageId);
if (message) {
return message;
}
@ -34,9 +29,5 @@ export async function __DEPRECATED$getMessageById(
return undefined;
}
return window.MessageCache.__DEPRECATED$register(
found.id,
found,
innerLocation
);
return window.MessageCache.register(new MessageModel(found));
}

View file

@ -3,23 +3,18 @@
import * as log from '../logging/log';
import { DataReader } from '../sql/Client';
import type { MessageModel } from '../models/messages';
import { MessageModel } from '../models/messages';
import type { MessageAttributesType } from '../model-types.d';
import * as Errors from '../types/errors';
export async function getMessagesById(
messageIds: Iterable<string>,
location: string
messageIds: Iterable<string>
): Promise<Array<MessageModel>> {
const innerLocation = `getMessagesById/${location}`;
const messagesFromMemory: Array<MessageModel> = [];
const messageIdsToLookUpInDatabase: Array<string> = [];
for (const messageId of messageIds) {
const message = window.MessageCache.__DEPRECATED$getById(
messageId,
innerLocation
);
const message = window.MessageCache.getById(messageId);
if (message) {
messagesFromMemory.push(message);
} else {
@ -41,15 +36,8 @@ export async function getMessagesById(
return [];
}
const messagesFromDatabase = rawMessagesFromDatabase.map(rawMessage => {
// We use `window.Whisper.Message` instead of `MessageModel` here to avoid a circular
// import.
const message = new window.Whisper.Message(rawMessage);
return window.MessageCache.__DEPRECATED$register(
message.id,
message,
innerLocation
);
const messagesFromDatabase = rawMessagesFromDatabase.map(message => {
return window.MessageCache.register(new MessageModel(message));
});
return [...messagesFromMemory, ...messagesFromDatabase];

View file

@ -0,0 +1,817 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { isNumber, partition } from 'lodash';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import * as MIME from '../types/MIME';
import * as LinkPreview from '../types/LinkPreview';
import { getAuthor, isStory, messageHasPaymentEvent } from './helpers';
import { getMessageIdForLogging } from '../util/idForLogging';
import { getSenderIdentifier } from '../util/getSenderIdentifier';
import { isNormalNumber } from '../util/isNormalNumber';
import { getOwn } from '../util/getOwn';
import {
SendActionType,
sendStateReducer,
SendStatus,
} from './MessageSendState';
import { DataReader, DataWriter } from '../sql/Client';
import { eraseMessageContents, postSaveUpdates } from '../util/cleanup';
import {
isDirectConversation,
isGroup,
isGroupV1,
} from '../util/whatTypeOfConversation';
import { generateMessageId } from '../util/generateMessageId';
import {
hasErrors,
isEndSession,
isExpirationTimerUpdate,
isGroupUpdate,
isTapToView,
isUnsupportedMessage,
} from '../state/selectors/message';
import { drop } from '../util/drop';
import { strictAssert } from '../util/assert';
import { isAciString } from '../util/isAciString';
import { copyFromQuotedMessage } from './copyQuote';
import { findStoryMessages } from '../util/findStoryMessage';
import { getRoomIdFromCallLink } from '../util/callLinksRingrtc';
import { isNotNil } from '../util/isNotNil';
import { normalizeServiceId } from '../types/ServiceId';
import { BodyRange } from '../types/BodyRange';
import { hydrateStoryContext } from '../util/hydrateStoryContext';
import { isMessageEmpty } from '../util/isMessageEmpty';
import { isValidTapToView } from '../util/isValidTapToView';
import { getNotificationTextForMessage } from '../util/getNotificationTextForMessage';
import { getMessageAuthorText } from '../util/getMessageAuthorText';
import { GiftBadgeStates } from '../components/conversation/Message';
import { getUserLanguages } from '../util/userLanguages';
import { parseBoostBadgeListFromServer } from '../badges/parseBadgesFromServer';
import { SignalService as Proto } from '../protobuf';
import {
modifyTargetMessage,
ModifyTargetMessageResult,
} from '../util/modifyTargetMessage';
import { saveAndNotify } from './saveAndNotify';
import { MessageModel } from '../models/messages';
import type { SentEventData } from '../textsecure/messageReceiverEvents';
import type {
ProcessedDataMessage,
ProcessedUnidentifiedDeliveryStatus,
} from '../textsecure/Types';
import type { ServiceIdString } from '../types/ServiceId';
import type { LinkPreviewType } from '../types/message/LinkPreviews';
const CURRENT_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.CURRENT;
const INITIAL_PROTOCOL_VERSION = Proto.DataMessage.ProtocolVersion.INITIAL;
export async function handleDataMessage(
message: MessageModel,
initialMessage: ProcessedDataMessage,
confirm: () => void,
options: { data?: SentEventData } = {}
): Promise<void> {
const { data } = options;
const { upgradeMessageSchema } = window.Signal.Migrations;
// This function is called from the background script in a few scenarios:
// 1. on an incoming message
// 2. on a sent message sync'd from another device
// 3. in rare cases, an incoming message can be retried, though it will
// still go through one of the previous two codepaths
const source = message.get('source');
const sourceServiceId = message.get('sourceServiceId');
const type = message.get('type');
const conversationId = message.get('conversationId');
const fromContact = getAuthor(message.attributes);
if (fromContact) {
fromContact.setRegistered();
}
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conversation = window.ConversationController.get(conversationId)!;
const idLog = `handleDataMessage/${conversation.idForLogging()} ${getMessageIdForLogging(message.attributes)}`;
await conversation.queueJob(idLog, async () => {
log.info(`${idLog}: starting processing in queue`);
// First, check for duplicates. If we find one, stop processing here.
const senderIdentifier = getSenderIdentifier(message.attributes);
const inMemoryMessage = window.MessageCache.findBySender(senderIdentifier);
if (inMemoryMessage) {
log.info(`${idLog}: cache hit`, senderIdentifier);
} else {
log.info(`${idLog}: duplicate check db lookup needed`, senderIdentifier);
}
let existingMessage = inMemoryMessage;
if (!existingMessage) {
const fromDb = await DataReader.getMessageBySender(message.attributes);
existingMessage = fromDb
? window.MessageCache.register(new MessageModel(fromDb))
: undefined;
}
const isUpdate = Boolean(data && data.isRecipientUpdate);
const isDuplicateMessage =
existingMessage &&
(type === 'incoming' ||
(type === 'story' &&
existingMessage.get('storyDistributionListId') ===
message.attributes.storyDistributionListId));
if (isDuplicateMessage) {
log.warn(
`${idLog}: Received duplicate message`,
getMessageIdForLogging(message.attributes)
);
confirm();
return;
}
if (type === 'outgoing') {
if (isUpdate && existingMessage) {
log.info(
`${idLog}: Updating message ${getMessageIdForLogging(message.attributes)} with received transcript`
);
const toUpdate = window.MessageCache.register(existingMessage);
const unidentifiedDeliveriesSet = new Set<string>(
toUpdate.get('unidentifiedDeliveries') ?? []
);
const sendStateByConversationId = {
...(toUpdate.get('sendStateByConversationId') || {}),
};
const unidentifiedStatus: Array<ProcessedUnidentifiedDeliveryStatus> =
data && Array.isArray(data.unidentifiedStatus)
? data.unidentifiedStatus
: [];
unidentifiedStatus.forEach(
({ destinationServiceId, destination, unidentified }) => {
const identifier = destinationServiceId || destination;
if (!identifier) {
return;
}
const destinationConversation =
window.ConversationController.lookupOrCreate({
serviceId: destinationServiceId,
e164: destination || undefined,
reason: `handleDataMessage(${initialMessage.timestamp})`,
});
if (!destinationConversation) {
return;
}
const updatedAt: number =
data && isNormalNumber(data.timestamp)
? data.timestamp
: Date.now();
const previousSendState = getOwn(
sendStateByConversationId,
destinationConversation.id
);
sendStateByConversationId[destinationConversation.id] =
previousSendState
? sendStateReducer(previousSendState, {
type: SendActionType.Sent,
updatedAt,
})
: {
status: SendStatus.Sent,
updatedAt,
};
if (unidentified) {
unidentifiedDeliveriesSet.add(identifier);
}
}
);
toUpdate.set({
sendStateByConversationId,
unidentifiedDeliveries: [...unidentifiedDeliveriesSet],
});
await DataWriter.saveMessage(toUpdate.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
confirm();
return;
}
if (isUpdate) {
log.warn(
`${idLog}: Received update transcript, but no existing entry for message ${getMessageIdForLogging(message.attributes)}. Dropping.`
);
confirm();
return;
}
if (existingMessage) {
// TODO: (DESKTOP-7301): improve this check in case previous message is not yet
// registered in memory
log.warn(
`${idLog}: Received duplicate transcript for message ${getMessageIdForLogging(message.attributes)}, but it was not an update transcript. Dropping.`
);
confirm();
return;
}
}
// GroupV2
if (initialMessage.groupV2) {
if (isGroupV1(conversation.attributes)) {
// If we received a GroupV2 message in a GroupV1 group, we migrate!
const { revision, groupChange } = initialMessage.groupV2;
await window.Signal.Groups.respondToGroupV2Migration({
conversation,
groupChange: groupChange
? {
base64: groupChange,
isTrusted: false,
}
: undefined,
newRevision: revision,
receivedAt: message.get('received_at'),
sentAt: message.get('sent_at'),
});
} else if (
initialMessage.groupV2.masterKey &&
initialMessage.groupV2.secretParams &&
initialMessage.groupV2.publicParams
) {
// Repair core GroupV2 data if needed
await conversation.maybeRepairGroupV2({
masterKey: initialMessage.groupV2.masterKey,
secretParams: initialMessage.groupV2.secretParams,
publicParams: initialMessage.groupV2.publicParams,
});
const existingRevision = conversation.get('revision');
const isFirstUpdate = !isNumber(existingRevision);
// Standard GroupV2 modification codepath
const isV2GroupUpdate =
initialMessage.groupV2 &&
isNumber(initialMessage.groupV2.revision) &&
(isFirstUpdate || initialMessage.groupV2.revision > existingRevision);
if (isV2GroupUpdate && initialMessage.groupV2) {
const { revision, groupChange } = initialMessage.groupV2;
try {
await window.Signal.Groups.maybeUpdateGroup({
conversation,
groupChange: groupChange
? {
base64: groupChange,
isTrusted: false,
}
: undefined,
newRevision: revision,
receivedAt: message.get('received_at'),
sentAt: message.get('sent_at'),
});
} catch (error) {
const errorText = Errors.toLogFormat(error);
log.error(
`${idLog}: Failed to process group update as part of message ${getMessageIdForLogging(message.attributes)}: ${errorText}`
);
throw error;
}
}
}
}
const ourAci = window.textsecure.storage.user.getCheckedAci();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const sender = window.ConversationController.lookupOrCreate({
e164: source,
serviceId: sourceServiceId,
reason: 'handleDataMessage',
})!;
const hasGroupV2Prop = Boolean(initialMessage.groupV2);
// Drop if from blocked user. Only GroupV2 messages should need to be dropped here.
const isBlocked =
(source && window.storage.blocked.isBlocked(source)) ||
(sourceServiceId &&
window.storage.blocked.isServiceIdBlocked(sourceServiceId));
if (isBlocked) {
log.info(
`${idLog}: Dropping message from blocked sender. hasGroupV2Prop: ${hasGroupV2Prop}`
);
confirm();
return;
}
const areWeMember =
!conversation.get('left') && conversation.hasMember(ourAci);
// Drop an incoming GroupV2 message if we or the sender are not part of the group
// after applying the message's associated group changes.
if (
type === 'incoming' &&
!isDirectConversation(conversation.attributes) &&
hasGroupV2Prop &&
(!areWeMember ||
(sourceServiceId && !conversation.hasMember(sourceServiceId)))
) {
log.warn(
`${idLog}: Received message destined for group, which we or the sender are not a part of. Dropping.`
);
confirm();
return;
}
// We drop incoming messages for v1 groups we already know about, which we're not
// a part of, except for group updates. Because group v1 updates haven't been
// applied by this point.
// Note: if we have no information about a group at all, we will accept those
// messages. We detect that via a missing 'members' field.
if (
type === 'incoming' &&
!isDirectConversation(conversation.attributes) &&
!hasGroupV2Prop &&
conversation.get('members') &&
!areWeMember
) {
log.warn(
`Received message destined for group ${conversation.idForLogging()}, which we're not a part of. Dropping.`
);
confirm();
return;
}
// Drop incoming messages to announcement only groups where sender is not admin
if (conversation.get('announcementsOnly')) {
const senderServiceId = sender.getServiceId();
if (!senderServiceId || !conversation.isAdmin(senderServiceId)) {
confirm();
return;
}
}
const messageId =
message.get('id') || generateMessageId(message.get('received_at')).id;
// Send delivery receipts, but only for non-story sealed sender messages
// and not for messages from unaccepted conversations
if (
type === 'incoming' &&
message.get('unidentifiedDeliveryReceived') &&
!hasErrors(message.attributes) &&
conversation.getAccepted()
) {
// Note: We both queue and batch because we want to wait until we are done
// processing incoming messages to start sending outgoing delivery receipts.
// The queue can be paused easily.
drop(
window.Whisper.deliveryReceiptQueue.add(() => {
strictAssert(
isAciString(sourceServiceId),
'Incoming message must be from ACI'
);
window.Whisper.deliveryReceiptBatcher.add({
messageId,
conversationId,
senderE164: source,
senderAci: sourceServiceId,
timestamp: message.get('sent_at'),
isDirectConversation: isDirectConversation(conversation.attributes),
});
})
);
}
const { storyContext } = initialMessage;
let storyContextLogId = 'no storyContext';
if (storyContext) {
storyContextLogId =
`storyContext(${storyContext.sentTimestamp}, ` +
`${storyContext.authorAci})`;
}
// Ensure that quote author's conversation exist
if (initialMessage.quote) {
window.ConversationController.lookupOrCreate({
serviceId: initialMessage.quote.authorAci,
reason: 'handleDataMessage.quote.author',
});
}
const [quote, storyQuotes] = await Promise.all([
initialMessage.quote
? copyFromQuotedMessage(initialMessage.quote, conversation.id)
: undefined,
findStoryMessages(conversation.id, storyContext),
]);
const storyQuote = storyQuotes.find(candidateQuote => {
const sendStateByConversationId =
candidateQuote.sendStateByConversationId || {};
const sendState = sendStateByConversationId[sender.id];
const storyQuoteIsFromSelf =
candidateQuote.sourceServiceId === window.storage.user.getCheckedAci();
if (!storyQuoteIsFromSelf) {
return true;
}
// The sender is not a recipient for this story
if (sendState === undefined) {
return false;
}
// Group replies are always allowed
if (!isDirectConversation(conversation.attributes)) {
return true;
}
// For 1:1 stories, we need to check if they can be replied to
return sendState.isAllowedToReplyToStory !== false;
});
if (
storyContext &&
!storyQuote &&
!isDirectConversation(conversation.attributes)
) {
log.warn(
`${idLog}: Received ${storyContextLogId} message in group but no matching story. Dropping.`
);
confirm();
return;
}
if (storyQuote) {
const { storyDistributionListId } = storyQuote;
if (storyDistributionListId) {
const storyDistribution =
await DataReader.getStoryDistributionWithMembers(
storyDistributionListId
);
if (!storyDistribution) {
log.warn(
`${idLog}: Received ${storyContextLogId} message for story with no associated distribution list. Dropping.`
);
confirm();
return;
}
if (!storyDistribution.allowsReplies) {
log.warn(
`${idLog}: Received ${storyContextLogId} message but distribution list does not allow replies. Dropping.`
);
confirm();
return;
}
}
}
const withQuoteReference = {
...message.attributes,
...initialMessage,
quote,
storyId: storyQuote?.id,
};
// There are type conflicts between ModelAttributesType and protos passed in here
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const dataMessage = await upgradeMessageSchema(withQuoteReference as any);
const isGroupStoryReply =
isGroup(conversation.attributes) && dataMessage.storyId;
try {
const now = new Date().getTime();
const urls = LinkPreview.findLinks(dataMessage.body || '');
const incomingPreview = dataMessage.preview || [];
const preview = incomingPreview
.map((item: LinkPreviewType) => {
if (LinkPreview.isCallLink(item.url)) {
return {
...item,
isCallLink: true,
callLinkRoomId: getRoomIdFromCallLink(item.url),
};
}
if (!item.image && !item.title) {
return null;
}
// Story link previews don't have to correspond to links in the
// message body.
if (isStory(message.attributes)) {
return item;
}
if (
!urls.includes(item.url) ||
!LinkPreview.shouldPreviewHref(item.url)
) {
return undefined;
}
return item;
})
.filter(isNotNil);
if (preview.length < incomingPreview.length) {
log.info(
`${getMessageIdForLogging(message.attributes)}: Eliminated ${
incomingPreview.length - preview.length
} previews with invalid urls'`
);
}
const ourPni = window.textsecure.storage.user.getCheckedPni();
const ourServiceIds: Set<ServiceIdString> = new Set([ourAci, ourPni]);
const [longMessageAttachments, normalAttachments] = partition(
dataMessage.attachments ?? [],
attachment => MIME.isLongMessage(attachment.contentType)
);
// eslint-disable-next-line no-param-reassign
message = window.MessageCache.register(message);
message.set({
id: messageId,
attachments: normalAttachments,
body: dataMessage.body,
bodyAttachment: longMessageAttachments[0],
bodyRanges: dataMessage.bodyRanges,
contact: dataMessage.contact,
conversationId: conversation.id,
decrypted_at: now,
errors: [],
flags: dataMessage.flags,
giftBadge: initialMessage.giftBadge,
hasAttachments: dataMessage.hasAttachments,
hasFileAttachments: dataMessage.hasFileAttachments,
hasVisualMediaAttachments: dataMessage.hasVisualMediaAttachments,
isViewOnce: Boolean(dataMessage.isViewOnce),
mentionsMe: (dataMessage.bodyRanges ?? []).some(bodyRange => {
if (!BodyRange.isMention(bodyRange)) {
return false;
}
return ourServiceIds.has(
normalizeServiceId(
bodyRange.mentionAci,
'handleDataMessage: mentionsMe check'
)
);
}),
preview,
requiredProtocolVersion:
dataMessage.requiredProtocolVersion || INITIAL_PROTOCOL_VERSION,
supportedVersionAtReceive: CURRENT_PROTOCOL_VERSION,
payment: dataMessage.payment,
quote: dataMessage.quote,
schemaVersion: dataMessage.schemaVersion,
sticker: dataMessage.sticker,
storyId: dataMessage.storyId,
});
if (storyQuote) {
await hydrateStoryContext(message.id, storyQuote, {
shouldSave: true,
});
}
const isSupported = !isUnsupportedMessage(message.attributes);
if (!isSupported) {
await eraseMessageContents(message);
}
if (isSupported) {
const attributes = {
...conversation.attributes,
};
// Drop empty messages after. This needs to happen after the initial
// message.set call and after GroupV1 processing to make sure all possible
// properties are set before we determine that a message is empty.
if (isMessageEmpty(message.attributes)) {
log.info(`${idLog}: Dropping empty message`);
confirm();
return;
}
if (isStory(message.attributes)) {
attributes.hasPostedStory = true;
} else {
attributes.active_at = now;
}
conversation.set(attributes);
// Sync group story reply expiration timers with the parent story's
// expiration timer
if (isGroupStoryReply && storyQuote) {
message.set({
expireTimer: storyQuote.expireTimer,
expirationStartTimestamp: storyQuote.expirationStartTimestamp,
});
}
if (dataMessage.expireTimer && !isExpirationTimerUpdate(dataMessage)) {
message.set({ expireTimer: dataMessage.expireTimer });
if (isStory(message.attributes)) {
log.info(`${idLog}: Starting story expiration`);
message.set({
expirationStartTimestamp: dataMessage.timestamp,
});
}
}
if (!hasGroupV2Prop && !isStory(message.attributes)) {
if (isExpirationTimerUpdate(message.attributes)) {
message.set({
expirationTimerUpdate: {
source,
sourceServiceId,
expireTimer: initialMessage.expireTimer,
},
});
if (conversation.get('expireTimer') !== dataMessage.expireTimer) {
log.info('Incoming expirationTimerUpdate changed timer', {
id: conversation.idForLogging(),
expireTimer: dataMessage.expireTimer || 'disabled',
source: idLog,
});
conversation.set({
expireTimer: dataMessage.expireTimer,
});
}
}
// Note: For incoming expire timer updates (not normal messages that come
// along with an expireTimer), the conversation will be updated by this
// point and these calls will return early.
if (dataMessage.expireTimer) {
void conversation.updateExpirationTimer(dataMessage.expireTimer, {
source: sourceServiceId || source,
receivedAt: message.get('received_at'),
receivedAtMS: message.get('received_at_ms'),
sentAt: message.get('sent_at'),
reason: idLog,
version: initialMessage.expireTimerVersion,
});
} else if (
// We won't turn off timers for these kinds of messages:
!isGroupUpdate(message.attributes) &&
!isEndSession(message.attributes)
) {
void conversation.updateExpirationTimer(undefined, {
source: sourceServiceId || source,
receivedAt: message.get('received_at'),
receivedAtMS: message.get('received_at_ms'),
sentAt: message.get('sent_at'),
reason: idLog,
version: initialMessage.expireTimerVersion,
});
}
}
if (initialMessage.profileKey) {
const { profileKey } = initialMessage;
if (
source === window.textsecure.storage.user.getNumber() ||
sourceServiceId === window.textsecure.storage.user.getAci()
) {
conversation.set({ profileSharing: true });
} else if (isDirectConversation(conversation.attributes)) {
drop(
conversation.setProfileKey(profileKey, {
reason: 'handleDataMessage',
})
);
} else {
const local = window.ConversationController.lookupOrCreate({
e164: source,
serviceId: sourceServiceId,
reason: 'handleDataMessage:setProfileKey',
});
drop(
local?.setProfileKey(profileKey, {
reason: 'handleDataMessage',
})
);
}
}
if (isTapToView(message.attributes) && type === 'outgoing') {
await eraseMessageContents(message);
}
if (
type === 'incoming' &&
isTapToView(message.attributes) &&
!isValidTapToView(message.attributes)
) {
log.warn(
`${idLog}: Received tap to view message with invalid data. Erasing contents.`
);
message.set({
isTapToViewInvalid: true,
});
await eraseMessageContents(message);
}
}
const conversationTimestamp = conversation.get('timestamp');
if (
!isStory(message.attributes) &&
!isGroupStoryReply &&
(!conversationTimestamp ||
message.get('sent_at') > conversationTimestamp) &&
messageHasPaymentEvent(message.attributes)
) {
conversation.set({
lastMessage: getNotificationTextForMessage(message.attributes),
lastMessageAuthor: getMessageAuthorText(message.attributes),
timestamp: message.get('sent_at'),
});
}
// eslint-disable-next-line no-param-reassign
message = window.MessageCache.register(message);
conversation.incrementMessageCount();
// If we sent a message in a given conversation, unarchive it!
if (type === 'outgoing') {
conversation.setArchived(false);
}
await DataWriter.updateConversation(conversation.attributes);
const giftBadge = message.get('giftBadge');
if (giftBadge && giftBadge.state !== GiftBadgeStates.Failed) {
const { level } = giftBadge;
const { updatesUrl } = window.SignalContext.config;
strictAssert(
typeof updatesUrl === 'string',
'getProfile: expected updatesUrl to be a defined string'
);
const userLanguages = getUserLanguages(
window.SignalContext.getPreferredSystemLocales(),
window.SignalContext.getResolvedMessagesLocale()
);
const { messaging } = window.textsecure;
if (!messaging) {
throw new Error(`${idLog}: messaging is not available`);
}
const response =
await messaging.server.getSubscriptionConfiguration(userLanguages);
const boostBadgesByLevel = parseBoostBadgeListFromServer(
response,
updatesUrl
);
const badge = boostBadgesByLevel[level];
if (!badge) {
log.error(
`${idLog}: gift badge with level ${level} not found on server`
);
} else {
await window.reduxActions.badges.updateOrCreate([badge]);
giftBadge.id = badge.id;
}
}
const result = await modifyTargetMessage(message, conversation, {
isFirstRun: true,
skipEdits: false,
});
if (result === ModifyTargetMessageResult.Deleted) {
confirm();
return;
}
log.info(`${idLog}: Batching save`);
drop(saveAndNotify(message, conversation, confirm));
} catch (error) {
const errorForLog = Errors.toLogFormat(error);
log.error(`${idLog}: error:`, errorForLog);
throw error;
}
});
}

View file

@ -12,6 +12,7 @@ import type { MessageAttributesType } from '../model-types.d';
import type { AciString } from '../types/ServiceId';
import * as Errors from '../types/errors';
import { DataReader, DataWriter } from '../sql/Client';
import { postSaveUpdates } from '../util/cleanup';
const MAX_CONCURRENCY = 5;
@ -57,7 +58,7 @@ export async function _migrateMessageData({
) => Promise<Array<MessageAttributesType>>;
saveMessagesIndividually: (
data: ReadonlyArray<MessageAttributesType>,
options: { ourAci: AciString }
options: { ourAci: AciString; postSaveUpdates: () => Promise<void> }
) => Promise<{ failedIndices: Array<number> }>;
incrementMessagesMigrationAttempts: (
messageIds: ReadonlyArray<string>
@ -122,6 +123,7 @@ export async function _migrateMessageData({
upgradedMessages,
{
ourAci,
postSaveUpdates,
}
);

View file

@ -0,0 +1,72 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import * as log from '../logging/log';
import { explodePromise } from '../util/explodePromise';
import { saveNewMessageBatcher } from '../util/messageBatcher';
import { handleAttachmentDownloadsForNewMessage } from '../util/queueAttachmentDownloads';
import {
modifyTargetMessage,
ModifyTargetMessageResult,
} from '../util/modifyTargetMessage';
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
import { isStory } from './helpers';
import { drop } from '../util/drop';
import type { ConversationModel } from '../models/conversations';
import type { MessageModel } from '../models/messages';
export async function saveAndNotify(
message: MessageModel,
conversation: ConversationModel,
confirm: () => void
): Promise<void> {
const { resolve, promise } = explodePromise<void>();
try {
conversation.addSavePromise(promise);
await saveNewMessageBatcher.add(message.attributes);
log.info('Message saved', message.get('sent_at'));
// Once the message is saved to DB, we queue attachment downloads
await handleAttachmentDownloadsForNewMessage(message, conversation);
// We'd like to check for deletions before scheduling downloads, but if an edit
// comes in, we want to have kicked off attachment downloads for the original
// message.
const result = await modifyTargetMessage(message, conversation, {
isFirstRun: false,
skipEdits: false,
});
if (result === ModifyTargetMessageResult.Deleted) {
confirm();
return;
}
drop(conversation.onNewMessage(message));
if (await shouldReplyNotifyUser(message.attributes, conversation)) {
await conversation.notify(message.attributes);
}
// Increment the sent message count if this is an outgoing message
if (message.get('type') === 'outgoing') {
conversation.incrementSentMessageCount();
}
window.Whisper.events.trigger('incrementProgress');
confirm();
if (!isStory(message.attributes)) {
drop(
conversation.queueJob('updateUnread', () => conversation.updateUnread())
);
}
} finally {
resolve();
conversation.removeSavePromise(promise);
}
}

491
ts/messages/send.ts Normal file
View file

@ -0,0 +1,491 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { noop, union } from 'lodash';
import { filter, map } from '../util/iterables';
import { isNotNil } from '../util/isNotNil';
import { SendMessageProtoError } from '../textsecure/Errors';
import { getOwn } from '../util/getOwn';
import { isGroup } from '../util/whatTypeOfConversation';
import { handleMessageSend } from '../util/handleMessageSend';
import { getSendOptions } from '../util/getSendOptions';
import * as log from '../logging/log';
import { DataWriter } from '../sql/Client';
import {
getPropForTimestamp,
getChangesForPropAtTimestamp,
} from '../util/editHelpers';
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
import {
notifyStorySendFailed,
saveErrorsOnMessage,
} from '../test-node/util/messageFailures';
import { postSaveUpdates } from '../util/cleanup';
import { isCustomError } from './helpers';
import { SendActionType, isSent, sendStateReducer } from './MessageSendState';
import type { CustomError, MessageAttributesType } from '../model-types.d';
import type { CallbackResultType } from '../textsecure/Types.d';
import type { MessageModel } from '../models/messages';
import type { ServiceIdString } from '../types/ServiceId';
import type { SendStateByConversationId } from './MessageSendState';
/* eslint-disable more/no-then */
export async function send(
message: MessageModel,
{
promise,
saveErrors,
targetTimestamp,
}: {
promise: Promise<CallbackResultType | void | null>;
saveErrors?: (errors: Array<Error>) => void;
targetTimestamp: number;
}
): Promise<void> {
const conversation = window.ConversationController.get(
message.attributes.conversationId
);
const updateLeftPane = conversation?.debouncedUpdateLastMessage ?? noop;
updateLeftPane();
let result:
| { success: true; value: CallbackResultType }
| {
success: false;
value: CustomError | SendMessageProtoError;
};
try {
const value = await (promise as Promise<CallbackResultType>);
result = { success: true, value };
} catch (err) {
result = { success: false, value: err };
}
updateLeftPane();
const attributesToUpdate: Partial<MessageAttributesType> = {};
// This is used by sendSyncMessage, then set to null
if ('dataMessage' in result.value && result.value.dataMessage) {
attributesToUpdate.dataMessage = result.value.dataMessage;
} else if ('editMessage' in result.value && result.value.editMessage) {
attributesToUpdate.dataMessage = result.value.editMessage;
}
if (!message.doNotSave) {
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
const sendStateByConversationId = {
...(getPropForTimestamp({
log,
message: message.attributes,
prop: 'sendStateByConversationId',
targetTimestamp,
}) || {}),
};
const sendIsNotFinal =
'sendIsNotFinal' in result.value && result.value.sendIsNotFinal;
const sendIsFinal = !sendIsNotFinal;
// Capture successful sends
const successfulServiceIds: Array<ServiceIdString> =
sendIsFinal &&
'successfulServiceIds' in result.value &&
Array.isArray(result.value.successfulServiceIds)
? result.value.successfulServiceIds
: [];
const sentToAtLeastOneRecipient =
result.success || Boolean(successfulServiceIds.length);
successfulServiceIds.forEach(serviceId => {
const targetConversation = window.ConversationController.get(serviceId);
if (!targetConversation) {
return;
}
// If we successfully sent to a user, we can remove our unregistered flag.
if (targetConversation.isEverUnregistered()) {
targetConversation.setRegistered();
}
const previousSendState = getOwn(
sendStateByConversationId,
targetConversation.id
);
if (previousSendState) {
sendStateByConversationId[targetConversation.id] = sendStateReducer(
previousSendState,
{
type: SendActionType.Sent,
updatedAt: Date.now(),
}
);
}
});
// Integrate sends via sealed sender
const latestEditTimestamp = message.get('editMessageTimestamp');
const sendIsLatest =
!latestEditTimestamp || targetTimestamp === latestEditTimestamp;
const previousUnidentifiedDeliveries =
message.get('unidentifiedDeliveries') || [];
const newUnidentifiedDeliveries =
sendIsLatest &&
sendIsFinal &&
'unidentifiedDeliveries' in result.value &&
Array.isArray(result.value.unidentifiedDeliveries)
? result.value.unidentifiedDeliveries
: [];
const promises: Array<Promise<unknown>> = [];
// Process errors
let errors: Array<CustomError>;
if (result.value instanceof SendMessageProtoError && result.value.errors) {
({ errors } = result.value);
} else if (isCustomError(result.value)) {
errors = [result.value];
} else if (Array.isArray(result.value.errors)) {
({ errors } = result.value);
} else {
errors = [];
}
// In groups, we don't treat unregistered users as a user-visible
// error. The message will look successful, but the details
// screen will show that we didn't send to these unregistered users.
const errorsToSave: Array<CustomError> = [];
errors.forEach(error => {
const errorConversation =
window.ConversationController.get(error.serviceId) ||
window.ConversationController.get(error.number);
if (errorConversation && !saveErrors && sendIsFinal) {
const previousSendState = getOwn(
sendStateByConversationId,
errorConversation.id
);
if (previousSendState) {
sendStateByConversationId[errorConversation.id] = sendStateReducer(
previousSendState,
{
type: SendActionType.Failed,
updatedAt: Date.now(),
}
);
notifyStorySendFailed(message);
}
}
let shouldSaveError = true;
switch (error.name) {
case 'OutgoingIdentityKeyError': {
if (conversation) {
promises.push(
conversation.getProfiles().catch(() => {
/* nothing to do here; logging already happened */
})
);
}
break;
}
case 'UnregisteredUserError':
if (conversation && isGroup(conversation.attributes)) {
shouldSaveError = false;
}
// If we just found out that we couldn't send to a user because they are no
// longer registered, we will update our unregistered flag. In groups we
// will not event try to send to them for 6 hours. And we will never try
// to fetch them on startup again.
//
// The way to discover registration once more is:
// 1) any attempt to send to them in 1:1 conversation
// 2) the six-hour time period has passed and we send in a group again
conversation?.setUnregistered();
break;
default:
break;
}
if (shouldSaveError) {
errorsToSave.push(error);
}
});
// Only update the expirationStartTimestamp if we don't already have one set
if (!message.get('expirationStartTimestamp')) {
attributesToUpdate.expirationStartTimestamp = sentToAtLeastOneRecipient
? Date.now()
: undefined;
}
attributesToUpdate.unidentifiedDeliveries = union(
previousUnidentifiedDeliveries,
newUnidentifiedDeliveries
);
// We may overwrite this in the `saveErrors` call below.
attributesToUpdate.errors = [];
const additionalProps = getChangesForPropAtTimestamp({
log,
message: message.attributes,
prop: 'sendStateByConversationId',
targetTimestamp,
value: sendStateByConversationId,
});
message.set({ ...attributesToUpdate, ...additionalProps });
if (saveErrors) {
saveErrors(errorsToSave);
} else {
// We skip save because we'll save in the next step.
await saveErrorsOnMessage(message, errorsToSave, {
skipSave: true,
});
}
if (!message.doNotSave) {
await window.MessageCache.saveMessage(message);
}
updateLeftPane();
if (sentToAtLeastOneRecipient && !message.doNotSendSyncMessage) {
promises.push(sendSyncMessage(message, targetTimestamp));
}
await Promise.all(promises);
updateLeftPane();
}
export async function sendSyncMessageOnly(
message: MessageModel,
{
targetTimestamp,
dataMessage,
saveErrors,
}: {
targetTimestamp: number;
dataMessage: Uint8Array;
saveErrors?: (errors: Array<Error>) => void;
}
): Promise<CallbackResultType | void> {
const conv = window.ConversationController.get(
message.attributes.conversationId
);
message.set({ dataMessage });
const updateLeftPane = conv?.debouncedUpdateLastMessage;
try {
message.set({
// This is the same as a normal send()
expirationStartTimestamp: Date.now(),
errors: [],
});
const result = await sendSyncMessage(message, targetTimestamp);
message.set({
// We have to do this afterward, since we didn't have a previous send!
unidentifiedDeliveries:
result && result.unidentifiedDeliveries
? result.unidentifiedDeliveries
: undefined,
});
return result;
} catch (error) {
const resultErrors = error?.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.
await saveErrorsOnMessage(message, errors, {
skipSave: true,
});
}
throw error;
} finally {
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
if (updateLeftPane) {
updateLeftPane();
}
}
}
export async function sendSyncMessage(
message: MessageModel,
targetTimestamp: number
): Promise<CallbackResultType | void> {
const ourConversation =
window.ConversationController.getOurConversationOrThrow();
const sendOptions = await getSendOptions(ourConversation.attributes, {
syncMessage: true,
});
if (window.ConversationController.areWePrimaryDevice()) {
log.warn(
'sendSyncMessage: We are primary device; not sending sync message'
);
message.set({ dataMessage: undefined });
return;
}
const { messaging } = window.textsecure;
if (!messaging) {
throw new Error('sendSyncMessage: messaging not available!');
}
// eslint-disable-next-line no-param-reassign
message.syncPromise = message.syncPromise || Promise.resolve();
const next = async () => {
const dataMessage = message.get('dataMessage');
if (!dataMessage) {
return;
}
const originalTimestamp = getMessageSentTimestamp(message.attributes, {
includeEdits: false,
log,
});
const isSendingEdit = targetTimestamp !== originalTimestamp;
const isUpdate = Boolean(message.get('synced')) && !isSendingEdit;
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conv = window.ConversationController.get(
message.attributes.conversationId
)!;
const sendEntries = Object.entries(
getPropForTimestamp({
log,
message: message.attributes,
prop: 'sendStateByConversationId',
targetTimestamp,
}) || {}
);
const sentEntries = filter(sendEntries, ([_conversationId, { status }]) =>
isSent(status)
);
const allConversationIdsSentTo = map(
sentEntries,
([conversationId]) => conversationId
);
const conversationIdsSentTo = filter(
allConversationIdsSentTo,
conversationId => conversationId !== ourConversation.id
);
const unidentifiedDeliveries = message.get('unidentifiedDeliveries') || [];
const maybeConversationsWithSealedSender = map(
unidentifiedDeliveries,
identifier => window.ConversationController.get(identifier)
);
const conversationsWithSealedSender = filter(
maybeConversationsWithSealedSender,
isNotNil
);
const conversationIdsWithSealedSender = new Set(
map(conversationsWithSealedSender, c => c.id)
);
const encodedContent = isSendingEdit
? {
encodedEditMessage: dataMessage,
}
: {
encodedDataMessage: dataMessage,
};
return handleMessageSend(
messaging.sendSyncMessage({
...encodedContent,
timestamp: targetTimestamp,
destination: conv.get('e164'),
destinationServiceId: conv.getServiceId(),
expirationStartTimestamp:
message.get('expirationStartTimestamp') || null,
conversationIdsSentTo,
conversationIdsWithSealedSender,
isUpdate,
options: sendOptions,
urgent: false,
}),
// Note: in some situations, for doNotSave messages, the message has no
// id, so we provide an empty array here.
{ messageIds: message.id ? [message.id] : [], sendType: 'sentSync' }
).then(async result => {
let newSendStateByConversationId: undefined | SendStateByConversationId;
const sendStateByConversationId =
getPropForTimestamp({
log,
message: message.attributes,
prop: 'sendStateByConversationId',
targetTimestamp,
}) || {};
const ourOldSendState = getOwn(
sendStateByConversationId,
ourConversation.id
);
if (ourOldSendState) {
const ourNewSendState = sendStateReducer(ourOldSendState, {
type: SendActionType.Sent,
updatedAt: Date.now(),
});
if (ourNewSendState !== ourOldSendState) {
newSendStateByConversationId = {
...sendStateByConversationId,
[ourConversation.id]: ourNewSendState,
};
}
}
const attributesForUpdate = newSendStateByConversationId
? getChangesForPropAtTimestamp({
log,
message: message.attributes,
prop: 'sendStateByConversationId',
value: newSendStateByConversationId,
targetTimestamp,
})
: null;
message.set({
synced: true,
dataMessage: null,
...attributesForUpdate,
});
// Return early, skip the save
if (message.doNotSave) {
return result;
}
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
return result;
});
};
// eslint-disable-next-line no-param-reassign
message.syncPromise = message.syncPromise.then(next, next);
return message.syncPromise;
}

26
ts/model-types.d.ts vendored
View file

@ -71,8 +71,8 @@ export type CustomError = Error & {
export type GroupMigrationType = {
areWeInvited: boolean;
droppedMemberIds?: Array<string>;
invitedMembers?: Array<LegacyMigrationPendingMemberType>;
droppedMemberIds?: ReadonlyArray<string>;
invitedMembers?: ReadonlyArray<LegacyMigrationPendingMemberType>;
// We don't generate data like this; these were added to support import/export
droppedMemberCount?: number;
@ -113,7 +113,7 @@ type StoryReplyContextType = {
export type GroupV1Update = {
avatarUpdated?: boolean;
joined?: Array<string>;
joined?: ReadonlyArray<string>;
left?: string | 'You';
name?: string;
};
@ -130,11 +130,11 @@ export type MessageReactionType = {
// needs more usage of get/setPropForTimestamp. Also, these fields must match the fields
// in MessageAttributesType.
export type EditHistoryType = {
attachments?: Array<AttachmentType>;
attachments?: ReadonlyArray<AttachmentType>;
body?: string;
bodyAttachment?: AttachmentType;
bodyRanges?: ReadonlyArray<RawBodyRange>;
preview?: Array<LinkPreviewType>;
preview?: ReadonlyArray<LinkPreviewType>;
quote?: QuotedMessageType;
sendStateByConversationId?: SendStateByConversationId;
timestamp: number;
@ -178,7 +178,7 @@ export type MessageAttributesType = {
decrypted_at?: number;
deletedForEveryone?: boolean;
deletedForEveryoneTimestamp?: number;
errors?: Array<CustomError>;
errors?: ReadonlyArray<CustomError>;
expirationStartTimestamp?: number | null;
expireTimer?: DurationInSeconds;
groupMigration?: GroupMigrationType;
@ -190,7 +190,7 @@ export type MessageAttributesType = {
isErased?: boolean;
isTapToViewInvalid?: boolean;
isViewOnce?: boolean;
editHistory?: Array<EditHistoryType>;
editHistory?: ReadonlyArray<EditHistoryType>;
editMessageTimestamp?: number;
editMessageReceivedAt?: number;
editMessageReceivedAtMs?: number;
@ -220,12 +220,12 @@ export type MessageAttributesType = {
id: string;
type: MessageType;
body?: string;
attachments?: Array<AttachmentType>;
preview?: Array<LinkPreviewType>;
attachments?: ReadonlyArray<AttachmentType>;
preview?: ReadonlyArray<LinkPreviewType>;
sticker?: StickerType;
sent_at: number;
unidentifiedDeliveries?: Array<string>;
contact?: Array<EmbeddedContactType>;
unidentifiedDeliveries?: ReadonlyArray<string>;
contact?: ReadonlyArray<EmbeddedContactType>;
conversationId: string;
storyReaction?: {
emoji: string;
@ -286,8 +286,8 @@ export type MessageAttributesType = {
timestamp: number;
// Backwards-compatibility with prerelease data schema
invitedGV2Members?: Array<LegacyMigrationPendingMemberType>;
droppedGV2MemberIds?: Array<string>;
invitedGV2Members?: ReadonlyArray<LegacyMigrationPendingMemberType>;
droppedGV2MemberIds?: ReadonlyArray<string>;
sendHQImages?: boolean;

View file

@ -189,6 +189,9 @@ import { getCallHistorySelector } from '../state/selectors/callHistory';
import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus';
import { migrateLegacySendAttributes } from '../messages/migrateLegacySendAttributes';
import { getIsInitialSync } from '../services/contactSync';
import { queueAttachmentDownloadsForMessage } from '../util/queueAttachmentDownloads';
import { cleanupMessages, postSaveUpdates } from '../util/cleanup';
import { MessageModel } from './messages';
/* eslint-disable more/no-then */
window.Whisper = window.Whisper || {};
@ -385,7 +388,6 @@ export class ConversationModel extends window.Backbone
);
}
this.on('newmessage', this.onNewMessage);
this.on('change:profileKey', this.onChangeProfileKey);
this.on(
'change:name change:profileName change:profileFamilyName change:e164 ' +
@ -464,8 +466,6 @@ export class ConversationModel extends window.Backbone
SECOND
);
this.on('newmessage', this.throttledUpdateVerified);
const migratedColor = this.getColor();
if (this.get('color') !== migratedColor) {
this.set('color', migratedColor);
@ -1442,8 +1442,14 @@ export class ConversationModel extends window.Backbone
});
}
async onNewMessage(message: MessageAttributesType): Promise<void> {
const { sourceServiceId: serviceId, source: e164, sourceDevice } = message;
async onNewMessage(message: MessageModel): Promise<void> {
const {
sourceServiceId: serviceId,
source: e164,
sourceDevice,
storyId,
} = message.attributes;
this.throttledUpdateVerified?.();
const source = window.ConversationController.lookupOrCreate({
serviceId,
@ -1459,15 +1465,15 @@ export class ConversationModel extends window.Backbone
// If it's a group story reply or a story message, we don't want to update
// the last message or add new messages to redux.
const isGroupStoryReply = isGroup(this.attributes) && message.storyId;
if (isGroupStoryReply || isStory(message)) {
const isGroupStoryReply = isGroup(this.attributes) && storyId;
if (isGroupStoryReply || isStory(message.attributes)) {
return;
}
// Change to message request state if contact was removed and sent message.
if (
this.get('removalStage') === 'justNotification' &&
isIncoming(message)
isIncoming(message.attributes)
) {
this.set({
removalStage: 'messageRequest',
@ -1476,7 +1482,7 @@ export class ConversationModel extends window.Backbone
await DataWriter.updateConversation(this.attributes);
}
void this.addSingleMessage(message);
drop(this.addSingleMessage(message.attributes));
}
// New messages might arrive while we're in the middle of a bulk fetch from the
@ -1966,65 +1972,64 @@ export class ConversationModel extends window.Backbone
const hydrated = await Promise.all(
present.map(async message => {
let migratedMessage = message;
const model = window.MessageCache.register(new MessageModel(message));
let updated = false;
const readStatus = migrateLegacyReadStatus(migratedMessage);
const readStatus = migrateLegacyReadStatus(model.attributes);
if (readStatus !== undefined) {
migratedMessage = {
...migratedMessage,
updated = true;
model.set({
readStatus,
seenStatus:
readStatus === ReadStatus.Unread
? SeenStatus.Unseen
: SeenStatus.Seen,
};
});
}
if (ourConversationId) {
const sendStateByConversationId = migrateLegacySendAttributes(
migratedMessage,
model.attributes,
window.ConversationController.get.bind(
window.ConversationController
),
ourConversationId
);
if (sendStateByConversationId) {
migratedMessage = {
...migratedMessage,
updated = true;
model.set({
sendStateByConversationId,
};
});
}
}
const upgradedMessage = await window.MessageCache.upgradeSchema(
migratedMessage,
const startingAttributes = model.attributes;
await window.MessageCache.upgradeSchema(
model,
Message.VERSION_NEEDED_FOR_DISPLAY
);
if (startingAttributes !== model.attributes) {
updated = true;
}
const patch = await hydrateStoryContext(message.id, undefined, {
shouldSave: true,
});
const didMigrate = migratedMessage !== message;
const didUpgrade = upgradedMessage !== migratedMessage;
const didPatch = Boolean(patch);
if (didMigrate || didUpgrade || didPatch) {
upgraded += 1;
if (patch) {
updated = true;
model.set(patch);
}
if (didMigrate && !didUpgrade && !didPatch) {
await window.MessageCache.setAttributes({
messageId: message.id,
messageAttributes: migratedMessage,
skipSaveToDatabase: false,
if (updated) {
upgraded += 1;
const ourAci = window.textsecure.storage.user.getCheckedAci();
await DataWriter.saveMessage(model.attributes, {
ourAci,
postSaveUpdates,
});
}
if (patch) {
return { ...upgradedMessage, ...patch };
}
return upgradedMessage;
return model.attributes;
})
);
if (upgraded > 0) {
@ -2322,15 +2327,13 @@ export class ConversationModel extends window.Backbone
// eslint-disable-next-line no-await-in-loop
await Promise.all(
readMessages.map(async m => {
const registered = window.MessageCache.__DEPRECATED$register(
m.id,
m,
'handleReadAndDownloadAttachments'
);
const shouldSave = await registered.queueAttachmentDownloads();
const registered = window.MessageCache.register(new MessageModel(m));
const shouldSave =
await queueAttachmentDownloadsForMessage(registered);
if (shouldSave) {
await DataWriter.saveMessage(registered.attributes, {
ourAci,
postSaveUpdates,
});
}
})
@ -2354,7 +2357,7 @@ export class ConversationModel extends window.Backbone
? timestamp
: lastMessageTimestamp;
const message: MessageAttributesType = {
const message = new MessageModel({
...generateMessageId(incrementMessageCounter()),
conversationId: this.id,
type: 'message-request-response-event',
@ -2364,18 +2367,17 @@ export class ConversationModel extends window.Backbone
seenStatus: SeenStatus.NotApplicable,
timestamp,
messageRequestResponseEvent: event,
};
});
await DataWriter.saveMessage(message, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
await window.MessageCache.saveMessage(message, {
forceSave: true,
});
if (!getIsInitialSync() && !this.get('active_at')) {
this.set({ active_at: Date.now() });
await DataWriter.updateConversation(this.attributes);
}
window.MessageCache.toMessageAttributes(message);
this.trigger('newmessage', message);
window.MessageCache.register(message);
drop(this.onNewMessage(message));
drop(this.updateLastMessage());
}
@ -3120,7 +3122,7 @@ export class ConversationModel extends window.Backbone
receivedAt,
});
const message: MessageAttributesType = {
const message = new MessageModel({
...generateMessageId(receivedAtCounter),
conversationId: this.id,
type: 'chat-session-refreshed',
@ -3129,19 +3131,15 @@ export class ConversationModel extends window.Backbone
received_at_ms: receivedAt,
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
};
await DataWriter.saveMessage(message, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
});
window.MessageCache.__DEPRECATED$register(
message.id,
message,
'addChatSessionRefreshed'
);
this.trigger('newmessage', message);
void this.updateUnread();
await window.MessageCache.saveMessage(message, {
forceSave: true,
});
window.MessageCache.register(message);
drop(this.onNewMessage(message));
drop(this.updateUnread());
}
async addDeliveryIssue({
@ -3167,7 +3165,7 @@ export class ConversationModel extends window.Backbone
return;
}
const message: MessageAttributesType = {
const message = new MessageModel({
...generateMessageId(receivedAtCounter),
conversationId: this.id,
type: 'delivery-issue',
@ -3177,21 +3175,17 @@ export class ConversationModel extends window.Backbone
timestamp: receivedAt,
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
};
await DataWriter.saveMessage(message, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
});
window.MessageCache.__DEPRECATED$register(
message.id,
message,
'addDeliveryIssue'
);
this.trigger('newmessage', message);
await window.MessageCache.saveMessage(message, {
forceSave: true,
});
window.MessageCache.register(message);
await this.notify(message);
void this.updateUnread();
drop(this.onNewMessage(message));
drop(this.updateUnread());
await this.notify(message.attributes);
}
async addKeyChange(
@ -3216,7 +3210,7 @@ export class ConversationModel extends window.Backbone
}
const timestamp = Date.now();
const message: MessageAttributesType = {
const message = new MessageModel({
...generateMessageId(incrementMessageCounter()),
conversationId: this.id,
type: 'keychange',
@ -3227,19 +3221,14 @@ export class ConversationModel extends window.Backbone
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
};
});
await DataWriter.saveMessage(message, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
await window.MessageCache.saveMessage(message, {
forceSave: true,
});
window.MessageCache.__DEPRECATED$register(
message.id,
message,
'addKeyChange'
);
window.MessageCache.register(message);
this.trigger('newmessage', message);
drop(this.onNewMessage(message));
const serviceId = this.getServiceId();
@ -3277,7 +3266,7 @@ export class ConversationModel extends window.Backbone
);
const timestamp = Date.now();
const message: MessageAttributesType = {
const message = new MessageModel({
...generateMessageId(incrementMessageCounter()),
conversationId: this.id,
type: 'conversation-merge',
@ -3290,19 +3279,12 @@ export class ConversationModel extends window.Backbone
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
};
await DataWriter.saveMessage(message, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
forceSave: true,
});
window.MessageCache.__DEPRECATED$register(
message.id,
message,
'addConversationMerge'
);
this.trigger('newmessage', message);
await window.MessageCache.saveMessage(message, { forceSave: true });
window.MessageCache.register(message);
drop(this.onNewMessage(message));
}
async addPhoneNumberDiscoveryIfNeeded(originalPni: PniString): Promise<void> {
@ -3325,7 +3307,7 @@ export class ConversationModel extends window.Backbone
log.info(`${logId}: adding notification`);
const timestamp = Date.now();
const message: MessageAttributesType = {
const message = new MessageModel({
...generateMessageId(incrementMessageCounter()),
conversationId: this.id,
type: 'phone-number-discovery',
@ -3338,19 +3320,12 @@ export class ConversationModel extends window.Backbone
readStatus: ReadStatus.Read,
seenStatus: SeenStatus.Unseen,
schemaVersion: Message.VERSION_NEEDED_FOR_DISPLAY,
};
await DataWriter.saveMessage(message, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
forceSave: true,
});
window.MessageCache.__DEPRECATED$register(
message.id,
message,
'addPhoneNumberDiscoveryIfNeeded'
);
this.trigger('newmessage', message);
await window.MessageCache.saveMessage(message, { forceSave: true });
window.MessageCache.register(message);
drop(this.onNewMessage(message));
}
async addVerifiedChange(
@ -3373,7 +3348,7 @@ export class ConversationModel extends window.Backbone
);
const timestamp = Date.now();
const message: MessageAttributesType = {
const message = new MessageModel({
...generateMessageId(incrementMessageCounter()),
conversationId: this.id,
local: Boolean(options.local),
@ -3385,19 +3360,12 @@ export class ConversationModel extends window.Backbone
type: 'verified-change',
verified,
verifiedChanged: verifiedChangeId,
};
await DataWriter.saveMessage(message, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
forceSave: true,
});
window.MessageCache.__DEPRECATED$register(
message.id,
message,
'addVerifiedChange'
);
this.trigger('newmessage', message);
await window.MessageCache.saveMessage(message, { forceSave: true });
window.MessageCache.register(message);
drop(this.onNewMessage(message));
drop(this.updateUnread());
const serviceId = this.getServiceId();
@ -3417,7 +3385,7 @@ export class ConversationModel extends window.Backbone
conversationId?: string
): Promise<void> {
const now = Date.now();
const message: MessageAttributesType = {
const message = new MessageModel({
...generateMessageId(incrementMessageCounter()),
conversationId: this.id,
type: 'profile-change',
@ -3428,18 +3396,12 @@ export class ConversationModel extends window.Backbone
timestamp: now,
changedId: conversationId || this.id,
profileChange,
};
await DataWriter.saveMessage(message, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
});
window.MessageCache.__DEPRECATED$register(
message.id,
message,
'addProfileChange'
);
this.trigger('newmessage', message);
await window.MessageCache.saveMessage(message, { forceSave: true });
window.MessageCache.register(message);
drop(this.onNewMessage(message));
const serviceId = this.getServiceId();
if (isDirectConversation(this.attributes) && serviceId) {
@ -3460,7 +3422,7 @@ export class ConversationModel extends window.Backbone
extra: Partial<MessageAttributesType> = {}
): Promise<string> {
const now = Date.now();
const message: MessageAttributesType = {
const message = new MessageModel({
...generateMessageId(incrementMessageCounter()),
conversationId: this.id,
type,
@ -3472,18 +3434,12 @@ export class ConversationModel extends window.Backbone
seenStatus: SeenStatus.NotApplicable,
...extra,
};
await DataWriter.saveMessage(message, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
});
window.MessageCache.__DEPRECATED$register(
message.id,
message as MessageAttributesType,
'addNotification'
);
this.trigger('newmessage', message);
await window.MessageCache.saveMessage(message, { forceSave: true });
window.MessageCache.register(message);
drop(this.onNewMessage(message));
return message.id;
}
@ -3561,13 +3517,10 @@ export class ConversationModel extends window.Backbone
`maybeRemoveUniversalTimer(${this.idForLogging()}): removed notification`
);
const message = window.MessageCache.__DEPRECATED$getById(
notificationId,
'maybeRemoveUniversalTimer'
);
const message = window.MessageCache.getById(notificationId);
if (message) {
await DataWriter.removeMessage(message.id, {
singleProtoJobQueue,
cleanupMessages,
});
}
return true;
@ -3607,13 +3560,10 @@ export class ConversationModel extends window.Backbone
`maybeClearContactRemoved(${this.idForLogging()}): removed notification`
);
const message = window.MessageCache.__DEPRECATED$getById(
notificationId,
'maybeClearContactRemoved'
);
const message = window.MessageCache.getById(notificationId);
if (message) {
await DataWriter.removeMessage(message.id, {
singleProtoJobQueue,
cleanupMessages,
});
}
@ -4164,16 +4114,12 @@ export class ConversationModel extends window.Backbone
storyId,
});
window.MessageCache.__DEPRECATED$register(
attributes.id,
attributes,
'enqueueMessageForSend'
);
const model = window.MessageCache.register(new MessageModel(attributes));
const dbStart = Date.now();
strictAssert(
typeof attributes.timestamp === 'number',
typeof model.get('timestamp') === 'number',
'Expected a timestamp'
);
@ -4186,17 +4132,16 @@ export class ConversationModel extends window.Backbone
{
type: conversationQueueJobEnum.enum.NormalMessage,
conversationId: this.id,
messageId: attributes.id,
messageId: model.id,
revision: this.get('revision'),
},
async jobToInsert => {
log.info(
`enqueueMessageForSend: saving message ${attributes.id} and job ${jobToInsert.id}`
`enqueueMessageForSend: saving message ${model.id} and job ${jobToInsert.id}`
);
await DataWriter.saveMessage(attributes, {
await window.MessageCache.saveMessage(model, {
jobToInsert,
forceSave: true,
ourAci: window.textsecure.storage.user.getCheckedAci(),
});
}
);
@ -4212,14 +4157,14 @@ export class ConversationModel extends window.Backbone
const renderStart = Date.now();
// Perform asynchronous tasks before entering the batching mode
await this.beforeAddSingleMessage(attributes);
await this.beforeAddSingleMessage(model.attributes);
if (sticker) {
await addStickerPackReference(attributes.id, sticker.packId);
await addStickerPackReference(model.id, sticker.packId);
}
this.beforeMessageSend({
message: attributes,
message: model.attributes,
dontClearDraft,
dontAddMessage: false,
now,
@ -4364,21 +4309,27 @@ export class ConversationModel extends window.Backbone
)
);
let { preview, activity } = stats;
const { preview: previewAttributes, activity: activityAttributes } = stats;
let preview: MessageModel | undefined;
let activity: MessageModel | undefined;
// Get the in-memory message from MessageCache so that if it already exists
// in memory we use that data instead of the data from the db which may
// be out of date.
if (preview) {
const inMemory = window.MessageCache.accessAttributes(preview.id);
preview = inMemory || preview;
preview = (await this.cleanAttributes([preview]))?.[0] || preview;
if (previewAttributes) {
preview = window.MessageCache.register(
new MessageModel(previewAttributes)
);
const updates = (await this.cleanAttributes([preview.attributes]))?.[0];
preview.set(updates);
}
if (activity) {
const inMemory = window.MessageCache.accessAttributes(activity.id);
activity = inMemory || activity;
activity = (await this.cleanAttributes([activity]))?.[0] || activity;
if (activityAttributes) {
activity = window.MessageCache.register(
new MessageModel(activityAttributes)
);
const updates = (await this.cleanAttributes([activity.attributes]))?.[0];
activity.set(updates);
}
if (
@ -4386,7 +4337,7 @@ export class ConversationModel extends window.Backbone
this.get('draftTimestamp') &&
(!preview ||
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
preview.sent_at < this.get('draftTimestamp')!)
preview.get('sent_at') < this.get('draftTimestamp')!)
) {
return;
}
@ -4395,36 +4346,43 @@ export class ConversationModel extends window.Backbone
let lastMessageReceivedAt = this.get('lastMessageReceivedAt');
let lastMessageReceivedAtMs = this.get('lastMessageReceivedAtMs');
if (activity) {
const { callId } = activity;
const { callId } = activity.attributes;
const callHistory = callId
? getCallHistorySelector(window.reduxStore.getState())(callId)
: undefined;
timestamp = callHistory?.timestamp || activity.sent_at || timestamp;
lastMessageReceivedAt = activity.received_at || lastMessageReceivedAt;
timestamp =
callHistory?.timestamp || activity.get('sent_at') || timestamp;
lastMessageReceivedAt =
activity.get('received_at') || lastMessageReceivedAt;
lastMessageReceivedAtMs =
activity.received_at_ms || lastMessageReceivedAtMs;
activity.get('received_at_ms') || lastMessageReceivedAtMs;
}
const notificationData = preview
? getNotificationDataForMessage(preview)
? getNotificationDataForMessage(preview.attributes)
: undefined;
this.set({
lastMessage:
notificationData?.text ||
(preview ? getNotificationTextForMessage(preview) : undefined) ||
(preview
? getNotificationTextForMessage(preview.attributes)
: undefined) ||
'',
lastMessageBodyRanges: notificationData?.bodyRanges,
lastMessagePrefix: notificationData?.emoji,
lastMessageAuthor: getMessageAuthorText(preview),
lastMessageStatus:
(preview ? getMessagePropStatus(preview, ourConversationId) : null) ||
null,
lastMessageAuthor: preview
? getMessageAuthorText(preview.attributes)
: undefined,
lastMessageStatus: preview
? getMessagePropStatus(preview.attributes, ourConversationId)
: undefined,
lastMessageReceivedAt,
lastMessageReceivedAtMs,
timestamp,
lastMessageDeletedForEveryone: preview?.deletedForEveryone || false,
lastMessageDeletedForEveryone:
preview?.get('deletedForEveryone') || false,
});
await DataWriter.updateConversation(this.attributes);
@ -4785,7 +4743,7 @@ export class ConversationModel extends window.Backbone
(isInitialSync && isFromSyncOperation) || isFromMe || isNoteToSelf;
const counter = receivedAt ?? incrementMessageCounter();
const attributes = {
const message = new MessageModel({
...generateMessageId(counter),
conversationId: this.id,
expirationTimerUpdate: {
@ -4801,24 +4759,18 @@ export class ConversationModel extends window.Backbone
sent_at: sentAt,
timestamp: sentAt,
type: 'timer-notification' as const,
};
await DataWriter.saveMessage(attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
forceSave: true,
});
window.MessageCache.__DEPRECATED$register(
attributes.id,
attributes,
'updateExpirationTimer'
);
await window.MessageCache.saveMessage(message, {
forceSave: true,
});
window.MessageCache.register(message);
void this.addSingleMessage(attributes);
void this.addSingleMessage(message.attributes);
void this.updateUnread();
log.info(
`${logId}: added a notification received_at=${attributes.received_at}`
`${logId}: added a notification received_at=${message.get('received_at')}`
);
}
@ -5306,9 +5258,9 @@ export class ConversationModel extends window.Backbone
log.info(`${logId}: Starting delete`);
await DataWriter.removeMessagesInConversation(this.id, {
cleanupMessages,
fromSync: source !== 'local-delete-sync',
logId: this.idForLogging(),
singleProtoJobQueue,
});
log.info(`${logId}: Delete complete`);
}

File diff suppressed because it is too large Load diff

View file

@ -5,10 +5,13 @@ import noop from 'lodash/noop';
import { v7 as generateUuid } from 'uuid';
import { DataWriter } from '../sql/Client';
import type { MessageModel } from '../models/messages';
import type { ReactionAttributesType } from '../messageModifiers/Reactions';
import { MessageModel } from '../models/messages';
import {
handleReaction,
type ReactionAttributesType,
} from '../messageModifiers/Reactions';
import { ReactionSource } from './ReactionSource';
import { __DEPRECATED$getMessageById } from '../messages/getMessageById';
import { getMessageById } from '../messages/getMessageById';
import { getSourceServiceId, isStory } from '../messages/helpers';
import { strictAssert } from '../util/assert';
import { isDirectConversation } from '../util/whatTypeOfConversation';
@ -19,6 +22,7 @@ import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
import { isAciString } from '../util/isAciString';
import { SendStatus } from '../messages/MessageSendState';
import * as log from '../logging/log';
import { getMessageIdForLogging } from '../util/idForLogging';
export async function enqueueReactionForSend({
emoji,
@ -29,20 +33,17 @@ export async function enqueueReactionForSend({
messageId: string;
remove: boolean;
}>): Promise<void> {
const message = await __DEPRECATED$getMessageById(
messageId,
'enqueueReactionForSend'
);
const message = await getMessageById(messageId);
strictAssert(message, 'enqueueReactionForSend: no message found');
const targetAuthorAci = getSourceServiceId(message.attributes);
strictAssert(
targetAuthorAci,
`enqueueReactionForSend: message ${message.idForLogging()} had no source UUID`
`enqueueReactionForSend: message ${getMessageIdForLogging(message.attributes)} had no source UUID`
);
strictAssert(
isAciString(targetAuthorAci),
`enqueueReactionForSend: message ${message.idForLogging()} had no source ACI`
`enqueueReactionForSend: message ${getMessageIdForLogging(message.attributes)} had no source ACI`
);
const targetTimestamp = getMessageSentTimestamp(message.attributes, {
@ -50,11 +51,13 @@ export async function enqueueReactionForSend({
});
strictAssert(
targetTimestamp,
`enqueueReactionForSend: message ${message.idForLogging()} had no timestamp`
`enqueueReactionForSend: message ${getMessageIdForLogging(message.attributes)} had no timestamp`
);
const timestamp = Date.now();
const messageConversation = message.getConversation();
const messageConversation = window.ConversationController.get(
message.get('conversationId')
);
strictAssert(
messageConversation,
'enqueueReactionForSend: No conversation extracted from target message'
@ -94,7 +97,7 @@ export async function enqueueReactionForSend({
// Only used in story scenarios, where we use a whole message to represent the reaction
let storyReactionMessage: MessageModel | undefined;
if (storyMessage) {
storyReactionMessage = new window.Whisper.Message({
storyReactionMessage = new MessageModel({
...generateMessageId(incrementMessageCounter()),
type: 'outgoing',
conversationId: targetConversation.id,
@ -132,5 +135,5 @@ export async function enqueueReactionForSend({
timestamp,
};
await message.handleReaction(reaction, { storyMessage });
await handleReaction(message, reaction, { storyMessage });
}

View file

@ -1,63 +1,124 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import cloneDeep from 'lodash/cloneDeep';
import { throttle } from 'lodash';
import { LRUCache } from 'lru-cache';
import type {
MessageAttributesType,
ReadonlyMessageAttributesType,
} from '../model-types.d';
import type { MessageModel } from '../models/messages';
import { DataReader, DataWriter } from '../sql/Client';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import { getEnvironment, Environment } from '../environment';
import { MessageModel } from '../models/messages';
import { DataReader, DataWriter } from '../sql/Client';
import { getMessageConversation } from '../util/getMessageConversation';
import { getMessageModelLogger } from '../util/MessageModelLogger';
import { getSenderIdentifier } from '../util/getSenderIdentifier';
import { isNotNil } from '../util/isNotNil';
import { softAssert, strictAssert } from '../util/assert';
import { isStory } from '../messages/helpers';
import type { SendStateByConversationId } from '../messages/MessageSendState';
import { getStoryDataFromMessageAttributes } from './storyLoader';
import { postSaveUpdates } from '../util/cleanup';
import type { MessageAttributesType } from '../model-types.d';
import type { SendStateByConversationId } from '../messages/MessageSendState';
import type { StoredJob } from '../jobs/types';
const MAX_THROTTLED_REDUX_UPDATERS = 200;
export class MessageCache {
static install(): MessageCache {
const instance = new MessageCache();
window.MessageCache = instance;
return instance;
}
private state = {
messages: new Map<string, MessageAttributesType>(),
messages: new Map<string, MessageModel>(),
messageIdsBySender: new Map<string, string>(),
messageIdsBySentAt: new Map<number, Array<string>>(),
lastAccessedAt: new Map<string, number>(),
};
// Stores the models so that __DEPRECATED$register always returns the existing
// copy instead of a new model.
private modelCache = new Map<string, MessageModel>();
public saveMessage(
message: MessageAttributesType | MessageModel,
options?: {
forceSave?: boolean;
jobToInsert?: Readonly<StoredJob>;
}
): Promise<string> {
const attributes =
message instanceof MessageModel ? message.attributes : message;
// Synchronously access a message's attributes from internal cache. Will
// return undefined if the message does not exist in memory.
public accessAttributes(
messageId: string
): Readonly<MessageAttributesType> | undefined {
const messageAttributes = this.state.messages.get(messageId);
return messageAttributes
? this.freezeAttributes(messageAttributes)
: undefined;
return DataWriter.saveMessage(attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
...options,
});
}
// Synchronously access a message's attributes from internal cache. Throws
// if the message does not exist in memory.
public accessAttributesOrThrow(
source: string,
messageId: string
): Readonly<MessageAttributesType> {
const messageAttributes = this.accessAttributes(messageId);
strictAssert(
messageAttributes,
`MessageCache.accessAttributesOrThrow/${source}: no message for id ${messageId}`
);
return messageAttributes;
public register(message: MessageModel): MessageModel {
if (!message || !message.id) {
throw new Error('MessageCache.register: Got falsey id or message');
}
const existing = this.getById(message.id);
if (existing) {
return existing;
}
this.addMessageToCache(message);
return message;
}
// Finds a message in the cache by sender identifier
public findBySender(senderIdentifier: string): MessageModel | undefined {
const id = this.state.messageIdsBySender.get(senderIdentifier);
if (!id) {
return undefined;
}
return this.getById(id);
}
// Finds a message in the cache by Id
public getById(id: string): MessageModel | undefined {
const message = this.state.messages.get(id);
if (!message) {
return undefined;
}
this.state.lastAccessedAt.set(id, Date.now());
return message;
}
// Finds a message in the cache by sentAt/timestamp
public async findBySentAt(
sentAt: number,
predicate: (model: MessageModel) => boolean
): Promise<MessageModel | undefined> {
const items = this.state.messageIdsBySentAt.get(sentAt) ?? [];
const inMemory = items
.map(id => this.getById(id))
.filter(isNotNil)
.find(predicate);
if (inMemory != null) {
return inMemory;
}
log.info(`findBySentAt(${sentAt}): db lookup needed`);
const allOnDisk = await DataReader.getMessagesBySentAt(sentAt);
const onDisk = allOnDisk
.map(message => this.register(new MessageModel(message)))
.find(predicate);
return onDisk;
}
// Deletes the message from our cache
public unregister(id: string): void {
const message = this.state.messages.get(id);
if (!message) {
return;
}
this.removeMessage(id);
}
// Evicts messages from the message cache if they have not been accessed past
@ -65,9 +126,9 @@ export class MessageCache {
public deleteExpiredMessages(expiryTime: number): void {
const now = Date.now();
for (const [messageId, messageAttributes] of this.state.messages) {
for (const [messageId, message] of this.state.messages) {
const timeLastAccessed = this.state.lastAccessedAt.get(messageId) ?? 0;
const conversation = getMessageConversation(messageAttributes);
const conversation = getMessageConversation(message.attributes);
const state = window.reduxStore.getState();
const selectedId = state?.conversations?.selectedConversationId;
@ -75,21 +136,25 @@ export class MessageCache {
conversation && selectedId && conversation.id === selectedId;
if (now - timeLastAccessed > expiryTime && !inActiveConversation) {
this.__DEPRECATED$unregister(messageId);
this.unregister(messageId);
}
}
}
// Finds a message in the cache by sender identifier
public findBySender(
senderIdentifier: string
): Readonly<MessageAttributesType> | undefined {
const id = this.state.messageIdsBySender.get(senderIdentifier);
if (!id) {
return undefined;
public async upgradeSchema(
message: MessageModel,
minSchemaVersion: number
): Promise<void> {
const { schemaVersion } = message.attributes;
if (!schemaVersion || schemaVersion >= minSchemaVersion) {
return;
}
const startingAttributes = message.attributes;
const upgradedAttributes =
await window.Signal.Migrations.upgradeMessageSchema(startingAttributes);
if (startingAttributes !== upgradedAttributes) {
message.set(upgradedAttributes);
}
return this.accessAttributes(id);
}
public replaceAllObsoleteConversationIds({
@ -112,12 +177,12 @@ export class MessageCache {
};
};
for (const [messageId, messageAttributes] of this.state.messages) {
if (messageAttributes.conversationId !== obsoleteId) {
for (const [, message] of this.state.messages) {
if (message.get('conversationId') !== obsoleteId) {
continue;
}
const editHistory = messageAttributes.editHistory?.map(history => {
const editHistory = message.get('editHistory')?.map(history => {
return {
...history,
sendStateByConversationId: updateSendState(
@ -126,117 +191,33 @@ export class MessageCache {
};
});
this.setAttributes({
messageId,
messageAttributes: {
message.set({
conversationId,
sendStateByConversationId: updateSendState(
messageAttributes.sendStateByConversationId
message.get('sendStateByConversationId')
),
editHistory,
},
skipSaveToDatabase: true,
});
}
}
// Find the message's attributes whether in memory or in the database.
// Refresh the attributes in the cache if they exist. Throw if we cannot find
// a matching message.
public async resolveAttributes(
source: string,
messageId: string
): Promise<Readonly<MessageAttributesType>> {
const inMemoryMessageAttributes = this.accessAttributes(messageId);
// Semi-public API
if (inMemoryMessageAttributes) {
return inMemoryMessageAttributes;
}
// Should only be called by MessageModel's set() function
public _updateCaches(message: MessageModel): undefined {
const existing = this.getById(message.id);
let messageAttributesFromDatabase: MessageAttributesType | undefined;
try {
messageAttributesFromDatabase =
await DataReader.getMessageById(messageId);
} catch (err: unknown) {
log.error(
`MessageCache.resolveAttributes(${messageId}): db error ${Errors.toLogFormat(
err
)}`
);
}
strictAssert(
messageAttributesFromDatabase,
`MessageCache.resolveAttributes/${source}: no message for id ${messageId}`
);
return this.freezeAttributes(messageAttributesFromDatabase);
}
// Updates a message's attributes and saves the message to cache and to the
// database. Option to skip the save to the database.
// Overload #1: if skipSaveToDatabase = true, returns void
public setAttributes({
messageId,
messageAttributes,
skipSaveToDatabase,
}: {
messageId: string;
messageAttributes: Partial<MessageAttributesType>;
skipSaveToDatabase: true;
}): void;
// Overload #2: if skipSaveToDatabase = false, returns DB save promise
public setAttributes({
messageId,
messageAttributes,
skipSaveToDatabase,
}: {
messageId: string;
messageAttributes: Partial<MessageAttributesType>;
skipSaveToDatabase: false;
}): Promise<string>;
// Implementation
public setAttributes({
messageId,
messageAttributes: partialMessageAttributes,
skipSaveToDatabase,
}: {
messageId: string;
messageAttributes: Partial<MessageAttributesType>;
skipSaveToDatabase: boolean;
}): Promise<string> | undefined {
let messageAttributes = this.accessAttributes(messageId);
softAssert(messageAttributes, 'could not find message attributes');
if (!messageAttributes) {
// We expect message attributes to be defined in cache if one is trying to
// set new attributes. In the case that the attributes are missing in cache
// we'll add whatever we currently have to cache as a defensive measure so
// that the code continues to work properly downstream. The softAssert above
// that logs/debugger should be addressed upstream immediately by ensuring
// that message is in cache.
const partiallyCachedMessage = {
id: messageId,
...partialMessageAttributes,
} as MessageAttributesType;
this.addMessageToCache(partiallyCachedMessage);
messageAttributes = partiallyCachedMessage;
// If this model hasn't been registered yet, we can't add to cache because we don't
// want to force `message` to be the primary MessageModel for this message.
if (!existing) {
return;
}
this.state.messageIdsBySender.delete(
getSenderIdentifier(messageAttributes)
getSenderIdentifier(message.attributes)
);
const nextMessageAttributes = {
...messageAttributes,
...partialMessageAttributes,
};
const { id, sent_at: sentAt } = nextMessageAttributes;
const { id, sent_at: sentAt } = message.attributes;
const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt);
let nextIdsBySentAtSet: Set<string>;
@ -247,44 +228,70 @@ export class MessageCache {
nextIdsBySentAtSet = new Set([id]);
}
this.state.messages.set(id, nextMessageAttributes);
this.state.lastAccessedAt.set(id, Date.now());
this.state.messageIdsBySender.set(
getSenderIdentifier(messageAttributes),
getSenderIdentifier(message.attributes),
id
);
this.markModelStale(nextMessageAttributes);
this.throttledUpdateRedux(message.attributes);
}
this.throttledUpdateRedux(nextMessageAttributes);
// Helpers
if (skipSaveToDatabase) {
private addMessageToCache(message: MessageModel): void {
if (!message.id) {
return;
}
return DataWriter.saveMessage(nextMessageAttributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
});
if (this.state.messages.has(message.id)) {
this.state.lastAccessedAt.set(message.id, Date.now());
return;
}
private throttledReduxUpdaters = new LRUCache<
string,
typeof this.updateRedux
>({
max: MAX_THROTTLED_REDUX_UPDATERS,
});
const { id, sent_at: sentAt } = message.attributes;
const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt);
private throttledUpdateRedux(attributes: MessageAttributesType) {
let updater = this.throttledReduxUpdaters.get(attributes.id);
if (!updater) {
updater = throttle(this.updateRedux.bind(this), 200, {
leading: true,
trailing: true,
});
this.throttledReduxUpdaters.set(attributes.id, updater);
let nextIdsBySentAtSet: Set<string>;
if (previousIdsBySentAt) {
nextIdsBySentAtSet = new Set(previousIdsBySentAt);
nextIdsBySentAtSet.add(id);
} else {
nextIdsBySentAtSet = new Set([id]);
}
updater(attributes);
this.state.messages.set(message.id, message);
this.state.lastAccessedAt.set(message.id, Date.now());
this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet));
this.state.messageIdsBySender.set(
getSenderIdentifier(message.attributes),
id
);
}
private removeMessage(messageId: string): void {
const message = this.state.messages.get(messageId);
if (!message) {
return;
}
const { id, sent_at: sentAt } = message.attributes;
const nextIdsBySentAtSet =
new Set(this.state.messageIdsBySentAt.get(sentAt)) || new Set();
nextIdsBySentAtSet.delete(id);
if (nextIdsBySentAtSet.size) {
this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet));
} else {
this.state.messageIdsBySentAt.delete(sentAt);
}
this.state.messages.delete(messageId);
this.state.lastAccessedAt.delete(messageId);
this.state.messageIdsBySender.delete(
getSenderIdentifier(message.attributes)
);
}
private updateRedux(attributes: MessageAttributesType) {
@ -313,238 +320,23 @@ export class MessageCache {
);
}
// When you already have the message attributes from the db and want to
// ensure that they're added to the cache. The latest attributes from cache
// are returned if they exist, if not the attributes passed in are returned.
public toMessageAttributes(
messageAttributes: MessageAttributesType
): Readonly<MessageAttributesType> {
this.addMessageToCache(messageAttributes);
const nextMessageAttributes = this.state.messages.get(messageAttributes.id);
strictAssert(
nextMessageAttributes,
`MessageCache.toMessageAttributes: no message for id ${messageAttributes.id}`
);
if (getEnvironment() === Environment.Development) {
return Object.freeze(cloneDeep(nextMessageAttributes));
}
return nextMessageAttributes;
}
static install(): MessageCache {
const instance = new MessageCache();
window.MessageCache = instance;
return instance;
}
private addMessageToCache(messageAttributes: MessageAttributesType): void {
if (!messageAttributes.id) {
return;
}
if (this.state.messages.has(messageAttributes.id)) {
this.state.lastAccessedAt.set(messageAttributes.id, Date.now());
return;
}
const { id, sent_at: sentAt } = messageAttributes;
const previousIdsBySentAt = this.state.messageIdsBySentAt.get(sentAt);
let nextIdsBySentAtSet: Set<string>;
if (previousIdsBySentAt) {
nextIdsBySentAtSet = new Set(previousIdsBySentAt);
nextIdsBySentAtSet.add(id);
} else {
nextIdsBySentAtSet = new Set([id]);
}
this.state.messages.set(messageAttributes.id, { ...messageAttributes });
this.state.lastAccessedAt.set(messageAttributes.id, Date.now());
this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet));
this.state.messageIdsBySender.set(
getSenderIdentifier(messageAttributes),
id
);
}
private freezeAttributes(
messageAttributes: MessageAttributesType
): Readonly<MessageAttributesType> {
this.addMessageToCache(messageAttributes);
if (getEnvironment() === Environment.Development) {
return Object.freeze(cloneDeep(messageAttributes));
}
return messageAttributes;
}
private removeMessage(messageId: string): void {
const messageAttributes = this.state.messages.get(messageId);
if (!messageAttributes) {
return;
}
const { id, sent_at: sentAt } = messageAttributes;
const nextIdsBySentAtSet =
new Set(this.state.messageIdsBySentAt.get(sentAt)) || new Set();
nextIdsBySentAtSet.delete(id);
if (nextIdsBySentAtSet.size) {
this.state.messageIdsBySentAt.set(sentAt, Array.from(nextIdsBySentAtSet));
} else {
this.state.messageIdsBySentAt.delete(sentAt);
}
this.state.messages.delete(messageId);
this.state.lastAccessedAt.delete(messageId);
this.state.messageIdsBySender.delete(
getSenderIdentifier(messageAttributes)
);
}
// Deprecated methods below
// Adds the message into the cache and eturns a Proxy that resembles
// a MessageModel
public __DEPRECATED$register(
id: string,
data: MessageModel | MessageAttributesType,
location: string
): MessageModel {
if (!id || !data) {
throw new Error(
'MessageCache.__DEPRECATED$register: Got falsey id or message'
);
}
const existing = this.__DEPRECATED$getById(id, location);
if (existing) {
this.addMessageToCache(existing.attributes);
return existing;
}
const modelProxy = this.toModel(data);
const messageAttributes = 'attributes' in data ? data.attributes : data;
this.addMessageToCache(messageAttributes);
modelProxy.registerLocations.add(location);
return modelProxy;
}
// Deletes the message from our cache
public __DEPRECATED$unregister(id: string): void {
const model = this.modelCache.get(id);
if (!model) {
return;
}
this.removeMessage(id);
this.modelCache.delete(id);
}
// Finds a message in the cache by Id
public __DEPRECATED$getById(
id: string,
location: string
): MessageModel | undefined {
const data = this.state.messages.get(id);
if (!data) {
return undefined;
}
const model = this.toModel(data);
model.registerLocations.add(location);
return model;
}
public async upgradeSchema(
attributes: MessageAttributesType,
minSchemaVersion: number
): Promise<MessageAttributesType> {
const { schemaVersion } = attributes;
if (!schemaVersion || schemaVersion >= minSchemaVersion) {
return attributes;
}
const upgradedAttributes =
await window.Signal.Migrations.upgradeMessageSchema(attributes);
await this.setAttributes({
messageId: upgradedAttributes.id,
messageAttributes: upgradedAttributes,
skipSaveToDatabase: false,
private throttledReduxUpdaters = new LRUCache<
string,
typeof this.updateRedux
>({
max: MAX_THROTTLED_REDUX_UPDATERS,
});
return upgradedAttributes;
}
// Finds a message in the cache by sentAt/timestamp
public async findBySentAt(
sentAt: number,
predicate: (attributes: ReadonlyMessageAttributesType) => boolean
): Promise<MessageAttributesType | undefined> {
const items = this.state.messageIdsBySentAt.get(sentAt) ?? [];
const inMemory = items
.map(id => this.accessAttributes(id))
.filter(isNotNil)
.find(predicate);
if (inMemory != null) {
return inMemory;
}
log.info(`findBySentAt(${sentAt}): db lookup needed`);
const allOnDisk = await DataReader.getMessagesBySentAt(sentAt);
const onDisk = allOnDisk.find(predicate);
if (onDisk != null) {
this.addMessageToCache(onDisk);
}
return onDisk;
}
// Marks cached model as "should be stale" to discourage continued use.
// The model's attributes are directly updated so that the model is in sync
// with the in-memory attributes.
private markModelStale(messageAttributes: MessageAttributesType): void {
const { id } = messageAttributes;
const model = this.modelCache.get(id);
if (!model) {
return;
}
model.attributes = { ...messageAttributes };
if (getEnvironment() === Environment.Development) {
log.warn('MessageCache: updating cached backbone model', {
cid: model.cid,
locations: Array.from(model.registerLocations).join(', '),
private throttledUpdateRedux(attributes: MessageAttributesType) {
let updater = this.throttledReduxUpdaters.get(attributes.id);
if (!updater) {
updater = throttle(this.updateRedux.bind(this), 200, {
leading: true,
trailing: true,
});
}
this.throttledReduxUpdaters.set(attributes.id, updater);
}
// Creates a proxy object for MessageModel which logs usage in development
// so that we're able to migrate off of models
private toModel(
messageAttributes: MessageAttributesType | MessageModel
): MessageModel {
const existingModel = this.modelCache.get(messageAttributes.id);
if (existingModel) {
return existingModel;
}
const model =
'attributes' in messageAttributes
? messageAttributes
: new window.Whisper.Message(messageAttributes);
const proxy = getMessageModelLogger(model);
this.modelCache.set(messageAttributes.id, proxy);
return proxy;
updater(attributes);
}
}

View file

@ -2,10 +2,19 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types.d';
import type { MessageModel } from '../models/messages';
import { ReadStatus, maxReadStatus } from '../messages/MessageReadStatus';
import { notificationService } from './notifications';
import { SeenStatus } from '../MessageSeenStatus';
import { queueUpdateMessage } from '../util/messageBatcher';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import { isValidTapToView } from '../util/isValidTapToView';
import { getMessageIdForLogging } from '../util/idForLogging';
import { eraseMessageContents } from '../util/cleanup';
import { getSource, getSourceServiceId } from '../messages/helpers';
import { isAciString } from '../util/isAciString';
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
function markReadOrViewed(
messageAttrs: Readonly<MessageAttributesType>,
@ -54,3 +63,65 @@ export const markViewed = (
{ skipSave = false } = {}
): MessageAttributesType =>
markReadOrViewed(messageAttrs, ReadStatus.Viewed, viewedAt, skipSave);
export async function markViewOnceMessageViewed(
message: MessageModel,
options?: {
fromSync?: boolean;
}
): Promise<void> {
const { fromSync } = options || {};
if (!isValidTapToView(message.attributes)) {
log.warn(
`markViewOnceMessageViewed: Message ${getMessageIdForLogging(message.attributes)} is not a valid tap to view message!`
);
return;
}
if (message.attributes.isErased) {
log.warn(
`markViewOnceMessageViewed: Message ${getMessageIdForLogging(message.attributes)} is already erased!`
);
return;
}
if (message.get('readStatus') !== ReadStatus.Viewed) {
message.set(markViewed(message.attributes));
}
await eraseMessageContents(message);
if (!fromSync) {
const senderE164 = getSource(message.attributes);
const senderAci = getSourceServiceId(message.attributes);
const timestamp = message.get('sent_at');
if (senderAci === undefined || !isAciString(senderAci)) {
throw new Error('markViewOnceMessageViewed: senderAci is undefined');
}
if (window.ConversationController.areWePrimaryDevice()) {
log.warn(
'markViewOnceMessageViewed: We are primary device; not sending view once open sync'
);
return;
}
try {
await viewOnceOpenJobQueue.add({
viewOnceOpens: [
{
senderE164,
senderAci,
timestamp,
},
],
});
} catch (error) {
log.error(
'markViewOnceMessageViewed: Failed to queue view once open sync',
Errors.toLogFormat(error)
);
}
}
}

View file

@ -128,6 +128,7 @@ import { isAdhoc, isNightly } from '../../util/version';
import { ToastType } from '../../types/Toast';
import { isConversationAccepted } from '../../util/isConversationAccepted';
import { saveBackupsSubscriberData } from '../../util/backupSubscriptionData';
import { postSaveUpdates } from '../../util/cleanup';
const MAX_CONCURRENCY = 10;
@ -609,6 +610,7 @@ export class BackupImportStream extends Writable {
await DataWriter.saveMessages(batch, {
forceSave: true,
ourAci,
postSaveUpdates,
});
const attachmentDownloadJobPromises: Array<Promise<unknown>> = [];

View file

@ -4,22 +4,21 @@
import { batch } from 'react-redux';
import { debounce } from 'lodash';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import { DataReader, DataWriter } from '../sql/Client';
import { clearTimeoutIfNecessary } from '../util/clearTimeoutIfNecessary';
import { sleep } from '../util/sleep';
import { SECOND } from '../util/durations';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import type { MessageModel } from '../models/messages';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { MessageModel } from '../models/messages';
import { cleanupMessages } from '../util/cleanup';
class ExpiringMessagesDeletionService {
public update: typeof this.checkExpiringMessages;
private timeout?: ReturnType<typeof setTimeout>;
constructor(private readonly singleProtoJobQueue: SingleProtoJobQueue) {
constructor() {
this.update = debounce(this.checkExpiringMessages, 1000);
}
@ -37,17 +36,15 @@ class ExpiringMessagesDeletionService {
const inMemoryMessages: Array<MessageModel> = [];
messages.forEach(dbMessage => {
const message = window.MessageCache.__DEPRECATED$register(
dbMessage.id,
dbMessage,
'destroyExpiredMessages'
const message = window.MessageCache.register(
new MessageModel(dbMessage)
);
messageIds.push(message.id);
inMemoryMessages.push(message);
});
await DataWriter.removeMessages(messageIds, {
singleProtoJobQueue: this.singleProtoJobQueue,
cleanupMessages,
});
batch(() => {
@ -57,7 +54,6 @@ class ExpiringMessagesDeletionService {
});
// We do this to update the UI, if this message is being displayed somewhere
message.trigger('expired');
window.reduxActions.conversations.messageExpired(message.id);
});
});
@ -114,14 +110,12 @@ class ExpiringMessagesDeletionService {
}
}
// Because this service is used inside of Client.ts, it can't directly reference
// SingleProtoJobQueue. Instead of direct access, it is provided once on startup.
export function initialize(singleProtoJobQueue: SingleProtoJobQueue): void {
export function initialize(): void {
if (instance) {
log.warn('Expiring Messages Deletion service is already initialized!');
return;
}
instance = new ExpiringMessagesDeletionService(singleProtoJobQueue);
instance = new ExpiringMessagesDeletionService();
}
export async function update(): Promise<void> {

View file

@ -26,6 +26,7 @@ import type {
ReleaseNoteResponseType,
} from '../textsecure/WebAPI';
import type { WithRequiredProperties } from '../types/Util';
import { MessageModel } from '../models/messages';
const FETCH_INTERVAL = 3 * durations.DAY;
const ERROR_RETRY_DELAY = 3 * durations.HOUR;
@ -187,7 +188,7 @@ export class ReleaseNotesFetcher {
];
const timestamp = Date.now() + index;
const message: MessageAttributesType = {
const message = new MessageModel({
...generateMessageId(incrementMessageCounter()),
body: messageBody,
bodyRanges,
@ -201,12 +202,12 @@ export class ReleaseNotesFetcher {
sourceServiceId: signalConversation.getServiceId(),
timestamp,
type: 'incoming',
};
});
window.MessageCache.toMessageAttributes(message);
signalConversation.trigger('newmessage', message);
window.MessageCache.register(message);
drop(signalConversation.onNewMessage(message));
messages.push(message);
messages.push(message.attributes);
});
await Promise.all(

View file

@ -18,6 +18,7 @@ import { strictAssert } from '../util/assert';
import { dropNull } from '../util/dropNull';
import { DurationInSeconds } from '../util/durations';
import { SIGNAL_ACI } from '../types/SignalConversation';
import { postSaveUpdates } from '../util/cleanup';
let storyData: GetAllStoriesResultType | undefined;
@ -174,6 +175,7 @@ async function repairUnexpiredStories(): Promise<void> {
storiesWithExpiry.map(messageAttributes => {
return DataWriter.saveMessage(messageAttributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
})
);

View file

@ -8,6 +8,9 @@ import { getMessageQueueTime } from '../util/getMessageQueueTime';
import * as Errors from '../types/errors';
import { strictAssert } from '../util/assert';
import { toBoundedDate } from '../util/timestamp';
import { getMessageIdForLogging } from '../util/idForLogging';
import { eraseMessageContents } from '../util/cleanup';
import { MessageModel } from '../models/messages';
async function eraseTapToViewMessages() {
try {
@ -26,22 +29,17 @@ async function eraseTapToViewMessages() {
'Must be older than maxTimestamp'
);
const message = window.MessageCache.__DEPRECATED$register(
fromDB.id,
fromDB,
'eraseTapToViewMessages'
);
const message = window.MessageCache.register(new MessageModel(fromDB));
window.SignalContext.log.info(
'eraseTapToViewMessages: erasing message contents',
message.idForLogging()
getMessageIdForLogging(message.attributes)
);
// We do this to update the UI, if this message is being displayed somewhere
message.trigger('expired');
window.reduxActions.conversations.messageExpired(message.id);
await message.eraseContents();
await eraseMessageContents(message);
})
);
} catch (error) {

View file

@ -2,30 +2,34 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { ipcRenderer as ipc } from 'electron';
import { groupBy, isTypedArray, last, map, omit } from 'lodash';
import type { ReadonlyDeep } from 'type-fest';
import { deleteExternalFiles } from '../types/Conversation';
import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion';
import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService';
// Note: nothing imported here can come back and require Client.ts, and that includes
// their imports too. That circularity causes problems. Anything that would do that needs
// to be passed in, like cleanupMessages below.
import * as Bytes from '../Bytes';
import * as log from '../logging/log';
import * as Errors from '../types/errors';
import { deleteExternalFiles } from '../types/Conversation';
import { createBatcher } from '../util/batcher';
import { assertDev, softAssert } from '../util/assert';
import { mapObjectWithSpec } from '../util/mapObjectWithSpec';
import type { ObjectMappingSpecType } from '../util/mapObjectWithSpec';
import { cleanDataForIpc } from './cleanDataForIpc';
import type { AciString, ServiceIdString } from '../types/ServiceId';
import createTaskWithTimeout from '../textsecure/TaskWithTimeout';
import * as log from '../logging/log';
import { isValidUuid, isValidUuidV7 } from '../util/isValidUuid';
import * as Errors from '../types/errors';
import type { StoredJob } from '../jobs/types';
import { formatJobForInsert } from '../jobs/formatJobForInsert';
import { cleanupMessages } from '../util/cleanup';
import { AccessType, ipcInvoke, doShutdown, removeDB } from './channels';
import { getMessageIdForLogging } from '../util/idForLogging';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import { generateSnippetAroundMention } from '../util/search';
import { drop } from '../util/drop';
import type { ObjectMappingSpecType } from '../util/mapObjectWithSpec';
import type { AciString, ServiceIdString } from '../types/ServiceId';
import type { StoredJob } from '../jobs/types';
import type {
ClientInterfaceWrap,
AdjacentMessagesByConversationOptionsType,
@ -58,12 +62,8 @@ import type {
ClientOnlyReadableInterface,
ClientOnlyWritableInterface,
} from './Interface';
import { getMessageIdForLogging } from '../util/idForLogging';
import type { MessageAttributesType } from '../model-types';
import { incrementMessageCounter } from '../util/incrementMessageCounter';
import { generateSnippetAroundMention } from '../util/search';
import type { AttachmentDownloadJobType } from '../types/AttachmentDownload';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
const ERASE_SQL_KEY = 'erase-sql-key';
const ERASE_ATTACHMENTS_KEY = 'erase-attachments';
@ -121,6 +121,10 @@ const clientOnlyWritable: ClientOnlyWritableInterface = {
removeMessage,
removeMessages,
saveMessage,
saveMessages,
saveMessagesIndividually,
// Client-side only
flushUpdateConversationBatcher,
@ -137,17 +141,12 @@ const clientOnlyWritable: ClientOnlyWritableInterface = {
type ClientOverridesType = ClientOnlyWritableInterface &
Pick<
ClientInterfaceWrap<ServerWritableDirectInterface>,
| 'saveAttachmentDownloadJob'
| 'saveMessage'
| 'saveMessages'
| 'updateConversations'
'saveAttachmentDownloadJob' | 'updateConversations'
>;
const clientOnlyWritableOverrides: ClientOverridesType = {
...clientOnlyWritable,
saveAttachmentDownloadJob,
saveMessage,
saveMessages,
updateConversations,
};
@ -595,41 +594,76 @@ async function searchMessages({
async function saveMessage(
data: ReadonlyDeep<MessageType>,
options: {
jobToInsert?: Readonly<StoredJob>;
{
forceSave,
jobToInsert,
ourAci,
postSaveUpdates,
}: {
forceSave?: boolean;
jobToInsert?: Readonly<StoredJob>;
ourAci: AciString;
postSaveUpdates: () => Promise<void>;
}
): Promise<string> {
const id = await writableChannel.saveMessage(_cleanMessageData(data), {
...options,
jobToInsert: options.jobToInsert && formatJobForInsert(options.jobToInsert),
forceSave,
jobToInsert: jobToInsert && formatJobForInsert(jobToInsert),
ourAci,
});
softAssert(
// Older messages still have `UUIDv4` so don't log errors when encountering
// it.
(!options.forceSave && isValidUuid(id)) || isValidUuidV7(id),
(!forceSave && isValidUuid(id)) || isValidUuidV7(id),
'saveMessage: messageId is not a UUID'
);
void updateExpiringMessagesService();
void tapToViewMessagesDeletionService.update();
drop(postSaveUpdates?.());
return id;
}
async function saveMessages(
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
options: { forceSave?: boolean; ourAci: AciString }
{
forceSave,
ourAci,
postSaveUpdates,
}: {
forceSave?: boolean;
ourAci: AciString;
postSaveUpdates: () => Promise<void>;
}
): Promise<Array<string>> {
const result = await writableChannel.saveMessages(
arrayOfMessages.map(message => _cleanMessageData(message)),
options
{ forceSave, ourAci }
);
void updateExpiringMessagesService();
void tapToViewMessagesDeletionService.update();
drop(postSaveUpdates?.());
return result;
}
async function saveMessagesIndividually(
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
{
forceSave,
ourAci,
postSaveUpdates,
}: {
forceSave?: boolean;
ourAci: AciString;
postSaveUpdates: () => Promise<void>;
}
): Promise<{ failedIndices: Array<number> }> {
const result = await writableChannel.saveMessagesIndividually(
arrayOfMessages,
{ forceSave, ourAci }
);
drop(postSaveUpdates?.());
return result;
}
@ -637,7 +671,10 @@ async function saveMessages(
async function removeMessage(
id: string,
options: {
singleProtoJobQueue: SingleProtoJobQueue;
cleanupMessages: (
messages: ReadonlyArray<MessageAttributesType>,
options: { fromSync?: boolean }
) => Promise<void>;
fromSync?: boolean;
}
): Promise<void> {
@ -647,9 +684,8 @@ async function removeMessage(
// it needs to delete all associated on-disk files along with the database delete.
if (message) {
await writableChannel.removeMessage(id);
await cleanupMessages([message], {
...options,
markCallHistoryDeleted: DataWriter.markCallHistoryDeleted,
await options.cleanupMessages([message], {
fromSync: options.fromSync,
});
}
}
@ -659,7 +695,10 @@ export async function deleteAndCleanup(
logId: string,
options: {
fromSync?: boolean;
singleProtoJobQueue: SingleProtoJobQueue;
cleanupMessages: (
messages: ReadonlyArray<MessageAttributesType>,
options: { fromSync?: boolean }
) => Promise<void>;
}
): Promise<void> {
const ids = messages.map(message => message.id);
@ -668,9 +707,8 @@ export async function deleteAndCleanup(
await writableChannel.removeMessages(ids);
log.info(`deleteAndCleanup/${logId}: Cleanup for ${ids.length} messages...`);
await cleanupMessages(messages, {
...options,
markCallHistoryDeleted: DataWriter.markCallHistoryDeleted,
await options.cleanupMessages(messages, {
fromSync: Boolean(options.fromSync),
});
log.info(`deleteAndCleanup/${logId}: Complete`);
@ -680,13 +718,15 @@ async function removeMessages(
messageIds: ReadonlyArray<string>,
options: {
fromSync?: boolean;
singleProtoJobQueue: SingleProtoJobQueue;
cleanupMessages: (
messages: ReadonlyArray<MessageAttributesType>,
options: { fromSync?: boolean }
) => Promise<void>;
}
): Promise<void> {
const messages = await readableChannel.getMessagesById(messageIds);
await cleanupMessages(messages, {
...options,
markCallHistoryDeleted: DataWriter.markCallHistoryDeleted,
await options.cleanupMessages(messages, {
fromSync: Boolean(options.fromSync),
});
await writableChannel.removeMessages(messageIds);
}
@ -743,15 +783,18 @@ async function getConversationRangeCenteredOnMessage(
async function removeMessagesInConversation(
conversationId: string,
{
cleanupMessages,
fromSync,
logId,
receivedAt,
singleProtoJobQueue,
fromSync,
}: {
cleanupMessages: (
messages: ReadonlyArray<MessageAttributesType>,
options: { fromSync?: boolean | undefined }
) => Promise<void>;
fromSync?: boolean;
logId: string;
receivedAt?: number;
singleProtoJobQueue: SingleProtoJobQueue;
}
): Promise<void> {
let messages;
@ -776,7 +819,7 @@ async function removeMessagesInConversation(
}
// eslint-disable-next-line no-await-in-loop
await deleteAndCleanup(messages, logId, { fromSync, singleProtoJobQueue });
await deleteAndCleanup(messages, logId, { fromSync, cleanupMessages });
} while (messages.length > 0);
}

View file

@ -44,7 +44,6 @@ import type {
} from '../types/GroupSendEndorsements';
import type { SyncTaskType } from '../util/syncTasks';
import type { AttachmentBackupJobType } from '../types/AttachmentBackup';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
export type ReadableDB = Database & { __readable_db: never };
export type WritableDB = ReadableDB & { __writable_db: never };
@ -749,23 +748,6 @@ type WritableInterface = {
replaceAllEndorsementsForGroup: (data: GroupSendEndorsementsData) => void;
deleteAllEndorsementsForGroup: (groupId: string) => void;
saveMessage: (
data: ReadonlyDeep<MessageType>,
options: {
jobToInsert?: StoredJob;
forceSave?: boolean;
ourAci: AciString;
}
) => string;
saveMessages: (
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
options: { forceSave?: boolean; ourAci: AciString }
) => Array<string>;
saveMessagesIndividually: (
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
options: { forceSave?: boolean; ourAci: AciString }
) => { failedIndices: Array<number> };
getUnreadByConversationAndMarkRead: (options: {
conversationId: string;
includeStoryReplies: boolean;
@ -1047,6 +1029,22 @@ export type ServerWritableDirectInterface = WritableInterface & {
updateConversation: (data: ConversationType) => void;
removeConversation: (id: Array<string> | string) => void;
saveMessage: (
data: ReadonlyDeep<MessageType>,
options: {
jobToInsert?: StoredJob;
forceSave?: boolean;
ourAci: AciString;
}
) => string;
saveMessages: (
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
options: { forceSave?: boolean; ourAci: AciString }
) => Array<string>;
saveMessagesIndividually: (
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
options: { forceSave?: boolean; ourAci: AciString }
) => { failedIndices: Array<number> };
removeMessage: (id: string) => void;
removeMessages: (ids: ReadonlyArray<string>) => void;
@ -1134,18 +1132,49 @@ export type ClientOnlyWritableInterface = ClientInterfaceWrap<{
removeConversation: (id: string) => void;
flushUpdateConversationBatcher: () => void;
saveMessage: (
data: ReadonlyDeep<MessageType>,
options: {
jobToInsert?: StoredJob;
forceSave?: boolean;
ourAci: AciString;
postSaveUpdates: () => Promise<void>;
}
) => string;
saveMessages: (
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
options: {
forceSave?: boolean;
ourAci: AciString;
postSaveUpdates: () => Promise<void>;
}
) => Array<string>;
saveMessagesIndividually: (
arrayOfMessages: ReadonlyArray<ReadonlyDeep<MessageType>>,
options: {
forceSave?: boolean;
ourAci: AciString;
postSaveUpdates: () => Promise<void>;
}
) => { failedIndices: Array<number> };
removeMessage: (
id: string,
options: {
fromSync?: boolean;
singleProtoJobQueue: SingleProtoJobQueue;
cleanupMessages: (
messages: ReadonlyArray<MessageAttributesType>,
options: { fromSync?: boolean | undefined }
) => Promise<void>;
}
) => void;
removeMessages: (
ids: ReadonlyArray<string>,
options: {
fromSync?: boolean;
singleProtoJobQueue: SingleProtoJobQueue;
cleanupMessages: (
messages: ReadonlyArray<MessageAttributesType>,
options: { fromSync?: boolean | undefined }
) => Promise<void>;
}
) => void;
@ -1170,10 +1199,13 @@ export type ClientOnlyWritableInterface = ClientInterfaceWrap<{
removeMessagesInConversation: (
conversationId: string,
options: {
cleanupMessages: (
messages: ReadonlyArray<MessageAttributesType>,
options: { fromSync?: boolean | undefined }
) => Promise<void>;
fromSync?: boolean;
logId: string;
receivedAt?: number;
singleProtoJobQueue: SingleProtoJobQueue;
}
) => void;
removeOtherData: () => void;

View file

@ -69,7 +69,7 @@ import { resolveAttachmentDraftData } from '../../util/resolveAttachmentDraftDat
import { resolveDraftAttachmentOnDisk } from '../../util/resolveDraftAttachmentOnDisk';
import { shouldShowInvalidMessageToast } from '../../util/shouldShowInvalidMessageToast';
import { writeDraftAttachment } from '../../util/writeDraftAttachment';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { getMessageById } from '../../messages/getMessageById';
import { canReply, isNormalBubble } from '../selectors/message';
import { getAuthorId } from '../../messages/helpers';
import { getConversationSelector } from '../selectors/conversations';
@ -730,9 +730,7 @@ export function setQuoteByMessageId(
return;
}
const message = messageId
? await __DEPRECATED$getMessageById(messageId, 'setQuoteByMessageId')
: undefined;
const message = messageId ? await getMessageById(messageId) : undefined;
const state = getState();
if (

View file

@ -126,11 +126,12 @@ import {
isDirectConversation,
isGroup,
isGroupV2,
isMe,
} from '../../util/whatTypeOfConversation';
import { missingCaseError } from '../../util/missingCaseError';
import { viewSyncJobQueue } from '../../jobs/viewSyncJobQueue';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { isIncoming, processBodyRanges } from '../selectors/message';
import { isIncoming, isStory, processBodyRanges } from '../selectors/message';
import { getActiveCall, getActiveCallState } from '../selectors/calling';
import { sendDeleteForEveryoneMessage } from '../../util/sendDeleteForEveryoneMessage';
import type { ShowToastActionType } from './toast';
@ -149,7 +150,7 @@ import {
buildUpdateAttributesChange,
initiateMigrationToGroupV2 as doInitiateMigrationToGroupV2,
} from '../../groups';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { getMessageById } from '../../messages/getMessageById';
import type { PanelRenderType, PanelRequestType } from '../../types/Panels';
import type { ConversationQueueJobData } from '../../jobs/conversationJobQueue';
import { isOlderThan } from '../../util/timestamp';
@ -184,7 +185,10 @@ import type { ChangeNavTabActionType } from './nav';
import { CHANGE_NAV_TAB, NavTab, actions as navActions } from './nav';
import { sortByMessageOrder } from '../../types/ForwardDraft';
import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPendingInvitation';
import { getConversationIdForLogging } from '../../util/idForLogging';
import {
getConversationIdForLogging,
getMessageIdForLogging,
} from '../../util/idForLogging';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import MessageSender from '../../textsecure/SendMessage';
import {
@ -204,6 +208,18 @@ import { markCallHistoryReadInConversation } from './callHistory';
import type { CapabilitiesType } from '../../textsecure/WebAPI';
import { actions as searchActions } from './search';
import type { SearchActionType } from './search';
import { getNotificationTextForMessage } from '../../util/getNotificationTextForMessage';
import { doubleCheckMissingQuoteReference as doDoubleCheckMissingQuoteReference } from '../../util/doubleCheckMissingQuoteReference';
import { queueAttachmentDownloadsForMessage } from '../../util/queueAttachmentDownloads';
import { markAttachmentAsCorrupted as doMarkAttachmentAsCorrupted } from '../../messageModifiers/AttachmentDownloads';
import {
isSent,
SendActionType,
sendStateReducer,
} from '../../messages/MessageSendState';
import { markFailed } from '../../test-node/util/messageFailures';
import { cleanupMessages, postSaveUpdates } from '../../util/cleanup';
import { MessageModel } from '../../models/messages';
// State
export type DBConversationType = ReadonlyDeep<{
@ -1410,10 +1426,7 @@ function markMessageRead(
return;
}
const message = await __DEPRECATED$getMessageById(
messageId,
'markMessageRead'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`markMessageRead: failed to load message ${messageId}`);
}
@ -1767,10 +1780,7 @@ function deleteMessages({
await Promise.all(
messageIds.map(
async (messageId): Promise<MessageToDelete | undefined> => {
const message = await __DEPRECATED$getMessageById(
messageId,
'deleteMessages'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`deleteMessages: Message ${messageId} missing!`);
}
@ -1805,7 +1815,7 @@ function deleteMessages({
}
await DataWriter.removeMessages(messageIds, {
singleProtoJobQueue,
cleanupMessages,
});
popPanelForConversation()(dispatch, getState, undefined);
@ -1930,9 +1940,7 @@ function setMessageToEdit(
return;
}
const message = (
await __DEPRECATED$getMessageById(messageId, 'setMessageToEdit')
)?.attributes;
const message = (await getMessageById(messageId))?.attributes;
if (!message) {
return;
}
@ -2025,10 +2033,7 @@ function generateNewGroupLink(
* replace it with an actual action that fits in with the redux approach.
*/
export const markViewed = (messageId: string): void => {
const message = window.MessageCache.__DEPRECATED$getById(
messageId,
'markViewed'
);
const message = window.MessageCache.getById(messageId);
if (!message) {
throw new Error(`markViewed: Message ${messageId} missing!`);
}
@ -2051,7 +2056,9 @@ export const markViewed = (messageId: string): void => {
);
senderAci = sourceServiceId;
const convoAttributes = message.getConversation()?.attributes;
const convo = window.ConversationController.get(
message.get('conversationId')
);
const conversationId = message.get('conversationId');
drop(
conversationJobQueue.add({
@ -2065,8 +2072,8 @@ export const markViewed = (messageId: string): void => {
senderE164,
senderAci,
timestamp,
isDirectConversation: convoAttributes
? isDirectConversation(convoAttributes)
isDirectConversation: convo
? isDirectConversation(convo.attributes)
: true,
},
],
@ -2292,16 +2299,14 @@ function kickOffAttachmentDownload(
options: Readonly<{ messageId: string }>
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const message = await __DEPRECATED$getMessageById(
options.messageId,
'kickOffAttachmentDownload'
);
const message = await getMessageById(options.messageId);
if (!message) {
throw new Error(
`kickOffAttachmentDownload: Message ${options.messageId} missing!`
);
}
const didUpdateValues = await message.queueAttachmentDownloads(
const didUpdateValues = await queueAttachmentDownloadsForMessage(
message,
AttachmentDownloadUrgency.IMMEDIATE
);
@ -2309,6 +2314,7 @@ function kickOffAttachmentDownload(
drop(
DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
})
);
}
@ -2329,10 +2335,7 @@ function cancelAttachmentDownload({
NoopActionType
> {
return async dispatch => {
const message = await __DEPRECATED$getMessageById(
messageId,
'cancelAttachmentDownload'
);
const message = await getMessageById(messageId);
if (!message) {
log.warn(`cancelAttachmentDownload: Message ${messageId} missing!`);
} else {
@ -2344,7 +2347,10 @@ function cancelAttachmentDownload({
});
const ourAci = window.textsecure.storage.user.getCheckedAci();
await DataWriter.saveMessage(message.attributes, { ourAci });
await DataWriter.saveMessage(message.attributes, {
ourAci,
postSaveUpdates,
});
}
// A click kicks off downloads for every attachment in a message, so cancel does too
@ -2370,16 +2376,7 @@ function markAttachmentAsCorrupted(
options: AttachmentOptions
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const message = await __DEPRECATED$getMessageById(
options.messageId,
'markAttachmentAsCorrupted'
);
if (!message) {
throw new Error(
`markAttachmentAsCorrupted: Message ${options.messageId} missing!`
);
}
message.markAttachmentAsCorrupted(options.attachment);
await doMarkAttachmentAsCorrupted(options.messageId, options.attachment);
dispatch({
type: 'NOOP',
@ -2392,10 +2389,7 @@ function openGiftBadge(
messageId: string
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return async dispatch => {
const message = await __DEPRECATED$getMessageById(
messageId,
'openGiftBadge'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`openGiftBadge: Message ${messageId} missing!`);
}
@ -2415,14 +2409,106 @@ function retryMessageSend(
messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const message = await __DEPRECATED$getMessageById(
messageId,
'retryMessageSend'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`retryMessageSend: Message ${messageId} missing!`);
}
await message.retrySend();
// eslint-disable-next-line @typescript-eslint/no-non-null-assertion
const conversation = window.ConversationController.get(
message.attributes.conversationId
)!;
let currentConversationRecipients: Set<string> | undefined;
const { storyDistributionListId } = message.attributes;
if (storyDistributionListId) {
const storyDistribution =
await DataReader.getStoryDistributionWithMembers(
storyDistributionListId
);
if (!storyDistribution) {
markFailed(message);
return;
}
currentConversationRecipients = new Set(
storyDistribution.members
.map(serviceId => window.ConversationController.get(serviceId)?.id)
.filter(isNotNil)
);
} else {
currentConversationRecipients = conversation.getMemberConversationIds();
}
// Determine retry recipients and get their most up-to-date addressing information
const oldSendStateByConversationId =
message.get('sendStateByConversationId') || {};
const newSendStateByConversationId = { ...oldSendStateByConversationId };
for (const [conversationId, sendState] of Object.entries(
oldSendStateByConversationId
)) {
if (isSent(sendState.status)) {
continue;
}
const recipient = window.ConversationController.get(conversationId);
if (
!recipient ||
(!currentConversationRecipients.has(conversationId) &&
!isMe(recipient.attributes))
) {
continue;
}
newSendStateByConversationId[conversationId] = sendStateReducer(
sendState,
{
type: SendActionType.ManuallyRetried,
updatedAt: Date.now(),
}
);
}
message.set({ sendStateByConversationId: newSendStateByConversationId });
if (isStory(message.attributes)) {
await conversationJobQueue.add(
{
type: conversationQueueJobEnum.enum.Story,
conversationId: conversation.id,
messageIds: [message.id],
// using the group timestamp, which will differ from the 1:1 timestamp
timestamp: message.attributes.timestamp,
},
async jobToInsert => {
await DataWriter.saveMessage(message.attributes, {
jobToInsert,
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
);
} else {
await conversationJobQueue.add(
{
type: conversationQueueJobEnum.enum.NormalMessage,
conversationId: conversation.id,
messageId: message.id,
revision: conversation.get('revision'),
},
async jobToInsert => {
await DataWriter.saveMessage(message.attributes, {
jobToInsert,
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
);
}
dispatch({
type: 'NOOP',
@ -2435,15 +2521,12 @@ export function copyMessageText(
messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const message = await __DEPRECATED$getMessageById(
messageId,
'copyMessageText'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`copy: Message ${messageId} missing!`);
}
const body = message.getNotificationText();
const body = getNotificationTextForMessage(message.attributes);
clipboard.writeText(body);
dispatch({
@ -2457,10 +2540,7 @@ export function retryDeleteForEveryone(
messageId: string
): ThunkAction<void, RootStateType, unknown, NoopActionType> {
return async dispatch => {
const message = await __DEPRECATED$getMessageById(
messageId,
'retryDeleteForEveryone'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`retryDeleteForEveryone: Message ${messageId} missing!`);
}
@ -2472,7 +2552,9 @@ export function retryDeleteForEveryone(
}
try {
const conversation = message.getConversation();
const conversation = window.ConversationController.get(
message.get('conversationId')
);
if (!conversation) {
throw new Error(
`retryDeleteForEveryone: Conversation for ${messageId} missing!`
@ -2489,7 +2571,7 @@ export function retryDeleteForEveryone(
};
log.info(
`retryDeleteForEveryone: Adding job for message ${message.idForLogging()}!`
`retryDeleteForEveryone: Adding job for message ${getMessageIdForLogging(message.attributes)}!`
);
await conversationJobQueue.add(jobData);
@ -3247,12 +3329,7 @@ function pushPanelForConversation(
const message =
conversations.messagesLookup[messageId] ||
(
await __DEPRECATED$getMessageById(
messageId,
'pushPanelForConversation'
)
)?.attributes;
(await getMessageById(messageId))?.attributes;
if (!message) {
throw new Error(
'pushPanelForConversation: could not find message for MessageDetails'
@ -3328,17 +3405,16 @@ function deleteMessagesForEveryone(
await Promise.all(
messageIds.map(async messageId => {
try {
const message = window.MessageCache.__DEPRECATED$getById(
messageId,
'deleteMessagesForEveryone'
);
const message = window.MessageCache.getById(messageId);
if (!message) {
throw new Error(
`deleteMessageForEveryone: Message ${messageId} missing!`
);
}
const conversation = message.getConversation();
const conversation = window.ConversationController.get(
message.get('conversationId')
);
if (!conversation) {
throw new Error('deleteMessageForEveryone: no conversation');
}
@ -3834,11 +3910,7 @@ function loadRecentMediaItems(
// Cache these messages in memory to ensure Lightbox can find them
messages.forEach(message => {
window.MessageCache.__DEPRECATED$register(
message.id,
message,
'loadRecentMediaItems'
);
window.MessageCache.register(new MessageModel(message));
});
let index = 0;
@ -4042,10 +4114,7 @@ export function saveAttachmentFromMessage(
providedAttachment?: AttachmentType
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
return async (dispatch, getState) => {
const message = await __DEPRECATED$getMessageById(
messageId,
'saveAttachmentFromMessage'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(
`saveAttachmentFromMessage: Message ${messageId} missing!`
@ -4138,10 +4207,7 @@ export function scrollToMessage(
throw new Error('scrollToMessage: No conversation found');
}
const message = await __DEPRECATED$getMessageById(
messageId,
'scrollToMessage'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`scrollToMessage: failed to load message ${messageId}`);
}
@ -4155,12 +4221,7 @@ export function scrollToMessage(
let isInMemory = true;
if (
!window.MessageCache.__DEPRECATED$getById(
messageId,
'scrollToMessage/notInMemory'
)
) {
if (!window.MessageCache.getById(messageId)) {
isInMemory = false;
}
@ -4591,10 +4652,7 @@ function onConversationOpened(
log.info(`${logId}: Updating newly opened conversation state`);
if (messageId) {
const message = await __DEPRECATED$getMessageById(
messageId,
'onConversationOpened'
);
const message = await getMessageById(messageId);
if (message) {
drop(conversation.loadAndScroll(messageId));
@ -4733,12 +4791,9 @@ function showArchivedConversations(): ShowArchivedConversationsActionType {
}
function doubleCheckMissingQuoteReference(messageId: string): NoopActionType {
const message = window.MessageCache.__DEPRECATED$getById(
messageId,
'doubleCheckMissingQuoteReference'
);
const message = window.MessageCache.getById(messageId);
if (message) {
void message.doubleCheckMissingQuoteReference();
drop(doDoubleCheckMissingQuoteReference(message));
}
return {

View file

@ -49,6 +49,7 @@ import type { CallLinkType } from '../../types/CallLink';
import type { LocalizerType } from '../../types/I18N';
import { linkCallRoute } from '../../util/signalRoutes';
import type { StartCallData } from '../../components/ConfirmLeaveCallModal';
import { getMessageById } from '../../messages/getMessageById';
// State
@ -624,12 +625,13 @@ function toggleForwardMessagesModal(
if (payload.type === ForwardMessagesModalType.Forward) {
messageDrafts = await Promise.all(
payload.messageIds.map(async messageId => {
const messageAttributes = await window.MessageCache.resolveAttributes(
'toggleForwardMessagesModal',
messageId
const message = await getMessageById(messageId);
if (!message) {
throw new Error(
'toggleForwardMessagesModal: failed to find target message'
);
const { attachments = [] } = messageAttributes;
}
const { attachments = [] } = message.attributes;
if (!attachments.every(isDownloaded)) {
dispatch(
@ -641,7 +643,7 @@ function toggleForwardMessagesModal(
const messagePropsSelector = getMessagePropsSelector(state);
const conversationSelector = getConversationSelector(state);
const messageProps = messagePropsSelector(messageAttributes);
const messageProps = messagePropsSelector(message.attributes);
const messageDraft = toMessageForwardDraft(
messageProps,
conversationSelector
@ -944,12 +946,14 @@ function showEditHistoryModal(
messageId: string
): ThunkAction<void, RootStateType, unknown, ShowEditHistoryModalActionType> {
return async dispatch => {
const messageAttributes = await window.MessageCache.resolveAttributes(
'showEditHistoryModal',
messageId
const message = await getMessageById(messageId);
if (!message) {
throw new Error('showEditHistoryModal: failed to find target message');
}
const nextEditHistoryMessages = copyOverMessageAttributesIntoEditHistory(
message.attributes
);
const nextEditHistoryMessages =
copyOverMessageAttributesIntoEditHistory(messageAttributes);
if (!nextEditHistoryMessages) {
log.warn('showEditHistoryModal: no edit history for message');

View file

@ -17,7 +17,7 @@ import type { ShowToastActionType } from './toast';
import type { StateType as RootStateType } from '../reducer';
import * as log from '../../logging/log';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { getMessageById } from '../../messages/getMessageById';
import type { ReadonlyMessageAttributesType } from '../../model-types.d';
import { isGIF } from '../../types/Attachment';
import {
@ -40,6 +40,8 @@ import {
import { showStickerPackPreview } from './globalModals';
import { useBoundActions } from '../../hooks/useBoundActions';
import { DataReader } from '../../sql/Client';
import { getMessageIdForLogging } from '../../util/idForLogging';
import { markViewOnceMessageViewed } from '../../services/MessageUpdater';
// eslint-disable-next-line local-rules/type-alias-readonlydeep
export type LightboxStateType =
@ -156,10 +158,7 @@ function showLightboxForViewOnceMedia(
return async dispatch => {
log.info('showLightboxForViewOnceMedia: attempting to display message');
const message = await __DEPRECATED$getMessageById(
messageId,
'showLightboxForViewOnceMedia'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(
`showLightboxForViewOnceMedia: Message ${messageId} missing!`
@ -168,20 +167,20 @@ function showLightboxForViewOnceMedia(
if (!isTapToView(message.attributes)) {
throw new Error(
`showLightboxForViewOnceMedia: Message ${message.idForLogging()} is not a tap to view message`
`showLightboxForViewOnceMedia: Message ${getMessageIdForLogging(message.attributes)} is not a tap to view message`
);
}
if (message.isErased()) {
if (message.get('isErased')) {
throw new Error(
`showLightboxForViewOnceMedia: Message ${message.idForLogging()} is already erased`
`showLightboxForViewOnceMedia: Message ${getMessageIdForLogging(message.attributes)} is already erased`
);
}
const firstAttachment = (message.get('attachments') || [])[0];
if (!firstAttachment || !firstAttachment.path) {
throw new Error(
`showLightboxForViewOnceMedia: Message ${message.idForLogging()} had no first attachment with path`
`showLightboxForViewOnceMedia: Message ${getMessageIdForLogging(message.attributes)} had no first attachment with path`
);
}
@ -195,7 +194,7 @@ function showLightboxForViewOnceMedia(
path: tempPath,
};
await message.markViewOnceMessageViewed();
await markViewOnceMessageViewed(message);
const { contentType } = tempAttachment;
@ -253,10 +252,7 @@ function showLightbox(opts: {
return async (dispatch, getState) => {
const { attachment, messageId } = opts;
const message = await __DEPRECATED$getMessageById(
messageId,
'showLightbox'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error(`showLightbox: Message ${messageId} missing!`);
}
@ -393,10 +389,7 @@ function showLightboxForAdjacentMessage(
const [media] = lightbox.media;
const { id: messageId, receivedAt, sentAt } = media.message;
const message = await __DEPRECATED$getMessageById(
messageId,
'showLightboxForAdjacentMessage'
);
const message = await getMessageById(messageId);
if (!message) {
log.warn('showLightboxForAdjacentMessage: original message is gone');
dispatch({

View file

@ -33,6 +33,7 @@ import type { MIMEType } from '../../types/MIME';
import type { MediaItemType } from '../../types/MediaItem';
import type { StateType as RootStateType } from '../reducer';
import type { MessageAttributesType } from '../../model-types';
import { MessageModel } from '../../models/messages';
type MediaItemMessage = ReadonlyDeep<{
attachments: Array<AttachmentType>;
@ -141,13 +142,13 @@ function _getMediaItemMessage(
}
function _cleanVisualAttachments(
rawMedia: ReadonlyDeep<ReadonlyArray<MessageAttributesType>>
rawMedia: ReadonlyDeep<ReadonlyArray<MessageModel>>
): ReadonlyArray<MediaType> {
return rawMedia
.flatMap(message => {
let index = 0;
return (message.attachments || []).map(
return (message.get('attachments') || []).map(
(attachment: AttachmentType): MediaType | undefined => {
if (
!attachment.path ||
@ -168,7 +169,7 @@ function _cleanVisualAttachments(
contentType: attachment.contentType,
index,
attachment,
message: _getMediaItemMessage(message),
message: _getMediaItemMessage(message.attributes),
};
index += 1;
@ -181,11 +182,11 @@ function _cleanVisualAttachments(
}
function _cleanFileAttachments(
rawDocuments: ReadonlyDeep<ReadonlyArray<MessageAttributesType>>
rawDocuments: ReadonlyDeep<ReadonlyArray<MessageModel>>
): ReadonlyArray<MediaItemType> {
return rawDocuments
.map(message => {
const attachments = message.attachments || [];
const attachments = message.get('attachments') || [];
const attachment = attachments[0];
if (!attachment) {
return;
@ -196,7 +197,7 @@ function _cleanFileAttachments(
index: 0,
attachment,
message: {
..._getMediaItemMessage(message),
..._getMediaItemMessage(message.attributes),
attachments: [attachment],
},
};
@ -205,27 +206,25 @@ function _cleanFileAttachments(
}
async function _upgradeMessages(
messages: ReadonlyArray<MessageAttributesType>
): Promise<ReadonlyArray<MessageAttributesType>> {
messages: ReadonlyArray<MessageModel>
): Promise<void> {
// We upgrade these messages so they are sure to have thumbnails
const upgraded = await Promise.all(
await Promise.all(
messages.map(async message => {
try {
return await window.MessageCache.upgradeSchema(
await window.MessageCache.upgradeSchema(
message,
VERSION_NEEDED_FOR_DISPLAY
);
} catch (error) {
log.warn(
'_upgradeMessages: Failed to upgrade message ' +
`${getMessageIdForLogging(message)}: ${Errors.toLogFormat(error)}`
`${getMessageIdForLogging(message.attributes)}: ${Errors.toLogFormat(error)}`
);
return undefined;
}
})
);
return upgraded.filter(isNotNil);
}
function initialLoad(
@ -242,24 +241,28 @@ function initialLoad(
payload: { loading: true },
});
const rawMedia = await DataReader.getOlderMessagesByConversation({
const rawMedia = (
await DataReader.getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false,
limit: FETCH_CHUNK_COUNT,
requireVisualMediaAttachments: true,
storyId: undefined,
});
const rawDocuments = await DataReader.getOlderMessagesByConversation({
})
).map(item => window.MessageCache.register(new MessageModel(item)));
const rawDocuments = (
await DataReader.getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false,
limit: FETCH_CHUNK_COUNT,
requireFileAttachments: true,
storyId: undefined,
});
})
).map(item => window.MessageCache.register(new MessageModel(item)));
const upgraded = await _upgradeMessages(rawMedia);
const media = _cleanVisualAttachments(upgraded);
await _upgradeMessages(rawMedia);
const media = _cleanVisualAttachments(rawMedia);
const documents = _cleanFileAttachments(rawDocuments);
dispatch({
@ -305,7 +308,8 @@ function loadMoreMedia(
const { sentAt, receivedAt, id: messageId } = oldestLoadedMedia.message;
const rawMedia = await DataReader.getOlderMessagesByConversation({
const rawMedia = (
await DataReader.getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false,
limit: FETCH_CHUNK_COUNT,
@ -314,10 +318,12 @@ function loadMoreMedia(
requireVisualMediaAttachments: true,
sentAt,
storyId: undefined,
});
})
).map(item => window.MessageCache.register(new MessageModel(item)));
const upgraded = await _upgradeMessages(rawMedia);
const media = _cleanVisualAttachments(upgraded);
await _upgradeMessages(rawMedia);
const media = _cleanVisualAttachments(rawMedia);
dispatch({
type: LOAD_MORE_MEDIA,
@ -367,7 +373,8 @@ function loadMoreDocuments(
const { sentAt, receivedAt, id: messageId } = oldestLoadedDocument.message;
const rawDocuments = await DataReader.getOlderMessagesByConversation({
const rawDocuments = (
await DataReader.getOlderMessagesByConversation({
conversationId,
includeStoryReplies: false,
limit: FETCH_CHUNK_COUNT,
@ -376,7 +383,8 @@ function loadMoreDocuments(
requireFileAttachments: true,
sentAt,
storyId: undefined,
});
})
).map(item => window.MessageCache.register(new MessageModel(item)));
const documents = _cleanFileAttachments(rawDocuments);
@ -500,8 +508,12 @@ export function reducer(
const oldestLoadedMedia = state.media[0];
const oldestLoadedDocument = state.documents[0];
const newMedia = _cleanVisualAttachments([message]);
const newDocuments = _cleanFileAttachments([message]);
const newMedia = _cleanVisualAttachments([
window.MessageCache.register(new MessageModel(message)),
]);
const newDocuments = _cleanFileAttachments([
window.MessageCache.register(new MessageModel(message)),
]);
let { documents, haveOldestDocument, haveOldestMedia, media } = state;

View file

@ -36,7 +36,7 @@ import { blockSendUntilConversationsAreVerified } from '../../util/blockSendUnti
import { deleteStoryForEveryone as doDeleteStoryForEveryone } from '../../util/deleteStoryForEveryone';
import { deleteGroupStoryReplyForEveryone as doDeleteGroupStoryReplyForEveryone } from '../../util/deleteGroupStoryReplyForEveryone';
import { enqueueReactionForSend } from '../../reactions/enqueueReactionForSend';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { getMessageById } from '../../messages/getMessageById';
import { markOnboardingStoryAsRead } from '../../util/markOnboardingStoryAsRead';
import { markViewed } from '../../services/MessageUpdater';
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
@ -69,7 +69,7 @@ import {
conversationQueueJobEnum,
} from '../../jobs/conversationJobQueue';
import { ReceiptType } from '../../types/Receipt';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import { cleanupMessages, postSaveUpdates } from '../../util/cleanup';
export type StoryDataType = ReadonlyDeep<
{
@ -286,7 +286,7 @@ function deleteGroupStoryReply(
messageId: string
): ThunkAction<void, RootStateType, unknown, StoryReplyDeletedActionType> {
return async dispatch => {
await DataWriter.removeMessage(messageId, { singleProtoJobQueue });
await DataWriter.removeMessage(messageId, { cleanupMessages });
dispatch({
type: STORY_REPLY_DELETED,
payload: messageId,
@ -382,10 +382,7 @@ function markStoryRead(
return;
}
const message = await __DEPRECATED$getMessageById(
messageId,
'markStoryRead'
);
const message = await getMessageById(messageId);
if (!message) {
log.warn(`markStoryRead: no message found ${messageId}`);
@ -427,6 +424,7 @@ function markStoryRead(
drop(
DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
})
);
@ -524,10 +522,7 @@ function queueStoryDownload(
return;
}
const message = await __DEPRECATED$getMessageById(
storyId,
'queueStoryDownload'
);
const message = await getMessageById(storyId);
if (message) {
// We want to ensure that we re-hydrate the story reply context with the
@ -1402,10 +1397,7 @@ function removeAllContactStories(
const messages = (
await Promise.all(
messageIds.map(async messageId => {
const message = await __DEPRECATED$getMessageById(
messageId,
'removeAllContactStories'
);
const message = await getMessageById(messageId);
if (!message) {
log.warn(`${logId}: no message found ${messageId}`);
@ -1419,7 +1411,7 @@ function removeAllContactStories(
log.info(`${logId}: removing ${messages.length} stories`);
await DataWriter.removeMessages(messageIds, { singleProtoJobQueue });
await DataWriter.removeMessages(messageIds, { cleanupMessages });
dispatch({
type: 'NOOP',

View file

@ -23,7 +23,7 @@ import { useLinkPreviewActions } from '../ducks/linkPreviews';
import { SmartCompositionTextArea } from './CompositionTextArea';
import { useToastActions } from '../ducks/toast';
import { isDownloaded } from '../../types/Attachment';
import { __DEPRECATED$getMessageById } from '../../messages/getMessageById';
import { getMessageById } from '../../messages/getMessageById';
import { strictAssert } from '../../util/assert';
import type {
ForwardMessageData,
@ -117,10 +117,7 @@ function SmartForwardMessagesModalInner({
if (draft.originalMessageId == null) {
return { draft, originalMessage: null };
}
const message = await __DEPRECATED$getMessageById(
draft.originalMessageId,
'doForwardMessages'
);
const message = await getMessageById(draft.originalMessageId);
strictAssert(message, 'no message found');
return {
draft,

View file

@ -17,6 +17,7 @@ import {
messageReceiptTypeSchema,
} from '../messageModifiers/MessageReceipts';
import { ReadStatus } from '../messages/MessageReadStatus';
import { postSaveUpdates } from '../util/cleanup';
describe('MessageReceipts', () => {
let ourAci: AciString;
@ -81,6 +82,7 @@ describe('MessageReceipts', () => {
await DataWriter.saveMessage(messageAttributes, {
forceSave: true,
ourAci,
postSaveUpdates,
});
await Promise.all([
@ -158,6 +160,7 @@ describe('MessageReceipts', () => {
await DataWriter.saveMessage(messageAttributes, {
forceSave: true,
ourAci,
postSaveUpdates,
});
await DataWriter.saveEditedMessage(messageAttributes, ourAci, {
conversationId: messageAttributes.conversationId,

View file

@ -27,6 +27,7 @@ import { generateAci, generatePni } from '../../types/ServiceId';
import { DataReader, DataWriter } from '../../sql/Client';
import { getRandomBytes } from '../../Crypto';
import * as Bytes from '../../Bytes';
import { postSaveUpdates } from '../../util/cleanup';
export const OUR_ACI = generateAci();
export const OUR_PNI = generatePni();
@ -213,7 +214,11 @@ export async function asymmetricRoundtripHarness(
try {
const targetOutputFile = path.join(outDir, 'backup.bin');
await DataWriter.saveMessages(before, { forceSave: true, ourAci: OUR_ACI });
await DataWriter.saveMessages(before, {
forceSave: true,
ourAci: OUR_ACI,
postSaveUpdates,
});
await backupsService.exportToDisk(targetOutputFile, options.backupLevel);

View file

@ -17,7 +17,6 @@ import { clearData } from './helpers';
import { loadAllAndReinitializeRedux } from '../../services/allLoaders';
import { backupsService, BackupType } from '../../services/backups';
import { initialize as initializeExpiringMessageService } from '../../services/expiringMessagesDeletion';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import { DataWriter } from '../../sql/Client';
const { BACKUP_INTEGRATION_DIR } = process.env;
@ -42,7 +41,7 @@ class MemoryStream extends InputStream {
describe('backup/integration', () => {
before(async () => {
await initializeExpiringMessageService(singleProtoJobQueue);
await initializeExpiringMessageService();
});
beforeEach(async () => {

View file

@ -2,12 +2,14 @@
// SPDX-License-Identifier: AGPL-3.0-only
import { assert } from 'chai';
import { v4 as generateUuid } from 'uuid';
import { v7 as generateUuid } from 'uuid';
import { DataWriter } from '../../sql/Client';
import { SendStatus } from '../../messages/MessageSendState';
import { IMAGE_PNG } from '../../types/MIME';
import { generateAci, generatePni } from '../../types/ServiceId';
import { postSaveUpdates } from '../../util/cleanup';
import { MessageModel } from '../../models/messages';
describe('Conversations', () => {
async function resetConversationController(): Promise<void> {
@ -40,6 +42,7 @@ describe('Conversations', () => {
profileSharing: true,
version: 0,
expireTimerVersion: 1,
lastMessage: 'starting value',
});
await window.textsecure.storage.user.setCredentials({
@ -59,7 +62,7 @@ describe('Conversations', () => {
// Creating a fake message
const now = Date.now();
let message = new window.Whisper.Message({
let message = new MessageModel({
attachments: [],
body: 'bananas',
conversationId: conversation.id,
@ -84,12 +87,9 @@ describe('Conversations', () => {
await DataWriter.saveMessage(message.attributes, {
forceSave: true,
ourAci,
postSaveUpdates,
});
message = window.MessageCache.__DEPRECATED$register(
message.id,
message,
'test'
);
message = window.MessageCache.register(message);
await DataWriter.updateConversation(conversation.attributes);
await conversation.updateLastMessage();

View file

@ -9,7 +9,7 @@ import type { AttachmentType } from '../../types/Attachment';
import type { CallbackResultType } from '../../textsecure/Types.d';
import type { ConversationModel } from '../../models/conversations';
import type { MessageAttributesType } from '../../model-types.d';
import type { MessageModel } from '../../models/messages';
import { MessageModel } from '../../models/messages';
import type { RawBodyRange } from '../../types/BodyRange';
import type { StorageAccessType } from '../../types/Storage.d';
import type { WebAPIType } from '../../textsecure/WebAPI';
@ -30,6 +30,9 @@ import {
TEXT_ATTACHMENT,
VIDEO_MP4,
} from '../../types/MIME';
import { getNotificationDataForMessage } from '../../util/getNotificationDataForMessage';
import { getNotificationTextForMessage } from '../../util/getNotificationTextForMessage';
import { send } from '../../messages/send';
describe('Message', () => {
const STORAGE_KEYS_TO_RESTORE: Array<keyof StorageAccessType> = [
@ -54,17 +57,22 @@ describe('Message', () => {
const ourServiceId = generateAci();
function createMessage(attrs: Partial<MessageAttributesType>): MessageModel {
return new window.Whisper.Message({
id: generateUuid(),
const id = generateUuid();
return window.MessageCache.register(
new MessageModel({
id,
...attrs,
sent_at: Date.now(),
received_at: Date.now(),
} as MessageAttributesType);
} as MessageAttributesType)
);
}
function createMessageAndGetNotificationData(attrs: {
[key: string]: unknown;
}) {
return createMessage(attrs).getNotificationData();
const message = createMessage(attrs);
return getNotificationDataForMessage(message.attributes);
}
before(async () => {
@ -184,7 +192,7 @@ describe('Message', () => {
editMessage: undefined,
});
await message.send({
await send(message, {
promise,
targetTimestamp: message.get('timestamp'),
});
@ -207,7 +215,7 @@ describe('Message', () => {
const message = createMessage({ type: 'outgoing', source });
const promise = Promise.reject(new Error('foo bar'));
await message.send({
await send(message, {
promise,
targetTimestamp: message.get('timestamp'),
});
@ -224,7 +232,7 @@ describe('Message', () => {
errors: [new Error('baz qux')],
};
const promise = Promise.reject(result);
await message.send({
await send(message, {
promise,
targetTimestamp: message.get('timestamp'),
});
@ -675,8 +683,7 @@ describe('Message', () => {
describe('getNotificationText', () => {
it("returns a notification's text", async () => {
assert.strictEqual(
createMessage({
const message = createMessage({
conversationId: (
await window.ConversationController.getOrCreateAndWait(
generateUuid(),
@ -686,7 +693,10 @@ describe('Message', () => {
type: 'incoming',
source,
body: 'hello world',
}).getNotificationText(),
});
assert.strictEqual(
getNotificationTextForMessage(message.attributes),
'hello world'
);
});
@ -698,9 +708,7 @@ describe('Message', () => {
return false;
},
});
assert.strictEqual(
createMessage({
const message = createMessage({
conversationId: (
await window.ConversationController.getOrCreateAndWait(
generateUuid(),
@ -715,7 +723,9 @@ describe('Message', () => {
size: 0,
},
],
}).getNotificationText(),
});
assert.strictEqual(
getNotificationTextForMessage(message.attributes),
'📷 Photo'
);
});
@ -728,8 +738,7 @@ describe('Message', () => {
},
});
assert.strictEqual(
createMessage({
const message = createMessage({
conversationId: (
await window.ConversationController.getOrCreateAndWait(
generateUuid(),
@ -744,7 +753,10 @@ describe('Message', () => {
size: 0,
},
],
}).getNotificationText(),
});
assert.strictEqual(
getNotificationTextForMessage(message.attributes),
'Photo'
);
});

View file

@ -22,6 +22,7 @@ import { type AttachmentType, AttachmentVariant } from '../../types/Attachment';
import { strictAssert } from '../../util/assert';
import { AttachmentDownloadSource } from '../../sql/Interface';
import { getAttachmentCiphertextLength } from '../../AttachmentCrypto';
import { postSaveUpdates } from '../../util/cleanup';
function composeJob({
messageId,
@ -119,6 +120,7 @@ describe('AttachmentDownloadManager/JobManager', () => {
{
ourAci: 'ourAci' as AciString,
forceSave: true,
postSaveUpdates,
}
);
await downloadManager?.addJob({

View file

@ -3,8 +3,6 @@
import { assert } from 'chai';
import { v4 as uuid } from 'uuid';
import type { MessageAttributesType } from '../../model-types.d';
import { DataReader, DataWriter } from '../../sql/Client';
import { MessageModel } from '../../models/messages';
import { strictAssert } from '../../util/assert';
@ -56,25 +54,33 @@ describe('MessageCache', () => {
type: 'outgoing',
});
message1 = mc.__DEPRECATED$register(message1.id, message1, 'test');
message2 = mc.__DEPRECATED$register(message2.id, message2, 'test');
message1 = mc.register(message1);
message2 = mc.register(message2);
// We deliberately register this message twice for testing.
message2 = mc.__DEPRECATED$register(message2.id, message2, 'test');
mc.__DEPRECATED$register(message3.id, message3, 'test');
message2 = mc.register(message2);
mc.register(message3);
const filteredMessage = await mc.findBySentAt(1234, () => true);
assert.deepEqual(filteredMessage, message1.attributes, 'first');
assert.deepEqual(
filteredMessage?.attributes,
message1.attributes,
'first'
);
mc.__DEPRECATED$unregister(message1.id);
mc.unregister(message1.id);
const filteredMessage2 = await mc.findBySentAt(1234, () => true);
assert.deepEqual(filteredMessage2, message2.attributes, 'second');
assert.deepEqual(
filteredMessage2?.attributes,
message2.attributes,
'second'
);
});
});
describe('__DEPRECATED$register: syncing with backbone', () => {
describe('register: syncing with backbone', () => {
it('backbone to redux', () => {
const message1 = new MessageModel({
conversationId: 'xyz',
@ -85,65 +91,33 @@ describe('MessageCache', () => {
timestamp: Date.now(),
type: 'outgoing',
});
const messageFromController = window.MessageCache.__DEPRECATED$register(
message1.id,
message1,
'test'
);
const messageFromController = window.MessageCache.register(message1);
assert.strictEqual(
message1,
messageFromController,
'same objects from mc.__DEPRECATED$register'
'same objects from mc.register'
);
const messageById = window.MessageCache.__DEPRECATED$getById(
message1.id,
'test'
const messageInCache = window.MessageCache.getById(message1.id);
assert.strictEqual(
message1,
messageInCache,
'same objects from mc.getById'
);
assert.strictEqual(message1, messageById, 'same objects from mc.getById');
const messageInCache = window.MessageCache.accessAttributes(message1.id);
strictAssert(messageInCache, 'no message found');
assert.deepEqual(
message1.attributes,
messageInCache,
messageInCache?.attributes,
'same attributes as in cache'
);
message1.set({ body: 'test2' });
assert.equal(message1.attributes.body, 'test2', 'message model updated');
assert.equal(
messageById?.attributes.body,
messageInCache?.attributes.body,
'test2',
'old reference from messageById was updated'
);
assert.equal(
messageInCache.body,
'test1',
'old cache reference not updated'
);
const newMessageById = window.MessageCache.__DEPRECATED$getById(
message1.id,
'test'
);
assert.deepEqual(
message1.attributes,
newMessageById?.attributes,
'same attributes from mc.getById (2)'
);
const newMessageInCache = window.MessageCache.accessAttributes(
message1.id
);
strictAssert(newMessageInCache, 'no message found');
assert.deepEqual(
message1.attributes,
newMessageInCache,
'same attributes as in cache (2)'
);
});
it('redux to backbone (working with models)', () => {
@ -157,271 +131,28 @@ describe('MessageCache', () => {
type: 'outgoing',
});
window.MessageCache.toMessageAttributes(message.attributes);
const messageFromController = window.MessageCache.register(message);
const messageFromController = window.MessageCache.__DEPRECATED$register(
message.id,
message,
'test'
);
assert.notStrictEqual(
assert.strictEqual(
message,
messageFromController,
'mc.__DEPRECATED$register returns existing but it is not the same reference'
'mc.register returns existing but it is not the same reference'
);
assert.deepEqual(
message.attributes,
messageFromController.attributes,
'mc.__DEPRECATED$register returns existing and is the same attributes'
'mc.register returns existing and is the same attributes'
);
messageFromController.set({ body: 'test2' });
message.set({ body: 'test2' });
assert.notEqual(
message.get('body'),
messageFromController.get('body'),
'new model is not equal to old model'
);
const messageInCache = window.MessageCache.accessAttributes(message.id);
const messageInCache = window.MessageCache.getById(message.id);
strictAssert(messageInCache, 'no message found');
assert.equal(
messageFromController.get('body'),
messageInCache.body,
messageInCache.get('body'),
'new update is in cache'
);
assert.isUndefined(
messageFromController.get('storyReplyContext'),
'storyReplyContext is undefined'
);
window.MessageCache.setAttributes({
messageId: message.id,
messageAttributes: {
storyReplyContext: {
attachment: undefined,
authorAci: undefined,
messageId: 'test123',
},
},
skipSaveToDatabase: true,
});
// This works because we refresh the model whenever an attribute changes
// but this should log a warning.
assert.equal(
messageFromController.get('storyReplyContext')?.messageId,
'test123',
'storyReplyContext was updated (stale model)'
);
const newMessageFromController =
window.MessageCache.__DEPRECATED$register(message.id, message, 'test');
assert.equal(
newMessageFromController.get('storyReplyContext')?.messageId,
'test123',
'storyReplyContext was updated (not stale)'
);
});
it('redux to backbone (working with attributes)', () => {
it('sets the attributes and returns a fresh copy', () => {
const mc = new MessageCache();
const messageAttributes: MessageAttributesType = {
conversationId: uuid(),
id: uuid(),
received_at: 1,
sent_at: Date.now(),
timestamp: Date.now(),
type: 'incoming',
};
const messageModel = mc.__DEPRECATED$register(
messageAttributes.id,
messageAttributes,
'test/updateAttributes'
);
assert.deepEqual(
messageAttributes,
messageModel.attributes,
'initial attributes matches message model'
);
const proposedStoryReplyContext = {
attachment: undefined,
authorAci: undefined,
messageId: 'test123',
};
assert.notDeepEqual(
messageModel.attributes.storyReplyContext,
proposedStoryReplyContext,
'attributes were changed outside of the message model'
);
mc.setAttributes({
messageId: messageAttributes.id,
messageAttributes: {
storyReplyContext: proposedStoryReplyContext,
},
skipSaveToDatabase: true,
});
const nextMessageAttributes = mc.accessAttributesOrThrow(
'test',
messageAttributes.id
);
assert.notDeepEqual(
messageAttributes,
nextMessageAttributes,
'initial attributes are stale'
);
assert.notDeepEqual(
messageAttributes.storyReplyContext,
proposedStoryReplyContext,
'initial attributes are stale 2'
);
assert.deepEqual(
nextMessageAttributes.storyReplyContext,
proposedStoryReplyContext,
'fresh attributes match what was proposed'
);
assert.notStrictEqual(
nextMessageAttributes.storyReplyContext,
proposedStoryReplyContext,
'fresh attributes are not the same reference as proposed attributes'
);
assert.deepEqual(
messageModel.attributes,
nextMessageAttributes,
'model was updated'
);
assert.equal(
messageModel.get('storyReplyContext')?.messageId,
'test123',
'storyReplyContext in model is set correctly'
);
});
});
});
describe('accessAttributes', () => {
it('gets the attributes if they exist', () => {
const mc = new MessageCache();
const messageAttributes: MessageAttributesType = {
conversationId: uuid(),
id: uuid(),
received_at: 1,
sent_at: Date.now(),
timestamp: Date.now(),
type: 'incoming',
};
mc.toMessageAttributes(messageAttributes);
const accessAttributes = mc.accessAttributes(messageAttributes.id);
assert.deepEqual(
accessAttributes,
messageAttributes,
'attributes returned have the same values'
);
assert.notStrictEqual(
accessAttributes,
messageAttributes,
'attributes returned are not the same references'
);
const undefinedMessage = mc.accessAttributes(uuid());
assert.isUndefined(undefinedMessage, 'access did not find message');
});
});
describe('setAttributes', () => {
it('saves the new attributes to the database', async () => {
const mc = new MessageCache();
const ourAci = generateAci();
const id = uuid();
const messageAttributes: MessageAttributesType = {
conversationId: uuid(),
id,
received_at: 1,
sent_at: Date.now(),
timestamp: Date.now(),
type: 'incoming',
};
await DataWriter.saveMessage(messageAttributes, {
forceSave: true,
ourAci,
});
const changes = {
received_at: 2,
};
const newAttributes = {
...messageAttributes,
...changes,
};
mc.toMessageAttributes(messageAttributes);
await mc.setAttributes({
messageId: id,
messageAttributes: changes,
skipSaveToDatabase: false,
});
const messageFromDatabase = await DataReader.getMessageById(id);
assert.deepEqual(newAttributes, messageFromDatabase);
});
});
describe('accessAttributesOrThrow', () => {
it('accesses the attributes or throws if they do not exist', () => {
const mc = new MessageCache();
const messageAttributes: MessageAttributesType = {
conversationId: uuid(),
id: uuid(),
received_at: 1,
sent_at: Date.now(),
timestamp: Date.now(),
type: 'incoming',
};
mc.toMessageAttributes(messageAttributes);
const accessAttributes = mc.accessAttributesOrThrow(
'tests.1',
messageAttributes.id
);
assert.deepEqual(
accessAttributes,
messageAttributes,
'attributes returned have the same values'
);
assert.notStrictEqual(
accessAttributes,
messageAttributes,
'attributes returned are not the same references'
);
assert.throws(() => {
mc.accessAttributesOrThrow('tests.2', uuid());
});
});
});
});

View file

@ -9,6 +9,7 @@ import { generateAci } from '../../types/ServiceId';
import { DurationInSeconds } from '../../util/durations';
import type { MessageAttributesType } from '../../model-types.d';
import { postSaveUpdates } from '../../util/cleanup';
const { _getAllMessages, getConversationMessageStats } = DataReader;
const { removeAll, saveMessages } = DataWriter;
@ -56,6 +57,7 @@ describe('sql/conversationSummary', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -109,6 +111,7 @@ describe('sql/conversationSummary', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -199,6 +202,7 @@ describe('sql/conversationSummary', () => {
{
forceSave: true,
ourAci,
postSaveUpdates,
}
);
@ -306,6 +310,7 @@ describe('sql/conversationSummary', () => {
{
forceSave: true,
ourAci,
postSaveUpdates,
}
);
@ -355,6 +360,7 @@ describe('sql/conversationSummary', () => {
await saveMessages([message1, message2], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 2);
@ -404,6 +410,7 @@ describe('sql/conversationSummary', () => {
await saveMessages([message1, message2], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 2);
@ -446,6 +453,7 @@ describe('sql/conversationSummary', () => {
await saveMessages([message1, message2], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 2);
@ -490,6 +498,7 @@ describe('sql/conversationSummary', () => {
await saveMessages([message1, message2], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 2);
@ -549,6 +558,7 @@ describe('sql/conversationSummary', () => {
await saveMessages([message1, message2], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 2);

View file

@ -8,6 +8,7 @@ import { DataReader, DataWriter } from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import type { MessageAttributesType } from '../../model-types.d';
import { postSaveUpdates } from '../../util/cleanup';
const { _getAllMessages, searchMessages } = DataReader;
const { removeAll, saveMessages, saveMessage } = DataWriter;
@ -54,6 +55,7 @@ describe('sql/searchMessages', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -63,7 +65,7 @@ describe('sql/searchMessages', () => {
assert.strictEqual(searchResults[0].id, message2.id);
message3.body = 'message 3 - unique string';
await saveMessage(message3, { ourAci });
await saveMessage(message3, { ourAci, postSaveUpdates });
const searchResults2 = await searchMessages({ query: 'unique' });
assert.lengthOf(searchResults2, 2);
@ -110,6 +112,7 @@ describe('sql/searchMessages', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -119,7 +122,7 @@ describe('sql/searchMessages', () => {
assert.strictEqual(searchResults[0].id, message1.id);
message1.body = 'message 3 - unique string';
await saveMessage(message3, { ourAci });
await saveMessage(message3, { ourAci, postSaveUpdates });
const searchResults2 = await searchMessages({ query: 'unique' });
assert.lengthOf(searchResults2, 1);
@ -165,6 +168,7 @@ describe('sql/searchMessages', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -174,7 +178,7 @@ describe('sql/searchMessages', () => {
assert.strictEqual(searchResults[0].id, message1.id);
message1.body = 'message 3 - unique string';
await saveMessage(message3, { ourAci });
await saveMessage(message3, { ourAci, postSaveUpdates });
const searchResults2 = await searchMessages({ query: 'unique' });
assert.lengthOf(searchResults2, 1);
@ -211,6 +215,7 @@ describe('sql/searchMessages', () => {
await saveMessages([message1, message2], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 2);
@ -251,6 +256,7 @@ describe('sql/searchMessages/withMentions', () => {
await saveMessages(messages, {
forceSave: true,
ourAci,
postSaveUpdates,
});
return messages;
}

View file

@ -8,6 +8,7 @@ import { DataReader, DataWriter } from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import type { MessageAttributesType } from '../../model-types.d';
import { postSaveUpdates } from '../../util/cleanup';
const { _getAllMessages, getCallHistoryMessageByCallId } = DataReader;
const { removeAll, saveMessages } = DataWriter;
@ -37,6 +38,7 @@ describe('sql/getCallHistoryMessageByCallId', () => {
await saveMessages([callHistoryMessage], {
forceSave: true,
ourAci,
postSaveUpdates,
});
const allMessages = await _getAllMessages();

View file

@ -8,6 +8,7 @@ import { generateAci } from '../../types/ServiceId';
import { DataReader, DataWriter } from '../../sql/Client';
import type { MessageAttributesType } from '../../model-types';
import { postSaveUpdates } from '../../util/cleanup';
const { _getAllMessages, getMessagesBetween } = DataReader;
const { saveMessages, _removeAllMessages } = DataWriter;
@ -45,6 +46,7 @@ describe('sql/getMessagesBetween', () => {
await saveMessages([message1, message2, message3, message4, message5], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 5);
@ -93,6 +95,7 @@ describe('sql/getMessagesBetween', () => {
await saveMessages([message1, message2, message3, message5], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 4);

View file

@ -8,6 +8,7 @@ import { DataReader, DataWriter } from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import type { MessageAttributesType } from '../../model-types';
import { postSaveUpdates } from '../../util/cleanup';
const { _getAllMessages, getNearbyMessageFromDeletedSet } = DataReader;
const { saveMessages, _removeAllMessages } = DataWriter;
@ -45,6 +46,7 @@ describe('sql/getNearbyMessageFromDeletedSet', () => {
await saveMessages([message1, message2, message3, message4, message5], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 5);

View file

@ -8,6 +8,7 @@ import { DataReader, DataWriter } from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import type { MessageAttributesType } from '../../model-types.d';
import { postSaveUpdates } from '../../util/cleanup';
const { _getAllMessages, getRecentStoryReplies } = DataReader;
const { removeAll, saveMessages } = DataWriter;
@ -91,6 +92,7 @@ describe('sql/getRecentStoryReplies', () => {
{
forceSave: true,
ourAci,
postSaveUpdates,
}
);

View file

@ -12,6 +12,7 @@ import { ReactionReadStatus } from '../../types/Reactions';
import { DurationInSeconds } from '../../util/durations';
import type { MessageAttributesType } from '../../model-types.d';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { postSaveUpdates } from '../../util/cleanup';
const { _getAllReactions, _getAllMessages, getTotalUnreadForConversation } =
DataReader;
@ -126,6 +127,7 @@ describe('sql/markRead', () => {
{
forceSave: true,
ourAci,
postSaveUpdates,
}
);
@ -290,6 +292,7 @@ describe('sql/markRead', () => {
{
forceSave: true,
ourAci,
postSaveUpdates,
}
);
@ -392,6 +395,7 @@ describe('sql/markRead', () => {
await saveMessages([message1, message2, message3, message4, message5], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.strictEqual(
@ -518,6 +522,7 @@ describe('sql/markRead', () => {
{
forceSave: true,
ourAci,
postSaveUpdates,
}
);
assert.lengthOf(await _getAllMessages(), pad.length + 5);
@ -673,6 +678,7 @@ describe('sql/markRead', () => {
await saveMessages([message1, message2, message3, message4, message5], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 5);
@ -823,6 +829,7 @@ describe('sql/markRead', () => {
await saveMessages([message1, message2, message3, message4], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 4);

View file

@ -7,7 +7,7 @@ import { v4 as generateUuid } from 'uuid';
import { DataReader, DataWriter } from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import { constantTimeEqual, getRandomBytes } from '../../Crypto';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import { cleanupMessages, postSaveUpdates } from '../../util/cleanup';
const {
_getAllSentProtoMessageIds,
@ -128,7 +128,7 @@ describe('sql/sendLog', () => {
timestamp,
type: 'outgoing',
},
{ forceSave: true, ourAci }
{ forceSave: true, ourAci, postSaveUpdates }
);
const bytes = getRandomBytes(128);
@ -152,7 +152,7 @@ describe('sql/sendLog', () => {
assert.strictEqual(actual.timestamp, proto.timestamp);
await removeMessage(id, { singleProtoJobQueue });
await removeMessage(id, { cleanupMessages });
assert.lengthOf(await getAllSentProtos(), 0);
});

View file

@ -8,6 +8,7 @@ import { DataReader, DataWriter } from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import type { MessageAttributesType } from '../../model-types.d';
import { postSaveUpdates } from '../../util/cleanup';
const { _getAllMessages, getAllStories } = DataReader;
const { removeAll, saveMessages } = DataWriter;
@ -80,6 +81,7 @@ describe('sql/stories', () => {
await saveMessages([story1, story2, story3, story4, story5], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 5);
@ -217,6 +219,7 @@ describe('sql/stories', () => {
{
forceSave: true,
ourAci,
postSaveUpdates,
}
);

View file

@ -9,6 +9,7 @@ import { generateAci } from '../../types/ServiceId';
import type { MessageAttributesType } from '../../model-types.d';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { postSaveUpdates } from '../../util/cleanup';
const {
_getAllMessages,
@ -86,6 +87,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3, message4, message5], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 5);
@ -144,6 +146,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -199,6 +202,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -251,6 +255,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -305,6 +310,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -363,6 +369,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -442,6 +449,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3, message4, message5], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 5);
@ -499,6 +507,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -552,6 +561,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -608,6 +618,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -662,6 +673,7 @@ describe('sql/timelineFetches', () => {
await saveMessages([message1, message2, message3], {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 3);
@ -781,7 +793,7 @@ describe('sql/timelineFetches', () => {
newestInStory,
newest,
],
{ forceSave: true, ourAci }
{ forceSave: true, ourAci, postSaveUpdates }
);
assert.lengthOf(await _getAllMessages(), 8);
@ -873,7 +885,11 @@ describe('sql/timelineFetches', () => {
}
);
await saveMessages(formattedMessages, { forceSave: true, ourAci });
await saveMessages(formattedMessages, {
forceSave: true,
ourAci,
postSaveUpdates,
});
assert.lengthOf(await _getAllMessages(), 4);

View file

@ -27,6 +27,8 @@ import { actions, getEmptyState } from '../../../state/ducks/stories';
import { noopAction } from '../../../state/ducks/noop';
import { reducer as rootReducer } from '../../../state/reducer';
import { dropNull } from '../../../util/dropNull';
import { postSaveUpdates } from '../../../util/cleanup';
import { MessageModel } from '../../../models/messages';
describe('both/state/ducks/stories', () => {
const getEmptyRootState = () => ({
@ -862,11 +864,7 @@ describe('both/state/ducks/stories', () => {
const storyId = generateUuid();
const messageAttributes = getStoryMessage(storyId);
window.MessageCache.__DEPRECATED$register(
storyId,
messageAttributes,
'test'
);
window.MessageCache.register(new MessageModel(messageAttributes));
const dispatch = sinon.spy();
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
@ -888,11 +886,7 @@ describe('both/state/ducks/stories', () => {
],
};
window.MessageCache.__DEPRECATED$register(
storyId,
messageAttributes,
'test'
);
window.MessageCache.register(new MessageModel(messageAttributes));
const dispatch = sinon.spy();
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
@ -914,11 +908,7 @@ describe('both/state/ducks/stories', () => {
],
};
window.MessageCache.__DEPRECATED$register(
storyId,
messageAttributes,
'test'
);
window.MessageCache.register(new MessageModel(messageAttributes));
const dispatch = sinon.spy();
await queueStoryDownload(storyId)(dispatch, getEmptyRootState, null);
@ -941,6 +931,7 @@ describe('both/state/ducks/stories', () => {
await DataWriter.saveMessage(messageAttributes, {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates,
});
const rootState = getEmptyRootState();
@ -963,11 +954,7 @@ describe('both/state/ducks/stories', () => {
},
});
window.MessageCache.__DEPRECATED$register(
storyId,
messageAttributes,
'test'
);
window.MessageCache.register(new MessageModel(messageAttributes));
const dispatch = sinon.spy();
await queueStoryDownload(storyId)(dispatch, getState, null);
@ -1007,6 +994,7 @@ describe('both/state/ducks/stories', () => {
await DataWriter.saveMessage(messageAttributes, {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates,
});
const rootState = getEmptyRootState();
@ -1029,11 +1017,7 @@ describe('both/state/ducks/stories', () => {
},
});
window.MessageCache.__DEPRECATED$register(
storyId,
messageAttributes,
'test'
);
window.MessageCache.register(new MessageModel(messageAttributes));
const dispatch = sinon.spy();
await queueStoryDownload(storyId)(dispatch, getState, null);

View file

@ -12,7 +12,7 @@ import { SignalProtocolStore } from '../../SignalProtocolStore';
import type { ConversationModel } from '../../models/conversations';
import * as KeyChangeListener from '../../textsecure/KeyChangeListener';
import * as Bytes from '../../Bytes';
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
import { cleanupMessages } from '../../util/cleanup';
describe('KeyChangeListener', () => {
let oldNumberId: string | undefined;
@ -71,7 +71,7 @@ describe('KeyChangeListener', () => {
afterEach(async () => {
await DataWriter.removeMessagesInConversation(convo.id, {
logId: ourServiceIdWithKeyChange,
singleProtoJobQueue,
cleanupMessages,
});
await DataWriter.removeConversation(convo.id);
@ -109,7 +109,7 @@ describe('KeyChangeListener', () => {
afterEach(async () => {
await DataWriter.removeMessagesInConversation(groupConvo.id, {
logId: ourServiceIdWithKeyChange,
singleProtoJobQueue,
cleanupMessages,
});
await DataWriter.removeConversation(groupConvo.id);
});

View file

@ -7,6 +7,7 @@ import { _migrateMessageData as migrateMessageData } from '../../messages/migrat
import type { MessageAttributesType } from '../../model-types';
import { DataReader, DataWriter } from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import { postSaveUpdates } from '../../util/cleanup';
function composeMessage(timestamp: number): MessageAttributesType {
return {
@ -39,6 +40,7 @@ describe('utils/migrateMessageData', async () => {
await DataWriter.saveMessages(messages, {
forceSave: true,
ourAci: generateAci(),
postSaveUpdates,
});
const result = await migrateMessageData({

View file

@ -274,7 +274,7 @@ describe('pnp/PNI Change', function (this: Mocha.Suite) {
}
});
it('shows identity and phone number change on send to contact when e165 has changed owners', async () => {
it('shows identity and phone number change on send to contact when e164 has changed owners', async () => {
const { desktop, phone } = bootstrap;
const window = await app.getWindow();

View file

@ -0,0 +1,159 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { mapValues, pick } from 'lodash';
import type { CustomError } from '../../textsecure/Types';
import type { MessageAttributesType } from '../../model-types';
import * as log from '../../logging/log';
import * as Errors from '../../types/errors';
import {
getChangesForPropAtTimestamp,
getPropForTimestamp,
} from '../../util/editHelpers';
import {
isSent,
SendActionType,
sendStateReducer,
someRecipientSendStatus,
} from '../../messages/MessageSendState';
import { isStory } from '../../messages/helpers';
import {
notificationService,
NotificationType,
} from '../../services/notifications';
import type { MessageModel } from '../../models/messages';
export async function saveErrorsOnMessage(
message: MessageModel,
providedErrors: Error | Array<Error>,
options: { skipSave?: boolean } = {}
): Promise<void> {
const { skipSave } = options;
let errors: Array<CustomError>;
if (!(providedErrors instanceof Array)) {
errors = [providedErrors];
} else {
errors = providedErrors;
}
errors.forEach(e => {
log.error('Message.saveErrors:', Errors.toLogFormat(e));
});
errors = errors.map(e => {
// Note: in our environment, instanceof can be scary, so we have a backup check
// (Node.js vs Browser context).
// We check instanceof second because typescript believes that anything that comes
// through here must be an instance of Error, so e is 'never' after that check.
if ((e.message && e.stack) || e instanceof Error) {
return pick(
e,
'name',
'message',
'code',
'number',
'identifier',
'retryAfter',
'data',
'reason'
) as Required<Error>;
}
return e;
});
message.set({
errors: errors.concat(message.get('errors') || []),
});
if (!skipSave) {
await window.MessageCache.saveMessage(message);
}
}
export function isReplayableError(e: Error): boolean {
return (
e.name === 'MessageError' ||
e.name === 'OutgoingMessageError' ||
e.name === 'SendMessageNetworkError' ||
e.name === 'SendMessageChallengeError' ||
e.name === 'OutgoingIdentityKeyError'
);
}
/**
* Change any Pending send state to Failed. Note that this will not mark successful
* sends failed.
*/
export function markFailed(
message: MessageModel,
editMessageTimestamp?: number
): void {
const now = Date.now();
const targetTimestamp = editMessageTimestamp || message.get('timestamp');
const sendStateByConversationId = getPropForTimestamp({
log,
message: message.attributes,
prop: 'sendStateByConversationId',
targetTimestamp,
});
const newSendStateByConversationId = mapValues(
sendStateByConversationId || {},
sendState =>
sendStateReducer(sendState, {
type: SendActionType.Failed,
updatedAt: now,
})
);
const updates = getChangesForPropAtTimestamp({
log,
message: message.attributes,
prop: 'sendStateByConversationId',
targetTimestamp,
value: newSendStateByConversationId,
});
if (updates) {
message.set(updates);
}
notifyStorySendFailed(message);
}
export function notifyStorySendFailed(message: MessageModel): void {
if (!isStory(message.attributes)) {
return;
}
const { conversationId, id, timestamp } = message.attributes;
const conversation = window.ConversationController.get(conversationId);
notificationService.add({
conversationId,
storyId: id,
messageId: id,
senderTitle: conversation?.getTitle() ?? window.i18n('icu:Stories__mine'),
message: hasSuccessfulDelivery(message.attributes)
? window.i18n('icu:Stories__failed-send--partial')
: window.i18n('icu:Stories__failed-send--full'),
isExpiringMessage: false,
sentAt: timestamp,
type: NotificationType.Message,
});
}
function hasSuccessfulDelivery(message: MessageAttributesType): boolean {
const { sendStateByConversationId } = message;
const ourConversationId =
window.ConversationController.getOurConversationIdOrThrow();
return someRecipientSendStatus(
sendStateByConversationId ?? {},
ourConversationId,
isSent
);
}

View file

@ -26,9 +26,9 @@ import { getLocalAttachmentUrl } from '../util/getLocalAttachmentUrl';
type GenericEmbeddedContactType<AvatarType> = {
name?: Name;
number?: Array<Phone>;
email?: Array<Email>;
address?: Array<PostalAddress>;
number?: ReadonlyArray<Phone>;
email?: ReadonlyArray<Email>;
address?: ReadonlyArray<PostalAddress>;
avatar?: AvatarType;
organization?: string;

View file

@ -1,52 +0,0 @@
// Copyright 2023 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageModel } from '../models/messages';
import { getEnvironment, Environment } from '../environment';
export function getMessageModelLogger(model: MessageModel): MessageModel {
const { id } = model;
if (getEnvironment() !== Environment.Development) {
return model;
}
const proxyHandler: ProxyHandler<MessageModel> = {
get(_: MessageModel, property: keyof MessageModel) {
// Allowed set of attributes & methods
if (property === 'attributes') {
return model.attributes;
}
if (property === 'id') {
return id;
}
if (property === 'get') {
return model.get.bind(model);
}
if (property === 'set') {
return model.set.bind(model);
}
if (property === 'registerLocations') {
return model.registerLocations;
}
// Disallowed set of methods & attributes
if (typeof model[property] === 'function') {
return model[property].bind(model);
}
if (typeof model[property] !== 'undefined') {
return model[property];
}
return undefined;
},
};
return new Proxy(model, proxyHandler);
}

View file

@ -2,10 +2,17 @@
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageModel } from '../models/messages';
import type { MessageAttributesType } from '../model-types';
import type { AttachmentType } from '../types/Attachment';
import * as log from '../logging/log';
import * as MIME from '../types/MIME';
import { DataWriter } from '../sql/Client';
import { isMoreRecentThan } from './timestamp';
import { isNotNil } from './isNotNil';
import { queueAttachmentDownloadsForMessage } from './queueAttachmentDownloads';
import { postSaveUpdates } from './cleanup';
const MAX_ATTACHMENT_DOWNLOAD_AGE = 3600 * 72 * 1000;
const MAX_ATTACHMENT_MSGS_TO_DOWNLOAD = 250;
@ -66,10 +73,7 @@ export async function flushAttachmentDownloadQueue(): Promise<void> {
let numMessagesQueued = 0;
await Promise.all(
messageIdsToDownload.map(async messageId => {
const message = window.MessageCache.__DEPRECATED$getById(
messageId,
'flushAttachmentDownloadQueue'
);
const message = window.MessageCache.getById(messageId);
if (!message) {
log.warn(
'attachmentDownloadQueue: message not found in messageCache, maybe it was deleted?'
@ -79,14 +83,14 @@ export async function flushAttachmentDownloadQueue(): Promise<void> {
if (
isMoreRecentThan(
message.getReceivedAt(),
message.get('received_at_ms') || message.get('received_at'),
MAX_ATTACHMENT_DOWNLOAD_AGE
) ||
// Stickers and long text attachments has to be downloaded for UI
// to display the message properly.
message.hasRequiredAttachmentDownloads()
hasRequiredAttachmentDownloads(message.attributes)
) {
const shouldSave = await message.queueAttachmentDownloads();
const shouldSave = await queueAttachmentDownloadsForMessage(message);
if (shouldSave) {
messageIdsToSave.push(messageId);
}
@ -101,13 +105,35 @@ export async function flushAttachmentDownloadQueue(): Promise<void> {
);
const messagesToSave = messageIdsToSave
.map(messageId => window.MessageCache.accessAttributes(messageId))
.map(messageId => window.MessageCache.getById(messageId)?.attributes)
.filter(isNotNil);
await DataWriter.saveMessages(messagesToSave, {
ourAci: window.storage.user.getCheckedAci(),
postSaveUpdates,
});
attachmentDownloadQueue = undefined;
onQueueEmpty();
}
function hasRequiredAttachmentDownloads(
message: MessageAttributesType
): boolean {
const attachments: ReadonlyArray<AttachmentType> = message.attachments || [];
const hasLongMessageAttachments = attachments.some(attachment => {
return MIME.isLongMessage(attachment.contentType);
});
if (hasLongMessageAttachments) {
return true;
}
const { sticker } = message;
if (sticker) {
return !sticker.data || !sticker.data.path;
}
return false;
}

View file

@ -46,10 +46,7 @@ import { incrementMessageCounter } from './incrementMessageCounter';
import { ReadStatus } from '../messages/MessageReadStatus';
import { SeenStatus, maxSeenStatus } from '../MessageSeenStatus';
import { canConversationBeUnarchived } from './canConversationBeUnarchived';
import type {
ConversationAttributesType,
MessageAttributesType,
} from '../model-types';
import type { ConversationAttributesType } from '../model-types';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import MessageSender from '../textsecure/SendMessage';
import * as Bytes from '../Bytes';
@ -71,6 +68,8 @@ import { storageServiceUploadJob } from '../services/storage';
import { CallLinkFinalizeDeleteManager } from '../jobs/CallLinkFinalizeDeleteManager';
import { parsePartial, parseStrict } from './schemas';
import { calling } from '../services/calling';
import { cleanupMessages } from './cleanup';
import { MessageModel } from '../models/messages';
// utils
// -----
@ -1192,7 +1191,7 @@ async function saveCallHistory({
if (prevMessage != null) {
await DataWriter.removeMessage(prevMessage.id, {
fromSync: true,
singleProtoJobQueue,
cleanupMessages,
});
}
return callHistory;
@ -1222,7 +1221,7 @@ async function saveCallHistory({
const { id: newId } = generateMessageId(counter);
const message: MessageAttributesType = {
const message = new MessageModel({
id: prevMessage?.id ?? newId,
conversationId: conversation.id,
type: 'call-history',
@ -1234,20 +1233,15 @@ async function saveCallHistory({
readStatus: ReadStatus.Read,
seenStatus,
callId: callHistory.callId,
};
});
message.id = await DataWriter.saveMessage(message, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
// We don't want to force save if we're updating an existing message
const id = await window.MessageCache.saveMessage(message, {
forceSave: prevMessage == null,
});
message.set({ id });
log.info('saveCallHistory: Saved call history message:', message.id);
window.MessageCache.__DEPRECATED$register(
message.id,
message,
'callDisposition'
);
const model = window.MessageCache.register(message);
if (prevMessage == null) {
if (callHistory.direction === CallDirection.Outgoing) {
@ -1255,7 +1249,7 @@ async function saveCallHistory({
} else {
conversation.incrementMessageCount();
}
conversation.trigger('newmessage', message);
drop(conversation.onNewMessage(model));
}
await conversation.updateLastMessage().catch(error => {
@ -1356,11 +1350,10 @@ export async function updateCallHistoryFromLocalEvent(
export function updateDeletedMessages(messageIds: ReadonlyArray<string>): void {
messageIds.forEach(messageId => {
const message = window.MessageCache.__DEPRECATED$getById(
messageId,
'updateDeletedMessages'
const message = window.MessageCache.getById(messageId);
const conversation = window.ConversationController.get(
message?.get('conversationId')
);
const conversation = message?.getConversation();
if (message == null || conversation == null) {
return;
}
@ -1369,7 +1362,7 @@ export function updateDeletedMessages(messageIds: ReadonlyArray<string>): void {
message.get('conversationId')
);
conversation.debouncedUpdateLastMessage();
window.MessageCache.__DEPRECATED$unregister(messageId);
window.MessageCache.unregister(messageId);
});
}

View file

@ -5,11 +5,15 @@ import PQueue from 'p-queue';
import { batch } from 'react-redux';
import type { MessageAttributesType } from '../model-types.d';
import { DataReader } from '../sql/Client';
import { MessageModel } from '../models/messages';
import * as Errors from '../types/errors';
import * as log from '../logging/log';
import { DataReader, DataWriter } from '../sql/Client';
import { deletePackReference } from '../types/Stickers';
import { isStory } from '../messages/helpers';
import { isDirectConversation } from './whatTypeOfConversation';
import * as log from '../logging/log';
import { getCallHistorySelector } from '../state/selectors/callHistory';
import {
DirectCallStatus,
@ -17,20 +21,71 @@ import {
AdhocCallStatus,
} from '../types/CallDisposition';
import { getMessageIdForLogging } from './idForLogging';
import type { SingleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { MINUTE } from './durations';
import { drop } from './drop';
import { hydrateStoryContext } from './hydrateStoryContext';
import { update as updateExpiringMessagesService } from '../services/expiringMessagesDeletion';
import { tapToViewMessagesDeletionService } from '../services/tapToViewMessagesDeletionService';
export async function postSaveUpdates(): Promise<void> {
await updateExpiringMessagesService();
await tapToViewMessagesDeletionService.update();
}
export async function eraseMessageContents(
message: MessageModel,
additionalProperties = {},
shouldPersist = true
): Promise<void> {
log.info(
`Erasing data for message ${getMessageIdForLogging(message.attributes)}`
);
// Note: There are cases where we want to re-erase a given message. For example, when
// a viewed (or outgoing) View-Once message is deleted for everyone.
try {
await deleteMessageData(message.attributes);
} catch (error) {
log.error(
`Error erasing data for message ${getMessageIdForLogging(message.attributes)}:`,
Errors.toLogFormat(error)
);
}
message.set({
attachments: [],
body: '',
bodyRanges: undefined,
contact: [],
editHistory: undefined,
isErased: true,
preview: [],
quote: undefined,
sticker: undefined,
...additionalProperties,
});
window.ConversationController.get(
message.attributes.conversationId
)?.debouncedUpdateLastMessage();
if (shouldPersist) {
await DataWriter.saveMessage(message.attributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
await DataWriter.deleteSentProtoByMessageId(message.id);
}
export async function cleanupMessages(
messages: ReadonlyArray<MessageAttributesType>,
{
fromSync,
markCallHistoryDeleted,
singleProtoJobQueue,
}: {
fromSync?: boolean;
markCallHistoryDeleted: (callId: string) => Promise<void>;
singleProtoJobQueue: SingleProtoJobQueue;
}
): Promise<void> {
// First, handle any calls that need to be deleted
@ -40,8 +95,6 @@ export async function cleanupMessages(
messages.map((message: MessageAttributesType) => async () => {
await maybeDeleteCall(message, {
fromSync,
markCallHistoryDeleted,
singleProtoJobQueue,
});
})
)
@ -76,7 +129,7 @@ export function cleanupMessageFromMemory(message: MessageAttributesType): void {
const parentConversation = window.ConversationController.get(conversationId);
parentConversation?.debouncedUpdateLastMessage();
window.MessageCache.__DEPRECATED$unregister(id);
window.MessageCache.unregister(id);
}
async function cleanupStoryReplies(
@ -120,24 +173,18 @@ async function cleanupStoryReplies(
// Cleanup all group replies
await Promise.all(
replies.map(reply => {
const replyMessageModel = window.MessageCache.__DEPRECATED$register(
reply.id,
reply,
'cleanupStoryReplies/group'
const replyMessageModel = window.MessageCache.register(
new MessageModel(reply)
);
return replyMessageModel.eraseContents();
return eraseMessageContents(replyMessageModel);
})
);
} else {
// Refresh the storyReplyContext data for 1:1 conversations
await Promise.all(
replies.map(async reply => {
const model = window.MessageCache.__DEPRECATED$register(
reply.id,
reply,
'cleanupStoryReplies/1:1'
);
await model.hydrateStoryContext(story, {
const model = window.MessageCache.register(new MessageModel(reply));
await hydrateStoryContext(model.id, story, {
shouldSave: true,
isStoryErased: true,
});
@ -175,12 +222,8 @@ export async function maybeDeleteCall(
message: MessageAttributesType,
{
fromSync,
markCallHistoryDeleted,
singleProtoJobQueue,
}: {
fromSync?: boolean;
markCallHistoryDeleted: (callId: string) => Promise<void>;
singleProtoJobQueue: SingleProtoJobQueue;
}
): Promise<void> {
const { callId } = message;
@ -214,6 +257,6 @@ export async function maybeDeleteCall(
window.textsecure.MessageSender.getDeleteCallEvent(callHistory)
);
}
await markCallHistoryDeleted(callId);
await DataWriter.markCallHistoryDeleted(callId);
window.reduxActions.callHistory.removeCallHistory(callId);
}

View file

@ -8,6 +8,9 @@ import { isMe } from './whatTypeOfConversation';
import { getAuthorId } from '../messages/helpers';
import { isStory } from '../state/selectors/message';
import { isTooOldToModifyMessage } from './isTooOldToModifyMessage';
import { drop } from './drop';
import { eraseMessageContents } from './cleanup';
import { notificationService } from '../services/notifications';
export async function deleteForEveryone(
message: MessageModel,
@ -18,7 +21,9 @@ export async function deleteForEveryone(
shouldPersist = true
): Promise<void> {
if (isDeletionByMe(message, doe)) {
const conversation = message.getConversation();
const conversation = window.ConversationController.get(
message.get('conversationId')
);
// Our 1:1 stories are deleted through ts/util/onStoryRecipientUpdate.ts
if (
@ -29,7 +34,7 @@ export async function deleteForEveryone(
return;
}
await message.handleDeleteForEveryone(doe, shouldPersist);
await handleDeleteForEveryone(message, doe, shouldPersist);
return;
}
@ -44,7 +49,7 @@ export async function deleteForEveryone(
return;
}
await message.handleDeleteForEveryone(doe, shouldPersist);
await handleDeleteForEveryone(message, doe, shouldPersist);
}
function isDeletionByMe(
@ -58,3 +63,49 @@ function isDeletionByMe(
doe.fromId === ourConversationId
);
}
export async function handleDeleteForEveryone(
message: MessageModel,
del: Pick<
DeleteAttributesType,
'fromId' | 'targetSentTimestamp' | 'serverTimestamp'
>,
shouldPersist = true
): Promise<void> {
if (message.deletingForEveryone || message.get('deletedForEveryone')) {
return;
}
log.info('Handling DOE.', {
messageId: message.id,
fromId: del.fromId,
targetSentTimestamp: del.targetSentTimestamp,
messageServerTimestamp: message.get('serverTimestamp'),
deleteServerTimestamp: del.serverTimestamp,
});
try {
// eslint-disable-next-line no-param-reassign
message.deletingForEveryone = true;
// Remove any notifications for this message
notificationService.removeBy({ messageId: message.get('id') });
// Erase the contents of this message
await eraseMessageContents(
message,
{ deletedForEveryone: true, reactions: [] },
shouldPersist
);
// Update the conversation's last message in case this was the last message
drop(
window.ConversationController.get(
message.attributes.conversationId
)?.updateLastMessage()
);
} finally {
// eslint-disable-next-line no-param-reassign
message.deletingForEveryone = undefined;
}
}

View file

@ -14,7 +14,6 @@ import { missingCaseError } from './missingCaseError';
import { getMessageSentTimestampSet } from './getMessageSentTimestampSet';
import { getAuthor } from '../messages/helpers';
import { isPniString } from '../types/ServiceId';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { DataReader, DataWriter, deleteAndCleanup } from '../sql/Client';
import { deleteData } from '../types/Attachment';
@ -29,7 +28,8 @@ import type {
} from '../textsecure/messageReceiverEvents';
import type { AciString, PniString } from '../types/ServiceId';
import type { AttachmentType } from '../types/Attachment';
import type { MessageModel } from '../models/messages';
import { MessageModel } from '../models/messages';
import { cleanupMessages, postSaveUpdates } from './cleanup';
const { getMessagesBySentAt, getMostRecentAddressableMessages } = DataReader;
@ -98,8 +98,8 @@ export async function deleteMessage(
return false;
}
const message = window.MessageCache.toMessageAttributes(found);
await applyDeleteMessage(message, logId);
const message = window.MessageCache.register(new MessageModel(found));
await applyDeleteMessage(message.attributes, logId);
return true;
}
@ -109,7 +109,7 @@ export async function applyDeleteMessage(
): Promise<void> {
await deleteAndCleanup([message], logId, {
fromSync: true,
singleProtoJobQueue,
cleanupMessages,
});
}
@ -141,11 +141,7 @@ export async function deleteAttachmentFromMessage(
return false;
}
const message = window.MessageCache.__DEPRECATED$register(
found.id,
found,
'ReadSyncs.onSync'
);
const message = window.MessageCache.register(new MessageModel(found));
return applyDeleteAttachmentFromMessage(message, deleteAttachmentData, {
deleteOnDisk,
@ -209,7 +205,7 @@ export async function applyDeleteAttachmentFromMessage(
attachments: attachments?.filter(item => item !== attachment),
});
if (shouldSave) {
await saveMessage(message.attributes, { ourAci });
await saveMessage(message.attributes, { ourAci, postSaveUpdates });
}
await deleteData({ deleteOnDisk, deleteDownloadOnDisk })(attachment);
@ -291,10 +287,10 @@ export async function deleteConversation(
const { received_at: receivedAt } = newestMessage;
await removeMessagesInConversation(conversation.id, {
cleanupMessages,
fromSync: true,
receivedAt,
logId: `${logId}(receivedAt=${receivedAt})`,
singleProtoJobQueue,
receivedAt,
});
}
@ -315,10 +311,10 @@ export async function deleteConversation(
const { received_at: receivedAt } = newestNondisappearingMessage;
await removeMessagesInConversation(conversation.id, {
cleanupMessages,
fromSync: true,
receivedAt,
logId: `${logId}(receivedAt=${receivedAt})`,
singleProtoJobQueue,
receivedAt,
});
}
}

View file

@ -3,16 +3,13 @@
import { DAY } from './durations';
import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage';
import { __DEPRECATED$getMessageById } from '../messages/getMessageById';
import { getMessageById } from '../messages/getMessageById';
import * as log from '../logging/log';
export async function deleteGroupStoryReplyForEveryone(
replyMessageId: string
): Promise<void> {
const messageModel = await __DEPRECATED$getMessageById(
replyMessageId,
'deleteGroupStoryReplyForEveryone'
);
const messageModel = await getMessageById(replyMessageId);
if (!messageModel) {
log.warn(
@ -23,7 +20,9 @@ export async function deleteGroupStoryReplyForEveryone(
const timestamp = messageModel.get('timestamp');
const group = messageModel.getConversation();
const group = window.ConversationController.get(
messageModel.get('conversationId')
);
if (!group) {
log.warn(

View file

@ -20,10 +20,11 @@ import {
import { onStoryRecipientUpdate } from './onStoryRecipientUpdate';
import { sendDeleteForEveryoneMessage } from './sendDeleteForEveryoneMessage';
import { isGroupV2 } from './whatTypeOfConversation';
import { __DEPRECATED$getMessageById } from '../messages/getMessageById';
import { getMessageById } from '../messages/getMessageById';
import { strictAssert } from './assert';
import { repeat, zipObject } from './iterables';
import { isOlderThan } from './timestamp';
import { postSaveUpdates } from './cleanup';
export async function deleteStoryForEveryone(
stories: ReadonlyArray<StoryDataType>,
@ -47,10 +48,7 @@ export async function deleteStoryForEveryone(
}
const logId = `deleteStoryForEveryone(${story.messageId})`;
const message = await __DEPRECATED$getMessageById(
story.messageId,
'deleteStoryForEveryone'
);
const message = await getMessageById(story.messageId);
if (!message) {
throw new Error('Story not found');
}
@ -197,6 +195,7 @@ export async function deleteStoryForEveryone(
await DataWriter.saveMessage(message.attributes, {
jobToInsert,
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
});
} catch (error) {

View file

@ -0,0 +1,94 @@
// Copyright 2019 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { getMessageById } from '../messages/getMessageById';
import type { MessageModel } from '../models/messages';
import { hydrateStoryContext } from './hydrateStoryContext';
import { getMessageIdForLogging } from './idForLogging';
import * as log from '../logging/log';
import {
isQuoteAMatch,
shouldTryToCopyFromQuotedMessage,
} from '../messages/helpers';
import { copyQuoteContentFromOriginal } from '../messages/copyQuote';
import { queueUpdateMessage } from './messageBatcher';
export async function doubleCheckMissingQuoteReference(
message: MessageModel
): Promise<void> {
const logId = getMessageIdForLogging(message.attributes);
const storyId = message.get('storyId');
if (storyId) {
log.warn(
`doubleCheckMissingQuoteReference/${logId}: missing story reference`
);
const storyMessage = await getMessageById(storyId);
if (!storyMessage) {
return;
}
if (message.get('storyReplyContext')) {
message.set({ storyReplyContext: undefined });
}
await hydrateStoryContext(message.id, storyMessage.attributes, {
shouldSave: true,
});
return;
}
const quote = message.get('quote');
if (!quote) {
log.warn(`doubleCheckMissingQuoteReference/${logId}: Missing quote!`);
return;
}
const { authorAci, author, id: sentAt, referencedMessageNotFound } = quote;
const contact = window.ConversationController.get(authorAci || author);
// Is the quote really without a reference? Check with our in memory store
// first to make sure it's not there.
if (
contact &&
shouldTryToCopyFromQuotedMessage({
referencedMessageNotFound,
quoteAttachment: quote.attachments.at(0),
})
) {
const matchingMessage = await window.MessageCache.findBySentAt(
Number(sentAt),
model =>
isQuoteAMatch(model.attributes, message.get('conversationId'), quote)
);
if (!matchingMessage) {
log.info(
`doubleCheckMissingQuoteReference/${logId}: No match for ${sentAt}.`
);
return;
}
message.set({
quote: {
...quote,
referencedMessageNotFound: false,
},
});
log.info(
`doubleCheckMissingQuoteReference/${logId}: Found match for ${sentAt}, updating.`
);
await copyQuoteContentFromOriginal(matchingMessage, quote);
message.set({
quote: {
...quote,
referencedMessageNotFound: false,
},
});
queueUpdateMessage(message.attributes);
}
}

View file

@ -4,8 +4,7 @@
import { v4 as generateUuid } from 'uuid';
import type { AttachmentType } from '../types/Attachment';
import type { MessageAttributesType } from '../model-types.d';
import type { MessageModel } from '../models/messages';
import { MessageModel } from '../models/messages';
import * as log from '../logging/log';
import { IMAGE_JPEG } from '../types/MIME';
import { ReadStatus } from '../messages/MessageReadStatus';
@ -84,7 +83,7 @@ export async function downloadOnboardingStory(): Promise<void> {
(attachment, index) => {
const timestamp = Date.now() + index;
const partialMessage: MessageAttributesType = {
const message = new MessageModel({
attachments: [attachment],
canReplyToStory: false,
conversationId: signalConversation.id,
@ -99,12 +98,8 @@ export async function downloadOnboardingStory(): Promise<void> {
sourceServiceId: signalConversation.getServiceId(),
timestamp,
type: 'story',
};
return window.MessageCache.__DEPRECATED$register(
partialMessage.id,
partialMessage,
'downloadOnboardingStory'
);
});
return window.MessageCache.register(message);
}
);
@ -112,11 +107,6 @@ export async function downloadOnboardingStory(): Promise<void> {
storyMessages.map(message => saveNewMessageBatcher.add(message.attributes))
);
// Sync to redux
storyMessages.forEach(message => {
message.trigger('change');
});
await window.storage.put(
'existingOnboardingStoryMessageIds',
storyMessages.map(message => message.id)

View file

@ -5,7 +5,8 @@ import * as log from '../logging/log';
import { DataWriter } from '../sql/Client';
import { calculateExpirationTimestamp } from './expirationTimer';
import { DAY } from './durations';
import { singleProtoJobQueue } from '../jobs/singleProtoJobQueue';
import { cleanupMessages } from './cleanup';
import { getMessageById } from '../messages/getMessageById';
export async function findAndDeleteOnboardingStoryIfExists(): Promise<void> {
const existingOnboardingStoryMessageIds = window.storage.get(
@ -19,12 +20,14 @@ export async function findAndDeleteOnboardingStoryIfExists(): Promise<void> {
const hasExpired = await (async () => {
const [storyId] = existingOnboardingStoryMessageIds;
try {
const messageAttributes = await window.MessageCache.resolveAttributes(
'findAndDeleteOnboardingStoryIfExists',
storyId
const message = await getMessageById(storyId);
if (!message) {
throw new Error(
`findAndDeleteOnboardingStoryIfExists: Failed to find message ${storyId}`
);
}
const expires = calculateExpirationTimestamp(messageAttributes) ?? 0;
const expires = calculateExpirationTimestamp(message.attributes) ?? 0;
const now = Date.now();
const isExpired = expires < now;
@ -46,7 +49,7 @@ export async function findAndDeleteOnboardingStoryIfExists(): Promise<void> {
log.info('findAndDeleteOnboardingStoryIfExists: removing onboarding stories');
await DataWriter.removeMessages(existingOnboardingStoryMessageIds, {
singleProtoJobQueue,
cleanupMessages,
});
await window.storage.put('existingOnboardingStoryMessageIds', undefined);

View file

@ -25,6 +25,7 @@ import { isTooOldToModifyMessage } from './isTooOldToModifyMessage';
import { queueAttachmentDownloads } from './queueAttachmentDownloads';
import { modifyTargetMessage } from './modifyTargetMessage';
import { isMessageNoteToSelf } from './isMessageNoteToSelf';
import { MessageModel } from '../models/messages';
const RECURSION_LIMIT = 15;
@ -103,15 +104,13 @@ export async function handleEditMessage(
return;
}
const mainMessageModel = window.MessageCache.__DEPRECATED$register(
mainMessage.id,
mainMessage,
'handleEditMessage'
const mainMessageModel = window.MessageCache.register(
new MessageModel(mainMessage)
);
// Pull out the edit history from the main message. If this is the first edit
// then the original message becomes the first item in the edit history.
let editHistory: Array<EditHistoryType> = mainMessage.editHistory || [
let editHistory: ReadonlyArray<EditHistoryType> = mainMessage.editHistory || [
{
attachments: mainMessage.attachments,
body: mainMessage.body,
@ -215,8 +214,10 @@ export async function handleEditMessage(
const { quote: upgradedQuote } = upgradedEditedMessageData;
let nextEditedMessageQuote: QuotedMessageType | undefined;
if (!upgradedQuote) {
if (mainMessage.quote) {
// Quote dropped
log.info(`${idLog}: dropping quote`);
}
} else if (!upgradedQuote.id || upgradedQuote.id === mainMessage.quote?.id) {
// Quote preserved
nextEditedMessageQuote = mainMessage.quote;
@ -370,7 +371,9 @@ export async function handleEditMessage(
conversation.clearContactTypingTimer(typingToken);
}
const mainMessageConversation = mainMessageModel.getConversation();
const mainMessageConversation = window.ConversationController.get(
mainMessageModel.get('conversationId')
);
if (mainMessageConversation) {
drop(mainMessageConversation.updateLastMessage());
// Apply any other operations, excluding edits that target this message
@ -386,7 +389,7 @@ export async function handleEditMessage(
// Apply any other pending edits that target this message
const edits = Edits.forMessage({
...mainMessage,
...mainMessageModel.attributes,
sent_at: editedMessage.timestamp,
timestamp: editedMessage.timestamp,
});

View file

@ -12,6 +12,10 @@ import { softAssert, strictAssert } from './assert';
import { getMessageSentTimestamp } from './getMessageSentTimestamp';
import { isOlderThan } from './timestamp';
import { DAY } from './durations';
import { getMessageById } from '../messages/getMessageById';
import { MessageModel } from '../models/messages';
import { DataWriter } from '../sql/Client';
import { postSaveUpdates } from './cleanup';
export async function hydrateStoryContext(
messageId: string,
@ -24,23 +28,18 @@ export async function hydrateStoryContext(
isStoryErased?: boolean;
} = {}
): Promise<Partial<MessageAttributesType> | undefined> {
let messageAttributes: MessageAttributesType;
try {
messageAttributes = await window.MessageCache.resolveAttributes(
'hydrateStoryContext',
messageId
);
} catch {
const message = await getMessageById(messageId);
if (!message) {
log.warn(`hydrateStoryContext: Message ${messageId} not found`);
return undefined;
}
const { storyId } = messageAttributes;
const { storyId, storyReplyContext: context } = message.attributes;
if (!storyId) {
return undefined;
}
const { storyReplyContext: context } = messageAttributes;
const sentTimestamp = getMessageSentTimestamp(messageAttributes, {
const sentTimestamp = getMessageSentTimestamp(message.attributes, {
includeEdits: false,
log,
});
@ -55,22 +54,19 @@ export async function hydrateStoryContext(
return undefined;
}
let storyMessage: MessageAttributesType | undefined;
let storyMessage: MessageModel | undefined;
try {
storyMessage =
storyMessageParam === undefined
? await window.MessageCache.resolveAttributes(
'hydrateStoryContext/story',
storyId
)
: window.MessageCache.toMessageAttributes(storyMessageParam);
? await getMessageById(storyId)
: window.MessageCache.register(new MessageModel(storyMessageParam));
} catch {
storyMessage = undefined;
}
if (!storyMessage || isStoryErased) {
const conversation = window.ConversationController.get(
messageAttributes.conversationId
message.attributes.conversationId
);
softAssert(
conversation && isDirectConversation(conversation.attributes),
@ -84,30 +80,25 @@ export async function hydrateStoryContext(
messageId: '',
},
};
message.set(newMessageAttributes);
if (shouldSave) {
await window.MessageCache.setAttributes({
messageId,
messageAttributes: newMessageAttributes,
skipSaveToDatabase: false,
});
} else {
window.MessageCache.setAttributes({
messageId,
messageAttributes: newMessageAttributes,
skipSaveToDatabase: true,
const ourAci = window.textsecure.storage.user.getCheckedAci();
await DataWriter.saveMessage(message.attributes, {
ourAci,
postSaveUpdates,
});
}
return newMessageAttributes;
}
const attachments = getAttachmentsForMessage({ ...storyMessage });
const attachments = getAttachmentsForMessage({ ...storyMessage.attributes });
let attachment: AttachmentType | undefined = attachments?.[0];
if (attachment && !attachment.url && !attachment.textAttachment) {
attachment = undefined;
}
const { sourceServiceId: authorAci } = storyMessage;
const { sourceServiceId: authorAci } = storyMessage.attributes;
strictAssert(isAciString(authorAci), 'Story message from pni');
const newMessageAttributes: Partial<MessageAttributesType> = {
storyReplyContext: {
@ -116,18 +107,14 @@ export async function hydrateStoryContext(
messageId: storyMessage.id,
},
};
message.set(newMessageAttributes);
if (shouldSave) {
await window.MessageCache.setAttributes({
messageId,
messageAttributes: newMessageAttributes,
skipSaveToDatabase: false,
});
} else {
window.MessageCache.setAttributes({
messageId,
messageAttributes: newMessageAttributes,
skipSaveToDatabase: true,
const ourAci = window.textsecure.storage.user.getCheckedAci();
await DataWriter.saveMessage(message.attributes, {
ourAci,
postSaveUpdates,
});
}
return newMessageAttributes;
}

97
ts/util/isMessageEmpty.ts Normal file
View file

@ -0,0 +1,97 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { messageHasPaymentEvent } from '../messages/helpers';
import type { MessageAttributesType } from '../model-types';
import {
hasErrors,
isCallHistory,
isChatSessionRefreshed,
isConversationMerge,
isDeliveryIssue,
isEndSession,
isExpirationTimerUpdate,
isGiftBadge,
isGroupUpdate,
isGroupV2Change,
isKeyChange,
isPhoneNumberDiscovery,
isProfileChange,
isTapToView,
isTitleTransitionNotification,
isUniversalTimerNotification,
isUnsupportedMessage,
isVerifiedChange,
} from '../state/selectors/message';
export function isMessageEmpty(attributes: MessageAttributesType): boolean {
// Core message types - we check for all four because they can each stand alone
const hasBody = Boolean(attributes.body);
const hasAttachment = (attributes.attachments || []).length > 0;
const hasEmbeddedContact = (attributes.contact || []).length > 0;
const isSticker = Boolean(attributes.sticker);
// Rendered sync messages
const isCallHistoryValue = isCallHistory(attributes);
const isChatSessionRefreshedValue = isChatSessionRefreshed(attributes);
const isDeliveryIssueValue = isDeliveryIssue(attributes);
const isGiftBadgeValue = isGiftBadge(attributes);
const isGroupUpdateValue = isGroupUpdate(attributes);
const isGroupV2ChangeValue = isGroupV2Change(attributes);
const isEndSessionValue = isEndSession(attributes);
const isExpirationTimerUpdateValue = isExpirationTimerUpdate(attributes);
const isVerifiedChangeValue = isVerifiedChange(attributes);
// Placeholder messages
const isUnsupportedMessageValue = isUnsupportedMessage(attributes);
const isTapToViewValue = isTapToView(attributes);
// Errors
const hasErrorsValue = hasErrors(attributes);
// Locally-generated notifications
const isKeyChangeValue = isKeyChange(attributes);
const isProfileChangeValue = isProfileChange(attributes);
const isUniversalTimerNotificationValue =
isUniversalTimerNotification(attributes);
const isConversationMergeValue = isConversationMerge(attributes);
const isPhoneNumberDiscoveryValue = isPhoneNumberDiscovery(attributes);
const isTitleTransitionNotificationValue =
isTitleTransitionNotification(attributes);
const isPayment = messageHasPaymentEvent(attributes);
// Note: not all of these message types go through message.handleDataMessage
const hasSomethingToDisplay =
// Core message types
hasBody ||
hasAttachment ||
hasEmbeddedContact ||
isSticker ||
isPayment ||
// Rendered sync messages
isCallHistoryValue ||
isChatSessionRefreshedValue ||
isDeliveryIssueValue ||
isGiftBadgeValue ||
isGroupUpdateValue ||
isGroupV2ChangeValue ||
isEndSessionValue ||
isExpirationTimerUpdateValue ||
isVerifiedChangeValue ||
// Placeholder messages
isUnsupportedMessageValue ||
isTapToViewValue ||
// Errors
hasErrorsValue ||
// Locally-generated notifications
isKeyChangeValue ||
isProfileChangeValue ||
isUniversalTimerNotificationValue ||
isConversationMergeValue ||
isPhoneNumberDiscoveryValue ||
isTitleTransitionNotificationValue;
return !hasSomethingToDisplay;
}

View file

@ -0,0 +1,38 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import type { MessageAttributesType } from '../model-types';
import * as GoogleChrome from './GoogleChrome';
export function isValidTapToView(message: MessageAttributesType): boolean {
const { body } = message;
if (body) {
return false;
}
const { attachments } = message;
if (!attachments || attachments.length !== 1) {
return false;
}
const firstAttachment = attachments[0];
if (
!GoogleChrome.isImageTypeSupported(firstAttachment.contentType) &&
!GoogleChrome.isVideoTypeSupported(firstAttachment.contentType)
) {
return false;
}
const { quote, sticker, contact, preview } = message;
if (
quote ||
sticker ||
(contact && contact.length > 0) ||
(preview && preview.length > 0)
) {
return false;
}
return true;
}

View file

@ -55,8 +55,8 @@ export async function makeQuote(
}
export async function getQuoteAttachment(
attachments?: Array<AttachmentType>,
preview?: Array<LinkPreviewType>,
attachments?: ReadonlyArray<AttachmentType>,
preview?: ReadonlyArray<LinkPreviewType>,
sticker?: StickerType
): Promise<Array<QuotedAttachmentType>> {
const { loadAttachmentData } = window.Signal.Migrations;

View file

@ -103,10 +103,7 @@ export async function markConversationRead(
const allReadMessagesSync = allUnreadMessages
.map(messageSyncData => {
const message = window.MessageCache.__DEPRECATED$getById(
messageSyncData.id,
'markConversationRead'
);
const message = window.MessageCache.getById(messageSyncData.id);
// we update the in-memory MessageModel with fresh read/seen status
if (message) {
message.set(

View file

@ -3,11 +3,12 @@
import * as log from '../logging/log';
import { DataWriter } from '../sql/Client';
import { __DEPRECATED$getMessageById } from '../messages/getMessageById';
import { getMessageById } from '../messages/getMessageById';
import { isNotNil } from './isNotNil';
import { DurationInSeconds } from './durations';
import { markViewed } from '../services/MessageUpdater';
import { storageServiceUploadJob } from '../services/storage';
import { postSaveUpdates } from './cleanup';
export async function markOnboardingStoryAsRead(): Promise<boolean> {
const existingOnboardingStoryMessageIds = window.storage.get(
@ -20,9 +21,7 @@ export async function markOnboardingStoryAsRead(): Promise<boolean> {
}
const messages = await Promise.all(
existingOnboardingStoryMessageIds.map(id =>
__DEPRECATED$getMessageById(id, 'markOnboardingStoryAsRead')
)
existingOnboardingStoryMessageIds.map(id => getMessageById(id))
);
const storyReadDate = Date.now();
@ -49,6 +48,7 @@ export async function markOnboardingStoryAsRead(): Promise<boolean> {
await DataWriter.saveMessages(messageAttributes, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
await window.storage.put('hasViewedOnboardingStory', true);

View file

@ -6,6 +6,8 @@ import { createBatcher } from './batcher';
import { createWaitBatcher } from './waitBatcher';
import { DataWriter } from '../sql/Client';
import * as log from '../logging/log';
import { postSaveUpdates } from './cleanup';
import { MessageModel } from '../models/messages';
const updateMessageBatcher = createBatcher<ReadonlyMessageAttributesType>({
name: 'messageBatcher.updateMessageBatcher',
@ -16,11 +18,12 @@ const updateMessageBatcher = createBatcher<ReadonlyMessageAttributesType>({
// Grab the latest from the cache in case they've changed
const messagesToSave = messageAttrs.map(
message => window.MessageCache.accessAttributes(message.id) ?? message
message => window.MessageCache.getById(message.id)?.attributes ?? message
);
await DataWriter.saveMessages(messagesToSave, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
},
});
@ -35,6 +38,7 @@ export function queueUpdateMessage(
} else {
void DataWriter.saveMessage(messageAttr, {
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
}
@ -55,12 +59,15 @@ export const saveNewMessageBatcher =
// Grab the latest from the cache in case they've changed
const messagesToSave = messageAttrs.map(
message => window.MessageCache.accessAttributes(message.id) ?? message
message =>
window.MessageCache.register(new MessageModel(message))?.attributes ??
message
);
await DataWriter.saveMessages(messagesToSave, {
forceSave: true,
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
},
});

View file

@ -35,6 +35,10 @@ import {
applyDeleteAttachmentFromMessage,
applyDeleteMessage,
} from './deleteForMe';
import { getMessageIdForLogging } from './idForLogging';
import { markViewOnceMessageViewed } from '../services/MessageUpdater';
import { handleReaction } from '../messageModifiers/Reactions';
import { postSaveUpdates } from './cleanup';
export enum ModifyTargetMessageResult {
Modified = 'Modified',
@ -52,7 +56,7 @@ export async function modifyTargetMessage(
): Promise<ModifyTargetMessageResult> {
const { isFirstRun = false, skipEdits = false } = options ?? {};
const logId = `modifyTargetMessage/${message.idForLogging()}`;
const logId = `modifyTargetMessage/${getMessageIdForLogging(message.attributes)}`;
const type = message.get('type');
let changed = false;
const ourAci = window.textsecure.storage.user.getCheckedAci();
@ -157,7 +161,7 @@ export async function modifyTargetMessage(
);
if (!isEqual(oldSendStateByConversationId, newSendStateByConversationId)) {
message.set('sendStateByConversationId', newSendStateByConversationId);
message.set({ sendStateByConversationId: newSendStateByConversationId });
changed = true;
}
}
@ -184,10 +188,12 @@ export async function modifyTargetMessage(
const existingExpirationStartTimestamp = message.get(
'expirationStartTimestamp'
);
message.set(
'expirationStartTimestamp',
Math.min(existingExpirationStartTimestamp ?? Date.now(), markReadAt)
);
message.set({
expirationStartTimestamp: Math.min(
existingExpirationStartTimestamp ?? Date.now(),
markReadAt
),
});
changed = true;
}
@ -208,8 +214,10 @@ export async function modifyTargetMessage(
});
changed = true;
message.setPendingMarkRead(
Math.min(message.getPendingMarkRead() ?? Date.now(), markReadAt)
// eslint-disable-next-line no-param-reassign
message.pendingMarkRead = Math.min(
message.pendingMarkRead ?? Date.now(),
markReadAt
);
} else if (
isFirstRun &&
@ -219,9 +227,10 @@ export async function modifyTargetMessage(
conversation.setArchived(false);
}
if (!isFirstRun && message.getPendingMarkRead()) {
const markReadAt = message.getPendingMarkRead();
message.setPendingMarkRead(undefined);
if (!isFirstRun && message.pendingMarkRead) {
const markReadAt = message.pendingMarkRead;
// eslint-disable-next-line no-param-reassign
message.pendingMarkRead = undefined;
const newestSentAt = maybeSingleReadSync?.readSync.timestamp;
// This is primarily to allow the conversation to mark all older
@ -232,9 +241,9 @@ export async function modifyTargetMessage(
// message and the other ones accompanying it in the batch are fully in
// the database.
drop(
message
.getConversation()
?.onReadMessage(message.attributes, markReadAt, newestSentAt)
window.ConversationController.get(
message.get('conversationId')
)?.onReadMessage(message.attributes, markReadAt, newestSentAt)
);
}
@ -242,7 +251,7 @@ export async function modifyTargetMessage(
if (isTapToView(message.attributes)) {
const viewOnceOpenSync = ViewOnceOpenSyncs.forMessage(message.attributes);
if (viewOnceOpenSync) {
await message.markViewOnceMessageViewed({ fromSync: true });
await markViewOnceMessageViewed(message, { fromSync: true });
changed = true;
}
}
@ -262,8 +271,10 @@ export async function modifyTargetMessage(
Date.now(),
...viewSyncs.map(({ viewSync }) => viewSync.viewedAt)
);
message.setPendingMarkRead(
Math.min(message.getPendingMarkRead() ?? Date.now(), markReadAt)
// eslint-disable-next-line no-param-reassign
message.pendingMarkRead = Math.min(
message.pendingMarkRead ?? Date.now(),
markReadAt
);
}
@ -272,7 +283,7 @@ export async function modifyTargetMessage(
expirationStartTimestamp: message.get('timestamp'),
expireTimer: message.get('expireTimer'),
});
message.set('expirationStartTimestamp', message.get('timestamp'));
message.set({ expirationStartTimestamp: message.get('timestamp') });
changed = true;
}
}
@ -292,12 +303,12 @@ export async function modifyTargetMessage(
generatedMessage,
'Story reactions must provide storyReactionMessage'
);
await generatedMessage.handleReaction(reaction, {
await handleReaction(generatedMessage, reaction, {
storyMessage: message.attributes,
});
} else {
changed = true;
await message.handleReaction(reaction, { shouldPersist: false });
await handleReaction(message, reaction, { shouldPersist: false });
}
})
);
@ -317,6 +328,7 @@ export async function modifyTargetMessage(
log.info(`${logId}: Changes in second run; saving.`);
await DataWriter.saveMessage(message.attributes, {
ourAci,
postSaveUpdates,
});
}

View file

@ -13,6 +13,8 @@ import { isStory } from '../state/selectors/message';
import { queueUpdateMessage } from './messageBatcher';
import { isMe } from './whatTypeOfConversation';
import { drop } from './drop';
import { handleDeleteForEveryone } from './deleteForEveryone';
import { MessageModel } from '../models/messages';
export async function onStoryRecipientUpdate(
event: StoryRecipientUpdateEvent
@ -162,11 +164,7 @@ export async function onStoryRecipientUpdate(
return true;
}
const message = window.MessageCache.__DEPRECATED$register(
item.id,
item,
'onStoryRecipientUpdate'
);
const message = window.MessageCache.register(new MessageModel(item));
const sendStateConversationIds = new Set(
Object.keys(nextSendStateByConversationId)
@ -190,7 +188,7 @@ export async function onStoryRecipientUpdate(
// sent timestamp doesn't happen (it would return all copies of the
// story, not just the one we want to delete).
drop(
message.handleDeleteForEveryone({
handleDeleteForEveryone(message, {
fromId: ourConversationId,
serverTimestamp: Number(item.serverTimestamp),
targetSentTimestamp: item.timestamp,

View file

@ -33,13 +33,23 @@ import {
AttachmentDownloadUrgency,
} from '../jobs/AttachmentDownloadManager';
import { AttachmentDownloadSource } from '../sql/Interface';
import type { MessageModel } from '../models/messages';
import type { ConversationModel } from '../models/conversations';
import { isOutgoing, isStory } from '../messages/helpers';
import { shouldDownloadStory } from './shouldDownloadStory';
import { hasAttachmentDownloads } from './hasAttachmentDownloads';
import {
addToAttachmentDownloadQueue,
shouldUseAttachmentDownloadQueue,
} from './attachmentDownloadQueue';
import { queueUpdateMessage } from './messageBatcher';
export type MessageAttachmentsDownloadedType = {
bodyAttachment?: AttachmentType;
attachments: Array<AttachmentType>;
editHistory?: Array<EditHistoryType>;
preview: Array<LinkPreviewType>;
contact: Array<EmbeddedContactType>;
attachments: ReadonlyArray<AttachmentType>;
editHistory?: ReadonlyArray<EditHistoryType>;
preview: ReadonlyArray<LinkPreviewType>;
contact: ReadonlyArray<EmbeddedContactType>;
quote?: QuotedMessageType;
sticker?: StickerType;
};
@ -49,6 +59,50 @@ function getLogger(source: AttachmentDownloadSource) {
const log = verbose ? logger : { ...logger, info: () => null };
return log;
}
export async function handleAttachmentDownloadsForNewMessage(
message: MessageModel,
conversation: ConversationModel
): Promise<void> {
const idLog = `handleAttachmentDownloadsForNewMessage/${conversation.idForLogging()} ${getMessageIdForLogging(message.attributes)}`;
// Only queue attachments for downloads if this is a story (with additional logic), or
// if it's either an outgoing message or we've accepted the conversation
let shouldQueueForDownload = false;
if (isStory(message.attributes)) {
shouldQueueForDownload = await shouldDownloadStory(conversation.attributes);
} else {
shouldQueueForDownload =
hasAttachmentDownloads(message.attributes) &&
(conversation.getAccepted() || isOutgoing(message.attributes));
}
if (shouldQueueForDownload) {
if (shouldUseAttachmentDownloadQueue()) {
addToAttachmentDownloadQueue(idLog, message);
} else {
await queueAttachmentDownloadsForMessage(message);
}
}
}
export async function queueAttachmentDownloadsForMessage(
message: MessageModel,
urgency?: AttachmentDownloadUrgency
): Promise<boolean> {
const updates = await queueAttachmentDownloads(message.attributes, {
urgency,
});
if (!updates) {
return false;
}
message.set(updates);
queueUpdateMessage(message.attributes);
return true;
}
// Receive logic
// NOTE: If you're changing any logic in this function that deals with the
// count then you'll also have to modify ./hasAttachmentsDownloads

View file

@ -16,11 +16,12 @@ import {
getConversationIdForLogging,
getMessageIdForLogging,
} from './idForLogging';
import { __DEPRECATED$getMessageById } from '../messages/getMessageById';
import { getMessageById } from '../messages/getMessageById';
import { getRecipientConversationIds } from './getRecipientConversationIds';
import { getRecipients } from './getRecipients';
import { repeat, zipObject } from './iterables';
import { isMe } from './whatTypeOfConversation';
import { postSaveUpdates } from './cleanup';
export async function sendDeleteForEveryoneMessage(
conversationAttributes: ConversationAttributesType,
@ -35,10 +36,7 @@ export async function sendDeleteForEveryoneMessage(
timestamp: targetTimestamp,
id: messageId,
} = options;
const message = await __DEPRECATED$getMessageById(
messageId,
'sendDeleteForEveryoneMessage'
);
const message = await getMessageById(messageId);
if (!message) {
throw new Error('sendDeleteForEveryoneMessage: Cannot find message!');
}
@ -88,6 +86,7 @@ export async function sendDeleteForEveryoneMessage(
await DataWriter.saveMessage(message.attributes, {
jobToInsert,
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
});
} catch (error) {

View file

@ -24,7 +24,7 @@ import {
import { concat, filter, map, repeat, zipObject, find } from './iterables';
import { getConversationIdForLogging } from './idForLogging';
import { isQuoteAMatch } from '../messages/helpers';
import { __DEPRECATED$getMessageById } from '../messages/getMessageById';
import { getMessageById } from '../messages/getMessageById';
import { handleEditMessage } from './handleEditMessage';
import { incrementMessageCounter } from './incrementMessageCounter';
import { isGroupV1 } from './whatTypeOfConversation';
@ -34,6 +34,7 @@ import { strictAssert } from './assert';
import { timeAndLogIfTooLong } from './timeAndLogIfTooLong';
import { makeQuote } from './makeQuote';
import { getMessageSentTimestamp } from './getMessageSentTimestamp';
import { postSaveUpdates } from './cleanup';
const SEND_REPORT_THRESHOLD_MS = 25;
@ -65,10 +66,7 @@ export async function sendEditedMessage(
conversation.attributes
)})`;
const targetMessage = await __DEPRECATED$getMessageById(
targetMessageId,
'sendEditedMessage'
);
const targetMessage = await getMessageById(targetMessageId);
strictAssert(targetMessage, 'could not find message to edit');
if (isGroupV1(conversation.attributes)) {
@ -229,6 +227,7 @@ export async function sendEditedMessage(
await DataWriter.saveMessage(targetMessage.attributes, {
jobToInsert,
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
),

View file

@ -31,6 +31,8 @@ import { collect } from './iterables';
import { DurationInSeconds } from './durations';
import { sanitizeLinkPreview } from '../services/LinkPreview';
import type { DraftBodyRanges } from '../types/BodyRange';
import { postSaveUpdates } from './cleanup';
import { MessageModel } from '../models/messages';
export async function sendStoryMessage(
listIds: Array<string>,
@ -308,11 +310,7 @@ export async function sendStoryMessage(
// * Add the message to the conversation
await Promise.all(
distributionListMessages.map(message => {
window.MessageCache.__DEPRECATED$register(
message.id,
new window.Whisper.Message(message),
'sendStoryMessage'
);
window.MessageCache.register(new MessageModel(message));
void ourConversation.addSingleMessage(message, { isJustSent: true });
@ -320,6 +318,7 @@ export async function sendStoryMessage(
return DataWriter.saveMessage(message, {
forceSave: true,
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
})
);
@ -359,11 +358,7 @@ export async function sendStoryMessage(
timestamp: messageAttributes.timestamp,
},
async jobToInsert => {
window.MessageCache.__DEPRECATED$register(
messageAttributes.id,
new window.Whisper.Message(messageAttributes),
'sendStoryMessage'
);
window.MessageCache.register(new MessageModel(messageAttributes));
const conversation =
window.ConversationController.get(conversationId);
void conversation?.addSingleMessage(messageAttributes, {
@ -377,6 +372,7 @@ export async function sendStoryMessage(
forceSave: true,
jobToInsert,
ourAci: window.textsecure.storage.user.getCheckedAci(),
postSaveUpdates,
});
}
);

2
ts/window.d.ts vendored
View file

@ -32,7 +32,6 @@ import type { Receipt } from './types/Receipt';
import type { ConversationController } from './ConversationController';
import type { ReduxActions } from './state/types';
import type { createApp } from './state/roots/createApp';
import type { MessageModel } from './models/messages';
import type { ConversationModel } from './models/conversations';
import type { BatcherType } from './util/batcher';
import type { ConfirmationDialog } from './components/ConfirmationDialog';
@ -319,7 +318,6 @@ declare global {
export type WhisperType = {
Conversation: typeof ConversationModel;
ConversationCollection: typeof ConversationModelCollectionType;
Message: typeof MessageModel;
deliveryReceiptQueue: PQueue;
deliveryReceiptBatcher: BatcherType<Receipt>;

View file

@ -65,8 +65,7 @@ if (
)?.attributes;
},
getConversation: (id: string) => window.ConversationController.get(id),
getMessageById: (id: string) =>
window.MessageCache.__DEPRECATED$getById(id, 'SignalDebug'),
getMessageById: (id: string) => window.MessageCache.getById(id),
getMessageBySentAt: (timestamp: number) =>
window.MessageCache.findBySentAt(timestamp, () => true),
getReduxState: () => window.reduxStore.getState(),