diff --git a/ts/components/conversation/Quote.tsx b/ts/components/conversation/Quote.tsx index 3d24967c94..81e615005e 100644 --- a/ts/components/conversation/Quote.tsx +++ b/ts/components/conversation/Quote.tsx @@ -24,7 +24,10 @@ import { getClassNamesFor } from '../../util/getClassNamesFor'; import { getCustomColorStyle } from '../../util/getCustomColorStyle'; import type { AnyPaymentEvent } from '../../types/Payment'; import { PaymentEventKind } from '../../types/Payment'; -import { getPaymentEventNotificationText } from '../../messages/helpers'; +import { + getPaymentEventNotificationText, + shouldTryToCopyFromQuotedMessage, +} from '../../messages/helpers'; import { RenderLocation } from './MessageTextRenderer'; import type { QuotedAttachmentType } from '../../model-types'; @@ -165,10 +168,19 @@ export function Quote(props: Props): JSX.Element | null { const getClassName = getClassNamesFor('module-quote', moduleClassName); useEffect(() => { - if (referencedMessageNotFound) { + if ( + shouldTryToCopyFromQuotedMessage({ + referencedMessageNotFound, + quoteAttachment: rawAttachment, + }) + ) { doubleCheckMissingQuoteReference?.(); } - }, [referencedMessageNotFound, doubleCheckMissingQuoteReference]); + }, [ + referencedMessageNotFound, + rawAttachment, + doubleCheckMissingQuoteReference, + ]); function handleKeyDown(event: React.KeyboardEvent) { // This is important to ensure that using this quote to navigate to the referenced diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index 1e94e0edfc..c63165a3de 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -789,17 +789,17 @@ async function uploadMessageQuote({ loadedQuote.attachments.map( attachment => async (): Promise => { const { thumbnail } = attachment; - if (!thumbnail) { + if (!thumbnail || !thumbnail.data) { return { contentType: attachment.contentType, fileName: attachment.fileName, }; } - const { data } = thumbnail; - strictAssert(data, 'data must be loaded into thumbnail'); - - const uploaded = await uploadAttachment({ ...thumbnail, data }); + const uploaded = await uploadAttachment({ + ...thumbnail, + data: thumbnail.data, + }); return { contentType: attachment.contentType, diff --git a/ts/messages/copyQuote.ts b/ts/messages/copyQuote.ts new file mode 100644 index 0000000000..1833af9786 --- /dev/null +++ b/ts/messages/copyQuote.ts @@ -0,0 +1,197 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { omit } from 'lodash'; + +import * as log from '../logging/log'; +import type { QuotedMessageType } from '../model-types'; +import type { MessageModel } from '../models/messages'; +import { SignalService } from '../protobuf'; +import { isGiftBadge, isTapToView } from '../state/selectors/message'; +import type { ProcessedQuote } from '../textsecure/Types'; +import { IMAGE_JPEG } from '../types/MIME'; +import { strictAssert } from '../util/assert'; +import { getQuoteBodyText } from '../util/getQuoteBodyText'; +import { find } from '../util/iterables'; +import { isQuoteAMatch, messageHasPaymentEvent } from './helpers'; +import * as Errors from '../types/errors'; +import { isDownloadable } from '../types/Attachment'; + +export const copyFromQuotedMessage = async ( + quote: ProcessedQuote, + conversationId: string +): Promise => { + const { id } = quote; + strictAssert(id, 'Quote must have an id'); + + const result: QuotedMessageType = { + ...omit(quote, 'type'), + + id, + + attachments: quote.attachments.slice(), + bodyRanges: quote.bodyRanges?.slice(), + + // Just placeholder values for the fields + referencedMessageNotFound: false, + isGiftBadge: quote.type === SignalService.DataMessage.Quote.Type.GIFT_BADGE, + isViewOnce: false, + messageId: '', + }; + + const inMemoryMessages = window.MessageCache.__DEPRECATED$filterBySentAt(id); + const matchingMessage = find(inMemoryMessages, item => + isQuoteAMatch(item.attributes, conversationId, result) + ); + + let queryMessage: undefined | MessageModel; + + if (matchingMessage) { + queryMessage = matchingMessage; + } else { + log.info('copyFromQuotedMessage: db lookup needed', id); + const messages = await window.Signal.Data.getMessagesBySentAt(id); + const found = messages.find(item => + isQuoteAMatch(item, conversationId, result) + ); + + if (!found) { + result.referencedMessageNotFound = true; + return result; + } + + queryMessage = window.MessageCache.__DEPRECATED$register( + found.id, + found, + 'copyFromQuotedMessage' + ); + } + + if (queryMessage) { + await copyQuoteContentFromOriginal(queryMessage, result); + } + + return result; +}; + +export const copyQuoteContentFromOriginal = async ( + originalMessage: MessageModel, + quote: QuotedMessageType +): Promise => { + const { attachments } = quote; + const firstAttachment = attachments ? attachments[0] : undefined; + + if (messageHasPaymentEvent(originalMessage.attributes)) { + // eslint-disable-next-line no-param-reassign + quote.payment = originalMessage.get('payment'); + } + + if (isTapToView(originalMessage.attributes)) { + // eslint-disable-next-line no-param-reassign + quote.text = undefined; + // eslint-disable-next-line no-param-reassign + quote.attachments = [ + { + contentType: IMAGE_JPEG, + }, + ]; + // eslint-disable-next-line no-param-reassign + quote.isViewOnce = true; + + return; + } + + const isMessageAGiftBadge = isGiftBadge(originalMessage.attributes); + if (isMessageAGiftBadge !== quote.isGiftBadge) { + log.warn( + `copyQuoteContentFromOriginal: Quote.isGiftBadge: ${quote.isGiftBadge}, isGiftBadge(message): ${isMessageAGiftBadge}` + ); + // eslint-disable-next-line no-param-reassign + quote.isGiftBadge = isMessageAGiftBadge; + } + if (isMessageAGiftBadge) { + // eslint-disable-next-line no-param-reassign + quote.text = undefined; + // eslint-disable-next-line no-param-reassign + quote.attachments = []; + + return; + } + + // eslint-disable-next-line no-param-reassign + quote.isViewOnce = false; + + // eslint-disable-next-line no-param-reassign + quote.text = getQuoteBodyText(originalMessage.attributes, quote.id); + + // eslint-disable-next-line no-param-reassign + quote.bodyRanges = originalMessage.attributes.bodyRanges; + + if (!firstAttachment || !firstAttachment.contentType) { + return; + } + + try { + const schemaVersion = originalMessage.get('schemaVersion'); + if ( + schemaVersion && + schemaVersion < window.Signal.Types.Message.VERSION_NEEDED_FOR_DISPLAY + ) { + const upgradedMessage = + await window.Signal.Migrations.upgradeMessageSchema( + originalMessage.attributes + ); + originalMessage.set(upgradedMessage); + await window.Signal.Data.saveMessage(upgradedMessage, { + ourAci: window.textsecure.storage.user.getCheckedAci(), + }); + } + } catch (error) { + log.error( + 'Problem upgrading message quoted message from database', + Errors.toLogFormat(error) + ); + return; + } + + const queryAttachments = originalMessage.get('attachments') || []; + if (queryAttachments.length > 0) { + const queryFirst = queryAttachments[0]; + const { thumbnail } = queryFirst; + + if (thumbnail && thumbnail.path) { + firstAttachment.thumbnail = { + ...thumbnail, + copied: true, + }; + } else if (!firstAttachment.thumbnail || !isDownloadable(queryFirst)) { + firstAttachment.contentType = queryFirst.contentType; + firstAttachment.fileName = queryFirst.fileName; + firstAttachment.thumbnail = undefined; + } else { + // there is a thumbnail, but the original message attachment has not been + // downloaded yet, so we leave the quote attachment as is for now + } + } + + const queryPreview = originalMessage.get('preview') || []; + if (queryPreview.length > 0) { + const queryFirst = queryPreview[0]; + const { image } = queryFirst; + + if (image && image.path) { + firstAttachment.thumbnail = { + ...image, + copied: true, + }; + } + } + + const sticker = originalMessage.get('sticker'); + if (sticker && sticker.data && sticker.data.path) { + firstAttachment.thumbnail = { + ...sticker.data, + copied: true, + }; + } +}; diff --git a/ts/messages/helpers.ts b/ts/messages/helpers.ts index f667cdec88..59a9f8f42e 100644 --- a/ts/messages/helpers.ts +++ b/ts/messages/helpers.ts @@ -6,6 +6,7 @@ import type { ConversationModel } from '../models/conversations'; import type { CustomError, MessageAttributesType, + QuotedAttachmentType, QuotedMessageType, } from '../model-types.d'; import type { ServiceIdString } from '../types/ServiceId'; @@ -136,6 +137,31 @@ export function isQuoteAMatch( ); } +export const shouldTryToCopyFromQuotedMessage = ({ + referencedMessageNotFound, + quoteAttachment, +}: { + referencedMessageNotFound: boolean; + quoteAttachment: QuotedAttachmentType | undefined; +}): boolean => { + // If we've tried and can't find the message, try again. + if (referencedMessageNotFound === true) { + return true; + } + + // Otherwise, try again in case we have not yet copied over the thumbnail from the + // original attachment (maybe it had not been downloaded when we first checked) + if (!quoteAttachment?.thumbnail) { + return false; + } + + if (quoteAttachment.thumbnail.copied === true) { + return false; + } + + return true; +}; + export function getAuthorId( message: Pick ): string | undefined { diff --git a/ts/models/messages.ts b/ts/models/messages.ts index cb6061814d..662749254c 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -17,7 +17,6 @@ import type { CustomError, MessageAttributesType, MessageReactionType, - QuotedMessageType, } from '../model-types.d'; import { filter, find, map, repeat, zipObject } from '../util/iterables'; import * as GoogleChrome from '../util/GoogleChrome'; @@ -31,7 +30,6 @@ import { drop } from '../util/drop'; import type { ConversationModel } from './conversations'; import type { ProcessedDataMessage, - ProcessedQuote, ProcessedUnidentifiedDeliveryStatus, CallbackResultType, } from '../textsecure/Types.d'; @@ -45,7 +43,7 @@ import { normalizeServiceId } from '../types/ServiceId'; import { isAciString } from '../util/isAciString'; import * as reactionUtil from '../reactions/util'; import * as Errors from '../types/errors'; -import type { AttachmentType } from '../types/Attachment'; +import { type AttachmentType } from '../types/Attachment'; import * as MIME from '../types/MIME'; import { ReadStatus } from '../messages/MessageReadStatus'; import type { SendStateByConversationId } from '../messages/MessageSendState'; @@ -119,6 +117,7 @@ import { messageHasPaymentEvent, isQuoteAMatch, getAuthor, + shouldTryToCopyFromQuotedMessage, } from '../messages/helpers'; import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue'; import { getMessageIdForLogging } from '../util/idForLogging'; @@ -136,7 +135,6 @@ import { shouldUseAttachmentDownloadQueue, } from '../util/attachmentDownloadQueue'; import dataInterface from '../sql/Client'; -import { getQuoteBodyText } from '../util/getQuoteBodyText'; import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser'; import type { RawBodyRange } from '../types/BodyRange'; import { BodyRange } from '../types/BodyRange'; @@ -154,6 +152,10 @@ import { } from '../util/editHelpers'; import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp'; import type { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager'; +import { + copyFromQuotedMessage, + copyQuoteContentFromOriginal, +} from '../messages/copyQuote'; /* eslint-disable more/no-then */ @@ -448,10 +450,13 @@ export class MessageModel extends window.Backbone.Model { // Is the quote really without a reference? Check with our in memory store // first to make sure it's not there. - if (referencedMessageNotFound && contact) { - log.info( - `doubleCheckMissingQuoteReference/${logId}: Verifying reference to ${sentAt}` - ); + if ( + contact && + shouldTryToCopyFromQuotedMessage({ + referencedMessageNotFound, + quoteAttachment: quote.attachments.at(0), + }) + ) { const inMemoryMessages = window.MessageCache.__DEPRECATED$filterBySentAt( Number(sentAt) ); @@ -492,7 +497,7 @@ export class MessageModel extends window.Backbone.Model { `doubleCheckMissingQuoteReference/${logId}: Found match for ${sentAt}, updating.` ); - await this.copyQuoteContentFromOriginal(matchingMessage, quote); + await copyQuoteContentFromOriginal(matchingMessage, quote); this.set({ quote: { ...quote, @@ -1392,190 +1397,6 @@ export class MessageModel extends window.Backbone.Model { }); } - async copyFromQuotedMessage( - quote: ProcessedQuote | undefined, - conversationId: string - ): Promise { - if (!quote) { - return undefined; - } - - const { id } = quote; - strictAssert(id, 'Quote must have an id'); - - const result: QuotedMessageType = { - ...quote, - - id, - - attachments: quote.attachments.slice(), - bodyRanges: quote.bodyRanges?.slice(), - - // Just placeholder values for the fields - referencedMessageNotFound: false, - isGiftBadge: quote.type === Proto.DataMessage.Quote.Type.GIFT_BADGE, - isViewOnce: false, - messageId: '', - }; - - const inMemoryMessages = - window.MessageCache.__DEPRECATED$filterBySentAt(id); - const matchingMessage = find(inMemoryMessages, item => - isQuoteAMatch(item.attributes, conversationId, result) - ); - - let queryMessage: undefined | MessageModel; - - if (matchingMessage) { - queryMessage = matchingMessage; - } else { - log.info('copyFromQuotedMessage: db lookup needed', id); - const messages = await window.Signal.Data.getMessagesBySentAt(id); - const found = messages.find(item => - isQuoteAMatch(item, conversationId, result) - ); - - if (!found) { - result.referencedMessageNotFound = true; - return result; - } - - queryMessage = window.MessageCache.__DEPRECATED$register( - found.id, - found, - 'copyFromQuotedMessage' - ); - } - - if (queryMessage) { - await this.copyQuoteContentFromOriginal(queryMessage, result); - } - - return result; - } - - async copyQuoteContentFromOriginal( - originalMessage: MessageModel, - quote: QuotedMessageType - ): Promise { - const { attachments } = quote; - const firstAttachment = attachments ? attachments[0] : undefined; - - if (messageHasPaymentEvent(originalMessage.attributes)) { - // eslint-disable-next-line no-param-reassign - quote.payment = originalMessage.get('payment'); - } - - if (isTapToView(originalMessage.attributes)) { - // eslint-disable-next-line no-param-reassign - quote.text = undefined; - // eslint-disable-next-line no-param-reassign - quote.attachments = [ - { - contentType: MIME.IMAGE_JPEG, - }, - ]; - // eslint-disable-next-line no-param-reassign - quote.isViewOnce = true; - - return; - } - - const isMessageAGiftBadge = isGiftBadge(originalMessage.attributes); - if (isMessageAGiftBadge !== quote.isGiftBadge) { - log.warn( - `copyQuoteContentFromOriginal: Quote.isGiftBadge: ${quote.isGiftBadge}, isGiftBadge(message): ${isMessageAGiftBadge}` - ); - // eslint-disable-next-line no-param-reassign - quote.isGiftBadge = isMessageAGiftBadge; - } - if (isMessageAGiftBadge) { - // eslint-disable-next-line no-param-reassign - quote.text = undefined; - // eslint-disable-next-line no-param-reassign - quote.attachments = []; - - return; - } - - // eslint-disable-next-line no-param-reassign - quote.isViewOnce = false; - - // eslint-disable-next-line no-param-reassign - quote.text = getQuoteBodyText(originalMessage.attributes, quote.id); - - // eslint-disable-next-line no-param-reassign - quote.bodyRanges = originalMessage.attributes.bodyRanges; - - if (firstAttachment) { - firstAttachment.thumbnail = undefined; - } - - if (!firstAttachment || !firstAttachment.contentType) { - return; - } - - try { - const schemaVersion = originalMessage.get('schemaVersion'); - if ( - schemaVersion && - schemaVersion < TypedMessage.VERSION_NEEDED_FOR_DISPLAY - ) { - const upgradedMessage = await upgradeMessageSchema( - originalMessage.attributes - ); - originalMessage.set(upgradedMessage); - await window.Signal.Data.saveMessage(upgradedMessage, { - ourAci: window.textsecure.storage.user.getCheckedAci(), - }); - } - } catch (error) { - log.error( - 'Problem upgrading message quoted message from database', - Errors.toLogFormat(error) - ); - return; - } - - const queryAttachments = originalMessage.get('attachments') || []; - if (queryAttachments.length > 0) { - const queryFirst = queryAttachments[0]; - const { thumbnail } = queryFirst; - - if (thumbnail && thumbnail.path) { - firstAttachment.thumbnail = { - ...thumbnail, - copied: true, - }; - } else { - firstAttachment.contentType = queryFirst.contentType; - firstAttachment.fileName = queryFirst.fileName; - firstAttachment.thumbnail = undefined; - } - } - - const queryPreview = originalMessage.get('preview') || []; - if (queryPreview.length > 0) { - const queryFirst = queryPreview[0]; - const { image } = queryFirst; - - if (image && image.path) { - firstAttachment.thumbnail = { - ...image, - copied: true, - }; - } - } - - const sticker = originalMessage.get('sticker'); - if (sticker && sticker.data && sticker.data.path) { - firstAttachment.thumbnail = { - ...sticker.data, - copied: true, - }; - } - } - async handleDataMessage( initialMessage: ProcessedDataMessage, confirm: () => void, @@ -1911,7 +1732,9 @@ export class MessageModel extends window.Backbone.Model { } const [quote, storyQuotes] = await Promise.all([ - this.copyFromQuotedMessage(initialMessage.quote, conversation.id), + initialMessage.quote + ? copyFromQuotedMessage(initialMessage.quote, conversation.id) + : undefined, findStoryMessages(conversation.id, storyContext), ]); diff --git a/ts/services/backups/export.ts b/ts/services/backups/export.ts index a1c1720378..f564989edb 100644 --- a/ts/services/backups/export.ts +++ b/ts/services/backups/export.ts @@ -1627,15 +1627,19 @@ export class BackupExportStream extends Readable { return groupUpdate; } - private async toQuote( - quote?: QuotedMessageType - ): Promise { + private async toQuote({ + quote, + backupLevel, + messageReceivedAt, + }: { + quote?: QuotedMessageType; + backupLevel: BackupLevel; + messageReceivedAt: number; + }): Promise { if (!quote) { return null; } - const quotedMessage = await Data.getMessageById(quote.messageId); - let authorId: Long; if (quote.authorAci) { authorId = this.getOrPushPrivateRecipient({ @@ -1653,19 +1657,28 @@ export class BackupExportStream extends Readable { } return { - targetSentTimestamp: - quotedMessage && !quote.referencedMessageNotFound - ? Long.fromNumber(quotedMessage.sent_at) - : null, + targetSentTimestamp: Long.fromNumber(quote.id), authorId, text: quote.text, - attachments: quote.attachments.map((attachment: QuotedAttachmentType) => { - return { - contentType: attachment.contentType, - fileName: attachment.fileName, - thumbnail: null, - }; - }), + attachments: await Promise.all( + quote.attachments.map( + async ( + attachment: QuotedAttachmentType + ): Promise => { + return { + contentType: attachment.contentType, + fileName: attachment.fileName, + thumbnail: attachment.thumbnail + ? await this.processMessageAttachment({ + attachment: attachment.thumbnail, + backupLevel, + messageReceivedAt, + }) + : undefined, + }; + } + ) + ), bodyRanges: quote.bodyRanges?.map(range => this.toBodyRange(range)), type: quote.isGiftBadge ? Backups.Quote.Type.GIFTBADGE @@ -1880,7 +1893,11 @@ export class BackupExportStream extends Readable { const includeText = !isVoiceMessage; return { - quote: await this.toQuote(message.quote), + quote: await this.toQuote({ + quote: message.quote, + backupLevel, + messageReceivedAt: message.received_at, + }), attachments: message.attachments ? await Promise.all( message.attachments.map(attachment => { diff --git a/ts/services/backups/import.ts b/ts/services/backups/import.ts index 7c0eb640e2..ec4184868e 100644 --- a/ts/services/backups/import.ts +++ b/ts/services/backups/import.ts @@ -29,6 +29,7 @@ import type { MessageAttributesType, MessageReactionType, EditHistoryType, + QuotedMessageType, } from '../../model-types.d'; import { assertDev, strictAssert } from '../../util/assert'; import { getTimestampFromLong } from '../../util/timestampLongUtils'; @@ -62,6 +63,9 @@ import { convertBackupMessageAttachmentToAttachment, convertFilePointerToAttachment, } from './util/filePointers'; +import { filterAndClean } from '../../types/BodyRange'; +import { APPLICATION_OCTET_STREAM, stringToMIMEType } from '../../types/MIME'; +import { copyFromQuotedMessage } from '../../messages/copyQuote'; const MAX_CONCURRENCY = 10; @@ -111,6 +115,8 @@ async function processMessagesBatch( id: ids[index], }; + window.MessageCache.__DEPRECATED$unregister(attributes.id); + const { editHistory } = attributes; if (editHistory?.length) { @@ -388,14 +394,28 @@ export class BackupImportStream extends Writable { } private saveConversation(attributes: ConversationAttributesType): void { + // add the conversation into memory without saving it to DB (that will happen in + // batcher); if we didn't do this, when we register messages to MessageCache, it would + // automatically create (and save to DB) a duplicate conversation which would have to + // be later merged + window.ConversationController.dangerouslyCreateAndAdd(attributes); this.conversationOpBatcher.add({ isUpdate: false, attributes }); } private updateConversation(attributes: ConversationAttributesType): void { + const existing = window.ConversationController.get(attributes.id); + if (existing) { + existing.set(attributes); + } this.conversationOpBatcher.add({ isUpdate: true, attributes }); } private saveMessage(attributes: MessageAttributesType): void { + window.MessageCache.__DEPRECATED$register( + attributes.id, + attributes, + 'import.saveMessage' + ); this.saveMessageBatcher.add(attributes); } @@ -802,7 +822,7 @@ export class BackupImportStream extends Writable { attributes = { ...attributes, - ...this.fromStandardMessage(item.standardMessage), + ...(await this.fromStandardMessage(item.standardMessage, chatConvo.id)), }; } else { const result = await this.fromNonBubbleChatItem(item, { @@ -838,7 +858,7 @@ export class BackupImportStream extends Writable { 'Only standard message can have revisions' ); - const history = this.fromRevisions(attributes, item.revisions); + const history = await this.fromRevisions(attributes, item.revisions); attributes.editHistory = history; // Update timestamps on the parent message @@ -971,10 +991,10 @@ export class BackupImportStream extends Writable { return { patch: {} }; } - private fromStandardMessage( - data: Backups.IStandardMessage - ): Partial { - // TODO (DESKTOP-6964): Quote, link preview + private async fromStandardMessage( + data: Backups.IStandardMessage, + conversationId: string + ): Promise> { return { body: data.text?.body || undefined, attachments: data.attachments?.length @@ -998,38 +1018,46 @@ export class BackupImportStream extends Writable { }) : undefined, reactions: this.fromReactions(data.reactions), + quote: data.quote + ? await this.fromQuote(data.quote, conversationId) + : undefined, }; } - private fromRevisions( + private async fromRevisions( mainMessage: MessageAttributesType, revisions: ReadonlyArray - ): Array { - const result = revisions - .map(rev => { - strictAssert( - rev.standardMessage, - 'Edit history has non-standard messages' - ); + ): Promise> { + const result = await Promise.all( + revisions + .map(async rev => { + strictAssert( + rev.standardMessage, + 'Edit history has non-standard messages' + ); - const timestamp = getTimestampFromLong(rev.dateSent); + const timestamp = getTimestampFromLong(rev.dateSent); - const { - // eslint-disable-next-line camelcase - patch: { sendStateByConversationId, received_at_ms }, - } = this.fromDirectionDetails(rev, timestamp); + 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(); + return { + ...(await this.fromStandardMessage( + rev.standardMessage, + mainMessage.conversationId + )), + 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. @@ -1051,6 +1079,71 @@ export class BackupImportStream extends Writable { return result; } + private convertQuoteType( + type: Backups.Quote.Type | null | undefined + ): SignalService.DataMessage.Quote.Type { + switch (type) { + case Backups.Quote.Type.GIFTBADGE: + return SignalService.DataMessage.Quote.Type.GIFT_BADGE; + case Backups.Quote.Type.NORMAL: + case Backups.Quote.Type.UNKNOWN: + case null: + case undefined: + return SignalService.DataMessage.Quote.Type.NORMAL; + default: + throw missingCaseError(type); + } + } + + private async fromQuote( + quote: Backups.IQuote, + conversationId: string + ): Promise { + strictAssert(quote.authorId != null, 'quote must have an authorId'); + + const authorConvo = this.recipientIdToConvo.get(quote.authorId.toNumber()); + strictAssert(authorConvo !== undefined, 'author conversation not found'); + strictAssert( + isAciString(authorConvo.serviceId), + 'must have ACI for authorId in quote' + ); + + return copyFromQuotedMessage( + { + id: getTimestampFromLong(quote.targetSentTimestamp), + authorAci: authorConvo.serviceId, + text: dropNull(quote.text), + bodyRanges: quote.bodyRanges?.length + ? filterAndClean( + quote.bodyRanges.map(range => ({ + ...range, + mentionAci: range.mentionAci + ? Aci.parseFromServiceIdBinary( + Buffer.from(range.mentionAci) + ).getServiceIdString() + : undefined, + })) + ) + : undefined, + attachments: + quote.attachments?.map(quotedAttachment => { + const { fileName, contentType, thumbnail } = quotedAttachment; + return { + fileName: dropNull(fileName), + contentType: contentType + ? stringToMIMEType(contentType) + : APPLICATION_OCTET_STREAM, + thumbnail: thumbnail?.pointer + ? convertFilePointerToAttachment(thumbnail.pointer) + : undefined, + }; + }) ?? [], + type: this.convertQuoteType(quote.type), + }, + conversationId + ); + } + private fromReactions( reactions: ReadonlyArray | null | undefined ): Array | undefined { diff --git a/ts/services/backups/util/filePointers.ts b/ts/services/backups/util/filePointers.ts index 0edb48558c..507f8edd85 100644 --- a/ts/services/backups/util/filePointers.ts +++ b/ts/services/backups/util/filePointers.ts @@ -289,37 +289,41 @@ export async function getFilePointerForAttachment({ }; } - // From here on, this attachment is headed to (or already on) the backup tier! - const mediaNameForCurrentVersionOfAttachment = - getMediaNameForAttachment(attachment); + // Some attachments (e.g. those quoted ones copied from the original message) may not + // have any encryption info, including a digest! + if (attachment.digest) { + // From here on, this attachment is headed to (or already on) the backup tier! + const mediaNameForCurrentVersionOfAttachment = + getMediaNameForAttachment(attachment); - const backupCdnInfo = await getBackupCdnInfo( - getMediaIdFromMediaName(mediaNameForCurrentVersionOfAttachment).string - ); + const backupCdnInfo = await getBackupCdnInfo( + getMediaIdFromMediaName(mediaNameForCurrentVersionOfAttachment).string + ); - // We can generate a backupLocator for this mediaName iff - // 1. we have iv, key, and digest so we can re-encrypt to the existing digest when - // uploading, or - // 2. the mediaId is already in the backup tier and we have the key & digest to decrypt - // and verify it - if ( - isReencryptableToSameDigest(attachment) || - (backupCdnInfo.isInBackupTier && isDecryptable(attachment)) - ) { - return { - filePointer: new Backups.FilePointer({ - ...filePointerRootProps, - backupLocator: getBackupLocator({ - ...attachment, - backupLocator: { - mediaName: mediaNameForCurrentVersionOfAttachment, - cdnNumber: backupCdnInfo.isInBackupTier - ? backupCdnInfo.cdnNumber - : undefined, - }, + // We can generate a backupLocator for this mediaName iff + // 1. we have iv, key, and digest so we can re-encrypt to the existing digest when + // uploading, or + // 2. the mediaId is already in the backup tier and we have the key & digest to + // decrypt and verify it + if ( + isReencryptableToSameDigest(attachment) || + (backupCdnInfo.isInBackupTier && isDecryptable(attachment)) + ) { + return { + filePointer: new Backups.FilePointer({ + ...filePointerRootProps, + backupLocator: getBackupLocator({ + ...attachment, + backupLocator: { + mediaName: mediaNameForCurrentVersionOfAttachment, + cdnNumber: backupCdnInfo.isInBackupTier + ? backupCdnInfo.cdnNumber + : undefined, + }, + }), }), - }), - }; + }; + } } log.info(`${logId}: Generating new encryption info for attachment`); diff --git a/ts/test-electron/backup/attachments_test.ts b/ts/test-electron/backup/attachments_test.ts index 7e75ae7543..9b394a6615 100644 --- a/ts/test-electron/backup/attachments_test.ts +++ b/ts/test-electron/backup/attachments_test.ts @@ -8,13 +8,16 @@ import { omit } from 'lodash'; import type { ConversationModel } from '../../models/conversations'; import * as Bytes from '../../Bytes'; import Data from '../../sql/Client'; -import { generateAci } from '../../types/ServiceId'; +import { type AciString, generateAci } from '../../types/ServiceId'; import { ReadStatus } from '../../messages/MessageReadStatus'; import { SeenStatus } from '../../MessageSeenStatus'; import { loadCallsHistory } from '../../services/callHistoryLoader'; import { setupBasics, asymmetricRoundtripHarness } from './helpers'; -import { AUDIO_MP3, IMAGE_JPEG } from '../../types/MIME'; -import type { MessageAttributesType } from '../../model-types'; +import { AUDIO_MP3, IMAGE_JPEG, IMAGE_PNG, VIDEO_MP4 } from '../../types/MIME'; +import type { + MessageAttributesType, + QuotedMessageType, +} from '../../model-types'; import { isVoiceMessage, type AttachmentType } from '../../types/Attachment'; import { strictAssert } from '../../util/assert'; import { SignalService } from '../../protobuf'; @@ -58,6 +61,13 @@ describe('backup/attachments', () => { contentType: IMAGE_JPEG, path: `/path/to/file${index}.png`, uploadTimestamp: index, + thumbnail: { + size: 1024, + width: 150, + height: 150, + contentType: IMAGE_PNG, + path: '/path/to/thumbnail.png', + }, ...overrides, }; } @@ -73,7 +83,7 @@ describe('backup/attachments', () => { received_at: timestamp, received_at_ms: timestamp, sourceServiceId: CONTACT_A, - sourceDevice: timestamp, + sourceDevice: 1, sent_at: timestamp, timestamp, readStatus: ReadStatus.Read, @@ -97,8 +107,8 @@ describe('backup/attachments', () => { [ composeMessage(1, { attachments: [ - omit(attachment1, ['path', 'iv']), - omit(attachment2, ['path', 'iv']), + omit(attachment1, ['path', 'iv', 'thumbnail']), + omit(attachment2, ['path', 'iv', 'thumbnail']), ], }), ], @@ -121,7 +131,12 @@ describe('backup/attachments', () => { // but there will be a backupLocator attachments: [ { - ...omit(attachment, ['path', 'iv', 'uploadTimestamp']), + ...omit(attachment, [ + 'path', + 'iv', + 'thumbnail', + 'uploadTimestamp', + ]), backupLocator: { mediaName: attachment.digest }, }, ], @@ -148,7 +163,12 @@ describe('backup/attachments', () => { composeMessage(1, { attachments: [ { - ...omit(attachment, ['path', 'iv', 'uploadTimestamp']), + ...omit(attachment, [ + 'path', + 'iv', + 'thumbnail', + 'uploadTimestamp', + ]), backupLocator: { mediaName: attachment.digest }, }, ], @@ -173,7 +193,11 @@ describe('backup/attachments', () => { [ composeMessage(1, { preview: [ - { url: 'url', date: 1, image: omit(attachment, ['path', 'iv']) }, + { + url: 'url', + date: 1, + image: omit(attachment, ['path', 'iv', 'thumbnail']), + }, ], }), ], @@ -209,7 +233,12 @@ describe('backup/attachments', () => { image: { // path, iv, and uploadTimestamp will not be roundtripped, // but there will be a backupLocator - ...omit(attachment, ['path', 'iv', 'uploadTimestamp']), + ...omit(attachment, [ + 'path', + 'iv', + 'thumbnail', + 'uploadTimestamp', + ]), backupLocator: { mediaName: attachment.digest }, }, }, @@ -237,7 +266,7 @@ describe('backup/attachments', () => { contact: [ { avatar: { - avatar: omit(attachment, ['path', 'iv']), + avatar: omit(attachment, ['path', 'iv', 'thumbnail']), isProfile: false, }, }, @@ -265,7 +294,12 @@ describe('backup/attachments', () => { { avatar: { avatar: { - ...omit(attachment, ['path', 'iv', 'uploadTimestamp']), + ...omit(attachment, [ + 'path', + 'iv', + 'thumbnail', + 'uploadTimestamp', + ]), backupLocator: { mediaName: attachment.digest }, }, isProfile: false, @@ -278,4 +312,155 @@ describe('backup/attachments', () => { ); }); }); + + describe('quotes', () => { + it('BackupLevel.Messages, roundtrips quote attachments', async () => { + const attachment = composeAttachment(1); + const authorAci = generateAci(); + const quotedMessage: QuotedMessageType = { + authorAci, + isViewOnce: false, + id: Date.now(), + referencedMessageNotFound: false, + messageId: '', + isGiftBadge: true, + attachments: [{ thumbnail: attachment, contentType: VIDEO_MP4 }], + }; + + await asymmetricRoundtripHarness( + [ + composeMessage(1, { + quote: quotedMessage, + }), + ], + // path & iv will not be roundtripped + [ + composeMessage(1, { + quote: { + ...quotedMessage, + referencedMessageNotFound: true, + attachments: [ + { + thumbnail: omit(attachment, ['iv', 'path', 'thumbnail']), + contentType: VIDEO_MP4, + }, + ], + }, + }), + ], + BackupLevel.Messages + ); + }); + it('BackupLevel.Media, roundtrips quote attachments', async () => { + const attachment = composeAttachment(1); + strictAssert(attachment.digest, 'digest exists'); + const authorAci = generateAci(); + const quotedMessage: QuotedMessageType = { + authorAci, + isViewOnce: false, + id: Date.now(), + referencedMessageNotFound: false, + messageId: '', + isGiftBadge: true, + attachments: [{ thumbnail: attachment, contentType: VIDEO_MP4 }], + }; + + await asymmetricRoundtripHarness( + [ + composeMessage(1, { + quote: quotedMessage, + }), + ], + [ + composeMessage(1, { + quote: { + ...quotedMessage, + referencedMessageNotFound: true, + attachments: [ + { + thumbnail: { + ...omit(attachment, [ + 'iv', + 'path', + 'uploadTimestamp', + 'thumbnail', + ]), + backupLocator: { mediaName: attachment.digest }, + }, + contentType: VIDEO_MP4, + }, + ], + }, + }), + ], + BackupLevel.Media + ); + }); + + it('Copies data from message if it exists', async () => { + const existingAttachment = composeAttachment(1); + const existingMessageTimestamp = Date.now(); + const existingMessage = composeMessage(existingMessageTimestamp, { + attachments: [existingAttachment], + }); + + const quoteAttachment = composeAttachment(2); + delete quoteAttachment.thumbnail; + + strictAssert(quoteAttachment.digest, 'digest exists'); + strictAssert(existingAttachment.digest, 'digest exists'); + const quotedMessage: QuotedMessageType = { + authorAci: existingMessage.sourceServiceId as AciString, + isViewOnce: false, + id: existingMessageTimestamp, + referencedMessageNotFound: false, + messageId: '', + isGiftBadge: false, + attachments: [{ thumbnail: quoteAttachment, contentType: VIDEO_MP4 }], + }; + + const quoteMessage = composeMessage(existingMessageTimestamp + 1, { + quote: quotedMessage, + }); + + await asymmetricRoundtripHarness( + [existingMessage, quoteMessage], + [ + { + ...existingMessage, + attachments: [ + { + ...omit(existingAttachment, [ + 'path', + 'iv', + 'uploadTimestamp', + 'thumbnail', + ]), + backupLocator: { mediaName: existingAttachment.digest }, + }, + ], + }, + { + ...quoteMessage, + quote: { + ...quotedMessage, + referencedMessageNotFound: false, + attachments: [ + { + // The thumbnail will not have been copied over yet since it has not yet + // been downloaded + thumbnail: { + ...omit(quoteAttachment, ['iv', 'path', 'uploadTimestamp']), + backupLocator: { mediaName: quoteAttachment.digest }, + }, + contentType: VIDEO_MP4, + }, + ], + }, + }, + ], + BackupLevel.Media + ); + }); + }); }); diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 99cccc31be..32d4daaee2 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -1073,6 +1073,13 @@ export function isDownloadableFromBackupTier( return false; } +export function isDownloadable(attachment: AttachmentType): boolean { + return ( + isDownloadableFromTransitTier(attachment) || + isDownloadableFromBackupTier(attachment) + ); +} + export function isAttachmentLocallySaved( attachment: AttachmentType ): attachment is LocallySavedAttachment {