Allow downloading multiple images into one directory

Co-authored-by: Major-Mayer <lrdarknesss@yahoo.de>
This commit is contained in:
Scott Nonnenberg 2024-10-24 07:44:12 +10:00 committed by GitHub
parent 35946ef53c
commit 76e2597d30
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
21 changed files with 282 additions and 24 deletions

View file

@ -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"
},

View file

@ -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."

View file

@ -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'];

View file

@ -55,6 +55,7 @@ const MESSAGE_DEFAULT_PROPS = {
renderAudioAttachment: () => <div />,
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,

View file

@ -64,8 +64,10 @@ const MESSAGE_DEFAULT_PROPS = {
pushPanelForConversation: shouldNeverBeCalled,
renderAudioAttachment: () => <div />,
saveAttachment: shouldNeverBeCalled,
saveAttachments: shouldNeverBeCalled,
scrollToQuotedMessage: shouldNeverBeCalled,
showConversation: noop,
showAttachmentDownloadStillInProgressToast: shouldNeverBeCalled,
showExpiredIncomingTapToViewToast: shouldNeverBeCalled,
showExpiredOutgoingTapToViewToast: shouldNeverBeCalled,
showLightbox: shouldNeverBeCalled,

View file

@ -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:

View file

@ -88,6 +88,16 @@ export function renderToast({
);
}
if (toastType === ToastType.AttachmentDownloadStillInProgress) {
return (
<Toast onClose={hideToast}>
{i18n('icu:attachmentStillDownloading', {
count: toast.parameters.count,
})}
</Toast>
);
}
if (toastType === ToastType.Blocked) {
return <Toast onClose={hideToast}>{i18n('icu:unblockToSend')}</Toast>;
}
@ -310,7 +320,9 @@ export function renderToast({
},
}}
>
{i18n('icu:attachmentSaved')}
{i18n('icu:attachmentSavedPlural', {
count: toast.parameters.countOfFiles ?? 1,
})}
</Toast>
);
}

View file

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

View file

@ -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
}

View file

@ -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'
),

View file

@ -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'
),

View file

@ -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'
),

View file

@ -317,6 +317,7 @@ const createProps = (overrideProps: Partial<Props> = {}): 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> = {}): Props => ({
showSpoiler: action('showSpoiler'),
pushPanelForConversation: action('pushPanelForConversation'),
showContactModal: action('showContactModal'),
showAttachmentDownloadStillInProgressToast: action(
'showAttachmentDownloadStillInProgressToast'
),
showExpiredIncomingTapToViewToast: action(
'showExpiredIncomingTapToViewToast'
),

View file

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

View file

@ -115,6 +115,7 @@ type MigrationsModuleType = {
saveAttachmentToDisk: (options: {
data: Uint8Array;
name: string;
baseDir?: string;
}) => Promise<null | { fullPath: string; name: string }>;
processNewAttachment: (attachment: AttachmentType) => Promise<AttachmentType>;
processNewSticker: (stickerData: Uint8Array) => Promise<
@ -406,9 +407,11 @@ type AttachmentsModuleType = {
saveAttachmentToDisk: ({
data,
name,
dirName,
}: {
data: Uint8Array;
name: string;
dirName?: string;
}) => Promise<null | { fullPath: string; name: string }>;
ensureAttachmentIsReencryptable: (

View file

@ -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<AttachmentType>,
timestamp?: number,
index?: number
) => unknown
>;
function saveAttachments(
attachments: ReadonlyArray<AttachmentType>,
timestamp = Date.now()
): ThunkAction<void, RootStateType, unknown, ShowToastActionType> {
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

View file

@ -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}

View file

@ -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}

View file

@ -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<string | null> => {
let data: Uint8Array;
if (attachment.path) {
@ -1009,6 +1016,7 @@ export const save = async ({
const result = await saveAttachmentToDisk({
data,
name,
baseDir,
});
if (!result) {

View file

@ -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 };

View file

@ -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<null | { fullPath: string; name: string }> => {
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);