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 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<HTMLButtonElement>) {
|
||||
// This is important to ensure that using this quote to navigate to the referenced
|
||||
|
|
|
@ -789,17 +789,17 @@ async function uploadMessageQuote({
|
|||
loadedQuote.attachments.map(
|
||||
attachment => async (): Promise<OutgoingQuoteAttachmentType> => {
|
||||
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,
|
||||
|
|
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 {
|
||||
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<MessageAttributesType, 'type' | 'source' | 'sourceServiceId'>
|
||||
): string | undefined {
|
||||
|
|
|
@ -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<MessageAttributesType> {
|
|||
|
||||
// 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<MessageAttributesType> {
|
|||
`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<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(
|
||||
initialMessage: ProcessedDataMessage,
|
||||
confirm: () => void,
|
||||
|
@ -1911,7 +1732,9 @@ export class MessageModel extends window.Backbone.Model<MessageAttributesType> {
|
|||
}
|
||||
|
||||
const [quote, storyQuotes] = await Promise.all([
|
||||
this.copyFromQuotedMessage(initialMessage.quote, conversation.id),
|
||||
initialMessage.quote
|
||||
? copyFromQuotedMessage(initialMessage.quote, conversation.id)
|
||||
: undefined,
|
||||
findStoryMessages(conversation.id, storyContext),
|
||||
]);
|
||||
|
||||
|
|
|
@ -1627,15 +1627,19 @@ export class BackupExportStream extends Readable {
|
|||
return groupUpdate;
|
||||
}
|
||||
|
||||
private async toQuote(
|
||||
quote?: QuotedMessageType
|
||||
): Promise<Backups.IQuote | null> {
|
||||
private async toQuote({
|
||||
quote,
|
||||
backupLevel,
|
||||
messageReceivedAt,
|
||||
}: {
|
||||
quote?: QuotedMessageType;
|
||||
backupLevel: BackupLevel;
|
||||
messageReceivedAt: number;
|
||||
}): Promise<Backups.IQuote | null> {
|
||||
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<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)),
|
||||
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 => {
|
||||
|
|
|
@ -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<MessageAttributesType> {
|
||||
// TODO (DESKTOP-6964): Quote, link preview
|
||||
private async fromStandardMessage(
|
||||
data: Backups.IStandardMessage,
|
||||
conversationId: string
|
||||
): Promise<Partial<MessageAttributesType>> {
|
||||
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<Backups.IChatItem>
|
||||
): Array<EditHistoryType> {
|
||||
const result = revisions
|
||||
.map(rev => {
|
||||
strictAssert(
|
||||
rev.standardMessage,
|
||||
'Edit history has non-standard messages'
|
||||
);
|
||||
): Promise<Array<EditHistoryType>> {
|
||||
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<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(
|
||||
reactions: ReadonlyArray<Backups.IReaction> | null | 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!
|
||||
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`);
|
||||
|
|
|
@ -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
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
@ -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 {
|
||||
|
|
Loading…
Add table
Reference in a new issue