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.",
|
"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."
|
"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": {
|
"icu:save": {
|
||||||
"messageformat": "Save",
|
"messageformat": "Save",
|
||||||
"description": "Used on save buttons"
|
"description": "Used on save buttons"
|
||||||
|
@@ -222,7 +222,7 @@
|
|||||||
"@indutny/parallel-prettier": "3.0.0",
|
"@indutny/parallel-prettier": "3.0.0",
|
||||||
"@indutny/rezip-electron": "2.0.1",
|
"@indutny/rezip-electron": "2.0.1",
|
||||||
"@napi-rs/canvas": "0.1.61",
|
"@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-a11y": "8.4.4",
|
||||||
"@storybook/addon-actions": "8.4.4",
|
"@storybook/addon-actions": "8.4.4",
|
||||||
"@storybook/addon-controls": "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
|
specifier: 0.1.61
|
||||||
version: 0.1.61
|
version: 0.1.61
|
||||||
'@signalapp/mock-server':
|
'@signalapp/mock-server':
|
||||||
specifier: 11.1.0
|
specifier: 11.2.0
|
||||||
version: 11.1.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
version: 11.2.0(bufferutil@4.0.9)(utf-8-validate@5.0.10)
|
||||||
'@storybook/addon-a11y':
|
'@storybook/addon-a11y':
|
||||||
specifier: 8.4.4
|
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))
|
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':
|
'@signalapp/libsignal-client@0.67.4':
|
||||||
resolution: {integrity: sha512-nenGxomG2zH0uCoFSwBzofqSAHnJRdbIbLr8libGy9y3rCL2z62nHL79Kh1o46ZnzxgAA7Ay3/qMhwPcXq7Iig==}
|
resolution: {integrity: sha512-nenGxomG2zH0uCoFSwBzofqSAHnJRdbIbLr8libGy9y3rCL2z62nHL79Kh1o46ZnzxgAA7Ay3/qMhwPcXq7Iig==}
|
||||||
|
|
||||||
'@signalapp/mock-server@11.1.0':
|
'@signalapp/mock-server@11.2.0':
|
||||||
resolution: {integrity: sha512-SYYek3QCh57vZZGNVQW+fSXMr+xHjnO8sMAFfj8DovjqW4HIBWbt3itGyyjxSoIXnaCMK346O6I/R79w8xY/aw==}
|
resolution: {integrity: sha512-y8bueRcXVulyXRRVm2M/qT7YmxGpUbiwQsRSi7a+DDI4aUeZIDW9z7KgjElv1CN1/n9O6M1bYO+TLy4ys+7U6w==}
|
||||||
|
|
||||||
'@signalapp/parchment-cjs@3.0.1':
|
'@signalapp/parchment-cjs@3.0.1':
|
||||||
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
|
resolution: {integrity: sha512-hSBMQ1M7wE4GcC8ZeNtvpJF+DAJg3eIRRf1SiHS3I3Algav/sgJJNm6HIYm6muHuK7IJmuEjkL3ILSXgmu0RfQ==}
|
||||||
@@ -12260,7 +12260,7 @@ snapshots:
|
|||||||
type-fest: 4.26.1
|
type-fest: 4.26.1
|
||||||
uuid: 8.3.2
|
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:
|
dependencies:
|
||||||
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
|
'@indutny/parallel-prettier': 3.0.0(prettier@3.3.3)
|
||||||
'@signalapp/libsignal-client': 0.60.2
|
'@signalapp/libsignal-client': 0.60.2
|
||||||
|
@@ -631,22 +631,6 @@ message SyncMessage {
|
|||||||
}
|
}
|
||||||
|
|
||||||
message DeleteForMe {
|
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 {
|
message MessageDeletes {
|
||||||
optional ConversationIdentifier conversation = 1;
|
optional ConversationIdentifier conversation = 1;
|
||||||
repeated AddressableMessage messages = 2;
|
repeated AddressableMessage messages = 2;
|
||||||
@@ -685,6 +669,42 @@ message SyncMessage {
|
|||||||
optional uint32 deviceId = 2;
|
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 Sent sent = 1;
|
||||||
optional Contacts contacts = 2;
|
optional Contacts contacts = 2;
|
||||||
reserved /*groups*/ 3;
|
reserved /*groups*/ 3;
|
||||||
@@ -707,6 +727,8 @@ message SyncMessage {
|
|||||||
optional CallLogEvent callLogEvent = 21;
|
optional CallLogEvent callLogEvent = 21;
|
||||||
optional DeleteForMe deleteForMe = 22;
|
optional DeleteForMe deleteForMe = 22;
|
||||||
optional DeviceNameChange deviceNameChange = 23;
|
optional DeviceNameChange deviceNameChange = 23;
|
||||||
|
optional AttachmentBackfillRequest attachmentBackfillRequest = 24;
|
||||||
|
optional AttachmentBackfillResponse attachmentBackfillResponse = 25;
|
||||||
}
|
}
|
||||||
|
|
||||||
message AttachmentPointer {
|
message AttachmentPointer {
|
||||||
@@ -807,3 +829,19 @@ message BodyRange {
|
|||||||
Style style = 4;
|
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/AvatarModalButtons.scss';
|
||||||
@use 'components/AvatarPreview.scss';
|
@use 'components/AvatarPreview.scss';
|
||||||
@use 'components/AvatarTextEditor.scss';
|
@use 'components/AvatarTextEditor.scss';
|
||||||
|
@use 'components/BackfillFailureModal.scss';
|
||||||
@use 'components/BackupMediaDownloadProgress.scss';
|
@use 'components/BackupMediaDownloadProgress.scss';
|
||||||
@use 'components/BadgeCarouselIndex.scss';
|
@use 'components/BadgeCarouselIndex.scss';
|
||||||
@use 'components/BadgeDialog.scss';
|
@use 'components/BadgeDialog.scss';
|
||||||
|
@@ -76,6 +76,7 @@ import { LatestQueue } from './util/LatestQueue';
|
|||||||
import { parseIntOrThrow } from './util/parseIntOrThrow';
|
import { parseIntOrThrow } from './util/parseIntOrThrow';
|
||||||
import { getProfile } from './util/getProfile';
|
import { getProfile } from './util/getProfile';
|
||||||
import type {
|
import type {
|
||||||
|
AttachmentBackfillResponseSyncEvent,
|
||||||
ConfigurationEvent,
|
ConfigurationEvent,
|
||||||
DeliveryEvent,
|
DeliveryEvent,
|
||||||
EnvelopeQueuedEvent,
|
EnvelopeQueuedEvent,
|
||||||
@@ -721,6 +722,10 @@ export async function startApp(): Promise<void> {
|
|||||||
'deleteForMeSync',
|
'deleteForMeSync',
|
||||||
queuedEventListener(onDeleteForMeSync, false)
|
queuedEventListener(onDeleteForMeSync, false)
|
||||||
);
|
);
|
||||||
|
messageReceiver.addEventListener(
|
||||||
|
'attachmentBackfillResponseSync',
|
||||||
|
queuedEventListener(onAttachmentBackfillResponseSync, false)
|
||||||
|
);
|
||||||
messageReceiver.addEventListener(
|
messageReceiver.addEventListener(
|
||||||
'deviceNameChangeSync',
|
'deviceNameChangeSync',
|
||||||
queuedEventListener(onDeviceNameChangeSync, false)
|
queuedEventListener(onDeviceNameChangeSync, false)
|
||||||
@@ -1917,6 +1922,7 @@ export async function startApp(): Promise<void> {
|
|||||||
deleteSync: true,
|
deleteSync: true,
|
||||||
versionedExpirationTimer: true,
|
versionedExpirationTimer: true,
|
||||||
ssre2: true,
|
ssre2: true,
|
||||||
|
attachmentBackfill: true,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
@@ -3689,6 +3695,13 @@ export async function startApp(): Promise<void> {
|
|||||||
|
|
||||||
log.info(`${logId}: Done`);
|
log.info(`${logId}: Done`);
|
||||||
}
|
}
|
||||||
|
async function onAttachmentBackfillResponseSync(
|
||||||
|
ev: AttachmentBackfillResponseSyncEvent
|
||||||
|
) {
|
||||||
|
const { confirm } = ev;
|
||||||
|
await AttachmentDownloadManager.handleBackfillResponse(ev);
|
||||||
|
confirm();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
window.startApp = startApp;
|
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,
|
TapToViewNotAvailableModal,
|
||||||
type DataPropsType as TapToViewNotAvailablePropsType,
|
type DataPropsType as TapToViewNotAvailablePropsType,
|
||||||
} from './TapToViewNotAvailableModal';
|
} from './TapToViewNotAvailableModal';
|
||||||
|
import {
|
||||||
|
BackfillFailureModal,
|
||||||
|
type DataPropsType as BackfillFailureModalPropsType,
|
||||||
|
} from './BackfillFailureModal';
|
||||||
|
|
||||||
// NOTE: All types should be required for this component so that the smart
|
// NOTE: All types should be required for this component so that the smart
|
||||||
// component gives you type errors when adding/removing props.
|
// component gives you type errors when adding/removing props.
|
||||||
@@ -124,6 +128,9 @@ export type PropsType = {
|
|||||||
// TapToViewNotAvailableModal
|
// TapToViewNotAvailableModal
|
||||||
tapToViewNotAvailableModalProps: TapToViewNotAvailablePropsType | undefined;
|
tapToViewNotAvailableModalProps: TapToViewNotAvailablePropsType | undefined;
|
||||||
hideTapToViewNotAvailableModal: () => void;
|
hideTapToViewNotAvailableModal: () => void;
|
||||||
|
// BackfillFailureModal
|
||||||
|
backfillFailureModalProps: BackfillFailureModalPropsType | undefined;
|
||||||
|
hideBackfillFailureModal: () => void;
|
||||||
// UserNotFoundModal
|
// UserNotFoundModal
|
||||||
hideUserNotFoundModal: () => unknown;
|
hideUserNotFoundModal: () => unknown;
|
||||||
userNotFoundModalState: UserNotFoundModalStateType | undefined;
|
userNotFoundModalState: UserNotFoundModalStateType | undefined;
|
||||||
@@ -214,6 +221,9 @@ export function GlobalModalContainer({
|
|||||||
// TapToViewNotAvailableModal
|
// TapToViewNotAvailableModal
|
||||||
tapToViewNotAvailableModalProps,
|
tapToViewNotAvailableModalProps,
|
||||||
hideTapToViewNotAvailableModal,
|
hideTapToViewNotAvailableModal,
|
||||||
|
// BackfillFailureModal
|
||||||
|
backfillFailureModalProps,
|
||||||
|
hideBackfillFailureModal,
|
||||||
// UserNotFoundModal
|
// UserNotFoundModal
|
||||||
hideUserNotFoundModal,
|
hideUserNotFoundModal,
|
||||||
userNotFoundModalState,
|
userNotFoundModalState,
|
||||||
@@ -394,5 +404,15 @@ export function GlobalModalContainer({
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (backfillFailureModalProps != null) {
|
||||||
|
return (
|
||||||
|
<BackfillFailureModal
|
||||||
|
i18n={i18n}
|
||||||
|
onClose={hideBackfillFailureModal}
|
||||||
|
{...backfillFailureModalProps}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
@@ -1205,6 +1205,11 @@ export class Message extends React.PureComponent<Props, State> {
|
|||||||
this.openGenericAttachment();
|
this.openGenericAttachment();
|
||||||
}}
|
}}
|
||||||
tabIndex={tabIndex}
|
tabIndex={tabIndex}
|
||||||
|
aria-label={
|
||||||
|
isDownloading(firstAttachment)
|
||||||
|
? i18n('icu:cancelDownload')
|
||||||
|
: i18n('icu:startDownload')
|
||||||
|
}
|
||||||
>
|
>
|
||||||
<AttachmentStatusIcon
|
<AttachmentStatusIcon
|
||||||
key={id}
|
key={id}
|
||||||
|
@@ -4,10 +4,12 @@ import { noop, omit, throttle } from 'lodash';
|
|||||||
|
|
||||||
import * as durations from '../util/durations';
|
import * as durations from '../util/durations';
|
||||||
import * as log from '../logging/log';
|
import * as log from '../logging/log';
|
||||||
|
import type { AttachmentBackfillResponseSyncEvent } from '../textsecure/messageReceiverEvents';
|
||||||
import {
|
import {
|
||||||
type AttachmentDownloadJobTypeType,
|
type AttachmentDownloadJobTypeType,
|
||||||
type AttachmentDownloadJobType,
|
type AttachmentDownloadJobType,
|
||||||
type CoreAttachmentDownloadJobType,
|
type CoreAttachmentDownloadJobType,
|
||||||
|
AttachmentDownloadUrgency,
|
||||||
coreAttachmentDownloadJobSchema,
|
coreAttachmentDownloadJobSchema,
|
||||||
} from '../types/AttachmentDownload';
|
} from '../types/AttachmentDownload';
|
||||||
import {
|
import {
|
||||||
@@ -24,6 +26,7 @@ import {
|
|||||||
AttachmentVariant,
|
AttachmentVariant,
|
||||||
mightBeOnBackupTier,
|
mightBeOnBackupTier,
|
||||||
} from '../types/Attachment';
|
} from '../types/Attachment';
|
||||||
|
import { type ReadonlyMessageAttributesType } from '../model-types.d';
|
||||||
import { getMessageById } from '../messages/getMessageById';
|
import { getMessageById } from '../messages/getMessageById';
|
||||||
import {
|
import {
|
||||||
KIBIBYTE,
|
KIBIBYTE,
|
||||||
@@ -53,13 +56,9 @@ import {
|
|||||||
import { safeParsePartial } from '../util/schemas';
|
import { safeParsePartial } from '../util/schemas';
|
||||||
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue';
|
import { deleteDownloadsJobQueue } from './deleteDownloadsJobQueue';
|
||||||
import { createBatcher } from '../util/batcher';
|
import { createBatcher } from '../util/batcher';
|
||||||
import { isOlderThan } from '../util/timestamp';
|
import { showDownloadFailedToast } from '../util/showDownloadFailedToast';
|
||||||
import { ToastType } from '../types/Toast';
|
import { markAttachmentAsPermanentlyErrored } from '../util/attachments/markAttachmentAsPermanentlyErrored';
|
||||||
|
import { AttachmentBackfill } from './helpers/attachmentBackfill';
|
||||||
export enum AttachmentDownloadUrgency {
|
|
||||||
IMMEDIATE = 'immediate',
|
|
||||||
STANDARD = 'standard',
|
|
||||||
}
|
|
||||||
|
|
||||||
// Type for adding a new job
|
// Type for adding a new job
|
||||||
export type NewAttachmentDownloadJobType = {
|
export type NewAttachmentDownloadJobType = {
|
||||||
@@ -74,7 +73,6 @@ export type NewAttachmentDownloadJobType = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const MAX_CONCURRENT_JOBS = 3;
|
const MAX_CONCURRENT_JOBS = 3;
|
||||||
const DOWNLOAD_FAILED_TIMESTAMP_REST = 10 * durations.SECOND;
|
|
||||||
|
|
||||||
const DEFAULT_RETRY_CONFIG = {
|
const DEFAULT_RETRY_CONFIG = {
|
||||||
maxAttempts: 5,
|
maxAttempts: 5,
|
||||||
@@ -134,6 +132,8 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
#attachmentBackfill = new AttachmentBackfill();
|
||||||
|
|
||||||
private static _instance: AttachmentDownloadManager | undefined;
|
private static _instance: AttachmentDownloadManager | undefined;
|
||||||
override logPrefix = 'AttachmentDownloadManager';
|
override logPrefix = 'AttachmentDownloadManager';
|
||||||
|
|
||||||
@@ -310,6 +310,18 @@ export class AttachmentDownloadManager extends JobManager<CoreAttachmentDownload
|
|||||||
callback();
|
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 = {
|
type DependenciesType = {
|
||||||
@@ -412,9 +424,21 @@ async function runDownloadAttachmentJob({
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (error instanceof AttachmentPermanentlyUndownloadableError) {
|
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(
|
await addAttachmentToMessage(
|
||||||
message.id,
|
message.id,
|
||||||
_markAttachmentAsPermanentlyErrored(job.attachment),
|
markAttachmentAsPermanentlyErrored(job.attachment),
|
||||||
logId,
|
logId,
|
||||||
{ type: job.attachmentType }
|
{ 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 (!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);
|
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({
|
async function downloadBackupThumbnail({
|
||||||
attachment,
|
attachment,
|
||||||
abortSignal,
|
abortSignal,
|
||||||
@@ -711,17 +733,11 @@ async function downloadBackupThumbnail({
|
|||||||
|
|
||||||
function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType {
|
function _markAttachmentAsTooBig(attachment: AttachmentType): AttachmentType {
|
||||||
return {
|
return {
|
||||||
..._markAttachmentAsPermanentlyErrored(attachment),
|
...markAttachmentAsPermanentlyErrored(attachment),
|
||||||
wasTooBig: true,
|
wasTooBig: true,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function _markAttachmentAsPermanentlyErrored(
|
|
||||||
attachment: AttachmentType
|
|
||||||
): AttachmentType {
|
|
||||||
return { ...omit(attachment, ['key', 'id']), pending: false, error: true };
|
|
||||||
}
|
|
||||||
|
|
||||||
function _markAttachmentAsTransientlyErrored(
|
function _markAttachmentAsTransientlyErrored(
|
||||||
attachment: AttachmentType
|
attachment: AttachmentType
|
||||||
): 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 { MessageAttributesType } from '../model-types';
|
||||||
import type {
|
import type {
|
||||||
ConversationToDelete,
|
ConversationIdentifier,
|
||||||
MessageToDelete,
|
AddressableMessage,
|
||||||
} from '../textsecure/messageReceiverEvents';
|
} from '../textsecure/messageReceiverEvents';
|
||||||
import {
|
import {
|
||||||
deleteAttachmentFromMessage,
|
deleteAttachmentFromMessage,
|
||||||
deleteMessage,
|
deleteMessage,
|
||||||
|
} from '../util/deleteForMe';
|
||||||
|
import {
|
||||||
doesMessageMatch,
|
doesMessageMatch,
|
||||||
getConversationFromTarget,
|
getConversationFromTarget,
|
||||||
getMessageQueryFromTarget,
|
getMessageQueryFromTarget,
|
||||||
} from '../util/deleteForMe';
|
} from '../util/syncIdentifiers';
|
||||||
import { DataWriter } from '../sql/Client';
|
import { DataWriter } from '../sql/Client';
|
||||||
|
|
||||||
const { removeSyncTaskById } = DataWriter;
|
const { removeSyncTaskById } = DataWriter;
|
||||||
|
|
||||||
export type DeleteForMeAttributesType = {
|
export type DeleteForMeAttributesType = {
|
||||||
conversation: ConversationToDelete;
|
conversation: ConversationIdentifier;
|
||||||
deleteAttachmentData?: {
|
deleteAttachmentData?: {
|
||||||
clientUuid?: string;
|
clientUuid?: string;
|
||||||
fallbackDigest?: string;
|
fallbackDigest?: string;
|
||||||
fallbackPlaintextHash?: string;
|
fallbackPlaintextHash?: string;
|
||||||
};
|
};
|
||||||
envelopeId: string;
|
envelopeId: string;
|
||||||
message: MessageToDelete;
|
message: AddressableMessage;
|
||||||
syncTaskId: string;
|
syncTaskId: string;
|
||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
@@ -179,11 +179,11 @@ import { getMessageAuthorText } from '../util/getMessageAuthorText';
|
|||||||
import { downscaleOutgoingAttachment } from '../util/attachments';
|
import { downscaleOutgoingAttachment } from '../util/attachments';
|
||||||
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
|
import { MessageRequestResponseEvent } from '../types/MessageRequestResponseEvent';
|
||||||
import { hasExpiration } from '../types/Message2';
|
import { hasExpiration } from '../types/Message2';
|
||||||
import type { MessageToDelete } from '../textsecure/messageReceiverEvents';
|
import type { AddressableMessage } from '../textsecure/messageReceiverEvents';
|
||||||
import {
|
import {
|
||||||
getConversationToDelete,
|
getConversationIdentifier,
|
||||||
getMessageToDelete,
|
getAddressableMessage,
|
||||||
} from '../util/deleteForMe';
|
} from '../util/syncIdentifiers';
|
||||||
import { explodePromise } from '../util/explodePromise';
|
import { explodePromise } from '../util/explodePromise';
|
||||||
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
import { getCallHistorySelector } from '../state/selectors/callHistory';
|
||||||
import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus';
|
import { migrateLegacyReadStatus } from '../messages/migrateLegacyReadStatus';
|
||||||
@@ -5195,8 +5195,8 @@ export class ConversationModel extends window.Backbone
|
|||||||
const addressableMessages = await getMostRecentAddressableMessages(
|
const addressableMessages = await getMostRecentAddressableMessages(
|
||||||
this.id
|
this.id
|
||||||
);
|
);
|
||||||
const mostRecentMessages: Array<MessageToDelete> = addressableMessages
|
const mostRecentMessages: Array<AddressableMessage> = addressableMessages
|
||||||
.map(getMessageToDelete)
|
.map(getAddressableMessage)
|
||||||
.filter(isNotNil)
|
.filter(isNotNil)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
log.info(
|
log.info(
|
||||||
@@ -5207,12 +5207,12 @@ export class ConversationModel extends window.Backbone
|
|||||||
item => item.expireTimer
|
item => item.expireTimer
|
||||||
);
|
);
|
||||||
|
|
||||||
let mostRecentNonExpiringMessages: Array<MessageToDelete> | undefined;
|
let mostRecentNonExpiringMessages: Array<AddressableMessage> | undefined;
|
||||||
if (areAnyDisappearing) {
|
if (areAnyDisappearing) {
|
||||||
const nondisappearingAddressableMessages =
|
const nondisappearingAddressableMessages =
|
||||||
await getMostRecentAddressableNondisappearingMessages(this.id);
|
await getMostRecentAddressableNondisappearingMessages(this.id);
|
||||||
mostRecentNonExpiringMessages = nondisappearingAddressableMessages
|
mostRecentNonExpiringMessages = nondisappearingAddressableMessages
|
||||||
.map(getMessageToDelete)
|
.map(getAddressableMessage)
|
||||||
.filter(isNotNil)
|
.filter(isNotNil)
|
||||||
.slice(0, 5);
|
.slice(0, 5);
|
||||||
log.info(
|
log.info(
|
||||||
@@ -5225,7 +5225,7 @@ export class ConversationModel extends window.Backbone
|
|||||||
MessageSender.getDeleteForMeSyncMessage([
|
MessageSender.getDeleteForMeSyncMessage([
|
||||||
{
|
{
|
||||||
type: 'delete-conversation',
|
type: 'delete-conversation',
|
||||||
conversation: getConversationToDelete(this.attributes),
|
conversation: getConversationIdentifier(this.attributes),
|
||||||
isFullDelete: true,
|
isFullDelete: true,
|
||||||
mostRecentMessages,
|
mostRecentMessages,
|
||||||
mostRecentNonExpiringMessages,
|
mostRecentNonExpiringMessages,
|
||||||
@@ -5238,7 +5238,7 @@ export class ConversationModel extends window.Backbone
|
|||||||
MessageSender.getDeleteForMeSyncMessage([
|
MessageSender.getDeleteForMeSyncMessage([
|
||||||
{
|
{
|
||||||
type: 'delete-local-conversation',
|
type: 'delete-local-conversation',
|
||||||
conversation: getConversationToDelete(this.attributes),
|
conversation: getConversationIdentifier(this.attributes),
|
||||||
timestamp,
|
timestamp,
|
||||||
},
|
},
|
||||||
])
|
])
|
||||||
|
@@ -70,6 +70,7 @@ type JobType = {
|
|||||||
const OBSERVED_CAPABILITY_KEYS = Object.keys({
|
const OBSERVED_CAPABILITY_KEYS = Object.keys({
|
||||||
deleteSync: true,
|
deleteSync: true,
|
||||||
ssre2: true,
|
ssre2: true,
|
||||||
|
attachmentBackfill: true,
|
||||||
} satisfies CapabilitiesType) as ReadonlyArray<keyof CapabilitiesType>;
|
} satisfies CapabilitiesType) as ReadonlyArray<keyof CapabilitiesType>;
|
||||||
|
|
||||||
export class ProfileService {
|
export class ProfileService {
|
||||||
|
@@ -532,6 +532,7 @@ export type GetRecentStoryRepliesOptionsType = {
|
|||||||
export enum AttachmentDownloadSource {
|
export enum AttachmentDownloadSource {
|
||||||
BACKUP_IMPORT = 'backup_import',
|
BACKUP_IMPORT = 'backup_import',
|
||||||
STANDARD = 'standard',
|
STANDARD = 'standard',
|
||||||
|
BACKFILL = 'backfill',
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReadableInterface = {
|
type ReadableInterface = {
|
||||||
|
@@ -29,6 +29,7 @@ import { drop } from '../../util/drop';
|
|||||||
import type { DurationInSeconds } from '../../util/durations';
|
import type { DurationInSeconds } from '../../util/durations';
|
||||||
import * as universalExpireTimer from '../../util/universalExpireTimer';
|
import * as universalExpireTimer from '../../util/universalExpireTimer';
|
||||||
import * as Attachment from '../../types/Attachment';
|
import * as Attachment from '../../types/Attachment';
|
||||||
|
import { AttachmentDownloadUrgency } from '../../types/AttachmentDownload';
|
||||||
import { isFileDangerous } from '../../util/isFileDangerous';
|
import { isFileDangerous } from '../../util/isFileDangerous';
|
||||||
import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl';
|
import { getLocalAttachmentUrl } from '../../util/getLocalAttachmentUrl';
|
||||||
import { instance as libphonenumberInstance } from '../../util/libphonenumberInstance';
|
import { instance as libphonenumberInstance } from '../../util/libphonenumberInstance';
|
||||||
@@ -191,18 +192,15 @@ import {
|
|||||||
} from '../../util/idForLogging';
|
} from '../../util/idForLogging';
|
||||||
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
|
import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue';
|
||||||
import MessageSender from '../../textsecure/SendMessage';
|
import MessageSender from '../../textsecure/SendMessage';
|
||||||
import {
|
import { AttachmentDownloadManager } from '../../jobs/AttachmentDownloadManager';
|
||||||
AttachmentDownloadManager,
|
|
||||||
AttachmentDownloadUrgency,
|
|
||||||
} from '../../jobs/AttachmentDownloadManager';
|
|
||||||
import type {
|
import type {
|
||||||
DeleteForMeSyncEventData,
|
DeleteForMeSyncEventData,
|
||||||
MessageToDelete,
|
AddressableMessage,
|
||||||
} from '../../textsecure/messageReceiverEvents';
|
} from '../../textsecure/messageReceiverEvents';
|
||||||
import {
|
import {
|
||||||
getConversationToDelete,
|
getConversationIdentifier,
|
||||||
getMessageToDelete,
|
getAddressableMessage,
|
||||||
} from '../../util/deleteForMe';
|
} from '../../util/syncIdentifiers';
|
||||||
import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types';
|
import { MAX_MESSAGE_COUNT } from '../../util/deleteForMe.types';
|
||||||
import { markCallHistoryReadInConversation } from './callHistory';
|
import { markCallHistoryReadInConversation } from './callHistory';
|
||||||
import type { CapabilitiesType } from '../../textsecure/WebAPI';
|
import type { CapabilitiesType } from '../../textsecure/WebAPI';
|
||||||
@@ -1782,7 +1780,7 @@ function deleteMessages({
|
|||||||
const messages = (
|
const messages = (
|
||||||
await Promise.all(
|
await Promise.all(
|
||||||
messageIds.map(
|
messageIds.map(
|
||||||
async (messageId): Promise<MessageToDelete | undefined> => {
|
async (messageId): Promise<AddressableMessage | undefined> => {
|
||||||
const message = await getMessageById(messageId);
|
const message = await getMessageById(messageId);
|
||||||
if (!message) {
|
if (!message) {
|
||||||
throw new Error(`deleteMessages: Message ${messageId} missing!`);
|
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 chunks = chunk(messages, MAX_MESSAGE_COUNT);
|
||||||
const conversationToDelete = getConversationToDelete(
|
const conversationToDelete = getConversationIdentifier(
|
||||||
conversation.attributes
|
conversation.attributes
|
||||||
);
|
);
|
||||||
const timestamp = Date.now();
|
const timestamp = Date.now();
|
||||||
|
@@ -58,6 +58,7 @@ import type { StartCallData } from '../../components/ConfirmLeaveCallModal';
|
|||||||
import { getMessageById } from '../../messages/getMessageById';
|
import { getMessageById } from '../../messages/getMessageById';
|
||||||
import type { AttachmentNotAvailableModalType } from '../../components/AttachmentNotAvailableModal';
|
import type { AttachmentNotAvailableModalType } from '../../components/AttachmentNotAvailableModal';
|
||||||
import type { DataPropsType as TapToViewNotAvailablePropsType } from '../../components/TapToViewNotAvailableModal';
|
import type { DataPropsType as TapToViewNotAvailablePropsType } from '../../components/TapToViewNotAvailableModal';
|
||||||
|
import type { DataPropsType as BackfillFailureModalPropsType } from '../../components/BackfillFailureModal';
|
||||||
|
|
||||||
// State
|
// State
|
||||||
|
|
||||||
@@ -102,6 +103,7 @@ export type GlobalModalsStateType = ReadonlyDeep<{
|
|||||||
addUserToAnotherGroupModalContactId?: string;
|
addUserToAnotherGroupModalContactId?: string;
|
||||||
aboutContactModalContactId?: string;
|
aboutContactModalContactId?: string;
|
||||||
attachmentNotAvailableModalType: AttachmentNotAvailableModalType | undefined;
|
attachmentNotAvailableModalType: AttachmentNotAvailableModalType | undefined;
|
||||||
|
backfillFailureModalProps: BackfillFailureModalPropsType | undefined;
|
||||||
callLinkAddNameModalRoomId: string | null;
|
callLinkAddNameModalRoomId: string | null;
|
||||||
callLinkEditModalRoomId: string | null;
|
callLinkEditModalRoomId: string | null;
|
||||||
callLinkPendingParticipantContactId: string | undefined;
|
callLinkPendingParticipantContactId: string | undefined;
|
||||||
@@ -152,6 +154,8 @@ const SHOW_TAP_TO_VIEW_NOT_AVAILABLE_MODAL =
|
|||||||
'globalModals/SHOW_TAP_TO_VIEW_NOT_AVAILABLE_MODAL';
|
'globalModals/SHOW_TAP_TO_VIEW_NOT_AVAILABLE_MODAL';
|
||||||
const HIDE_TAP_TO_VIEW_NOT_AVAILABLE_MODAL =
|
const HIDE_TAP_TO_VIEW_NOT_AVAILABLE_MODAL =
|
||||||
'globalModals/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 HIDE_CONTACT_MODAL = 'globalModals/HIDE_CONTACT_MODAL';
|
||||||
const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL';
|
const SHOW_CONTACT_MODAL = 'globalModals/SHOW_CONTACT_MODAL';
|
||||||
const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL';
|
const HIDE_WHATS_NEW_MODAL = 'globalModals/HIDE_WHATS_NEW_MODAL_MODAL';
|
||||||
@@ -242,6 +246,15 @@ type ShowTapToViewNotAvailableModalActionType = ReadonlyDeep<{
|
|||||||
payload: TapToViewNotAvailablePropsType;
|
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 HideContactModalActionType = ReadonlyDeep<{
|
||||||
type: typeof HIDE_CONTACT_MODAL;
|
type: typeof HIDE_CONTACT_MODAL;
|
||||||
}>;
|
}>;
|
||||||
@@ -449,6 +462,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
|
|||||||
| CloseShortcutGuideModalActionType
|
| CloseShortcutGuideModalActionType
|
||||||
| CloseStickerPackPreviewActionType
|
| CloseStickerPackPreviewActionType
|
||||||
| HideAttachmentNotAvailableModalActionType
|
| HideAttachmentNotAvailableModalActionType
|
||||||
|
| HideBackfillFailureModalActionType
|
||||||
| HideContactModalActionType
|
| HideContactModalActionType
|
||||||
| HideSendAnywayDialogActiontype
|
| HideSendAnywayDialogActiontype
|
||||||
| HideStoriesSettingsActionType
|
| HideStoriesSettingsActionType
|
||||||
@@ -459,6 +473,7 @@ export type GlobalModalsActionType = ReadonlyDeep<
|
|||||||
| MessageDeletedActionType
|
| MessageDeletedActionType
|
||||||
| MessageExpiredActionType
|
| MessageExpiredActionType
|
||||||
| ShowAttachmentNotAvailableModalActionType
|
| ShowAttachmentNotAvailableModalActionType
|
||||||
|
| ShowBackfillFailureModalActionType
|
||||||
| ShowContactModalActionType
|
| ShowContactModalActionType
|
||||||
| ShowEditHistoryModalActionType
|
| ShowEditHistoryModalActionType
|
||||||
| ShowErrorModalActionType
|
| ShowErrorModalActionType
|
||||||
@@ -502,6 +517,7 @@ export const actions = {
|
|||||||
closeMediaPermissionsModal,
|
closeMediaPermissionsModal,
|
||||||
ensureSystemMediaPermissions,
|
ensureSystemMediaPermissions,
|
||||||
hideAttachmentNotAvailableModal,
|
hideAttachmentNotAvailableModal,
|
||||||
|
hideBackfillFailureModal,
|
||||||
hideBlockingSafetyNumberChangeDialog,
|
hideBlockingSafetyNumberChangeDialog,
|
||||||
hideContactModal,
|
hideContactModal,
|
||||||
hideStoriesSettings,
|
hideStoriesSettings,
|
||||||
@@ -509,6 +525,7 @@ export const actions = {
|
|||||||
hideUserNotFoundModal,
|
hideUserNotFoundModal,
|
||||||
hideWhatsNewModal,
|
hideWhatsNewModal,
|
||||||
showAttachmentNotAvailableModal,
|
showAttachmentNotAvailableModal,
|
||||||
|
showBackfillFailureModal,
|
||||||
showBlockingSafetyNumberChangeDialog,
|
showBlockingSafetyNumberChangeDialog,
|
||||||
showContactModal,
|
showContactModal,
|
||||||
showEditHistoryModal,
|
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 {
|
function hideContactModal(): HideContactModalActionType {
|
||||||
return {
|
return {
|
||||||
type: HIDE_CONTACT_MODAL,
|
type: HIDE_CONTACT_MODAL,
|
||||||
@@ -1185,6 +1217,7 @@ function copyOverMessageAttributesIntoForwardMessages(
|
|||||||
export function getEmptyState(): GlobalModalsStateType {
|
export function getEmptyState(): GlobalModalsStateType {
|
||||||
return {
|
return {
|
||||||
attachmentNotAvailableModalType: undefined,
|
attachmentNotAvailableModalType: undefined,
|
||||||
|
backfillFailureModalProps: undefined,
|
||||||
hasConfirmationModal: false,
|
hasConfirmationModal: false,
|
||||||
callLinkAddNameModalRoomId: null,
|
callLinkAddNameModalRoomId: null,
|
||||||
callLinkEditModalRoomId: 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) {
|
if (action.type === SHOW_CONTACT_MODAL) {
|
||||||
const ourId = window.ConversationController.getOurConversationIdOrThrow();
|
const ourId = window.ConversationController.getOurConversationIdOrThrow();
|
||||||
if (action.payload.contactId === ourId) {
|
if (action.payload.contactId === ourId) {
|
||||||
|
@@ -41,7 +41,7 @@ import { showStickerPackPreview } from './globalModals';
|
|||||||
import { useBoundActions } from '../../hooks/useBoundActions';
|
import { useBoundActions } from '../../hooks/useBoundActions';
|
||||||
import { DataReader } from '../../sql/Client';
|
import { DataReader } from '../../sql/Client';
|
||||||
import { deleteDownloadsJobQueue } from '../../jobs/deleteDownloadsJobQueue';
|
import { deleteDownloadsJobQueue } from '../../jobs/deleteDownloadsJobQueue';
|
||||||
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
|
import { AttachmentDownloadUrgency } from '../../types/AttachmentDownload';
|
||||||
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
import { queueAttachmentDownloads } from '../../util/queueAttachmentDownloads';
|
||||||
import { getMessageIdForLogging } from '../../util/idForLogging';
|
import { getMessageIdForLogging } from '../../util/idForLogging';
|
||||||
import { markViewOnceMessageViewed } from '../../services/MessageUpdater';
|
import { markViewOnceMessageViewed } from '../../services/MessageUpdater';
|
||||||
|
@@ -70,7 +70,7 @@ import {
|
|||||||
} from '../../jobs/conversationJobQueue';
|
} from '../../jobs/conversationJobQueue';
|
||||||
import { ReceiptType } from '../../types/Receipt';
|
import { ReceiptType } from '../../types/Receipt';
|
||||||
import { cleanupMessages } from '../../util/cleanup';
|
import { cleanupMessages } from '../../util/cleanup';
|
||||||
import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager';
|
import { AttachmentDownloadUrgency } from '../../types/AttachmentDownload';
|
||||||
|
|
||||||
export type StoryDataType = ReadonlyDeep<
|
export type StoryDataType = ReadonlyDeep<
|
||||||
{
|
{
|
||||||
|
@@ -121,6 +121,7 @@ export const SmartGlobalModalContainer = memo(
|
|||||||
aboutContactModalContactId,
|
aboutContactModalContactId,
|
||||||
addUserToAnotherGroupModalContactId,
|
addUserToAnotherGroupModalContactId,
|
||||||
attachmentNotAvailableModalType,
|
attachmentNotAvailableModalType,
|
||||||
|
backfillFailureModalProps,
|
||||||
callLinkAddNameModalRoomId,
|
callLinkAddNameModalRoomId,
|
||||||
callLinkEditModalRoomId,
|
callLinkEditModalRoomId,
|
||||||
callLinkPendingParticipantContactId,
|
callLinkPendingParticipantContactId,
|
||||||
@@ -155,6 +156,7 @@ export const SmartGlobalModalContainer = memo(
|
|||||||
hideTapToViewNotAvailableModal,
|
hideTapToViewNotAvailableModal,
|
||||||
hideUserNotFoundModal,
|
hideUserNotFoundModal,
|
||||||
hideWhatsNewModal,
|
hideWhatsNewModal,
|
||||||
|
hideBackfillFailureModal,
|
||||||
toggleSignalConnectionsModal,
|
toggleSignalConnectionsModal,
|
||||||
} = useGlobalModalActions();
|
} = useGlobalModalActions();
|
||||||
|
|
||||||
@@ -210,6 +212,7 @@ export const SmartGlobalModalContainer = memo(
|
|||||||
addUserToAnotherGroupModalContactId={
|
addUserToAnotherGroupModalContactId={
|
||||||
addUserToAnotherGroupModalContactId
|
addUserToAnotherGroupModalContactId
|
||||||
}
|
}
|
||||||
|
backfillFailureModalProps={backfillFailureModalProps}
|
||||||
callLinkAddNameModalRoomId={callLinkAddNameModalRoomId}
|
callLinkAddNameModalRoomId={callLinkAddNameModalRoomId}
|
||||||
callLinkEditModalRoomId={callLinkEditModalRoomId}
|
callLinkEditModalRoomId={callLinkEditModalRoomId}
|
||||||
callLinkPendingParticipantContactId={
|
callLinkPendingParticipantContactId={
|
||||||
@@ -230,6 +233,7 @@ export const SmartGlobalModalContainer = memo(
|
|||||||
openSystemMediaPermissions={window.IPC.openSystemMediaPermissions}
|
openSystemMediaPermissions={window.IPC.openSystemMediaPermissions}
|
||||||
notePreviewModalProps={notePreviewModalProps}
|
notePreviewModalProps={notePreviewModalProps}
|
||||||
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
|
hasSafetyNumberChangeModal={hasSafetyNumberChangeModal}
|
||||||
|
hideBackfillFailureModal={hideBackfillFailureModal}
|
||||||
hideUserNotFoundModal={hideUserNotFoundModal}
|
hideUserNotFoundModal={hideUserNotFoundModal}
|
||||||
hideWhatsNewModal={hideWhatsNewModal}
|
hideWhatsNewModal={hideWhatsNewModal}
|
||||||
hideTapToViewNotAvailableModal={hideTapToViewNotAvailableModal}
|
hideTapToViewNotAvailableModal={hideTapToViewNotAvailableModal}
|
||||||
|
@@ -10,11 +10,13 @@ import { omit } from 'lodash';
|
|||||||
import * as MIME from '../../types/MIME';
|
import * as MIME from '../../types/MIME';
|
||||||
import {
|
import {
|
||||||
AttachmentDownloadManager,
|
AttachmentDownloadManager,
|
||||||
AttachmentDownloadUrgency,
|
|
||||||
runDownloadAttachmentJobInner,
|
runDownloadAttachmentJobInner,
|
||||||
type NewAttachmentDownloadJobType,
|
type NewAttachmentDownloadJobType,
|
||||||
} from '../../jobs/AttachmentDownloadManager';
|
} from '../../jobs/AttachmentDownloadManager';
|
||||||
import type { AttachmentDownloadJobType } from '../../types/AttachmentDownload';
|
import {
|
||||||
|
type AttachmentDownloadJobType,
|
||||||
|
AttachmentDownloadUrgency,
|
||||||
|
} from '../../types/AttachmentDownload';
|
||||||
import { DataReader, DataWriter } from '../../sql/Client';
|
import { DataReader, DataWriter } from '../../sql/Client';
|
||||||
import { MINUTE } from '../../util/durations';
|
import { MINUTE } from '../../util/durations';
|
||||||
import { type AttachmentType, AttachmentVariant } from '../../types/Attachment';
|
import { type AttachmentType, AttachmentVariant } from '../../types/Attachment';
|
||||||
|
@@ -146,13 +146,19 @@ export function sendTextMessage({
|
|||||||
to,
|
to,
|
||||||
text,
|
text,
|
||||||
attachments,
|
attachments,
|
||||||
|
sticker,
|
||||||
|
preview,
|
||||||
|
quote,
|
||||||
desktop,
|
desktop,
|
||||||
timestamp = Date.now(),
|
timestamp = Date.now(),
|
||||||
}: {
|
}: {
|
||||||
from: PrimaryDevice;
|
from: PrimaryDevice;
|
||||||
to: PrimaryDevice | Device | GroupInfo;
|
to: PrimaryDevice | Device | GroupInfo;
|
||||||
text: string;
|
text: string | undefined;
|
||||||
attachments?: Array<Proto.IAttachmentPointer>;
|
attachments?: Array<Proto.IAttachmentPointer>;
|
||||||
|
sticker?: Proto.DataMessage.ISticker;
|
||||||
|
preview?: Proto.IPreview;
|
||||||
|
quote?: Proto.DataMessage.IQuote;
|
||||||
desktop: Device;
|
desktop: Device;
|
||||||
timestamp?: number;
|
timestamp?: number;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
@@ -167,6 +173,9 @@ export function sendTextMessage({
|
|||||||
dataMessage: {
|
dataMessage: {
|
||||||
body: text,
|
body: text,
|
||||||
attachments,
|
attachments,
|
||||||
|
sticker,
|
||||||
|
preview: preview == null ? null : [preview],
|
||||||
|
quote,
|
||||||
timestamp: Long.fromNumber(timestamp),
|
timestamp: Long.fromNumber(timestamp),
|
||||||
groupV2: groupInfo
|
groupV2: groupInfo
|
||||||
? {
|
? {
|
||||||
@@ -209,7 +218,7 @@ export function sendReaction({
|
|||||||
reaction: {
|
reaction: {
|
||||||
emoji,
|
emoji,
|
||||||
targetAuthorAci: getDevice(targetAuthor).aci,
|
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');
|
debug('sending calling offer message');
|
||||||
await unknownContact.sendRaw(desktop, {
|
await unknownContact.sendRaw(desktop, {
|
||||||
callingMessage: {
|
callMessage: {
|
||||||
offer: {
|
offer: {
|
||||||
callId: new Long(Math.floor(Math.random() * 1e10)),
|
id: new Long(Math.floor(Math.random() * 1e10)),
|
||||||
type: Proto.CallingMessage.Offer.Type.OFFER_AUDIO_CALL,
|
type: Proto.CallMessage.Offer.Type.OFFER_AUDIO_CALL,
|
||||||
opaque: new Uint8Array(0),
|
opaque: new Uint8Array(0),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@@ -260,7 +260,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
|
|||||||
debug('Send a PNI sync message');
|
debug('Send a PNI sync message');
|
||||||
const timestamp = bootstrap.getTimestamp();
|
const timestamp = bootstrap.getTimestamp();
|
||||||
const destinationServiceId = stranger.device.pni;
|
const destinationServiceId = stranger.device.pni;
|
||||||
const destination = stranger.device.number;
|
const destinationE164 = stranger.device.number;
|
||||||
const destinationPniIdentityKey = await stranger.device.getIdentityKey(
|
const destinationPniIdentityKey = await stranger.device.getIdentityKey(
|
||||||
ServiceIdKind.PNI
|
ServiceIdKind.PNI
|
||||||
);
|
);
|
||||||
@@ -272,7 +272,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
|
|||||||
syncMessage: {
|
syncMessage: {
|
||||||
sent: {
|
sent: {
|
||||||
destinationServiceId,
|
destinationServiceId,
|
||||||
destination,
|
destinationE164,
|
||||||
timestamp: Long.fromNumber(timestamp),
|
timestamp: Long.fromNumber(timestamp),
|
||||||
message: originalDataMessage,
|
message: originalDataMessage,
|
||||||
unidentifiedStatus: [
|
unidentifiedStatus: [
|
||||||
@@ -322,7 +322,7 @@ describe('pnp/PNI Signature', function (this: Mocha.Suite) {
|
|||||||
.innerText();
|
.innerText();
|
||||||
assert.equal(
|
assert.equal(
|
||||||
strangerName.slice(-4),
|
strangerName.slice(-4),
|
||||||
destination?.slice(-4),
|
destinationE164?.slice(-4),
|
||||||
'no profile, just phone number'
|
'no profile, just phone number'
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@@ -40,6 +40,7 @@ import {
|
|||||||
SignedPreKeys,
|
SignedPreKeys,
|
||||||
} from '../LibSignalStores';
|
} from '../LibSignalStores';
|
||||||
import { verifySignature } from '../Curve';
|
import { verifySignature } from '../Curve';
|
||||||
|
import { createName } from '../util/attachmentPath';
|
||||||
import { assertDev, strictAssert } from '../util/assert';
|
import { assertDev, strictAssert } from '../util/assert';
|
||||||
import type { BatcherType } from '../util/batcher';
|
import type { BatcherType } from '../util/batcher';
|
||||||
import { createBatcher } from '../util/batcher';
|
import { createBatcher } from '../util/batcher';
|
||||||
@@ -97,14 +98,17 @@ import type {
|
|||||||
UnprocessedType,
|
UnprocessedType,
|
||||||
} from './Types.d';
|
} from './Types.d';
|
||||||
import type {
|
import type {
|
||||||
ConversationToDelete,
|
ConversationIdentifier,
|
||||||
DeleteForMeSyncEventData,
|
DeleteForMeSyncEventData,
|
||||||
|
AttachmentBackfillAttachmentType,
|
||||||
|
AttachmentBackfillResponseSyncEventData,
|
||||||
DeleteForMeSyncTarget,
|
DeleteForMeSyncTarget,
|
||||||
MessageToDelete,
|
AddressableMessage,
|
||||||
ReadSyncEventData,
|
ReadSyncEventData,
|
||||||
ViewSyncEventData,
|
ViewSyncEventData,
|
||||||
} from './messageReceiverEvents';
|
} from './messageReceiverEvents';
|
||||||
import {
|
import {
|
||||||
|
AttachmentBackfillResponseSyncEvent,
|
||||||
CallEventSyncEvent,
|
CallEventSyncEvent,
|
||||||
CallLinkUpdateSyncEvent,
|
CallLinkUpdateSyncEvent,
|
||||||
CallLogEventSyncEvent,
|
CallLogEventSyncEvent,
|
||||||
@@ -678,6 +682,11 @@ export default class MessageReceiver
|
|||||||
handler: (ev: DeleteForMeSyncEvent) => void
|
handler: (ev: DeleteForMeSyncEvent) => void
|
||||||
): void;
|
): void;
|
||||||
|
|
||||||
|
public override addEventListener(
|
||||||
|
name: 'attachmentBackfillResponseSync',
|
||||||
|
handler: (ev: AttachmentBackfillResponseSyncEvent) => void
|
||||||
|
): void;
|
||||||
|
|
||||||
public override addEventListener(
|
public override addEventListener(
|
||||||
name: 'deviceNameChangeSync',
|
name: 'deviceNameChangeSync',
|
||||||
handler: (ev: DeviceNameChangeSyncEvent) => void
|
handler: (ev: DeviceNameChangeSyncEvent) => void
|
||||||
@@ -3160,6 +3169,12 @@ export default class MessageReceiver
|
|||||||
syncMessage.deviceNameChange
|
syncMessage.deviceNameChange
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
if (syncMessage.attachmentBackfillResponse) {
|
||||||
|
return this.#handleAttachmentBackfillResponse(
|
||||||
|
envelope,
|
||||||
|
syncMessage.attachmentBackfillResponse
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
this.#removeFromCache(envelope);
|
this.#removeFromCache(envelope);
|
||||||
const envelopeId = getEnvelopeId(envelope);
|
const envelopeId = getEnvelopeId(envelope);
|
||||||
@@ -3601,10 +3616,10 @@ export default class MessageReceiver
|
|||||||
deleteSync.messageDeletes
|
deleteSync.messageDeletes
|
||||||
.flatMap((item): Array<DeleteForMeSyncTarget> | undefined => {
|
.flatMap((item): Array<DeleteForMeSyncTarget> | undefined => {
|
||||||
const messages = item.messages
|
const messages = item.messages
|
||||||
?.map(message => processMessageToDelete(message, logId))
|
?.map(message => processAddressableMessage(message, logId))
|
||||||
.filter(isNotNil);
|
.filter(isNotNil);
|
||||||
const conversation = item.conversation
|
const conversation = item.conversation
|
||||||
? processConversationToDelete(item.conversation, logId)
|
? processConversationIdentifier(item.conversation, logId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
@@ -3639,14 +3654,14 @@ export default class MessageReceiver
|
|||||||
deleteSync.conversationDeletes
|
deleteSync.conversationDeletes
|
||||||
.map(item => {
|
.map(item => {
|
||||||
const mostRecentMessages = item.mostRecentMessages
|
const mostRecentMessages = item.mostRecentMessages
|
||||||
?.map(message => processMessageToDelete(message, logId))
|
?.map(message => processAddressableMessage(message, logId))
|
||||||
.filter(isNotNil);
|
.filter(isNotNil);
|
||||||
const mostRecentNonExpiringMessages =
|
const mostRecentNonExpiringMessages =
|
||||||
item.mostRecentNonExpiringMessages
|
item.mostRecentNonExpiringMessages
|
||||||
?.map(message => processMessageToDelete(message, logId))
|
?.map(message => processAddressableMessage(message, logId))
|
||||||
.filter(isNotNil);
|
.filter(isNotNil);
|
||||||
const conversation = item.conversation
|
const conversation = item.conversation
|
||||||
? processConversationToDelete(item.conversation, logId)
|
? processConversationIdentifier(item.conversation, logId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
@@ -3680,7 +3695,7 @@ export default class MessageReceiver
|
|||||||
deleteSync.localOnlyConversationDeletes
|
deleteSync.localOnlyConversationDeletes
|
||||||
.map(item => {
|
.map(item => {
|
||||||
const conversation = item.conversation
|
const conversation = item.conversation
|
||||||
? processConversationToDelete(item.conversation, logId)
|
? processConversationIdentifier(item.conversation, logId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
@@ -3712,10 +3727,10 @@ export default class MessageReceiver
|
|||||||
targetMessage,
|
targetMessage,
|
||||||
} = item;
|
} = item;
|
||||||
const conversation = targetConversation
|
const conversation = targetConversation
|
||||||
? processConversationToDelete(targetConversation, logId)
|
? processConversationIdentifier(targetConversation, logId)
|
||||||
: undefined;
|
: undefined;
|
||||||
const message = targetMessage
|
const message = targetMessage
|
||||||
? processMessageToDelete(targetMessage, logId)
|
? processAddressableMessage(targetMessage, logId)
|
||||||
: undefined;
|
: undefined;
|
||||||
|
|
||||||
if (!conversation) {
|
if (!conversation) {
|
||||||
@@ -3782,6 +3797,84 @@ export default class MessageReceiver
|
|||||||
log.info('handleDeleteForMeSync: finished');
|
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(
|
async #handleDeviceNameChangeSync(
|
||||||
envelope: ProcessedEnvelope,
|
envelope: ProcessedEnvelope,
|
||||||
deviceNameChange: Proto.SyncMessage.IDeviceNameChange
|
deviceNameChange: Proto.SyncMessage.IDeviceNameChange
|
||||||
@@ -4030,14 +4123,14 @@ function envelopeTypeToCiphertextType(type: number | undefined): number {
|
|||||||
throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`);
|
throw new Error(`envelopeTypeToCiphertextType: Unknown type ${type}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
function processMessageToDelete(
|
function processAddressableMessage(
|
||||||
target: Proto.SyncMessage.DeleteForMe.IAddressableMessage,
|
target: Proto.IAddressableMessage,
|
||||||
logId: string
|
logId: string
|
||||||
): MessageToDelete | undefined {
|
): AddressableMessage | undefined {
|
||||||
const sentAt = target.sentTimestamp?.toNumber();
|
const sentAt = target.sentTimestamp?.toNumber();
|
||||||
if (!isNumber(sentAt)) {
|
if (!isNumber(sentAt)) {
|
||||||
log.warn(
|
log.warn(
|
||||||
`${logId}/processMessageToDelete: No sentTimestamp found! Dropping AddressableMessage.`
|
`${logId}/processAddressableMessage: No sentTimestamp found! Dropping AddressableMessage.`
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -4049,7 +4142,7 @@ function processMessageToDelete(
|
|||||||
type: 'aci' as const,
|
type: 'aci' as const,
|
||||||
authorAci: normalizeAci(
|
authorAci: normalizeAci(
|
||||||
authorServiceId,
|
authorServiceId,
|
||||||
`${logId}/processMessageToDelete/aci`
|
`${logId}/processAddressableMessage/aci`
|
||||||
),
|
),
|
||||||
sentAt,
|
sentAt,
|
||||||
};
|
};
|
||||||
@@ -4059,13 +4152,13 @@ function processMessageToDelete(
|
|||||||
type: 'pni' as const,
|
type: 'pni' as const,
|
||||||
authorPni: normalizePni(
|
authorPni: normalizePni(
|
||||||
authorServiceId,
|
authorServiceId,
|
||||||
`${logId}/processMessageToDelete/pni`
|
`${logId}/processAddressableMessage/pni`
|
||||||
),
|
),
|
||||||
sentAt,
|
sentAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
log.error(
|
log.error(
|
||||||
`${logId}/processMessageToDelete: invalid authorServiceId, Dropping AddressableMessage.`
|
`${logId}/processAddressableMessage: invalid authorServiceId, Dropping AddressableMessage.`
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -4078,15 +4171,15 @@ function processMessageToDelete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.warn(
|
log.warn(
|
||||||
`${logId}/processMessageToDelete: No author field found! Dropping AddressableMessage.`
|
`${logId}/processAddressableMessage: No author field found! Dropping AddressableMessage.`
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
function processConversationToDelete(
|
function processConversationIdentifier(
|
||||||
target: Proto.SyncMessage.DeleteForMe.IConversationIdentifier,
|
target: Proto.IConversationIdentifier,
|
||||||
logId: string
|
logId: string
|
||||||
): ConversationToDelete | undefined {
|
): ConversationIdentifier | undefined {
|
||||||
const { threadServiceId, threadGroupId, threadE164 } = target;
|
const { threadServiceId, threadGroupId, threadE164 } = target;
|
||||||
|
|
||||||
if (threadServiceId) {
|
if (threadServiceId) {
|
||||||
@@ -4103,7 +4196,7 @@ function processConversationToDelete(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
log.error(
|
log.error(
|
||||||
`${logId}/processConversationToDelete: Invalid threadServiceId, dropping ConversationIdentifier.`
|
`${logId}/processConversationIdentifier: Invalid threadServiceId, dropping ConversationIdentifier.`
|
||||||
);
|
);
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -4121,7 +4214,30 @@ function processConversationToDelete(
|
|||||||
}
|
}
|
||||||
|
|
||||||
log.warn(
|
log.warn(
|
||||||
`${logId}/processConversationToDelete: No identifier field found! Dropping ConversationIdentifier.`
|
`${logId}/processConversationIdentifier: No identifier field found! Dropping ConversationIdentifier.`
|
||||||
);
|
);
|
||||||
return undefined;
|
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 { missingCaseError } from '../util/missingCaseError';
|
||||||
import { drop } from '../util/drop';
|
import { drop } from '../util/drop';
|
||||||
import type {
|
import type {
|
||||||
ConversationToDelete,
|
ConversationIdentifier,
|
||||||
DeleteForMeSyncEventData,
|
DeleteForMeSyncEventData,
|
||||||
DeleteMessageSyncTarget,
|
DeleteMessageSyncTarget,
|
||||||
MessageToDelete,
|
AddressableMessage,
|
||||||
} from './messageReceiverEvents';
|
} from './messageReceiverEvents';
|
||||||
import { getConversationFromTarget } from '../util/deleteForMe';
|
import { getConversationFromTarget } from '../util/syncIdentifiers';
|
||||||
import type { CallDetails, CallHistoryDetails } from '../types/CallDisposition';
|
import type { CallDetails, CallHistoryDetails } from '../types/CallDisposition';
|
||||||
import {
|
import {
|
||||||
AdhocCallStatus,
|
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(
|
static getClearCallHistoryMessage(
|
||||||
latestCall: CallHistoryDetails
|
latestCall: CallHistoryDetails
|
||||||
): SingleProtoJobData {
|
): SingleProtoJobData {
|
||||||
@@ -2465,8 +2494,8 @@ export default class MessageSender {
|
|||||||
|
|
||||||
// Helpers
|
// Helpers
|
||||||
|
|
||||||
function toAddressableMessage(message: MessageToDelete) {
|
function toAddressableMessage(message: AddressableMessage) {
|
||||||
const targetMessage = new Proto.SyncMessage.DeleteForMe.AddressableMessage();
|
const targetMessage = new Proto.AddressableMessage();
|
||||||
targetMessage.sentTimestamp = Long.fromNumber(message.sentAt);
|
targetMessage.sentTimestamp = Long.fromNumber(message.sentAt);
|
||||||
|
|
||||||
if (message.type === 'aci') {
|
if (message.type === 'aci') {
|
||||||
@@ -2482,9 +2511,8 @@ function toAddressableMessage(message: MessageToDelete) {
|
|||||||
return targetMessage;
|
return targetMessage;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toConversationIdentifier(conversation: ConversationToDelete) {
|
function toConversationIdentifier(conversation: ConversationIdentifier) {
|
||||||
const targetConversation =
|
const targetConversation = new Proto.ConversationIdentifier();
|
||||||
new Proto.SyncMessage.DeleteForMe.ConversationIdentifier();
|
|
||||||
|
|
||||||
if (conversation.type === 'aci') {
|
if (conversation.type === 'aci') {
|
||||||
targetConversation.threadServiceId = conversation.aci;
|
targetConversation.threadServiceId = conversation.aci;
|
||||||
|
@@ -782,11 +782,13 @@ export type WebAPIConnectType = {
|
|||||||
export type CapabilitiesType = {
|
export type CapabilitiesType = {
|
||||||
deleteSync: boolean;
|
deleteSync: boolean;
|
||||||
ssre2: boolean;
|
ssre2: boolean;
|
||||||
|
attachmentBackfill: boolean;
|
||||||
};
|
};
|
||||||
export type CapabilitiesUploadType = {
|
export type CapabilitiesUploadType = {
|
||||||
deleteSync: true;
|
deleteSync: true;
|
||||||
versionedExpirationTimer: true;
|
versionedExpirationTimer: true;
|
||||||
ssre2: true;
|
ssre2: true;
|
||||||
|
attachmentBackfill: true;
|
||||||
};
|
};
|
||||||
|
|
||||||
type StickerPackManifestType = Uint8Array;
|
type StickerPackManifestType = Uint8Array;
|
||||||
@@ -2992,6 +2994,7 @@ export function initialize({
|
|||||||
deleteSync: true,
|
deleteSync: true,
|
||||||
versionedExpirationTimer: true,
|
versionedExpirationTimer: true,
|
||||||
ssre2: true,
|
ssre2: true,
|
||||||
|
attachmentBackfill: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonData = {
|
const jsonData = {
|
||||||
@@ -3048,6 +3051,7 @@ export function initialize({
|
|||||||
deleteSync: true,
|
deleteSync: true,
|
||||||
versionedExpirationTimer: true,
|
versionedExpirationTimer: true,
|
||||||
ssre2: true,
|
ssre2: true,
|
||||||
|
attachmentBackfill: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
const jsonData = {
|
const jsonData = {
|
||||||
|
@@ -491,7 +491,7 @@ export class DeviceNameChangeSyncEvent extends ConfirmableEvent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageToDeleteSchema = z.union([
|
const addressableMessageSchema = z.union([
|
||||||
z.object({
|
z.object({
|
||||||
type: z.literal('aci').readonly(),
|
type: z.literal('aci').readonly(),
|
||||||
authorAci: z.string().refine(isAciString),
|
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({
|
z.object({
|
||||||
type: z.literal('aci').readonly(),
|
type: z.literal('aci').readonly(),
|
||||||
aci: z.string().refine(isAciString),
|
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({
|
export const deleteMessageSchema = z.object({
|
||||||
type: z.literal('delete-message').readonly(),
|
type: z.literal('delete-message').readonly(),
|
||||||
conversation: conversationToDeleteSchema,
|
conversation: conversationIdentifierSchema,
|
||||||
message: messageToDeleteSchema,
|
message: addressableMessageSchema,
|
||||||
timestamp: z.number(),
|
timestamp: z.number(),
|
||||||
});
|
});
|
||||||
export type DeleteMessageSyncTarget = z.infer<typeof deleteMessageSchema>;
|
export type DeleteMessageSyncTarget = z.infer<typeof deleteMessageSchema>;
|
||||||
export const deleteConversationSchema = z.object({
|
export const deleteConversationSchema = z.object({
|
||||||
type: z.literal('delete-conversation').readonly(),
|
type: z.literal('delete-conversation').readonly(),
|
||||||
conversation: conversationToDeleteSchema,
|
conversation: conversationIdentifierSchema,
|
||||||
mostRecentMessages: z.array(messageToDeleteSchema),
|
mostRecentMessages: z.array(addressableMessageSchema),
|
||||||
mostRecentNonExpiringMessages: z.array(messageToDeleteSchema).optional(),
|
mostRecentNonExpiringMessages: z.array(addressableMessageSchema).optional(),
|
||||||
isFullDelete: z.boolean(),
|
isFullDelete: z.boolean(),
|
||||||
timestamp: z.number(),
|
timestamp: z.number(),
|
||||||
});
|
});
|
||||||
export const deleteLocalConversationSchema = z.object({
|
export const deleteLocalConversationSchema = z.object({
|
||||||
type: z.literal('delete-local-conversation').readonly(),
|
type: z.literal('delete-local-conversation').readonly(),
|
||||||
conversation: conversationToDeleteSchema,
|
conversation: conversationIdentifierSchema,
|
||||||
timestamp: z.number(),
|
timestamp: z.number(),
|
||||||
});
|
});
|
||||||
export const deleteAttachmentSchema = z.object({
|
export const deleteAttachmentSchema = z.object({
|
||||||
type: z.literal('delete-single-attachment').readonly(),
|
type: z.literal('delete-single-attachment').readonly(),
|
||||||
conversation: conversationToDeleteSchema,
|
conversation: conversationIdentifierSchema,
|
||||||
message: messageToDeleteSchema,
|
message: addressableMessageSchema,
|
||||||
clientUuid: z.string().optional(),
|
clientUuid: z.string().optional(),
|
||||||
fallbackDigest: z.string().optional(),
|
fallbackDigest: z.string().optional(),
|
||||||
fallbackPlaintextHash: 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<{
|
export type CallLogEventSyncEventData = Readonly<{
|
||||||
callLogEventDetails: CallLogEventDetails;
|
callLogEventDetails: CallLogEventDetails;
|
||||||
receivedAtCounter: number;
|
receivedAtCounter: number;
|
||||||
|
@@ -69,3 +69,8 @@ export const attachmentDownloadJobSchema = coreAttachmentDownloadJobSchema.and(
|
|||||||
attachment: Record<string, unknown>;
|
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: {
|
observedCapabilities: {
|
||||||
deleteSync?: true;
|
deleteSync?: true;
|
||||||
ssre2?: true;
|
ssre2?: true;
|
||||||
|
attachmentBackfill?: true;
|
||||||
|
|
||||||
// Note: Upon capability deprecation - change the value type to `never` and
|
// Note: Upon capability deprecation - change the value type to `never` and
|
||||||
// remove it in `ts/background.ts`
|
// 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 { last, sortBy } from 'lodash';
|
||||||
|
|
||||||
import * as log from '../logging/log';
|
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 { DataReader, DataWriter, deleteAndCleanup } from '../sql/Client';
|
||||||
import { deleteData } from '../types/Attachment';
|
import { deleteData } from '../types/Attachment';
|
||||||
|
|
||||||
import type {
|
import type { MessageAttributesType } from '../model-types';
|
||||||
ConversationAttributesType,
|
|
||||||
MessageAttributesType,
|
|
||||||
} from '../model-types';
|
|
||||||
import type { ConversationModel } from '../models/conversations';
|
import type { ConversationModel } from '../models/conversations';
|
||||||
import type {
|
import type { AddressableMessage } from '../textsecure/messageReceiverEvents';
|
||||||
ConversationToDelete,
|
|
||||||
MessageToDelete,
|
|
||||||
} from '../textsecure/messageReceiverEvents';
|
|
||||||
import type { AciString, PniString } from '../types/ServiceId';
|
|
||||||
import type { AttachmentType } from '../types/Attachment';
|
import type { AttachmentType } from '../types/Attachment';
|
||||||
import { MessageModel } from '../models/messages';
|
import { MessageModel } from '../models/messages';
|
||||||
import { cleanupMessages, postSaveUpdates } from './cleanup';
|
import { cleanupMessages, postSaveUpdates } from './cleanup';
|
||||||
|
import {
|
||||||
|
findMatchingMessage,
|
||||||
|
getMessageQueryFromTarget,
|
||||||
|
} from './syncIdentifiers';
|
||||||
|
|
||||||
const { getMessagesBySentAt, getMostRecentAddressableMessages } = DataReader;
|
const { getMostRecentAddressableMessages } = DataReader;
|
||||||
|
|
||||||
const { removeMessagesInConversation, saveMessage } = DataWriter;
|
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(
|
export async function deleteMessage(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
targetMessage: MessageToDelete,
|
targetMessage: AddressableMessage,
|
||||||
logId: string
|
logId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
const query = getMessageQueryFromTarget(targetMessage);
|
const query = getMessageQueryFromTarget(targetMessage);
|
||||||
@@ -115,7 +52,7 @@ export async function applyDeleteMessage(
|
|||||||
|
|
||||||
export async function deleteAttachmentFromMessage(
|
export async function deleteAttachmentFromMessage(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
targetMessage: MessageToDelete,
|
targetMessage: AddressableMessage,
|
||||||
deleteAttachmentData: {
|
deleteAttachmentData: {
|
||||||
clientUuid?: string;
|
clientUuid?: string;
|
||||||
fallbackDigest?: string;
|
fallbackDigest?: string;
|
||||||
@@ -256,7 +193,7 @@ export async function applyDeleteAttachmentFromMessage(
|
|||||||
|
|
||||||
async function getMostRecentMatchingMessage(
|
async function getMostRecentMatchingMessage(
|
||||||
conversationId: string,
|
conversationId: string,
|
||||||
targetMessages: Array<MessageToDelete>
|
targetMessages: Array<AddressableMessage>
|
||||||
): Promise<MessageAttributesType | undefined> {
|
): Promise<MessageAttributesType | undefined> {
|
||||||
const queries = targetMessages.map(getMessageQueryFromTarget);
|
const queries = targetMessages.map(getMessageQueryFromTarget);
|
||||||
const found = await Promise.all(
|
const found = await Promise.all(
|
||||||
@@ -269,8 +206,8 @@ async function getMostRecentMatchingMessage(
|
|||||||
|
|
||||||
export async function deleteConversation(
|
export async function deleteConversation(
|
||||||
conversation: ConversationModel,
|
conversation: ConversationModel,
|
||||||
mostRecentMessages: Array<MessageToDelete>,
|
mostRecentMessages: Array<AddressableMessage>,
|
||||||
mostRecentNonExpiringMessages: Array<MessageToDelete> | undefined,
|
mostRecentNonExpiringMessages: Array<AddressableMessage> | undefined,
|
||||||
isFullDelete: boolean,
|
isFullDelete: boolean,
|
||||||
providedLogId: string
|
providedLogId: string
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
@@ -351,118 +288,3 @@ export async function deleteLocalOnlyConversation(
|
|||||||
|
|
||||||
return true;
|
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',
|
'callLinkUpdateSync',
|
||||||
'callLogEventSync',
|
'callLogEventSync',
|
||||||
'deviceNameChangeSync',
|
'deviceNameChangeSync',
|
||||||
|
'attachmentBackfillRequestSync',
|
||||||
|
'attachmentBackfillResponseSync',
|
||||||
|
|
||||||
// No longer used, all non-urgent
|
// No longer used, all non-urgent
|
||||||
'legacyGroupChange',
|
'legacyGroupChange',
|
||||||
|
@@ -26,13 +26,12 @@ import {
|
|||||||
isVoiceMessage,
|
isVoiceMessage,
|
||||||
partitionBodyAndNormalAttachments,
|
partitionBodyAndNormalAttachments,
|
||||||
} from '../types/Attachment';
|
} from '../types/Attachment';
|
||||||
|
import { AttachmentDownloadUrgency } from '../types/AttachmentDownload';
|
||||||
import type { StickerType } from '../types/Stickers';
|
import type { StickerType } from '../types/Stickers';
|
||||||
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
import type { LinkPreviewType } from '../types/message/LinkPreviews';
|
||||||
|
import { strictAssert } from './assert';
|
||||||
import { isNotNil } from './isNotNil';
|
import { isNotNil } from './isNotNil';
|
||||||
import {
|
import { AttachmentDownloadManager } from '../jobs/AttachmentDownloadManager';
|
||||||
AttachmentDownloadManager,
|
|
||||||
AttachmentDownloadUrgency,
|
|
||||||
} from '../jobs/AttachmentDownloadManager';
|
|
||||||
import { AttachmentDownloadSource } from '../sql/Interface';
|
import { AttachmentDownloadSource } from '../sql/Interface';
|
||||||
import type { MessageModel } from '../models/messages';
|
import type { MessageModel } from '../models/messages';
|
||||||
import type { ConversationModel } from '../models/conversations';
|
import type { ConversationModel } from '../models/conversations';
|
||||||
@@ -97,6 +96,7 @@ export async function queueAttachmentDownloadsForMessage(
|
|||||||
message: MessageModel,
|
message: MessageModel,
|
||||||
options: {
|
options: {
|
||||||
urgency?: AttachmentDownloadUrgency;
|
urgency?: AttachmentDownloadUrgency;
|
||||||
|
source?: AttachmentDownloadSource;
|
||||||
isManualDownload: boolean;
|
isManualDownload: boolean;
|
||||||
}
|
}
|
||||||
): Promise<boolean> {
|
): Promise<boolean> {
|
||||||
@@ -304,7 +304,6 @@ export async function queueAttachmentDownloads(
|
|||||||
|
|
||||||
let sticker = message.get('sticker');
|
let sticker = message.get('sticker');
|
||||||
let copiedSticker = false;
|
let copiedSticker = false;
|
||||||
let queuedStickerDownload = false;
|
|
||||||
if (sticker && sticker.data && sticker.data.path) {
|
if (sticker && sticker.data && sticker.data.path) {
|
||||||
log.info(`${logId}: Sticker attachment already downloaded`);
|
log.info(`${logId}: Sticker attachment already downloaded`);
|
||||||
} else if (sticker) {
|
} else if (sticker) {
|
||||||
@@ -312,13 +311,23 @@ export async function queueAttachmentDownloads(
|
|||||||
const { packId, stickerId, packKey } = sticker;
|
const { packId, stickerId, packKey } = sticker;
|
||||||
|
|
||||||
const status = getStickerPackStatus(packId);
|
const status = getStickerPackStatus(packId);
|
||||||
let data: AttachmentType | undefined;
|
|
||||||
|
|
||||||
if (status && (status === 'downloaded' || status === 'installed')) {
|
if (status && (status === 'downloaded' || status === 'installed')) {
|
||||||
try {
|
try {
|
||||||
log.info(`${logId}: Copying sticker from installed pack`);
|
log.info(`${logId}: Copying sticker from installed pack`);
|
||||||
data = await copyStickerToAttachments(packId, stickerId);
|
|
||||||
copiedSticker = true;
|
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) {
|
} catch (error) {
|
||||||
log.error(
|
log.error(
|
||||||
`${logId}: Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
|
`${logId}: Problem copying sticker (${packId}, ${stickerId}) to attachments:`,
|
||||||
@@ -327,11 +336,10 @@ export async function queueAttachmentDownloads(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!data) {
|
if (!copiedSticker) {
|
||||||
if (sticker.data) {
|
if (sticker.data) {
|
||||||
log.info(`${logId}: Queueing sticker download`);
|
log.info(`${logId}: Queueing sticker download`);
|
||||||
queuedStickerDownload = true;
|
await AttachmentDownloadManager.addJob({
|
||||||
data = await AttachmentDownloadManager.addJob({
|
|
||||||
attachment: sticker.data,
|
attachment: sticker.data,
|
||||||
attachmentType: 'sticker',
|
attachmentType: 'sticker',
|
||||||
isManualDownload,
|
isManualDownload,
|
||||||
@@ -357,18 +365,6 @@ export async function queueAttachmentDownloads(
|
|||||||
} else {
|
} else {
|
||||||
await DataWriter.addStickerPackReference(stickerRef);
|
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');
|
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,
|
receiptSyncTaskSchema,
|
||||||
onReceipt,
|
onReceipt,
|
||||||
} from '../messageModifiers/MessageReceipts';
|
} from '../messageModifiers/MessageReceipts';
|
||||||
import {
|
import { deleteConversation, deleteLocalOnlyConversation } from './deleteForMe';
|
||||||
deleteConversation,
|
import { getConversationFromTarget } from './syncIdentifiers';
|
||||||
deleteLocalOnlyConversation,
|
|
||||||
getConversationFromTarget,
|
|
||||||
} from './deleteForMe';
|
|
||||||
import {
|
import {
|
||||||
onSync as onReadSync,
|
onSync as onReadSync,
|
||||||
readSyncTaskSchema,
|
readSyncTaskSchema,
|
||||||
|
Reference in New Issue
Block a user