From d3f0b7ba802740d2796bd4e1b05a130d8a963bd5 Mon Sep 17 00:00:00 2001 From: FellowTraveler Date: Sat, 2 May 2026 09:04:35 -0500 Subject: [PATCH 1/4] Open file attachments in default app instead of save dialog Clicking a downloaded generic file attachment now immediately opens it with the system default application (e.g. Preview on macOS) rather than showing a save-to-disk modal dialog, reducing friction for viewing files shared in conversations. Adds an 'open-file-path' IPC channel in the main process that calls shell.openPath, and a new openAttachmentInDefaultApp action that decrypts the attachment to the OS temp directory before opening it. --- app/main.main.ts | 4 +++ ts/components/conversation/Message.dom.tsx | 6 +++-- .../conversation/TimelineItem.dom.stories.tsx | 1 + .../TimelineMessage.dom.stories.tsx | 1 + ts/state/ducks/conversations.preload.ts | 26 +++++++++++++++++++ ts/state/smart/TimelineItem.preload.tsx | 2 ++ 6 files changed, 38 insertions(+), 2 deletions(-) diff --git a/app/main.main.ts b/app/main.main.ts index c9eaff594ed..ca79953a217 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -3194,6 +3194,10 @@ ipc.on('show-item-in-folder', (_event, folder) => { shell.showItemInFolder(folder); }); +ipc.handle('open-file-path', async (_event, filePath: string) => { + await shell.openPath(filePath); +}); + ipc.handle('show-save-dialog', async (_event, { defaultPath }) => { if (!mainWindow) { log.warn('show-save-dialog: no main window'); diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index d8f96e8e7a5..28db86603d3 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -19,6 +19,7 @@ import type { ConversationType, ConversationTypeType, InteractionModeType, + OpenAttachmentInDefaultAppActionCreatorType, PushPanelForConversationActionType, SaveAttachmentActionCreatorType, SaveAttachmentsActionCreatorType, @@ -386,6 +387,7 @@ export type PropsActions = { attachment: AttachmentType; messageId: string; }) => void; + openAttachmentInDefaultApp: OpenAttachmentInDefaultAppActionCreatorType; saveAttachment: SaveAttachmentActionCreatorType; saveAttachments: SaveAttachmentsActionCreatorType; showLightbox: (options: { @@ -3218,7 +3220,7 @@ export class Message extends React.PureComponent { const { id, attachments, - saveAttachment, + openAttachmentInDefaultApp, timestamp, kickOffAttachmentDownload, attachmentDroppedDueToSize, @@ -3251,7 +3253,7 @@ export class Message extends React.PureComponent { messageId: id, }); } else { - saveAttachment(firstAttachment, timestamp); + openAttachmentInDefaultApp(firstAttachment, timestamp); } }; diff --git a/ts/components/conversation/TimelineItem.dom.stories.tsx b/ts/components/conversation/TimelineItem.dom.stories.tsx index 69707c85b7f..150b3fbe293 100644 --- a/ts/components/conversation/TimelineItem.dom.stories.tsx +++ b/ts/components/conversation/TimelineItem.dom.stories.tsx @@ -73,6 +73,7 @@ const getDefaultProps = () => ({ messageExpanded: action('messageExpanded'), showConversation: action('showConversation'), openGiftBadge: action('openGiftBadge'), + openAttachmentInDefaultApp: action('openAttachmentInDefaultApp'), saveAttachment: action('saveAttachment'), saveAttachments: action('saveAttachments'), showPinMessageDialog: action('showPinMessageDialog'), diff --git a/ts/components/conversation/TimelineMessage.dom.stories.tsx b/ts/components/conversation/TimelineMessage.dom.stories.tsx index 658cbbe1cc7..a2b2da857f7 100644 --- a/ts/components/conversation/TimelineMessage.dom.stories.tsx +++ b/ts/components/conversation/TimelineMessage.dom.stories.tsx @@ -317,6 +317,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ : overrideProps.readStatus, renderReactionPicker, renderAudioAttachment, + openAttachmentInDefaultApp: action('openAttachmentInDefaultApp'), saveAttachment: action('saveAttachment'), saveAttachments: action('saveAttachments'), setQuoteByMessageId: action('setQuoteByMessageId'), diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index a15d49b3320..b096082902f 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -1,6 +1,7 @@ // Copyright 2019 Signal Messenger, LLC // SPDX-License-Identifier: AGPL-3.0-only +import { tmpdir } from 'node:os'; import type { ThunkAction } from 'redux-thunk'; import lodash from 'lodash'; import { type PhoneNumber } from 'google-libphonenumber'; @@ -1227,6 +1228,7 @@ export const actions = { saveAttachment, saveAttachments, saveAttachmentFromMessage, + openAttachmentInDefaultApp, saveAvatarToDisk, scrollToMessage, scrollToOldestUnreadMention, @@ -4187,6 +4189,30 @@ function saveAttachment( }; } +export type OpenAttachmentInDefaultAppActionCreatorType = ReadonlyDeep< + (attachment: AttachmentType, timestamp?: number) => unknown +>; + +function openAttachmentInDefaultApp( + attachment: AttachmentType, + timestamp = Date.now() +): ThunkAction { + return async () => { + const fullPath = await Attachment.save({ + attachment, + getUnusedFilename, + readAttachmentData, + saveAttachmentToDisk, + timestamp, + baseDir: tmpdir(), + }); + + if (fullPath) { + await ipcRenderer.invoke('open-file-path', fullPath); + } + }; +} + const showSaveMultiDialog = ( i18n: LocalizerType ): Promise<{ diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx index 879e07c6b73..bf9126ca46c 100644 --- a/ts/state/smart/TimelineItem.preload.tsx +++ b/ts/state/smart/TimelineItem.preload.tsx @@ -166,6 +166,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( markAttachmentAsCorrupted, messageExpanded, onPinnedMessageRemove, + openAttachmentInDefaultApp, openGiftBadge, retryDeleteForEveryone, retryMessageSend, @@ -297,6 +298,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( sendPollVote={sendPollVote} renderItem={renderItem} returnToActiveCall={returnToActiveCall} + openAttachmentInDefaultApp={openAttachmentInDefaultApp} saveAttachment={saveAttachment} saveAttachments={saveAttachments} scrollToPollMessage={scrollToPollMessage} From 43ed55b152fc44fce089ff83190c5e39587acd0e Mon Sep 17 00:00:00 2001 From: FellowTraveler Date: Sat, 2 May 2026 09:16:00 -0500 Subject: [PATCH 2/4] Add save icon button on downloaded file attachments Shows a save icon button (on hover) overlaying the attachment UI when a file has been fully downloaded, allowing users to save to a chosen location. The icon uses the existing save.svg asset and appears/fades with a CSS transition. Clicking it calls saveAttachment (shows the save dialog) without triggering the main click-to-open behavior. --- stylesheets/_modules.scss | 50 ++++++++++++++++++++++ ts/components/conversation/Message.dom.tsx | 14 ++++++ 2 files changed, 64 insertions(+) diff --git a/stylesheets/_modules.scss b/stylesheets/_modules.scss index 3bd8bc83dba..5d3c944fcad 100644 --- a/stylesheets/_modules.scss +++ b/stylesheets/_modules.scss @@ -671,6 +671,56 @@ $message-padding-horizontal: 12px; cursor: pointer; } +.module-message__simple-attachment-container { + position: relative; + width: 100%; +} + +.module-message__simple-attachment__save-button { + @include mixins.button-reset; + + & { + position: absolute; + top: 50%; + inset-inline-end: 8px; + transform: translateY(-50%); + width: 20px; + height: 20px; + opacity: 0; + transition: opacity 150ms; + } + + .module-message__simple-attachment-container:hover & { + opacity: 1; + } + + @include mixins.light-theme { + @include mixins.color-svg( + '../images/icons/v3/save/save.svg', + variables.$color-gray-45 + ); + &:hover { + @include mixins.color-svg( + '../images/icons/v3/save/save.svg', + variables.$color-gray-90 + ); + } + } + + @include mixins.dark-theme { + @include mixins.color-svg( + '../images/icons/v3/save/save.svg', + variables.$color-gray-45 + ); + &:hover { + @include mixins.color-svg( + '../images/icons/v3/save/save.svg', + variables.$color-gray-02 + ); + } + } +} + .module-message__simple-attachment { @include mixins.button-reset; diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index 28db86603d3..d8a37f67752 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -1187,6 +1187,7 @@ export class Message extends React.PureComponent { shouldCollapseAbove, shouldCollapseBelow, showEditHistoryModal, + saveAttachment, showLightbox, showMediaNoLongerAvailableToast, status, @@ -1358,6 +1359,7 @@ export class Message extends React.PureComponent { // attachment. But we don't want the user to tab here unless that text exists. const tabIndex = text ? 0 : -1; return ( +
+ {firstAttachment.path && !isAttachmentNotAvailable && ( + - {firstAttachment.path && !isAttachmentNotAvailable && ( -