diff --git a/src/main/index.ts b/src/main/index.ts index 5519d47ac..5cda4decb 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -93,7 +93,7 @@ if (IS_VESKTOP || !IS_VANILLA) { if (header) { const csp = parsePolicy(headers[header][0]); - for (const directive of ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src"]) { + for (const directive of ["style-src", "connect-src", "img-src", "font-src", "media-src", "worker-src", "frame-src", "object-src"]) { csp[directive] ??= []; csp[directive].push("*", "blob:", "data:", "vencord:", "'unsafe-inline'"); } diff --git a/src/plugins/pdfViewer.desktop/README.md b/src/plugins/pdfViewer.desktop/README.md new file mode 100644 index 000000000..70fad73fa --- /dev/null +++ b/src/plugins/pdfViewer.desktop/README.md @@ -0,0 +1,5 @@ +# PdfViewer + +Allows you to Preview PDF Files without having to download them + +![](https://github.com/user-attachments/assets/f403dff5-5edc-4e57-8503-73f709df4842) diff --git a/src/plugins/pdfViewer.desktop/cache.ts b/src/plugins/pdfViewer.desktop/cache.ts new file mode 100644 index 000000000..45f90728d --- /dev/null +++ b/src/plugins/pdfViewer.desktop/cache.ts @@ -0,0 +1,48 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export class LRUCache { + private cache: Map; + private maxSize: number; + + constructor(maxSize: number) { + this.cache = new Map(); + this.maxSize = maxSize; + } + + get(key: string): string | undefined { + if (!this.cache.has(key)) return undefined; + + const value = this.cache.get(key)!; + this.cache.delete(key); + this.cache.set(key, value); + return value; + } + + set(key: string, value: string) { + if (this.cache.has(key)) { + this.cache.delete(key); + } else if (this.cache.size >= this.maxSize) { + const oldestKey = this.cache.keys().next().value!; + this.cache.delete(oldestKey); + } + + this.cache.set(key, value); + } + + delete(key: string) { + if (!this.cache.has(key)) return; + URL.revokeObjectURL(this.cache.get(key)!); + this.cache.delete(key); + } + + clear() { + for (const key of this.cache.keys()) { + URL.revokeObjectURL(this.cache.get(key)!); + } + this.cache.clear(); + } +} diff --git a/src/plugins/pdfViewer.desktop/index.tsx b/src/plugins/pdfViewer.desktop/index.tsx new file mode 100644 index 000000000..59085a84e --- /dev/null +++ b/src/plugins/pdfViewer.desktop/index.tsx @@ -0,0 +1,176 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import "./pdfViewer.css"; + +import { get, set } from "@api/DataStore"; +import { addAccessory, removeAccessory } from "@api/MessageAccessories"; +import { updateMessage } from "@api/MessageUpdater"; +import { definePluginSettings } from "@api/Settings"; +import ErrorBoundary from "@components/ErrorBoundary"; +import { Devs } from "@utils/constants"; +import definePlugin, { OptionType, PluginNative } from "@utils/types"; +import { Icons, Spinner, Tooltip, useEffect, useState } from "@webpack/common"; + +import { LRUCache } from "./cache"; + +const Native = VencordNative.pluginHelpers.PdfViewer as PluginNative; + +const settings = definePluginSettings({ + autoEmptyCache: { + type: OptionType.BOOLEAN, + description: "Automatically remove the cached PDF file when the component is unmounted. Turning this on will increase load times for PDFs that have already been viewed, but may consume less memory.", + default: false + }, + persistPreviewState: { + type: OptionType.BOOLEAN, + description: "Persist the state of opened/closed File Previews across channel switches and reloads.", + default: false + }, +}); + +const objectUrlsCache = new LRUCache(20); +const STORE_KEY = "PdfViewer_PersistVisible"; + +interface Attachment { + id: string; + filename: string; + size: number; + url: string; + proxy_url: string; + content_type: string; + content_scan_version: number; + title: string; + spoiler: boolean; + previewBlobUrl?: string; + previewVisible?: boolean; +} + +function FilePreview({ attachment }: { attachment: Attachment; }) { + const { previewBlobUrl, previewVisible } = attachment; + + if (!previewVisible) return null; + + return ( +
+ {previewBlobUrl ? :
} +
+ ); +} + +function PreviewButton({ attachment, channelId, messageId }: { attachment: Attachment; channelId: string; messageId: string; }) { + const [visible, setVisible] = useState(null); + const [url, setUrl] = useState(); + + const initPdfData = async () => { + const cachedUrl = objectUrlsCache.get(attachment.url); + if (cachedUrl) { + setUrl(cachedUrl); + return; + } + try { + const buffer = await Native.getBufferResponse(attachment.url); + const file = new File([buffer], attachment.filename, { type: attachment.content_type }); + + const blobUrl = URL.createObjectURL(file); + objectUrlsCache.set(attachment.url, blobUrl); + setUrl(blobUrl); + } catch (error) { + console.log(error); + } + }; + + const updateVisibility = async () => { + const data: Set = await get(STORE_KEY) ?? new Set(); + + if (visible === null) { + setVisible(settings.store.persistPreviewState ? data.has(attachment.url) : false); + } else { + if (visible) data.add(attachment.url); + else data.delete(attachment.url); + + await set(STORE_KEY, data); + + attachment.previewVisible = visible; + updateMessage(channelId, messageId); + } + }; + + useEffect(() => { + updateVisibility(); + + if (visible && !url) initPdfData(); + }, [visible]); + + useEffect(() => { + attachment.previewBlobUrl = url; + updateMessage(channelId, messageId); + return () => { + if (url && settings.store.autoEmptyCache) { + objectUrlsCache.delete(attachment.url); + } + }; + }, [url]); + + return + {tooltipProps => ( +
{ + setVisible(v => !v); + }} + > + {visible ? : } +
+ )} +
; +} + +export default definePlugin({ + name: "PdfViewer", + description: "Preview PDF Files without having to download them", + authors: [Devs.AGreenPig], + dependencies: ["MessageAccessoriesAPI", "MessageUpdaterAPI",], + settings, + + patches: [ + { + find: "Messages.IMG_ALT_ATTACHMENT_FILE_TYPE.format", + replacement: { + match: /newMosaicStyle,\i\),children:\[(?<=}=(\i);.+?)/, + replace: "$&$self.renderPreviewButton($1)," + } + } + ], + + start() { + addAccessory("pdfViewer", props => { + const pdfAttachments = props.message.attachments.filter(a => a.content_type === "application/pdf"); + if (!pdfAttachments.length) return null; + + return ( + + {pdfAttachments.map((attachment, index) => ( + + ))} + + ); + }, -1); + }, + + stop() { + objectUrlsCache.clear(); + removeAccessory("pdfViewer"); + } + + renderPreviewButton: ErrorBoundary.wrap(e => { + if (e.item.originalItem.content_type !== "application/pdf") return null; + return ; + }, { noop: true }), + +}); diff --git a/src/plugins/pdfViewer.desktop/native.ts b/src/plugins/pdfViewer.desktop/native.ts new file mode 100644 index 000000000..29850446d --- /dev/null +++ b/src/plugins/pdfViewer.desktop/native.ts @@ -0,0 +1,28 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +import { IpcMainInvokeEvent } from "electron"; + +const urlChecks = [ + (url: URL) => url.host === "cdn.discordapp.com", + (url: URL) => url.pathname.startsWith("/attachments/"), + (url: URL) => url.pathname.endsWith(".pdf") +]; + +export async function getBufferResponse(_: IpcMainInvokeEvent, url: string): Promise { + const urlObj = new URL(url); + if (!urlChecks.every(check => check(urlObj))) { + throw new Error("Invalid URL"); + } + + const response = await fetch(url); + if (!response.ok) { + throw new Error(`Failed to fetch: ${response.statusText}`); + } + + const arrayBuffer = await response.arrayBuffer(); + return Buffer.from(arrayBuffer); +} diff --git a/src/plugins/pdfViewer.desktop/pdfViewer.css b/src/plugins/pdfViewer.desktop/pdfViewer.css new file mode 100644 index 000000000..ff53b9f26 --- /dev/null +++ b/src/plugins/pdfViewer.desktop/pdfViewer.css @@ -0,0 +1,30 @@ +.vc-pdf-viewer-container { + resize: both; + overflow: hidden; + max-width: 100%; + min-width: 432px; + max-height: 80vh; + min-height: 500px; + box-sizing: border-box; + display: flex; + justify-content: center; +} + +.vc-pdf-viewer-preview { + width: 100%; + flex-grow: 1; + border: none; +} + +.vc-pdf-viewer-toggle { + justify-self: center; + display: flex; + justify-content: center; + margin-right: 8px; + color: var(--interactive-normal); + cursor: pointer; +} + +.vc-pdf-viewer-toggle:hover { + color: var(--interactive-hover); +} diff --git a/src/utils/constants.ts b/src/utils/constants.ts index 6653e6307..e982c95be 100644 --- a/src/utils/constants.ts +++ b/src/utils/constants.ts @@ -575,6 +575,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({ name: "RamziAH", id: 1279957227612147747n, }, + AGreenPig: { + name: "AGreenPig", + id: 427179231164760066n, + } } satisfies Record); // iife so #__PURE__ works correctly diff --git a/src/webpack/common/components.ts b/src/webpack/common/components.ts index 0bcb82d1b..fd62533bf 100644 --- a/src/webpack/common/components.ts +++ b/src/webpack/common/components.ts @@ -51,6 +51,8 @@ export let ScrollerThin: t.ScrollerThin; export let Clickable: t.Clickable; export let Avatar: t.Avatar; export let FocusLock: t.FocusLock; +export let Spinner: t.Spinner; +export let SpinnerType: t.SpinnerType; // token lagger real /** css colour resolver stuff, no clue what exactly this does, just copied usage from Discord */ export let useToken: t.useToken; @@ -84,7 +86,9 @@ waitFor(["FormItem", "Button"], m => { Clickable, Avatar, FocusLock, - Heading + Heading, + Spinner, + SpinnerType, } = m); Forms = m; Icons = m; diff --git a/src/webpack/common/types/components.d.ts b/src/webpack/common/types/components.d.ts index 260a763a7..068acd3ae 100644 --- a/src/webpack/common/types/components.d.ts +++ b/src/webpack/common/types/components.d.ts @@ -505,9 +505,29 @@ type FocusLock = ComponentType; }>>; +export declare enum SpinnerType { + WANDERING_CUBES = "wanderingCubes", + CHASING_DOTS = "chasingDots", + PULSING_ELLIPSIS = "pulsingEllipsis", + SPINNING_CIRCLE = "spinningCircle", + SPINNING_CIRCLE_SIMPLE = "spinningCircleSimple", + LOW_MOTION = "lowMotion", +} + +export type Spinner = ComponentType<{ + type?: SpinnerType; + animated?: boolean; + className?: string; + itemClassName?: string; + "aria-label"?: string; +}> & { + Type: typeof SpinnerType; +}; + export type Icon = ComponentType>; export type Icons = Record; +