- {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 (