diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 1c77bcf214a..16595b342a5 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -1238,6 +1238,8 @@ type WritableInterface = { processGroupCallRingCancellation(ringId: bigint): void; cleanExpiredGroupCallRingCancellations(): void; + + _testOnlyRemoveMessageAttachments(timestamp: number): void; }; // Adds a database argument diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 253fd24dd7c..628c35482b4 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -705,6 +705,8 @@ export const DataWriter: ServerWritableInterface = { disableFSync, enableFSyncAndCheckpoint, + _testOnlyRemoveMessageAttachments, + // Server-only removeKnownStickers, @@ -2720,6 +2722,17 @@ function saveMessageAttachment({ ).run(values); } +function _testOnlyRemoveMessageAttachments( + db: WritableDB, + timestamp: number +): void { + const [query, params] = sql` + DELETE FROM message_attachments + WHERE sentAt = ${timestamp};`; + + db.prepare(query).run(params); +} + function saveMessage( db: WritableDB, message: ReadonlyDeep, diff --git a/ts/sql/hydration.ts b/ts/sql/hydration.ts index 357cf6caec1..a0adb6cc9ce 100644 --- a/ts/sql/hydration.ts +++ b/ts/sql/hydration.ts @@ -23,13 +23,20 @@ import { sql, sqlJoin, } from './util'; -import type { AttachmentType } from '../types/Attachment'; -import { IMAGE_JPEG, stringToMIMEType } from '../types/MIME'; +import { type AttachmentType } from '../types/Attachment'; +import { + APPLICATION_OCTET_STREAM, + IMAGE_JPEG, + IMAGE_PNG, + stringToMIMEType, +} from '../types/MIME'; import { strictAssert } from '../util/assert'; import type { MessageAttributesType } from '../model-types'; +import { createLogger } from '../logging/log'; export const ROOT_MESSAGE_ATTACHMENT_EDIT_HISTORY_INDEX = -1; +const log = createLogger('hydrateMessage'); function toBoolean(value: number | null): boolean | undefined { if (value == null) { return undefined; @@ -48,9 +55,12 @@ export function hydrateMessages( db: ReadableDB, unhydratedMessages: Array ): Array { - const messagesWithColumnsHydrated = unhydratedMessages.map( - hydrateMessageTableColumns - ); + const messagesWithColumnsHydrated = unhydratedMessages.map(msg => ({ + ...hydrateMessageTableColumns(msg), + hasAttachments: msg.hasAttachments === 1, + hasFileAttachments: msg.hasFileAttachments === 1, + hasVisualMediaAttachments: msg.hasVisualMediaAttachments === 1, + })); return hydrateMessagesWithAttachments(db, messagesWithColumnsHydrated); } @@ -142,7 +152,13 @@ export function getAttachmentReferencesForMessages( function hydrateMessagesWithAttachments( db: ReadableDB, - messagesWithoutAttachments: Array + messagesWithoutAttachments: Array< + MessageType & { + hasAttachments: boolean; + hasVisualMediaAttachments: boolean; + hasFileAttachments: boolean; + } + > ): Array { const attachmentReferencesForAllMessages = getAttachmentReferencesForMessages( db, @@ -153,10 +169,56 @@ function hydrateMessagesWithAttachments( 'messageId' ); - return messagesWithoutAttachments.map(msg => { + return messagesWithoutAttachments.map(msgWithExtraFields => { + const { + hasAttachments, + hasFileAttachments, + hasVisualMediaAttachments, + ...msg + } = msgWithExtraFields; + const attachmentReferences = attachmentReferencesByMessage[msg.id] ?? []; + if (!attachmentReferences.length) { - return msg; + if (msg.attachments?.length) { + // legacy message, attachments still on JSON + return msg; + } + + if (msg.isErased || msg.deletedForEveryone || msg.isViewOnce) { + return msg; + } + + if (msg.type !== 'incoming' && msg.type !== 'outgoing') { + return msg; + } + + if ( + !hasAttachments && + !hasFileAttachments && + !hasVisualMediaAttachments + ) { + return msg; + } + + log.warn( + `Retrieved message that should have attachments but missing message_attachment rows, timestamp: ${msg.timestamp}` + ); + // Add an empty attachment to the message to enable backfilling in the UI + return { + ...msg, + attachments: [ + { + error: true, + size: 0, + width: hasVisualMediaAttachments ? 150 : undefined, + height: hasVisualMediaAttachments ? 150 : undefined, + contentType: hasVisualMediaAttachments + ? IMAGE_PNG + : APPLICATION_OCTET_STREAM, + }, + ], + }; } const attachmentsByEditHistoryIndex = groupBy( diff --git a/ts/test-electron/normalizedAttachments_test.ts b/ts/test-electron/normalizedAttachments_test.ts index 5eddcf06761..50ea1446826 100644 --- a/ts/test-electron/normalizedAttachments_test.ts +++ b/ts/test-electron/normalizedAttachments_test.ts @@ -688,4 +688,28 @@ describe('normalizes attachment references', () => { omit(attachmentWithoutKey, 'randomKey') ); }); + + it('adds a placeholder attachment when attachments had been deleted', async () => { + const message = composeMessage(Date.now(), { + attachments: [composeAttachment(), composeAttachment()], + }); + + await DataWriter.saveMessage(message, { + forceSave: true, + ourAci: generateAci(), + postSaveUpdates: () => Promise.resolve(), + }); + + await DataWriter._testOnlyRemoveMessageAttachments(message.timestamp); + + const messageFromDB = await DataReader.getMessageById(message.id); + assert(messageFromDB, 'message was saved'); + assert.deepEqual(messageFromDB.attachments?.[0], { + size: 0, + contentType: IMAGE_PNG, + width: 150, + height: 150, + error: true, + }); + }); });