diff --git a/src/plugins/saveAll/index.tsx b/src/plugins/saveAll/index.tsx new file mode 100644 index 0000000000..75c2590f35 --- /dev/null +++ b/src/plugins/saveAll/index.tsx @@ -0,0 +1,295 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2026 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./styles.css"; + +import { NavContextMenuPatchCallback } from "@api/ContextMenu"; +import { BaseText } from "@components/BaseText"; +import { Button } from "@components/Button"; +import { Flex } from "@components/Flex"; +import { Devs } from "@utils/constants"; +import { classNameFactory } from "@utils/css"; +import { ModalCloseButton, ModalContent, ModalFooter, ModalHeader, ModalProps, ModalRoot, ModalSize, openModal } from "@utils/modal"; +import definePlugin from "@utils/types"; +import { ChannelStore, Checkbox, Menu, React, useState } from "@webpack/common"; +import { Zippable, zipSync } from "fflate"; + +const cl = classNameFactory("vc-saveall-"); + +const SaveIcon = () => ( + + + + +); + +const FileIcon = () => ( + + + +); + +interface DownloadModalProps extends ModalProps { + attachments: any[]; +} + +// Idk why, but fetch breaks on large files so we use good old XHR +// the downside that there is no streaming/progress, but stability matters more here +async function fetchAsBytes(url: string): Promise { + const safeUrl = url + (url.includes("?") ? "&" : "?") + "_vctype=bin"; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + xhr.open("GET", safeUrl, true); + xhr.responseType = "arraybuffer"; + xhr.onload = () => { + if (xhr.status === 200 || xhr.status === 206) { + resolve(new Uint8Array(xhr.response)); + } else { + reject(new Error(`HTTP ${xhr.status}`)); + } + }; + xhr.onerror = () => reject(new Error("XHR network error")); + xhr.send(); + }); +} + +// Turn bytes into a blob and trigger a native save dialog +// always using a blob URL so the download attribute works cross-origin +// could probably be cleaner, but this is the most consistent method across browsers and Electron that i've found +function triggerSave(data: Uint8Array, filename: string, mimeType = "application/octet-stream"): void { + const blob = new Blob([data as BlobPart], { type: mimeType }); + const objectUrl = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = objectUrl; + a.download = filename; + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + setTimeout(() => URL.revokeObjectURL(objectUrl), 5000); +} + +async function saveSingle(url: string, filename: string): Promise { + const data = await fetchAsBytes(url); + triggerSave(data, filename); +} + +async function saveMultiple(attachments: any[], indices: number[]): Promise { + const files: Zippable = {}; + const usedNames = new Set(); + + // Loads everything into memory before zipping, cuz fflate doesn't support streaming, so whatever + for (const idx of indices) { + const att = attachments[idx]; + const data = await fetchAsBytes(att.url); + + // Duped filenames would overwrite each other inside the zip so handle that + let name = att.filename ?? `attachment_${idx}`; + if (usedNames.has(name)) { + const dot = name.lastIndexOf("."); + const base = dot !== -1 ? name.slice(0, dot) : name; + const ext = dot !== -1 ? name.slice(dot) : ""; + name = `${base}_${idx}${ext}`; + } + + usedNames.add(name); + files[name] = data; + } + + if (!Object.keys(files).length) return; + + // todo: Maybe make this streaming someday xd + const zipdta = zipSync(files); + triggerSave(zipdta, "Attachments.zip", "application/zip"); +} + +// No idea if these sizes are right, but looks fine to me +function getModalConfig(count: number): { size: ModalSize; gridSize: number; columns: number; } { + if (count <= 3) return { size: ModalSize.SMALL, gridSize: 80, columns: 2 }; + if (count <= 5) return { size: ModalSize.MEDIUM, gridSize: 90, columns: 3 }; + if (count <= 7) return { size: ModalSize.LARGE, gridSize: 100, columns: 4 }; + if (count <= 9) return { size: ModalSize.LARGE, gridSize: 110, columns: 5 }; + return { size: ModalSize.DYNAMIC, gridSize: 120, columns: 99 }; +} + +function Thumb({ att, selected, onToggle }: { att: any; selected: boolean; onToggle(): void; }) { + const isImage = att.content_type?.startsWith("image/") || + /\.(png|jpe?g|gif|webp|svg)(\?|$)/i.test(att.url); + + return ( + + {isImage ? ( + + ) : ( + + + {att.filename?.slice(0, 18) ?? "file"} + + )} + + + {selected && ( + + + + )} + + + ); +} + +function DownloadModal({ attachments, ...props }: DownloadModalProps) { + const [selected, setSelected] = useState>(new Set()); + const [saving, setSaving] = useState(false); + + const modalConfig = getModalConfig(attachments.length); + const { size, gridSize, columns } = modalConfig; + + const allSelected = selected.size === attachments.length; + const count = selected.size; + + const toggle = (i: number) => { + setSelected(prev => { + const next = new Set(prev); + next.has(i) ? next.delete(i) : next.add(i); + return next; + }); + }; + + const selectAll = () => { + setSelected(allSelected ? new Set() : new Set(attachments.map((_, i) => i))); + }; + + const save = async () => { + if (!count || saving) return; + setSaving(true); + try { + const indices = [...selected]; + if (indices.length === 1) { + const att = attachments[indices[0]]; + await saveSingle(att.url, att.filename ?? "attachment"); + } else { + await saveMultiple(attachments, indices); + } + } finally { + setSaving(false); + props.onClose(); + } + }; + + let saveLabel = "Save"; + if (saving) saveLabel = "Saving..."; + else if (count > 1) saveLabel = `Save ${count} as zip`; + else if (count === 1) saveLabel = "Save file"; + + return ( + + + Save Attachments + + + + + + + {attachments.map((att, i) => ( + toggle(i)} + /> + ))} + + + Select all ({attachments.length}) + + + + + + + Cancel + {saveLabel} + + + + ); +} + +function openDownloadModal(attachments: any[]) { + openModal(props => ); +} + +const messageContextMenuPatch: NavContextMenuPatchCallback = (children, { message }) => { + if (!message?.attachments?.length) return; + + children.push( + { + if (message.attachments.length === 1) { + const att = message.attachments[0]; + saveSingle(att.url, att.filename ?? "attachment"); + } else { + openDownloadModal(message.attachments); + } + }} + icon={SaveIcon} + /> + ); +}; + +export default definePlugin({ + name: "SaveAll", + description: "Save multiple message attachments at once", + authors: [Devs.omar, Devs.paige], + tags: ["Utility", "Media"], + contextMenus: { + "message": messageContextMenuPatch, + }, + + // 1 attachment = instant save, no modal -- 2+ = selection modal + messagePopoverButton: { + icon: SaveIcon, + render(message) { + if (!message?.attachments?.length) return null; + + return { + label: "Save Attachments", + icon: SaveIcon, + message, + channel: ChannelStore.getChannel(message.channel_id), + onClick: () => { + if (message.attachments.length === 1) { + const att = message.attachments[0]; + saveSingle(att.url, att.filename ?? "attachment"); + } else { + openDownloadModal(message.attachments); + } + }, + }; + } + } +}); diff --git a/src/plugins/saveAll/styles.css b/src/plugins/saveAll/styles.css new file mode 100644 index 0000000000..993da3bf29 --- /dev/null +++ b/src/plugins/saveAll/styles.css @@ -0,0 +1,67 @@ +.vc-saveall-content { + padding: 16px 16px 8px; +} + +.vc-saveall-grid { + display: grid; + gap: 8px; + margin-bottom: 12px; +} + +.vc-saveall-thumb { + position: relative; + aspect-ratio: 1; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + border: 2px solid var(--background-modifier-accent); + background: var(--background-secondary); + transition: border-color 0.1s ease; +} + +.vc-saveall-thumb-selected { + border-color: var(--brand-500); +} + +.vc-saveall-thumb-img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.vc-saveall-thumb-file { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + padding: 6px; + color: var(--text-muted); + font-size: 10px; + text-align: center; + word-break: break-all; +} + +.vc-saveall-thumb-checkbox { + position: absolute; + top: 4px; + right: 4px; + width: 18px; + height: 18px; + border-radius: 4px; + background: rgb(0 0 0 / 55%); + border: 1.5px solid rgb(255 255 255 / 40%); + display: flex; + align-items: center; + justify-content: center; + pointer-events: none; + transition: background 0.1s ease; +} + +.vc-saveall-thumb-selected .vc-saveall-thumb-checkbox { + background: var(--brand-500); + border: none; +} \ No newline at end of file diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 61550c00a0..79be63a2ee 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -638,6 +638,14 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "prism", id: 390884143749136386n, }, + omar: { + name: "omar", + id: 919681032612089947n, + }, + paige: { + name: "paige", + id: 1375697625864601650n, + }, } satisfies Record); // iife so #__PURE__ works correctly