Attachment backfill
This commit is contained in:
@@ -1602,6 +1602,18 @@
|
||||
"messageformat": "Can’t download view once media. {name} will need to send it again.",
|
||||
"description": "Body text for info dialog for messages with old view once media which are no longer available for download."
|
||||
},
|
||||
"icu:BackfillFailureModal__title": {
|
||||
"messageformat": "Can’t download media",
|
||||
"description": "Title for modal saying that message's attachments cannot be downloaded from the primary device"
|
||||
},
|
||||
"icu:BackfillFailureModal__body--timeout": {
|
||||
"messageformat": "Check your desktop and phone’s internet connection. Open Signal on your phone, then try downloading again.",
|
||||
"description": "Body for modal saying that message's attachments cannot be downloaded from the primary device due to timeout"
|
||||
},
|
||||
"icu:BackfillFailureModal__body--not-found": {
|
||||
"messageformat": "This media is no longer available on your phone and can’t be downloaded.",
|
||||
"description": "Body for modal saying that message's attachments cannot be downloaded from the primary device due to message not being available anymore"
|
||||
},
|
||||
"icu:save": {
|
||||
"messageformat": "Save",
|
||||
"description": "Used on save buttons"
|
||||
|
@@ -222,7 +222,7 @@
|
||||
"@indutny/parallel-prettier": "3.0.0",
|
||||
"@indutny/rezip-electron": "2.0.1",
|
||||
"@napi-rs/canvas": "0.1.61",
|
||||
"@signalapp/mock-server": "11.1.0",
|
||||
"@signalapp/mock-server": "11.2.0",
|
||||
"@storybook/addon-a11y": "8.4.4",
|
||||
"@storybook/addon-actions": "8.4.4",
|
||||
"@storybook/addon-controls": "8.4.4",
|
||||
|
10
pnpm-lock.yaml
generated
10
pnpm-lock.yaml
generated
@@ -435,8 +435,8 @@ importers:
|
||||
specifier: 0.1.61
|
||||
version: 0.1.61
|
||||
'@signalapp/mock-server':
|
||||
specifier: 11.1.0
|
||||
version: 11.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||
specifier: 11.2.0
|
||||
version: 11.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||
'@storybook/addon-a11y':
|
||||
specifier: 8.4.4
|
||||
version: 8.4.4(storybook@8.4.4(bufferutil@4.0.9)(prettier@3.3.3)(utf-8-validate@5.0.10))
|
||||
@@ -2543,8 +2543,8 @@ packages:
|
||||
'@signalapp/libsignal-client@0.67.4':
|
||||
resolution: {integrity: sha512-nenGxomG2zH0uCoFSwBzofqSAHnJRdbIbLr8libGy9y3rCL2z62nHL79Kh1o46ZnzxgAA7Ay3/qMhwPcXq7Iig==}
|
||||
|
||||
'@signalapp/mock-server@11.1.0':
|
||||
resolution: {integrity: sha512-SYYek3QCh57vZZGNVQW+fSXMr+xHjnO8sMAFfj8DovjqW4HIBWbt3itGyyjxSoIXnaCMK346O6I/R79w8xY/aw==}
|
||||
'@signalapp/mock-server@11.2.0':
|
||||
resolution: {integrity: sha512-y8bueRcXVulyXRRVm2M/qT7YmxGpUbiwQsRSi7a+DDI4aUeZIDW9z7KgjElv1CN1/n9O6M1bYO+TLy4ys+7U6w==}
|
||||
|
||||
'@signalapp/parchment-cjs@3.0.1':
|
||||
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
|
||||
@@ -12260,7 +12260,7 @@ snapshots:
|
||||
type-fest: 4.26.1
|
||||
uuid: 8.3.2
|
||||
|
||||
'@signalapp/mock-server@11.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
|
||||
'@signalapp/mock-server@11.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)':
|
||||
dependencies:
|
||||
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
|
||||
'@signalapp/libsignal-client': 0.60.2
|
||||
|
@@ -631,22 +631,6 @@ message SyncMessage {
|
||||
}
|
||||
|
||||
message DeleteForMe {
|
||||
message ConversationIdentifier {
|
||||
oneof identifier {
|
||||
string threadServiceId = 1;
|
||||
bytes threadGroupId = 2;
|
||||
string threadE164 = 3;
|
||||
}
|
||||
}
|
||||
|
||||
message AddressableMessage {
|
||||
oneof author {
|
||||
string authorServiceId = 1;
|
||||
string authorE164 = 2;
|
||||
}
|
||||
optional uint64 sentTimestamp = 3;
|
||||
}
|
||||
|
||||
message MessageDeletes {
|
||||
optional ConversationIdentifier conversation = 1;
|
||||
repeated AddressableMessage messages = 2;
|
||||
@@ -685,6 +669,42 @@ message SyncMessage {
|
||||
optional uint32 deviceId = 2;
|
||||
}
|
||||
|
||||
message AttachmentBackfillRequest {
|
||||
optional AddressableMessage targetMessage = 1;
|
||||
optional ConversationIdentifier targetConversation = 2;
|
||||
}
|
||||
|
||||
message AttachmentBackfillResponse {
|
||||
message AttachmentData {
|
||||
enum Status {
|
||||
PENDING = 0;
|
||||
TERMINAL_ERROR = 1;
|
||||
}
|
||||
|
||||
oneof data {
|
||||
AttachmentPointer attachment = 1;
|
||||
Status status = 2;
|
||||
}
|
||||
}
|
||||
|
||||
enum Error {
|
||||
MESSAGE_NOT_FOUND = 0;
|
||||
}
|
||||
|
||||
message AttachmentDataList {
|
||||
repeated AttachmentData attachments = 1;
|
||||
optional AttachmentData longText = 2;
|
||||
}
|
||||
|
||||
optional AddressableMessage targetMessage = 1;
|
||||
optional ConversationIdentifier targetConversation = 2;
|
||||
|
||||
oneof data {
|
||||
AttachmentDataList attachments = 3;
|
||||
Error error = 4;
|
||||
}
|
||||
}
|
||||
|
||||
optional Sent sent = 1;
|
||||
optional Contacts contacts = 2;
|
||||
reserved /*groups*/ 3;
|
||||
@@ -707,6 +727,8 @@ message SyncMessage {
|
||||
optional CallLogEvent callLogEvent = 21;
|
||||
optional DeleteForMe deleteForMe = 22;
|
||||
optional DeviceNameChange deviceNameChange = 23;
|
||||
optional AttachmentBackfillRequest attachmentBackfillRequest = 24;
|
||||
optional AttachmentBackfillResponse attachmentBackfillResponse = 25;
|
||||
}
|
||||
|
||||
message AttachmentPointer {
|
||||
@@ -807,3 +829,19 @@ message BodyRange {
|
||||
Style style = 4;
|
||||
}
|
||||
}
|
||||
|
||||
message AddressableMessage {
|
||||
oneof author {
|
||||
string authorServiceId = 1;
|
||||
string authorE164 = 2;
|
||||
}
|
||||
optional uint64 sentTimestamp = 3;
|
||||
}
|
||||
|
||||
message ConversationIdentifier {
|
||||
oneof identifier {
|
||||
string threadServiceId = 1;
|
||||
bytes threadGroupId = 2;
|
||||
string threadE164 = 3;
|
||||
}
|
||||
}
|
||||
|
22
stylesheets/components/BackfillFailureModal.scss
Normal file
22
stylesheets/components/BackfillFailureModal.scss
Normal file
@@ -0,0 +1,22 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
@use '../mixins';
|
||||
@use '../variables';
|
||||
|
||||
.BackfillFailureModal__width-container {
|
||||
max-width: 440px;
|
||||
}
|
||||
|
||||
.BackfillFailureModal__body {
|
||||
padding-block: 16px 0;
|
||||
padding-inline: 16px;
|
||||
}
|
||||
|
||||
.BackfillFailureModal .module-Button {
|
||||
padding-inline: 24px;
|
||||
}
|
||||
|
||||
.BackfillFailureModal .module-Modal__button-footer {
|
||||
padding-block-start: 18px;
|
||||
}
|
@@ -34,6 +34,7 @@
|
||||
@use 'components/AvatarModalButtons.scss';
|
||||
@use 'components/AvatarPreview.scss';
|
||||
@use 'components/AvatarTextEditor.scss';
|
||||
@use 'components/BackfillFailureModal.scss';
|
||||
@use 'components/BackupMediaDownloadProgress.scss';
|
||||
@use 'components/BadgeCarouselIndex.scss';
|
||||
@use 'components/BadgeDialog.scss';
|
||||
|
@@ -76,6 +76,7 @@ import { LatestQueue } from './util/LatestQueue';
|
||||
import { parseIntOrThrow } from './util/parseIntOrThrow';
|
||||
import { getProfile } from './util/getProfile';
|
||||
import type {
|
||||
AttachmentBackfillResponseSyncEvent,
|
||||
ConfigurationEvent,
|
||||
DeliveryEvent,
|
||||
EnvelopeQueuedEvent,
|
||||
@@ -721,6 +722,10 @@ export async function startApp(): Promise<void> {
|
||||
'deleteForMeSync',
|
||||
queuedEventListener(onDeleteForMeSync, false)
|
||||
);
|
||||
messageReceiver.addEventListener(
|
||||
'attachmentBackfillResponseSync',
|
||||
queuedEventListener(onAttachmentBackfillResponseSync, false)
|
||||
);
|
||||
messageReceiver.addEventListener(
|
||||
'deviceNameChangeSync',
|
||||
queuedEventListener(onDeviceNameChangeSync, false)
|
||||
@@ -1917,6 +1922,7 @@ export async function startApp(): Promise<void> {
|
||||
deleteSync: true,
|
||||
versionedExpirationTimer: true,
|
||||
ssre2: true,
|
||||
attachmentBackfill: true,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(
|
||||
@@ -3689,6 +3695,13 @@ export async function startApp(): Promise<void> {
|
||||
|
||||
log.info(`${logId}: Done`);
|
||||
}
|
||||
async function onAttachmentBackfillResponseSync(
|
||||
ev: AttachmentBackfillResponseSyncEvent
|
||||
) {
|
||||
const { confirm } = ev;
|
||||
await AttachmentDownloadManager.handleBackfillResponse(ev);
|
||||
confirm();
|
||||
}
|
||||
}
|
||||
|
||||
window.startApp = startApp;
|
||||
|
31
ts/components/BackfillFailureModal.stories.tsx
Normal file
31
ts/components/BackfillFailureModal.stories.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import React from 'react';
|
||||
import { action } from '@storybook/addon-actions';
|
||||
import type { PropsType } from './BackfillFailureModal';
|
||||
import {
|
||||
BackfillFailureModal,
|
||||
BackfillFailureKind,
|
||||
} from './BackfillFailureModal';
|
||||
import type { ComponentMeta } from '../storybook/types';
|
||||
|
||||
const { i18n } = window.SignalContext;
|
||||
|
||||
export default {
|
||||
title: 'Components/BackfillFailureModal',
|
||||
component: BackfillFailureModal,
|
||||
args: {
|
||||
i18n,
|
||||
onClose: action('onClose'),
|
||||
kind: BackfillFailureKind.Timeout,
|
||||
},
|
||||
} satisfies ComponentMeta<PropsType>;
|
||||
|
||||
export function Timeout(args: PropsType): JSX.Element {
|
||||
return <BackfillFailureModal {...args} />;
|
||||
}
|
||||
|
||||
export function NotFound(args: PropsType): JSX.Element {
|
||||
return <BackfillFailureModal {...args} kind={BackfillFailureKind.NotFound} />;
|
||||
}
|
64
ts/components/BackfillFailureModal.tsx
Normal file
64
ts/components/BackfillFailureModal.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import * as React from 'react';
|
||||
|
||||
import type { LocalizerType } from '../types/Util';
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { Modal } from './Modal';
|
||||
import { Button, ButtonVariant } from './Button';
|
||||
|
||||
export enum BackfillFailureKind {
|
||||
Timeout = 'Timeout',
|
||||
NotFound = 'NotFound',
|
||||
}
|
||||
|
||||
export type DataPropsType = Readonly<{
|
||||
kind: BackfillFailureKind;
|
||||
}>;
|
||||
|
||||
export type HousekeepingPropsType = Readonly<{
|
||||
i18n: LocalizerType;
|
||||
onClose: () => void;
|
||||
}>;
|
||||
|
||||
export type PropsType = DataPropsType & HousekeepingPropsType;
|
||||
|
||||
function focusRef(el: HTMLElement | null) {
|
||||
if (el) {
|
||||
el.focus();
|
||||
}
|
||||
}
|
||||
|
||||
export function BackfillFailureModal(props: PropsType): JSX.Element {
|
||||
const { i18n, kind, onClose } = props;
|
||||
|
||||
const footer = (
|
||||
<Button onClick={onClose} ref={focusRef} variant={ButtonVariant.Primary}>
|
||||
{i18n('icu:Confirmation--confirm')}
|
||||
</Button>
|
||||
);
|
||||
|
||||
let body: string;
|
||||
if (kind === BackfillFailureKind.Timeout) {
|
||||
body = i18n('icu:BackfillFailureModal__body--timeout');
|
||||
} else if (kind === BackfillFailureKind.NotFound) {
|
||||
body = i18n('icu:BackfillFailureModal__body--not-found');
|
||||
} else {
|
||||
throw missingCaseError(kind);
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
modalName="BackfillFailureModal"
|
||||
moduleClassName="BackfillFailureModal"
|
||||
title={i18n('icu:BackfillFailureModal__title')}
|
||||
i18n={i18n}
|
||||
onClose={onClose}
|
||||
modalFooter={footer}
|
||||
padded={false}
|
||||
>
|
||||
<div className="module-error-modal__description">{body}</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
@@ -27,6 +27,10 @@ import {
|
||||
TapToViewNotAvailableModal,
|
||||
type DataPropsType as TapToViewNotAvailablePropsType,
|
||||
} from './TapToViewNotAvailableModal';
|
||||
import {
|
||||
BackfillFailureModal,
|
||||
type DataPropsType as BackfillFailureModalPropsType,
|
||||
} from './BackfillFailureModal';
|
||||
|
||||
// NOTE: All types should be required for this component so that the smart
|
||||
// component gives you type errors when adding/removing props.
|
||||
@@ -124,6 +128,9 @@ export type PropsType = {
|
||||
// TapToViewNotAvailableModal
|
||||
tapToViewNotAvailableModalProps: TapToViewNotAvailablePropsType | undefined;
|
||||
hideTapToViewNotAvailableModal: () => void;
|
||||
// BackfillFailureModal
|
||||
backfillFailureModalProps: BackfillFailureModalPropsType | undefined;
|
||||
hideBackfillFailureModal: () => void;
|
||||
// UserNotFoundModal
|
||||
hideUserNotFoundModal: () => unknown;
|
||||
userNotFoundModalState: UserNotFoundModalStateType | undefined;
|
||||
@@ -214,6 +221,9 @@ export function GlobalModalContainer({
|
||||
// TapToViewNotAvailableModal
|
||||
tapToViewNotAvailableModalProps,
|
||||
hideTapToViewNotAvailableModal,
|
||||
// BackfillFailureModal
|
||||
backfillFailureModalProps,
|
||||
hideBackfillFailureModal,
|
||||
// UserNotFoundModal
|
||||
hideUserNotFoundModal,
|
||||
userNotFoundModalState,
|
||||
@@ -394,5 +404,15 @@ export function GlobalModalContainer({
|
||||
);
|
||||
}
|
||||
|
||||
if (backfillFailureModalProps != null) {
|
||||
return (
|
||||
<BackfillFailureModal
|
||||
i18n={i18n}
|
||||
onClose={hideBackfillFailureModal}
|
||||
{...backfillFailureModalProps}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
@@ -1205,6 +1205,11 @@ export class Message extends React.PureComponent<Props, State> {
|
||||
this.openGenericAttachment();
|
||||
}}
|
||||
tabIndex={tabIndex}
|
||||
aria-label={
|
||||
isDownloading(firstAttachment)
|
||||
? i18n('icu:cancelDownload')
|
||||
: i18n('icu:startDownload')
|
||||
}
|
||||
>
|
||||
<AttachmentStatusIcon
|
||||
key={id}
|
||||
|
@@ -4,10 +4,12 @@ import { noop, omit, throttle } from 'lodash';
|
||||
|
||||
import * as durations from '../util/durations';
|
||||
import * as log from '../logging/log';
|
||||
import type { AttachmentBackfillResponseSyncEvent } from '../textsecure/messageReceiverEvents';
|
||||
import {
|
||||
type AttachmentDownloadJobTypeType,
|
||||
type AttachmentDownloadJobType,
|
||||
type CoreAttachmentDownloadJobType,
|
||||
AttachmentDownloadUrgency,
|
||||
coreAttachmentDownloadJobSchema,
|
||||
} from '../types/AttachmentDownload';
|
||||
import {
|
||||
@@ -24,6 +26,7 @@ import {
|
||||
AttachmentVariant,
|
||||
mightBeOnBackupTier,
|
||||
} from '../types/Attachment';
|
||||
import { type ReadonlyMessageAttributesType } from '../model-types.d';
|
||||
import { getMessageById } from '../messages/getMessageById';
|
||||
import {
|
||||
KIBIBYTE,
|
||||
@@ -53,13 +56,9 @@ import {
|
||||
import { safeParsePartial } from '../util/schemas';
|
||||
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue';
|
||||
import { createBatcher } from '../util/batcher';
|
||||
import { isOlderThan } from '../util/timestamp';
|
||||
import { ToastType } from '../types/Toast';
|
||||
|
||||
export enum AttachmentDownloadUrgency {
|
||||
IMMEDIATE = 'immediate',
|
||||
STANDARD = 'standard',
|
||||
}
|
||||
import { showDownloadFailedToast } from '../util/showDownloadFailedToast';
|
||||
import { markAttachmentAsPermanentlyErrored } from '../util/attachments/markAttachmentAsPermanentlyErrored';
|
||||
import { AttachmentBackfill } from './helpers/attachmentBackfill';
|
||||
|
||||
// Type for adding a new job
|
||||
export type NewAttachmentDownloadJobType = {
|
||||
@@ -74,7 +73,6 @@ export type NewAttachmentDownloadJobType = {
|
||||
};
|
||||
|
||||
const MAX_CONCURRENT_JOBS = 3;
|
||||
const DOWNLOAD_FAILED_TIMESTAMP_REST = 10 * durations.SECOND;
|
||||
|
||||
const DEFAULT_RETRY_CONFIG = {
|
||||
maxAttempts: 5,
|
||||
@@ -134,6 +132,8 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
|
||||
},
|
||||
});
|
||||
|
||||
#attachmentBackfill = new AttachmentBackfill();
|
||||
|
||||
private static _instance: AttachmentDownloadManager | undefined;
|
||||
override logPrefix = 'AttachmentDownloadManager';
|
||||
|
||||
@@ -310,6 +310,18 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
|
||||
callback();
|
||||
}
|
||||
}
|
||||
|
||||
static async requestBackfill(
|
||||
message: ReadonlyMessageAttributesType
|
||||
): Promise<void> {
|
||||
return this.instance.#attachmentBackfill.request(message);
|
||||
}
|
||||
|
||||
static async handleBackfillResponse(
|
||||
event: AttachmentBackfillResponseSyncEvent
|
||||
): Promise<void> {
|
||||
return this.instance.#attachmentBackfill.handleResponse(event);
|
||||
}
|
||||
}
|
||||
|
||||
type DependenciesType = {
|
||||
@@ -412,9 +424,21 @@ async function runDownloadAttachmentJob({
|
||||
}
|
||||
|
||||
if (error instanceof AttachmentPermanentlyUndownloadableError) {
|
||||
if (
|
||||
job.isManualDownload &&
|
||||
job.source !== AttachmentDownloadSource.BACKFILL &&
|
||||
AttachmentBackfill.isEnabledForJob(
|
||||
job.attachmentType,
|
||||
message.attributes
|
||||
)
|
||||
) {
|
||||
await AttachmentDownloadManager.requestBackfill(message.attributes);
|
||||
return { status: 'finished' };
|
||||
}
|
||||
|
||||
await addAttachmentToMessage(
|
||||
message.id,
|
||||
_markAttachmentAsPermanentlyErrored(job.attachment),
|
||||
markAttachmentAsPermanentlyErrored(job.attachment),
|
||||
logId,
|
||||
{ type: job.attachmentType }
|
||||
);
|
||||
@@ -654,7 +678,26 @@ export async function runDownloadAttachmentJobInner({
|
||||
}
|
||||
}
|
||||
|
||||
let showToast = false;
|
||||
|
||||
// Show toast if manual download failed
|
||||
if (!abortSignal.aborted && job.isManualDownload) {
|
||||
if (job.source === AttachmentDownloadSource.BACKFILL) {
|
||||
// ...and it was already a backfill request
|
||||
showToast = true;
|
||||
} else {
|
||||
// ...or we didn't backfill the download
|
||||
const message = await getMessageById(job.messageId);
|
||||
showToast =
|
||||
message != null &&
|
||||
!AttachmentBackfill.isEnabledForJob(
|
||||
attachmentType,
|
||||
message.attributes
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
showDownloadFailedToast(messageId);
|
||||
}
|
||||
|
||||
@@ -662,27 +705,6 @@ export async function runDownloadAttachmentJobInner({
|
||||
}
|
||||
}
|
||||
|
||||
export const lastErrorsByMessageId = new Map<string, number>();
|
||||
|
||||
function showDownloadFailedToast(messageId: string): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [id, timestamp] of lastErrorsByMessageId) {
|
||||
if (isOlderThan(timestamp, DOWNLOAD_FAILED_TIMESTAMP_REST)) {
|
||||
lastErrorsByMessageId.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = lastErrorsByMessageId.get(messageId);
|
||||
if (!existing) {
|
||||
window.reduxActions.toast.showToast({
|
||||
toastType: ToastType.AttachmentDownloadFailed,
|
||||
parameters: { messageId },
|
||||
});
|
||||
lastErrorsByMessageId.set(messageId, now);
|
||||
}
|
||||
}
|
||||
|
||||
async function downloadBackupThumbnail({
|
||||
attachment,
|
||||
abortSignal,
|
||||
@@ -711,17 +733,11 @@ async function downloadBackupThumbnail({
|
||||
|
||||
function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType {
|
||||
return {
|
||||
..._markAttachmentAsPermanentlyErrored(attachment),
|
||||
...markAttachmentAsPermanentlyErrored(attachment),
|
||||
wasTooBig: true,
|
||||
};
|
||||
}
|
||||
|
||||
function _markAttachmentAsPermanentlyErrored(
|
||||
attachment: AttachmentType
|
||||
): AttachmentType {
|
||||
return { ...omit(attachment, ['key', 'id']), pending: false, error: true };
|
||||
}
|
||||
|
||||
function _markAttachmentAsTransientlyErrored(
|
||||
attachment: AttachmentType
|
||||
): AttachmentType {
|
||||
|
434
ts/jobs/helpers/attachmentBackfill.ts
Normal file
434
ts/jobs/helpers/attachmentBackfill.ts
Normal file
@@ -0,0 +1,434 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { AttachmentBackfillResponseSyncEvent } from '../../textsecure/messageReceiverEvents';
|
||||
import MessageSender from '../../textsecure/SendMessage';
|
||||
import * as log from '../../logging/log';
|
||||
import type { ReadonlyMessageAttributesType } from '../../model-types.d';
|
||||
import {
|
||||
type AttachmentType,
|
||||
isDownloading,
|
||||
isDownloaded,
|
||||
} from '../../types/Attachment';
|
||||
import {
|
||||
type AttachmentDownloadJobTypeType,
|
||||
AttachmentDownloadUrgency,
|
||||
} from '../../types/AttachmentDownload';
|
||||
import { AttachmentDownloadSource } from '../../sql/Interface';
|
||||
import { APPLICATION_OCTET_STREAM } from '../../types/MIME';
|
||||
import {
|
||||
getConversationIdentifier,
|
||||
getAddressableMessage,
|
||||
getConversationFromTarget,
|
||||
getMessageQueryFromTarget,
|
||||
findMatchingMessage,
|
||||
} from '../../util/syncIdentifiers';
|
||||
import { strictAssert } from '../../util/assert';
|
||||
import { drop } from '../../util/drop';
|
||||
import { missingCaseError } from '../../util/missingCaseError';
|
||||
import { isStagingServer } from '../../util/isStagingServer';
|
||||
import {
|
||||
ensureBodyAttachmentsAreSeparated,
|
||||
queueAttachmentDownloadsForMessage,
|
||||
} from '../../util/queueAttachmentDownloads';
|
||||
import { SECOND } from '../../util/durations';
|
||||
import { showDownloadFailedToast } from '../../util/showDownloadFailedToast';
|
||||
import { markAttachmentAsPermanentlyErrored } from '../../util/attachments/markAttachmentAsPermanentlyErrored';
|
||||
import { singleProtoJobQueue } from '../singleProtoJobQueue';
|
||||
import { MessageModel } from '../../models/messages';
|
||||
import { getMessageById } from '../../messages/getMessageById';
|
||||
import { addAttachmentToMessage } from '../../messageModifiers/AttachmentDownloads';
|
||||
import { SignalService as Proto } from '../../protobuf';
|
||||
import * as RemoteConfig from '../../RemoteConfig';
|
||||
import { isTestOrMockEnvironment } from '../../environment';
|
||||
import { BackfillFailureKind } from '../../components/BackfillFailureModal';
|
||||
|
||||
const REQUEST_TIMEOUT = isTestOrMockEnvironment() ? 5 * SECOND : 10 * SECOND;
|
||||
|
||||
const PLACEHOLDER_ATTACHMENT: AttachmentType = {
|
||||
error: true,
|
||||
contentType: APPLICATION_OCTET_STREAM,
|
||||
size: 0,
|
||||
};
|
||||
|
||||
function isBackfillEnabled(): boolean {
|
||||
if (isStagingServer() || isTestOrMockEnvironment()) {
|
||||
return true;
|
||||
}
|
||||
if (RemoteConfig.isEnabled('desktop.internalUser')) {
|
||||
return true;
|
||||
}
|
||||
const ourConversation = window.ConversationController.getOurConversation();
|
||||
return ourConversation?.get('capabilities')?.attachmentBackfill === true;
|
||||
}
|
||||
|
||||
export class AttachmentBackfill {
|
||||
#pendingRequests = new Map<
|
||||
ReadonlyMessageAttributesType['id'],
|
||||
NodeJS.Timeout
|
||||
>();
|
||||
|
||||
public async request(message: ReadonlyMessageAttributesType): Promise<void> {
|
||||
const existingTimer = this.#pendingRequests.get(message.id);
|
||||
if (existingTimer != null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const addr = getAddressableMessage(message);
|
||||
if (addr == null) {
|
||||
throw new Error('No address for message');
|
||||
}
|
||||
|
||||
const convo = window.ConversationController.get(message.conversationId);
|
||||
strictAssert(convo != null, 'Missing message conversation');
|
||||
|
||||
this.#pendingRequests.set(
|
||||
message.id,
|
||||
setTimeout(() => drop(this.#onTimeout(message.id)), REQUEST_TIMEOUT)
|
||||
);
|
||||
|
||||
await singleProtoJobQueue.add(
|
||||
MessageSender.getAttachmentBackfillSyncMessage(
|
||||
getConversationIdentifier(convo.attributes),
|
||||
addr
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
public async handleResponse({
|
||||
timestamp,
|
||||
response,
|
||||
}: AttachmentBackfillResponseSyncEvent): Promise<void> {
|
||||
const logId = `onAttachmentBackfillResponseSync(${timestamp})`;
|
||||
if (!isBackfillEnabled()) {
|
||||
log.info(`${logId}: disabled`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`${logId}: start`);
|
||||
|
||||
const {
|
||||
Error: ErrorEnum,
|
||||
AttachmentData: { Status },
|
||||
} = Proto.SyncMessage.AttachmentBackfillResponse;
|
||||
|
||||
if ('error' in response) {
|
||||
if (response.error === ErrorEnum.MESSAGE_NOT_FOUND) {
|
||||
window.reduxActions.globalModals.showBackfillFailureModal({
|
||||
kind: BackfillFailureKind.NotFound,
|
||||
});
|
||||
} else {
|
||||
throw missingCaseError(response.error);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const { targetMessage, targetConversation } = response;
|
||||
|
||||
const convo = getConversationFromTarget(targetConversation);
|
||||
if (convo == null) {
|
||||
log.error(`${logId}: conversation not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const query = getMessageQueryFromTarget(targetMessage);
|
||||
const attributes = await findMatchingMessage(convo.id, query);
|
||||
if (attributes == null) {
|
||||
log.error(`${logId}: message not found`);
|
||||
return;
|
||||
}
|
||||
|
||||
const message = window.MessageCache.register(new MessageModel(attributes));
|
||||
|
||||
// IMPORTANT: no awaits until we finish modifying attachments
|
||||
|
||||
const timer = this.#pendingRequests.get(message.id);
|
||||
if (timer != null) {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
|
||||
// Since we are matching remote attachments with local attachments we need
|
||||
// to make sure things are normalized before starting.
|
||||
message.set(
|
||||
ensureBodyAttachmentsAreSeparated(message.attributes, {
|
||||
logId,
|
||||
logger: log,
|
||||
})
|
||||
);
|
||||
|
||||
// We will be potentially modifying these attachments below
|
||||
let updatedSticker = message.get('sticker');
|
||||
let updatedBodyAttachment = message.get('bodyAttachment');
|
||||
const updatedAttachments = message.get('attachments')?.slice() ?? [];
|
||||
let changeCount = 0;
|
||||
|
||||
// If `true` - show a toast at the end of the process
|
||||
let showToast = false;
|
||||
|
||||
// If `true` - queue downloads at the end of the process
|
||||
let shouldDownload = false;
|
||||
|
||||
// Track number of pending attachments to decide when the request is
|
||||
// fully processed by the phone.
|
||||
let pendingCount = 0;
|
||||
|
||||
const remoteAttachments = response.attachments.slice();
|
||||
if (updatedSticker?.data != null) {
|
||||
const remoteSticker = remoteAttachments.shift();
|
||||
if (remoteSticker == null) {
|
||||
log.error(`${logId}: no attachment for sticker`);
|
||||
return;
|
||||
}
|
||||
|
||||
const existing = updatedSticker.data;
|
||||
if (isDownloaded(existing)) {
|
||||
log.info(`${logId}: not updating, sticker downloaded`);
|
||||
} else if ('status' in remoteSticker) {
|
||||
if (remoteSticker.status === Status.PENDING) {
|
||||
pendingCount += 1;
|
||||
// Keep sticker as is
|
||||
} else if (remoteSticker.status === Status.TERMINAL_ERROR) {
|
||||
changeCount += 1;
|
||||
updatedSticker = {
|
||||
...updatedSticker,
|
||||
data: markAttachmentAsPermanentlyErrored(existing),
|
||||
};
|
||||
showToast = true;
|
||||
} else {
|
||||
throw missingCaseError(remoteSticker.status);
|
||||
}
|
||||
} else {
|
||||
// If the attachment is not in pending state - we got a response for
|
||||
// other device's backfill request. Update the CDN info without queueing
|
||||
// a download.
|
||||
if (isDownloading(updatedSticker.data)) {
|
||||
shouldDownload = true;
|
||||
}
|
||||
updatedSticker = {
|
||||
...updatedSticker,
|
||||
data: remoteSticker.attachment,
|
||||
};
|
||||
changeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Pad local attachments until they match remote
|
||||
let padding = 0;
|
||||
while (updatedAttachments.length < remoteAttachments.length) {
|
||||
updatedAttachments.push(PLACEHOLDER_ATTACHMENT);
|
||||
changeCount += 1;
|
||||
padding += 1;
|
||||
}
|
||||
|
||||
if (padding !== 0) {
|
||||
log.warn(`${logId}: padded with ${padding} attachments`);
|
||||
}
|
||||
|
||||
if (response.longText != null) {
|
||||
if (updatedBodyAttachment == null) {
|
||||
updatedBodyAttachment = PLACEHOLDER_ATTACHMENT;
|
||||
changeCount += 1;
|
||||
log.warn(`${logId}: padded with a body attachment`);
|
||||
}
|
||||
|
||||
if (isDownloaded(updatedBodyAttachment)) {
|
||||
log.info(`${logId}: not updating long body`);
|
||||
} else if ('status' in response.longText) {
|
||||
if (response.longText.status === Status.PENDING) {
|
||||
// Keep attachment as is
|
||||
pendingCount += 1;
|
||||
} else if (response.longText.status === Status.TERMINAL_ERROR) {
|
||||
changeCount += 1;
|
||||
updatedBodyAttachment = markAttachmentAsPermanentlyErrored(
|
||||
updatedBodyAttachment
|
||||
);
|
||||
showToast = true;
|
||||
} else {
|
||||
throw missingCaseError(response.longText.status);
|
||||
}
|
||||
} else {
|
||||
// See sticker handling code above for the reasoning
|
||||
if (isDownloading(updatedBodyAttachment)) {
|
||||
shouldDownload = true;
|
||||
}
|
||||
updatedBodyAttachment = response.longText.attachment;
|
||||
changeCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
for (const [index, entry] of remoteAttachments.entries()) {
|
||||
const existing = updatedAttachments[index];
|
||||
|
||||
if (isDownloaded(existing)) {
|
||||
log.info(`${logId}: not updating ${index}, downloaded`);
|
||||
continue;
|
||||
}
|
||||
|
||||
if ('status' in entry) {
|
||||
if (entry.status === Status.PENDING) {
|
||||
// Keep attachment as is
|
||||
pendingCount += 1;
|
||||
} else if (entry.status === Status.TERMINAL_ERROR) {
|
||||
showToast = true;
|
||||
|
||||
changeCount += 1;
|
||||
updatedAttachments[index] =
|
||||
markAttachmentAsPermanentlyErrored(existing);
|
||||
} else {
|
||||
throw missingCaseError(entry.status);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
changeCount += 1;
|
||||
|
||||
// See sticker handling code above for the reasoning
|
||||
if (isDownloading(existing)) {
|
||||
shouldDownload = true;
|
||||
}
|
||||
updatedAttachments[index] = entry.attachment;
|
||||
}
|
||||
|
||||
if (showToast) {
|
||||
log.warn(`${logId}: showing toast`);
|
||||
showDownloadFailedToast(message.id);
|
||||
}
|
||||
|
||||
if (pendingCount === 0) {
|
||||
log.info(`${logId}: no pending attachments, fulfilled`);
|
||||
this.#pendingRequests.delete(message.id);
|
||||
}
|
||||
|
||||
if (changeCount === 0) {
|
||||
log.info(`${logId}: no changes`);
|
||||
return;
|
||||
}
|
||||
|
||||
log.info(`${logId}: updating ${changeCount} attachments`);
|
||||
message.set({
|
||||
attachments: updatedAttachments,
|
||||
bodyAttachment: updatedBodyAttachment,
|
||||
sticker: updatedSticker,
|
||||
editHistory: message.get('editHistory')?.map(edit => ({
|
||||
...edit,
|
||||
attachments: updatedAttachments,
|
||||
bodyAttachment: updatedBodyAttachment,
|
||||
})),
|
||||
});
|
||||
|
||||
// It is fine to await below this line
|
||||
|
||||
if (shouldDownload) {
|
||||
log.info(`${logId}: queueing downloads`);
|
||||
await queueAttachmentDownloadsForMessage(message, {
|
||||
source: AttachmentDownloadSource.BACKFILL,
|
||||
urgency: AttachmentDownloadUrgency.IMMEDIATE,
|
||||
isManualDownload: true,
|
||||
});
|
||||
}
|
||||
|
||||
// Save after queueing because queuing might update attributes
|
||||
await window.MessageCache.saveMessage(message.attributes);
|
||||
}
|
||||
|
||||
public static isEnabledForJob(
|
||||
jobType: AttachmentDownloadJobTypeType,
|
||||
message: Pick<ReadonlyMessageAttributesType, 'type'>
|
||||
): boolean {
|
||||
if (message.type === 'story') {
|
||||
return false;
|
||||
}
|
||||
|
||||
switch (jobType) {
|
||||
// Supported
|
||||
case 'long-message':
|
||||
break;
|
||||
case 'attachment':
|
||||
break;
|
||||
case 'sticker':
|
||||
break;
|
||||
|
||||
// Not supported
|
||||
case 'contact':
|
||||
return false;
|
||||
case 'preview':
|
||||
return false;
|
||||
case 'quote':
|
||||
return false;
|
||||
|
||||
default:
|
||||
throw missingCaseError(jobType);
|
||||
}
|
||||
|
||||
return isBackfillEnabled();
|
||||
}
|
||||
|
||||
async #onTimeout(messageId: string): Promise<void> {
|
||||
const message = await getMessageById(messageId);
|
||||
|
||||
this.#pendingRequests.delete(messageId);
|
||||
|
||||
// Message already removed
|
||||
if (message == null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const logId = `attachmentBackfill.onTimeout(${message.get('sent_at')})`;
|
||||
log.info(`${logId}: onTimeout`);
|
||||
|
||||
const bodyAttachment = message.get('bodyAttachment');
|
||||
if (bodyAttachment != null && isDownloading(bodyAttachment)) {
|
||||
log.info(`${logId}: clearing long text download`);
|
||||
await addAttachmentToMessage(
|
||||
message.id,
|
||||
{
|
||||
...bodyAttachment,
|
||||
pending: false,
|
||||
},
|
||||
'attachmentBackfillTimeout',
|
||||
{ type: 'long-message' }
|
||||
);
|
||||
}
|
||||
|
||||
const sticker = message.get('sticker');
|
||||
if (sticker?.data != null && isDownloading(sticker.data)) {
|
||||
log.info(`${logId}: clearing long text download`);
|
||||
await addAttachmentToMessage(
|
||||
message.id,
|
||||
{
|
||||
...sticker.data,
|
||||
pending: false,
|
||||
},
|
||||
'attachmentBackfillTimeout',
|
||||
{ type: 'sticker' }
|
||||
);
|
||||
}
|
||||
|
||||
const pendingAttachments = (message.get('attachments') ?? []).filter(
|
||||
isDownloading
|
||||
);
|
||||
|
||||
if (pendingAttachments.length !== 0) {
|
||||
log.info(
|
||||
`${logId}: clearing attachment downloads ${pendingAttachments.length}`
|
||||
);
|
||||
await Promise.all(
|
||||
pendingAttachments.map(attachment => {
|
||||
return addAttachmentToMessage(
|
||||
message.id,
|
||||
{
|
||||
...attachment,
|
||||
pending: false,
|
||||
},
|
||||
'attachmentBackfillTimeout',
|
||||
{ type: 'attachment' }
|
||||
);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
window.reduxActions.globalModals.showBackfillFailureModal({
|
||||
kind: BackfillFailureKind.Timeout,
|
||||
});
|
||||
}
|
||||
}
|
@@ -8,29 +8,31 @@ import { getMessageSentTimestampSet } from '../util/getMessageSentTimestampSet';
|
||||
|
||||
import type { MessageAttributesType } from '../model-types';
|
||||
import type {
|
||||
ConversationToDelete,
|
||||
MessageToDelete,
|
||||
ConversationIdentifier,
|
||||
AddressableMessage,
|
||||
} from '../textsecure/messageReceiverEvents';
|
||||
import {
|
||||
deleteAttachmentFromMessage,
|
||||
deleteMessage,
|
||||
} from '../util/deleteForMe';
|
||||
import {
|
||||
doesMessageMatch,
|
||||
getConversationFromTarget,
|
||||
getMessageQueryFromTarget,
|
||||
} from '../util/deleteForMe';
|
||||
} from '../util/syncIdentifiers';
|
||||
import { DataWriter } from '../sql/Client';
|
||||
|
||||
const { removeSyncTaskById } = DataWriter;
|
||||
|
||||
export type DeleteForMeAttributesType = {
|
||||
conversation: ConversationToDelete;
|
||||
conversation: ConversationIdentifier;
|
||||
deleteAttachmentData?: {
|
||||
clientUuid?: string;
|
||||
fallbackDigest?: string;
|
||||
fallbackPlaintextHash?: string;
|
||||
};
|
||||
envelopeId: string;
|
||||
message: MessageToDelete;
|
||||
message: AddressableMessage;
|
||||
syncTaskId: string;
|
||||
timestamp: number;
|
||||
};
|
||||
|
@@ -179,11 +179,11 @@ import { getMessageAuthorText } from '../util/getMessageAuthorText';
|
||||
import { downscaleOutgoingAttachment } from '../util/attachments';
|
||||
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
|
||||
import { hasExpiration } from '../types/Message2';
|
||||
import type { MessageToDelete } from '../textsecure/messageReceiverEvents';
|
||||
import type { AddressableMessage } from '../textsecure/messageReceiverEvents';
|
||||
import {
|
||||
getConversationToDelete,
|
||||
getMessageToDelete,
|
||||
} from '../util/deleteForMe';
|
||||
getConversationIdentifier,
|
||||
getAddressableMessage,
|
||||
} from '../util/syncIdentifiers';
|
||||
import { explodePromise } from '../util/explodePromise';
|
||||
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
||||
import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus';
|
||||
@@ -5195,8 +5195,8 @@ export class ConversationModel extends window.Backbone
|
||||
const addressableMessages = await getMostRecentAddressableMessages(
|
||||
this.id
|
||||
);
|
||||
const mostRecentMessages: Array<MessageToDelete> = addressableMessages
|
||||
.map(getMessageToDelete)
|
||||
const mostRecentMessages: Array<AddressableMessage> = addressableMessages
|
||||
.map(getAddressableMessage)
|
||||
.filter(isNotNil)
|
||||
.slice(0, 5);
|
||||
log.info(
|
||||
@@ -5207,12 +5207,12 @@ export class ConversationModel extends window.Backbone
|
||||
item => item.expireTimer
|
||||
);
|
||||
|
||||
let mostRecentNonExpiringMessages: Array<MessageToDelete> | undefined;
|
||||
let mostRecentNonExpiringMessages: Array<AddressableMessage> | undefined;
|
||||
if (areAnyDisappearing) {
|
||||
const nondisappearingAddressableMessages =
|
||||
await getMostRecentAddressableNondisappearingMessages(this.id);
|
||||
mostRecentNonExpiringMessages = nondisappearingAddressableMessages
|
||||
.map(getMessageToDelete)
|
||||
.map(getAddressableMessage)
|
||||
.filter(isNotNil)
|
||||
.slice(0, 5);
|
||||
log.info(
|
||||
@@ -5225,7 +5225,7 @@ export class ConversationModel extends window.Backbone
|
||||
MessageSender.getDeleteForMeSyncMessage([
|
||||
{
|
||||
type: 'delete-conversation',
|
||||
conversation: getConversationToDelete(this.attributes),
|
||||
conversation: getConversationIdentifier(this.attributes),
|
||||
isFullDelete: true,
|
||||
mostRecentMessages,
|
||||
mostRecentNonExpiringMessages,
|
||||
@@ -5238,7 +5238,7 @@ export class ConversationModel extends window.Backbone
|
||||
MessageSender.getDeleteForMeSyncMessage([
|
||||
{
|
||||
type: 'delete-local-conversation',
|
||||
conversation: getConversationToDelete(this.attributes),
|
||||
conversation: getConversationIdentifier(this.attributes),
|
||||
timestamp,
|
||||
},
|
||||
])
|
||||
|
@@ -70,6 +70,7 @@ type JobType = {
|
||||
const OBSERVED_CAPABILITY_KEYS = Object.keys({
|
||||
deleteSync: true,
|
||||
ssre2: true,
|
||||
attachmentBackfill: true,
|
||||
} satisfies CapabilitiesType) as ReadonlyArray<keyof CapabilitiesType>;
|
||||
|
||||
export class ProfileService {
|
||||
|
@@ -532,6 +532,7 @@ export type GetRecentStoryRepliesOptionsType = {
|
||||
export enum AttachmentDownloadSource {
|
||||
BACKUP_IMPORT = 'backup_import',
|
||||
STANDARD = 'standard',
|
||||
BACKFILL = 'backfill',
|
||||
}
|
||||
|
||||
type ReadableInterface = {
|
||||
|
@@ -29,6 +29,7 @@ import { drop } from '../../util/drop';
|
||||
import type { DurationInSeconds } from '../../util/durations';
|
||||
import * as universalExpireTimer from '../../util/universalExpireTimer';
|
||||
import * as Attachment from '../../types/Attachment';
|
||||
import { AttachmentDownloadUrgency } from '../../types/AttachmentDownload';
|
||||
import { isFileDangerous } from '../../util/isFileDangerous';
|
||||
import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl';
|
||||
import { instance as libphonenumberInstance } from '../../util/libphonenumberInstance';
|
||||
@@ -191,18 +192,15 @@ import {
|
||||
} from '../../util/idForLogging';
|
||||
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
|
||||
import MessageSender from '../../textsecure/SendMessage';
|
||||
import {
|
||||
AttachmentDownloadManager,
|
||||
AttachmentDownloadUrgency,
|
||||
} from '../../jobs/AttachmentDownloadManager';
|
||||
import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager';
|
||||
import type {
|
||||
DeleteForMeSyncEventData,
|
||||
MessageToDelete,
|
||||
AddressableMessage,
|
||||
} from '../../textsecure/messageReceiverEvents';
|
||||
import {
|
||||
getConversationToDelete,
|
||||
getMessageToDelete,
|
||||
} from '../../util/deleteForMe';
|
||||
getConversationIdentifier,
|
||||
getAddressableMessage,
|
||||
} from '../../util/syncIdentifiers';
|
||||
import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types';
|
||||
import { markCallHistoryReadInConversation } from './callHistory';
|
||||
import type { CapabilitiesType } from '../../textsecure/WebAPI';
|
||||
@@ -1782,7 +1780,7 @@ function deleteMessages({
|
||||
const messages = (
|
||||
await Promise.all(
|
||||
messageIds.map(
|
||||
async (messageId): Promise<MessageToDelete | undefined> => {
|
||||
async (messageId): Promise<AddressableMessage | undefined> => {
|
||||
const message = await getMessageById(messageId);
|
||||
if (!message) {
|
||||
throw new Error(`deleteMessages: Message ${messageId} missing!`);
|
||||
@@ -1795,7 +1793,7 @@ function deleteMessages({
|
||||
);
|
||||
}
|
||||
|
||||
return getMessageToDelete(message.attributes);
|
||||
return getAddressableMessage(message.attributes);
|
||||
}
|
||||
)
|
||||
)
|
||||
@@ -1839,7 +1837,7 @@ function deleteMessages({
|
||||
}
|
||||
|
||||
const chunks = chunk(messages, MAX_MESSAGE_COUNT);
|
||||
const conversationToDelete = getConversationToDelete(
|
||||
const conversationToDelete = getConversationIdentifier(
|
||||
conversation.attributes
|
||||
);
|
||||
const timestamp = Date.now();
|
||||
|
@@ -58,6 +58,7 @@ import type { StartCallData } from '../../components/ConfirmLeaveCallModal';
|
||||
import { getMessageById } from '../../messages/getMessageById';
|
||||
import type { AttachmentNotAvailableModalType } from '../../components/AttachmentNotAvailableModal';
|
||||
import type { DataPropsType as TapToViewNotAvailablePropsType } from '../../components/TapToViewNotAvailableModal';
|
||||
import type { DataPropsType as BackfillFailureModalPropsType } from '../../components/BackfillFailureModal';
|
||||
|
||||
// State
|
||||
|
||||
@@ -102,6 +103,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
|
||||
addUserToAnotherGroupModalContactId?: string;
|
||||
aboutContactModalContactId?: string;
|
||||
attachmentNotAvailableModalType: AttachmentNotAvailableModalType | undefined;
|
||||
backfillFailureModalProps: BackfillFailureModalPropsType | undefined;
|
||||
callLinkAddNameModalRoomId: string | null;
|
||||
callLinkEditModalRoomId: string | null;
|
||||
callLinkPendingParticipantContactId: string | undefined;
|
||||
@@ -152,6 +154,8 @@ const SHOW_TAP_TO_VIEW_NOT_AVAILABLE_MODAL =
|
||||
'globalModals/SHOW_TAP_TO_VIEW_NOT_AVAILABLE_MODAL';
|
||||
const HIDE_TAP_TO_VIEW_NOT_AVAILABLE_MODAL =
|
||||
'globalModals/HIDE_TAP_TO_VIEW_NOT_AVAILABLE_MODAL';
|
||||
const SHOW_BACKFILL_FAILURE_MODAL = 'globalModals/SHOW_BACKFILL_FAILURE_MODAL';
|
||||
const HIDE_BACKFILL_FAILURE_MODAL = 'globalModals/HIDE_BACKFILL_FAILURE_MODAL';
|
||||
const HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL';
|
||||
const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL';
|
||||
const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL';
|
||||
@@ -242,6 +246,15 @@ type ShowTapToViewNotAvailableModalActionType = ReadonlyDeep<{
|
||||
payload: TapToViewNotAvailablePropsType;
|
||||
}>;
|
||||
|
||||
type HideBackfillFailureModalActionType = ReadonlyDeep<{
|
||||
type: typeof HIDE_BACKFILL_FAILURE_MODAL;
|
||||
}>;
|
||||
|
||||
type ShowBackfillFailureModalActionType = ReadonlyDeep<{
|
||||
type: typeof SHOW_BACKFILL_FAILURE_MODAL;
|
||||
payload: BackfillFailureModalPropsType;
|
||||
}>;
|
||||
|
||||
type HideContactModalActionType = ReadonlyDeep<{
|
||||
type: typeof HIDE_CONTACT_MODAL;
|
||||
}>;
|
||||
@@ -449,6 +462,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
|
||||
| CloseShortcutGuideModalActionType
|
||||
| CloseStickerPackPreviewActionType
|
||||
| HideAttachmentNotAvailableModalActionType
|
||||
| HideBackfillFailureModalActionType
|
||||
| HideContactModalActionType
|
||||
| HideSendAnywayDialogActiontype
|
||||
| HideStoriesSettingsActionType
|
||||
@@ -459,6 +473,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
|
||||
| MessageDeletedActionType
|
||||
| MessageExpiredActionType
|
||||
| ShowAttachmentNotAvailableModalActionType
|
||||
| ShowBackfillFailureModalActionType
|
||||
| ShowContactModalActionType
|
||||
| ShowEditHistoryModalActionType
|
||||
| ShowErrorModalActionType
|
||||
@@ -502,6 +517,7 @@ export const actions = {
|
||||
closeMediaPermissionsModal,
|
||||
ensureSystemMediaPermissions,
|
||||
hideAttachmentNotAvailableModal,
|
||||
hideBackfillFailureModal,
|
||||
hideBlockingSafetyNumberChangeDialog,
|
||||
hideContactModal,
|
||||
hideStoriesSettings,
|
||||
@@ -509,6 +525,7 @@ export const actions = {
|
||||
hideUserNotFoundModal,
|
||||
hideWhatsNewModal,
|
||||
showAttachmentNotAvailableModal,
|
||||
showBackfillFailureModal,
|
||||
showBlockingSafetyNumberChangeDialog,
|
||||
showContactModal,
|
||||
showEditHistoryModal,
|
||||
@@ -575,6 +592,21 @@ function showTapToViewNotAvailableModal(
|
||||
};
|
||||
}
|
||||
|
||||
function showBackfillFailureModal(
|
||||
payload: BackfillFailureModalPropsType
|
||||
): ShowBackfillFailureModalActionType {
|
||||
return {
|
||||
type: SHOW_BACKFILL_FAILURE_MODAL,
|
||||
payload,
|
||||
};
|
||||
}
|
||||
|
||||
function hideBackfillFailureModal(): HideBackfillFailureModalActionType {
|
||||
return {
|
||||
type: HIDE_BACKFILL_FAILURE_MODAL,
|
||||
};
|
||||
}
|
||||
|
||||
function hideContactModal(): HideContactModalActionType {
|
||||
return {
|
||||
type: HIDE_CONTACT_MODAL,
|
||||
@@ -1185,6 +1217,7 @@ function copyOverMessageAttributesIntoForwardMessages(
|
||||
export function getEmptyState(): GlobalModalsStateType {
|
||||
return {
|
||||
attachmentNotAvailableModalType: undefined,
|
||||
backfillFailureModalProps: undefined,
|
||||
hasConfirmationModal: false,
|
||||
callLinkAddNameModalRoomId: null,
|
||||
callLinkEditModalRoomId: null,
|
||||
@@ -1315,6 +1348,20 @@ export function reducer(
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === SHOW_BACKFILL_FAILURE_MODAL) {
|
||||
return {
|
||||
...state,
|
||||
backfillFailureModalProps: action.payload,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === HIDE_BACKFILL_FAILURE_MODAL) {
|
||||
return {
|
||||
...state,
|
||||
backfillFailureModalProps: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
if (action.type === SHOW_CONTACT_MODAL) {
|
||||
const ourId = window.ConversationController.getOurConversationIdOrThrow();
|
||||
if (action.payload.contactId === ourId) {
|
||||
|
@@ -41,7 +41,7 @@ import { showStickerPackPreview } from './globalModals';
|
||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||
import { DataReader } from '../../sql/Client';
|
||||
import { deleteDownloadsJobQueue } from '../../jobs/deleteDownloadsJobQueue';
|
||||
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
|
||||
import { AttachmentDownloadUrgency } from '../../types/AttachmentDownload';
|
||||
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
||||
import { getMessageIdForLogging } from '../../util/idForLogging';
|
||||
import { markViewOnceMessageViewed } from '../../services/MessageUpdater';
|
||||
|
@@ -70,7 +70,7 @@ import {
|
||||
} from '../../jobs/conversationJobQueue';
|
||||
import { ReceiptType } from '../../types/Receipt';
|
||||
import { cleanupMessages } from '../../util/cleanup';
|
||||
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
|
||||
import { AttachmentDownloadUrgency } from '../../types/AttachmentDownload';
|
||||
|
||||
export type StoryDataType = ReadonlyDeep<
|
||||
{
|
||||
|
@@ -121,6 +121,7 @@ export const SmartGlobalModalContainer = memo(
|
||||
aboutContactModalContactId,
|
||||
addUserToAnotherGroupModalContactId,
|
||||
attachmentNotAvailableModalType,
|
||||
backfillFailureModalProps,
|
||||
callLinkAddNameModalRoomId,
|
||||
callLinkEditModalRoomId,
|
||||
callLinkPendingParticipantContactId,
|
||||
@@ -155,6 +156,7 @@ export const SmartGlobalModalContainer = memo(
|
||||
hideTapToViewNotAvailableModal,
|
||||
hideUserNotFoundModal,
|
||||
hideWhatsNewModal,
|
||||
hideBackfillFailureModal,
|
||||
toggleSignalConnectionsModal,
|
||||
} = useGlobalModalActions();
|
||||
|
||||
@@ -210,6 +212,7 @@ export const SmartGlobalModalContainer = memo(
|
||||
addUserToAnotherGroupModalContactId={
|
||||
addUserToAnotherGroupModalContactId
|
||||
}
|
||||
backfillFailureModalProps={backfillFailureModalProps}
|
||||
callLinkAddNameModalRoomId={callLinkAddNameModalRoomId}
|
||||
callLinkEditModalRoomId={callLinkEditModalRoomId}
|
||||
callLinkPendingParticipantContactId={
|
||||
@@ -230,6 +233,7 @@ export const SmartGlobalModalContainer = memo(
|
||||
openSystemMediaPermissions={window.IPC.openSystemMediaPermissions}
|
||||
notePreviewModalProps={notePreviewModalProps}
|
||||
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
|
||||
hideBackfillFailureModal={hideBackfillFailureModal}
|
||||
hideUserNotFoundModal={hideUserNotFoundModal}
|
||||
hideWhatsNewModal={hideWhatsNewModal}
|
||||
hideTapToViewNotAvailableModal={hideTapToViewNotAvailableModal}
|
||||
|
@@ -10,11 +10,13 @@ import { omit } from 'lodash';
|
||||
import * as MIME from '../../types/MIME';
|
||||
import {
|
||||
AttachmentDownloadManager,
|
||||
AttachmentDownloadUrgency,
|
||||
runDownloadAttachmentJobInner,
|
||||
type NewAttachmentDownloadJobType,
|
||||
} from '../../jobs/AttachmentDownloadManager';
|
||||
import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload';
|
||||
import {
|
||||
type AttachmentDownloadJobType,
|
||||
AttachmentDownloadUrgency,
|
||||
} from '../../types/AttachmentDownload';
|
||||
import { DataReader, DataWriter } from '../../sql/Client';
|
||||
import { MINUTE } from '../../util/durations';
|
||||
import { type AttachmentType, AttachmentVariant } from '../../types/Attachment';
|
||||
|
@@ -146,13 +146,19 @@ export function sendTextMessage({
|
||||
to,
|
||||
text,
|
||||
attachments,
|
||||
sticker,
|
||||
preview,
|
||||
quote,
|
||||
desktop,
|
||||
timestamp = Date.now(),
|
||||
}: {
|
||||
from: PrimaryDevice;
|
||||
to: PrimaryDevice | Device | GroupInfo;
|
||||
text: string;
|
||||
text: string | undefined;
|
||||
attachments?: Array<Proto.IAttachmentPointer>;
|
||||
sticker?: Proto.DataMessage.ISticker;
|
||||
preview?: Proto.IPreview;
|
||||
quote?: Proto.DataMessage.IQuote;
|
||||
desktop: Device;
|
||||
timestamp?: number;
|
||||
}): Promise<void> {
|
||||
@@ -167,6 +173,9 @@ export function sendTextMessage({
|
||||
dataMessage: {
|
||||
body: text,
|
||||
attachments,
|
||||
sticker,
|
||||
preview: preview == null ? null : [preview],
|
||||
quote,
|
||||
timestamp: Long.fromNumber(timestamp),
|
||||
groupV2: groupInfo
|
||||
? {
|
||||
@@ -209,7 +218,7 @@ export function sendReaction({
|
||||
reaction: {
|
||||
emoji,
|
||||
targetAuthorAci: getDevice(targetAuthor).aci,
|
||||
targetTimestamp: Long.fromNumber(targetMessageTimestamp),
|
||||
targetSentTimestamp: Long.fromNumber(targetMessageTimestamp),
|
||||
},
|
||||
},
|
||||
}),
|
||||
|
475
ts/test-mock/messaging/backfill_test.ts
Normal file
475
ts/test-mock/messaging/backfill_test.ts
Normal file
@@ -0,0 +1,475 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type { PrimaryDevice } from '@signalapp/mock-server';
|
||||
import { Proto } from '@signalapp/mock-server';
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
import createDebug from 'debug';
|
||||
import type { Page } from 'playwright';
|
||||
import assert from 'assert';
|
||||
import Long from 'long';
|
||||
|
||||
import { LONG_MESSAGE, IMAGE_JPEG } from '../../types/MIME';
|
||||
import * as durations from '../../util/durations';
|
||||
import type { App } from '../playwright';
|
||||
import { Bootstrap } from '../bootstrap';
|
||||
import { sendTextMessage, getTimelineMessageWithText } from '../helpers';
|
||||
|
||||
export const debug = createDebug('mock:test:edit');
|
||||
|
||||
const FIXTURES_PATH = join(__dirname, '..', '..', '..', 'fixtures');
|
||||
|
||||
const CAT_PATH = join(FIXTURES_PATH, 'cat-screenshot.png');
|
||||
const SNOW_PATH = join(FIXTURES_PATH, 'snow.jpg');
|
||||
|
||||
const { Status } = Proto.SyncMessage.AttachmentBackfillResponse.AttachmentData;
|
||||
|
||||
function createResponse(
|
||||
response: Proto.SyncMessage.IAttachmentBackfillResponse
|
||||
): Proto.IContent {
|
||||
return { syncMessage: { attachmentBackfillResponse: response } };
|
||||
}
|
||||
|
||||
describe('attachment backfill', function (this: Mocha.Suite) {
|
||||
this.timeout(durations.MINUTE);
|
||||
|
||||
let bootstrap: Bootstrap;
|
||||
let app: App;
|
||||
let page: Page;
|
||||
let unknownContact: PrimaryDevice;
|
||||
let textAttachment: Proto.IAttachmentPointer;
|
||||
let catAttachment: Proto.IAttachmentPointer;
|
||||
let snowAttachment: Proto.IAttachmentPointer;
|
||||
|
||||
beforeEach(async () => {
|
||||
bootstrap = new Bootstrap({ contactCount: 1, unknownContactCount: 1 });
|
||||
await bootstrap.init();
|
||||
app = await bootstrap.link();
|
||||
page = await app.getWindow();
|
||||
|
||||
const { unknownContacts } = bootstrap;
|
||||
[unknownContact] = unknownContacts;
|
||||
|
||||
textAttachment = await bootstrap.storeAttachmentOnCDN(
|
||||
Buffer.from('look at this pic, it is gorgeous!'),
|
||||
LONG_MESSAGE
|
||||
);
|
||||
|
||||
const plaintextCat = await readFile(CAT_PATH);
|
||||
catAttachment = await bootstrap.storeAttachmentOnCDN(
|
||||
plaintextCat,
|
||||
IMAGE_JPEG
|
||||
);
|
||||
|
||||
const plaintextSnow = await readFile(SNOW_PATH);
|
||||
snowAttachment = await bootstrap.storeAttachmentOnCDN(
|
||||
plaintextSnow,
|
||||
IMAGE_JPEG
|
||||
);
|
||||
});
|
||||
|
||||
afterEach(async function (this: Mocha.Context) {
|
||||
if (!bootstrap) {
|
||||
return;
|
||||
}
|
||||
|
||||
await bootstrap.maybeSaveLogs(this.currentTest, app);
|
||||
await app.close();
|
||||
await bootstrap.teardown();
|
||||
});
|
||||
|
||||
it('should be requested on manual download', async () => {
|
||||
const { phone, desktop } = bootstrap;
|
||||
|
||||
debug('sending a message with attachment that is 404 on CDN');
|
||||
const timestamp = bootstrap.getTimestamp();
|
||||
await sendTextMessage({
|
||||
from: unknownContact,
|
||||
to: desktop,
|
||||
desktop,
|
||||
text: 'look at this pic!',
|
||||
attachments: [
|
||||
{ ...textAttachment, cdnKey: 'text-not-found' },
|
||||
{ ...catAttachment, cdnKey: 'cat-not-found' },
|
||||
{ ...snowAttachment, cdnKey: 'snow-not-found' },
|
||||
],
|
||||
timestamp,
|
||||
});
|
||||
|
||||
debug('opening conversation');
|
||||
const leftPane = page.locator('#LeftPane');
|
||||
|
||||
const conversationListItem = leftPane.getByRole('button', {
|
||||
name: 'Chat with Unknown contact',
|
||||
});
|
||||
await conversationListItem.getByText('Message Request').click();
|
||||
|
||||
debug('dowloading attachment');
|
||||
const conversationStack = page.locator('.Inbox__conversation-stack');
|
||||
const startDownload = conversationStack.getByRole('button', {
|
||||
name: 'Start Download',
|
||||
});
|
||||
await startDownload.click();
|
||||
|
||||
debug('waiting for backfill request');
|
||||
const {
|
||||
syncMessage: { attachmentBackfillRequest: request },
|
||||
} = await phone.waitForSyncMessage(entry => {
|
||||
return entry.syncMessage.attachmentBackfillRequest != null;
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
request?.targetConversation?.threadServiceId,
|
||||
unknownContact.device.aci
|
||||
);
|
||||
assert.strictEqual(
|
||||
request?.targetMessage?.authorServiceId,
|
||||
unknownContact.device.aci
|
||||
);
|
||||
assert.strictEqual(
|
||||
request?.targetMessage?.sentTimestamp?.toNumber(),
|
||||
timestamp
|
||||
);
|
||||
|
||||
// No download buttons
|
||||
debug('waiting for spinner to become visible');
|
||||
await startDownload.waitFor({ state: 'detached' });
|
||||
const cancelDownload = conversationStack.getByRole('button', {
|
||||
name: 'Cancel Download',
|
||||
});
|
||||
await cancelDownload.waitFor();
|
||||
|
||||
debug('sending pending backfill response');
|
||||
await phone.sendRaw(
|
||||
desktop,
|
||||
createResponse({
|
||||
targetConversation: request.targetConversation,
|
||||
targetMessage: request.targetMessage,
|
||||
attachments: {
|
||||
longText: { status: Status.PENDING },
|
||||
attachments: [{ status: Status.PENDING }, { status: Status.PENDING }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
}
|
||||
);
|
||||
|
||||
debug('resolving long text');
|
||||
await phone.sendRaw(
|
||||
desktop,
|
||||
createResponse({
|
||||
targetConversation: request.targetConversation,
|
||||
targetMessage: request.targetMessage,
|
||||
attachments: {
|
||||
longText: { attachment: textAttachment },
|
||||
attachments: [{ status: Status.PENDING }, { status: Status.PENDING }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
}
|
||||
);
|
||||
|
||||
await getTimelineMessageWithText(page, 'gorgeous').waitFor();
|
||||
|
||||
debug('resolving first attachment');
|
||||
await phone.sendRaw(
|
||||
desktop,
|
||||
createResponse({
|
||||
targetConversation: request.targetConversation,
|
||||
targetMessage: request.targetMessage,
|
||||
attachments: {
|
||||
longText: { attachment: textAttachment },
|
||||
attachments: [
|
||||
{ attachment: catAttachment },
|
||||
{ status: Status.PENDING },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
}
|
||||
);
|
||||
|
||||
await conversationStack
|
||||
.getByRole('button', {
|
||||
name: 'Open this attachment in a larger view',
|
||||
})
|
||||
.waitFor();
|
||||
|
||||
debug('failing second attachment');
|
||||
await phone.sendRaw(
|
||||
desktop,
|
||||
createResponse({
|
||||
targetConversation: request.targetConversation,
|
||||
targetMessage: request.targetMessage,
|
||||
attachments: {
|
||||
longText: { attachment: textAttachment },
|
||||
attachments: [
|
||||
{ attachment: catAttachment },
|
||||
{ status: Status.TERMINAL_ERROR },
|
||||
],
|
||||
},
|
||||
}),
|
||||
{
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
}
|
||||
);
|
||||
|
||||
await page.locator('.Toast >> "Download failed"').waitFor();
|
||||
await cancelDownload.waitFor({ state: 'detached' });
|
||||
await conversationStack
|
||||
.getByRole('button', {
|
||||
name: 'This media is not available',
|
||||
})
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
it('should show modal on timeout', async () => {
|
||||
const { desktop } = bootstrap;
|
||||
|
||||
debug('sending a message with attachment that is 404 on CDN');
|
||||
const timestamp = bootstrap.getTimestamp();
|
||||
await sendTextMessage({
|
||||
from: unknownContact,
|
||||
to: desktop,
|
||||
desktop,
|
||||
text: undefined,
|
||||
attachments: [{ ...catAttachment, cdnKey: 'cat-not-found' }],
|
||||
timestamp,
|
||||
});
|
||||
|
||||
debug('opening conversation');
|
||||
const leftPane = page.locator('#LeftPane');
|
||||
|
||||
const conversationListItem = leftPane.getByRole('button', {
|
||||
name: 'Chat with Unknown contact',
|
||||
});
|
||||
await conversationListItem.getByText('Message Request').click();
|
||||
|
||||
debug('dowloading attachment');
|
||||
const conversationStack = page.locator('.Inbox__conversation-stack');
|
||||
const startDownload = conversationStack.getByRole('button', {
|
||||
name: 'Start Download',
|
||||
});
|
||||
await startDownload.click();
|
||||
|
||||
debug('waiting for modal');
|
||||
const modal = page.getByTestId('BackfillFailureModal');
|
||||
await modal.waitFor();
|
||||
await modal.locator('text=/internet connection/').waitFor();
|
||||
});
|
||||
|
||||
it('should show modal on missing message', async () => {
|
||||
const { phone, desktop } = bootstrap;
|
||||
|
||||
debug('sending a message with attachment that is 404 on CDN');
|
||||
const timestamp = bootstrap.getTimestamp();
|
||||
await sendTextMessage({
|
||||
from: unknownContact,
|
||||
to: desktop,
|
||||
desktop,
|
||||
text: undefined,
|
||||
attachments: [{ ...catAttachment, cdnKey: 'cat-not-found' }],
|
||||
timestamp,
|
||||
});
|
||||
|
||||
debug('opening conversation');
|
||||
const leftPane = page.locator('#LeftPane');
|
||||
|
||||
const conversationListItem = leftPane.getByRole('button', {
|
||||
name: 'Chat with Unknown contact',
|
||||
});
|
||||
await conversationListItem.getByText('Message Request').click();
|
||||
|
||||
debug('dowloading attachment');
|
||||
const conversationStack = page.locator('.Inbox__conversation-stack');
|
||||
const startDownload = conversationStack.getByRole('button', {
|
||||
name: 'Start Download',
|
||||
});
|
||||
await startDownload.click();
|
||||
|
||||
debug('waiting for request');
|
||||
const {
|
||||
syncMessage: { attachmentBackfillRequest: request },
|
||||
} = await phone.waitForSyncMessage(entry => {
|
||||
return entry.syncMessage.attachmentBackfillRequest != null;
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
request?.targetConversation?.threadServiceId,
|
||||
unknownContact.device.aci
|
||||
);
|
||||
assert.strictEqual(
|
||||
request?.targetMessage?.authorServiceId,
|
||||
unknownContact.device.aci
|
||||
);
|
||||
assert.strictEqual(
|
||||
request?.targetMessage?.sentTimestamp?.toNumber(),
|
||||
timestamp
|
||||
);
|
||||
|
||||
debug('sending not found response');
|
||||
await phone.sendRaw(
|
||||
desktop,
|
||||
createResponse({
|
||||
targetConversation: request.targetConversation,
|
||||
targetMessage: request.targetMessage,
|
||||
error:
|
||||
Proto.SyncMessage.AttachmentBackfillResponse.Error.MESSAGE_NOT_FOUND,
|
||||
}),
|
||||
{
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
}
|
||||
);
|
||||
|
||||
debug('waiting for modal');
|
||||
const modal = page.getByTestId('BackfillFailureModal');
|
||||
await modal.waitFor();
|
||||
await modal.locator('text=/no longer available/').waitFor();
|
||||
});
|
||||
|
||||
it('should resolve sticker', async () => {
|
||||
const { phone, desktop } = bootstrap;
|
||||
|
||||
debug('sending a message with attachment that is 404 on CDN');
|
||||
const timestamp = bootstrap.getTimestamp();
|
||||
await sendTextMessage({
|
||||
from: unknownContact,
|
||||
to: desktop,
|
||||
desktop,
|
||||
text: undefined,
|
||||
sticker: {
|
||||
packId: Buffer.from('ae8fedafda4768fd3384d4b3b9db963d', 'hex'),
|
||||
packKey: Buffer.from(
|
||||
'53f4aa8b95e1c2e75afab2328fe67eb6d7affbcd4f50cd4da89dfc325dbc73ca',
|
||||
'hex'
|
||||
),
|
||||
stickerId: 1,
|
||||
emoji: '🐈',
|
||||
data: { ...catAttachment, cdnKey: 'cat-not-found' },
|
||||
},
|
||||
timestamp,
|
||||
});
|
||||
|
||||
debug('opening conversation');
|
||||
const leftPane = page.locator('#LeftPane');
|
||||
|
||||
const conversationListItem = leftPane.getByRole('button', {
|
||||
name: 'Chat with Unknown contact',
|
||||
});
|
||||
await conversationListItem.getByText('Message Request').click();
|
||||
|
||||
debug('dowloading attachment');
|
||||
const conversationStack = page.locator('.Inbox__conversation-stack');
|
||||
const startDownload = conversationStack.getByRole('button', {
|
||||
name: 'Start Download',
|
||||
});
|
||||
await startDownload.click();
|
||||
|
||||
debug('waiting for backfill request');
|
||||
const {
|
||||
syncMessage: { attachmentBackfillRequest: request },
|
||||
} = await phone.waitForSyncMessage(entry => {
|
||||
return entry.syncMessage.attachmentBackfillRequest != null;
|
||||
});
|
||||
|
||||
assert.strictEqual(
|
||||
request?.targetConversation?.threadServiceId,
|
||||
unknownContact.device.aci
|
||||
);
|
||||
assert.strictEqual(
|
||||
request?.targetMessage?.authorServiceId,
|
||||
unknownContact.device.aci
|
||||
);
|
||||
assert.strictEqual(
|
||||
request?.targetMessage?.sentTimestamp?.toNumber(),
|
||||
timestamp
|
||||
);
|
||||
|
||||
debug('sending pending backfill response');
|
||||
await phone.sendRaw(
|
||||
desktop,
|
||||
createResponse({
|
||||
targetConversation: request.targetConversation,
|
||||
targetMessage: request.targetMessage,
|
||||
attachments: {
|
||||
attachments: [{ status: Status.PENDING }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
}
|
||||
);
|
||||
|
||||
debug('resolving sticker');
|
||||
await phone.sendRaw(
|
||||
desktop,
|
||||
createResponse({
|
||||
targetConversation: request.targetConversation,
|
||||
targetMessage: request.targetMessage,
|
||||
attachments: {
|
||||
attachments: [{ attachment: catAttachment }],
|
||||
},
|
||||
}),
|
||||
{
|
||||
timestamp: bootstrap.getTimestamp(),
|
||||
}
|
||||
);
|
||||
|
||||
await conversationStack
|
||||
.locator('.module-image-grid--with-sticker')
|
||||
.waitFor();
|
||||
});
|
||||
|
||||
it('should not request backfill on quote/preview', async () => {
|
||||
const { desktop } = bootstrap;
|
||||
|
||||
debug('sending a message with attachment that is 404 on CDN');
|
||||
const timestamp = bootstrap.getTimestamp();
|
||||
await sendTextMessage({
|
||||
from: unknownContact,
|
||||
to: desktop,
|
||||
desktop,
|
||||
quote: {
|
||||
id: Long.fromNumber(bootstrap.getTimestamp()),
|
||||
authorAci: unknownContact.device.aci,
|
||||
text: 'quote text',
|
||||
attachments: [
|
||||
{
|
||||
contentType: IMAGE_JPEG,
|
||||
fileName: 'snow.jpg',
|
||||
thumbnail: { ...snowAttachment, cdnKey: 'snow-not-found' },
|
||||
},
|
||||
],
|
||||
type: Proto.DataMessage.Quote.Type.NORMAL,
|
||||
},
|
||||
preview: {
|
||||
url: 'https://signal.org',
|
||||
title: 'Signal',
|
||||
image: { ...catAttachment, cdnKey: 'cat-not-found' },
|
||||
},
|
||||
text: 'https://signal.org',
|
||||
timestamp,
|
||||
});
|
||||
|
||||
debug('opening conversation');
|
||||
const leftPane = page.locator('#LeftPane');
|
||||
|
||||
const conversationListItem = leftPane.getByRole('button', {
|
||||
name: 'Chat with Unknown contact',
|
||||
});
|
||||
await conversationListItem.getByText('Message Request').click();
|
||||
|
||||
debug('dowloading attachment');
|
||||
const conversationStack = page.locator('.Inbox__conversation-stack');
|
||||
const startDownload = conversationStack.getByRole('button', {
|
||||
name: 'Start Download',
|
||||
});
|
||||
await startDownload.click();
|
||||
|
||||
await page.locator('.Toast >> "Download failed"').waitFor();
|
||||
});
|
||||
});
|
@@ -47,10 +47,10 @@ describe('unknown contacts', function (this: Mocha.Suite) {
|
||||
|
||||
debug('sending calling offer message');
|
||||
await unknownContact.sendRaw(desktop, {
|
||||
callingMessage: {
|
||||
callMessage: {
|
||||
offer: {
|
||||
callId: new Long(Math.floor(Math.random() * 1e10)),
|
||||
type: Proto.CallingMessage.Offer.Type.OFFER_AUDIO_CALL,
|
||||
id: new Long(Math.floor(Math.random() * 1e10)),
|
||||
type: Proto.CallMessage.Offer.Type.OFFER_AUDIO_CALL,
|
||||
opaque: new Uint8Array(0),
|
||||
},
|
||||
},
|
||||
|
@@ -260,7 +260,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
|
||||
debug('Send a PNI sync message');
|
||||
const timestamp = bootstrap.getTimestamp();
|
||||
const destinationServiceId = stranger.device.pni;
|
||||
const destination = stranger.device.number;
|
||||
const destinationE164 = stranger.device.number;
|
||||
const destinationPniIdentityKey = await stranger.device.getIdentityKey(
|
||||
ServiceIdKind.PNI
|
||||
);
|
||||
@@ -272,7 +272,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
|
||||
syncMessage: {
|
||||
sent: {
|
||||
destinationServiceId,
|
||||
destination,
|
||||
destinationE164,
|
||||
timestamp: Long.fromNumber(timestamp),
|
||||
message: originalDataMessage,
|
||||
unidentifiedStatus: [
|
||||
@@ -322,7 +322,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
|
||||
.innerText();
|
||||
assert.equal(
|
||||
strangerName.slice(-4),
|
||||
destination?.slice(-4),
|
||||
destinationE164?.slice(-4),
|
||||
'no profile, just phone number'
|
||||
);
|
||||
}
|
||||
|
@@ -40,6 +40,7 @@ import {
|
||||
SignedPreKeys,
|
||||
} from '../LibSignalStores';
|
||||
import { verifySignature } from '../Curve';
|
||||
import { createName } from '../util/attachmentPath';
|
||||
import { assertDev, strictAssert } from '../util/assert';
|
||||
import type { BatcherType } from '../util/batcher';
|
||||
import { createBatcher } from '../util/batcher';
|
||||
@@ -97,14 +98,17 @@ import type {
|
||||
UnprocessedType,
|
||||
} from './Types.d';
|
||||
import type {
|
||||
ConversationToDelete,
|
||||
ConversationIdentifier,
|
||||
DeleteForMeSyncEventData,
|
||||
AttachmentBackfillAttachmentType,
|
||||
AttachmentBackfillResponseSyncEventData,
|
||||
DeleteForMeSyncTarget,
|
||||
MessageToDelete,
|
||||
AddressableMessage,
|
||||
ReadSyncEventData,
|
||||
ViewSyncEventData,
|
||||
} from './messageReceiverEvents';
|
||||
import {
|
||||
AttachmentBackfillResponseSyncEvent,
|
||||
CallEventSyncEvent,
|
||||
CallLinkUpdateSyncEvent,
|
||||
CallLogEventSyncEvent,
|
||||
@@ -678,6 +682,11 @@ export default class MessageReceiver
|
||||
handler: (ev: DeleteForMeSyncEvent) => void
|
||||
): void;
|
||||
|
||||
public override addEventListener(
|
||||
name: 'attachmentBackfillResponseSync',
|
||||
handler: (ev: AttachmentBackfillResponseSyncEvent) => void
|
||||
): void;
|
||||
|
||||
public override addEventListener(
|
||||
name: 'deviceNameChangeSync',
|
||||
handler: (ev: DeviceNameChangeSyncEvent) => void
|
||||
@@ -3160,6 +3169,12 @@ export default class MessageReceiver
|
||||
syncMessage.deviceNameChange
|
||||
);
|
||||
}
|
||||
if (syncMessage.attachmentBackfillResponse) {
|
||||
return this.#handleAttachmentBackfillResponse(
|
||||
envelope,
|
||||
syncMessage.attachmentBackfillResponse
|
||||
);
|
||||
}
|
||||
|
||||
this.#removeFromCache(envelope);
|
||||
const envelopeId = getEnvelopeId(envelope);
|
||||
@@ -3601,10 +3616,10 @@ export default class MessageReceiver
|
||||
deleteSync.messageDeletes
|
||||
.flatMap((item): Array<DeleteForMeSyncTarget> | undefined => {
|
||||
const messages = item.messages
|
||||
?.map(message => processMessageToDelete(message, logId))
|
||||
?.map(message => processAddressableMessage(message, logId))
|
||||
.filter(isNotNil);
|
||||
const conversation = item.conversation
|
||||
? processConversationToDelete(item.conversation, logId)
|
||||
? processConversationIdentifier(item.conversation, logId)
|
||||
: undefined;
|
||||
|
||||
if (!conversation) {
|
||||
@@ -3639,14 +3654,14 @@ export default class MessageReceiver
|
||||
deleteSync.conversationDeletes
|
||||
.map(item => {
|
||||
const mostRecentMessages = item.mostRecentMessages
|
||||
?.map(message => processMessageToDelete(message, logId))
|
||||
?.map(message => processAddressableMessage(message, logId))
|
||||
.filter(isNotNil);
|
||||
const mostRecentNonExpiringMessages =
|
||||
item.mostRecentNonExpiringMessages
|
||||
?.map(message => processMessageToDelete(message, logId))
|
||||
?.map(message => processAddressableMessage(message, logId))
|
||||
.filter(isNotNil);
|
||||
const conversation = item.conversation
|
||||
? processConversationToDelete(item.conversation, logId)
|
||||
? processConversationIdentifier(item.conversation, logId)
|
||||
: undefined;
|
||||
|
||||
if (!conversation) {
|
||||
@@ -3680,7 +3695,7 @@ export default class MessageReceiver
|
||||
deleteSync.localOnlyConversationDeletes
|
||||
.map(item => {
|
||||
const conversation = item.conversation
|
||||
? processConversationToDelete(item.conversation, logId)
|
||||
? processConversationIdentifier(item.conversation, logId)
|
||||
: undefined;
|
||||
|
||||
if (!conversation) {
|
||||
@@ -3712,10 +3727,10 @@ export default class MessageReceiver
|
||||
targetMessage,
|
||||
} = item;
|
||||
const conversation = targetConversation
|
||||
? processConversationToDelete(targetConversation, logId)
|
||||
? processConversationIdentifier(targetConversation, logId)
|
||||
: undefined;
|
||||
const message = targetMessage
|
||||
? processMessageToDelete(targetMessage, logId)
|
||||
? processAddressableMessage(targetMessage, logId)
|
||||
: undefined;
|
||||
|
||||
if (!conversation) {
|
||||
@@ -3782,6 +3797,84 @@ export default class MessageReceiver
|
||||
log.info('handleDeleteForMeSync: finished');
|
||||
}
|
||||
|
||||
async #handleAttachmentBackfillResponse(
|
||||
envelope: ProcessedEnvelope,
|
||||
response: Proto.SyncMessage.IAttachmentBackfillResponse
|
||||
): Promise<void> {
|
||||
const logId = getEnvelopeId(envelope);
|
||||
log.info('MessageReceiver.handleAttachmentBackfillResponse', logId);
|
||||
|
||||
logUnexpectedUrgentValue(envelope, 'attachmentBackfillResponseSync');
|
||||
|
||||
strictAssert(
|
||||
response.targetMessage != null,
|
||||
'MessageReceiver.handleAttachmentBackfillResponse: no target message'
|
||||
);
|
||||
strictAssert(
|
||||
response.targetConversation != null,
|
||||
'MessageReceiver.handleAttachmentBackfillResponse: no target conversation'
|
||||
);
|
||||
|
||||
const targetMessage = processAddressableMessage(
|
||||
response.targetMessage,
|
||||
logId
|
||||
);
|
||||
|
||||
const targetConversation = processConversationIdentifier(
|
||||
response.targetConversation,
|
||||
logId
|
||||
);
|
||||
|
||||
let eventData: AttachmentBackfillResponseSyncEventData;
|
||||
if (response.error != null) {
|
||||
eventData = {
|
||||
error: response.error,
|
||||
targetMessage,
|
||||
targetConversation,
|
||||
};
|
||||
} else {
|
||||
strictAssert(
|
||||
targetMessage != null,
|
||||
'MessageReceiver.handleAttachmentBackfillResponse: invalid target message'
|
||||
);
|
||||
strictAssert(
|
||||
targetConversation != null,
|
||||
'MessageReceiver.handleAttachmentBackfillResponse: ' +
|
||||
'invalid target conversation'
|
||||
);
|
||||
|
||||
const { attachments } = response;
|
||||
strictAssert(
|
||||
attachments != null,
|
||||
'MessageReceiver.handleAttachmentBackfillResponse: no attachments'
|
||||
);
|
||||
|
||||
eventData = {
|
||||
targetMessage,
|
||||
targetConversation,
|
||||
longText:
|
||||
attachments.longText == null
|
||||
? undefined
|
||||
: processBackfilledAttachment(attachments.longText),
|
||||
attachments: (attachments.attachments ?? []).map(
|
||||
processBackfilledAttachment
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
const attachmentBackfillResponseSync =
|
||||
new AttachmentBackfillResponseSyncEvent(
|
||||
eventData,
|
||||
envelope.timestamp,
|
||||
envelope.id,
|
||||
this.#removeFromCache.bind(this, envelope)
|
||||
);
|
||||
|
||||
await this.#dispatchAndWait(logId, attachmentBackfillResponseSync);
|
||||
|
||||
log.info('handleAttachmentBackfillResponse: finished');
|
||||
}
|
||||
|
||||
async #handleDeviceNameChangeSync(
|
||||
envelope: ProcessedEnvelope,
|
||||
deviceNameChange: Proto.SyncMessage.IDeviceNameChange
|
||||
@@ -4030,14 +4123,14 @@ function envelopeTypeToCiphertextType(type: number | undefined): number {
|
||||
throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`);
|
||||
}
|
||||
|
||||
function processMessageToDelete(
|
||||
target: Proto.SyncMessage.DeleteForMe.IAddressableMessage,
|
||||
function processAddressableMessage(
|
||||
target: Proto.IAddressableMessage,
|
||||
logId: string
|
||||
): MessageToDelete | undefined {
|
||||
): AddressableMessage | undefined {
|
||||
const sentAt = target.sentTimestamp?.toNumber();
|
||||
if (!isNumber(sentAt)) {
|
||||
log.warn(
|
||||
`${logId}/processMessageToDelete: No sentTimestamp found! Dropping AddressableMessage.`
|
||||
`${logId}/processAddressableMessage: No sentTimestamp found! Dropping AddressableMessage.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
@@ -4049,7 +4142,7 @@ function processMessageToDelete(
|
||||
type: 'aci' as const,
|
||||
authorAci: normalizeAci(
|
||||
authorServiceId,
|
||||
`${logId}/processMessageToDelete/aci`
|
||||
`${logId}/processAddressableMessage/aci`
|
||||
),
|
||||
sentAt,
|
||||
};
|
||||
@@ -4059,13 +4152,13 @@ function processMessageToDelete(
|
||||
type: 'pni' as const,
|
||||
authorPni: normalizePni(
|
||||
authorServiceId,
|
||||
`${logId}/processMessageToDelete/pni`
|
||||
`${logId}/processAddressableMessage/pni`
|
||||
),
|
||||
sentAt,
|
||||
};
|
||||
}
|
||||
log.error(
|
||||
`${logId}/processMessageToDelete: invalid authorServiceId, Dropping AddressableMessage.`
|
||||
`${logId}/processAddressableMessage: invalid authorServiceId, Dropping AddressableMessage.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
@@ -4078,15 +4171,15 @@ function processMessageToDelete(
|
||||
}
|
||||
|
||||
log.warn(
|
||||
`${logId}/processMessageToDelete: No author field found! Dropping AddressableMessage.`
|
||||
`${logId}/processAddressableMessage: No author field found! Dropping AddressableMessage.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function processConversationToDelete(
|
||||
target: Proto.SyncMessage.DeleteForMe.IConversationIdentifier,
|
||||
function processConversationIdentifier(
|
||||
target: Proto.IConversationIdentifier,
|
||||
logId: string
|
||||
): ConversationToDelete | undefined {
|
||||
): ConversationIdentifier | undefined {
|
||||
const { threadServiceId, threadGroupId, threadE164 } = target;
|
||||
|
||||
if (threadServiceId) {
|
||||
@@ -4103,7 +4196,7 @@ function processConversationToDelete(
|
||||
};
|
||||
}
|
||||
log.error(
|
||||
`${logId}/processConversationToDelete: Invalid threadServiceId, dropping ConversationIdentifier.`
|
||||
`${logId}/processConversationIdentifier: Invalid threadServiceId, dropping ConversationIdentifier.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
@@ -4121,7 +4214,30 @@ function processConversationToDelete(
|
||||
}
|
||||
|
||||
log.warn(
|
||||
`${logId}/processConversationToDelete: No identifier field found! Dropping ConversationIdentifier.`
|
||||
`${logId}/processConversationIdentifier: No identifier field found! Dropping ConversationIdentifier.`
|
||||
);
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function processBackfilledAttachment(
|
||||
data: Proto.SyncMessage.AttachmentBackfillResponse.IAttachmentData
|
||||
): AttachmentBackfillAttachmentType {
|
||||
if (data.status != null) {
|
||||
return { status: data.status };
|
||||
}
|
||||
|
||||
const attachment = processAttachment(data.attachment);
|
||||
strictAssert(
|
||||
attachment != null,
|
||||
'MessageReceiver.handleAttachmentBackfillResponse: ' +
|
||||
'invalid attachment pointer'
|
||||
);
|
||||
return {
|
||||
attachment: {
|
||||
...attachment,
|
||||
|
||||
// Resumable downloads
|
||||
downloadPath: createName(),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
@@ -81,12 +81,12 @@ import {
|
||||
import { missingCaseError } from '../util/missingCaseError';
|
||||
import { drop } from '../util/drop';
|
||||
import type {
|
||||
ConversationToDelete,
|
||||
ConversationIdentifier,
|
||||
DeleteForMeSyncEventData,
|
||||
DeleteMessageSyncTarget,
|
||||
MessageToDelete,
|
||||
AddressableMessage,
|
||||
} from './messageReceiverEvents';
|
||||
import { getConversationFromTarget } from '../util/deleteForMe';
|
||||
import { getConversationFromTarget } from '../util/syncIdentifiers';
|
||||
import type { CallDetails, CallHistoryDetails } from '../types/CallDisposition';
|
||||
import {
|
||||
AdhocCallStatus,
|
||||
@@ -1609,6 +1609,35 @@ export default class MessageSender {
|
||||
};
|
||||
}
|
||||
|
||||
static getAttachmentBackfillSyncMessage(
|
||||
targetConversation: ConversationIdentifier,
|
||||
targetMessage: AddressableMessage
|
||||
): SingleProtoJobData {
|
||||
const myAci = window.textsecure.storage.user.getCheckedAci();
|
||||
|
||||
const syncMessage = this.createSyncMessage();
|
||||
syncMessage.attachmentBackfillRequest = {
|
||||
targetMessage: toAddressableMessage(targetMessage),
|
||||
targetConversation: toConversationIdentifier(targetConversation),
|
||||
};
|
||||
|
||||
const contentMessage = new Proto.Content();
|
||||
contentMessage.syncMessage = syncMessage;
|
||||
|
||||
const { ContentHint } = Proto.UnidentifiedSenderMessage.Message;
|
||||
|
||||
return {
|
||||
contentHint: ContentHint.RESENDABLE,
|
||||
serviceId: myAci,
|
||||
isSyncMessage: true,
|
||||
protoBase64: Bytes.toBase64(
|
||||
Proto.Content.encode(contentMessage).finish()
|
||||
),
|
||||
type: 'attachmentBackfillRequestSync',
|
||||
urgent: false,
|
||||
};
|
||||
}
|
||||
|
||||
static getClearCallHistoryMessage(
|
||||
latestCall: CallHistoryDetails
|
||||
): SingleProtoJobData {
|
||||
@@ -2465,8 +2494,8 @@ export default class MessageSender {
|
||||
|
||||
// Helpers
|
||||
|
||||
function toAddressableMessage(message: MessageToDelete) {
|
||||
const targetMessage = new Proto.SyncMessage.DeleteForMe.AddressableMessage();
|
||||
function toAddressableMessage(message: AddressableMessage) {
|
||||
const targetMessage = new Proto.AddressableMessage();
|
||||
targetMessage.sentTimestamp = Long.fromNumber(message.sentAt);
|
||||
|
||||
if (message.type === 'aci') {
|
||||
@@ -2482,9 +2511,8 @@ function toAddressableMessage(message: MessageToDelete) {
|
||||
return targetMessage;
|
||||
}
|
||||
|
||||
function toConversationIdentifier(conversation: ConversationToDelete) {
|
||||
const targetConversation =
|
||||
new Proto.SyncMessage.DeleteForMe.ConversationIdentifier();
|
||||
function toConversationIdentifier(conversation: ConversationIdentifier) {
|
||||
const targetConversation = new Proto.ConversationIdentifier();
|
||||
|
||||
if (conversation.type === 'aci') {
|
||||
targetConversation.threadServiceId = conversation.aci;
|
||||
|
@@ -782,11 +782,13 @@ export type WebAPIConnectType = {
|
||||
export type CapabilitiesType = {
|
||||
deleteSync: boolean;
|
||||
ssre2: boolean;
|
||||
attachmentBackfill: boolean;
|
||||
};
|
||||
export type CapabilitiesUploadType = {
|
||||
deleteSync: true;
|
||||
versionedExpirationTimer: true;
|
||||
ssre2: true;
|
||||
attachmentBackfill: true;
|
||||
};
|
||||
|
||||
type StickerPackManifestType = Uint8Array;
|
||||
@@ -2992,6 +2994,7 @@ export function initialize({
|
||||
deleteSync: true,
|
||||
versionedExpirationTimer: true,
|
||||
ssre2: true,
|
||||
attachmentBackfill: true,
|
||||
};
|
||||
|
||||
const jsonData = {
|
||||
@@ -3048,6 +3051,7 @@ export function initialize({
|
||||
deleteSync: true,
|
||||
versionedExpirationTimer: true,
|
||||
ssre2: true,
|
||||
attachmentBackfill: true,
|
||||
};
|
||||
|
||||
const jsonData = {
|
||||
|
@@ -491,7 +491,7 @@ export class DeviceNameChangeSyncEvent extends ConfirmableEvent {
|
||||
}
|
||||
}
|
||||
|
||||
const messageToDeleteSchema = z.union([
|
||||
const addressableMessageSchema = z.union([
|
||||
z.object({
|
||||
type: z.literal('aci').readonly(),
|
||||
authorAci: z.string().refine(isAciString),
|
||||
@@ -509,9 +509,9 @@ const messageToDeleteSchema = z.union([
|
||||
}),
|
||||
]);
|
||||
|
||||
export type MessageToDelete = z.infer<typeof messageToDeleteSchema>;
|
||||
export type AddressableMessage = z.infer<typeof addressableMessageSchema>;
|
||||
|
||||
const conversationToDeleteSchema = z.union([
|
||||
const conversationIdentifierSchema = z.union([
|
||||
z.object({
|
||||
type: z.literal('aci').readonly(),
|
||||
aci: z.string().refine(isAciString),
|
||||
@@ -530,32 +530,34 @@ const conversationToDeleteSchema = z.union([
|
||||
}),
|
||||
]);
|
||||
|
||||
export type ConversationToDelete = z.infer<typeof conversationToDeleteSchema>;
|
||||
export type ConversationIdentifier = z.infer<
|
||||
typeof conversationIdentifierSchema
|
||||
>;
|
||||
|
||||
export const deleteMessageSchema = z.object({
|
||||
type: z.literal('delete-message').readonly(),
|
||||
conversation: conversationToDeleteSchema,
|
||||
message: messageToDeleteSchema,
|
||||
conversation: conversationIdentifierSchema,
|
||||
message: addressableMessageSchema,
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export type DeleteMessageSyncTarget = z.infer<typeof deleteMessageSchema>;
|
||||
export const deleteConversationSchema = z.object({
|
||||
type: z.literal('delete-conversation').readonly(),
|
||||
conversation: conversationToDeleteSchema,
|
||||
mostRecentMessages: z.array(messageToDeleteSchema),
|
||||
mostRecentNonExpiringMessages: z.array(messageToDeleteSchema).optional(),
|
||||
conversation: conversationIdentifierSchema,
|
||||
mostRecentMessages: z.array(addressableMessageSchema),
|
||||
mostRecentNonExpiringMessages: z.array(addressableMessageSchema).optional(),
|
||||
isFullDelete: z.boolean(),
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export const deleteLocalConversationSchema = z.object({
|
||||
type: z.literal('delete-local-conversation').readonly(),
|
||||
conversation: conversationToDeleteSchema,
|
||||
conversation: conversationIdentifierSchema,
|
||||
timestamp: z.number(),
|
||||
});
|
||||
export const deleteAttachmentSchema = z.object({
|
||||
type: z.literal('delete-single-attachment').readonly(),
|
||||
conversation: conversationToDeleteSchema,
|
||||
message: messageToDeleteSchema,
|
||||
conversation: conversationIdentifierSchema,
|
||||
message: addressableMessageSchema,
|
||||
clientUuid: z.string().optional(),
|
||||
fallbackDigest: z.string().optional(),
|
||||
fallbackPlaintextHash: z.string().optional(),
|
||||
@@ -583,6 +585,40 @@ export class DeleteForMeSyncEvent extends ConfirmableEvent {
|
||||
}
|
||||
}
|
||||
|
||||
export type AttachmentBackfillAttachmentType = Readonly<
|
||||
| {
|
||||
attachment: ProcessedAttachment;
|
||||
}
|
||||
| {
|
||||
status: Proto.SyncMessage.AttachmentBackfillResponse.AttachmentData.Status;
|
||||
}
|
||||
>;
|
||||
|
||||
export type AttachmentBackfillResponseSyncEventData = Readonly<
|
||||
| {
|
||||
error: Proto.SyncMessage.AttachmentBackfillResponse.Error;
|
||||
targetMessage?: AddressableMessage;
|
||||
targetConversation?: ConversationIdentifier;
|
||||
}
|
||||
| {
|
||||
attachments: ReadonlyArray<AttachmentBackfillAttachmentType>;
|
||||
longText: AttachmentBackfillAttachmentType | undefined;
|
||||
targetMessage: AddressableMessage;
|
||||
targetConversation: ConversationIdentifier;
|
||||
}
|
||||
>;
|
||||
|
||||
export class AttachmentBackfillResponseSyncEvent extends ConfirmableEvent {
|
||||
constructor(
|
||||
public readonly response: AttachmentBackfillResponseSyncEventData,
|
||||
public readonly timestamp: number,
|
||||
public readonly envelopeId: string,
|
||||
confirm: ConfirmCallback
|
||||
) {
|
||||
super('attachmentBackfillResponseSync', confirm);
|
||||
}
|
||||
}
|
||||
|
||||
export type CallLogEventSyncEventData = Readonly<{
|
||||
callLogEventDetails: CallLogEventDetails;
|
||||
receivedAtCounter: number;
|
||||
|
@@ -69,3 +69,8 @@ export const attachmentDownloadJobSchema = coreAttachmentDownloadJobSchema.and(
|
||||
attachment: Record<string, unknown>;
|
||||
}
|
||||
>;
|
||||
|
||||
export enum AttachmentDownloadUrgency {
|
||||
IMMEDIATE = 'immediate',
|
||||
STANDARD = 'standard',
|
||||
}
|
||||
|
1
ts/types/Storage.d.ts
vendored
1
ts/types/Storage.d.ts
vendored
@@ -200,6 +200,7 @@ export type StorageAccessType = {
|
||||
observedCapabilities: {
|
||||
deleteSync?: true;
|
||||
ssre2?: true;
|
||||
attachmentBackfill?: true;
|
||||
|
||||
// Note: Upon capability deprecation - change the value type to `never` and
|
||||
// remove it in `ts/background.ts`
|
||||
|
12
ts/util/attachments/markAttachmentAsPermanentlyErrored.ts
Normal file
12
ts/util/attachments/markAttachmentAsPermanentlyErrored.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { omit } from 'lodash';
|
||||
|
||||
import { type AttachmentType } from '../../types/Attachment';
|
||||
|
||||
export function markAttachmentAsPermanentlyErrored(
|
||||
attachment: AttachmentType
|
||||
): AttachmentType {
|
||||
return { ...omit(attachment, ['key', 'id']), pending: false, error: true };
|
||||
}
|
@@ -4,90 +4,27 @@
|
||||
import { last, sortBy } from 'lodash';
|
||||
|
||||
import * as log from '../logging/log';
|
||||
import { isAciString } from './isAciString';
|
||||
import { isGroup, isGroupV2 } from './whatTypeOfConversation';
|
||||
import {
|
||||
getConversationIdForLogging,
|
||||
getMessageIdForLogging,
|
||||
} from './idForLogging';
|
||||
import { missingCaseError } from './missingCaseError';
|
||||
import { getMessageSentTimestampSet } from './getMessageSentTimestampSet';
|
||||
import { getAuthor } from '../messages/helpers';
|
||||
import { isPniString } from '../types/ServiceId';
|
||||
import { DataReader, DataWriter, deleteAndCleanup } from '../sql/Client';
|
||||
import { deleteData } from '../types/Attachment';
|
||||
|
||||
import type {
|
||||
ConversationAttributesType,
|
||||
MessageAttributesType,
|
||||
} from '../model-types';
|
||||
import type { MessageAttributesType } from '../model-types';
|
||||
import type { ConversationModel } from '../models/conversations';
|
||||
import type {
|
||||
ConversationToDelete,
|
||||
MessageToDelete,
|
||||
} from '../textsecure/messageReceiverEvents';
|
||||
import type { AciString, PniString } from '../types/ServiceId';
|
||||
import type { AddressableMessage } from '../textsecure/messageReceiverEvents';
|
||||
import type { AttachmentType } from '../types/Attachment';
|
||||
import { MessageModel } from '../models/messages';
|
||||
import { cleanupMessages, postSaveUpdates } from './cleanup';
|
||||
import {
|
||||
findMatchingMessage,
|
||||
getMessageQueryFromTarget,
|
||||
} from './syncIdentifiers';
|
||||
|
||||
const { getMessagesBySentAt, getMostRecentAddressableMessages } = DataReader;
|
||||
const { getMostRecentAddressableMessages } = DataReader;
|
||||
|
||||
const { removeMessagesInConversation, saveMessage } = DataWriter;
|
||||
|
||||
export function doesMessageMatch({
|
||||
conversationId,
|
||||
message,
|
||||
query,
|
||||
sentTimestamps,
|
||||
}: {
|
||||
message: MessageAttributesType;
|
||||
conversationId: string;
|
||||
query: MessageQuery;
|
||||
sentTimestamps: ReadonlySet<number>;
|
||||
}): boolean {
|
||||
const author = getAuthor(message);
|
||||
|
||||
const conversationMatches = message.conversationId === conversationId;
|
||||
const aciMatches =
|
||||
query.authorAci && author?.attributes.serviceId === query.authorAci;
|
||||
const pniMatches =
|
||||
query.authorPni && author?.attributes.serviceId === query.authorPni;
|
||||
const e164Matches =
|
||||
query.authorE164 && author?.attributes.e164 === query.authorE164;
|
||||
const timestampMatches = sentTimestamps.has(query.sentAt);
|
||||
|
||||
return Boolean(
|
||||
conversationMatches &&
|
||||
timestampMatches &&
|
||||
(aciMatches || e164Matches || pniMatches)
|
||||
);
|
||||
}
|
||||
|
||||
export async function findMatchingMessage(
|
||||
conversationId: string,
|
||||
query: MessageQuery
|
||||
): Promise<MessageAttributesType | undefined> {
|
||||
const sentAtMatches = await getMessagesBySentAt(query.sentAt);
|
||||
|
||||
if (!sentAtMatches.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return sentAtMatches.find(message => {
|
||||
const sentTimestamps = getMessageSentTimestampSet(message);
|
||||
return doesMessageMatch({
|
||||
conversationId,
|
||||
message,
|
||||
query,
|
||||
sentTimestamps,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export async function deleteMessage(
|
||||
conversationId: string,
|
||||
targetMessage: MessageToDelete,
|
||||
targetMessage: AddressableMessage,
|
||||
logId: string
|
||||
): Promise<boolean> {
|
||||
const query = getMessageQueryFromTarget(targetMessage);
|
||||
@@ -115,7 +52,7 @@ export async function applyDeleteMessage(
|
||||
|
||||
export async function deleteAttachmentFromMessage(
|
||||
conversationId: string,
|
||||
targetMessage: MessageToDelete,
|
||||
targetMessage: AddressableMessage,
|
||||
deleteAttachmentData: {
|
||||
clientUuid?: string;
|
||||
fallbackDigest?: string;
|
||||
@@ -256,7 +193,7 @@ export async function applyDeleteAttachmentFromMessage(
|
||||
|
||||
async function getMostRecentMatchingMessage(
|
||||
conversationId: string,
|
||||
targetMessages: Array<MessageToDelete>
|
||||
targetMessages: Array<AddressableMessage>
|
||||
): Promise<MessageAttributesType | undefined> {
|
||||
const queries = targetMessages.map(getMessageQueryFromTarget);
|
||||
const found = await Promise.all(
|
||||
@@ -269,8 +206,8 @@ async function getMostRecentMatchingMessage(
|
||||
|
||||
export async function deleteConversation(
|
||||
conversation: ConversationModel,
|
||||
mostRecentMessages: Array<MessageToDelete>,
|
||||
mostRecentNonExpiringMessages: Array<MessageToDelete> | undefined,
|
||||
mostRecentMessages: Array<AddressableMessage>,
|
||||
mostRecentNonExpiringMessages: Array<AddressableMessage> | undefined,
|
||||
isFullDelete: boolean,
|
||||
providedLogId: string
|
||||
): Promise<boolean> {
|
||||
@@ -351,118 +288,3 @@ export async function deleteLocalOnlyConversation(
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
export function getConversationFromTarget(
|
||||
targetConversation: ConversationToDelete
|
||||
): ConversationModel | undefined {
|
||||
const { type } = targetConversation;
|
||||
|
||||
if (type === 'aci') {
|
||||
return window.ConversationController.get(targetConversation.aci);
|
||||
}
|
||||
if (type === 'group') {
|
||||
return window.ConversationController.get(targetConversation.groupId);
|
||||
}
|
||||
if (type === 'e164') {
|
||||
return window.ConversationController.get(targetConversation.e164);
|
||||
}
|
||||
if (type === 'pni') {
|
||||
return window.ConversationController.get(targetConversation.pni);
|
||||
}
|
||||
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
type MessageQuery = {
|
||||
sentAt: number;
|
||||
authorAci?: AciString;
|
||||
authorE164?: string;
|
||||
authorPni?: PniString;
|
||||
};
|
||||
|
||||
export function getMessageQueryFromTarget(
|
||||
targetMessage: MessageToDelete
|
||||
): MessageQuery {
|
||||
const { type, sentAt } = targetMessage;
|
||||
|
||||
if (type === 'aci') {
|
||||
if (!isAciString(targetMessage.authorAci)) {
|
||||
throw new Error('Provided authorAci was not an ACI!');
|
||||
}
|
||||
return { sentAt, authorAci: targetMessage.authorAci };
|
||||
}
|
||||
if (type === 'pni') {
|
||||
if (!isPniString(targetMessage.authorPni)) {
|
||||
throw new Error('Provided authorPni was not a PNI!');
|
||||
}
|
||||
return { sentAt, authorPni: targetMessage.authorPni };
|
||||
}
|
||||
|
||||
if (type === 'e164') {
|
||||
return { sentAt, authorE164: targetMessage.authorE164 };
|
||||
}
|
||||
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
export function getConversationToDelete(
|
||||
attributes: ConversationAttributesType
|
||||
): ConversationToDelete {
|
||||
const { groupId, serviceId: aci, e164 } = attributes;
|
||||
const idForLogging = getConversationIdForLogging(attributes);
|
||||
const logId = `getConversationToDelete(${idForLogging})`;
|
||||
|
||||
if (isGroupV2(attributes) && groupId) {
|
||||
return {
|
||||
type: 'group',
|
||||
groupId,
|
||||
};
|
||||
}
|
||||
if (isGroup(attributes)) {
|
||||
throw new Error(`${logId}: is a group, but not groupV2 or no groupId!`);
|
||||
}
|
||||
if (aci && isAciString(aci)) {
|
||||
return {
|
||||
type: 'aci',
|
||||
aci,
|
||||
};
|
||||
}
|
||||
if (e164) {
|
||||
return {
|
||||
type: 'e164',
|
||||
e164,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`${logId}: No valid identifier found!`);
|
||||
}
|
||||
|
||||
export function getMessageToDelete(
|
||||
attributes: MessageAttributesType
|
||||
): MessageToDelete | undefined {
|
||||
const logId = `getMessageToDelete(${getMessageIdForLogging(attributes)})`;
|
||||
const { sent_at: sentAt } = attributes;
|
||||
|
||||
const author = getAuthor(attributes);
|
||||
const authorAci = author?.get('serviceId');
|
||||
const authorE164 = author?.get('e164');
|
||||
|
||||
if (authorAci && isAciString(authorAci)) {
|
||||
return {
|
||||
type: 'aci' as const,
|
||||
authorAci,
|
||||
sentAt,
|
||||
};
|
||||
}
|
||||
if (authorE164) {
|
||||
return {
|
||||
type: 'e164' as const,
|
||||
authorE164,
|
||||
sentAt,
|
||||
};
|
||||
}
|
||||
|
||||
log.warn(`${logId}: Message was missing source ACI/e164`);
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
@@ -70,6 +70,8 @@ export const sendTypesEnum = z.enum([
|
||||
'callLinkUpdateSync',
|
||||
'callLogEventSync',
|
||||
'deviceNameChangeSync',
|
||||
'attachmentBackfillRequestSync',
|
||||
'attachmentBackfillResponseSync',
|
||||
|
||||
// No longer used, all non-urgent
|
||||
'legacyGroupChange',
|
||||
|
@@ -26,13 +26,12 @@ import {
|
||||
isVoiceMessage,
|
||||
partitionBodyAndNormalAttachments,
|
||||
} from '../types/Attachment';
|
||||
import { AttachmentDownloadUrgency } from '../types/AttachmentDownload';
|
||||
import type { StickerType } from '../types/Stickers';
|
||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||
import { strictAssert } from './assert';
|
||||
import { isNotNil } from './isNotNil';
|
||||
import {
|
||||
AttachmentDownloadManager,
|
||||
AttachmentDownloadUrgency,
|
||||
} from '../jobs/AttachmentDownloadManager';
|
||||
import { AttachmentDownloadManager } from '../jobs/AttachmentDownloadManager';
|
||||
import { AttachmentDownloadSource } from '../sql/Interface';
|
||||
import type { MessageModel } from '../models/messages';
|
||||
import type { ConversationModel } from '../models/conversations';
|
||||
@@ -97,6 +96,7 @@ export async function queueAttachmentDownloadsForMessage(
|
||||
message: MessageModel,
|
||||
options: {
|
||||
urgency?: AttachmentDownloadUrgency;
|
||||
source?: AttachmentDownloadSource;
|
||||
isManualDownload: boolean;
|
||||
}
|
||||
): Promise<boolean> {
|
||||
@@ -304,7 +304,6 @@ export async function queueAttachmentDownloads(
|
||||
|
||||
let sticker = message.get('sticker');
|
||||
let copiedSticker = false;
|
||||
let queuedStickerDownload = false;
|
||||
if (sticker && sticker.data && sticker.data.path) {
|
||||
log.info(`${logId}: Sticker attachment already downloaded`);
|
||||
} else if (sticker) {
|
||||
@@ -312,13 +311,23 @@ export async function queueAttachmentDownloads(
|
||||
const { packId, stickerId, packKey } = sticker;
|
||||
|
||||
const status = getStickerPackStatus(packId);
|
||||
let data: AttachmentType | undefined;
|
||||
|
||||
if (status && (status === 'downloaded' || status === 'installed')) {
|
||||
try {
|
||||
log.info(`${logId}: Copying sticker from installed pack`);
|
||||
data = await copyStickerToAttachments(packId, stickerId);
|
||||
copiedSticker = true;
|
||||
const data = await copyStickerToAttachments(packId, stickerId);
|
||||
|
||||
// Refresh sticker attachment since we had to await above
|
||||
const freshSticker = message.get('sticker');
|
||||
strictAssert(freshSticker != null, 'Sticker is gone while copying');
|
||||
sticker = {
|
||||
...freshSticker,
|
||||
data,
|
||||
};
|
||||
message.set({
|
||||
sticker,
|
||||
});
|
||||
} catch (error) {
|
||||
log.error(
|
||||
`${logId}: Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
|
||||
@@ -327,11 +336,10 @@ export async function queueAttachmentDownloads(
|
||||
}
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
if (!copiedSticker) {
|
||||
if (sticker.data) {
|
||||
log.info(`${logId}: Queueing sticker download`);
|
||||
queuedStickerDownload = true;
|
||||
data = await AttachmentDownloadManager.addJob({
|
||||
await AttachmentDownloadManager.addJob({
|
||||
attachment: sticker.data,
|
||||
attachmentType: 'sticker',
|
||||
isManualDownload,
|
||||
@@ -357,18 +365,6 @@ export async function queueAttachmentDownloads(
|
||||
} else {
|
||||
await DataWriter.addStickerPackReference(stickerRef);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error('queueAttachmentDownloads: Failed to fetch sticker data');
|
||||
}
|
||||
|
||||
sticker = {
|
||||
...sticker,
|
||||
data,
|
||||
};
|
||||
}
|
||||
if (queuedStickerDownload || copiedSticker) {
|
||||
message.set({ sticker });
|
||||
}
|
||||
|
||||
let editHistory = message.get('editHistory');
|
||||
|
29
ts/util/showDownloadFailedToast.ts
Normal file
29
ts/util/showDownloadFailedToast.ts
Normal file
@@ -0,0 +1,29 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import { ToastType } from '../types/Toast';
|
||||
import { SECOND } from './durations';
|
||||
import { isOlderThan } from './timestamp';
|
||||
|
||||
const DOWNLOAD_FAILED_TIMESTAMP_REST = 10 * SECOND;
|
||||
|
||||
export const lastErrorsByMessageId = new Map<string, number>();
|
||||
|
||||
export function showDownloadFailedToast(messageId: string): void {
|
||||
const now = Date.now();
|
||||
|
||||
for (const [id, timestamp] of lastErrorsByMessageId) {
|
||||
if (isOlderThan(timestamp, DOWNLOAD_FAILED_TIMESTAMP_REST)) {
|
||||
lastErrorsByMessageId.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
const existing = lastErrorsByMessageId.get(messageId);
|
||||
if (!existing) {
|
||||
window.reduxActions.toast.showToast({
|
||||
toastType: ToastType.AttachmentDownloadFailed,
|
||||
parameters: { messageId },
|
||||
});
|
||||
lastErrorsByMessageId.set(messageId, now);
|
||||
}
|
||||
}
|
193
ts/util/syncIdentifiers.ts
Normal file
193
ts/util/syncIdentifiers.ts
Normal file
@@ -0,0 +1,193 @@
|
||||
// Copyright 2025 Signal Messenger, LLC
|
||||
// SPDX-License-Identifier: AGPL-3.0-only
|
||||
|
||||
import type {
|
||||
ConversationIdentifier,
|
||||
AddressableMessage,
|
||||
} from '../textsecure/messageReceiverEvents';
|
||||
import type {
|
||||
ConversationAttributesType,
|
||||
ReadonlyMessageAttributesType,
|
||||
MessageAttributesType,
|
||||
} from '../model-types';
|
||||
import type { AciString, PniString } from '../types/ServiceId';
|
||||
import { isPniString } from '../types/ServiceId';
|
||||
import { getAuthor } from '../messages/helpers';
|
||||
import * as log from '../logging/log';
|
||||
import type { ConversationModel } from '../models/conversations';
|
||||
import { DataReader } from '../sql/Client';
|
||||
import {
|
||||
getConversationIdForLogging,
|
||||
getMessageIdForLogging,
|
||||
} from './idForLogging';
|
||||
import { isGroup, isGroupV2 } from './whatTypeOfConversation';
|
||||
import { getMessageSentTimestampSet } from './getMessageSentTimestampSet';
|
||||
import { isAciString } from './isAciString';
|
||||
import { missingCaseError } from './missingCaseError';
|
||||
|
||||
const { getMessagesBySentAt } = DataReader;
|
||||
|
||||
export function doesMessageMatch({
|
||||
conversationId,
|
||||
message,
|
||||
query,
|
||||
sentTimestamps,
|
||||
}: {
|
||||
message: ReadonlyMessageAttributesType;
|
||||
conversationId: string;
|
||||
query: MessageQuery;
|
||||
sentTimestamps: ReadonlySet<number>;
|
||||
}): boolean {
|
||||
const author = getAuthor(message);
|
||||
|
||||
const conversationMatches = message.conversationId === conversationId;
|
||||
const aciMatches =
|
||||
query.authorAci && author?.attributes.serviceId === query.authorAci;
|
||||
const pniMatches =
|
||||
query.authorPni && author?.attributes.serviceId === query.authorPni;
|
||||
const e164Matches =
|
||||
query.authorE164 && author?.attributes.e164 === query.authorE164;
|
||||
const timestampMatches = sentTimestamps.has(query.sentAt);
|
||||
|
||||
return Boolean(
|
||||
conversationMatches &&
|
||||
timestampMatches &&
|
||||
(aciMatches || e164Matches || pniMatches)
|
||||
);
|
||||
}
|
||||
|
||||
export async function findMatchingMessage(
|
||||
conversationId: string,
|
||||
query: MessageQuery
|
||||
): Promise<MessageAttributesType | undefined> {
|
||||
const sentAtMatches = await getMessagesBySentAt(query.sentAt);
|
||||
|
||||
if (!sentAtMatches.length) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return sentAtMatches.find(message => {
|
||||
const sentTimestamps = getMessageSentTimestampSet(message);
|
||||
return doesMessageMatch({
|
||||
conversationId,
|
||||
message,
|
||||
query,
|
||||
sentTimestamps,
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
export function getConversationFromTarget(
|
||||
targetConversation: ConversationIdentifier
|
||||
): ConversationModel | undefined {
|
||||
const { type } = targetConversation;
|
||||
|
||||
if (type === 'aci') {
|
||||
return window.ConversationController.get(targetConversation.aci);
|
||||
}
|
||||
if (type === 'group') {
|
||||
return window.ConversationController.get(targetConversation.groupId);
|
||||
}
|
||||
if (type === 'e164') {
|
||||
return window.ConversationController.get(targetConversation.e164);
|
||||
}
|
||||
if (type === 'pni') {
|
||||
return window.ConversationController.get(targetConversation.pni);
|
||||
}
|
||||
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
export type MessageQuery = Readonly<{
|
||||
sentAt: number;
|
||||
authorAci?: AciString;
|
||||
authorE164?: string;
|
||||
authorPni?: PniString;
|
||||
}>;
|
||||
|
||||
export function getMessageQueryFromTarget(
|
||||
targetMessage: AddressableMessage
|
||||
): MessageQuery {
|
||||
const { type, sentAt } = targetMessage;
|
||||
|
||||
if (type === 'aci') {
|
||||
if (!isAciString(targetMessage.authorAci)) {
|
||||
throw new Error('Provided authorAci was not an ACI!');
|
||||
}
|
||||
return { sentAt, authorAci: targetMessage.authorAci };
|
||||
}
|
||||
if (type === 'pni') {
|
||||
if (!isPniString(targetMessage.authorPni)) {
|
||||
throw new Error('Provided authorPni was not a PNI!');
|
||||
}
|
||||
return { sentAt, authorPni: targetMessage.authorPni };
|
||||
}
|
||||
|
||||
if (type === 'e164') {
|
||||
return { sentAt, authorE164: targetMessage.authorE164 };
|
||||
}
|
||||
|
||||
throw missingCaseError(type);
|
||||
}
|
||||
|
||||
export function getConversationIdentifier(
|
||||
attributes: ConversationAttributesType
|
||||
): ConversationIdentifier {
|
||||
const { groupId, serviceId: aci, e164 } = attributes;
|
||||
const idForLogging = getConversationIdForLogging(attributes);
|
||||
const logId = `getConversationIdentifier(${idForLogging})`;
|
||||
|
||||
if (isGroupV2(attributes) && groupId) {
|
||||
return {
|
||||
type: 'group',
|
||||
groupId,
|
||||
};
|
||||
}
|
||||
if (isGroup(attributes)) {
|
||||
throw new Error(`${logId}: is a group, but not groupV2 or no groupId!`);
|
||||
}
|
||||
if (aci && isAciString(aci)) {
|
||||
return {
|
||||
type: 'aci',
|
||||
aci,
|
||||
};
|
||||
}
|
||||
if (e164) {
|
||||
return {
|
||||
type: 'e164',
|
||||
e164,
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(`${logId}: No valid identifier found!`);
|
||||
}
|
||||
|
||||
export function getAddressableMessage(
|
||||
attributes: ReadonlyMessageAttributesType
|
||||
): AddressableMessage | undefined {
|
||||
const logId = `getAddressableMessage(${getMessageIdForLogging(attributes)})`;
|
||||
const { sent_at: sentAt } = attributes;
|
||||
|
||||
const author = getAuthor(attributes);
|
||||
const authorAci = author?.get('serviceId');
|
||||
const authorE164 = author?.get('e164');
|
||||
|
||||
if (authorAci && isAciString(authorAci)) {
|
||||
return {
|
||||
type: 'aci' as const,
|
||||
authorAci,
|
||||
sentAt,
|
||||
};
|
||||
}
|
||||
if (authorE164) {
|
||||
return {
|
||||
type: 'e164' as const,
|
||||
authorE164,
|
||||
sentAt,
|
||||
};
|
||||
}
|
||||
|
||||
log.warn(`${logId}: Message was missing source ACI/e164`);
|
||||
|
||||
return undefined;
|
||||
}
|
@@ -17,11 +17,8 @@ import {
|
||||
receiptSyncTaskSchema,
|
||||
onReceipt,
|
||||
} from '../messageModifiers/MessageReceipts';
|
||||
import {
|
||||
deleteConversation,
|
||||
deleteLocalOnlyConversation,
|
||||
getConversationFromTarget,
|
||||
} from './deleteForMe';
|
||||
import { deleteConversation, deleteLocalOnlyConversation } from './deleteForMe';
|
||||
import { getConversationFromTarget } from './syncIdentifiers';
|
||||
import {
|
||||
onSync as onReadSync,
|
||||
readSyncTaskSchema,
|
||||
|
Reference in New Issue
Block a user