From 2741fbb5d2e3a1c90802a591da8f5779678f584e Mon Sep 17 00:00:00 2001 From: Scott Nonnenberg Date: Tue, 10 Dec 2024 08:54:18 +1000 Subject: [PATCH] Show attachment download progress, new stop button to cancel Co-authored-by: Jamie Kyle --- _locales/en/messages.json | 24 + images/icons/v3/stop/stop-fill.svg | 1 + stylesheets/_modules.scss | 157 +++-- .../components/AttachmentDetailPill.scss | 87 +++ stylesheets/manifest.scss | 1 + ts/AttachmentCrypto.ts | 21 +- ts/components/EditHistoryMessagesModal.tsx | 23 +- ts/components/StoryViewsNRepliesModal.tsx | 1 + .../AttachmentDetailPill.stories.tsx | 91 +++ .../conversation/AttachmentDetailPill.tsx | 57 ++ ts/components/conversation/AttachmentList.tsx | 4 +- ts/components/conversation/GIF.tsx | 94 +-- ts/components/conversation/Image.stories.tsx | 86 ++- ts/components/conversation/Image.tsx | 318 +++++++---- .../conversation/ImageGrid.stories.tsx | 540 +++++++++++++++++- ts/components/conversation/ImageGrid.tsx | 150 ++++- ts/components/conversation/Message.tsx | 107 ++-- ts/components/conversation/MessageDetail.tsx | 3 + ts/components/conversation/Quote.stories.tsx | 1 + .../conversation/Timeline.stories.tsx | 1 + .../conversation/TimelineItem.stories.tsx | 1 + .../conversation/TimelineMessage.stories.tsx | 62 +- .../conversation/TimelineMessage.tsx | 5 +- ts/groups.ts | 5 +- ts/jobs/AttachmentBackupManager.ts | 5 +- ts/jobs/AttachmentDownloadManager.ts | 80 ++- ts/jobs/CallLinkFinalizeDeleteManager.ts | 2 +- ts/jobs/JobManager.ts | 94 ++- ts/jobs/helpers/sendDeleteForEveryone.ts | 2 +- ts/jobs/helpers/sendDeleteStoryForEveryone.ts | 5 +- ts/jobs/helpers/sendNormalMessage.ts | 9 +- ts/jobs/helpers/sendReaction.ts | 2 +- ts/jobs/helpers/sendStory.ts | 60 +- ts/messageModifiers/AttachmentDownloads.ts | 4 +- ts/messages/getMessageById.ts | 11 +- ts/messages/getMessagesById.ts | 11 +- ts/models/conversations.ts | 10 +- ts/models/messages.ts | 5 +- ts/reactions/enqueueReactionForSend.ts | 5 +- ts/services/MessageCache.ts | 15 +- ts/services/backups/index.ts | 6 +- ts/services/backups/util/filePointers.ts | 5 +- ts/services/contactSync.ts | 6 + ts/sql/Interface.ts | 1 + ts/sql/Server.ts | 13 + ts/state/ducks/composer.ts | 2 +- ts/state/ducks/conversations.ts | 135 ++++- ts/state/ducks/lightbox.ts | 15 +- ts/state/ducks/stories.ts | 15 +- ts/state/selectors/message.ts | 4 +- ts/state/smart/EditHistoryMessagesModal.tsx | 4 +- ts/state/smart/ForwardMessagesModal.tsx | 3 +- ts/state/smart/MessageDetail.tsx | 6 +- ts/state/smart/TimelineItem.tsx | 10 +- ts/test-both/helpers/fakeAttachment.ts | 3 +- ts/test-both/processDataMessage_test.ts | 2 +- ts/test-electron/backup/filePointer_test.ts | 10 +- .../services/AttachmentBackupManager_test.ts | 21 +- .../AttachmentDownloadManager_test.ts | 124 ++-- .../services/MessageCache_test.ts | 8 +- .../util/downloadAttachment_test.ts | 45 ++ ts/textsecure/WebAPI.ts | 3 + ts/textsecure/downloadAttachment.ts | 44 +- ts/types/Attachment.ts | 19 +- ts/util/MessageModelLogger.ts | 12 +- ts/util/attachmentDownloadQueue.ts | 5 +- ts/util/attachments.ts | 4 +- ts/util/callDisposition.ts | 5 +- ts/util/deleteGroupStoryReplyForEveryone.ts | 5 +- ts/util/deleteStoryForEveryone.ts | 5 +- ts/util/downloadAttachment.ts | 20 +- ts/util/dropZero.ts | 11 + ts/util/formatFileSize.ts | 4 +- ts/util/markConversationRead.ts | 3 +- ts/util/markOnboardingStoryAsRead.ts | 4 +- ts/util/sendDeleteForEveryoneMessage.ts | 5 +- ts/util/sendEditedMessage.ts | 5 +- ts/windows/main/start.ts | 2 +- 78 files changed, 2192 insertions(+), 562 deletions(-) create mode 100644 images/icons/v3/stop/stop-fill.svg create mode 100644 stylesheets/components/AttachmentDetailPill.scss create mode 100644 ts/components/conversation/AttachmentDetailPill.stories.tsx create mode 100644 ts/components/conversation/AttachmentDetailPill.tsx create mode 100644 ts/util/dropZero.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index 8a2c478870..c09ce58f46 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1502,6 +1502,30 @@ "messageformat": "Icon showing that this image has a caption", "description": "Used for the icon layered on top of an image in message bubbles" }, + "icu:imageOpenAlt": { + "messageformat": "Open this attachment in a larger view", + "description": "Used for the button that overlays all attachments in the timeline" + }, + "icu:startDownload": { + "messageformat": "Start download", + "description": "Describes a button shown on an an attachment to kick off the download" + }, + "icu:cancelDownload": { + "messageformat": "Cancel download", + "description": "Describes a button shown on an existing download to stop that in-progress or pending download" + }, + "icu:retryDownload": { + "messageformat": "Retry download", + "description": "Label for button shown on an existing download to restart a download that was partially completed" + }, + "icu:retryDownloadShort": { + "messageformat": "Retry", + "description": "Describes a button shown on an existing download to restart a download that was partially completed" + }, + "icu:downloadNItems": { + "messageformat": "{count, plural, one {# item} other {# items}}", + "description": "Describes a button shown on an existing download to restart a download that was partially completed" + }, "icu:save": { "messageformat": "Save", "description": "Used on save buttons" diff --git a/images/icons/v3/stop/stop-fill.svg b/images/icons/v3/stop/stop-fill.svg new file mode 100644 index 0000000000..20d7576342 --- /dev/null +++ b/images/icons/v3/stop/stop-fill.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 033759351a..e828112b34 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -2677,9 +2677,9 @@ button.ConversationDetails__action-button { align-items: center; display: flex; justify-content: center; - border-radius: 48px; - height: 48px; - width: 48px; + border-radius: 50px; + height: 50px; + width: 50px; background-color: variables.$color-black-alpha-70; } @@ -2719,52 +2719,38 @@ button.ConversationDetails__action-button { align-items: center; content: 'GIF'; - height: 24px; - width: 24px; @include mixins.font-body-1; color: variables.$color-white; } } -.module-image__download-pending { - position: relative; +.module-image__progress-circle-wrapper { + @include mixins.position-absolute-center; - &--spinner-container { - align-items: center; - display: flex; - height: 100%; - justify-content: center; - inset-inline-start: 0; - position: absolute; - top: 0; - width: 100%; + .ProgressCircle .ProgressCircle__background { + stroke: variables.$color-white-alpha-20; } + .ProgressCircle .ProgressCircle__fill { + stroke: variables.$color-white; + } +} - &--spinner { - background-color: variables.$color-gray-75; - border-radius: 48px; - height: 48px; - width: 48px; +.module-image__spinner-container { + @include mixins.position-absolute-center; - .module-image-spinner { - &__container { - margin-block: 12px; - margin-inline: auto; - } + .module-image-spinner { + &__arc { + background-color: variables.$color-black-alpha-80; + } + &__circle { + background-color: variables.$color-white; + } + + @include mixins.dark-theme { &__arc { - background-color: variables.$color-gray-75; - } - - &__circle { - background-color: variables.$color-white; - } - - @include mixins.dark-theme { - &__arc { - background-color: variables.$color-gray-75; - } + background-color: variables.$color-black-alpha-80; } } } @@ -2791,10 +2777,10 @@ button.ConversationDetails__action-button { .module-image__border-overlay { @include mixins.button-reset; - & { width: 100%; cursor: inherit; + pointer-events: none; position: absolute; top: 0; @@ -2806,6 +2792,7 @@ button.ConversationDetails__action-button { .module-image__border-overlay--with-click-handler { cursor: pointer; + pointer-events: all; } .module-image__border-overlay--with-border { @@ -2818,24 +2805,6 @@ button.ConversationDetails__action-button { } .module-image--gif { - &__filesize { - position: absolute; - top: 10px; - inset-inline-start: 10px; - padding-block: 2px; - padding-inline: 8px; - - color: variables.$color-white; - background: variables.$color-black-alpha-70; - - /* The height is: 14px + 2x2px from the padding */ - border-radius: 9px; - - font-size: 11px; - line-height: 14px; - user-select: none; - } - video { cursor: pointer; object-fit: cover; @@ -2897,22 +2866,46 @@ button.module-image__border-overlay:focus { inset-inline: 0; } -.module-image__play-overlay__circle { +.module-image__overlay-circle { @include mixins.position-absolute-center; - width: 48px; - height: 48px; - background-color: variables.$color-white; - border-radius: 24px; + @include mixins.button-reset; + & { + width: 50px; + height: 50px; + background-color: variables.$color-black-alpha-80; + border-radius: 25px; + } } -.module-image__play-overlay__icon { +.module-image__play-icon { @include mixins.position-absolute-center; height: 24px; width: 24px; @include mixins.color-svg( '../images/icons/v3/play/play-fill.svg', - variables.$color-ultramarine + variables.$color-white + ); +} +.module-image__stop-icon { + @include mixins.position-absolute-center; + + // Smaller to fit within the spinner + height: 24px; + width: 24px; + @include mixins.color-svg( + '../images/icons/v3/stop/stop-fill.svg', + variables.$color-white + ); +} +.module-image__download-icon { + @include mixins.position-absolute-center; + + height: 24px; + width: 24px; + @include mixins.color-svg( + '../images/icons/v3/arrow/arrow-down.svg', + variables.$color-white ); } @@ -2962,6 +2955,7 @@ button.module-image__border-overlay:focus { // Module: Image Grid .module-image-grid { + position: relative; display: inline-flex; flex-direction: row; align-items: center; @@ -2969,6 +2963,43 @@ button.module-image__border-overlay:focus { gap: 1px; } +.module-image-grid__download-pill { + @include mixins.position-absolute-center; + @include mixins.button-reset; + + & { + background-color: variables.$color-black-alpha-80; + color: variables.$color-white; + + height: 44px; + border-radius: 44px; + + display: inline-flex; + flex-direction: row; + align-items: center; + } +} +.module-image-grid__download_pill__icon-wrapper { + position: relative; + width: 44px; + height: 44px; + margin-inline-end: -6px; +} +.module-image-grid__download_pill__download-icon { + @include mixins.position-absolute-center; + + height: 24px; + width: 24px; + @include mixins.color-svg( + '../images/icons/v3/arrow/arrow-down.svg', + variables.$color-white + ); +} +.module-image-grid__download_pill__text-wrapper { + @include mixins.font-body-1; + margin-inline-end: 14px; +} + .module-image-grid--one-image { margin-bottom: -5px; } diff --git a/stylesheets/components/AttachmentDetailPill.scss b/stylesheets/components/AttachmentDetailPill.scss new file mode 100644 index 0000000000..94f00d54ef --- /dev/null +++ b/stylesheets/components/AttachmentDetailPill.scss @@ -0,0 +1,87 @@ +// Copyright 2021 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../mixins'; +@use '../variables'; + +// This needs to go before the top-level class, so it doesn't interfere +.AttachmentDetailPill--interactive { + @include mixins.button-reset; +} + +.AttachmentDetailPill { + position: absolute; + top: 6px; + /* stylelint-disable-next-line liberty/use-logical-spec */ + left: 6px; + height: 32px; + border-radius: 32px; + background-color: variables.$color-black-alpha-80; + + display: inline-flex; + flex-direction: row; + align-items: center; + + z-index: variables.$z-index-above-base; + + @include mixins.font-caption; + color: variables.$color-white; + + transition: width 400ms ease-out; +} + +.AttachmentDetailPill__spinner-wrapper { + position: relative; + margin: 4px; + margin-inline-end: -4px; + + .ProgressCircle .ProgressCircle__background { + stroke: variables.$color-white-alpha-20; + } + .ProgressCircle .ProgressCircle__fill { + stroke: variables.$color-white; + } + + .module-spinner__circle { + background-color: variables.$color-white-alpha-20; + } + + .module-spinner__arc { + background-color: variables.$color-white; + } +} + +.AttachmentDetailPill__text-wrapper { + margin-inline-start: 10px; + margin-inline-end: 10px; +} + +.AttachmentDetailPill__icon-wrapper { + position: relative; + margin-inline-start: 4px; + margin-inline-end: -11px; + width: 24px; + height: 24px; +} + +.AttachmentDetailPill__stop-icon { + @include mixins.position-absolute-center; + + // Smaller to fit within the spinner + height: 12px; + width: 12px; + @include mixins.color-svg( + '../images/icons/v3/stop/stop-fill.svg', + variables.$color-white + ); +} +.AttachmentDetailPill__download-icon { + @include mixins.position-absolute-center; + + height: 16px; + width: 16px; + @include mixins.color-svg( + '../images/icons/v3/arrow/arrow-down.svg', + variables.$color-white + ); +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index a96d48cd22..0ac507348e 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -25,6 +25,7 @@ @use 'components/AddUserToAnotherGroupModal.scss'; @use 'components/AnnouncementsOnlyGroupBanner.scss'; @use 'components/App.scss'; +@use 'components/AttachmentDetailPill.scss'; @use 'components/AudioCapture.scss'; @use 'components/AutoSizeInput.scss'; @use 'components/Avatar.scss'; diff --git a/ts/AttachmentCrypto.ts b/ts/AttachmentCrypto.ts index 4a539da8ed..a1194427b8 100644 --- a/ts/AttachmentCrypto.ts +++ b/ts/AttachmentCrypto.ts @@ -244,8 +244,10 @@ export async function encryptAttachmentV2({ }), peekAndUpdateHash(digest), incrementalDigestCreator, - measureSize(finalSize => { - ciphertextSize = finalSize; + measureSize({ + onComplete: finalSize => { + ciphertextSize = finalSize; + }, }), sink ?? new PassThrough().resume(), ].filter(isNotNil) @@ -434,6 +436,7 @@ export async function decryptAttachmentV2ToSink( let isPaddingAllZeros = false; let readFd; let iv: Uint8Array | undefined; + try { try { readFd = await open(ciphertextPath, 'r'); @@ -652,15 +655,27 @@ function peekAndUpdateHash(hash: Hash) { }); } -export function measureSize(onComplete: (size: number) => void): Transform { +export function measureSize({ + downloadOffset = 0, + onComplete, + onSizeUpdate, +}: { + downloadOffset?: number; + onComplete: (size: number) => void; + onSizeUpdate?: (size: number) => void; +}): Transform { let totalBytes = 0; + const passthrough = new PassThrough(); + passthrough.on('data', chunk => { totalBytes += chunk.length; + onSizeUpdate?.(totalBytes + downloadOffset); }); passthrough.on('end', () => { onComplete(totalBytes); }); + return passthrough; } diff --git a/ts/components/EditHistoryMessagesModal.tsx b/ts/components/EditHistoryMessagesModal.tsx index f58be423bd..c49f4dada1 100644 --- a/ts/components/EditHistoryMessagesModal.tsx +++ b/ts/components/EditHistoryMessagesModal.tsx @@ -23,10 +23,8 @@ export type PropsType = { getPreferredBadge: PreferredBadgeSelectorType; i18n: LocalizerType; platform: string; - kickOffAttachmentDownload: (options: { - attachment: AttachmentType; - messageId: string; - }) => void; + kickOffAttachmentDownload: (options: { messageId: string }) => void; + cancelAttachmentDownload: (options: { messageId: string }) => void; showLightbox: (options: { attachment: AttachmentType; messageId: string; @@ -73,6 +71,7 @@ const MESSAGE_DEFAULT_PROPS = { }; export function EditHistoryMessagesModal({ + cancelAttachmentDownload, closeEditHistoryModal, getPreferredBadge, editHistoryMessages, @@ -127,12 +126,8 @@ export function EditHistoryMessagesModal({ isEditedMessage isSpoilerExpanded={revealedSpoilersById[currentMessageId] || {}} key={currentMessage.timestamp} - kickOffAttachmentDownload={({ attachment }) => - kickOffAttachmentDownload({ - attachment, - messageId: currentMessage.id, - }) - } + kickOffAttachmentDownload={kickOffAttachmentDownload} + cancelAttachmentDownload={cancelAttachmentDownload} messageExpanded={(messageId, displayLimit) => { const update = { ...displayLimitById, @@ -195,12 +190,8 @@ export function EditHistoryMessagesModal({ getPreferredBadge={getPreferredBadge} i18n={i18n} isSpoilerExpanded={revealedSpoilersById[syntheticId] || {}} - kickOffAttachmentDownload={({ attachment }) => - kickOffAttachmentDownload({ - attachment, - messageId: messageAttributes.id, - }) - } + kickOffAttachmentDownload={kickOffAttachmentDownload} + cancelAttachmentDownload={cancelAttachmentDownload} messageExpanded={(messageId, displayLimit) => { const update = { ...displayLimitById, diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index 0e13e01392..d2acd1d42f 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -55,6 +55,7 @@ const MESSAGE_DEFAULT_PROPS = { onToggleSelect: shouldNeverBeCalled, onReplyToMessage: shouldNeverBeCalled, kickOffAttachmentDownload: shouldNeverBeCalled, + cancelAttachmentDownload: shouldNeverBeCalled, markAttachmentAsCorrupted: shouldNeverBeCalled, messageExpanded: shouldNeverBeCalled, openGiftBadge: shouldNeverBeCalled, diff --git a/ts/components/conversation/AttachmentDetailPill.stories.tsx b/ts/components/conversation/AttachmentDetailPill.stories.tsx new file mode 100644 index 0000000000..17af7c41ff --- /dev/null +++ b/ts/components/conversation/AttachmentDetailPill.stories.tsx @@ -0,0 +1,91 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { type PropsType, AttachmentDetailPill } from './AttachmentDetailPill'; +import { type ComponentMeta } from '../../storybook/types'; +import { setupI18n } from '../../util/setupI18n'; +import enMessages from '../../../_locales/en/messages.json'; +import { fakeAttachment } from '../../test-both/helpers/fakeAttachment'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/Conversation/AttachmentDetailPill', + component: AttachmentDetailPill, + argTypes: { + isGif: { control: { type: 'boolean' } }, + }, + args: { + i18n, + attachments: [], + isGif: false, + startDownload: action('startDownload'), + cancelDownload: action('cancelDownload'), + }, +} satisfies ComponentMeta; + +export function NoneDefaultsBlank(args: PropsType): JSX.Element { + return ; +} + +export function OneDownloadedBlank(args: PropsType): JSX.Element { + return ; +} + +export function OneNotPendingNotDownloaded(args: PropsType): JSX.Element { + return ( + + ); +} + +export function OnePendingNotDownloading(args: PropsType): JSX.Element { + return ( + + ); +} + +export function OneDownloading(args: PropsType): JSX.Element { + return ( + + ); +} + +export function OneNotPendingSomeDownloaded(args: PropsType): JSX.Element { + return ( + + ); +} diff --git a/ts/components/conversation/AttachmentDetailPill.tsx b/ts/components/conversation/AttachmentDetailPill.tsx new file mode 100644 index 0000000000..361197ef45 --- /dev/null +++ b/ts/components/conversation/AttachmentDetailPill.tsx @@ -0,0 +1,57 @@ +// Copyright 2018 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; + +import { formatFileSize } from '../../util/formatFileSize'; + +import type { AttachmentForUIType } from '../../types/Attachment'; +import type { LocalizerType } from '../../types/I18N'; + +export type PropsType = { + attachments: ReadonlyArray; + i18n: LocalizerType; + isGif?: boolean; + startDownload: () => void; + cancelDownload: () => void; +}; + +export function AttachmentDetailPill({ + attachments, + isGif, +}: PropsType): JSX.Element | null { + const areAllDownloaded = attachments.every(attachment => attachment.path); + const totalSize = attachments.reduce( + (total: number, attachment: AttachmentForUIType) => { + return total + (attachment.size ?? 0); + }, + 0 + ); + + if (areAllDownloaded || totalSize === 0) { + return null; + } + + const totalDownloadedSize = attachments.reduce( + (total: number, attachment: AttachmentForUIType) => { + return ( + total + + (attachment.path ? attachment.size : (attachment.totalDownloaded ?? 0)) + ); + }, + 0 + ); + const areAnyPending = attachments.some(attachment => attachment.pending); + + return ( +
+
+ {totalDownloadedSize > 0 && areAnyPending + ? `${formatFileSize(totalDownloadedSize, 2)} / ` + : undefined} + {formatFileSize(totalSize, 2)} + {isGif ? ' · GIF' : undefined} +
+
+ ); +} diff --git a/ts/components/conversation/AttachmentList.tsx b/ts/components/conversation/AttachmentList.tsx index 46e63b647b..90bcd43022 100644 --- a/ts/components/conversation/AttachmentList.tsx +++ b/ts/components/conversation/AttachmentList.tsx @@ -91,7 +91,6 @@ export function AttachmentList({ isVideo || attachment.pending ) { - const isDownloaded = !attachment.pending; const imageUrl = url || (isVideo ? BLANK_VIDEO_THUMBNAIL : undefined); @@ -108,7 +107,6 @@ export function AttachmentList({ className="module-staged-attachment" i18n={i18n} attachment={attachment} - isDownloaded={isDownloaded} curveBottomLeft={CurveType.Tiny} curveBottomRight={CurveType.Tiny} curveTopLeft={CurveType.Tiny} @@ -118,7 +116,7 @@ export function AttachmentList({ width={IMAGE_WIDTH} url={imageUrl} closeButton - onClick={clickAttachment} + showVisualAttachment={clickAttachment} onClickClose={closeAttachment} onError={closeAttachment} /> diff --git a/ts/components/conversation/GIF.tsx b/ts/components/conversation/GIF.tsx index f69375491f..9f39e05469 100644 --- a/ts/components/conversation/GIF.tsx +++ b/ts/components/conversation/GIF.tsx @@ -1,14 +1,13 @@ // Copyright 2021 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import React, { useRef, useState, useEffect } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import classNames from 'classnames'; import { Blurhash } from 'react-blurhash'; import type { LocalizerType, ThemeType } from '../../types/Util'; -import { Spinner } from '../Spinner'; -import type { AttachmentType } from '../../types/Attachment'; +import type { AttachmentForUIType } from '../../types/Attachment'; import { hasNotResolved, getImageDimensions, @@ -17,21 +16,26 @@ import { import * as Errors from '../../types/errors'; import * as log from '../../logging/log'; import { useReducedMotion } from '../../hooks/useReducedMotion'; +import { AttachmentDetailPill } from './AttachmentDetailPill'; +import { getSpinner } from './Image'; const MAX_GIF_REPEAT = 4; const MAX_GIF_TIME = 8; export type Props = { - readonly attachment: AttachmentType; + readonly attachment: AttachmentForUIType; readonly size?: number; readonly tabIndex: number; + // test-only, to force reduced motion experience + readonly _forceTapToPlay?: boolean; readonly i18n: LocalizerType; readonly theme?: ThemeType; onError(): void; showVisualAttachment(): void; - kickOffAttachmentDownload(): void; + startDownload(): void; + cancelDownload(): void; }; type MediaEvent = React.SyntheticEvent; @@ -41,16 +45,18 @@ export function GIF(props: Props): JSX.Element { attachment, size, tabIndex, + _forceTapToPlay, i18n, theme, onError, showVisualAttachment, - kickOffAttachmentDownload, + startDownload, + cancelDownload, } = props; - const tapToPlay = useReducedMotion(); + const tapToPlay = useReducedMotion() || _forceTapToPlay; const videoRef = useRef(null); const { height, width } = getImageDimensions(attachment, size); @@ -142,7 +148,7 @@ export function GIF(props: Props): JSX.Element { event.stopPropagation(); if (!attachment.url) { - kickOffAttachmentDownload(); + startDownload(); } else if (tapToPlay) { setPlayTime(0); setCurrentTime(0); @@ -158,21 +164,18 @@ export function GIF(props: Props): JSX.Element { event.preventDefault(); event.stopPropagation(); - kickOffAttachmentDownload(); + if (!attachment.url) { + startDownload(); + } else if (tapToPlay) { + setPlayTime(0); + setCurrentTime(0); + setRepeatCount(0); + } }; const isPending = Boolean(attachment.pending); const isNotResolved = hasNotResolved(attachment) && !isPending; - let fileSize: JSX.Element | undefined; - if (isNotResolved && attachment.fileSize) { - fileSize = ( -
- {attachment.fileSize} · GIF -
- ); - } - let gif: JSX.Element | undefined; if (isNotResolved || isPending) { gif = ( @@ -208,6 +211,35 @@ export function GIF(props: Props): JSX.Element { ); } + const cancelDownloadClick = useCallback( + (event: React.MouseEvent) => { + if (cancelDownload) { + event.preventDefault(); + event.stopPropagation(); + cancelDownload(); + } + }, + [cancelDownload] + ); + const cancelDownloadKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (cancelDownload && (event.key === 'Enter' || event.key === 'Space')) { + event.preventDefault(); + event.stopPropagation(); + cancelDownload(); + } + }, + [cancelDownload] + ); + + const spinner = getSpinner({ + attachment, + i18n, + cancelDownloadClick, + cancelDownloadKeyDown, + tabIndex, + }); + let overlay: JSX.Element | undefined; if ((tapToPlay && !isPlaying) || isNotResolved) { const className = classNames([ @@ -232,26 +264,22 @@ export function GIF(props: Props): JSX.Element { ); } - let spinner: JSX.Element | undefined; - if (isPending) { - spinner = ( -
-
- -
-
- ); - } + const detailPill = ( + + ); return (
{gif} - {overlay} {spinner} - {fileSize} + {overlay} + {detailPill}
); } diff --git a/ts/components/conversation/Image.stories.tsx b/ts/components/conversation/Image.stories.tsx index 5729d5971a..4b8a620a51 100644 --- a/ts/components/conversation/Image.stories.tsx +++ b/ts/components/conversation/Image.stories.tsx @@ -38,11 +38,13 @@ const createProps = (overrideProps: Partial = {}): Props => ({ curveTopLeft: overrideProps.curveTopLeft || CurveType.None, curveTopRight: overrideProps.curveTopRight || CurveType.None, darkOverlay: overrideProps.darkOverlay || false, - height: overrideProps.height || 100, + height: overrideProps.height || 200, i18n, noBackground: overrideProps.noBackground || false, noBorder: overrideProps.noBorder || false, - onClick: action('onClick'), + showVisualAttachment: action('showVisualAttachment'), + startDownload: action('startDownload'), + cancelDownload: action('cancelDownload'), onClickClose: action('onClickClose'), onError: action('onError'), overlayText: overrideProps.overlayText || '', @@ -50,7 +52,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ tabIndex: overrideProps.tabIndex || 0, theme: overrideProps.theme || ('light' as ThemeType), url: 'url' in overrideProps ? overrideProps.url || '' : pngUrl, - width: overrideProps.width || 100, + width: overrideProps.width || 300, }); export function UrlWithHeightWidth(): JSX.Element { @@ -107,37 +109,68 @@ export function NoBorderOrBackground(): JSX.Element { ); } -export function Pending(): JSX.Element { +export function NotDownloadedNotIncrementalNotPending(): JSX.Element { const props = createProps({ attachment: fakeAttachment({ contentType: IMAGE_PNG, fileName: 'sax.png', - url: pngUrl, - pending: true, + path: undefined, + size: 5300000, }), + url: undefined, + blurHash: 'thisisafakeblurhashthatwasmadeup', }); return ; } -export function PendingWBlurhash(): JSX.Element { +export function PendingWDownloadQueuedNotIncremental(): JSX.Element { const props = createProps({ attachment: fakeAttachment({ contentType: IMAGE_PNG, fileName: 'sax.png', - url: pngUrl, + path: undefined, pending: true, + size: 5300000, }), + url: undefined, + blurHash: 'thisisafakeblurhashthatwasmadeup', }); - return ( - - ); + return ; +} + +export function PendingWDownloadProgress(): JSX.Element { + const props = createProps({ + attachment: fakeAttachment({ + contentType: IMAGE_PNG, + fileName: 'sax.png', + path: undefined, + pending: true, + size: 5300000, + totalDownloaded: 1230000, + }), + blurHash: 'thisisafakeblurhashthatwasmadeup', + url: undefined, + }); + + return ; +} + +export function NotPendingWDownloadProgress(): JSX.Element { + const props = createProps({ + attachment: fakeAttachment({ + contentType: IMAGE_PNG, + fileName: 'sax.png', + path: undefined, + size: 5300000, + totalDownloaded: 1230000, + }), + blurHash: 'thisisafakeblurhashthatwasmadeup', + url: undefined, + }); + + return ; } export function CurvedCorners(): JSX.Element { @@ -188,11 +221,14 @@ export function FullOverlayWithText(): JSX.Element { } export function Blurhash(): JSX.Element { - const defaultProps = createProps(); - const props = { - ...defaultProps, + const props = createProps({ + attachment: fakeAttachment({ + contentType: IMAGE_PNG, + fileName: 'sax.png', + }), blurHash: 'thisisafakeblurhashthatwasmadeup', - }; + url: undefined, + }); return ; } @@ -213,12 +249,10 @@ export function UndefinedBlurHash(): JSX.Element { } export function MissingImage(): JSX.Element { - const defaultProps = createProps(); - const props = { - ...defaultProps, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - attachment: undefined as any, - }; + const props = createProps({ + attachment: undefined, + url: 'random', + }); return ; } diff --git a/ts/components/conversation/Image.tsx b/ts/components/conversation/Image.tsx index ceb031f859..ec6a6ff92b 100644 --- a/ts/components/conversation/Image.tsx +++ b/ts/components/conversation/Image.tsx @@ -2,17 +2,18 @@ // SPDX-License-Identifier: AGPL-3.0-only import type { CSSProperties } from 'react'; -import React, { useCallback, useMemo } from 'react'; +import React, { useCallback } from 'react'; import classNames from 'classnames'; import { Blurhash } from 'react-blurhash'; import { Spinner } from '../Spinner'; import type { LocalizerType, ThemeType } from '../../types/Util'; -import type { AttachmentType } from '../../types/Attachment'; -import { - isDownloaded as isDownloadedFunction, - defaultBlurHash, +import type { + AttachmentForUIType, + AttachmentType, } from '../../types/Attachment'; +import { defaultBlurHash, isReadyToView } from '../../types/Attachment'; +import { ProgressCircle } from '../ProgressCircle'; export enum CurveType { None = 0, @@ -23,10 +24,9 @@ export enum CurveType { export type Props = { alt: string; - attachment: AttachmentType; + attachment: AttachmentForUIType; url?: string; - isDownloaded?: boolean; className?: string; height?: number; width?: number; @@ -51,7 +51,9 @@ export type Props = { i18n: LocalizerType; theme?: ThemeType; - onClick?: (attachment: AttachmentType) => void; + showVisualAttachment?: (attachment: AttachmentType) => void; + cancelDownload?: () => void; + startDownload?: () => void; onClickClose?: (attachment: AttachmentType) => void; onError?: () => void; }; @@ -68,12 +70,13 @@ export function Image({ curveTopLeft, curveTopRight, darkOverlay, - isDownloaded, height = 0, i18n, noBackground, noBorder, - onClick, + showVisualAttachment, + startDownload, + cancelDownload, onClickClose, onError, overlayText, @@ -85,11 +88,6 @@ export function Image({ cropWidth = 0, cropHeight = 0, }: Props): JSX.Element { - const { caption, pending } = attachment || { caption: null, pending: true }; - const imgNotDownloaded = isDownloaded - ? false - : !isDownloadedFunction(attachment); - const resolvedBlurHash = blurHash || defaultBlurHash(theme); const curveStyles: CSSProperties = { @@ -99,48 +97,112 @@ export function Image({ borderEndEndRadius: curveBottomRight || CurveType.None, }; - const canClick = useMemo(() => { - return onClick != null && !pending; - }, [pending, onClick]); - - const handleClick = useCallback( + const showVisualAttachmentClick = useCallback( (event: React.MouseEvent) => { - if (!canClick) { + if (showVisualAttachment) { event.preventDefault(); event.stopPropagation(); - - return; - } - - if (onClick) { - event.preventDefault(); - event.stopPropagation(); - - onClick(attachment); + showVisualAttachment(attachment); } }, - [attachment, canClick, onClick] + [attachment, showVisualAttachment] ); - - const handleKeyDown = useCallback( + const showVisualAttachmentKeyDown = useCallback( (event: React.KeyboardEvent) => { - if (!canClick) { + if ( + showVisualAttachment && + (event.key === 'Enter' || event.key === 'Space') + ) { event.preventDefault(); event.stopPropagation(); - - return; - } - - if (onClick && (event.key === 'Enter' || event.key === 'Space')) { - event.preventDefault(); - event.stopPropagation(); - onClick(attachment); + showVisualAttachment(attachment); } }, - [attachment, canClick, onClick] + [attachment, showVisualAttachment] + ); + const cancelDownloadClick = useCallback( + (event: React.MouseEvent) => { + if (cancelDownload) { + event.preventDefault(); + event.stopPropagation(); + cancelDownload(); + } + }, + [cancelDownload] + ); + const cancelDownloadKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (cancelDownload && (event.key === 'Enter' || event.key === 'Space')) { + event.preventDefault(); + event.stopPropagation(); + cancelDownload(); + } + }, + [cancelDownload] + ); + const startDownloadClick = useCallback( + (event: React.MouseEvent) => { + if (startDownload) { + event.preventDefault(); + event.stopPropagation(); + startDownload(); + } + }, + [startDownload] + ); + const startDownloadKeyDown = useCallback( + (event: React.KeyboardEvent) => { + if (startDownload && (event.key === 'Enter' || event.key === 'Space')) { + event.preventDefault(); + event.stopPropagation(); + startDownload(); + } + }, + [startDownload] ); - /* eslint-disable no-nested-ternary */ + const imageOrBlurHash = url ? ( + {alt} + ) : ( + + ); + + const startDownloadButton = + startDownload && !attachment.path && !attachment.pending ? ( + + ) : undefined; + + const spinner = !cancelDownload + ? undefined + : getSpinner({ + attachment, + i18n, + cancelDownloadClick, + cancelDownloadKeyDown, + tabIndex, + }); + return (
- {pending ? ( - url || blurHash ? ( -
- {url ? ( - {alt} - ) : blurHash ? ( - - ) : undefined} -
-
- -
-
-
- ) : ( -
- -
- ) - ) : url ? ( - {alt} - ) : resolvedBlurHash ? ( - - ) : null} - {caption ? ( + {imageOrBlurHash} + {startDownloadButton} + {spinner} + + {attachment.caption ? ( ) : null} - {!pending && !imgNotDownloaded && playIconOverlay ? ( -
-
+ {attachment.path && playIconOverlay ? ( +
+
) : null} {overlayText ? ( @@ -247,22 +250,27 @@ export function Image({ {overlayText}
) : null} - {canClick ? ( + {darkOverlay || !noBorder ? ( +
+ ) : null} + {showVisualAttachment && isReadyToView(attachment) ? ( + /> ) : null} {closeButton ? ( + ); + } + + if (!attachment.pending) { + return undefined; + } + + return ( + + ); } diff --git a/ts/components/conversation/ImageGrid.stories.tsx b/ts/components/conversation/ImageGrid.stories.tsx index 5b890fee50..3978c20e13 100644 --- a/ts/components/conversation/ImageGrid.stories.tsx +++ b/ts/components/conversation/ImageGrid.stories.tsx @@ -44,7 +44,9 @@ export default { direction: 'incoming', i18n, isSticker: false, - onClick: action('onClick'), + showVisualAttachment: action('showVisualAttachment'), + startDownload: action('startDownload'), + cancelDownload: action('cancelDownload'), onError: action('onError'), stickerSize: 0, tabIndex: 0, @@ -57,13 +59,111 @@ export function OneImage(args: Props): JSX.Element { return ; } +export function OneVideo(args: Props): JSX.Element { + const props = { + ...args, + attachments: [ + fakeAttachment({ + contentType: VIDEO_MP4, + fileName: 'sax.png', + height: 1200, + url: pngUrl, + width: 800, + screenshot: { + path: 'something', + url: pngUrl, + contentType: IMAGE_PNG, + height: 1200, + width: 800, + }, + }), + ], + }; + + return ; +} + +export function OneVideoNotDownloadedNotPending(args: Props): JSX.Element { + const props = { + ...args, + attachments: [ + fakeAttachment({ + contentType: VIDEO_MP4, + fileName: 'sax.png', + path: undefined, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + url: undefined, + }), + ], + }; + + return ; +} + +export function OneVideoPendingWDownloadQueued(args: Props): JSX.Element { + const props = { + ...args, + attachments: [ + fakeAttachment({ + contentType: VIDEO_MP4, + fileName: 'sax.png', + path: undefined, + pending: true, + size: 1000000, + url: undefined, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + }), + ], + }; + + return ; +} + +export function OneVideoPendingWDownloadProgress(args: Props): JSX.Element { + const props = { + ...args, + attachments: [ + fakeAttachment({ + contentType: VIDEO_MP4, + fileName: 'sax.png', + path: undefined, + pending: true, + size: 1000000, + totalDownloaded: 300000, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + url: undefined, + }), + ], + }; + + return ; +} + +export function OneVideoDownloadProgressNotPending(args: Props): JSX.Element { + const props = { + ...args, + attachments: [ + fakeAttachment({ + contentType: VIDEO_MP4, + fileName: 'sax.png', + path: undefined, + size: 1000000, + totalDownloaded: 300000, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + url: undefined, + }), + ], + }; + + return ; +} export function TwoImages(args: Props): JSX.Element { return ( + ); +} + +export function TwoImagesPendingWDownloadProgress(args: Props): JSX.Element { + const props = { + ...args, + attachments: [ + fakeAttachment({ + contentType: IMAGE_PNG, + fileName: 'sax.png', + path: undefined, + pending: true, + size: 1000000, + totalDownloaded: 300000, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + url: undefined, + }), + fakeAttachment({ + contentType: IMAGE_PNG, + fileName: 'sax.png', + path: undefined, + pending: true, + size: 1000000, + totalDownloaded: 300000, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + url: undefined, + }), + ], + }; + + return ; +} + export function ThreeImages(args: Props): JSX.Element { return ( ; +} + +export function ThreeImagesNotDownloaded(args: Props): JSX.Element { + return ( + + ); +} + export function FourImages(args: Props): JSX.Element { return ( ; +} + +export function FourImagesNotDownloaded(args: Props): JSX.Element { + return ( + + ); +} + export function FiveImages(args: Props): JSX.Element { return ( ; +} + +export function FiveImagesNotDownloaded(args: Props): JSX.Element { + return ( + + ); +} + export const _6Images = (args: Props): JSX.Element => { return ( { ); }; +export function _6ImagesPendingWDownloadProgress(args: Props): JSX.Element { + const props = { + ...args, + attachments: [ + fakeAttachment({ + contentType: IMAGE_PNG, + fileName: 'sax.png', + path: undefined, + pending: true, + size: 1000000, + totalDownloaded: 300000, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + url: undefined, + }), + fakeAttachment({ + contentType: IMAGE_JPEG, + fileName: 'tina-rolf-269345-unsplash.jpg', + height: 1680, + url: '/fixtures/tina-rolf-269345-unsplash.jpg', + width: 3000, + }), + fakeAttachment({ + contentType: IMAGE_JPEG, + fileName: 'tina-rolf-269345-unsplash.jpg', + height: 1680, + url: '/fixtures/tina-rolf-269345-unsplash.jpg', + width: 3000, + }), + fakeAttachment({ + contentType: IMAGE_JPEG, + fileName: 'tina-rolf-269345-unsplash.jpg', + height: 1680, + url: '/fixtures/tina-rolf-269345-unsplash.jpg', + width: 3000, + }), + fakeAttachment({ + contentType: IMAGE_JPEG, + fileName: 'tina-rolf-269345-unsplash.jpg', + height: 1680, + url: '/fixtures/tina-rolf-269345-unsplash.jpg', + width: 3000, + }), + fakeAttachment({ + contentType: IMAGE_PNG, + fileName: 'sax.png', + path: undefined, + pending: true, + size: 1000000, + totalDownloaded: 300000, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + url: undefined, + }), + ], + }; + + return ; +} export function MixedContentTypes(args: Props): JSX.Element { return ( + ); +} + export function Sticker(args: Props): JSX.Element { return ( void; - onClick?: (attachment: AttachmentType) => void; + showVisualAttachment: (attachment: AttachmentType) => void; + cancelDownload: () => void; + startDownload: () => void; }; const GAP = 1; @@ -108,7 +111,9 @@ export function ImageGrid({ isSticker, stickerSize, onError, - onClick, + showVisualAttachment, + cancelDownload, + startDownload, shouldCollapseAbove, shouldCollapseBelow, tabIndex, @@ -127,10 +132,46 @@ export function ImageGrid({ const withBottomOverlay = Boolean(bottomOverlay && !withContentBelow); + const startDownloadClick = React.useCallback( + (event: React.MouseEvent) => { + if (startDownload) { + event.preventDefault(); + event.stopPropagation(); + startDownload(); + } + }, + [startDownload] + ); + const startDownloadKeyDown = React.useCallback( + (event: React.KeyboardEvent) => { + if (startDownload && (event.key === 'Enter' || event.key === 'Space')) { + event.preventDefault(); + event.stopPropagation(); + startDownload(); + } + }, + [startDownload] + ); + if (!attachments || !attachments.length) { return null; } + const detailPill = ( + + ); + const downloadPill = renderDownloadPill({ + attachments, + i18n, + startDownloadClick, + startDownloadKeyDown, + }); + if (attachments.length === 1 || !areAllAttachmentsVisual(attachments)) { const { height, width } = getImageDimensions( attachments[0], @@ -165,9 +206,12 @@ export function ImageGrid({ getUrl(attachments[0]) ?? attachments[0].thumbnailFromBackup?.url } tabIndex={tabIndex} - onClick={onClick} + showVisualAttachment={showVisualAttachment} + cancelDownload={cancelDownload} + startDownload={startDownload} onError={onError} /> + {detailPill}
); } @@ -190,7 +234,9 @@ export function ImageGrid({ width={150} cropWidth={GAP} url={getThumbnailUrl(attachments[0])} - onClick={onClick} + showVisualAttachment={showVisualAttachment} + cancelDownload={cancelDownload} + startDownload={downloadPill ? undefined : startDownload} onError={onError} /> + {detailPill} + {downloadPill}
); } @@ -232,7 +282,9 @@ export function ImageGrid({ width={200} cropWidth={GAP} url={getUrl(attachments[0])} - onClick={onClick} + showVisualAttachment={showVisualAttachment} + cancelDownload={cancelDownload} + startDownload={downloadPill ? undefined : startDownload} onError={onError} />
@@ -248,7 +300,9 @@ export function ImageGrid({ attachment={attachments[1]} playIconOverlay={isVideoAttachment(attachments[1])} url={getThumbnailUrl(attachments[1])} - onClick={onClick} + showVisualAttachment={showVisualAttachment} + cancelDownload={cancelDownload} + startDownload={downloadPill ? undefined : startDownload} onError={onError} />
+ {detailPill} + {downloadPill}
); } @@ -291,7 +349,9 @@ export function ImageGrid({ cropHeight={GAP} cropWidth={GAP} url={getThumbnailUrl(attachments[0])} - onClick={onClick} + showVisualAttachment={showVisualAttachment} + cancelDownload={cancelDownload} + startDownload={downloadPill ? undefined : startDownload} onError={onError} />
@@ -326,7 +388,9 @@ export function ImageGrid({ cropWidth={GAP} attachment={attachments[2]} url={getThumbnailUrl(attachments[2])} - onClick={onClick} + showVisualAttachment={showVisualAttachment} + cancelDownload={cancelDownload} + startDownload={downloadPill ? undefined : startDownload} onError={onError} /> + {detailPill} + {downloadPill} ); } @@ -372,7 +440,9 @@ export function ImageGrid({ width={150} cropWidth={GAP} url={getThumbnailUrl(attachments[0])} - onClick={onClick} + showVisualAttachment={showVisualAttachment} + cancelDownload={cancelDownload} + startDownload={downloadPill ? undefined : startDownload} onError={onError} /> @@ -405,7 +477,9 @@ export function ImageGrid({ cropWidth={GAP} attachment={attachments[2]} url={getThumbnailUrl(attachments[2])} - onClick={onClick} + showVisualAttachment={showVisualAttachment} + cancelDownload={cancelDownload} + startDownload={downloadPill ? undefined : startDownload} onError={onError} /> + {detailPill} + {downloadPill} ); } + +function renderDownloadPill({ + attachments, + i18n, + startDownloadClick, + startDownloadKeyDown, +}: { + attachments: ReadonlyArray; + i18n: LocalizerType; + startDownloadClick: (event: React.MouseEvent) => void; + startDownloadKeyDown: (event: React.KeyboardEvent) => void; +}): JSX.Element | null { + const downloadedOrPending = attachments.some( + attachment => attachment.path || attachment.pending + ); + if (downloadedOrPending) { + return null; + } + + return ( + + ); +} diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index 45f1176123..db9adb9504 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -13,7 +13,7 @@ import React from 'react'; import { createPortal } from 'react-dom'; import classNames from 'classnames'; import getDirection from 'direction'; -import { drop, groupBy, noop, orderBy, take, unescape } from 'lodash'; +import { drop, groupBy, orderBy, take, unescape } from 'lodash'; import { Manager, Popper, Reference } from 'react-popper'; import type { PreventOverflowModifier } from '@popperjs/core/lib/modifiers/preventOverflow'; import type { ReadonlyDeep } from 'type-fest'; @@ -52,7 +52,10 @@ import type { WidthBreakpoint } from '../_util'; import { OutgoingGiftBadgeModal } from '../OutgoingGiftBadgeModal'; import * as log from '../../logging/log'; import { StoryViewModeType } from '../../types/Stories'; -import type { AttachmentType } from '../../types/Attachment'; +import type { + AttachmentForUIType, + AttachmentType, +} from '../../types/Attachment'; import { canDisplayImage, getExtensionForDisplay, @@ -101,6 +104,7 @@ import { UserText } from '../UserText'; import { getColorForCallLink } from '../../util/getColorForCallLink'; import { getKeyFromCallLink } from '../../util/callLinks'; import { InAnotherCallTooltip } from './InAnotherCallTooltip'; +import { formatFileSize } from '../../util/formatFileSize'; const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; @@ -173,7 +177,7 @@ export type AudioAttachmentProps = { i18n: LocalizerType; buttonRef: React.RefObject; theme: ThemeType | undefined; - attachment: AttachmentType; + attachment: AttachmentForUIType; collapseMetadata: boolean; withContentAbove: boolean; withContentBelow: boolean; @@ -226,7 +230,7 @@ export type PropsData = { activeCallConversationId?: string; text?: string; textDirection: TextDirection; - textAttachment?: AttachmentType; + textAttachment?: AttachmentForUIType; isEditedMessage?: boolean; isSticker?: boolean; isTargeted?: boolean; @@ -255,7 +259,7 @@ export type PropsData = { | 'unblurredAvatarUrl' >; conversationType: ConversationTypeType; - attachments?: ReadonlyArray; + attachments?: ReadonlyArray; giftBadge?: GiftBadgeType; payment?: AnyPaymentEvent; quote?: { @@ -312,6 +316,8 @@ export type PropsData = { onKeyDown?: (event: React.KeyboardEvent) => void; item?: never; + // test-only, to force GIF's reduced motion experience + _forceTapToPlay?: boolean; }; export type PropsHousekeeping = { @@ -344,10 +350,8 @@ export type PropsActions = { showContactModal: (contactId: string, conversationId?: string) => void; showSpoiler: (messageId: string, data: Record) => void; - kickOffAttachmentDownload: (options: { - attachment: AttachmentType; - messageId: string; - }) => void; + cancelAttachmentDownload: (options: { messageId: string }) => void; + kickOffAttachmentDownload: (options: { messageId: string }) => void; markAttachmentAsCorrupted: (options: { attachment: AttachmentType; messageId: string; @@ -919,10 +923,12 @@ export class Message extends React.PureComponent { const { attachments, attachmentDroppedDueToSize, + cancelAttachmentDownload, conversationId, direction, expirationLength, expirationTimestamp, + _forceTapToPlay, i18n, id, isSticker, @@ -978,9 +984,10 @@ export class Message extends React.PureComponent { { showLightbox({ @@ -988,9 +995,13 @@ export class Message extends React.PureComponent { messageId: id, }); }} - kickOffAttachmentDownload={() => { + startDownload={() => { kickOffAttachmentDownload({ - attachment: firstAttachment, + messageId: id, + }); + }} + cancelDownload={() => { + cancelAttachmentDownload({ messageId: id, }); }} @@ -1026,12 +1037,14 @@ export class Message extends React.PureComponent { shouldCollapseAbove={shouldCollapseAbove} shouldCollapseBelow={shouldCollapseBelow} tabIndex={tabIndex} - onClick={attachment => { - if (!isDownloaded(attachment)) { - kickOffAttachmentDownload({ attachment, messageId: id }); - } else { - showLightbox({ attachment, messageId: id }); - } + showVisualAttachment={attachment => { + showLightbox({ attachment, messageId: id }); + }} + startDownload={() => { + kickOffAttachmentDownload({ messageId: id }); + }} + cancelDownload={() => { + cancelAttachmentDownload({ messageId: id }); }} /> @@ -1063,10 +1076,7 @@ export class Message extends React.PureComponent { timestamp, kickOffAttachmentDownload() { - kickOffAttachmentDownload({ - attachment: firstAttachment, - messageId: id, - }); + kickOffAttachmentDownload({ messageId: id }); }, onCorrupted() { markAttachmentAsCorrupted({ @@ -1076,7 +1086,7 @@ export class Message extends React.PureComponent { }, }); } - const { pending, fileName, fileSize, contentType } = firstAttachment; + const { pending, fileName, size, contentType } = firstAttachment; const extension = getExtensionForDisplay({ contentType, fileName }); const isDangerous = isFileDangerous(fileName || ''); @@ -1100,7 +1110,6 @@ export class Message extends React.PureComponent { if (!isDownloaded(firstAttachment)) { kickOffAttachmentDownload({ - attachment: firstAttachment, messageId: id, }); } else { @@ -1143,7 +1152,7 @@ export class Message extends React.PureComponent { `module-message__generic-attachment__file-size--${direction}` )} > - {fileSize} + {formatFileSize(size)} @@ -1158,6 +1167,7 @@ export class Message extends React.PureComponent { i18n, id, kickOffAttachmentDownload, + cancelAttachmentDownload, previews, quote, shouldCollapseAbove, @@ -1209,18 +1219,6 @@ export class Message extends React.PureComponent { 'module-message__link-preview--nonclickable': !isClickable, } ); - const onPreviewImageClick = isClickable - ? () => { - if (first.image && !isDownloaded(first.image)) { - kickOffAttachmentDownload({ - attachment: first.image, - messageId: id, - }); - return; - } - openLinkInWebBrowser(first.url); - } - : noop; const contents = ( <> {first.image && previewHasImage && isFullSizeImage ? ( @@ -1233,7 +1231,15 @@ export class Message extends React.PureComponent { onError={this.handleImageError} i18n={i18n} theme={theme} - onClick={onPreviewImageClick} + showVisualAttachment={() => { + openLinkInWebBrowser(first.url); + }} + startDownload={() => { + kickOffAttachmentDownload({ messageId: id }); + }} + cancelDownload={() => { + cancelAttachmentDownload({ messageId: id }); + }} /> ) : null}
@@ -1261,7 +1267,15 @@ export class Message extends React.PureComponent { blurHash={first.image.blurHash} onError={this.handleImageError} i18n={i18n} - onClick={onPreviewImageClick} + showVisualAttachment={() => { + openLinkInWebBrowser(first.url); + }} + startDownload={() => { + kickOffAttachmentDownload({ messageId: id }); + }} + cancelDownload={() => { + cancelAttachmentDownload({ messageId: id }); + }} />
) : null} @@ -1970,7 +1984,6 @@ export class Message extends React.PureComponent { return; } kickOffAttachmentDownload({ - attachment: textAttachment, messageId: id, }); }} @@ -2574,10 +2587,7 @@ export class Message extends React.PureComponent { } if (attachments && !isDownloaded(attachments[0])) { - kickOffAttachmentDownload({ - attachment: attachments[0], - messageId: id, - }); + kickOffAttachmentDownload({ messageId: id }); return; } @@ -2597,9 +2607,7 @@ export class Message extends React.PureComponent { event.preventDefault(); event.stopPropagation(); - const attachment = attachments[0]; - - kickOffAttachmentDownload({ attachment, messageId: id }); + kickOffAttachmentDownload({ messageId: id }); return; } @@ -2699,10 +2707,7 @@ export class Message extends React.PureComponent { const attachment = attachments[0]; if (!isDownloaded(attachment)) { - kickOffAttachmentDownload({ - attachment, - messageId: id, - }); + kickOffAttachmentDownload({ messageId: id }); return; } diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 2b0aa3f516..34a7b378c7 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -85,6 +85,7 @@ export type PropsSmartActions = Pick; export type PropsReduxActions = Pick< MessagePropsType, + | 'cancelAttachmentDownload' | 'checkForAccount' | 'clearTargetedMessage' | 'doubleCheckMissingQuoteReference' @@ -125,6 +126,7 @@ export function MessageDetail({ message, receivedAt, sentAt, + cancelAttachmentDownload, checkForAccount, clearTargetedMessage, contactNameColor, @@ -330,6 +332,7 @@ export function MessageDetail({ ({ showContactDetail: action('showContactDetail'), showContactModal: action('showContactModal'), showConversation: action('showConversation'), + cancelAttachmentDownload: action('cancelAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), messageExpanded: action('messageExpanded'), diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index 0d381442be..4f0fa532a3 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -76,6 +76,7 @@ const getDefaultProps = () => ({ retryDeleteForEveryone: action('retryDeleteForEveryone'), retryMessageSend: action('retryMessageSend'), blockGroupLinkRequests: action('blockGroupLinkRequests'), + cancelAttachmentDownload: action('cancelAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), messageExpanded: action('messageExpanded'), diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index d9be15a9a9..0685ba757b 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -300,6 +300,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ isTapToView: overrideProps.isTapToView, isTapToViewError: overrideProps.isTapToViewError, isTapToViewExpired: overrideProps.isTapToViewExpired, + cancelAttachmentDownload: action('cancelAttachmentDownload'), kickOffAttachmentDownload: action('kickOffAttachmentDownload'), markAttachmentAsCorrupted: action('markAttachmentAsCorrupted'), messageExpanded: action('messageExpanded'), @@ -1400,6 +1401,22 @@ Gif.args = { status: 'sent', }; +export const GifReducedMotion = Template.bind({}); +GifReducedMotion.args = { + attachments: [ + fakeAttachment({ + contentType: VIDEO_MP4, + flags: SignalService.AttachmentPointer.Flags.GIF, + fileName: 'cat-gif.mp4', + url: '/fixtures/cat-gif.mp4', + width: 400, + height: 332, + }), + ], + status: 'sent', + _forceTapToPlay: true, +}; + export const GifInAGroup = Template.bind({}); GifInAGroup.args = { attachments: [ @@ -1423,10 +1440,10 @@ NotDownloadedGif.args = { contentType: VIDEO_MP4, flags: SignalService.AttachmentPointer.Flags.GIF, fileName: 'cat-gif.mp4', - fileSize: '188.61 KB', blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', width: 400, height: 332, + path: undefined, }), ], status: 'sent', @@ -1440,10 +1457,48 @@ PendingGif.args = { contentType: VIDEO_MP4, flags: SignalService.AttachmentPointer.Flags.GIF, fileName: 'cat-gif.mp4', - fileSize: '188.61 KB', + size: 188610, blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', width: 400, height: 332, + path: undefined, + }), + ], + status: 'sent', +}; + +export const DownloadingGif = Template.bind({}); +DownloadingGif.args = { + attachments: [ + fakeAttachment({ + pending: true, + contentType: VIDEO_MP4, + flags: SignalService.AttachmentPointer.Flags.GIF, + fileName: 'cat-gif.mp4', + size: 188610, + totalDownloaded: 101010, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + width: 400, + height: 332, + path: undefined, + }), + ], + status: 'sent', +}; + +export const PartialDownloadNotPendingGif = Template.bind({}); +PartialDownloadNotPendingGif.args = { + attachments: [ + fakeAttachment({ + contentType: VIDEO_MP4, + flags: SignalService.AttachmentPointer.Flags.GIF, + fileName: 'cat-gif.mp4', + size: 188610, + totalDownloaded: 101010, + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + width: 400, + height: 332, + path: undefined, }), ], status: 'sent', @@ -1553,7 +1608,6 @@ OtherFileType.args = { contentType: stringToMIMEType('text/plain'), fileName: 'my-resume.txt', url: 'my-resume.txt', - fileSize: '10MB', }), ], status: 'sent', @@ -1566,7 +1620,6 @@ OtherFileTypeWithCaption.args = { contentType: stringToMIMEType('text/plain'), fileName: 'my-resume.txt', url: 'my-resume.txt', - fileSize: '10MB', }), ], status: 'sent', @@ -1581,7 +1634,6 @@ OtherFileTypeWithLongFilename.args = { fileName: 'INSERT-APP-NAME_INSERT-APP-APPLE-ID_AppStore_AppsGamesWatch.psd.zip', url: 'a2/a2334324darewer4234', - fileSize: '10MB', }), ], status: 'sent', diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx index 96fcb9d9f1..b57ffceefc 100644 --- a/ts/components/conversation/TimelineMessage.tsx +++ b/ts/components/conversation/TimelineMessage.tsx @@ -221,10 +221,7 @@ export function TimelineMessage(props: Props): JSX.Element { // check if any attachment needs to be downloaded from servers for (const attachment of attachments) { if (!isDownloaded(attachment)) { - kickOffAttachmentDownload({ - attachment, - messageId: id, - }); + kickOffAttachmentDownload({ messageId: id }); attachmentsInProgress += 1; } diff --git a/ts/groups.ts b/ts/groups.ts index 7c3b4480cf..b0076f6746 100644 --- a/ts/groups.ts +++ b/ts/groups.ts @@ -3461,7 +3461,10 @@ async function appendChangeMessages( let newMessages = 0; for (const changeMessage of mergedMessages) { - const existing = window.MessageCache.__DEPRECATED$getById(changeMessage.id); + const existing = window.MessageCache.__DEPRECATED$getById( + changeMessage.id, + 'appendChangeMessages' + ); // Update existing message if (existing) { diff --git a/ts/jobs/AttachmentBackupManager.ts b/ts/jobs/AttachmentBackupManager.ts index bf576846f6..dd9a56a339 100644 --- a/ts/jobs/AttachmentBackupManager.ts +++ b/ts/jobs/AttachmentBackupManager.ts @@ -190,7 +190,10 @@ type RunAttachmentBackupJobDependenciesType = { export async function runAttachmentBackupJob( job: AttachmentBackupJobType, - _isLastAttempt: boolean, + _options: { + isLastAttempt: boolean; + abortSignal: AbortSignal; + }, dependencies: RunAttachmentBackupJobDependenciesType = { getAbsoluteAttachmentPath: window.Signal.Migrations.getAbsoluteAttachmentPath, diff --git a/ts/jobs/AttachmentDownloadManager.ts b/ts/jobs/AttachmentDownloadManager.ts index 44faa5d341..2b7c002642 100644 --- a/ts/jobs/AttachmentDownloadManager.ts +++ b/ts/jobs/AttachmentDownloadManager.ts @@ -1,6 +1,6 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only -import { omit } from 'lodash'; +import { debounce, noop, omit } from 'lodash'; import * as durations from '../util/durations'; import * as log from '../logging/log'; @@ -37,6 +37,7 @@ import { JobManager, type JobManagerParamsType, type JobManagerJobResultType, + type JobManagerJobType, } from './JobManager'; import { isImageTypeSupported, @@ -93,7 +94,10 @@ type AttachmentDownloadManagerParamsType = Omit< runDownloadAttachmentJob: (args: { job: AttachmentDownloadJobType; isLastAttempt: boolean; - options?: { isForCurrentlyVisibleMessage: boolean }; + options: { + abortSignal: AbortSignal; + isForCurrentlyVisibleMessage: boolean; + }; dependencies?: DependenciesType; }) => Promise>; }; @@ -164,7 +168,13 @@ export class AttachmentDownloadManager extends JobManager { + runJob: ( + job: AttachmentDownloadJobType, + { + abortSignal, + isLastAttempt, + }: { abortSignal: AbortSignal; isLastAttempt: boolean } + ) => { const isForCurrentlyVisibleMessage = this.visibleTimelineMessages.has( job.messageId ); @@ -172,6 +182,7 @@ export class AttachmentDownloadManager extends JobManager boolean + ): Promise { + return AttachmentDownloadManager.instance.cancelJobs(predicate); + } + static updateVisibleTimelineMessages(messageIds: Array): void { AttachmentDownloadManager.instance.updateVisibleTimelineMessages( messageIds @@ -283,6 +302,7 @@ export class AttachmentDownloadManager extends JobManager> { const jobIdForLogging = getJobIdForLogging(job); const logId = `AttachmentDownloadManager/runDownloadAttachmentJob/${jobIdForLogging}`; - const message = await __DEPRECATED$getMessageById(job.messageId); + const message = await __DEPRECATED$getMessageById( + job.messageId, + 'runDownloadAttachmentJob' + ); if (!message) { log.error(`${logId} message not found`); @@ -315,6 +342,7 @@ async function runDownloadAttachmentJob({ const result = await runDownloadAttachmentJobInner({ job, + abortSignal: options.abortSignal, isForCurrentlyVisibleMessage: options?.isForCurrentlyVisibleMessage ?? false, dependencies, @@ -342,6 +370,14 @@ async function runDownloadAttachmentJob({ status: 'finished', }; } catch (error) { + if (options.abortSignal.aborted) { + log.warn( + `${logId}: Cancelled attempt ${job.attempts}. Not scheduling a retry. Error:`, + Errors.toLogFormat(error) + ); + return { status: 'finished' }; + } + log.error( `${logId}: Failed to download attachment, attempt ${job.attempts}:`, Errors.toLogFormat(error) @@ -407,10 +443,12 @@ type DownloadAttachmentResultType = export async function runDownloadAttachmentJobInner({ job, + abortSignal, isForCurrentlyVisibleMessage, dependencies, }: { job: AttachmentDownloadJobType; + abortSignal: AbortSignal; isForCurrentlyVisibleMessage: boolean; dependencies: DependenciesType; }): Promise { @@ -458,6 +496,7 @@ export async function runDownloadAttachmentJobInner({ try { const attachmentWithThumbnail = await downloadBackupThumbnail({ attachment, + abortSignal, dependencies, }); await addAttachmentToMessage(messageId, attachmentWithThumbnail, logId, { @@ -482,9 +521,29 @@ export async function runDownloadAttachmentJobInner({ ); try { + let totalDownloaded = 0; + + const onSizeUpdate = async (totalBytes: number) => { + if (abortSignal.aborted) { + return; + } + + totalDownloaded = Math.min(totalBytes, attachment.size); + await addAttachmentToMessage( + messageId, + { ...attachment, totalDownloaded, pending: true }, + logId, + { type: attachmentType } + ); + }; + const downloaded = await dependencies.downloadAttachment({ attachment, - variant: AttachmentVariant.Default, + options: { + variant: AttachmentVariant.Default, + onSizeUpdate: debounce(onSizeUpdate, 200), + abortSignal, + }, }); const upgradedAttachment = await dependencies.processNewAttachment({ @@ -510,6 +569,7 @@ export async function runDownloadAttachmentJobInner({ const attachmentWithThumbnail = omit( await downloadBackupThumbnail({ attachment, + abortSignal, dependencies, }), 'pending' @@ -539,14 +599,20 @@ export async function runDownloadAttachmentJobInner({ async function downloadBackupThumbnail({ attachment, + abortSignal, dependencies, }: { attachment: AttachmentType; + abortSignal: AbortSignal; dependencies: { downloadAttachment: typeof downloadAttachmentUtil }; }): Promise { const downloadedThumbnail = await dependencies.downloadAttachment({ attachment, - variant: AttachmentVariant.ThumbnailFromBackup, + options: { + onSizeUpdate: noop, + variant: AttachmentVariant.ThumbnailFromBackup, + abortSignal, + }, }); const attachmentWithThumbnail = { diff --git a/ts/jobs/CallLinkFinalizeDeleteManager.ts b/ts/jobs/CallLinkFinalizeDeleteManager.ts index 9dd5f1608b..7f80b03b46 100644 --- a/ts/jobs/CallLinkFinalizeDeleteManager.ts +++ b/ts/jobs/CallLinkFinalizeDeleteManager.ts @@ -184,7 +184,7 @@ async function removeJob( async function runJob( job: CallLinkDeleteJobType, - _isLastAttempt: boolean + _options: { isLastAttempt: boolean; abortSignal: AbortSignal } ): Promise> { const logId = `CallLinkDeleteJobType/runJob/${getJobId(job)}`; diff --git a/ts/jobs/JobManager.ts b/ts/jobs/JobManager.ts index 6b74564760..ef07144e5c 100644 --- a/ts/jobs/JobManager.ts +++ b/ts/jobs/JobManager.ts @@ -1,7 +1,7 @@ // Copyright 2024 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only import * as z from 'zod'; -import { MINUTE } from '../util/durations'; +import { MINUTE, SECOND } from '../util/durations'; import { explodePromise, type ExplodePromiseResultType, @@ -15,6 +15,7 @@ import { exponentialBackoffSleepTime, } from '../util/exponentialBackoff'; import * as Errors from '../types/errors'; +import { sleep } from '../util/sleep'; export type JobManagerJobType = { active: boolean; @@ -46,7 +47,10 @@ export type JobManagerParamsType< removeJob: (job: JobType) => Promise; runJob: ( job: JobType, - isLastAttempt: boolean + options: { + abortSignal: AbortSignal; + isLastAttempt: boolean; + } ) => Promise>; shouldHoldOffOnStartingQueuedJobs?: () => boolean; getJobId: (job: CoreJobType) => string; @@ -66,15 +70,15 @@ export type JobManagerJobResultType = | { status: 'finished'; newJob?: CoreJobType } | { status: 'rate-limited'; pauseDurationMs: number }; +export type ActiveJobData = { + completionPromise: ExplodePromiseResultType; + abortController: AbortController; + job: CoreJobType & JobManagerJobType; +}; + export abstract class JobManager { private enabled: boolean = false; - private activeJobs: Map< - string, - { - completionPromise: ExplodePromiseResultType; - job: CoreJobType & JobManagerJobType; - } - > = new Map(); + private activeJobs: Map> = new Map(); private jobStartPromises: Map> = new Map(); private jobCompletePromises: Map> = @@ -108,7 +112,10 @@ export abstract class JobManager { clearTimeoutIfNecessary(this.tickTimeout); this.tickTimeout = null; await Promise.all( - activeJobs.map(({ completionPromise }) => completionPromise.promise) + activeJobs.map(async ({ abortController, completionPromise }) => { + abortController.abort(); + await completionPromise.promise; + }) ); } @@ -291,9 +298,12 @@ export abstract class JobManager { let jobRunResult: JobManagerJobResultType | undefined; try { log.info(`${logId}: starting job`); - this.addRunningJob(job); + const { abortController } = this.addRunningJob(job); await this.params.saveJob({ ...job, active: true }); - const runJobPromise = this.params.runJob(job, isLastAttempt); + const runJobPromise = this.params.runJob(job, { + abortSignal: abortController.signal, + isLastAttempt, + }); this.handleJobStartPromises(job); jobRunResult = await runJobPromise; const { status } = jobRunResult; @@ -388,17 +398,71 @@ export abstract class JobManager { this.activeJobs.delete(id); } - private addRunningJob(job: CoreJobType & JobManagerJobType) { + public async cancelJobs( + predicate: (job: CoreJobType & JobManagerJobType) => boolean + ): Promise { + const logId = `${this.logPrefix}/cancelJobs`; + const jobs = Array.from(this.activeJobs.values()).filter(data => + predicate(data.job) + ); + + if (jobs.length === 0) { + log.warn(`${logId}: found no target jobs`); + return; + } + + await Promise.all( + jobs.map(async jobData => { + const { abortController, completionPromise, job } = jobData; + + abortController.abort(); + + // First tell those waiting for the job that it's not happening + const rejectionError = new Error('Cancelled at JobManager.cancelJobs'); + const idWithAttempts = this.getJobIdIncludingAttempts(job); + this.jobCompletePromises.get(idWithAttempts)?.reject(rejectionError); + this.jobCompletePromises.delete(idWithAttempts); + + // Give the job 1 second to cancel itself + await Promise.race([completionPromise.promise, sleep(SECOND)]); + + const jobId = this.params.getJobId(job); + const hasCompleted = Boolean(this.activeJobs.get(jobId)); + + if (!hasCompleted) { + const jobIdForLogging = this.params.getJobIdForLogging(job); + log.warn( + `${logId}: job ${jobIdForLogging} didn't complete; rejecting promises` + ); + completionPromise.reject(rejectionError); + this.activeJobs.delete(jobId); + } + + await this.params.removeJob(job); + }) + ); + + log.warn(`${logId}: Successfully cancelled ${jobs.length} jobs`); + } + + private addRunningJob( + job: CoreJobType & JobManagerJobType + ): ActiveJobData { if (this.isJobRunning(job)) { const jobIdForLogging = this.params.getJobIdForLogging(job); log.warn( `${this.logPrefix}/addRunningJob: job ${jobIdForLogging} is already running` ); } - this.activeJobs.set(this.params.getJobId(job), { + + const activeJob = { completionPromise: explodePromise(), + abortController: new AbortController(), job, - }); + }; + this.activeJobs.set(this.params.getJobId(job), activeJob); + + return activeJob; } private handleJobStartPromises(job: CoreJobType & JobManagerJobType) { diff --git a/ts/jobs/helpers/sendDeleteForEveryone.ts b/ts/jobs/helpers/sendDeleteForEveryone.ts index 7c566e7e8e..7930cf09e2 100644 --- a/ts/jobs/helpers/sendDeleteForEveryone.ts +++ b/ts/jobs/helpers/sendDeleteForEveryone.ts @@ -60,7 +60,7 @@ export async function sendDeleteForEveryone( const logId = `sendDeleteForEveryone(${conversation.idForLogging()}, ${messageId})`; - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById(messageId, logId); if (!message) { log.error(`${logId}: Failed to fetch message. Failing job.`); return; diff --git a/ts/jobs/helpers/sendDeleteStoryForEveryone.ts b/ts/jobs/helpers/sendDeleteStoryForEveryone.ts index 915f7725a5..9846da35a7 100644 --- a/ts/jobs/helpers/sendDeleteStoryForEveryone.ts +++ b/ts/jobs/helpers/sendDeleteStoryForEveryone.ts @@ -46,7 +46,10 @@ export async function sendDeleteStoryForEveryone( const logId = `sendDeleteStoryForEveryone(${storyId})`; - const message = await __DEPRECATED$getMessageById(storyId); + const message = await __DEPRECATED$getMessageById( + storyId, + 'sendDeleteStoryForEveryone' + ); if (!message) { log.error(`${logId}: Failed to fetch message. Failing job.`); return; diff --git a/ts/jobs/helpers/sendNormalMessage.ts b/ts/jobs/helpers/sendNormalMessage.ts index 1fec8652c9..7f551f55cb 100644 --- a/ts/jobs/helpers/sendNormalMessage.ts +++ b/ts/jobs/helpers/sendNormalMessage.ts @@ -73,7 +73,10 @@ export async function sendNormalMessage( const { Message } = window.Signal.Types; const { messageId, revision, editedMessageTimestamp } = data; - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'sendNormalMessage' + ); if (!message) { log.info( `message ${messageId} was not found, maybe because it was deleted. Giving up on sending it` @@ -654,7 +657,9 @@ async function getMessageSendData({ uploadQueue, }), uploadMessageSticker(message, uploadQueue), - storyId ? __DEPRECATED$getMessageById(storyId) : undefined, + storyId + ? __DEPRECATED$getMessageById(storyId, 'sendNormalMessage') + : undefined, ]); // Save message after uploading attachments diff --git a/ts/jobs/helpers/sendReaction.ts b/ts/jobs/helpers/sendReaction.ts index 508b9306a4..fc1c6b0a7d 100644 --- a/ts/jobs/helpers/sendReaction.ts +++ b/ts/jobs/helpers/sendReaction.ts @@ -61,7 +61,7 @@ export async function sendReaction( const ourConversationId = window.ConversationController.getOurConversationIdOrThrow(); - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById(messageId, 'sendReaction'); if (!message) { log.info( `message ${messageId} was not found, maybe because it was deleted. Giving up on sending its reactions` diff --git a/ts/jobs/helpers/sendStory.ts b/ts/jobs/helpers/sendStory.ts index 9b15267c21..9f89ea70b7 100644 --- a/ts/jobs/helpers/sendStory.ts +++ b/ts/jobs/helpers/sendStory.ts @@ -71,38 +71,40 @@ export async function sendStory( } const notFound = new Set(messageIds); - const messages = (await getMessagesById(messageIds)).filter(message => { - notFound.delete(message.id); + const messages = (await getMessagesById(messageIds, 'sendStory')).filter( + message => { + notFound.delete(message.id); - const distributionId = message.get('storyDistributionListId'); - const logId = `stories.sendStory(${timestamp}/${distributionId})`; + const distributionId = message.get('storyDistributionListId'); + const logId = `stories.sendStory(${timestamp}/${distributionId})`; - const messageConversation = message.getConversation(); - if (messageConversation !== conversation) { - log.error( - `${logId}: Message conversation ` + - `'${messageConversation?.idForLogging()}' does not match job ` + - `conversation ${conversation.idForLogging()}` - ); - return false; + const messageConversation = message.getConversation(); + if (messageConversation !== conversation) { + log.error( + `${logId}: Message conversation ` + + `'${messageConversation?.idForLogging()}' does not match job ` + + `conversation ${conversation.idForLogging()}` + ); + return false; + } + + if (message.get('timestamp') !== timestamp) { + log.error( + `${logId}: Message timestamp ${message.get( + 'timestamp' + )} does not match job timestamp` + ); + return false; + } + + if (message.isErased() || message.get('deletedForEveryone')) { + log.info(`${logId}: message was erased. Giving up on sending it`); + return false; + } + + return true; } - - if (message.get('timestamp') !== timestamp) { - log.error( - `${logId}: Message timestamp ${message.get( - 'timestamp' - )} does not match job timestamp` - ); - return false; - } - - if (message.isErased() || message.get('deletedForEveryone')) { - log.info(`${logId}: message was erased. Giving up on sending it`); - return false; - } - - return true; - }); + ); for (const messageId of notFound) { log.info( diff --git a/ts/messageModifiers/AttachmentDownloads.ts b/ts/messageModifiers/AttachmentDownloads.ts index afc19cae53..548da44dad 100644 --- a/ts/messageModifiers/AttachmentDownloads.ts +++ b/ts/messageModifiers/AttachmentDownloads.ts @@ -14,13 +14,13 @@ export async function addAttachmentToMessage( jobLogId: string, { type }: { type: AttachmentDownloadJobTypeType } ): Promise { - const message = await __DEPRECATED$getMessageById(messageId); + const logPrefix = `${jobLogId}/addAttachmentToMessage`; + const message = await __DEPRECATED$getMessageById(messageId, logPrefix); if (!message) { return; } - const logPrefix = `${jobLogId}/addAttachmentToMessage`; const attachmentSignature = getAttachmentSignature(attachment); if (type === 'long-message') { diff --git a/ts/messages/getMessageById.ts b/ts/messages/getMessageById.ts index 5b0b5094ee..e8595bcd90 100644 --- a/ts/messages/getMessageById.ts +++ b/ts/messages/getMessageById.ts @@ -8,9 +8,14 @@ import * as Errors from '../types/errors'; import type { MessageModel } from '../models/messages'; export async function __DEPRECATED$getMessageById( - messageId: string + messageId: string, + location: string ): Promise { - const message = window.MessageCache.__DEPRECATED$getById(messageId); + const innerLocation = `__DEPRECATED$getMessageById/${location}`; + const message = window.MessageCache.__DEPRECATED$getById( + messageId, + innerLocation + ); if (message) { return message; } @@ -32,6 +37,6 @@ export async function __DEPRECATED$getMessageById( return window.MessageCache.__DEPRECATED$register( found.id, found, - '__DEPRECATED$getMessageById' + innerLocation ); } diff --git a/ts/messages/getMessagesById.ts b/ts/messages/getMessagesById.ts index d017763856..77d0988bae 100644 --- a/ts/messages/getMessagesById.ts +++ b/ts/messages/getMessagesById.ts @@ -8,13 +8,18 @@ import type { MessageAttributesType } from '../model-types.d'; import * as Errors from '../types/errors'; export async function getMessagesById( - messageIds: Iterable + messageIds: Iterable, + location: string ): Promise> { + const innerLocation = `getMessagesById/${location}`; const messagesFromMemory: Array = []; const messageIdsToLookUpInDatabase: Array = []; for (const messageId of messageIds) { - const message = window.MessageCache.__DEPRECATED$getById(messageId); + const message = window.MessageCache.__DEPRECATED$getById( + messageId, + innerLocation + ); if (message) { messagesFromMemory.push(message); } else { @@ -43,7 +48,7 @@ export async function getMessagesById( return window.MessageCache.__DEPRECATED$register( message.id, message, - 'getMessagesById' + innerLocation ); }); diff --git a/ts/models/conversations.ts b/ts/models/conversations.ts index f2283d8ecc..5bfb9ca060 100644 --- a/ts/models/conversations.ts +++ b/ts/models/conversations.ts @@ -3556,7 +3556,10 @@ export class ConversationModel extends window.Backbone `maybeRemoveUniversalTimer(${this.idForLogging()}): removed notification` ); - const message = window.MessageCache.__DEPRECATED$getById(notificationId); + const message = window.MessageCache.__DEPRECATED$getById( + notificationId, + 'maybeRemoveUniversalTimer' + ); if (message) { await DataWriter.removeMessage(message.id, { singleProtoJobQueue, @@ -3599,7 +3602,10 @@ export class ConversationModel extends window.Backbone `maybeClearContactRemoved(${this.idForLogging()}): removed notification` ); - const message = window.MessageCache.__DEPRECATED$getById(notificationId); + const message = window.MessageCache.__DEPRECATED$getById( + notificationId, + 'maybeClearContactRemoved' + ); if (message) { await DataWriter.removeMessage(message.id, { singleProtoJobQueue, diff --git a/ts/models/messages.ts b/ts/models/messages.ts index a68ad3da91..9f435f6254 100644 --- a/ts/models/messages.ts +++ b/ts/models/messages.ts @@ -393,7 +393,10 @@ export class MessageModel extends window.Backbone.Model { `doubleCheckMissingQuoteReference/${logId}: missing story reference` ); - const message = window.MessageCache.__DEPRECATED$getById(storyId); + const message = window.MessageCache.__DEPRECATED$getById( + storyId, + 'doubleCheckMissingQuoteReference' + ); if (!message) { return; } diff --git a/ts/reactions/enqueueReactionForSend.ts b/ts/reactions/enqueueReactionForSend.ts index 6b936f8ed0..0fbc3c561e 100644 --- a/ts/reactions/enqueueReactionForSend.ts +++ b/ts/reactions/enqueueReactionForSend.ts @@ -29,7 +29,10 @@ export async function enqueueReactionForSend({ messageId: string; remove: boolean; }>): Promise { - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'enqueueReactionForSend' + ); strictAssert(message, 'enqueueReactionForSend: no message found'); const targetAuthorAci = getSourceServiceId(message.attributes); diff --git a/ts/services/MessageCache.ts b/ts/services/MessageCache.ts index b7d2d30e9e..df7f6ffe7b 100644 --- a/ts/services/MessageCache.ts +++ b/ts/services/MessageCache.ts @@ -420,7 +420,7 @@ export class MessageCache { ); } - const existing = this.__DEPRECATED$getById(id); + const existing = this.__DEPRECATED$getById(id, location); if (existing) { this.addMessageToCache(existing.attributes); @@ -447,13 +447,18 @@ export class MessageCache { } // Finds a message in the cache by Id - public __DEPRECATED$getById(id: string): MessageModel | undefined { + public __DEPRECATED$getById( + id: string, + location: string + ): MessageModel | undefined { const data = this.state.messages.get(id); if (!data) { return undefined; } - return this.toModel(data); + const model = this.toModel(data); + model.registerLocations.add(location); + return model; } public async upgradeSchema( @@ -513,9 +518,9 @@ export class MessageCache { model.attributes = { ...messageAttributes }; if (getEnvironment() === Environment.Development) { - log.warn('MessageCache: stale model', { + log.warn('MessageCache: updating cached backbone model', { cid: model.cid, - locations: Array.from(model.registerLocations).join('+'), + locations: Array.from(model.registerLocations).join(', '), }); } } diff --git a/ts/services/backups/index.ts b/ts/services/backups/index.ts index a07a9d0378..02bb54489f 100644 --- a/ts/services/backups/index.ts +++ b/ts/services/backups/index.ts @@ -669,8 +669,10 @@ export class BackupsService { createCipheriv(CipherType.AES256CBC, aesKey, iv), prependStream(iv), appendMacStream(macKey), - measureSize(size => { - totalBytes = size; + measureSize({ + onComplete: size => { + totalBytes = size; + }, }), sink ); diff --git a/ts/services/backups/util/filePointers.ts b/ts/services/backups/util/filePointers.ts index 2489ffabe6..638972264a 100644 --- a/ts/services/backups/util/filePointers.ts +++ b/ts/services/backups/util/filePointers.ts @@ -40,6 +40,7 @@ import { bytesToUuid } from '../../../util/uuidToBytes'; import { createName } from '../../../util/attachmentPath'; import { ensureAttachmentIsReencryptable } from '../../../util/ensureAttachmentIsReencryptable'; import type { ReencryptionInfo } from '../../../AttachmentCrypto'; +import { dropZero } from '../../../util/dropZero'; export function convertFilePointerToAttachment( filePointer: Backups.FilePointer, @@ -72,7 +73,7 @@ export function convertFilePointerToAttachment( incrementalMac: incrementalMac?.length ? Bytes.toBase64(incrementalMac) : undefined, - incrementalMacChunkSize: incrementalMacChunkSize ?? undefined, + chunkSize: dropZero(incrementalMacChunkSize), downloadPath: doCreateName(), }; @@ -182,7 +183,7 @@ export async function getFilePointerForAttachment({ incrementalMac: attachment.incrementalMac ? Bytes.fromBase64(attachment.incrementalMac) : undefined, - incrementalMacChunkSize: attachment.incrementalMacChunkSize, + incrementalMacChunkSize: dropZero(attachment.chunkSize), fileName: attachment.fileName, width: attachment.width, height: attachment.height, diff --git a/ts/services/contactSync.ts b/ts/services/contactSync.ts index 8feb28074d..d62ecc3b8c 100644 --- a/ts/services/contactSync.ts +++ b/ts/services/contactSync.ts @@ -2,6 +2,7 @@ // SPDX-License-Identifier: AGPL-3.0-only import PQueue from 'p-queue'; +import { noop } from 'lodash'; import { DataWriter } from '../sql/Client'; import type { ContactSyncEvent } from '../textsecure/messageReceiverEvents'; @@ -23,6 +24,7 @@ import { downloadAttachment } from '../textsecure/downloadAttachment'; import { strictAssert } from '../util/assert'; import type { ReencryptedAttachmentV2 } from '../AttachmentCrypto'; import { SECOND } from '../util/durations'; +import { AttachmentVariant } from '../types/Attachment'; // When true - we are running the very first storage and contact sync after // linking. @@ -103,12 +105,16 @@ async function downloadAndParseContactAttachment( strictAssert(window.textsecure.server, 'server must exist'); let downloaded: ReencryptedAttachmentV2 | undefined; try { + const abortController = new AbortController(); downloaded = await downloadAttachment( window.textsecure.server, contactAttachment, { + variant: AttachmentVariant.Default, + onSizeUpdate: noop, disableRetries: true, timeout: 90 * SECOND, + abortSignal: abortController.signal, } ); diff --git a/ts/sql/Interface.ts b/ts/sql/Interface.ts index 715b99ad41..450cd147fb 100644 --- a/ts/sql/Interface.ts +++ b/ts/sql/Interface.ts @@ -873,6 +873,7 @@ type WritableInterface = { saveAttachmentDownloadJobs: (jobs: Array) => void; resetAttachmentDownloadActive: () => void; removeAttachmentDownloadJob: (job: AttachmentDownloadJobType) => void; + removeAttachmentDownloadJobsForMessage: (messageId: string) => void; removeAllBackupAttachmentDownloadJobs: () => void; getNextAttachmentBackupJobs: (options: { diff --git a/ts/sql/Server.ts b/ts/sql/Server.ts index 791082113a..5eb347fb9a 100644 --- a/ts/sql/Server.ts +++ b/ts/sql/Server.ts @@ -488,6 +488,7 @@ export const DataWriter: ServerWritableInterface = { saveAttachmentDownloadJobs, resetAttachmentDownloadActive, removeAttachmentDownloadJob, + removeAttachmentDownloadJobsForMessage, removeAllBackupAttachmentDownloadJobs, getNextAttachmentBackupJobs, @@ -5129,6 +5130,18 @@ function removeAttachmentDownloadJob( db.prepare(query).run(params); } +function removeAttachmentDownloadJobsForMessage( + db: WritableDB, + messageId: string +): void { + const [query, params] = sql` + DELETE FROM attachment_downloads + WHERE messageId = ${messageId} + `; + + db.prepare(query).run(params); +} + // Backup Attachments function clearAllAttachmentBackupJobs(db: WritableDB): void { diff --git a/ts/state/ducks/composer.ts b/ts/state/ducks/composer.ts index cf268aa330..1a611145fe 100644 --- a/ts/state/ducks/composer.ts +++ b/ts/state/ducks/composer.ts @@ -731,7 +731,7 @@ export function setQuoteByMessageId( } const message = messageId - ? await __DEPRECATED$getMessageById(messageId) + ? await __DEPRECATED$getMessageById(messageId, 'setQuoteByMessageId') : undefined; const state = getState(); diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts index d08220add3..b42ea4dd90 100644 --- a/ts/state/ducks/conversations.ts +++ b/ts/state/ducks/conversations.ts @@ -187,7 +187,10 @@ import { getAddedByForOurPendingInvitation } from '../../util/getAddedByForOurPe import { getConversationIdForLogging } from '../../util/idForLogging'; import { singleProtoJobQueue } from '../../jobs/singleProtoJobQueue'; import MessageSender from '../../textsecure/SendMessage'; -import { AttachmentDownloadUrgency } from '../../jobs/AttachmentDownloadManager'; +import { + AttachmentDownloadManager, + AttachmentDownloadUrgency, +} from '../../jobs/AttachmentDownloadManager'; import type { DeleteForMeSyncEventData, MessageToDelete, @@ -1083,6 +1086,7 @@ export const actions = { blockAndReportSpam, blockConversation, blockGroupLinkRequests, + cancelAttachmentDownload, cancelConversationVerification, changeHasGroupLink, clearCancelledConversationVerification, @@ -1405,7 +1409,10 @@ function markMessageRead( return; } - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'markMessageRead' + ); if (!message) { throw new Error(`markMessageRead: failed to load message ${messageId}`); } @@ -1759,7 +1766,10 @@ function deleteMessages({ await Promise.all( messageIds.map( async (messageId): Promise => { - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'deleteMessages' + ); if (!message) { throw new Error(`deleteMessages: Message ${messageId} missing!`); } @@ -1919,7 +1929,9 @@ function setMessageToEdit( return; } - const message = (await __DEPRECATED$getMessageById(messageId))?.attributes; + const message = ( + await __DEPRECATED$getMessageById(messageId, 'setMessageToEdit') + )?.attributes; if (!message) { return; } @@ -2012,7 +2024,10 @@ function generateNewGroupLink( * replace it with an actual action that fits in with the redux approach. */ export const markViewed = (messageId: string): void => { - const message = window.MessageCache.__DEPRECATED$getById(messageId); + const message = window.MessageCache.__DEPRECATED$getById( + messageId, + 'markViewed' + ); if (!message) { throw new Error(`markViewed: Message ${messageId} missing!`); } @@ -2276,7 +2291,10 @@ function kickOffAttachmentDownload( options: Readonly<{ messageId: string }> ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById(options.messageId); + const message = await __DEPRECATED$getMessageById( + options.messageId, + 'kickOffAttachmentDownload' + ); if (!message) { throw new Error( `kickOffAttachmentDownload: Message ${options.messageId} missing!` @@ -2301,6 +2319,47 @@ function kickOffAttachmentDownload( }; } +function cancelAttachmentDownload({ + messageId, +}: Readonly<{ messageId: string }>): ThunkAction< + void, + RootStateType, + unknown, + NoopActionType +> { + return async dispatch => { + const message = await __DEPRECATED$getMessageById( + messageId, + 'cancelAttachmentDownload' + ); + if (!message) { + log.warn(`cancelAttachmentDownload: Message ${messageId} missing!`); + } else { + message.set({ + attachments: (message.get('attachments') || []).map(attachment => ({ + ...attachment, + pending: false, + })), + }); + + const ourAci = window.textsecure.storage.user.getCheckedAci(); + await DataWriter.saveMessage(message.attributes, { ourAci }); + } + + // A click kicks off downloads for every attachment in a message, so cancel does too + await AttachmentDownloadManager.cancelJobs(job => { + return job.messageId === messageId; + }); + + await DataWriter.removeAttachmentDownloadJobsForMessage(messageId); + + dispatch({ + type: 'NOOP', + payload: null, + }); + }; +} + type AttachmentOptions = ReadonlyDeep<{ messageId: string; attachment: AttachmentType; @@ -2310,7 +2369,10 @@ function markAttachmentAsCorrupted( options: AttachmentOptions ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById(options.messageId); + const message = await __DEPRECATED$getMessageById( + options.messageId, + 'markAttachmentAsCorrupted' + ); if (!message) { throw new Error( `markAttachmentAsCorrupted: Message ${options.messageId} missing!` @@ -2329,7 +2391,10 @@ function openGiftBadge( messageId: string ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'openGiftBadge' + ); if (!message) { throw new Error(`openGiftBadge: Message ${messageId} missing!`); } @@ -2349,7 +2414,10 @@ function retryMessageSend( messageId: string ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'retryMessageSend' + ); if (!message) { throw new Error(`retryMessageSend: Message ${messageId} missing!`); } @@ -2366,7 +2434,10 @@ export function copyMessageText( messageId: string ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'copyMessageText' + ); if (!message) { throw new Error(`copy: Message ${messageId} missing!`); } @@ -2385,7 +2456,10 @@ export function retryDeleteForEveryone( messageId: string ): ThunkAction { return async dispatch => { - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'retryDeleteForEveryone' + ); if (!message) { throw new Error(`retryDeleteForEveryone: Message ${messageId} missing!`); } @@ -3172,7 +3246,12 @@ function pushPanelForConversation( const message = conversations.messagesLookup[messageId] || - (await __DEPRECATED$getMessageById(messageId))?.attributes; + ( + await __DEPRECATED$getMessageById( + messageId, + 'pushPanelForConversation' + ) + )?.attributes; if (!message) { throw new Error( 'pushPanelForConversation: could not find message for MessageDetails' @@ -3248,7 +3327,10 @@ function deleteMessagesForEveryone( await Promise.all( messageIds.map(async messageId => { try { - const message = window.MessageCache.__DEPRECATED$getById(messageId); + const message = window.MessageCache.__DEPRECATED$getById( + messageId, + 'deleteMessagesForEveryone' + ); if (!message) { throw new Error( `deleteMessageForEveryone: Message ${messageId} missing!` @@ -3959,7 +4041,10 @@ export function saveAttachmentFromMessage( providedAttachment?: AttachmentType ): ThunkAction { return async (dispatch, getState) => { - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'saveAttachmentFromMessage' + ); if (!message) { throw new Error( `saveAttachmentFromMessage: Message ${messageId} missing!` @@ -4052,7 +4137,10 @@ export function scrollToMessage( throw new Error('scrollToMessage: No conversation found'); } - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'scrollToMessage' + ); if (!message) { throw new Error(`scrollToMessage: failed to load message ${messageId}`); } @@ -4066,7 +4154,12 @@ export function scrollToMessage( let isInMemory = true; - if (!window.MessageCache.__DEPRECATED$getById(messageId)) { + if ( + !window.MessageCache.__DEPRECATED$getById( + messageId, + 'scrollToMessage/notInMemory' + ) + ) { isInMemory = false; } @@ -4497,7 +4590,10 @@ function onConversationOpened( log.info(`${logId}: Updating newly opened conversation state`); if (messageId) { - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'onConversationOpened' + ); if (message) { drop(conversation.loadAndScroll(messageId)); @@ -4636,7 +4732,10 @@ function showArchivedConversations(): ShowArchivedConversationsActionType { } function doubleCheckMissingQuoteReference(messageId: string): NoopActionType { - const message = window.MessageCache.__DEPRECATED$getById(messageId); + const message = window.MessageCache.__DEPRECATED$getById( + messageId, + 'doubleCheckMissingQuoteReference' + ); if (message) { void message.doubleCheckMissingQuoteReference(); } diff --git a/ts/state/ducks/lightbox.ts b/ts/state/ducks/lightbox.ts index eb30330b75..06a35ca457 100644 --- a/ts/state/ducks/lightbox.ts +++ b/ts/state/ducks/lightbox.ts @@ -156,7 +156,10 @@ function showLightboxForViewOnceMedia( return async dispatch => { log.info('showLightboxForViewOnceMedia: attempting to display message'); - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'showLightboxForViewOnceMedia' + ); if (!message) { throw new Error( `showLightboxForViewOnceMedia: Message ${messageId} missing!` @@ -250,7 +253,10 @@ function showLightbox(opts: { return async (dispatch, getState) => { const { attachment, messageId } = opts; - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'showLightbox' + ); if (!message) { throw new Error(`showLightbox: Message ${messageId} missing!`); } @@ -387,7 +393,10 @@ function showLightboxForAdjacentMessage( const [media] = lightbox.media; const { id: messageId, receivedAt, sentAt } = media.message; - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'showLightboxForAdjacentMessage' + ); if (!message) { log.warn('showLightboxForAdjacentMessage: original message is gone'); dispatch({ diff --git a/ts/state/ducks/stories.ts b/ts/state/ducks/stories.ts index 03b2c812af..c1cf73b7ad 100644 --- a/ts/state/ducks/stories.ts +++ b/ts/state/ducks/stories.ts @@ -382,7 +382,10 @@ function markStoryRead( return; } - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'markStoryRead' + ); if (!message) { log.warn(`markStoryRead: no message found ${messageId}`); @@ -521,7 +524,10 @@ function queueStoryDownload( return; } - const message = await __DEPRECATED$getMessageById(storyId); + const message = await __DEPRECATED$getMessageById( + storyId, + 'queueStoryDownload' + ); if (message) { // We want to ensure that we re-hydrate the story reply context with the @@ -1396,7 +1402,10 @@ function removeAllContactStories( const messages = ( await Promise.all( messageIds.map(async messageId => { - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'removeAllContactStories' + ); if (!message) { log.warn(`${logId}: no message found ${messageId}`); diff --git a/ts/state/selectors/message.ts b/ts/state/selectors/message.ts index dc8813875c..61526c9fcf 100644 --- a/ts/state/selectors/message.ts +++ b/ts/state/selectors/message.ts @@ -152,7 +152,6 @@ import { CallMode, CallDirection } from '../../types/CallDisposition'; import { getCallIdFromEra } from '../../util/callDisposition'; import { LONG_MESSAGE } from '../../types/MIME'; import type { MessageRequestResponseNotificationData } from '../../components/conversation/MessageRequestResponseNotification'; -import { formatFileSize } from '../../util/formatFileSize'; export { isIncoming, isOutgoing, isStory }; @@ -1837,12 +1836,11 @@ export function getPropsForAttachment( return undefined; } - const { path, pending, size, screenshot, thumbnail, thumbnailFromBackup } = + const { path, pending, screenshot, thumbnail, thumbnailFromBackup } = attachment; return { ...attachment, - fileSize: size ? formatFileSize(size) : undefined, isVoiceMessage: isVoiceMessage(attachment), pending, url: path ? getLocalAttachmentUrl(attachment) : undefined, diff --git a/ts/state/smart/EditHistoryMessagesModal.tsx b/ts/state/smart/EditHistoryMessagesModal.tsx index 43dcd8a618..237842ab15 100644 --- a/ts/state/smart/EditHistoryMessagesModal.tsx +++ b/ts/state/smart/EditHistoryMessagesModal.tsx @@ -20,7 +20,8 @@ export const SmartEditHistoryMessagesModal = memo( const platform = useSelector(getPlatform); const { closeEditHistoryModal } = useGlobalModalActions(); - const { kickOffAttachmentDownload } = useConversationsActions(); + const { cancelAttachmentDownload, kickOffAttachmentDownload } = + useConversationsActions(); const { showLightbox } = useLightboxActions(); const getPreferredBadge = useSelector(getPreferredBadgeSelector); @@ -46,6 +47,7 @@ export const SmartEditHistoryMessagesModal = memo( return ( = {} + overrides: Partial = {} ): AttachmentForUIType => ({ contentType: IMAGE_JPEG, width: 800, diff --git a/ts/test-both/processDataMessage_test.ts b/ts/test-both/processDataMessage_test.ts index c2eb79d8e3..ac01a01960 100644 --- a/ts/test-both/processDataMessage_test.ts +++ b/ts/test-both/processDataMessage_test.ts @@ -100,9 +100,9 @@ describe('processDataMessage', () => { assert.deepStrictEqual(out.attachments, [ { ...PROCESSED_ATTACHMENT, - chunkSize: 2, downloadPath: 'random-path', incrementalMac: 'AAAA', + chunkSize: 2, }, ]); }); diff --git a/ts/test-electron/backup/filePointer_test.ts b/ts/test-electron/backup/filePointer_test.ts index 58c583eb7a..e0ba7a509f 100644 --- a/ts/test-electron/backup/filePointer_test.ts +++ b/ts/test-electron/backup/filePointer_test.ts @@ -60,7 +60,7 @@ describe('convertFilePointerToAttachment', () => { digest: Bytes.toBase64(Bytes.fromString('digest')), uploadTimestamp: 1970, incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')), - incrementalMacChunkSize: 1000, + chunkSize: 1000, downloadPath: 'downloadPath', }); }); @@ -102,7 +102,7 @@ describe('convertFilePointerToAttachment', () => { key: Bytes.toBase64(Bytes.fromString('key')), digest: Bytes.toBase64(Bytes.fromString('digest')), incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')), - incrementalMacChunkSize: 1000, + chunkSize: 1000, backupLocator: { mediaName: 'mediaName', cdnNumber: 3, @@ -135,7 +135,7 @@ describe('convertFilePointerToAttachment', () => { fileName: 'filename', caption: 'caption', incrementalMac: Bytes.toBase64(Bytes.fromString('incrementalMac')), - incrementalMacChunkSize: 1000, + chunkSize: 1000, size: 0, error: true, }); @@ -163,7 +163,7 @@ describe('convertFilePointerToAttachment', () => { key: undefined, digest: undefined, incrementalMac: undefined, - incrementalMacChunkSize: undefined, + chunkSize: undefined, backupLocator: undefined, }); }); @@ -190,7 +190,7 @@ function composeAttachment( fileName: 'filename', caption: 'caption', incrementalMac: 'incrementalMac', - incrementalMacChunkSize: 1000, + chunkSize: 1000, uploadTimestamp: 1234, localKey: Bytes.toBase64(generateKeys()), isReencryptableToSameDigest: true, diff --git a/ts/test-electron/services/AttachmentBackupManager_test.ts b/ts/test-electron/services/AttachmentBackupManager_test.ts index d8948b8f40..dc71c26d7b 100644 --- a/ts/test-electron/services/AttachmentBackupManager_test.ts +++ b/ts/test-electron/services/AttachmentBackupManager_test.ts @@ -142,15 +142,20 @@ describe('AttachmentBackupManager/JobManager', function attachmentBackupManager( const decryptAttachmentV2ToSink = sinon.stub(); const { getAbsoluteAttachmentPath } = window.Signal.Migrations; + const abortController = new AbortController(); runJob = sandbox.stub().callsFake((job: AttachmentBackupJobType) => { - return runAttachmentBackupJob(job, false, { - // @ts-expect-error incomplete stubbing - backupsService, - backupMediaBatch, - getAbsoluteAttachmentPath, - encryptAndUploadAttachment, - decryptAttachmentV2ToSink, - }); + return runAttachmentBackupJob( + job, + { abortSignal: abortController.signal, isLastAttempt: false }, + { + // @ts-expect-error incomplete stubbing + backupsService, + backupMediaBatch, + getAbsoluteAttachmentPath, + encryptAndUploadAttachment, + decryptAttachmentV2ToSink, + } + ); }); backupManager = new AttachmentBackupManager({ diff --git a/ts/test-electron/services/AttachmentDownloadManager_test.ts b/ts/test-electron/services/AttachmentDownloadManager_test.ts index 5f3fa41ef4..07be8b8985 100644 --- a/ts/test-electron/services/AttachmentDownloadManager_test.ts +++ b/ts/test-electron/services/AttachmentDownloadManager_test.ts @@ -444,8 +444,11 @@ describe('AttachmentDownloadManager/JobManager', () => { describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { let sandbox: sinon.SinonSandbox; + let deleteDownloadData: sinon.SinonStub; let downloadAttachment: sinon.SinonStub; let processNewAttachment: sinon.SinonStub; + const abortController = new AbortController(); + beforeEach(async () => { sandbox = sinon.createSandbox(); downloadAttachment = sandbox.stub().returns({ @@ -470,7 +473,9 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { const result = await runDownloadAttachmentJobInner({ job, isForCurrentlyVisibleMessage: true, + abortSignal: abortController.signal, dependencies: { + deleteDownloadData, downloadAttachment, processNewAttachment, }, @@ -478,10 +483,13 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default); assert.strictEqual(downloadAttachment.callCount, 1); - assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { - attachment: job.attachment, - variant: AttachmentVariant.Default, - }); + + const downloadCallArgs = downloadAttachment.getCall(0).args[0]; + assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment); + assert.deepStrictEqual( + downloadCallArgs.options.variant, + AttachmentVariant.Default + ); }); it('will download thumbnail if attachment is from backup', async () => { const job = composeJob({ @@ -497,7 +505,9 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { const result = await runDownloadAttachmentJobInner({ job, isForCurrentlyVisibleMessage: true, + abortSignal: abortController.signal, dependencies: { + deleteDownloadData, downloadAttachment, processNewAttachment, }, @@ -521,10 +531,13 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { '/path/to/file' ); assert.strictEqual(downloadAttachment.callCount, 1); - assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { - attachment: job.attachment, - variant: AttachmentVariant.ThumbnailFromBackup, - }); + + const downloadCallArgs = downloadAttachment.getCall(0).args[0]; + assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment); + assert.deepStrictEqual( + downloadCallArgs.options.variant, + AttachmentVariant.ThumbnailFromBackup + ); }); it('will download full size if thumbnail already backed up', async () => { const job = composeJob({ @@ -543,17 +556,22 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { const result = await runDownloadAttachmentJobInner({ job, isForCurrentlyVisibleMessage: true, + abortSignal: abortController.signal, dependencies: { + deleteDownloadData, downloadAttachment, processNewAttachment, }, }); assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default); assert.strictEqual(downloadAttachment.callCount, 1); - assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { - attachment: job.attachment, - variant: AttachmentVariant.Default, - }); + + const downloadCallArgs = downloadAttachment.getCall(0).args[0]; + assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment); + assert.deepStrictEqual( + downloadCallArgs.options.variant, + AttachmentVariant.Default + ); }); it('will attempt to download full size if thumbnail fails', async () => { @@ -575,7 +593,9 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { runDownloadAttachmentJobInner({ job, isForCurrentlyVisibleMessage: true, + abortSignal: abortController.signal, dependencies: { + deleteDownloadData, downloadAttachment, processNewAttachment, }, @@ -583,14 +603,20 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { ); assert.strictEqual(downloadAttachment.callCount, 2); - assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { - attachment: job.attachment, - variant: AttachmentVariant.ThumbnailFromBackup, - }); - assert.deepStrictEqual(downloadAttachment.getCall(1).args[0], { - attachment: job.attachment, - variant: AttachmentVariant.Default, - }); + + const downloadCallArgs0 = downloadAttachment.getCall(0).args[0]; + assert.deepStrictEqual(downloadCallArgs0.attachment, job.attachment); + assert.deepStrictEqual( + downloadCallArgs0.options.variant, + AttachmentVariant.ThumbnailFromBackup + ); + + const downloadCallArgs1 = downloadAttachment.getCall(1).args[0]; + assert.deepStrictEqual(downloadCallArgs1.attachment, job.attachment); + assert.deepStrictEqual( + downloadCallArgs1.options.variant, + AttachmentVariant.Default + ); }); }); describe('message not visible', () => { @@ -608,21 +634,26 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { const result = await runDownloadAttachmentJobInner({ job, isForCurrentlyVisibleMessage: false, + abortSignal: abortController.signal, dependencies: { + deleteDownloadData, downloadAttachment, processNewAttachment, }, }); assert.strictEqual(result.downloadedVariant, AttachmentVariant.Default); assert.strictEqual(downloadAttachment.callCount, 1); - assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { - attachment: job.attachment, - variant: AttachmentVariant.Default, - }); + + const downloadCallArgs = downloadAttachment.getCall(0).args[0]; + assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment); + assert.deepStrictEqual( + downloadCallArgs.options.variant, + AttachmentVariant.Default + ); }); it('will fallback to thumbnail if main download fails and backuplocator exists', async () => { - downloadAttachment = sandbox.stub().callsFake(({ variant }) => { - if (variant === AttachmentVariant.Default) { + downloadAttachment = sandbox.stub().callsFake(({ options }) => { + if (options.variant === AttachmentVariant.Default) { throw new Error('error while downloading'); } return { @@ -645,7 +676,9 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { const result = await runDownloadAttachmentJobInner({ job, isForCurrentlyVisibleMessage: false, + abortSignal: abortController.signal, dependencies: { + deleteDownloadData, downloadAttachment, processNewAttachment, }, @@ -655,19 +688,25 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { AttachmentVariant.ThumbnailFromBackup ); assert.strictEqual(downloadAttachment.callCount, 2); - assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { - attachment: job.attachment, - variant: AttachmentVariant.Default, - }); - assert.deepStrictEqual(downloadAttachment.getCall(1).args[0], { - attachment: job.attachment, - variant: AttachmentVariant.ThumbnailFromBackup, - }); + + const downloadCallArgs0 = downloadAttachment.getCall(0).args[0]; + assert.deepStrictEqual(downloadCallArgs0.attachment, job.attachment); + assert.deepStrictEqual( + downloadCallArgs0.options.variant, + AttachmentVariant.Default + ); + + const downloadCallArgs1 = downloadAttachment.getCall(1).args[0]; + assert.deepStrictEqual(downloadCallArgs1.attachment, job.attachment); + assert.deepStrictEqual( + downloadCallArgs1.options.variant, + AttachmentVariant.ThumbnailFromBackup + ); }); it("won't fallback to thumbnail if main download fails and no backup locator", async () => { - downloadAttachment = sandbox.stub().callsFake(({ variant }) => { - if (variant === AttachmentVariant.Default) { + downloadAttachment = sandbox.stub().callsFake(({ options }) => { + if (options.variant === AttachmentVariant.Default) { throw new Error('error while downloading'); } return { @@ -686,7 +725,9 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { runDownloadAttachmentJobInner({ job, isForCurrentlyVisibleMessage: false, + abortSignal: abortController.signal, dependencies: { + deleteDownloadData, downloadAttachment, processNewAttachment, }, @@ -694,10 +735,13 @@ describe('AttachmentDownloadManager/runDownloadAttachmentJob', () => { ); assert.strictEqual(downloadAttachment.callCount, 1); - assert.deepStrictEqual(downloadAttachment.getCall(0).args[0], { - attachment: job.attachment, - variant: AttachmentVariant.Default, - }); + + const downloadCallArgs = downloadAttachment.getCall(0).args[0]; + assert.deepStrictEqual(downloadCallArgs.attachment, job.attachment); + assert.deepStrictEqual( + downloadCallArgs.options.variant, + AttachmentVariant.Default + ); }); }); }); diff --git a/ts/test-electron/services/MessageCache_test.ts b/ts/test-electron/services/MessageCache_test.ts index 49cdb872c9..21e7bec8c7 100644 --- a/ts/test-electron/services/MessageCache_test.ts +++ b/ts/test-electron/services/MessageCache_test.ts @@ -97,7 +97,10 @@ describe('MessageCache', () => { 'same objects from mc.__DEPRECATED$register' ); - const messageById = window.MessageCache.__DEPRECATED$getById(message1.id); + const messageById = window.MessageCache.__DEPRECATED$getById( + message1.id, + 'test' + ); assert.strictEqual(message1, messageById, 'same objects from mc.getById'); @@ -123,7 +126,8 @@ describe('MessageCache', () => { ); const newMessageById = window.MessageCache.__DEPRECATED$getById( - message1.id + message1.id, + 'test' ); assert.deepEqual( message1.attributes, diff --git a/ts/test-electron/util/downloadAttachment_test.ts b/ts/test-electron/util/downloadAttachment_test.ts index c70753f540..3cd89f1f44 100644 --- a/ts/test-electron/util/downloadAttachment_test.ts +++ b/ts/test-electron/util/downloadAttachment_test.ts @@ -3,6 +3,8 @@ import { assert } from 'chai'; import * as sinon from 'sinon'; +import { noop } from 'lodash'; + import { DataWriter } from '../../sql/Client'; import { IMAGE_PNG } from '../../types/MIME'; import { @@ -22,6 +24,7 @@ describe('utils/downloadAttachment', () => { contentType: IMAGE_PNG, digest: 'digest', }; + const abortController = new AbortController(); let sandbox: sinon.SinonSandbox; const fakeServer = {}; @@ -42,6 +45,10 @@ describe('utils/downloadAttachment', () => { }; await downloadAttachment({ attachment, + options: { + onSizeUpdate: noop, + abortSignal: abortController.signal, + }, dependencies: { downloadAttachmentFromServer: stubDownload, }, @@ -53,6 +60,8 @@ describe('utils/downloadAttachment', () => { { mediaTier: MediaTier.STANDARD, variant: AttachmentVariant.Default, + onSizeUpdate: noop, + abortSignal: abortController.signal, logPrefix: '[REDACTED]est', }, ]); @@ -72,6 +81,10 @@ describe('utils/downloadAttachment', () => { await assert.isRejected( downloadAttachment({ attachment, + options: { + onSizeUpdate: noop, + abortSignal: abortController.signal, + }, dependencies: { downloadAttachmentFromServer: stubDownload, }, @@ -86,6 +99,8 @@ describe('utils/downloadAttachment', () => { { mediaTier: MediaTier.STANDARD, variant: AttachmentVariant.Default, + onSizeUpdate: noop, + abortSignal: abortController.signal, logPrefix: '[REDACTED]est', }, ]); @@ -103,6 +118,10 @@ describe('utils/downloadAttachment', () => { }; await downloadAttachment({ attachment, + options: { + onSizeUpdate: noop, + abortSignal: abortController.signal, + }, dependencies: { downloadAttachmentFromServer: stubDownload, }, @@ -114,6 +133,8 @@ describe('utils/downloadAttachment', () => { { mediaTier: MediaTier.BACKUP, variant: AttachmentVariant.Default, + onSizeUpdate: noop, + abortSignal: abortController.signal, logPrefix: '[REDACTED]est', }, ]); @@ -135,6 +156,10 @@ describe('utils/downloadAttachment', () => { }; await downloadAttachment({ attachment, + options: { + onSizeUpdate: noop, + abortSignal: abortController.signal, + }, dependencies: { downloadAttachmentFromServer: stubDownload, }, @@ -146,6 +171,8 @@ describe('utils/downloadAttachment', () => { { mediaTier: MediaTier.BACKUP, variant: AttachmentVariant.Default, + onSizeUpdate: noop, + abortSignal: abortController.signal, logPrefix: '[REDACTED]est', }, ]); @@ -155,6 +182,8 @@ describe('utils/downloadAttachment', () => { { mediaTier: MediaTier.STANDARD, variant: AttachmentVariant.Default, + onSizeUpdate: noop, + abortSignal: abortController.signal, logPrefix: '[REDACTED]est', }, ]); @@ -176,6 +205,10 @@ describe('utils/downloadAttachment', () => { }; await downloadAttachment({ attachment, + options: { + onSizeUpdate: noop, + abortSignal: abortController.signal, + }, dependencies: { downloadAttachmentFromServer: stubDownload, }, @@ -187,6 +220,8 @@ describe('utils/downloadAttachment', () => { { mediaTier: MediaTier.BACKUP, variant: AttachmentVariant.Default, + onSizeUpdate: noop, + abortSignal: abortController.signal, logPrefix: '[REDACTED]est', }, ]); @@ -196,6 +231,8 @@ describe('utils/downloadAttachment', () => { { mediaTier: MediaTier.STANDARD, variant: AttachmentVariant.Default, + onSizeUpdate: noop, + abortSignal: abortController.signal, logPrefix: '[REDACTED]est', }, ]); @@ -218,6 +255,10 @@ describe('utils/downloadAttachment', () => { await assert.isRejected( downloadAttachment({ attachment, + options: { + onSizeUpdate: noop, + abortSignal: abortController.signal, + }, dependencies: { downloadAttachmentFromServer: stubDownload, }, @@ -231,6 +272,8 @@ describe('utils/downloadAttachment', () => { { mediaTier: MediaTier.BACKUP, variant: AttachmentVariant.Default, + onSizeUpdate: noop, + abortSignal: abortController.signal, logPrefix: '[REDACTED]est', }, ]); @@ -240,6 +283,8 @@ describe('utils/downloadAttachment', () => { { mediaTier: MediaTier.STANDARD, variant: AttachmentVariant.Default, + onSizeUpdate: noop, + abortSignal: abortController.signal, logPrefix: '[REDACTED]est', }, ]); diff --git a/ts/textsecure/WebAPI.ts b/ts/textsecure/WebAPI.ts index 16bae39c1f..e77ec66521 100644 --- a/ts/textsecure/WebAPI.ts +++ b/ts/textsecure/WebAPI.ts @@ -1328,6 +1328,7 @@ export type WebAPIType = { disableRetries?: boolean; timeout?: number; downloadOffset?: number; + abortSignal: AbortSignal; }; }) => Promise; getAttachment: (args: { @@ -1337,6 +1338,7 @@ export type WebAPIType = { disableRetries?: boolean; timeout?: number; downloadOffset?: number; + abortSignal?: AbortSignal; }; }) => Promise; getAttachmentUploadForm: () => Promise; @@ -3784,6 +3786,7 @@ export function initialize({ disableRetries?: boolean; timeout?: number; downloadOffset?: number; + abortSignal?: AbortSignal; }; }) { return _getAttachment({ diff --git a/ts/textsecure/downloadAttachment.ts b/ts/textsecure/downloadAttachment.ts index 11b57bcc48..263dc3bbdd 100644 --- a/ts/textsecure/downloadAttachment.ts +++ b/ts/textsecure/downloadAttachment.ts @@ -102,16 +102,18 @@ export async function downloadAttachment( server: WebAPIType, attachment: ProcessedAttachment, options: { - variant?: AttachmentVariant; disableRetries?: boolean; - timeout?: number; - mediaTier?: MediaTier; logPrefix?: string; - } = { variant: AttachmentVariant.Default } + mediaTier?: MediaTier; + onSizeUpdate: (totalBytes: number) => void; + timeout?: number; + variant: AttachmentVariant; + abortSignal: AbortSignal; + } ): Promise { const logId = `downloadAttachment/${options.logPrefix ?? ''}`; - const { chunkSize, digest, incrementalMac, key, size } = attachment; + const { digest, incrementalMac, chunkSize, key, size } = attachment; strictAssert(digest, `${logId}: missing digest`); strictAssert(key, `${logId}: missing key`); @@ -127,7 +129,7 @@ export async function downloadAttachment( let downloadOffset = 0; if (downloadPath) { const absoluteDownloadPath = - window.Signal.Migrations.getAbsoluteAttachmentPath(downloadPath); + window.Signal.Migrations.getAbsoluteDownloadsPath(downloadPath); try { ({ size: downloadOffset } = await stat(absoluteDownloadPath)); } catch (error) { @@ -173,10 +175,11 @@ export async function downloadAttachment( }, }); downloadResult = await downloadToDisk({ - downloadStream, - size, - downloadPath, downloadOffset, + downloadPath, + downloadStream, + onSizeUpdate: options.onSizeUpdate, + size, }); } else { const mediaId = @@ -209,6 +212,7 @@ export async function downloadAttachment( downloadStream, downloadPath, downloadOffset, + onSizeUpdate: options.onSizeUpdate, size: getAttachmentCiphertextLength( options.variant === AttachmentVariant.ThumbnailFromBackup ? // be generous, accept downloads of up to twice what we expect for thumbnail @@ -275,19 +279,23 @@ export async function downloadAttachment( } } } finally { - await safeUnlink(cipherTextAbsolutePath); + if (!downloadPath) { + await safeUnlink(cipherTextAbsolutePath); + } } } async function downloadToDisk({ - downloadStream, - downloadPath, downloadOffset = 0, + downloadPath, + downloadStream, + onSizeUpdate, size, }: { - downloadStream: Readable; - downloadPath?: string; downloadOffset?: number; + downloadPath?: string; + downloadStream: Readable; + onSizeUpdate: (totalBytes: number) => void; size: number; }): Promise<{ absolutePath: string; downloadSize: number }> { const absoluteTargetPath = downloadPath @@ -317,8 +325,12 @@ async function downloadToDisk({ await pipeline( downloadStream, checkSize(targetSize), - measureSize(bytesSeen => { - downloadSize = bytesSeen; + measureSize({ + downloadOffset, + onSizeUpdate, + onComplete: bytesSeen => { + downloadSize = bytesSeen; + }, }), writeStream ); diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts index 4f6c5c4197..9188aa5b5a 100644 --- a/ts/types/Attachment.ts +++ b/ts/types/Attachment.ts @@ -66,7 +66,6 @@ export type AttachmentType = { /** For messages not already on disk, this will be a data url */ url?: string; size: number; - fileSize?: string; pending?: boolean; width?: number; height?: number; @@ -88,8 +87,9 @@ export type AttachmentType = { textAttachment?: TextAttachmentType; wasTooBig?: boolean; + totalDownloaded?: number; incrementalMac?: string; - incrementalMacChunkSize?: number; + chunkSize?: number; backupLocator?: { mediaName: string; @@ -779,6 +779,21 @@ export function isDownloaded( return Boolean(resolved && (resolved.path || resolved.textAttachment)); } +export function isReadyToView( + attachment?: Pick< + AttachmentType, + 'incrementalMac' | 'chunkSize' | 'path' | 'textAttachment' + > +): boolean { + const fullyDownloaded = isDownloaded(attachment); + if (fullyDownloaded) { + return fullyDownloaded; + } + + const resolved = resolveNestedAttachment(attachment); + return Boolean(resolved && (resolved.path || resolved.textAttachment)); +} + export function hasNotResolved(attachment?: AttachmentType): boolean { const resolved = resolveNestedAttachment(attachment); return Boolean(resolved && !resolved.url && !resolved.textAttachment); diff --git a/ts/util/MessageModelLogger.ts b/ts/util/MessageModelLogger.ts index 4e6e5243f4..bfec13b053 100644 --- a/ts/util/MessageModelLogger.ts +++ b/ts/util/MessageModelLogger.ts @@ -12,7 +12,7 @@ export function getMessageModelLogger(model: MessageModel): MessageModel { } const proxyHandler: ProxyHandler = { - get(target: MessageModel, property: keyof MessageModel) { + get(_: MessageModel, property: keyof MessageModel) { // Allowed set of attributes & methods if (property === 'attributes') { return model.attributes; @@ -31,17 +31,17 @@ export function getMessageModelLogger(model: MessageModel): MessageModel { } if (property === 'registerLocations') { - return target.registerLocations; + return model.registerLocations; } // Disallowed set of methods & attributes - if (typeof target[property] === 'function') { - return target[property].bind(target); + if (typeof model[property] === 'function') { + return model[property].bind(model); } - if (typeof target[property] !== 'undefined') { - return target[property]; + if (typeof model[property] !== 'undefined') { + return model[property]; } return undefined; diff --git a/ts/util/attachmentDownloadQueue.ts b/ts/util/attachmentDownloadQueue.ts index ebe4c92be5..eab9a1121e 100644 --- a/ts/util/attachmentDownloadQueue.ts +++ b/ts/util/attachmentDownloadQueue.ts @@ -66,7 +66,10 @@ export async function flushAttachmentDownloadQueue(): Promise { let numMessagesQueued = 0; await Promise.all( messageIdsToDownload.map(async messageId => { - const message = window.MessageCache.__DEPRECATED$getById(messageId); + const message = window.MessageCache.__DEPRECATED$getById( + messageId, + 'flushAttachmentDownloadQueue' + ); if (!message) { log.warn( 'attachmentDownloadQueue: message not found in messageCache, maybe it was deleted?' diff --git a/ts/util/attachments.ts b/ts/util/attachments.ts index 98be5a3af1..3cf59e7c6b 100644 --- a/ts/util/attachments.ts +++ b/ts/util/attachments.ts @@ -83,7 +83,7 @@ export type CdnFieldsType = Pick< | 'cdnNumber' | 'digest' | 'incrementalMac' - | 'incrementalMacChunkSize' + | 'chunkSize' | 'isReencryptableToSameDigest' | 'iv' | 'key' @@ -104,7 +104,7 @@ export function copyCdnFields( incrementalMac: uploaded.incrementalMac ? Bytes.toBase64(uploaded.incrementalMac) : undefined, - incrementalMacChunkSize: dropNull(uploaded.chunkSize), + chunkSize: dropNull(uploaded.chunkSize), isReencryptableToSameDigest: uploaded.isReencryptableToSameDigest, iv: Bytes.toBase64(uploaded.iv), key: Bytes.toBase64(uploaded.key), diff --git a/ts/util/callDisposition.ts b/ts/util/callDisposition.ts index 61c23bb8f8..7da397c6a2 100644 --- a/ts/util/callDisposition.ts +++ b/ts/util/callDisposition.ts @@ -1356,7 +1356,10 @@ export async function updateCallHistoryFromLocalEvent( export function updateDeletedMessages(messageIds: ReadonlyArray): void { messageIds.forEach(messageId => { - const message = window.MessageCache.__DEPRECATED$getById(messageId); + const message = window.MessageCache.__DEPRECATED$getById( + messageId, + 'updateDeletedMessages' + ); const conversation = message?.getConversation(); if (message == null || conversation == null) { return; diff --git a/ts/util/deleteGroupStoryReplyForEveryone.ts b/ts/util/deleteGroupStoryReplyForEveryone.ts index f6a7cec267..da4e68bd8e 100644 --- a/ts/util/deleteGroupStoryReplyForEveryone.ts +++ b/ts/util/deleteGroupStoryReplyForEveryone.ts @@ -9,7 +9,10 @@ import * as log from '../logging/log'; export async function deleteGroupStoryReplyForEveryone( replyMessageId: string ): Promise { - const messageModel = await __DEPRECATED$getMessageById(replyMessageId); + const messageModel = await __DEPRECATED$getMessageById( + replyMessageId, + 'deleteGroupStoryReplyForEveryone' + ); if (!messageModel) { log.warn( diff --git a/ts/util/deleteStoryForEveryone.ts b/ts/util/deleteStoryForEveryone.ts index 541a67e5ac..808f2ec728 100644 --- a/ts/util/deleteStoryForEveryone.ts +++ b/ts/util/deleteStoryForEveryone.ts @@ -47,7 +47,10 @@ export async function deleteStoryForEveryone( } const logId = `deleteStoryForEveryone(${story.messageId})`; - const message = await __DEPRECATED$getMessageById(story.messageId); + const message = await __DEPRECATED$getMessageById( + story.messageId, + 'deleteStoryForEveryone' + ); if (!message) { throw new Error('Story not found'); } diff --git a/ts/util/downloadAttachment.ts b/ts/util/downloadAttachment.ts index e260a0abf1..16e3a5d7fd 100644 --- a/ts/util/downloadAttachment.ts +++ b/ts/util/downloadAttachment.ts @@ -18,11 +18,15 @@ export class AttachmentPermanentlyUndownloadableError extends Error {} export async function downloadAttachment({ attachment, - variant = AttachmentVariant.Default, + options: { variant = AttachmentVariant.Default, onSizeUpdate, abortSignal }, dependencies = { downloadAttachmentFromServer: doDownloadAttachment }, }: { attachment: AttachmentType; - variant?: AttachmentVariant; + options: { + variant?: AttachmentVariant; + onSizeUpdate: (totalBytes: number) => void; + abortSignal: AbortSignal; + }; dependencies?: { downloadAttachmentFromServer: typeof doDownloadAttachment }; }): Promise { const attachmentId = getAttachmentIdForLogging(attachment); @@ -54,9 +58,11 @@ export async function downloadAttachment({ server, migratedAttachment, { - variant, - mediaTier: MediaTier.BACKUP, logPrefix: dataId, + mediaTier: MediaTier.BACKUP, + onSizeUpdate, + variant, + abortSignal, } ); } catch (error) { @@ -80,9 +86,11 @@ export async function downloadAttachment({ server, migratedAttachment, { - variant, - mediaTier: MediaTier.STANDARD, logPrefix: dataId, + mediaTier: MediaTier.STANDARD, + onSizeUpdate, + variant, + abortSignal, } ); } catch (error) { diff --git a/ts/util/dropZero.ts b/ts/util/dropZero.ts new file mode 100644 index 0000000000..cac14e4741 --- /dev/null +++ b/ts/util/dropZero.ts @@ -0,0 +1,11 @@ +// Copyright 2024 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import { isNumber } from 'lodash'; + +export function dropZero(value: number | null | undefined): number | undefined { + if (isNumber(value) && value !== 0) { + return value; + } + return undefined; +} diff --git a/ts/util/formatFileSize.ts b/ts/util/formatFileSize.ts index a83b42b69f..bc2f2f622b 100644 --- a/ts/util/formatFileSize.ts +++ b/ts/util/formatFileSize.ts @@ -2,6 +2,6 @@ // SPDX-License-Identifier: AGPL-3.0-only import filesize from 'filesize'; -export function formatFileSize(size: number): string { - return filesize(size, { round: 0 }); +export function formatFileSize(size: number, decimals = 0): string { + return filesize(size, { round: decimals }); } diff --git a/ts/util/markConversationRead.ts b/ts/util/markConversationRead.ts index 806c846ea3..46332fbc38 100644 --- a/ts/util/markConversationRead.ts +++ b/ts/util/markConversationRead.ts @@ -104,7 +104,8 @@ export async function markConversationRead( const allReadMessagesSync = allUnreadMessages .map(messageSyncData => { const message = window.MessageCache.__DEPRECATED$getById( - messageSyncData.id + messageSyncData.id, + 'markConversationRead' ); // we update the in-memory MessageModel with fresh read/seen status if (message) { diff --git a/ts/util/markOnboardingStoryAsRead.ts b/ts/util/markOnboardingStoryAsRead.ts index b83d372c99..a0d181c258 100644 --- a/ts/util/markOnboardingStoryAsRead.ts +++ b/ts/util/markOnboardingStoryAsRead.ts @@ -20,7 +20,9 @@ export async function markOnboardingStoryAsRead(): Promise { } const messages = await Promise.all( - existingOnboardingStoryMessageIds.map(__DEPRECATED$getMessageById) + existingOnboardingStoryMessageIds.map(id => + __DEPRECATED$getMessageById(id, 'markOnboardingStoryAsRead') + ) ); const storyReadDate = Date.now(); diff --git a/ts/util/sendDeleteForEveryoneMessage.ts b/ts/util/sendDeleteForEveryoneMessage.ts index 6c0e07ce4c..4ec8d663dd 100644 --- a/ts/util/sendDeleteForEveryoneMessage.ts +++ b/ts/util/sendDeleteForEveryoneMessage.ts @@ -35,7 +35,10 @@ export async function sendDeleteForEveryoneMessage( timestamp: targetTimestamp, id: messageId, } = options; - const message = await __DEPRECATED$getMessageById(messageId); + const message = await __DEPRECATED$getMessageById( + messageId, + 'sendDeleteForEveryoneMessage' + ); if (!message) { throw new Error('sendDeleteForEveryoneMessage: Cannot find message!'); } diff --git a/ts/util/sendEditedMessage.ts b/ts/util/sendEditedMessage.ts index 7dcdc940e8..b0c61e2c90 100644 --- a/ts/util/sendEditedMessage.ts +++ b/ts/util/sendEditedMessage.ts @@ -65,7 +65,10 @@ export async function sendEditedMessage( conversation.attributes )})`; - const targetMessage = await __DEPRECATED$getMessageById(targetMessageId); + const targetMessage = await __DEPRECATED$getMessageById( + targetMessageId, + 'sendEditedMessage' + ); strictAssert(targetMessage, 'could not find message to edit'); if (isGroupV1(conversation.attributes)) { diff --git a/ts/windows/main/start.ts b/ts/windows/main/start.ts index 3229f0aff8..2c53b1f81f 100644 --- a/ts/windows/main/start.ts +++ b/ts/windows/main/start.ts @@ -66,7 +66,7 @@ if ( }, getConversation: (id: string) => window.ConversationController.get(id), getMessageById: (id: string) => - window.MessageCache.__DEPRECATED$getById(id), + window.MessageCache.__DEPRECATED$getById(id, 'SignalDebug'), getMessageBySentAt: (timestamp: number) => window.MessageCache.findBySentAt(timestamp, () => true), getReduxState: () => window.reduxStore.getState(),