From c235cdf58b6fb183544a7e239936ab6b7233cd8a Mon Sep 17 00:00:00 2001 From: ayumi-signal <143036029+ayumi-signal@users.noreply.github.com> Date: Tue, 11 Feb 2025 13:47:54 -0800 Subject: [PATCH] Show dialog for permanently undownloadable attachments --- _locales/en/messages.json | 62 ++++++- images/icons/v3/file/file-slash.svg | 5 - images/icons/v3/sticker/sticker-slash.svg | 5 + .../waveform-slash.svg} | 10 +- stylesheets/_modules.scss | 113 ++++++++++-- .../AttachmentNotAvailableModal.scss | 22 +++ stylesheets/manifest.scss | 1 + .../AttachmentNotAvailableModal.stories.tsx | 70 ++++++++ ts/components/AttachmentNotAvailableModal.tsx | 92 ++++++++++ ts/components/DebugLogWindow.tsx | 2 + ts/components/EditHistoryMessagesModal.tsx | 1 + ts/components/GlobalModalContainer.tsx | 11 ++ ts/components/LeftPane.stories.tsx | 3 + ts/components/Preferences.tsx | 1 + ts/components/StoryViewsNRepliesModal.tsx | 1 + ts/components/ToastManager.stories.tsx | 1 + ts/components/ToastManager.tsx | 12 +- ts/components/conversation/Message.tsx | 163 +++++++++++++++--- 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 | 81 +++++++++ ts/state/ducks/globalModals.ts | 49 ++++++ ts/state/selectors/globalModals.ts | 5 + .../smart/AttachmentNotAvailableModal.tsx | 38 ++++ ts/state/smart/GlobalModalContainer.tsx | 8 + ts/state/smart/MessageDetail.tsx | 9 +- ts/state/smart/TimelineItem.tsx | 2 + ts/state/smart/ToastManager.tsx | 4 +- ts/types/AttachmentNotAvailable.ts | 10 ++ ts/types/support.ts | 2 - 32 files changed, 734 insertions(+), 55 deletions(-) delete mode 100644 images/icons/v3/file/file-slash.svg create mode 100644 images/icons/v3/sticker/sticker-slash.svg rename images/icons/v3/{play/play-slash.svg => waveform/waveform-slash.svg} (63%) create mode 100644 stylesheets/components/AttachmentNotAvailableModal.scss create mode 100644 ts/components/AttachmentNotAvailableModal.stories.tsx create mode 100644 ts/components/AttachmentNotAvailableModal.tsx create mode 100644 ts/state/smart/AttachmentNotAvailableModal.tsx create mode 100644 ts/types/AttachmentNotAvailable.ts diff --git a/_locales/en/messages.json b/_locales/en/messages.json index f06dcccada..6b3b5a4c50 100644 --- a/_locales/en/messages.json +++ b/_locales/en/messages.json @@ -1526,18 +1526,70 @@ "messageformat": "This media is not available", "description": "Shown in info toast for messages with old image and video attachments which are no longer available for download. Also used for accessibility label for the download attachment button." }, + "icu:mediaNotAvailable--short": { + "messageformat": "Media not available", + "description": "Title of info dialog shown for messages with missing old image and video attachments which are no longer available for download." + }, "icu:attachmentNoLongerAvailable__learnMore": { "messageformat": "Learn more", "description": "Link in message placeholder and info toast for messages with old attachments which are no longer available for download." }, - "icu:fileNotAvailable": { - "messageformat": "This file is not available", - "description": "Shown in chat timeline for messages with old generic file attachments which are no longer available for download." + "icu:attachmentNotAvailable__file": { + "messageformat": "File not available", + "description": "Shown in chat timeline for messages with old file attachments which are no longer available for download." }, - "icu:voiceMessageNotAvailable": { - "messageformat": "This voice message is not available", + "icu:attachmentNotAvailable__longMessage": { + "messageformat": "This message is incomplete", + "description": "Shown in chat timeline for long messages when the complete messages are no longer available for download." + }, + "icu:attachmentNotAvailable__sticker": { + "messageformat": "Sticker not available", "description": "Shown in chat timeline for messages with old audio attachments which are no longer available for download." }, + "icu:attachmentNotAvailable__voice": { + "messageformat": "Voice message not available", + "description": "Shown in chat timeline for messages with old audio attachments which are no longer available for download." + }, + "icu:AttachmentNotAvailableModal__title--file": { + "messageformat": "File not available", + "description": "Title for info dialog for messages with old file attachments which are no longer available for download." + }, + "icu:AttachmentNotAvailableModal__title--long-text": { + "messageformat": "Complete message not available", + "description": "Title for info dialog for long messages which are no longer available for download." + }, + "icu:AttachmentNotAvailableModal__title--media": { + "messageformat": "Media not available", + "description": "Title for info dialog for messages with old visual media which are no longer available for download." + }, + "icu:AttachmentNotAvailableModal__title--sticker": { + "messageformat": "Sticker not available", + "description": "Title for info dialog for old sticker messages which are no longer available for download." + }, + "icu:AttachmentNotAvailableModal__title--voice-message": { + "messageformat": "Voice message not available", + "description": "Title for info dialog for messages with old voice messages which are no longer available for download." + }, + "icu:AttachmentNotAvailableModal__body--file": { + "messageformat": "This file was not transferred from your phone when this device was linked. Media and files older than 45 days at the time of device linking can not be synced.", + "description": "Body text for info dialog for messages with old file attachments which are no longer available for download." + }, + "icu:AttachmentNotAvailableModal__body--long-text": { + "messageformat": "The complete text in this message was not transferred from your phone when this device was linked. Long text messages older than 45 days at the time of device linking can not be synced.", + "description": "Body text for info dialog for long messages which are no longer available for download." + }, + "icu:AttachmentNotAvailableModal__body--media": { + "messageformat": "This media was not transferred from your phone when this device was linked. Media older than 45 days at the time of device linking can not be synced.", + "description": "Body text for info dialog for messages with old visual media which are no longer available for download." + }, + "icu:AttachmentNotAvailableModal__body--sticker": { + "messageformat": "This sticker was not transferred from your phone when this device was linked. Media and files older than 45 days at the time of device linking can not be synced.", + "description": "Body text for info dialog for old sticker messages which are no longer available for download." + }, + "icu:AttachmentNotAvailableModal__body--voice-message": { + "messageformat": "This voice message was not transferred from your phone when this device was linked. Media and files older than 45 days at the time of device linking can not be synced.", + "description": "Body text for info dialog for messages with old voice messages which are no longer available for download." + }, "icu:save": { "messageformat": "Save", "description": "Used on save buttons" diff --git a/images/icons/v3/file/file-slash.svg b/images/icons/v3/file/file-slash.svg deleted file mode 100644 index 8a19a92017..0000000000 --- a/images/icons/v3/file/file-slash.svg +++ /dev/null @@ -1,5 +0,0 @@ - - - - - diff --git a/images/icons/v3/sticker/sticker-slash.svg b/images/icons/v3/sticker/sticker-slash.svg new file mode 100644 index 0000000000..a420eee601 --- /dev/null +++ b/images/icons/v3/sticker/sticker-slash.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/images/icons/v3/play/play-slash.svg b/images/icons/v3/waveform/waveform-slash.svg similarity index 63% rename from images/icons/v3/play/play-slash.svg rename to images/icons/v3/waveform/waveform-slash.svg index c2a25b1ed1..8f5d91788b 100644 --- a/images/icons/v3/play/play-slash.svg +++ b/images/icons/v3/waveform/waveform-slash.svg @@ -1,7 +1,11 @@ - - - + + + + + + + diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 5098219c2a..67fe242bb5 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -594,6 +594,7 @@ $message-padding-horizontal: 12px; } .module-message__attachment-too-big--content-above { + margin-block-start: 0; border-top-left-radius: 0; border-top-right-radius: 0; } @@ -784,6 +785,11 @@ $message-padding-horizontal: 12px; margin-block-start: -25px; } +.module-message__generic-attachment--undownloadable-no-text + + .module-message__metadata { + margin-block-start: -$message-padding-vertical - 2px; +} + .module-message__sticker-container { // To ensure that images are centered if they aren't full width of bubble text-align: center; @@ -825,6 +831,10 @@ $message-padding-horizontal: 12px; } } +.module-message__generic-attachment--undownloadable { + min-width: 260px; +} + .module-message__generic-attachment--with-content-below { padding-bottom: 6px; } @@ -896,18 +906,21 @@ $message-padding-horizontal: 12px; color: variables.$color-gray-90; } +$message-attachment-padding-horizontal: 8px; + .module-message__generic-attachment__text { flex-grow: 1; - margin-inline-start: 8px; + margin-inline-start: $message-attachment-padding-horizontal + 2px; // The width of the icon plus our 8px margin plus 1px leeway max-width: calc(100% - 36px); } .module-message__generic-attachment__file-name { - @include mixins.font-body-2-bold; + @include mixins.font-body-1; margin-top: 2px; user-select: none; + font-weight: 500; // Handling really long filenames - cut them off overflow-x: hidden; @@ -933,6 +946,11 @@ $message-padding-horizontal: 12px; } } +.module-message__container--incoming + .module-message__generic-attachment__file-name--undownloadable { + color: variables.$color-black-alpha-50; +} + .module-message__generic-attachment__file-size { @include mixins.font-body-2; @@ -959,7 +977,12 @@ $message-padding-horizontal: 12px; } .module-message__undownloadable-attachment__icon-container { - margin-inline-end: 8px; + margin-inline-end: $message-attachment-padding-horizontal; +} + +.module-message__undownloadable-attachment__icon-container--file { + margin-block-start: 1px; + margin-inline-end: 3px; } .module-message__undownloadable-attachment__icon { @@ -968,35 +991,103 @@ $message-padding-horizontal: 12px; &--audio { @include mixins.color-svg( - '../images/icons/v3/play/play-slash.svg', + '../images/icons/v3/waveform/waveform-slash.svg', currentColor ); } - &--generic { + &--file { @include mixins.color-svg( - '../images/icons/v3/file/file-slash.svg', + '../images/icons/v3/error/error-circle.svg', currentColor ); } + + &--sticker { + @include mixins.color-svg( + '../images/icons/v3/sticker/sticker-slash.svg', + currentColor + ); + } + + &--small { + width: 16px; + height: 16px; + } } -.module-message__undownloadable-attachment-info { - margin-inline-end: 15px; +.module-message__undownloadable-attachment-info--file { + @include mixins.font-body-2; } -.module-message__undownloadable-attachment-learn-more-container { - font-weight: 500; +.module-message__container--incoming + .module-message__undownloadable-attachment-info--file { + color: variables.$color-black-alpha-90; +} + +.module-message__undownloadable-attachment { + min-width: 200px; +} + +.module-message__undownloadable-attachment-file { + @include mixins.font-body-2; + display: flex; +} + +.module-message__undownloadable-attachment-text { + @include mixins.button-reset; + & { + @include mixins.font-body-1; + @include mixins.light-theme { + border-block-start-color: variables.$color-black-alpha-10; + } + @include mixins.dark-theme { + border-block-start-color: variables.$color-white-alpha-10; + } + + width: calc(100% + 24px); + margin-block-start: 9px; + margin-inline: -12px; + padding-block-start: 9px; + padding-inline: 12px; + border-block-start: 0.5px solid; + } +} + +.module-message__container--outgoing + .module-message__undownloadable-attachment-text { + border-block-start-color: variables.$color-white-alpha-30; +} + +.module-message__undownloadable-attachment-text__icon-container { + margin-inline-end: 8px; + align-self: flex-start; +} + +.module-message--outgoing { + .module-message__undownloadable-attachment + .module-message__metadata { + .module-message__metadata__date--with-sticker { + color: inherit; + } + + .module-message__metadata__status-icon--with-sticker { + background-color: variables.$color-white-alpha-80; + } + } } .module-message__undownloadable-attachment-learn-more { @include mixins.button-reset; - @include mixins.font-body-1-bold; @include mixins.keyboard-mode { &:focus { box-shadow: 0px 0px 0px 2px variables.$color-ultramarine; } } + + & { + @include mixins.font-body-1-bold; + font-weight: 500; + } } .module-message__link-preview { diff --git a/stylesheets/components/AttachmentNotAvailableModal.scss b/stylesheets/components/AttachmentNotAvailableModal.scss new file mode 100644 index 0000000000..cd899b808f --- /dev/null +++ b/stylesheets/components/AttachmentNotAvailableModal.scss @@ -0,0 +1,22 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +@use '../mixins'; +@use '../variables'; + +.AttachmentNotAvailableModal__width-container { + max-width: 440px; +} + +.AttachmentNotAvailableModal .AttachmentNotAvailableModal__headerTitle { + padding-block-end: 5px; +} + +.AttachmentNotAvailableModal__body { + padding-block: 16px 0; + padding-inline: 16px; +} + +.AttachmentNotAvailableModal .module-Button { + padding-inline: 24px; +} diff --git a/stylesheets/manifest.scss b/stylesheets/manifest.scss index a1bc75b0db..ead04b1923 100644 --- a/stylesheets/manifest.scss +++ b/stylesheets/manifest.scss @@ -25,6 +25,7 @@ @use 'components/AnnouncementsOnlyGroupBanner.scss'; @use 'components/App.scss'; @use 'components/AttachmentDetailPill.scss'; +@use 'components/AttachmentNotAvailableModal.scss'; @use 'components/AudioCapture.scss'; @use 'components/AutoSizeInput.scss'; @use 'components/Avatar.scss'; diff --git a/ts/components/AttachmentNotAvailableModal.stories.tsx b/ts/components/AttachmentNotAvailableModal.stories.tsx new file mode 100644 index 0000000000..5854a21399 --- /dev/null +++ b/ts/components/AttachmentNotAvailableModal.stories.tsx @@ -0,0 +1,70 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React from 'react'; +import { action } from '@storybook/addon-actions'; +import { setupI18n } from '../util/setupI18n'; +import enMessages from '../../_locales/en/messages.json'; +import type { PropsType } from './AttachmentNotAvailableModal'; +import { + AttachmentNotAvailableModal, + AttachmentNotAvailableModalType, +} from './AttachmentNotAvailableModal'; +import type { ComponentMeta } from '../storybook/types'; + +const i18n = setupI18n('en', enMessages); + +export default { + title: 'Components/AttachmentNotAvailableModal', + component: AttachmentNotAvailableModal, + args: { + modalType: AttachmentNotAvailableModalType.VisualMedia, + i18n, + onClose: action('onClose'), + }, +} satisfies ComponentMeta; + +export function File(args: PropsType): JSX.Element { + return ( + + ); +} + +export function LongText(args: PropsType): JSX.Element { + return ( + + ); +} + +export function Sticker(args: PropsType): JSX.Element { + return ( + + ); +} + +export function VisualMedia(args: PropsType): JSX.Element { + return ( + + ); +} + +export function VoiceMessage(args: PropsType): JSX.Element { + return ( + + ); +} diff --git a/ts/components/AttachmentNotAvailableModal.tsx b/ts/components/AttachmentNotAvailableModal.tsx new file mode 100644 index 0000000000..02694a19ea --- /dev/null +++ b/ts/components/AttachmentNotAvailableModal.tsx @@ -0,0 +1,92 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import * as React from 'react'; + +import type { LocalizerType } from '../types/Util'; +import { Modal } from './Modal'; +import { Button, ButtonVariant } from './Button'; + +export type PropsType = { + i18n: LocalizerType; + modalType: AttachmentNotAvailableModalType; + onClose: () => void; +}; + +export enum AttachmentNotAvailableModalType { + File = 'File', + LongText = 'LongText', + Sticker = 'Sticker', + VisualMedia = 'VisualMedia', + VoiceMessage = 'VoiceMessage', +} + +function focusRef(el: HTMLElement | null) { + if (el) { + el.focus(); + } +} + +function getTitle( + i18n: LocalizerType, + modalType: AttachmentNotAvailableModalType +): string { + switch (modalType) { + case AttachmentNotAvailableModalType.LongText: + return i18n('icu:AttachmentNotAvailableModal__title--long-text'); + case AttachmentNotAvailableModalType.Sticker: + return i18n('icu:AttachmentNotAvailableModal__title--sticker'); + case AttachmentNotAvailableModalType.VisualMedia: + return i18n('icu:AttachmentNotAvailableModal__title--media'); + case AttachmentNotAvailableModalType.VoiceMessage: + return i18n('icu:AttachmentNotAvailableModal__title--voice-message'); + case AttachmentNotAvailableModalType.File: + default: + return i18n('icu:AttachmentNotAvailableModal__title--file'); + } +} + +function getBody( + i18n: LocalizerType, + modalType: AttachmentNotAvailableModalType +): string { + switch (modalType) { + case AttachmentNotAvailableModalType.LongText: + return i18n('icu:AttachmentNotAvailableModal__body--long-text'); + case AttachmentNotAvailableModalType.Sticker: + return i18n('icu:AttachmentNotAvailableModal__body--sticker'); + case AttachmentNotAvailableModalType.VisualMedia: + return i18n('icu:AttachmentNotAvailableModal__body--media'); + case AttachmentNotAvailableModalType.VoiceMessage: + return i18n('icu:AttachmentNotAvailableModal__body--voice-message'); + case AttachmentNotAvailableModalType.File: + default: + return i18n('icu:AttachmentNotAvailableModal__body--file'); + } +} + +export function AttachmentNotAvailableModal(props: PropsType): JSX.Element { + const { i18n, modalType, onClose } = props; + + const footer = ( + + ); + + return ( + +
+ {getBody(i18n, modalType)} +
+
+ ); +} diff --git a/ts/components/DebugLogWindow.tsx b/ts/components/DebugLogWindow.tsx index 0c94a7b8cb..dae59a4a08 100644 --- a/ts/components/DebugLogWindow.tsx +++ b/ts/components/DebugLogWindow.tsx @@ -155,6 +155,7 @@ export function DebugLogWindow({ onShowDebugLog={shouldNeverBeCalled} onUndoArchive={shouldNeverBeCalled} openFileInFolder={shouldNeverBeCalled} + showAttachmentNotAvailableModal={shouldNeverBeCalled} toast={toast} containerWidthBreakpoint={null} isInFullScreenCall={false} @@ -212,6 +213,7 @@ export function DebugLogWindow({ onShowDebugLog={shouldNeverBeCalled} onUndoArchive={shouldNeverBeCalled} openFileInFolder={shouldNeverBeCalled} + showAttachmentNotAvailableModal={shouldNeverBeCalled} toast={toast} containerWidthBreakpoint={null} isInFullScreenCall={false} diff --git a/ts/components/EditHistoryMessagesModal.tsx b/ts/components/EditHistoryMessagesModal.tsx index 660868c695..4bffc659dc 100644 --- a/ts/components/EditHistoryMessagesModal.tsx +++ b/ts/components/EditHistoryMessagesModal.tsx @@ -62,6 +62,7 @@ const MESSAGE_DEFAULT_PROPS = { showConversation: noop, showEditHistoryModal: noop, showAttachmentDownloadStillInProgressToast: shouldNeverBeCalled, + showAttachmentNotAvailableModal: shouldNeverBeCalled, showExpiredIncomingTapToViewToast: shouldNeverBeCalled, showExpiredOutgoingTapToViewToast: shouldNeverBeCalled, showLightboxForViewOnceMedia: shouldNeverBeCalled, diff --git a/ts/components/GlobalModalContainer.tsx b/ts/components/GlobalModalContainer.tsx index b7f14bb1ed..a6aebb72c6 100644 --- a/ts/components/GlobalModalContainer.tsx +++ b/ts/components/GlobalModalContainer.tsx @@ -21,6 +21,7 @@ import { ConfirmationDialog } from './ConfirmationDialog'; import { SignalConnectionsModal } from './SignalConnectionsModal'; import { WhatsNewModal } from './WhatsNewModal'; import type { StartCallData } from './ConfirmLeaveCallModal'; +import type { AttachmentNotAvailableModalType } from './AttachmentNotAvailableModal'; // NOTE: All types should be required for this component so that the smart // component gives you type errors when adding/removing props. @@ -30,6 +31,9 @@ export type PropsType = { // AddUserToAnotherGroupModal addUserToAnotherGroupModalContactId: string | undefined; renderAddUserToAnotherGroup: () => JSX.Element; + // AttachmentNotAvailableModal + attachmentNotAvailableModalType: AttachmentNotAvailableModalType | undefined; + renderAttachmentNotAvailableModal: () => JSX.Element; // CallLinkAddNameModal callLinkAddNameModalRoomId: string | null; renderCallLinkAddNameModal: () => JSX.Element; @@ -116,6 +120,9 @@ export type PropsType = { export function GlobalModalContainer({ i18n, + // AttachmentNotAvailableModal + attachmentNotAvailableModalType, + renderAttachmentNotAvailableModal, // AddUserToAnotherGroupModal addUserToAnotherGroupModalContactId, renderAddUserToAnotherGroup, @@ -327,5 +334,9 @@ export function GlobalModalContainer({ ); } + if (attachmentNotAvailableModalType) { + return renderAttachmentNotAvailableModal(); + } + return null; } diff --git a/ts/components/LeftPane.stories.tsx b/ts/components/LeftPane.stories.tsx index d82cd0b6c5..bc2d02bb8c 100644 --- a/ts/components/LeftPane.stories.tsx +++ b/ts/components/LeftPane.stories.tsx @@ -287,6 +287,9 @@ const useProps = (overrideProps: OverridePropsType = {}): PropsType => { onShowDebugLog={action('onShowDebugLog')} onUndoArchive={action('onUndoArchive')} openFileInFolder={action('openFileInFolder')} + showAttachmentNotAvailableModal={action( + 'showAttachmentNotAvailableModal' + )} toast={undefined} megaphone={undefined} containerWidthBreakpoint={containerWidthBreakpoint} diff --git a/ts/components/Preferences.tsx b/ts/components/Preferences.tsx index 7bca5ed8db..c491be888e 100644 --- a/ts/components/Preferences.tsx +++ b/ts/components/Preferences.tsx @@ -1654,6 +1654,7 @@ export function Preferences({ onShowDebugLog={shouldNeverBeCalled} onUndoArchive={shouldNeverBeCalled} openFileInFolder={shouldNeverBeCalled} + showAttachmentNotAvailableModal={shouldNeverBeCalled} toast={toast} containerWidthBreakpoint={WidthBreakpoint.Narrow} isInFullScreenCall={false} diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx index 58eb764f9a..0831c254e5 100644 --- a/ts/components/StoryViewsNRepliesModal.tsx +++ b/ts/components/StoryViewsNRepliesModal.tsx @@ -69,6 +69,7 @@ const MESSAGE_DEFAULT_PROPS = { scrollToQuotedMessage: shouldNeverBeCalled, showConversation: noop, showAttachmentDownloadStillInProgressToast: shouldNeverBeCalled, + showAttachmentNotAvailableModal: shouldNeverBeCalled, showExpiredIncomingTapToViewToast: shouldNeverBeCalled, showExpiredOutgoingTapToViewToast: shouldNeverBeCalled, showLightbox: shouldNeverBeCalled, diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx index d4fe3b2f4d..5e166430a1 100644 --- a/ts/components/ToastManager.stories.tsx +++ b/ts/components/ToastManager.stories.tsx @@ -238,6 +238,7 @@ export default { openFileInFolder: action('openFileInFolder'), onShowDebugLog: action('onShowDebugLog'), onUndoArchive: action('onUndoArchive'), + showAttachmentNotAvailableModal: action('showAttachmentNotAvailableModal'), i18n, toastType: ToastType.AddingUserToGroup, megaphoneType: MegaphoneType.UsernameOnboarding, diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx index 9166c227d8..41ed6724d7 100644 --- a/ts/components/ToastManager.tsx +++ b/ts/components/ToastManager.tsx @@ -16,8 +16,7 @@ import type { AnyToast } from '../types/Toast'; import { ToastType } from '../types/Toast'; import type { AnyActionableMegaphone } from '../types/Megaphone'; import { MegaphoneType } from '../types/Megaphone'; -import { openLinkInWebBrowser } from '../util/openLinkInWebBrowser'; -import { LINKED_DEVICES_URL } from '../types/support'; +import { AttachmentNotAvailableModalType } from './AttachmentNotAvailableModal'; export type PropsType = { hideToast: () => unknown; @@ -29,6 +28,9 @@ export type PropsType = { conversationId: string, options?: { wasPinned?: boolean } ) => unknown; + showAttachmentNotAvailableModal: ( + type: AttachmentNotAvailableModalType + ) => void; toast?: AnyToast; megaphone?: AnyActionableMegaphone; centerToast?: boolean; @@ -45,6 +47,7 @@ export function renderToast({ openFileInFolder, onShowDebugLog, onUndoArchive, + showAttachmentNotAvailableModal, OS, toast, }: PropsType): JSX.Element | null { @@ -415,7 +418,10 @@ export function renderToast({ onClose={hideToast} toastAction={{ label: i18n('icu:attachmentNoLongerAvailable__learnMore'), - onClick: () => openLinkInWebBrowser(LINKED_DEVICES_URL), + onClick: () => + showAttachmentNotAvailableModal( + AttachmentNotAvailableModalType.VisualMedia + ), }} > {i18n('icu:mediaNotAvailable')} diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx index ed1ac704e0..25a5369590 100644 --- a/ts/components/conversation/Message.tsx +++ b/ts/components/conversation/Message.tsx @@ -107,7 +107,8 @@ import { getColorForCallLink } from '../../util/getColorForCallLink'; import { getKeyFromCallLink } from '../../util/callLinks'; import { InAnotherCallTooltip } from './InAnotherCallTooltip'; import { formatFileSize } from '../../util/formatFileSize'; -import { LINKED_DEVICES_URL } from '../../types/support'; +import { AttachmentNotAvailableModalType } from '../AttachmentNotAvailableModal'; +import { assertDev } from '../../util/assert'; const GUESS_METADATA_WIDTH_TIMESTAMP_SIZE = 16; const GUESS_METADATA_WIDTH_EXPIRE_TIMER_SIZE = 18; @@ -376,6 +377,9 @@ export type PropsActions = { showEditHistoryModal?: (id: string) => unknown; showAttachmentDownloadStillInProgressToast: (count: number) => unknown; + showAttachmentNotAvailableModal: ( + modalType: AttachmentNotAvailableModalType + ) => void; showExpiredIncomingTapToViewToast: () => unknown; showExpiredOutgoingTapToViewToast: () => unknown; showMediaNoLongerAvailableToast: () => unknown; @@ -948,6 +952,7 @@ export class Message extends React.PureComponent { renderingContext, shouldCollapseAbove, shouldCollapseBelow, + showAttachmentNotAvailableModal, showLightbox, showMediaNoLongerAvailableToast, status, @@ -969,9 +974,19 @@ export class Message extends React.PureComponent { // For attachments which aren't full-frame const withContentBelow = Boolean(text || attachmentDroppedDueToSize); const withContentAbove = Boolean(quote) || this.#shouldRenderAuthor(); - const displayImage = canDisplayImage(attachments); + const displayImage = + canDisplayImage(attachments) && !attachmentDroppedDueToSize; - if (displayImage && !imageBroken) { + // attachmentDroppedDueToSize is handled in renderAttachmentTooBig + const isAttachmentNotAvailable = + isPermanentlyUndownloadable(firstAttachment) && + !attachmentDroppedDueToSize; + + if ( + displayImage && + !imageBroken && + !(isSticker && isAttachmentNotAvailable) + ) { const prefix = isSticker ? 'sticker' : 'attachment'; const containerClassName = classNames( `module-message__${prefix}-container`, @@ -1057,8 +1072,26 @@ export class Message extends React.PureComponent { } const isAttachmentAudio = isAudio(attachments); - // Undownloadable audio and generic files - if (isPermanentlyUndownloadable(firstAttachment)) { + if (isAttachmentNotAvailable && (isAttachmentAudio || isSticker)) { + let attachmentType: string; + let info: string; + let modalType: AttachmentNotAvailableModalType; + if (isAttachmentAudio) { + attachmentType = 'audio'; + info = i18n('icu:attachmentNotAvailable__voice'); + modalType = AttachmentNotAvailableModalType.VoiceMessage; + } else if (isSticker) { + attachmentType = 'sticker'; + info = i18n('icu:attachmentNotAvailable__sticker'); + modalType = AttachmentNotAvailableModalType.Sticker; + } else { + assertDev( + false, + 'renderAttachment(): Invalid case for permanently undownloadable attachment' + ); + return null; + } + const containerClassName = classNames( 'module-message__undownloadable-attachment', withContentAbove @@ -1069,11 +1102,11 @@ export class Message extends React.PureComponent { : null, text ? null : 'module-message__undownloadable-attachment--no-text' ); - const attachmentType = isAttachmentAudio ? 'audio' : 'generic'; const iconClassName = classNames( 'module-message__undownloadable-attachment__icon', `module-message__undownloadable-attachment__icon--${attachmentType}` ); + return (
@@ -1081,9 +1114,7 @@ export class Message extends React.PureComponent {
- {isAttachmentAudio - ? i18n('icu:voiceMessageNotAvailable') - : i18n('icu:fileNotAvailable')} + {info}
+ ); + } + + public renderUndownloadableTextAttachment(): JSX.Element | null { + const { i18n, textAttachment, showAttachmentNotAvailableModal } = + this.props; + if (!textAttachment || !isPermanentlyUndownloadable(textAttachment)) { + return null; + } + + return ( + @@ -2586,6 +2681,7 @@ export class Message extends React.PureComponent { {this.renderPayment()} {this.renderEmbeddedContact()} {this.renderText()} + {this.renderUndownloadableTextAttachment()} {this.#renderAction()} {this.#renderMetadata()} {this.renderSendMessageButton()} @@ -2603,12 +2699,14 @@ export class Message extends React.PureComponent { direction, giftBadge, id, + isSticker, isTapToView, isTapToViewExpired, kickOffAttachmentDownload, startConversation, openGiftBadge, pushPanelForConversation, + showAttachmentNotAvailableModal, showLightbox, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, @@ -2631,7 +2729,21 @@ export class Message extends React.PureComponent { event.preventDefault(); event.stopPropagation(); - showMediaNoLongerAvailableToast(); + // This needs to be the first check because canDisplayImage is true for stickers + if (isSticker) { + showAttachmentNotAvailableModal( + AttachmentNotAvailableModalType.Sticker + ); + } else if (canDisplayImage(attachments)) { + showMediaNoLongerAvailableToast(); + } else if (isAudio(attachments)) { + showAttachmentNotAvailableModal( + AttachmentNotAvailableModalType.VoiceMessage + ); + } else { + showAttachmentNotAvailableModal(AttachmentNotAvailableModalType.File); + } + return; } @@ -2817,7 +2929,12 @@ export class Message extends React.PureComponent { const isAttachmentPending = this.isAttachmentPending(); const width = this.getWidth(); const isEmojiOnly = this.#canRenderStickerLikeEmoji(); - const isStickerLike = isSticker || isEmojiOnly; + const isStickerLike = + isEmojiOnly || + (isSticker && + attachments && + attachments[0] && + !isPermanentlyUndownloadable(attachments[0])); // If it's a mostly-normal gray incoming text box, we don't want to darken it as much const lighterSelect = diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx index 0a826fb257..15659cb37d 100644 --- a/ts/components/conversation/MessageDetail.tsx +++ b/ts/components/conversation/MessageDetail.tsx @@ -101,6 +101,7 @@ export type PropsReduxActions = Pick< | 'showConversation' | 'showEditHistoryModal' | 'showAttachmentDownloadStillInProgressToast' + | 'showAttachmentNotAvailableModal' | 'showExpiredIncomingTapToViewToast' | 'showExpiredOutgoingTapToViewToast' | 'showLightbox' @@ -149,6 +150,7 @@ export function MessageDetail({ showConversation, showEditHistoryModal, showAttachmentDownloadStillInProgressToast, + showAttachmentNotAvailableModal, showExpiredIncomingTapToViewToast, showExpiredOutgoingTapToViewToast, showLightbox, @@ -370,6 +372,7 @@ export function MessageDetail({ showAttachmentDownloadStillInProgressToast={ showAttachmentDownloadStillInProgressToast } + showAttachmentNotAvailableModal={showAttachmentNotAvailableModal} showExpiredIncomingTapToViewToast={ showExpiredIncomingTapToViewToast } diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx index e45e5447cb..ec000c5706 100644 --- a/ts/components/conversation/Quote.stories.tsx +++ b/ts/components/conversation/Quote.stories.tsx @@ -138,6 +138,7 @@ const defaultMessageProps: TimelineMessagesProps = { showAttachmentDownloadStillInProgressToast: action( 'showAttachmentDownloadStillInProgressToast' ), + showAttachmentNotAvailableModal: action('showAttachmentNotAvailableModal'), showExpiredIncomingTapToViewToast: action( 'showExpiredIncomingTapToViewToast' ), diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx index 85dca9a546..92fa9e8153 100644 --- a/ts/components/conversation/Timeline.stories.tsx +++ b/ts/components/conversation/Timeline.stories.tsx @@ -310,6 +310,7 @@ const actions = () => ({ showAttachmentDownloadStillInProgressToast: action( 'showAttachmentDownloadStillInProgressToast' ), + showAttachmentNotAvailableModal: action('showAttachmentNotAvailableModal'), showExpiredIncomingTapToViewToast: action( 'showExpiredIncomingTapToViewToast' ), diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx index c0d9a79c8c..05c08c5d95 100644 --- a/ts/components/conversation/TimelineItem.stories.tsx +++ b/ts/components/conversation/TimelineItem.stories.tsx @@ -107,6 +107,7 @@ const getDefaultProps = () => ({ showExpiredOutgoingTapToViewToast: action( 'showExpiredIncomingTapToViewToast' ), + showAttachmentNotAvailableModal: action('showAttachmentNotAvailableModal'), showMediaNoLongerAvailableToast: action('showMediaNoLongerAvailableToast'), scrollToQuotedMessage: action('scrollToQuotedMessage'), showSpoiler: action('showSpoiler'), diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx index 28f1274dbe..ad0b04acf9 100644 --- a/ts/components/conversation/TimelineMessage.stories.tsx +++ b/ts/components/conversation/TimelineMessage.stories.tsx @@ -345,6 +345,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ showAttachmentDownloadStillInProgressToast: action( 'showAttachmentDownloadStillInProgressToast' ), + showAttachmentNotAvailableModal: action('showAttachmentNotAvailableModal'), showExpiredIncomingTapToViewToast: action( 'showExpiredIncomingTapToViewToast' ), @@ -845,10 +846,24 @@ CanDeleteForEveryone.args = { direction: 'outgoing', }; +const bigAttachment = { + contentType: stringToMIMEType('text/plain'), + fileName: 'why-i-love-birds.txt', + size: 100000000000, + width: undefined, + height: undefined, + path: undefined, + key: undefined, + id: undefined, + error: true, + wasTooBig: true, +}; + export function AttachmentTooBig(): JSX.Element { const propsSent = createProps({ conversationType: 'direct', attachmentDroppedDueToSize: true, + attachments: [bigAttachment], }); return <>{renderBothDirections(propsSent)}; @@ -858,6 +873,37 @@ export function AttachmentTooBigWithText(): JSX.Element { const propsSent = createProps({ conversationType: 'direct', attachmentDroppedDueToSize: true, + attachments: [bigAttachment], + text: 'Check out this file!', + }); + + return <>{renderBothDirections(propsSent)}; +} + +const bigImageAttachment = { + ...bigAttachment, + contentType: IMAGE_JPEG, + fileName: 'bird.jpg', + blurHash: 'LDA,FDBnm+I=p{tkIUI;~UkpELV]', + width: 1000, + height: 1000, +}; + +export function AttachmentTooBigImage(): JSX.Element { + const propsSent = createProps({ + conversationType: 'direct', + attachmentDroppedDueToSize: true, + attachments: [bigImageAttachment], + }); + + return <>{renderBothDirections(propsSent)}; +} + +export function AttachmentTooBigImageWithText(): JSX.Element { + const propsSent = createProps({ + conversationType: 'direct', + attachmentDroppedDueToSize: true, + attachments: [bigImageAttachment], text: 'Check out this file!', }); @@ -2340,6 +2386,29 @@ export function PermanentlyUndownloadableAttachments(): JSX.Element { ...textFileProps, text: "Here's that file", }; + const stickerProps = createProps({ + attachments: [ + fakeAttachment({ + fileName: '512x515-thumbs-up-lincoln.webp', + contentType: IMAGE_WEBP, + width: 128, + height: 128, + error: true, + }), + ], + isSticker: true, + status: 'sent', + }); + const longMessageProps = createProps({ + text: 'Hello there from a pal! I am sending a long message so that it will wrap a bit, since I like that look.', + textAttachment: { + contentType: LONG_MESSAGE, + size: 123, + pending: false, + key: undefined, + error: true, + }, + }); const outgoingAuthor = { ...imageProps.author, @@ -2352,8 +2421,10 @@ export function PermanentlyUndownloadableAttachments(): JSX.Element { + + + + ; +type HideAttachmentNotAvailableModalActionType = ReadonlyDeep<{ + type: typeof HIDE_ATTACHMENT_NOT_AVAILABLE_MODAL; +}>; + +type ShowAttachmentNotAvailableModalActionType = ReadonlyDeep<{ + type: typeof SHOW_ATTACHMENT_NOT_AVAILABLE_MODAL; + payload: AttachmentNotAvailableModalType; +}>; + type HideContactModalActionType = ReadonlyDeep<{ type: typeof HIDE_CONTACT_MODAL; }>; @@ -378,6 +393,7 @@ export type GlobalModalsActionType = ReadonlyDeep< | CloseGV2MigrationDialogActionType | CloseShortcutGuideModalActionType | CloseStickerPackPreviewActionType + | HideAttachmentNotAvailableModalActionType | HideContactModalActionType | HideSendAnywayDialogActiontype | HideStoriesSettingsActionType @@ -386,6 +402,7 @@ export type GlobalModalsActionType = ReadonlyDeep< | MessageChangedActionType | MessageDeletedActionType | MessageExpiredActionType + | ShowAttachmentNotAvailableModalActionType | ShowContactModalActionType | ShowEditHistoryModalActionType | ShowErrorModalActionType @@ -423,11 +440,13 @@ export const actions = { closeGV2MigrationDialog, closeShortcutGuideModal, closeStickerPackPreview, + hideAttachmentNotAvailableModal, hideBlockingSafetyNumberChangeDialog, hideContactModal, hideStoriesSettings, hideUserNotFoundModal, hideWhatsNewModal, + showAttachmentNotAvailableModal, showBlockingSafetyNumberChangeDialog, showContactModal, showEditHistoryModal, @@ -462,6 +481,21 @@ export const useGlobalModalActions = (): BoundActionCreatorsMapObject< typeof actions > => useBoundActions(actions); +function hideAttachmentNotAvailableModal(): HideAttachmentNotAvailableModalActionType { + return { + type: HIDE_ATTACHMENT_NOT_AVAILABLE_MODAL, + }; +} + +function showAttachmentNotAvailableModal( + payload: AttachmentNotAvailableModalType +): ShowAttachmentNotAvailableModalActionType { + return { + type: SHOW_ATTACHMENT_NOT_AVAILABLE_MODAL, + payload, + }; +} + function hideContactModal(): HideContactModalActionType { return { type: HIDE_CONTACT_MODAL, @@ -994,6 +1028,7 @@ function copyOverMessageAttributesIntoForwardMessages( export function getEmptyState(): GlobalModalsStateType { return { + attachmentNotAvailableModalType: undefined, hasConfirmationModal: false, callLinkAddNameModalRoomId: null, callLinkEditModalRoomId: null, @@ -1083,6 +1118,20 @@ export function reducer( }; } + if (action.type === HIDE_ATTACHMENT_NOT_AVAILABLE_MODAL) { + return { + ...state, + attachmentNotAvailableModalType: undefined, + }; + } + + if (action.type === SHOW_ATTACHMENT_NOT_AVAILABLE_MODAL) { + return { + ...state, + attachmentNotAvailableModalType: action.payload, + }; + } + if (action.type === SHOW_CONTACT_MODAL) { const ourId = window.ConversationController.getOurConversationIdOrThrow(); if (action.payload.contactId === ourId) { diff --git a/ts/state/selectors/globalModals.ts b/ts/state/selectors/globalModals.ts index 63b70c29c5..cf7a2c436e 100644 --- a/ts/state/selectors/globalModals.ts +++ b/ts/state/selectors/globalModals.ts @@ -22,6 +22,11 @@ export const isShowingAnyModal = createSelector( }) ); +export const getAttachmentNotAvailableModalType = createSelector( + getGlobalModalsState, + ({ attachmentNotAvailableModalType }) => attachmentNotAvailableModalType +); + export const getCallLinkEditModalRoomId = createSelector( getGlobalModalsState, ({ callLinkEditModalRoomId }) => callLinkEditModalRoomId diff --git a/ts/state/smart/AttachmentNotAvailableModal.tsx b/ts/state/smart/AttachmentNotAvailableModal.tsx new file mode 100644 index 0000000000..19d1aafff5 --- /dev/null +++ b/ts/state/smart/AttachmentNotAvailableModal.tsx @@ -0,0 +1,38 @@ +// Copyright 2025 Signal Messenger, LLC +// SPDX-License-Identifier: AGPL-3.0-only + +import React, { memo, useCallback } from 'react'; +import { useSelector } from 'react-redux'; +import { AttachmentNotAvailableModal } from '../../components/AttachmentNotAvailableModal'; +import { strictAssert } from '../../util/assert'; +import { getAttachmentNotAvailableModalType } from '../selectors/globalModals'; +import { getIntl } from '../selectors/user'; +import { useGlobalModalActions } from '../ducks/globalModals'; + +export const SmartAttachmentNotAvailableModal = memo( + function SmartAttachmentNotAvailableModal() { + const i18n = useSelector(getIntl); + const attachmentNotAvailableModalType = useSelector( + getAttachmentNotAvailableModalType + ); + + strictAssert( + attachmentNotAvailableModalType != null, + 'attachmentNotAvailableModalType is required' + ); + + const { hideAttachmentNotAvailableModal } = useGlobalModalActions(); + + const handleClose = useCallback(() => { + hideAttachmentNotAvailableModal(); + }, [hideAttachmentNotAvailableModal]); + + return ( + + ); + } +); diff --git a/ts/state/smart/GlobalModalContainer.tsx b/ts/state/smart/GlobalModalContainer.tsx index 8f802ca52d..9fbedb1dbc 100644 --- a/ts/state/smart/GlobalModalContainer.tsx +++ b/ts/state/smart/GlobalModalContainer.tsx @@ -30,6 +30,7 @@ import { SmartCallLinkEditModal } from './CallLinkEditModal'; import { SmartCallLinkAddNameModal } from './CallLinkAddNameModal'; import { SmartConfirmLeaveCallModal } from './ConfirmLeaveCallModal'; import { SmartCallLinkPendingParticipantModal } from './CallLinkPendingParticipantModal'; +import { SmartAttachmentNotAvailableModal } from './AttachmentNotAvailableModal'; function renderCallLinkAddNameModal(): JSX.Element { return ; @@ -99,6 +100,10 @@ function renderAboutContactModal(): JSX.Element { return ; } +function renderAttachmentNotAvailableModal(): JSX.Element { + return ; +} + export const SmartGlobalModalContainer = memo( function SmartGlobalModalContainer() { const conversationsStoppingSend = useSelector(getConversationsStoppingSend); @@ -110,6 +115,7 @@ export const SmartGlobalModalContainer = memo( const { aboutContactModalContactId, addUserToAnotherGroupModalContactId, + attachmentNotAvailableModalType, callLinkAddNameModalRoomId, callLinkEditModalRoomId, callLinkPendingParticipantContactId, @@ -189,6 +195,7 @@ export const SmartGlobalModalContainer = memo( return (