Edit message import/export

This commit is contained in:
Fedor Indutny 2024-06-03 10:02:25 -07:00 committed by GitHub
parent d47b46500e
commit fa1530debf
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 562 additions and 158 deletions

View file

@ -34,7 +34,7 @@ export async function migrateMessageData({
saveMessages: ( saveMessages: (
data: ReadonlyArray<MessageAttributesType>, data: ReadonlyArray<MessageAttributesType>,
options: { ourAci: AciString } options: { ourAci: AciString }
) => Promise<void>; ) => Promise<unknown>;
maxVersion?: number; maxVersion?: number;
}>): Promise< }>): Promise<
| { | {

View file

@ -679,7 +679,9 @@ export class BackupExportStream extends Readable {
const result: Backups.IChatItem = { const result: Backups.IChatItem = {
chatId, chatId,
authorId, authorId,
dateSent: getSafeLongFromTimestamp(message.sent_at), dateSent: getSafeLongFromTimestamp(
message.editMessageTimestamp || message.sent_at
),
expireStartDate, expireStartDate,
expiresInMs, expiresInMs,
revisions: [], revisions: [],
@ -708,7 +710,10 @@ export class BackupExportStream extends Readable {
}); });
if (authorId === me) { if (authorId === me) {
result.outgoing = this.getOutgoingMessageDetails(message); result.outgoing = this.getOutgoingMessageDetails(
message.sent_at,
message
);
} else { } else {
result.incoming = this.getIncomingMessageDetails(message); result.incoming = this.getIncomingMessageDetails(message);
} }
@ -811,51 +816,22 @@ export class BackupExportStream extends Readable {
reactions: this.getMessageReactions(message), reactions: this.getMessageReactions(message),
}; };
} else { } else {
result.standardMessage = { result.standardMessage = await this.toStandardMessage(
quote: await this.toQuote(message.quote), message,
attachments: message.attachments backupLevel
? await Promise.all( );
message.attachments.map(attachment => { result.revisions = await this.toChatItemRevisions(
return this.processMessageAttachment({ result,
attachment, message,
backupLevel, backupLevel
messageReceivedAt: message.received_at, );
});
})
)
: undefined,
text: {
// Note that we store full text on the message model so we have to
// trim it before serializing.
body: message.body?.slice(0, LONG_ATTACHMENT_LIMIT),
bodyRanges: message.bodyRanges?.map(range => this.toBodyRange(range)),
},
linkPreview: message.preview
? await Promise.all(
message.preview.map(async preview => {
return {
url: preview.url,
title: preview.title,
description: preview.description,
date: getSafeLongFromTimestamp(preview.date),
image: preview.image
? await this.processAttachment({
attachment: preview.image,
backupLevel,
messageReceivedAt: message.received_at,
})
: undefined,
};
})
)
: undefined,
reactions: this.getMessageReactions(message),
};
} }
if (isOutgoing) { if (isOutgoing) {
result.outgoing = this.getOutgoingMessageDetails(message); result.outgoing = this.getOutgoingMessageDetails(
message.sent_at,
message
);
} else { } else {
result.incoming = this.getIncomingMessageDetails(message); result.incoming = this.getIncomingMessageDetails(message);
} }
@ -1792,7 +1768,9 @@ export class BackupExportStream extends Readable {
private getMessageReactions({ private getMessageReactions({
reactions, reactions,
}: MessageAttributesType): Array<Backups.IReaction> | undefined { }: Pick<MessageAttributesType, 'reactions'>):
| Array<Backups.IReaction>
| undefined {
return reactions?.map(reaction => { return reactions?.map(reaction => {
return { return {
emoji: reaction.emoji, emoji: reaction.emoji,
@ -1809,12 +1787,20 @@ export class BackupExportStream extends Readable {
private getIncomingMessageDetails({ private getIncomingMessageDetails({
received_at_ms: receivedAtMs, received_at_ms: receivedAtMs,
editMessageReceivedAtMs,
serverTimestamp, serverTimestamp,
readStatus, readStatus,
}: MessageAttributesType): Backups.ChatItem.IIncomingMessageDetails { }: Pick<
MessageAttributesType,
| 'received_at_ms'
| 'editMessageReceivedAtMs'
| 'serverTimestamp'
| 'readStatus'
>): Backups.ChatItem.IIncomingMessageDetails {
const dateReceived = editMessageReceivedAtMs || receivedAtMs;
return { return {
dateReceived: dateReceived:
receivedAtMs != null ? getSafeLongFromTimestamp(receivedAtMs) : null, dateReceived != null ? getSafeLongFromTimestamp(dateReceived) : null,
dateServerSent: dateServerSent:
serverTimestamp != null serverTimestamp != null
? getSafeLongFromTimestamp(serverTimestamp) ? getSafeLongFromTimestamp(serverTimestamp)
@ -1823,10 +1809,12 @@ export class BackupExportStream extends Readable {
}; };
} }
private getOutgoingMessageDetails({ private getOutgoingMessageDetails(
sent_at: sentAt, sentAt: number,
sendStateByConversationId = {}, {
}: MessageAttributesType): Backups.ChatItem.IOutgoingMessageDetails { sendStateByConversationId = {},
}: Pick<MessageAttributesType, 'sendStateByConversationId'>
): Backups.ChatItem.IOutgoingMessageDetails {
const BackupSendStatus = Backups.SendStatus.Status; const BackupSendStatus = Backups.SendStatus.Status;
const sendStatus = new Array<Backups.ISendStatus>(); const sendStatus = new Array<Backups.ISendStatus>();
@ -1874,6 +1862,106 @@ export class BackupExportStream extends Readable {
sendStatus, sendStatus,
}; };
} }
private async toStandardMessage(
message: Pick<
MessageAttributesType,
| 'quote'
| 'attachments'
| 'body'
| 'bodyRanges'
| 'preview'
| 'reactions'
| 'received_at'
>,
backupLevel: BackupLevel
): Promise<Backups.IStandardMessage> {
return {
quote: await this.toQuote(message.quote),
attachments: message.attachments
? await Promise.all(
message.attachments.map(attachment => {
return this.processMessageAttachment({
attachment,
backupLevel,
messageReceivedAt: message.received_at,
});
})
)
: undefined,
text: {
// Note that we store full text on the message model so we have to
// trim it before serializing.
body: message.body?.slice(0, LONG_ATTACHMENT_LIMIT),
bodyRanges: message.bodyRanges?.map(range => this.toBodyRange(range)),
},
linkPreview: message.preview
? await Promise.all(
message.preview.map(async preview => {
return {
url: preview.url,
title: preview.title,
description: preview.description,
date: getSafeLongFromTimestamp(preview.date),
image: preview.image
? await this.processAttachment({
attachment: preview.image,
backupLevel,
messageReceivedAt: message.received_at,
})
: undefined,
};
})
)
: undefined,
reactions: this.getMessageReactions(message),
};
}
private async toChatItemRevisions(
parent: Backups.IChatItem,
message: MessageAttributesType,
backupLevel: BackupLevel
): Promise<Array<Backups.IChatItem> | undefined> {
const { editHistory } = message;
if (editHistory == null) {
return undefined;
}
const isOutgoing = message.type === 'outgoing';
return Promise.all(
editHistory
// The first history is the copy of the current message
.slice(1)
.map(async history => {
return {
// Required fields
chatId: parent.chatId,
authorId: parent.authorId,
dateSent: getSafeLongFromTimestamp(history.timestamp),
expireStartDate: parent.expireStartDate,
expiresInMs: parent.expiresInMs,
sms: parent.sms,
// Directional details
outgoing: isOutgoing
? this.getOutgoingMessageDetails(history.timestamp, history)
: undefined,
incoming: isOutgoing
? undefined
: this.getIncomingMessageDetails(history),
// Message itself
standardMessage: await this.toStandardMessage(history, backupLevel),
};
// Backups use oldest to newest order
})
.reverse()
);
}
} }
function checkServiceIdEquivalence( function checkServiceIdEquivalence(

View file

@ -11,7 +11,7 @@ import { Backups, SignalService } from '../../protobuf';
import Data from '../../sql/Client'; import Data from '../../sql/Client';
import * as log from '../../logging/log'; import * as log from '../../logging/log';
import { StorySendMode } from '../../types/Stories'; import { StorySendMode } from '../../types/Stories';
import type { ServiceIdString } from '../../types/ServiceId'; import type { ServiceIdString, AciString } from '../../types/ServiceId';
import { fromAciObject, fromPniObject } from '../../types/ServiceId'; import { fromAciObject, fromPniObject } from '../../types/ServiceId';
import { isStoryDistributionId } from '../../types/StoryDistributionId'; import { isStoryDistributionId } from '../../types/StoryDistributionId';
import * as Errors from '../../types/errors'; import * as Errors from '../../types/errors';
@ -28,6 +28,7 @@ import type {
ConversationAttributesType, ConversationAttributesType,
MessageAttributesType, MessageAttributesType,
MessageReactionType, MessageReactionType,
EditHistoryType,
} from '../../model-types.d'; } from '../../model-types.d';
import { assertDev, strictAssert } from '../../util/assert'; import { assertDev, strictAssert } from '../../util/assert';
import { getTimestampFromLong } from '../../util/timestampLongUtils'; import { getTimestampFromLong } from '../../util/timestampLongUtils';
@ -90,6 +91,45 @@ async function processConversationOpBatch(
await Data.saveConversations(saves); await Data.saveConversations(saves);
await Data.updateConversations(updates); await Data.updateConversations(updates);
} }
async function processMessagesBatch(
ourAci: AciString,
batch: ReadonlyArray<MessageAttributesType>
): Promise<void> {
const ids = await Data.saveMessages(batch, {
forceSave: true,
ourAci,
});
strictAssert(ids.length === batch.length, 'Should get same number of ids');
// TODO (DESKTOP-7402): consider re-saving after updating the pending state
for (const [index, rawAttributes] of batch.entries()) {
const attributes = {
...rawAttributes,
id: ids[index],
};
const { editHistory } = attributes;
if (editHistory?.length) {
drop(
Data.saveEditedMessages(
attributes,
ourAci,
editHistory.slice(0, -1).map(({ timestamp }) => ({
conversationId: attributes.conversationId,
messageId: attributes.id,
// Main message will track this
readStatus: ReadStatus.Read,
sentAt: timestamp,
}))
)
);
}
drop(queueAttachmentDownloads(attributes));
}
}
function phoneToContactFormType( function phoneToContactFormType(
type: Backups.ContactAttachment.Phone.Type | null | undefined type: Backups.ContactAttachment.Phone.Type | null | undefined
@ -181,18 +221,11 @@ export class BackupImportStream extends Writable {
name: 'BackupImport.saveMessageBatcher', name: 'BackupImport.saveMessageBatcher',
wait: 0, wait: 0,
maxSize: 1000, maxSize: 1000,
processBatch: async batch => { processBatch: batch => {
const ourAci = this.ourConversation?.serviceId; const ourAci = this.ourConversation?.serviceId;
assertDev(isAciString(ourAci), 'Our conversation must have ACI'); assertDev(isAciString(ourAci), 'Our conversation must have ACI');
await Data.saveMessages(batch, {
forceSave: true,
ourAci,
});
// TODO (DESKTOP-7402): consider re-saving after updating the pending state return processMessagesBatch(ourAci, batch);
for (const messageAttributes of batch) {
drop(queueAttachmentDownloads(messageAttributes));
}
}, },
}); });
private ourConversation?: ConversationAttributesType; private ourConversation?: ConversationAttributesType;
@ -715,6 +748,19 @@ export class BackupImportStream extends Writable {
? this.recipientIdToConvo.get(item.authorId.toNumber()) ? this.recipientIdToConvo.get(item.authorId.toNumber())
: undefined; : undefined;
const {
patch: directionDetails,
newActiveAt,
unread,
} = this.fromDirectionDetails(item, timestamp);
if (newActiveAt != null) {
chatConvo.active_at = newActiveAt;
}
if (unread != null) {
chatConvo.unreadCount = (chatConvo.unreadCount ?? 0) + 1;
}
let attributes: MessageAttributesType = { let attributes: MessageAttributesType = {
id: generateUuid(), id: generateUuid(),
conversationId: chatConvo.id, conversationId: chatConvo.id,
@ -732,16 +778,109 @@ export class BackupImportStream extends Writable {
item.expiresInMs && !item.expiresInMs.isZero() item.expiresInMs && !item.expiresInMs.isZero()
? DurationInSeconds.fromMillis(item.expiresInMs.toNumber()) ? DurationInSeconds.fromMillis(item.expiresInMs.toNumber())
: undefined, : undefined,
...directionDetails,
}; };
const additionalMessages: Array<MessageAttributesType> = []; const additionalMessages: Array<MessageAttributesType> = [];
const { outgoing, incoming, directionless } = item; if (item.incoming) {
if (outgoing) { strictAssert(
authorConvo && this.ourConversation.id !== authorConvo?.id,
`${logId}: message with incoming field must be incoming`
);
} else if (item.outgoing) {
strictAssert( strictAssert(
authorConvo && this.ourConversation.id === authorConvo?.id, authorConvo && this.ourConversation.id === authorConvo?.id,
`${logId}: outgoing message must have outgoing field` `${logId}: outgoing message must have outgoing field`
); );
}
if (item.standardMessage) {
// TODO (DESKTOP-6964): gift badge
attributes = {
...attributes,
...this.fromStandardMessage(item.standardMessage),
};
} else {
const result = await this.fromNonBubbleChatItem(item, {
aboutMe,
author: authorConvo,
conversation: chatConvo,
timestamp,
});
if (!result) {
throw new Error(`${logId}: fromNonBubbleChat item returned nothing!`);
}
attributes = {
...attributes,
...result.message,
};
let sentAt = attributes.sent_at;
(result.additionalMessages || []).forEach(additional => {
sentAt -= 1;
additionalMessages.push({
...attributes,
sent_at: sentAt,
...additional,
});
});
}
if (item.revisions?.length) {
strictAssert(
item.standardMessage,
'Only standard message can have revisions'
);
const history = this.fromRevisions(attributes, item.revisions);
attributes.editHistory = history;
// Update timestamps on the parent message
const oldest = history.at(-1);
assertDev(oldest != null, 'History is non-empty');
attributes.editMessageReceivedAt = attributes.received_at;
attributes.editMessageReceivedAtMs = attributes.received_at_ms;
attributes.editMessageTimestamp = attributes.timestamp;
attributes.received_at = oldest.received_at;
attributes.received_at_ms = oldest.received_at_ms;
attributes.timestamp = oldest.timestamp;
attributes.sent_at = oldest.timestamp;
}
assertDev(
isAciString(this.ourConversation.serviceId),
`${logId}: Our conversation must have ACI`
);
this.saveMessage(attributes);
additionalMessages.forEach(additional => this.saveMessage(additional));
// TODO (DESKTOP-6964): We'll want to increment for more types here - stickers, etc.
if (item.standardMessage) {
if (item.outgoing != null) {
chatConvo.sentMessageCount = (chatConvo.sentMessageCount ?? 0) + 1;
} else {
chatConvo.messageCount = (chatConvo.messageCount ?? 0) + 1;
}
}
this.updateConversation(chatConvo);
}
private fromDirectionDetails(
item: Backups.IChatItem,
timestamp: number
): {
patch: Partial<MessageAttributesType>;
newActiveAt?: number;
unread?: boolean;
} {
const { outgoing, incoming, directionless } = item;
if (outgoing) {
const sendStateByConversationId: SendStateByConversationId = {}; const sendStateByConversationId: SendStateByConversationId = {};
const BackupSendStatus = Backups.SendStatus.Status; const BackupSendStatus = Backups.SendStatus.Status;
@ -785,93 +924,54 @@ export class BackupImportStream extends Writable {
sendStateByConversationId[target.id] = { sendStateByConversationId[target.id] = {
status: sendStatus, status: sendStatus,
updatedAt: updatedAt:
status.lastStatusUpdateTimestamp != null status.lastStatusUpdateTimestamp != null &&
!status.lastStatusUpdateTimestamp.isZero()
? getTimestampFromLong(status.lastStatusUpdateTimestamp) ? getTimestampFromLong(status.lastStatusUpdateTimestamp)
: undefined, : undefined,
}; };
} }
attributes.sendStateByConversationId = sendStateByConversationId; return {
chatConvo.active_at = attributes.sent_at; patch: {
} else if (incoming) { sendStateByConversationId,
strictAssert( received_at_ms: timestamp,
authorConvo && this.ourConversation.id !== authorConvo?.id, },
`${logId}: message with incoming field must be incoming` newActiveAt: timestamp,
); };
attributes.received_at_ms = }
incoming.dateReceived?.toNumber() ?? Date.now(); if (incoming) {
const receivedAtMs = incoming.dateReceived?.toNumber() ?? Date.now();
if (incoming.read) { if (incoming.read) {
attributes.readStatus = ReadStatus.Read; return {
attributes.seenStatus = SeenStatus.Seen; patch: {
} else { readStatus: ReadStatus.Read,
attributes.readStatus = ReadStatus.Unread; seenStatus: SeenStatus.Seen,
attributes.seenStatus = SeenStatus.Unseen; received_at_ms: receivedAtMs,
chatConvo.unreadCount = (chatConvo.unreadCount ?? 0) + 1; },
newActiveAt: receivedAtMs,
};
} }
chatConvo.active_at = attributes.received_at_ms; return {
} else if (directionless) { patch: {
// Nothing to do readStatus: ReadStatus.Unread,
} seenStatus: SeenStatus.Unseen,
received_at_ms: receivedAtMs,
if (item.standardMessage) { },
// TODO (DESKTOP-6964): add revisions to editHistory newActiveAt: receivedAtMs,
// gift badge unread: true,
attributes = {
...attributes,
...this.fromStandardMessage(item.standardMessage),
}; };
} else {
const result = await this.fromNonBubbleChatItem(item, {
aboutMe,
author: authorConvo,
conversation: chatConvo,
timestamp,
});
if (!result) {
throw new Error(`${logId}: fromNonBubbleChat item returned nothing!`);
}
attributes = {
...attributes,
...result.message,
};
let sentAt = attributes.sent_at;
(result.additionalMessages || []).forEach(additional => {
sentAt -= 1;
additionalMessages.push({
...attributes,
sent_at: sentAt,
...additional,
});
});
} }
assertDev( strictAssert(directionless, 'Absent direction state');
isAciString(this.ourConversation.serviceId), return { patch: {} };
`${logId}: Our conversation must have ACI`
);
this.saveMessage(attributes);
additionalMessages.forEach(additional => this.saveMessage(additional));
// TODO (DESKTOP-6964): We'll want to increment for more types here - stickers, etc.
if (item.standardMessage) {
if (item.outgoing != null) {
chatConvo.sentMessageCount = (chatConvo.sentMessageCount ?? 0) + 1;
} else {
chatConvo.messageCount = (chatConvo.messageCount ?? 0) + 1;
}
}
this.updateConversation(chatConvo);
} }
private fromStandardMessage( private fromStandardMessage(
data: Backups.IStandardMessage data: Backups.IStandardMessage
): Partial<MessageAttributesType> { ): Partial<MessageAttributesType> {
// TODO (DESKTOP-6964): Quote, link preview
return { return {
body: data.text?.body || undefined, body: data.text?.body || undefined,
attachments: data.attachments?.length attachments: data.attachments?.length
@ -903,13 +1003,63 @@ export class BackupImportStream extends Writable {
}; };
} }
private fromRevisions(
mainMessage: MessageAttributesType,
revisions: ReadonlyArray<Backups.IChatItem>
): Array<EditHistoryType> {
const result = revisions
.map(rev => {
strictAssert(
rev.standardMessage,
'Edit history has non-standard messages'
);
const timestamp = getTimestampFromLong(rev.dateSent);
const {
// eslint-disable-next-line camelcase
patch: { sendStateByConversationId, received_at_ms },
} = this.fromDirectionDetails(rev, timestamp);
return {
...this.fromStandardMessage(rev.standardMessage),
timestamp,
received_at: incrementMessageCounter(),
sendStateByConversationId,
// eslint-disable-next-line camelcase
received_at_ms,
};
})
// Fix order: from newest to oldest
.reverse();
// See `ts/util/handleEditMessage.ts`, the first history entry is always
// the current message.
result.unshift({
attachments: mainMessage.attachments,
body: mainMessage.body,
bodyAttachment: mainMessage.bodyAttachment,
bodyRanges: mainMessage.bodyRanges,
preview: mainMessage.preview,
quote: mainMessage.quote,
sendStateByConversationId: mainMessage.sendStateByConversationId
? { ...mainMessage.sendStateByConversationId }
: undefined,
timestamp: mainMessage.timestamp,
received_at: mainMessage.received_at,
received_at_ms: mainMessage.received_at_ms,
});
return result;
}
private fromReactions( private fromReactions(
reactions: ReadonlyArray<Backups.IReaction> | null | undefined reactions: ReadonlyArray<Backups.IReaction> | null | undefined
): Array<MessageReactionType> | undefined { ): Array<MessageReactionType> | undefined {
if (!reactions?.length) { if (!reactions?.length) {
return undefined; return undefined;
} }
return reactions?.map( return reactions.map(
({ emoji, authorId, sentTimestamp, receivedTimestamp }) => { ({ emoji, authorId, sentTimestamp, receivedTimestamp }) => {
strictAssert(emoji != null, 'reaction must have an emoji'); strictAssert(emoji != null, 'reaction must have an emoji');
strictAssert(authorId != null, 'reaction must have authorId'); strictAssert(authorId != null, 'reaction must have authorId');

View file

@ -571,14 +571,16 @@ async function saveMessage(
async function saveMessages( async function saveMessages(
arrayOfMessages: ReadonlyArray<MessageType>, arrayOfMessages: ReadonlyArray<MessageType>,
options: { forceSave?: boolean; ourAci: AciString } options: { forceSave?: boolean; ourAci: AciString }
): Promise<void> { ): Promise<Array<string>> {
await channels.saveMessages( const result = await channels.saveMessages(
arrayOfMessages.map(message => _cleanMessageData(message)), arrayOfMessages.map(message => _cleanMessageData(message)),
options options
); );
void expiringMessagesDeletionService.update(); void expiringMessagesDeletionService.update();
void tapToViewMessagesDeletionService.update(); void tapToViewMessagesDeletionService.update();
return result;
} }
async function removeMessage(id: string): Promise<void> { async function removeMessage(id: string): Promise<void> {

View file

@ -556,7 +556,7 @@ export type DataInterface = {
saveMessages: ( saveMessages: (
arrayOfMessages: ReadonlyArray<MessageType>, arrayOfMessages: ReadonlyArray<MessageType>,
options: { forceSave?: boolean; ourAci: AciString } options: { forceSave?: boolean; ourAci: AciString }
) => Promise<void>; ) => Promise<Array<string>>;
removeMessage: (id: string) => Promise<void>; removeMessage: (id: string) => Promise<void>;
removeMessages: (ids: ReadonlyArray<string>) => Promise<void>; removeMessages: (ids: ReadonlyArray<string>) => Promise<void>;
pageMessages: ( pageMessages: (
@ -724,6 +724,11 @@ export type DataInterface = {
ourAci: AciString, ourAci: AciString,
opts: EditedMessageType opts: EditedMessageType
) => Promise<void>; ) => Promise<void>;
saveEditedMessages: (
mainMessage: MessageType,
ourAci: AciString,
history: ReadonlyArray<EditedMessageType>
) => Promise<void>;
getMostRecentAddressableMessages: ( getMostRecentAddressableMessages: (
conversationId: string, conversationId: string,
limit?: number limit?: number

View file

@ -369,6 +369,7 @@ const dataInterface: ServerInterface = {
getMessagesBetween, getMessagesBetween,
getNearbyMessageFromDeletedSet, getNearbyMessageFromDeletedSet,
saveEditedMessage, saveEditedMessage,
saveEditedMessages,
getMostRecentAddressableMessages, getMostRecentAddressableMessages,
removeSyncTaskById, removeSyncTaskById,
@ -2450,15 +2451,17 @@ async function saveMessage(
async function saveMessages( async function saveMessages(
arrayOfMessages: ReadonlyArray<MessageType>, arrayOfMessages: ReadonlyArray<MessageType>,
options: { forceSave?: boolean; ourAci: AciString } options: { forceSave?: boolean; ourAci: AciString }
): Promise<void> { ): Promise<Array<string>> {
const db = await getWritableInstance(); const db = await getWritableInstance();
db.transaction(() => { return db.transaction(() => {
const result = new Array<string>();
for (const message of arrayOfMessages) { for (const message of arrayOfMessages) {
assertSync( result.push(
saveMessageSync(db, message, { ...options, alreadyInTransaction: true }) saveMessageSync(db, message, { ...options, alreadyInTransaction: true })
); );
} }
return result;
})(); })();
} }
@ -7165,10 +7168,10 @@ async function removeAllProfileKeyCredentials(): Promise<void> {
); );
} }
async function saveEditedMessage( async function saveEditedMessagesSync(
mainMessage: MessageType, mainMessage: MessageType,
ourAci: AciString, ourAci: AciString,
{ conversationId, messageId, readStatus, sentAt }: EditedMessageType history: ReadonlyArray<EditedMessageType>
): Promise<void> { ): Promise<void> {
const db = await getWritableInstance(); const db = await getWritableInstance();
@ -7180,24 +7183,42 @@ async function saveEditedMessage(
}) })
); );
const [query, params] = sql` for (const { conversationId, messageId, readStatus, sentAt } of history) {
INSERT INTO edited_messages ( const [query, params] = sql`
conversationId, INSERT INTO edited_messages (
messageId, conversationId,
sentAt, messageId,
readStatus sentAt,
) VALUES ( readStatus
${conversationId}, ) VALUES (
${messageId}, ${conversationId},
${sentAt}, ${messageId},
${readStatus} ${sentAt},
); ${readStatus}
`; );
`;
db.prepare(query).run(params); db.prepare(query).run(params);
}
})(); })();
} }
async function saveEditedMessage(
mainMessage: MessageType,
ourAci: AciString,
editedMessage: EditedMessageType
): Promise<void> {
return saveEditedMessagesSync(mainMessage, ourAci, [editedMessage]);
}
async function saveEditedMessages(
mainMessage: MessageType,
ourAci: AciString,
editedMessages: ReadonlyArray<EditedMessageType>
): Promise<void> {
return saveEditedMessagesSync(mainMessage, ourAci, editedMessages);
}
async function _getAllEditedMessages(): Promise< async function _getAllEditedMessages(): Promise<
Array<{ messageId: string; sentAt: number }> Array<{ messageId: string; sentAt: number }>
> { > {

View file

@ -0,0 +1,134 @@
// Copyright 2024 Signal Messenger, LLC
// SPDX-License-Identifier: AGPL-3.0-only
import { v4 as generateGuid } from 'uuid';
import { SendStatus } from '../../messages/MessageSendState';
import type { ConversationModel } from '../../models/conversations';
import Data from '../../sql/Client';
import { generateAci } from '../../types/ServiceId';
import { ReadStatus } from '../../messages/MessageReadStatus';
import { SeenStatus } from '../../MessageSeenStatus';
import { loadCallsHistory } from '../../services/callHistoryLoader';
import { setupBasics, symmetricRoundtripHarness, OUR_ACI } from './helpers';
const CONTACT_A = generateAci();
describe('backup/bubble messages', () => {
let contactA: ConversationModel;
beforeEach(async () => {
await Data._removeAllMessages();
await Data._removeAllConversations();
window.storage.reset();
await setupBasics();
contactA = await window.ConversationController.getOrCreateAndWait(
CONTACT_A,
'private',
{ systemGivenName: 'CONTACT_A' }
);
await loadCallsHistory();
});
it('roundtrips incoming edited message', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'incoming',
received_at: 3,
received_at_ms: 3,
sent_at: 3,
timestamp: 3,
sourceServiceId: CONTACT_A,
body: 'd',
readStatus: ReadStatus.Unread,
seenStatus: SeenStatus.Unseen,
editMessageTimestamp: 5,
editMessageReceivedAtMs: 5,
editHistory: [
{
body: 'd',
timestamp: 5,
received_at: 5,
received_at_ms: 5,
},
{
body: 'c',
timestamp: 4,
received_at: 4,
received_at_ms: 4,
},
{
body: 'b',
timestamp: 3,
received_at: 3,
received_at_ms: 3,
},
],
},
]);
});
it('roundtrips outgoing edited message', async () => {
await symmetricRoundtripHarness([
{
conversationId: contactA.id,
id: generateGuid(),
type: 'outgoing',
received_at: 3,
received_at_ms: 3,
sent_at: 3,
sourceServiceId: OUR_ACI,
sendStateByConversationId: {
[contactA.id]: {
status: SendStatus.Delivered,
},
},
timestamp: 3,
editMessageTimestamp: 5,
editMessageReceivedAtMs: 5,
body: 'd',
editHistory: [
{
body: 'd',
timestamp: 5,
received_at: 5,
received_at_ms: 5,
sendStateByConversationId: {
[contactA.id]: {
status: SendStatus.Delivered,
},
},
},
{
body: 'c',
timestamp: 4,
received_at: 4,
received_at_ms: 4,
sendStateByConversationId: {
[contactA.id]: {
status: SendStatus.Viewed,
},
},
},
{
body: 'b',
timestamp: 3,
received_at: 3,
received_at_ms: 3,
sendStateByConversationId: {
[contactA.id]: {
status: SendStatus.Viewed,
},
},
},
],
},
]);
});
});

View file

@ -60,6 +60,8 @@ function sortAndNormalize(
received_at: _receivedAt, received_at: _receivedAt,
// eslint-disable-next-line @typescript-eslint/no-unused-vars // eslint-disable-next-line @typescript-eslint/no-unused-vars
sourceDevice: _sourceDevice, sourceDevice: _sourceDevice,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
editMessageReceivedAt: _editMessageReceivedAt,
...rest ...rest
} = message; } = message;
@ -94,6 +96,8 @@ function sortAndNormalize(
editHistory: editHistory?.map(history => { editHistory: editHistory?.map(history => {
const { const {
sendStateByConversationId: historySendState, sendStateByConversationId: historySendState,
// eslint-disable-next-line @typescript-eslint/no-unused-vars
received_at: _receivedAtHistory,
...restOfHistory ...restOfHistory
} = history; } = history;
@ -155,7 +159,7 @@ export async function asymmetricRoundtripHarness(
const expected = sortAndNormalize(after); const expected = sortAndNormalize(after);
const actual = sortAndNormalize(messagesFromDatabase); const actual = sortAndNormalize(messagesFromDatabase);
assert.deepEqual(expected, actual); assert.deepEqual(actual, expected);
} finally { } finally {
fetchAndSaveBackupCdnObjectMetadata.restore(); fetchAndSaveBackupCdnObjectMetadata.restore();
await rm(outDir, { recursive: true }); await rm(outDir, { recursive: true });