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);