This commit is contained in:
TheGreenPig 2024-09-18 02:06:45 +02:00 committed by GitHub
commit 4f88753ac5
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 312 additions and 2 deletions

View file

@ -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'");
}

View file

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

View file

@ -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<string, string>;
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();
}
}

View file

@ -0,0 +1,171 @@
/*
* 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<typeof import("./native")>;
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 (
<div className={"vc-pdf-viewer-container"}>
{previewBlobUrl ? <embed src={previewBlobUrl} className="vc-pdf-viewer-preview" title={attachment.filename} /> : <div style={{ display: "flex" }}><Spinner /></div>}
</div>
);
}
function PreviewButton({ attachment, channelId, messageId }: { attachment: Attachment; channelId: string; messageId: string; }) {
const [visible, setVisible] = useState<boolean | null>(null);
const [url, setUrl] = useState<string>();
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<string> = 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 <Tooltip text={visible ? "Hide File Preview" : "Preview File"}>
{tooltipProps => (
<div
{...tooltipProps}
className="vc-pdf-viewer-toggle"
role="button"
onClick={() => {
setVisible(v => !v);
}}
>
{visible ? <Icons.EyeSlashIcon /> : <Icons.EyeIcon />}
</div>
)}
</Tooltip>;
}
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 (
<ErrorBoundary>
{pdfAttachments.map((attachment, index) => (
<FilePreview key={index} attachment={attachment} />
))}
</ErrorBoundary>
);
}, -1);
},
renderPreviewButton: ErrorBoundary.wrap(e => {
if (e.item.originalItem.content_type !== "application/pdf") return null;
return <PreviewButton attachment={e.item.originalItem} channelId={e.message.channel_id} messageId={e.message.id} />;
}),
stop() {
objectUrlsCache.clear();
removeAccessory("pdfViewer");
}
});

View file

@ -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<Buffer> {
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);
}

View file

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

View file

@ -575,6 +575,10 @@ export const Devs = /* #__PURE__*/ Object.freeze({
name: "RamziAH",
id: 1279957227612147747n,
},
AGreenPig: {
name: "AGreenPig",
id: 427179231164760066n,
}
} satisfies Record<string, Dev>);
// iife so #__PURE__ works correctly

View file

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

View file

@ -505,9 +505,29 @@ type FocusLock = ComponentType<PropsWithChildren<{
containerRef: RefObject<HTMLElement>;
}>>;
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<JSX.IntrinsicElements["svg"] & {
size?: string;
colorClass?: string;
} & Record<string, any>>;
export type Icons = Record<IconNames, Icon>;