diff --git a/_locales/de/messages.json b/_locales/de/messages.json
index 60c7062fcf57..fa19cc163fdb 100644
--- a/_locales/de/messages.json
+++ b/_locales/de/messages.json
@@ -791,9 +791,15 @@
"icu:attachmentSaved": {
"messageformat": "Anhang gespeichert."
},
+ "icu:attachmentSavedPlural": {
+ "messageformat": "{count, plural, one {Anhang} other {# Anhänge}} gespeichert."
+ },
"icu:attachmentSavedShow": {
"messageformat": "Im Ordner anzeigen"
},
+ "icu:attachmentStillDownloading": {
+ "messageformat": "{count, plural, one {Anhang kann} other {Anhänge können}} nicht gespeichert werden, da {count, plural, one {er} other {#}} noch nicht fertig heruntergeladen {count, plural, one {wurde} other {wurden}}."
+ },
"icu:you": {
"messageformat": "Du"
},
diff --git a/_locales/en/messages.json b/_locales/en/messages.json
index 8802b83b3183..fb4f0e654ba9 100644
--- a/_locales/en/messages.json
+++ b/_locales/en/messages.json
@@ -1066,10 +1066,18 @@
"messageformat": "Attachment saved.",
"description": "Shown after user selects to save to downloads"
},
+ "icu:attachmentSavedPlural": {
+ "messageformat": "{count, plural, one {Attachment} other {# attachments}} saved",
+ "description": "Shown after user selects to save to downloads"
+ },
"icu:attachmentSavedShow": {
"messageformat": "Show in folder",
"description": "Button label for showing the attachment in your file system"
},
+ "icu:attachmentStillDownloading": {
+ "messageformat": "Can't save {count, plural, one {attachment, since it hasn't} other {attachments, since # haven't}} finished downloading yet",
+ "description": "Shown when the user tries to save an attachment to the filesystem, but it's impossible since the download from servers hasn't finished yet"
+ },
"icu:you": {
"messageformat": "You",
"description": "Shown when the user represented is the current user."
diff --git a/app/main.ts b/app/main.ts
index a8b845c880ae..3f8df3b881cc 100644
--- a/app/main.ts
+++ b/app/main.ts
@@ -3032,6 +3032,32 @@ ipc.handle('show-save-dialog', async (_event, { defaultPath }) => {
return { canceled: false, filePath: finalFilePath };
});
+ipc.handle('show-save-multi-dialog', async _event => {
+ if (!mainWindow) {
+ getLogger().warn('show-save-multi-dialog: no main window');
+
+ return { canceled: true };
+ }
+ const { canceled, filePaths: selectedDirPaths } = await dialog.showOpenDialog(
+ mainWindow,
+ {
+ defaultPath: app.getPath('downloads'),
+ properties: ['openDirectory', 'createDirectory'],
+ }
+ );
+ if (canceled || selectedDirPaths.length === 0) {
+ return { canceled: true };
+ }
+
+ if (selectedDirPaths.length > 1) {
+ getLogger().warn('show-save-multi-dialog: multiple directories selected');
+
+ return { canceled: true };
+ }
+
+ return { canceled: false, dirPath: selectedDirPaths[0] };
+});
+
ipc.handle('executeMenuRole', async ({ sender }, untypedRole) => {
const role = untypedRole as MenuItemConstructorOptions['role'];
diff --git a/ts/components/EditHistoryMessagesModal.tsx b/ts/components/EditHistoryMessagesModal.tsx
index 548a1fe257bc..f58be423bdaf 100644
--- a/ts/components/EditHistoryMessagesModal.tsx
+++ b/ts/components/EditHistoryMessagesModal.tsx
@@ -55,6 +55,7 @@ const MESSAGE_DEFAULT_PROPS = {
renderAudioAttachment: () =>
,
renderingContext: 'EditHistoryMessagesModal',
saveAttachment: shouldNeverBeCalled,
+ saveAttachments: shouldNeverBeCalled,
scrollToQuotedMessage: shouldNeverBeCalled,
shouldCollapseAbove: false,
shouldCollapseBelow: false,
@@ -62,6 +63,7 @@ const MESSAGE_DEFAULT_PROPS = {
showContactModal: shouldNeverBeCalled,
showConversation: noop,
showEditHistoryModal: noop,
+ showAttachmentDownloadStillInProgressToast: shouldNeverBeCalled,
showExpiredIncomingTapToViewToast: shouldNeverBeCalled,
showExpiredOutgoingTapToViewToast: shouldNeverBeCalled,
showLightboxForViewOnceMedia: shouldNeverBeCalled,
diff --git a/ts/components/StoryViewsNRepliesModal.tsx b/ts/components/StoryViewsNRepliesModal.tsx
index 3da976564b30..0e13e01392de 100644
--- a/ts/components/StoryViewsNRepliesModal.tsx
+++ b/ts/components/StoryViewsNRepliesModal.tsx
@@ -64,8 +64,10 @@ const MESSAGE_DEFAULT_PROPS = {
pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => ,
saveAttachment: shouldNeverBeCalled,
+ saveAttachments: shouldNeverBeCalled,
scrollToQuotedMessage: shouldNeverBeCalled,
showConversation: noop,
+ showAttachmentDownloadStillInProgressToast: shouldNeverBeCalled,
showExpiredIncomingTapToViewToast: shouldNeverBeCalled,
showExpiredOutgoingTapToViewToast: shouldNeverBeCalled,
showLightbox: shouldNeverBeCalled,
diff --git a/ts/components/ToastManager.stories.tsx b/ts/components/ToastManager.stories.tsx
index 29abc2de9988..aedefc3a6455 100644
--- a/ts/components/ToastManager.stories.tsx
+++ b/ts/components/ToastManager.stories.tsx
@@ -27,6 +27,13 @@ function getToast(toastType: ToastType): AnyToast {
return { toastType: ToastType.AlreadyGroupMember };
case ToastType.AlreadyRequestedToJoin:
return { toastType: ToastType.AlreadyRequestedToJoin };
+ case ToastType.AttachmentDownloadStillInProgress:
+ return {
+ toastType: ToastType.AttachmentDownloadStillInProgress,
+ parameters: {
+ count: 1,
+ },
+ };
case ToastType.Blocked:
return { toastType: ToastType.Blocked };
case ToastType.BlockedGroup:
diff --git a/ts/components/ToastManager.tsx b/ts/components/ToastManager.tsx
index b2b7c3b274bc..04ffcc2d2771 100644
--- a/ts/components/ToastManager.tsx
+++ b/ts/components/ToastManager.tsx
@@ -88,6 +88,16 @@ export function renderToast({
);
}
+ if (toastType === ToastType.AttachmentDownloadStillInProgress) {
+ return (
+
+ {i18n('icu:attachmentStillDownloading', {
+ count: toast.parameters.count,
+ })}
+
+ );
+ }
+
if (toastType === ToastType.Blocked) {
return {i18n('icu:unblockToSend')};
}
@@ -310,7 +320,9 @@ export function renderToast({
},
}}
>
- {i18n('icu:attachmentSaved')}
+ {i18n('icu:attachmentSavedPlural', {
+ count: toast.parameters.countOfFiles ?? 1,
+ })}
);
}
diff --git a/ts/components/conversation/Message.tsx b/ts/components/conversation/Message.tsx
index 6ae91a50eeef..45f117612362 100644
--- a/ts/components/conversation/Message.tsx
+++ b/ts/components/conversation/Message.tsx
@@ -24,6 +24,7 @@ import type {
InteractionModeType,
PushPanelForConversationActionType,
SaveAttachmentActionCreatorType,
+ SaveAttachmentsActionCreatorType,
ShowConversationType,
} from '../../state/ducks/conversations';
import type { ViewStoryActionCreatorType } from '../../state/ducks/stories';
@@ -352,6 +353,7 @@ export type PropsActions = {
messageId: string;
}) => void;
saveAttachment: SaveAttachmentActionCreatorType;
+ saveAttachments: SaveAttachmentsActionCreatorType;
showLightbox: (options: {
attachment: AttachmentType;
messageId: string;
@@ -366,6 +368,7 @@ export type PropsActions = {
targetMessage?: (messageId: string, conversationId: string) => unknown;
showEditHistoryModal?: (id: string) => unknown;
+ showAttachmentDownloadStillInProgressToast: (count: number) => unknown;
showExpiredIncomingTapToViewToast: () => unknown;
showExpiredOutgoingTapToViewToast: () => unknown;
viewStory: ViewStoryActionCreatorType;
diff --git a/ts/components/conversation/MessageDetail.tsx b/ts/components/conversation/MessageDetail.tsx
index c25d3e17e297..2b0aa3f516d9 100644
--- a/ts/components/conversation/MessageDetail.tsx
+++ b/ts/components/conversation/MessageDetail.tsx
@@ -95,9 +95,11 @@ export type PropsReduxActions = Pick<
| 'pushPanelForConversation'
| 'retryMessageSend'
| 'saveAttachment'
+ | 'saveAttachments'
| 'showContactModal'
| 'showConversation'
| 'showEditHistoryModal'
+ | 'showAttachmentDownloadStillInProgressToast'
| 'showExpiredIncomingTapToViewToast'
| 'showExpiredOutgoingTapToViewToast'
| 'showLightbox'
@@ -139,9 +141,11 @@ export function MessageDetail({
retryMessageSend,
renderAudioAttachment,
saveAttachment,
+ saveAttachments,
showContactModal,
showConversation,
showEditHistoryModal,
+ showAttachmentDownloadStillInProgressToast,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showLightbox,
@@ -348,6 +352,7 @@ export function MessageDetail({
retryMessageSend={retryMessageSend}
renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment}
+ saveAttachments={saveAttachments}
shouldCollapseAbove={false}
shouldCollapseBelow={false}
shouldHideMetadata={false}
@@ -357,6 +362,9 @@ export function MessageDetail({
log.warn('MessageDetail: scrollToQuotedMessage called!');
}}
showContactModal={showContactModal}
+ showAttachmentDownloadStillInProgressToast={
+ showAttachmentDownloadStillInProgressToast
+ }
showExpiredIncomingTapToViewToast={
showExpiredIncomingTapToViewToast
}
diff --git a/ts/components/conversation/Quote.stories.tsx b/ts/components/conversation/Quote.stories.tsx
index 1280e8400e12..fc1d453ac76e 100644
--- a/ts/components/conversation/Quote.stories.tsx
+++ b/ts/components/conversation/Quote.stories.tsx
@@ -125,6 +125,7 @@ const defaultMessageProps: TimelineMessagesProps = {
copyMessageText: action('copyMessageText'),
retryDeleteForEveryone: action('default--retryDeleteForEveryone'),
saveAttachment: action('saveAttachment'),
+ saveAttachments: action('saveAttachments'),
scrollToQuotedMessage: action('default--scrollToQuotedMessage'),
targetMessage: action('default--targetMessage'),
shouldCollapseAbove: false,
@@ -133,6 +134,9 @@ const defaultMessageProps: TimelineMessagesProps = {
showSpoiler: action('showSpoiler'),
pushPanelForConversation: action('default--pushPanelForConversation'),
showContactModal: action('default--showContactModal'),
+ showAttachmentDownloadStillInProgressToast: action(
+ 'showAttachmentDownloadStillInProgressToast'
+ ),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),
diff --git a/ts/components/conversation/Timeline.stories.tsx b/ts/components/conversation/Timeline.stories.tsx
index 6a23057bdb5b..988e74fb7f70 100644
--- a/ts/components/conversation/Timeline.stories.tsx
+++ b/ts/components/conversation/Timeline.stories.tsx
@@ -291,6 +291,7 @@ const actions = () => ({
retryDeleteForEveryone: action('retryDeleteForEveryone'),
retryMessageSend: action('retryMessageSend'),
saveAttachment: action('saveAttachment'),
+ saveAttachments: action('saveAttachments'),
pushPanelForConversation: action('pushPanelForConversation'),
showContactDetail: action('showContactDetail'),
showContactModal: action('showContactModal'),
@@ -305,6 +306,9 @@ const actions = () => ({
openGiftBadge: action('openGiftBadge'),
scrollToQuotedMessage: action('scrollToQuotedMessage'),
+ showAttachmentDownloadStillInProgressToast: action(
+ 'showAttachmentDownloadStillInProgressToast'
+ ),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),
diff --git a/ts/components/conversation/TimelineItem.stories.tsx b/ts/components/conversation/TimelineItem.stories.tsx
index 423209b84dd2..0d381442be91 100644
--- a/ts/components/conversation/TimelineItem.stories.tsx
+++ b/ts/components/conversation/TimelineItem.stories.tsx
@@ -82,6 +82,7 @@ const getDefaultProps = () => ({
showConversation: action('showConversation'),
openGiftBadge: action('openGiftBadge'),
saveAttachment: action('saveAttachment'),
+ saveAttachments: action('saveAttachments'),
onOpenEditNicknameAndNoteModal: action('onOpenEditNicknameAndNoteModal'),
onOutgoingAudioCallInConversation: action(
'onOutgoingAudioCallInConversation'
@@ -96,6 +97,9 @@ const getDefaultProps = () => ({
toggleForwardMessagesModal: action('toggleForwardMessagesModal'),
showLightboxForViewOnceMedia: action('showLightboxForViewOnceMedia'),
doubleCheckMissingQuoteReference: action('doubleCheckMissingQuoteReference'),
+ showAttachmentDownloadStillInProgressToast: action(
+ 'showAttachmentDownloadStillInProgressToast'
+ ),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),
diff --git a/ts/components/conversation/TimelineMessage.stories.tsx b/ts/components/conversation/TimelineMessage.stories.tsx
index 2e9630b6dbdd..d9be15a9a9f0 100644
--- a/ts/components/conversation/TimelineMessage.stories.tsx
+++ b/ts/components/conversation/TimelineMessage.stories.tsx
@@ -317,6 +317,7 @@ const createProps = (overrideProps: Partial = {}): Props => ({
renderReactionPicker,
renderAudioAttachment,
saveAttachment: action('saveAttachment'),
+ saveAttachments: action('saveAttachments'),
setQuoteByMessageId: action('setQuoteByMessageId'),
retryMessageSend: action('retryMessageSend'),
copyMessageText: action('copyMessageText'),
@@ -340,6 +341,9 @@ const createProps = (overrideProps: Partial = {}): Props => ({
showSpoiler: action('showSpoiler'),
pushPanelForConversation: action('pushPanelForConversation'),
showContactModal: action('showContactModal'),
+ showAttachmentDownloadStillInProgressToast: action(
+ 'showAttachmentDownloadStillInProgressToast'
+ ),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),
diff --git a/ts/components/conversation/TimelineMessage.tsx b/ts/components/conversation/TimelineMessage.tsx
index e2364f0a430f..d3b05c7826d6 100644
--- a/ts/components/conversation/TimelineMessage.tsx
+++ b/ts/components/conversation/TimelineMessage.tsx
@@ -121,6 +121,8 @@ export function TimelineMessage(props: Props): JSX.Element {
retryDeleteForEveryone,
retryMessageSend,
saveAttachment,
+ saveAttachments,
+ showAttachmentDownloadStillInProgressToast,
selectedReaction,
setQuoteByMessageId,
setMessageToEdit,
@@ -212,22 +214,42 @@ export function TimelineMessage(props: Props): JSX.Element {
event.stopPropagation();
}
- if (!attachments || attachments.length !== 1) {
+ if (!attachments || attachments.length === 0) {
return;
}
- const attachment = attachments[0];
- if (!isDownloaded(attachment)) {
- kickOffAttachmentDownload({
- attachment,
- messageId: id,
- });
- return;
+ let attachmentsInProgress = 0;
+ // check if any attachment needs to be downloaded from servers
+ for (const attachment of attachments) {
+ if (!isDownloaded(attachment)) {
+ kickOffAttachmentDownload({
+ attachment,
+ messageId: id,
+ });
+
+ attachmentsInProgress += 1;
+ }
}
- saveAttachment(attachment, timestamp);
+ if (attachmentsInProgress !== 0) {
+ showAttachmentDownloadStillInProgressToast(attachmentsInProgress);
+ }
+
+ if (attachments.length !== 1) {
+ saveAttachments(attachments, timestamp);
+ } else {
+ saveAttachment(attachments[0], timestamp);
+ }
},
- [kickOffAttachmentDownload, saveAttachment, attachments, id, timestamp]
+ [
+ kickOffAttachmentDownload,
+ saveAttachments,
+ saveAttachment,
+ showAttachmentDownloadStillInProgressToast,
+ attachments,
+ id,
+ timestamp,
+ ]
);
const handleContextMenu = useHandleMessageContextMenu(menuTriggerRef);
@@ -237,16 +259,13 @@ export function TimelineMessage(props: Props): JSX.Element {
const shouldShowAdditional =
doesMessageBodyOverflow(text || '') || !isWindowWidthNotNarrow;
- const multipleAttachments = attachments && attachments.length > 1;
- const firstAttachment = attachments && attachments[0];
+ const hasPendingAttachments =
+ attachments?.length && attachments.some(attachment => attachment.pending);
+ // If any of the conditions is not given -> undefined is returned
+ // --> download menu icon is not rendered
const handleDownload =
- canDownload &&
- !isSticker &&
- !multipleAttachments &&
- !isTapToView &&
- firstAttachment &&
- !firstAttachment.pending
+ canDownload && !isSticker && !isTapToView && !hasPendingAttachments
? openGenericAttachment
: undefined;
diff --git a/ts/signal.ts b/ts/signal.ts
index 8bb271c1cc69..af2a75cad402 100644
--- a/ts/signal.ts
+++ b/ts/signal.ts
@@ -115,6 +115,7 @@ type MigrationsModuleType = {
saveAttachmentToDisk: (options: {
data: Uint8Array;
name: string;
+ baseDir?: string;
}) => Promise;
processNewAttachment: (attachment: AttachmentType) => Promise;
processNewSticker: (stickerData: Uint8Array) => Promise<
@@ -406,9 +407,11 @@ type AttachmentsModuleType = {
saveAttachmentToDisk: ({
data,
name,
+ dirName,
}: {
data: Uint8Array;
name: string;
+ dirName?: string;
}) => Promise;
ensureAttachmentIsReencryptable: (
diff --git a/ts/state/ducks/conversations.ts b/ts/state/ducks/conversations.ts
index cb6018fb7811..b1e823aec162 100644
--- a/ts/state/ducks/conversations.ts
+++ b/ts/state/ducks/conversations.ts
@@ -15,7 +15,7 @@ import {
} from 'lodash';
import type { PhoneNumber } from 'google-libphonenumber';
-import { clipboard } from 'electron';
+import { clipboard, ipcRenderer } from 'electron';
import type { ReadonlyDeep } from 'type-fest';
import { DataReader, DataWriter } from '../../sql/Client';
import type { AttachmentType } from '../../types/Attachment';
@@ -1163,6 +1163,7 @@ export const actions = {
reviewConversationNameCollision,
revokePendingMembershipsFromGroupV2,
saveAttachment,
+ saveAttachments,
saveAttachmentFromMessage,
saveAvatarToDisk,
scrollToMessage,
@@ -1189,6 +1190,7 @@ export const actions = {
setPreJoinConversation,
setVoiceNotePlaybackRate,
showArchivedConversations,
+ showAttachmentDownloadStillInProgressToast,
showChooseGroupMembers,
showConversation,
showExpiredIncomingTapToViewToast,
@@ -3862,6 +3864,105 @@ function saveAttachment(
};
}
+const showSaveMultiDialog = (): Promise<{
+ canceled: boolean;
+ dirPath?: string;
+}> => {
+ return ipcRenderer.invoke('show-save-multi-dialog');
+};
+
+export type SaveAttachmentsActionCreatorType = ReadonlyDeep<
+ (
+ attachments: ReadonlyArray,
+ timestamp?: number,
+ index?: number
+ ) => unknown
+>;
+
+function saveAttachments(
+ attachments: ReadonlyArray,
+ timestamp = Date.now()
+): ThunkAction {
+ return async dispatch => {
+ // check if any of the attachments could be dangerous
+ for (const attachment of attachments) {
+ const { fileName = '' } = attachment;
+
+ const isDangerous = isFileDangerous(fileName);
+ if (isDangerous) {
+ dispatch({
+ type: SHOW_TOAST,
+ payload: {
+ toastType: ToastType.DangerousFileType,
+ },
+ });
+ return;
+ }
+ }
+
+ const { canceled, dirPath } = await showSaveMultiDialog();
+ if (canceled || !dirPath) {
+ return;
+ }
+
+ const { readAttachmentData, saveAttachmentToDisk } =
+ window.Signal.Migrations;
+
+ let fullPath;
+ let index = 0;
+ for (const attachment of attachments) {
+ index += 1;
+
+ // eslint-disable-next-line no-await-in-loop
+ const result = await Attachment.save({
+ attachment,
+ index,
+ readAttachmentData,
+ saveAttachmentToDisk,
+ timestamp,
+ baseDir: dirPath,
+ });
+
+ if (fullPath === undefined) {
+ fullPath = result;
+ }
+ }
+
+ if (fullPath == null) {
+ throw new Error('saveAttachments: Returned path to attachment is null!');
+ }
+
+ dispatch({
+ type: SHOW_TOAST,
+ payload: {
+ toastType: ToastType.FileSaved,
+ parameters: {
+ countOfFiles: attachments.length,
+ fullPath,
+ },
+ },
+ });
+ };
+}
+
+function showAttachmentDownloadStillInProgressToast(
+ count: number
+): ShowToastActionType {
+ log.info(
+ `showAttachmentDownloadStillInProgressToast: ${count} still-pending attachments`
+ );
+ return {
+ type: SHOW_TOAST,
+ payload: {
+ toastType: ToastType.AttachmentDownloadStillInProgress,
+ parameters: {
+ count,
+ },
+ },
+ };
+}
+
+// is only used by lightbox/ gallery
export function saveAttachmentFromMessage(
messageId: string,
providedAttachment?: AttachmentType
diff --git a/ts/state/smart/MessageDetail.tsx b/ts/state/smart/MessageDetail.tsx
index f267c1391ed9..504cc77fb849 100644
--- a/ts/state/smart/MessageDetail.tsx
+++ b/ts/state/smart/MessageDetail.tsx
@@ -49,7 +49,9 @@ export const SmartMessageDetail = memo(
popPanelForConversation,
pushPanelForConversation,
saveAttachment,
+ saveAttachments,
showConversation,
+ showAttachmentDownloadStillInProgressToast,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showSpoiler,
@@ -99,10 +101,14 @@ export const SmartMessageDetail = memo(
receivedAt={receivedAt}
renderAudioAttachment={renderAudioAttachment}
saveAttachment={saveAttachment}
+ saveAttachments={saveAttachments}
sentAt={message.timestamp}
showContactModal={showContactModal}
showConversation={showConversation}
showEditHistoryModal={showEditHistoryModal}
+ showAttachmentDownloadStillInProgressToast={
+ showAttachmentDownloadStillInProgressToast
+ }
showExpiredIncomingTapToViewToast={showExpiredIncomingTapToViewToast}
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
showLightbox={showLightbox}
diff --git a/ts/state/smart/TimelineItem.tsx b/ts/state/smart/TimelineItem.tsx
index 4fc2534a7900..0b2378c37a03 100644
--- a/ts/state/smart/TimelineItem.tsx
+++ b/ts/state/smart/TimelineItem.tsx
@@ -126,10 +126,12 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
retryDeleteForEveryone,
retryMessageSend,
saveAttachment,
+ saveAttachments,
targetMessage,
toggleSelectMessage,
setMessageToEdit,
showConversation,
+ showAttachmentDownloadStillInProgressToast,
showExpiredIncomingTapToViewToast,
showExpiredOutgoingTapToViewToast,
showSpoiler,
@@ -218,12 +220,16 @@ export const SmartTimelineItem = memo(function SmartTimelineItem(
retryMessageSend={retryMessageSend}
returnToActiveCall={returnToActiveCall}
saveAttachment={saveAttachment}
+ saveAttachments={saveAttachments}
scrollToQuotedMessage={scrollToQuotedMessage}
targetMessage={targetMessage}
setQuoteByMessageId={setQuoteByMessageId}
setMessageToEdit={setMessageToEdit}
showContactModal={showContactModal}
showConversation={showConversation}
+ showAttachmentDownloadStillInProgressToast={
+ showAttachmentDownloadStillInProgressToast
+ }
showExpiredIncomingTapToViewToast={showExpiredIncomingTapToViewToast}
showExpiredOutgoingTapToViewToast={showExpiredOutgoingTapToViewToast}
showLightbox={showLightbox}
diff --git a/ts/types/Attachment.ts b/ts/types/Attachment.ts
index dc6ff5cf87c2..af4ffd390aef 100644
--- a/ts/types/Attachment.ts
+++ b/ts/types/Attachment.ts
@@ -983,6 +983,7 @@ export const save = async ({
readAttachmentData,
saveAttachmentToDisk,
timestamp,
+ baseDir,
}: {
attachment: AttachmentType;
index?: number;
@@ -992,8 +993,14 @@ export const save = async ({
saveAttachmentToDisk: (options: {
data: Uint8Array;
name: string;
+ baseDir?: string;
}) => Promise<{ name: string; fullPath: string } | null>;
timestamp?: number;
+ /**
+ * Base directory for saving the attachment.
+ * If omitted, a dialog will be opened to let the user choose a directory
+ */
+ baseDir?: string;
}): Promise => {
let data: Uint8Array;
if (attachment.path) {
@@ -1009,6 +1016,7 @@ export const save = async ({
const result = await saveAttachmentToDisk({
data,
name,
+ baseDir,
});
if (!result) {
diff --git a/ts/types/Toast.tsx b/ts/types/Toast.tsx
index 980eeae7956f..f4e4b1563a1d 100644
--- a/ts/types/Toast.tsx
+++ b/ts/types/Toast.tsx
@@ -6,6 +6,7 @@ export enum ToastType {
AddedUsersToCall = 'AddedUsersToCall',
AlreadyGroupMember = 'AlreadyGroupMember',
AlreadyRequestedToJoin = 'AlreadyRequestedToJoin',
+ AttachmentDownloadStillInProgress = 'AttachmentDownloadStillInProgress',
Blocked = 'Blocked',
BlockedGroup = 'BlockedGroup',
CallHistoryCleared = 'CallHistoryCleared',
@@ -78,6 +79,10 @@ export type AnyToast =
}
| { toastType: ToastType.AlreadyGroupMember }
| { toastType: ToastType.AlreadyRequestedToJoin }
+ | {
+ toastType: ToastType.AttachmentDownloadStillInProgress;
+ parameters: { count: number };
+ }
| { toastType: ToastType.Blocked }
| { toastType: ToastType.BlockedGroup }
| { toastType: ToastType.CallHistoryCleared }
@@ -108,7 +113,10 @@ export type AnyToast =
| { toastType: ToastType.FailedToFetchPhoneNumber }
| { toastType: ToastType.FailedToFetchUsername }
| { toastType: ToastType.FailedToSendWithEndorsements }
- | { toastType: ToastType.FileSaved; parameters: { fullPath: string } }
+ | {
+ toastType: ToastType.FileSaved;
+ parameters: { fullPath: string; countOfFiles?: number };
+ }
| {
toastType: ToastType.FileSize;
parameters: { limit: number; units: string };
diff --git a/ts/windows/attachments.ts b/ts/windows/attachments.ts
index 35e4ea84e9b2..53238aec370e 100644
--- a/ts/windows/attachments.ts
+++ b/ts/windows/attachments.ts
@@ -231,14 +231,31 @@ async function writeWithAttributes(
export const saveAttachmentToDisk = async ({
data,
name,
+ baseDir,
}: {
data: Uint8Array;
name: string;
+ /**
+ * Base directory for saving the attachment.
+ * If omitted, a dialog will be opened to let the user choose a directory
+ */
+ baseDir?: string;
}): Promise => {
- const { canceled, filePath } = await showSaveDialog(name);
+ let filePath;
- if (canceled || !filePath) {
- return null;
+ if (!baseDir) {
+ const { canceled, filePath: dialogFilePath } = await showSaveDialog(name);
+ if (canceled) {
+ return null;
+ }
+ if (!dialogFilePath) {
+ throw new Error(
+ "saveAttachmentToDisk: Dialog wasn't canceled, but returned path to attachment is null!"
+ );
+ }
+ filePath = dialogFilePath;
+ } else {
+ filePath = join(baseDir, name);
}
await writeWithAttributes(filePath, data);