Backup support for quotes & quoted attachments
This commit is contained in:
parent
0f2b71b4a6
commit
e0dc4c412d
10 changed files with 653 additions and 289 deletions
|
@ -24,7 +24,10 @@ import { getClassNamesFor } from '../../util/getClassNamesFor';
|
||||||
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
|
import { getCustomColorStyle } from '../../util/getCustomColorStyle';
|
||||||
import type { AnyPaymentEvent } from '../../types/Payment';
|
import type { AnyPaymentEvent } from '../../types/Payment';
|
||||||
import { PaymentEventKind } from '../../types/Payment';
|
import { PaymentEventKind } from '../../types/Payment';
|
||||||
import { getPaymentEventNotificationText } from '../../messages/helpers';
|
import {
|
||||||
|
getPaymentEventNotificationText,
|
||||||
|
shouldTryToCopyFromQuotedMessage,
|
||||||
|
} from '../../messages/helpers';
|
||||||
import { RenderLocation } from './MessageTextRenderer';
|
import { RenderLocation } from './MessageTextRenderer';
|
||||||
import type { QuotedAttachmentType } from '../../model-types';
|
import type { QuotedAttachmentType } from '../../model-types';
|
||||||
|
|
||||||
|
@ -165,10 +168,19 @@ export function Quote(props: Props): JSX.Element | null {
|
||||||
const getClassName = getClassNamesFor('module-quote', moduleClassName);
|
const getClassName = getClassNamesFor('module-quote', moduleClassName);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (referencedMessageNotFound) {
|
if (
|
||||||
|
shouldTryToCopyFromQuotedMessage({
|
||||||
|
referencedMessageNotFound,
|
||||||
|
quoteAttachment: rawAttachment,
|
||||||
|
})
|
||||||
|
) {
|
||||||
doubleCheckMissingQuoteReference?.();
|
doubleCheckMissingQuoteReference?.();
|
||||||
}
|
}
|
||||||
}, [referencedMessageNotFound, doubleCheckMissingQuoteReference]);
|
}, [
|
||||||
|
referencedMessageNotFound,
|
||||||
|
rawAttachment,
|
||||||
|
doubleCheckMissingQuoteReference,
|
||||||
|
]);
|
||||||
|
|
||||||
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
|
function handleKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
|
||||||
// This is important to ensure that using this quote to navigate to the referenced
|
// This is important to ensure that using this quote to navigate to the referenced
|
||||||
|
|
|
@ -789,17 +789,17 @@ async function uploadMessageQuote({
|
||||||
loadedQuote.attachments.map(
|
loadedQuote.attachments.map(
|
||||||
attachment => async (): Promise<OutgoingQuoteAttachmentType> => {
|
attachment => async (): Promise<OutgoingQuoteAttachmentType> => {
|
||||||
const { thumbnail } = attachment;
|
const { thumbnail } = attachment;
|
||||||
if (!thumbnail) {
|
if (!thumbnail || !thumbnail.data) {
|
||||||
return {
|
return {
|
||||||
contentType: attachment.contentType,
|
contentType: attachment.contentType,
|
||||||
fileName: attachment.fileName,
|
fileName: attachment.fileName,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const { data } = thumbnail;
|
const uploaded = await uploadAttachment({
|
||||||
strictAssert(data, 'data must be loaded into thumbnail');
|
...thumbnail,
|
||||||
|
data: thumbnail.data,
|
||||||
const uploaded = await uploadAttachment({ ...thumbnail, data });
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
contentType: attachment.contentType,
|
contentType: attachment.contentType,
|
||||||
|
|
197
ts/messages/copyQuote.ts
Normal file
197
ts/messages/copyQuote.ts
Normal file
|
@ -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<QuotedMessageType> => {
|
||||||
|
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<void> => {
|
||||||
|
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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
};
|
|
@ -6,6 +6,7 @@ import type { ConversationModel } from '../models/conversations';
|
||||||
import type {
|
import type {
|
||||||
CustomError,
|
CustomError,
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
|
QuotedAttachmentType,
|
||||||
QuotedMessageType,
|
QuotedMessageType,
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
import type { ServiceIdString } from '../types/ServiceId';
|
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(
|
export function getAuthorId(
|
||||||
message: Pick<MessageAttributesType, 'type' | 'source' | 'sourceServiceId'>
|
message: Pick<MessageAttributesType, 'type' | 'source' | 'sourceServiceId'>
|
||||||
): string | undefined {
|
): string | undefined {
|
||||||
|
|
|
@ -17,7 +17,6 @@ import type {
|
||||||
CustomError,
|
CustomError,
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
MessageReactionType,
|
MessageReactionType,
|
||||||
QuotedMessageType,
|
|
||||||
} from '../model-types.d';
|
} from '../model-types.d';
|
||||||
import { filter, find, map, repeat, zipObject } from '../util/iterables';
|
import { filter, find, map, repeat, zipObject } from '../util/iterables';
|
||||||
import * as GoogleChrome from '../util/GoogleChrome';
|
import * as GoogleChrome from '../util/GoogleChrome';
|
||||||
|
@ -31,7 +30,6 @@ import { drop } from '../util/drop';
|
||||||
import type { ConversationModel } from './conversations';
|
import type { ConversationModel } from './conversations';
|
||||||
import type {
|
import type {
|
||||||
ProcessedDataMessage,
|
ProcessedDataMessage,
|
||||||
ProcessedQuote,
|
|
||||||
ProcessedUnidentifiedDeliveryStatus,
|
ProcessedUnidentifiedDeliveryStatus,
|
||||||
CallbackResultType,
|
CallbackResultType,
|
||||||
} from '../textsecure/Types.d';
|
} from '../textsecure/Types.d';
|
||||||
|
@ -45,7 +43,7 @@ import { normalizeServiceId } from '../types/ServiceId';
|
||||||
import { isAciString } from '../util/isAciString';
|
import { isAciString } from '../util/isAciString';
|
||||||
import * as reactionUtil from '../reactions/util';
|
import * as reactionUtil from '../reactions/util';
|
||||||
import * as Errors from '../types/errors';
|
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 * as MIME from '../types/MIME';
|
||||||
import { ReadStatus } from '../messages/MessageReadStatus';
|
import { ReadStatus } from '../messages/MessageReadStatus';
|
||||||
import type { SendStateByConversationId } from '../messages/MessageSendState';
|
import type { SendStateByConversationId } from '../messages/MessageSendState';
|
||||||
|
@ -119,6 +117,7 @@ import {
|
||||||
messageHasPaymentEvent,
|
messageHasPaymentEvent,
|
||||||
isQuoteAMatch,
|
isQuoteAMatch,
|
||||||
getAuthor,
|
getAuthor,
|
||||||
|
shouldTryToCopyFromQuotedMessage,
|
||||||
} from '../messages/helpers';
|
} from '../messages/helpers';
|
||||||
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
|
import { viewOnceOpenJobQueue } from '../jobs/viewOnceOpenJobQueue';
|
||||||
import { getMessageIdForLogging } from '../util/idForLogging';
|
import { getMessageIdForLogging } from '../util/idForLogging';
|
||||||
|
@ -136,7 +135,6 @@ import {
|
||||||
shouldUseAttachmentDownloadQueue,
|
shouldUseAttachmentDownloadQueue,
|
||||||
} from '../util/attachmentDownloadQueue';
|
} from '../util/attachmentDownloadQueue';
|
||||||
import dataInterface from '../sql/Client';
|
import dataInterface from '../sql/Client';
|
||||||
import { getQuoteBodyText } from '../util/getQuoteBodyText';
|
|
||||||
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
|
import { shouldReplyNotifyUser } from '../util/shouldReplyNotifyUser';
|
||||||
import type { RawBodyRange } from '../types/BodyRange';
|
import type { RawBodyRange } from '../types/BodyRange';
|
||||||
import { BodyRange } from '../types/BodyRange';
|
import { BodyRange } from '../types/BodyRange';
|
||||||
|
@ -154,6 +152,10 @@ import {
|
||||||
} from '../util/editHelpers';
|
} from '../util/editHelpers';
|
||||||
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
|
import { getMessageSentTimestamp } from '../util/getMessageSentTimestamp';
|
||||||
import type { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager';
|
import type { AttachmentDownloadUrgency } from '../jobs/AttachmentDownloadManager';
|
||||||
|
import {
|
||||||
|
copyFromQuotedMessage,
|
||||||
|
copyQuoteContentFromOriginal,
|
||||||
|
} from '../messages/copyQuote';
|
||||||
|
|
||||||
/* eslint-disable more/no-then */
|
/* eslint-disable more/no-then */
|
||||||
|
|
||||||
|
@ -448,10 +450,13 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
|
|
||||||
// Is the quote really without a reference? Check with our in memory store
|
// Is the quote really without a reference? Check with our in memory store
|
||||||
// first to make sure it's not there.
|
// first to make sure it's not there.
|
||||||
if (referencedMessageNotFound && contact) {
|
if (
|
||||||
log.info(
|
contact &&
|
||||||
`doubleCheckMissingQuoteReference/${logId}: Verifying reference to ${sentAt}`
|
shouldTryToCopyFromQuotedMessage({
|
||||||
);
|
referencedMessageNotFound,
|
||||||
|
quoteAttachment: quote.attachments.at(0),
|
||||||
|
})
|
||||||
|
) {
|
||||||
const inMemoryMessages = window.MessageCache.__DEPRECATED$filterBySentAt(
|
const inMemoryMessages = window.MessageCache.__DEPRECATED$filterBySentAt(
|
||||||
Number(sentAt)
|
Number(sentAt)
|
||||||
);
|
);
|
||||||
|
@ -492,7 +497,7 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
`doubleCheckMissingQuoteReference/${logId}: Found match for ${sentAt}, updating.`
|
`doubleCheckMissingQuoteReference/${logId}: Found match for ${sentAt}, updating.`
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.copyQuoteContentFromOriginal(matchingMessage, quote);
|
await copyQuoteContentFromOriginal(matchingMessage, quote);
|
||||||
this.set({
|
this.set({
|
||||||
quote: {
|
quote: {
|
||||||
...quote,
|
...quote,
|
||||||
|
@ -1392,190 +1397,6 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async copyFromQuotedMessage(
|
|
||||||
quote: ProcessedQuote | undefined,
|
|
||||||
conversationId: string
|
|
||||||
): Promise<QuotedMessageType | undefined> {
|
|
||||||
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<void> {
|
|
||||||
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(
|
async handleDataMessage(
|
||||||
initialMessage: ProcessedDataMessage,
|
initialMessage: ProcessedDataMessage,
|
||||||
confirm: () => void,
|
confirm: () => void,
|
||||||
|
@ -1911,7 +1732,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const [quote, storyQuotes] = await Promise.all([
|
const [quote, storyQuotes] = await Promise.all([
|
||||||
this.copyFromQuotedMessage(initialMessage.quote, conversation.id),
|
initialMessage.quote
|
||||||
|
? copyFromQuotedMessage(initialMessage.quote, conversation.id)
|
||||||
|
: undefined,
|
||||||
findStoryMessages(conversation.id, storyContext),
|
findStoryMessages(conversation.id, storyContext),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
|
|
@ -1627,15 +1627,19 @@ export class BackupExportStream extends Readable {
|
||||||
return groupUpdate;
|
return groupUpdate;
|
||||||
}
|
}
|
||||||
|
|
||||||
private async toQuote(
|
private async toQuote({
|
||||||
quote?: QuotedMessageType
|
quote,
|
||||||
): Promise<Backups.IQuote | null> {
|
backupLevel,
|
||||||
|
messageReceivedAt,
|
||||||
|
}: {
|
||||||
|
quote?: QuotedMessageType;
|
||||||
|
backupLevel: BackupLevel;
|
||||||
|
messageReceivedAt: number;
|
||||||
|
}): Promise<Backups.IQuote | null> {
|
||||||
if (!quote) {
|
if (!quote) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const quotedMessage = await Data.getMessageById(quote.messageId);
|
|
||||||
|
|
||||||
let authorId: Long;
|
let authorId: Long;
|
||||||
if (quote.authorAci) {
|
if (quote.authorAci) {
|
||||||
authorId = this.getOrPushPrivateRecipient({
|
authorId = this.getOrPushPrivateRecipient({
|
||||||
|
@ -1653,19 +1657,28 @@ export class BackupExportStream extends Readable {
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
targetSentTimestamp:
|
targetSentTimestamp: Long.fromNumber(quote.id),
|
||||||
quotedMessage && !quote.referencedMessageNotFound
|
|
||||||
? Long.fromNumber(quotedMessage.sent_at)
|
|
||||||
: null,
|
|
||||||
authorId,
|
authorId,
|
||||||
text: quote.text,
|
text: quote.text,
|
||||||
attachments: quote.attachments.map((attachment: QuotedAttachmentType) => {
|
attachments: await Promise.all(
|
||||||
return {
|
quote.attachments.map(
|
||||||
contentType: attachment.contentType,
|
async (
|
||||||
fileName: attachment.fileName,
|
attachment: QuotedAttachmentType
|
||||||
thumbnail: null,
|
): Promise<Backups.Quote.IQuotedAttachment> => {
|
||||||
};
|
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)),
|
bodyRanges: quote.bodyRanges?.map(range => this.toBodyRange(range)),
|
||||||
type: quote.isGiftBadge
|
type: quote.isGiftBadge
|
||||||
? Backups.Quote.Type.GIFTBADGE
|
? Backups.Quote.Type.GIFTBADGE
|
||||||
|
@ -1880,7 +1893,11 @@ export class BackupExportStream extends Readable {
|
||||||
const includeText = !isVoiceMessage;
|
const includeText = !isVoiceMessage;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
quote: await this.toQuote(message.quote),
|
quote: await this.toQuote({
|
||||||
|
quote: message.quote,
|
||||||
|
backupLevel,
|
||||||
|
messageReceivedAt: message.received_at,
|
||||||
|
}),
|
||||||
attachments: message.attachments
|
attachments: message.attachments
|
||||||
? await Promise.all(
|
? await Promise.all(
|
||||||
message.attachments.map(attachment => {
|
message.attachments.map(attachment => {
|
||||||
|
|
|
@ -29,6 +29,7 @@ import type {
|
||||||
MessageAttributesType,
|
MessageAttributesType,
|
||||||
MessageReactionType,
|
MessageReactionType,
|
||||||
EditHistoryType,
|
EditHistoryType,
|
||||||
|
QuotedMessageType,
|
||||||
} 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';
|
||||||
|
@ -62,6 +63,9 @@ import {
|
||||||
convertBackupMessageAttachmentToAttachment,
|
convertBackupMessageAttachmentToAttachment,
|
||||||
convertFilePointerToAttachment,
|
convertFilePointerToAttachment,
|
||||||
} from './util/filePointers';
|
} 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;
|
const MAX_CONCURRENCY = 10;
|
||||||
|
|
||||||
|
@ -111,6 +115,8 @@ async function processMessagesBatch(
|
||||||
id: ids[index],
|
id: ids[index],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
window.MessageCache.__DEPRECATED$unregister(attributes.id);
|
||||||
|
|
||||||
const { editHistory } = attributes;
|
const { editHistory } = attributes;
|
||||||
|
|
||||||
if (editHistory?.length) {
|
if (editHistory?.length) {
|
||||||
|
@ -388,14 +394,28 @@ export class BackupImportStream extends Writable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveConversation(attributes: ConversationAttributesType): void {
|
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 });
|
this.conversationOpBatcher.add({ isUpdate: false, attributes });
|
||||||
}
|
}
|
||||||
|
|
||||||
private updateConversation(attributes: ConversationAttributesType): void {
|
private updateConversation(attributes: ConversationAttributesType): void {
|
||||||
|
const existing = window.ConversationController.get(attributes.id);
|
||||||
|
if (existing) {
|
||||||
|
existing.set(attributes);
|
||||||
|
}
|
||||||
this.conversationOpBatcher.add({ isUpdate: true, attributes });
|
this.conversationOpBatcher.add({ isUpdate: true, attributes });
|
||||||
}
|
}
|
||||||
|
|
||||||
private saveMessage(attributes: MessageAttributesType): void {
|
private saveMessage(attributes: MessageAttributesType): void {
|
||||||
|
window.MessageCache.__DEPRECATED$register(
|
||||||
|
attributes.id,
|
||||||
|
attributes,
|
||||||
|
'import.saveMessage'
|
||||||
|
);
|
||||||
this.saveMessageBatcher.add(attributes);
|
this.saveMessageBatcher.add(attributes);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -802,7 +822,7 @@ export class BackupImportStream extends Writable {
|
||||||
|
|
||||||
attributes = {
|
attributes = {
|
||||||
...attributes,
|
...attributes,
|
||||||
...this.fromStandardMessage(item.standardMessage),
|
...(await this.fromStandardMessage(item.standardMessage, chatConvo.id)),
|
||||||
};
|
};
|
||||||
} else {
|
} else {
|
||||||
const result = await this.fromNonBubbleChatItem(item, {
|
const result = await this.fromNonBubbleChatItem(item, {
|
||||||
|
@ -838,7 +858,7 @@ export class BackupImportStream extends Writable {
|
||||||
'Only standard message can have revisions'
|
'Only standard message can have revisions'
|
||||||
);
|
);
|
||||||
|
|
||||||
const history = this.fromRevisions(attributes, item.revisions);
|
const history = await this.fromRevisions(attributes, item.revisions);
|
||||||
attributes.editHistory = history;
|
attributes.editHistory = history;
|
||||||
|
|
||||||
// Update timestamps on the parent message
|
// Update timestamps on the parent message
|
||||||
|
@ -971,10 +991,10 @@ export class BackupImportStream extends Writable {
|
||||||
return { patch: {} };
|
return { patch: {} };
|
||||||
}
|
}
|
||||||
|
|
||||||
private fromStandardMessage(
|
private async fromStandardMessage(
|
||||||
data: Backups.IStandardMessage
|
data: Backups.IStandardMessage,
|
||||||
): Partial<MessageAttributesType> {
|
conversationId: string
|
||||||
// TODO (DESKTOP-6964): Quote, link preview
|
): Promise<Partial<MessageAttributesType>> {
|
||||||
return {
|
return {
|
||||||
body: data.text?.body || undefined,
|
body: data.text?.body || undefined,
|
||||||
attachments: data.attachments?.length
|
attachments: data.attachments?.length
|
||||||
|
@ -998,38 +1018,46 @@ export class BackupImportStream extends Writable {
|
||||||
})
|
})
|
||||||
: undefined,
|
: undefined,
|
||||||
reactions: this.fromReactions(data.reactions),
|
reactions: this.fromReactions(data.reactions),
|
||||||
|
quote: data.quote
|
||||||
|
? await this.fromQuote(data.quote, conversationId)
|
||||||
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private fromRevisions(
|
private async fromRevisions(
|
||||||
mainMessage: MessageAttributesType,
|
mainMessage: MessageAttributesType,
|
||||||
revisions: ReadonlyArray<Backups.IChatItem>
|
revisions: ReadonlyArray<Backups.IChatItem>
|
||||||
): Array<EditHistoryType> {
|
): Promise<Array<EditHistoryType>> {
|
||||||
const result = revisions
|
const result = await Promise.all(
|
||||||
.map(rev => {
|
revisions
|
||||||
strictAssert(
|
.map(async rev => {
|
||||||
rev.standardMessage,
|
strictAssert(
|
||||||
'Edit history has non-standard messages'
|
rev.standardMessage,
|
||||||
);
|
'Edit history has non-standard messages'
|
||||||
|
);
|
||||||
|
|
||||||
const timestamp = getTimestampFromLong(rev.dateSent);
|
const timestamp = getTimestampFromLong(rev.dateSent);
|
||||||
|
|
||||||
const {
|
const {
|
||||||
// eslint-disable-next-line camelcase
|
// eslint-disable-next-line camelcase
|
||||||
patch: { sendStateByConversationId, received_at_ms },
|
patch: { sendStateByConversationId, received_at_ms },
|
||||||
} = this.fromDirectionDetails(rev, timestamp);
|
} = this.fromDirectionDetails(rev, timestamp);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...this.fromStandardMessage(rev.standardMessage),
|
...(await this.fromStandardMessage(
|
||||||
timestamp,
|
rev.standardMessage,
|
||||||
received_at: incrementMessageCounter(),
|
mainMessage.conversationId
|
||||||
sendStateByConversationId,
|
)),
|
||||||
// eslint-disable-next-line camelcase
|
timestamp,
|
||||||
received_at_ms,
|
received_at: incrementMessageCounter(),
|
||||||
};
|
sendStateByConversationId,
|
||||||
})
|
// eslint-disable-next-line camelcase
|
||||||
// Fix order: from newest to oldest
|
received_at_ms,
|
||||||
.reverse();
|
};
|
||||||
|
})
|
||||||
|
// Fix order: from newest to oldest
|
||||||
|
.reverse()
|
||||||
|
);
|
||||||
|
|
||||||
// See `ts/util/handleEditMessage.ts`, the first history entry is always
|
// See `ts/util/handleEditMessage.ts`, the first history entry is always
|
||||||
// the current message.
|
// the current message.
|
||||||
|
@ -1051,6 +1079,71 @@ export class BackupImportStream extends Writable {
|
||||||
return result;
|
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<QuotedMessageType> {
|
||||||
|
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(
|
private fromReactions(
|
||||||
reactions: ReadonlyArray<Backups.IReaction> | null | undefined
|
reactions: ReadonlyArray<Backups.IReaction> | null | undefined
|
||||||
): Array<MessageReactionType> | undefined {
|
): Array<MessageReactionType> | undefined {
|
||||||
|
|
|
@ -289,37 +289,41 @@ export async function getFilePointerForAttachment({
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// From here on, this attachment is headed to (or already on) the backup tier!
|
// Some attachments (e.g. those quoted ones copied from the original message) may not
|
||||||
const mediaNameForCurrentVersionOfAttachment =
|
// have any encryption info, including a digest!
|
||||||
getMediaNameForAttachment(attachment);
|
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(
|
const backupCdnInfo = await getBackupCdnInfo(
|
||||||
getMediaIdFromMediaName(mediaNameForCurrentVersionOfAttachment).string
|
getMediaIdFromMediaName(mediaNameForCurrentVersionOfAttachment).string
|
||||||
);
|
);
|
||||||
|
|
||||||
// We can generate a backupLocator for this mediaName iff
|
// 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
|
// 1. we have iv, key, and digest so we can re-encrypt to the existing digest when
|
||||||
// uploading, or
|
// uploading, or
|
||||||
// 2. the mediaId is already in the backup tier and we have the key & digest to decrypt
|
// 2. the mediaId is already in the backup tier and we have the key & digest to
|
||||||
// and verify it
|
// decrypt and verify it
|
||||||
if (
|
if (
|
||||||
isReencryptableToSameDigest(attachment) ||
|
isReencryptableToSameDigest(attachment) ||
|
||||||
(backupCdnInfo.isInBackupTier && isDecryptable(attachment))
|
(backupCdnInfo.isInBackupTier && isDecryptable(attachment))
|
||||||
) {
|
) {
|
||||||
return {
|
return {
|
||||||
filePointer: new Backups.FilePointer({
|
filePointer: new Backups.FilePointer({
|
||||||
...filePointerRootProps,
|
...filePointerRootProps,
|
||||||
backupLocator: getBackupLocator({
|
backupLocator: getBackupLocator({
|
||||||
...attachment,
|
...attachment,
|
||||||
backupLocator: {
|
backupLocator: {
|
||||||
mediaName: mediaNameForCurrentVersionOfAttachment,
|
mediaName: mediaNameForCurrentVersionOfAttachment,
|
||||||
cdnNumber: backupCdnInfo.isInBackupTier
|
cdnNumber: backupCdnInfo.isInBackupTier
|
||||||
? backupCdnInfo.cdnNumber
|
? backupCdnInfo.cdnNumber
|
||||||
: undefined,
|
: undefined,
|
||||||
},
|
},
|
||||||
|
}),
|
||||||
}),
|
}),
|
||||||
}),
|
};
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
log.info(`${logId}: Generating new encryption info for attachment`);
|
log.info(`${logId}: Generating new encryption info for attachment`);
|
||||||
|
|
|
@ -8,13 +8,16 @@ import { omit } from 'lodash';
|
||||||
import type { ConversationModel } from '../../models/conversations';
|
import type { ConversationModel } from '../../models/conversations';
|
||||||
import * as Bytes from '../../Bytes';
|
import * as Bytes from '../../Bytes';
|
||||||
import Data from '../../sql/Client';
|
import Data from '../../sql/Client';
|
||||||
import { generateAci } from '../../types/ServiceId';
|
import { type AciString, generateAci } from '../../types/ServiceId';
|
||||||
import { ReadStatus } from '../../messages/MessageReadStatus';
|
import { ReadStatus } from '../../messages/MessageReadStatus';
|
||||||
import { SeenStatus } from '../../MessageSeenStatus';
|
import { SeenStatus } from '../../MessageSeenStatus';
|
||||||
import { loadCallsHistory } from '../../services/callHistoryLoader';
|
import { loadCallsHistory } from '../../services/callHistoryLoader';
|
||||||
import { setupBasics, asymmetricRoundtripHarness } from './helpers';
|
import { setupBasics, asymmetricRoundtripHarness } from './helpers';
|
||||||
import { AUDIO_MP3, IMAGE_JPEG } from '../../types/MIME';
|
import { AUDIO_MP3, IMAGE_JPEG, IMAGE_PNG, VIDEO_MP4 } from '../../types/MIME';
|
||||||
import type { MessageAttributesType } from '../../model-types';
|
import type {
|
||||||
|
MessageAttributesType,
|
||||||
|
QuotedMessageType,
|
||||||
|
} from '../../model-types';
|
||||||
import { isVoiceMessage, type AttachmentType } from '../../types/Attachment';
|
import { isVoiceMessage, type AttachmentType } from '../../types/Attachment';
|
||||||
import { strictAssert } from '../../util/assert';
|
import { strictAssert } from '../../util/assert';
|
||||||
import { SignalService } from '../../protobuf';
|
import { SignalService } from '../../protobuf';
|
||||||
|
@ -58,6 +61,13 @@ describe('backup/attachments', () => {
|
||||||
contentType: IMAGE_JPEG,
|
contentType: IMAGE_JPEG,
|
||||||
path: `/path/to/file${index}.png`,
|
path: `/path/to/file${index}.png`,
|
||||||
uploadTimestamp: index,
|
uploadTimestamp: index,
|
||||||
|
thumbnail: {
|
||||||
|
size: 1024,
|
||||||
|
width: 150,
|
||||||
|
height: 150,
|
||||||
|
contentType: IMAGE_PNG,
|
||||||
|
path: '/path/to/thumbnail.png',
|
||||||
|
},
|
||||||
...overrides,
|
...overrides,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -73,7 +83,7 @@ describe('backup/attachments', () => {
|
||||||
received_at: timestamp,
|
received_at: timestamp,
|
||||||
received_at_ms: timestamp,
|
received_at_ms: timestamp,
|
||||||
sourceServiceId: CONTACT_A,
|
sourceServiceId: CONTACT_A,
|
||||||
sourceDevice: timestamp,
|
sourceDevice: 1,
|
||||||
sent_at: timestamp,
|
sent_at: timestamp,
|
||||||
timestamp,
|
timestamp,
|
||||||
readStatus: ReadStatus.Read,
|
readStatus: ReadStatus.Read,
|
||||||
|
@ -97,8 +107,8 @@ describe('backup/attachments', () => {
|
||||||
[
|
[
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
attachments: [
|
attachments: [
|
||||||
omit(attachment1, ['path', 'iv']),
|
omit(attachment1, ['path', 'iv', 'thumbnail']),
|
||||||
omit(attachment2, ['path', 'iv']),
|
omit(attachment2, ['path', 'iv', 'thumbnail']),
|
||||||
],
|
],
|
||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
|
@ -121,7 +131,12 @@ describe('backup/attachments', () => {
|
||||||
// but there will be a backupLocator
|
// but there will be a backupLocator
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
|
...omit(attachment, [
|
||||||
|
'path',
|
||||||
|
'iv',
|
||||||
|
'thumbnail',
|
||||||
|
'uploadTimestamp',
|
||||||
|
]),
|
||||||
backupLocator: { mediaName: attachment.digest },
|
backupLocator: { mediaName: attachment.digest },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -148,7 +163,12 @@ describe('backup/attachments', () => {
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
attachments: [
|
attachments: [
|
||||||
{
|
{
|
||||||
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
|
...omit(attachment, [
|
||||||
|
'path',
|
||||||
|
'iv',
|
||||||
|
'thumbnail',
|
||||||
|
'uploadTimestamp',
|
||||||
|
]),
|
||||||
backupLocator: { mediaName: attachment.digest },
|
backupLocator: { mediaName: attachment.digest },
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
|
@ -173,7 +193,11 @@ describe('backup/attachments', () => {
|
||||||
[
|
[
|
||||||
composeMessage(1, {
|
composeMessage(1, {
|
||||||
preview: [
|
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: {
|
image: {
|
||||||
// path, iv, and uploadTimestamp will not be roundtripped,
|
// path, iv, and uploadTimestamp will not be roundtripped,
|
||||||
// but there will be a backupLocator
|
// but there will be a backupLocator
|
||||||
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
|
...omit(attachment, [
|
||||||
|
'path',
|
||||||
|
'iv',
|
||||||
|
'thumbnail',
|
||||||
|
'uploadTimestamp',
|
||||||
|
]),
|
||||||
backupLocator: { mediaName: attachment.digest },
|
backupLocator: { mediaName: attachment.digest },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -237,7 +266,7 @@ describe('backup/attachments', () => {
|
||||||
contact: [
|
contact: [
|
||||||
{
|
{
|
||||||
avatar: {
|
avatar: {
|
||||||
avatar: omit(attachment, ['path', 'iv']),
|
avatar: omit(attachment, ['path', 'iv', 'thumbnail']),
|
||||||
isProfile: false,
|
isProfile: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -265,7 +294,12 @@ describe('backup/attachments', () => {
|
||||||
{
|
{
|
||||||
avatar: {
|
avatar: {
|
||||||
avatar: {
|
avatar: {
|
||||||
...omit(attachment, ['path', 'iv', 'uploadTimestamp']),
|
...omit(attachment, [
|
||||||
|
'path',
|
||||||
|
'iv',
|
||||||
|
'thumbnail',
|
||||||
|
'uploadTimestamp',
|
||||||
|
]),
|
||||||
backupLocator: { mediaName: attachment.digest },
|
backupLocator: { mediaName: attachment.digest },
|
||||||
},
|
},
|
||||||
isProfile: false,
|
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
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
@ -1073,6 +1073,13 @@ export function isDownloadableFromBackupTier(
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function isDownloadable(attachment: AttachmentType): boolean {
|
||||||
|
return (
|
||||||
|
isDownloadableFromTransitTier(attachment) ||
|
||||||
|
isDownloadableFromBackupTier(attachment)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function isAttachmentLocallySaved(
|
export function isAttachmentLocallySaved(
|
||||||
attachment: AttachmentType
|
attachment: AttachmentType
|
||||||
): attachment is LocallySavedAttachment {
|
): attachment is LocallySavedAttachment {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue