From 02187636ef1522d454caaac7d77bf40f0391c6c2 Mon Sep 17 00:00:00 2001 From: FellowTraveler Date: Sat, 2 May 2026 10:07:00 -0500 Subject: [PATCH 1/6] Add drag-and-drop support for file attachments Allows users to drag downloaded file attachments from Signal directly into a Finder folder. Decrypts the attachment to a temp file and uses Electron's webContents.startDrag() for native OS drag-and-drop support. --- app/main.main.ts | 9 +++++++ ts/components/conversation/Message.dom.tsx | 15 +++++++++++ .../conversation/TimelineItem.dom.stories.tsx | 1 + .../TimelineMessage.dom.stories.tsx | 1 + ts/state/ducks/conversations.preload.ts | 25 +++++++++++++++++++ ts/state/smart/TimelineItem.preload.tsx | 2 ++ 6 files changed, 53 insertions(+) diff --git a/app/main.main.ts b/app/main.main.ts index e1a3cd83eaa..93c6365ff08 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -18,6 +18,7 @@ import { dialog, ipcMain as ipc, Menu, + nativeImage, nativeTheme, net, powerSaveBlocker, @@ -3218,6 +3219,14 @@ ipc.on('show-item-in-folder', (_event, folder) => { shell.showItemInFolder(folder); }); +ipc.on('start-attachment-drag', (event, filePath: string) => { + const icon = nativeImage + .createFromPath(join(__dirname, '../images/group_default.png')) + .resize({ width: 32 }); + event.sender.startDrag({ file: filePath, icon }); +}); + + 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 009e29b8461..d92d53f9e09 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -22,6 +22,7 @@ import type { ReadonlyDeep } from 'type-fest'; import type { ConversationType, ConversationTypeType, + DragAttachmentActionCreatorType, InteractionModeType, PushPanelForConversationActionType, SaveAttachmentActionCreatorType, @@ -375,6 +376,7 @@ export type PropsActions = { attachment: AttachmentType; messageId: string; }) => void; + dragAttachment: DragAttachmentActionCreatorType; saveAttachment: SaveAttachmentActionCreatorType; saveAttachments: SaveAttachmentsActionCreatorType; showLightbox: (options: { @@ -1154,6 +1156,7 @@ export class Message extends PureComponent { canRetryDeleteForEveryone, cancelAttachmentDownload, direction, + dragAttachment, expirationLength, expirationTimestamp, i18n, @@ -1345,7 +1348,18 @@ export class Message extends PureComponent { // Note: this has to be interactive for the case where text comes along with the // attachment. But we don't want the user to tab here unless that text exists. const tabIndex = text ? 0 : -1; + const isDraggable = !!firstAttachment.path && !isAttachmentNotAvailable; return ( +
{ + event.preventDefault(); + if (isDraggable) { + dragAttachment(firstAttachment, timestamp); + } + }} + >
+ ); } diff --git a/ts/components/conversation/TimelineItem.dom.stories.tsx b/ts/components/conversation/TimelineItem.dom.stories.tsx index 6e221fc783d..29d6254972b 100644 --- a/ts/components/conversation/TimelineItem.dom.stories.tsx +++ b/ts/components/conversation/TimelineItem.dom.stories.tsx @@ -75,6 +75,7 @@ const getDefaultProps = () => ({ messageExpanded: action('messageExpanded'), showConversation: action('showConversation'), openGiftBadge: action('openGiftBadge'), + dragAttachment: action('dragAttachment'), 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 05a35a6f153..aab540a10b3 100644 --- a/ts/components/conversation/TimelineMessage.dom.stories.tsx +++ b/ts/components/conversation/TimelineMessage.dom.stories.tsx @@ -319,6 +319,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({ : overrideProps.readStatus, renderReactionPicker, renderAudioAttachment, + dragAttachment: action('dragAttachment'), 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 0357a166d70..a7d54bcefa1 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -1233,6 +1233,7 @@ export const actions = { saveAttachment, saveAttachments, saveAttachmentFromMessage, + dragAttachment, saveAvatarToDisk, scrollToMessage, scrollToOldestUnreadMention, @@ -4204,6 +4205,30 @@ function saveAttachment( }; } +export type DragAttachmentActionCreatorType = ReadonlyDeep< + (attachment: AttachmentType, timestamp?: number) => unknown +>; + +function dragAttachment( + attachment: AttachmentType, + timestamp = Date.now() +): ThunkAction { + return async () => { + const fullPath = await Attachment.save({ + attachment, + getUnusedFilename, + readAttachmentData, + saveAttachmentToDisk, + timestamp, + baseDir: tmpdir(), + }); + if (fullPath) { + ipcRenderer.send('start-attachment-drag', fullPath); + } + }; +} + + const showSaveMultiDialog = ( i18n: LocalizerType ): Promise<{ diff --git a/ts/state/smart/TimelineItem.preload.tsx b/ts/state/smart/TimelineItem.preload.tsx index 69010daacb9..7fae4f21d8c 100644 --- a/ts/state/smart/TimelineItem.preload.tsx +++ b/ts/state/smart/TimelineItem.preload.tsx @@ -163,6 +163,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( clearTargetedMessage: clearSelectedMessage, copyMessageText, doubleCheckMissingQuoteReference, + dragAttachment, kickOffAttachmentDownload, markAttachmentAsCorrupted, messageExpanded, @@ -300,6 +301,7 @@ export const SmartTimelineItem = memo(function SmartTimelineItem( sendPollVote={sendPollVote} renderItem={renderItem} returnToActiveCall={returnToActiveCall} + dragAttachment={dragAttachment} saveAttachment={saveAttachment} saveAttachments={saveAttachments} scrollToPollMessage={scrollToPollMessage} From f0a4aacdd51a8ce32e43bc861b3642a742137370 Mon Sep 17 00:00:00 2001 From: FellowTraveler Date: Fri, 8 May 2026 21:50:31 -0500 Subject: [PATCH 2/6] Fix missing tmpdir import for drag attachment temp file --- ts/state/ducks/conversations.preload.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/ts/state/ducks/conversations.preload.ts b/ts/state/ducks/conversations.preload.ts index a7d54bcefa1..7a485edba57 100644 --- a/ts/state/ducks/conversations.preload.ts +++ b/ts/state/ducks/conversations.preload.ts @@ -5,6 +5,7 @@ import type { ThunkAction } from 'redux-thunk'; import lodash from 'lodash'; import { type PhoneNumber } from 'google-libphonenumber'; +import { tmpdir } from 'os'; import { clipboard, ipcRenderer } from 'electron'; import type { ReadonlyDeep, SetOptional } from 'type-fest'; import { DataReader, DataWriter } from '../../sql/Client.preload.ts'; From 5570a6d68e631663449d1a64b12367a1afee0a43 Mon Sep 17 00:00:00 2001 From: FellowTraveler Date: Mon, 1 Jun 2026 13:23:16 -0500 Subject: [PATCH 3/6] Use Signal temp dir and clean up temp file after drag --- app/main.main.ts | 6 +++++ 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, 40 insertions(+), 2 deletions(-) diff --git a/app/main.main.ts b/app/main.main.ts index 93c6365ff08..bf354d08937 100644 --- a/app/main.main.ts +++ b/app/main.main.ts @@ -3226,6 +3226,12 @@ ipc.on('start-attachment-drag', (event, filePath: string) => { event.sender.startDrag({ file: filePath, icon }); }); +ipc.on('cleanup-drag-temp-file', (_event, filePath: string) => { + fsExtra.remove(filePath).catch(err => { + log.warn('Failed to cleanup drag temp file:', Errors.toLogFormat(err)); + }); +}); + ipc.handle('show-save-dialog', async (_event, { defaultPath }) => { if (!mainWindow) { diff --git a/ts/components/conversation/Message.dom.tsx b/ts/components/conversation/Message.dom.tsx index d92d53f9e09..7e5e277b82f 100644 --- a/ts/components/conversation/Message.dom.tsx +++ b/ts/components/conversation/Message.dom.tsx @@ -22,6 +22,7 @@ import type { ReadonlyDeep } from 'type-fest'; import type { ConversationType, ConversationTypeType, + CleanupDragAttachmentActionCreatorType, DragAttachmentActionCreatorType, InteractionModeType, PushPanelForConversationActionType, @@ -377,6 +378,7 @@ export type PropsActions = { messageId: string; }) => void; dragAttachment: DragAttachmentActionCreatorType; + cleanupDragAttachment: CleanupDragAttachmentActionCreatorType; saveAttachment: SaveAttachmentActionCreatorType; saveAttachments: SaveAttachmentsActionCreatorType; showLightbox: (options: { @@ -1157,6 +1159,7 @@ export class Message extends PureComponent { cancelAttachmentDownload, direction, dragAttachment, + cleanupDragAttachment, expirationLength, expirationTimestamp, i18n, @@ -1359,6 +1362,9 @@ export class Message extends PureComponent { dragAttachment(firstAttachment, timestamp); } }} + onDragEnd={() => { + cleanupDragAttachment(); + }} >