From 363fee4ecabab37275ba6e2aad8ef42a0d83c76b Mon Sep 17 00:00:00 2001 From: Tyler Thompson <28869766+dhopcs@users.noreply.github.com> Date: Wed, 13 May 2026 16:16:30 +0100 Subject: [PATCH] feat(plugin): add downloadAllAttachments (#1077) * feat(plugin): add downloadAllAttachments * Update index.tsx --------- Co-authored-by: thororen1234 <78185467+thororen1234@users.noreply.github.com> --- .../downloadAllAttachments/index.tsx | 101 ++++++++++++++++++ src/utils/constants.ts | 4 + 2 files changed, 105 insertions(+) create mode 100644 src/equicordplugins/downloadAllAttachments/index.tsx diff --git a/src/equicordplugins/downloadAllAttachments/index.tsx b/src/equicordplugins/downloadAllAttachments/index.tsx new file mode 100644 index 000000000..b3955139e --- /dev/null +++ b/src/equicordplugins/downloadAllAttachments/index.tsx @@ -0,0 +1,101 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { CloudDownloadIcon } from "@components/Icons"; +import { EquicordDevs } from "@utils/constants"; +import { Logger } from "@utils/Logger"; +import { pluralise } from "@utils/misc"; +import definePlugin from "@utils/types"; +import { Message, MessageAttachment } from "@vencord/discord-types"; +import { ChannelStore, showToast, Toasts } from "@webpack/common"; + +const logger = new Logger("DownloadAllAttachments"); + +async function downloadAll(attachments: MessageAttachment[]) { + let dir: FileSystemDirectoryHandle; + try { + dir = await window.showDirectoryPicker({ mode: "readwrite" }); + } catch (e) { + if (e instanceof DOMException && e.name === "AbortError") return; + logger.error("Failed to open directory picker:", e); + return; + } + + const usedNames = new Map(); + + function uniqueName(original: string): string { + const count = usedNames.get(original) ?? 0; + usedNames.set(original, count + 1); + if (count === 0) return original; + const dot = original.lastIndexOf("."); + return dot === -1 + ? `${original}_${count}` + : `${original.slice(0, dot)}_${count}${original.slice(dot)}`; + } + + const tasks = attachments.map(a => ({ attachment: a, filename: uniqueName(a.filename) })); + + const results = await Promise.allSettled(tasks.map(async ({ attachment, filename }) => { + if (!attachment.proxy_url) throw new Error("Missing Proxy URL"); + + const res = await fetch(attachment.proxy_url); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + if (!res.body) throw new Error("Response body is empty"); + + let fileHandle: FileSystemFileHandle | undefined; + try { + fileHandle = await dir.getFileHandle(filename, { create: true }); + const writable = await fileHandle.createWritable(); + try { + await res.body.pipeTo(writable); + } catch (e) { + await writable.abort(); + throw e; + } + } catch (e) { + if (fileHandle) { + try { await dir.removeEntry(filename); } catch { } + } + throw e; + } + })); + + const failed = results.filter(r => { + if (r.status === "rejected") { + logger.warn("Failed to download attachment:", r.reason); + return true; + } + return false; + }).length; + + const succeeded = attachments.length - failed; + + if (failed === 0) + showToast(`Downloaded ${pluralise(succeeded, "attachment")}.`, Toasts.Type.SUCCESS); + else + showToast(`Downloaded ${succeeded} of ${attachments.length} attachments. ${failed} failed.`, Toasts.Type.FAILURE); +} + +export default definePlugin({ + name: "DownloadAllAttachments", + description: "Adds a popover button to download all attachments in a message at once.", + tags: ["Utility", "Chat"], + authors: [EquicordDevs.dhopcs], + dependencies: ["MessagePopoverAPI"], + messagePopoverButton: { + icon: CloudDownloadIcon, + render(message: Message) { + if (!message.attachments.length) return null; + return { + label: "Download All Attachments", + icon: CloudDownloadIcon, + message, + channel: ChannelStore.getChannel(message.channel_id), + onClick: () => downloadAll(message.attachments) + }; + } + } +}); diff --git a/src/utils/constants.ts b/src/utils/constants.ts index f95af20b7..49b5bdb77 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -1325,6 +1325,10 @@ export const EquicordDevs = Object.freeze({ name: "NassCT", id: 354996937868705793n }, + dhopcs: { + name: "dhopcs", + id: 206309860038410240n + }, } satisfies Record); // iife so #__PURE__ works correctly