diff --git a/src/equicordplugins/loginWithQR/images.ts b/src/equicordplugins/loginWithQR/images.ts new file mode 100644 index 00000000..865ea50a --- /dev/null +++ b/src/equicordplugins/loginWithQR/images.ts @@ -0,0 +1,41 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +export const images = { + cross: "https://i.imgur.com/XxRnu3b.png", + deviceImage: { + success: + "https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_succeeded.png", + notFound: + "https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_not_found.png", + loading: + "https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_loaded.png", + }, +} as const; + +export let unload: () => void; +export function preload() { + const elements = new Array(); + + // Normally, we'd use link:preload (or link:prefetch), but + // Discord blocks third party prefetch domains and link:preload + // throws a warning, so we'll just put the images in the head + const browse = (dir: Record) => { + for (const entry of Object.values(dir)) { + if (typeof entry === "string") { + const img = new Image(); + img.setAttribute("data-purpose", "prefetch"); + img.setAttribute("data-added-by", "LoginWithQR"); + img.src = entry; + document.head.appendChild(img); + elements.push(img); + } else if (typeof entry === "object") browse(entry); + } + }; + browse(images); + + unload = () => elements.forEach(element => element.remove()); +} diff --git a/src/equicordplugins/loginWithQR/index.tsx b/src/equicordplugins/loginWithQR/index.tsx index e7cb05fa..9f9cfb3a 100644 --- a/src/equicordplugins/loginWithQR/index.tsx +++ b/src/equicordplugins/loginWithQR/index.tsx @@ -5,19 +5,22 @@ */ import { definePluginSettings } from "@api/Settings"; -import { EquicordDevs } from "@utils/constants"; import definePlugin, { OptionType } from "@utils/types"; import { findByProps } from "@webpack"; import { Button, Forms, i18n, Menu, TabBar } from "@webpack/common"; import { ReactElement } from "react"; +import { preload, unload } from "./images"; import { cl, QrCodeCameraIcon } from "./ui"; import openQrModal from "./ui/modals/QrModal"; +import { EquicordDevs } from "@utils/constants"; export default definePlugin({ name: "LoginWithQR", - description: "Allows you to login to another device by scanning a login QR code, just like on mobile!", + description: + "Allows you to login to another device by scanning a login QR code, just like on mobile!", authors: [EquicordDevs.nexpid], + settings: definePluginSettings({ scanQr: { type: OptionType.COMPONENT, @@ -26,7 +29,7 @@ export default definePlugin({ if (!Vencord.Plugins.plugins.LoginWithQR.started) return ( - Scan a login QR code + Enable the plugin and restart your client to scan a login QR code ); @@ -38,6 +41,7 @@ export default definePlugin({ }, }, }), + patches: [ // Prevent paste event from firing when the QRModal is open { @@ -80,7 +84,9 @@ export default definePlugin({ }, }, ], + qrModalOpen: false, + insertScanQrButton: (button: ReactElement) => (
), + get ScanQrMenuItem() { - const { menuItemFocused, subMenuIcon } = findByProps("menuItemFocused") as Record; + const { menuItemFocused, subMenuIcon } = findByProps( + "menuItemFocused" + ) as Record; return ( @@ -106,9 +115,19 @@ export default definePlugin({ ); }, + ScanQrTabBarComponent: () => ( {i18n.Messages.USER_SETTINGS_SCAN_QR_CODE} ), + + start() { + // Preload images + preload(); + }, + + stop() { + unload?.(); + }, }); diff --git a/src/equicordplugins/loginWithQR/jsqr.d.ts b/src/equicordplugins/loginWithQR/jsqr.d.ts new file mode 100644 index 00000000..96aec89a --- /dev/null +++ b/src/equicordplugins/loginWithQR/jsqr.d.ts @@ -0,0 +1,12 @@ +/* + * Vencord, a Discord client mod + * Copyright (c) 2024 Vendicated and contributors + * SPDX-License-Identifier: GPL-3.0-or-later + */ + +declare module "jsqr" { + import jsQR, { QRCode } from "jsqr/dist"; + + export default jsQR; + export { QRCode }; +} diff --git a/src/equicordplugins/loginWithQR/ui/index.ts b/src/equicordplugins/loginWithQR/ui/index.ts index 33de3669..cb3f0ded 100644 --- a/src/equicordplugins/loginWithQR/ui/index.ts +++ b/src/equicordplugins/loginWithQR/ui/index.ts @@ -4,7 +4,7 @@ * SPDX-License-Identifier: GPL-3.0-or-later */ -import "./style.css"; +import "./styles.css"; import { classNameFactory } from "@api/Styles"; import { proxyLazy } from "@utils/lazy"; diff --git a/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx b/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx index 6acd99ef..2523d0fd 100644 --- a/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx +++ b/src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx @@ -22,8 +22,10 @@ import { useRef, useState, } from "@webpack/common"; -import jsQR from "jsqr"; +import jsQR, { QRCode } from "jsqr"; +import { MutableRefObject, ReactElement } from "react"; +import { images } from "../../images"; import { cl, Spinner, SpinnerTypes } from ".."; import openVerifyModal from "./VerifyModal"; @@ -33,14 +35,41 @@ enum LoginStateType { Camera, } +interface Preview { + source: ReactElement; + size: { + width: number; + height: number; + }; + crosses?: { x: number; y: number; rot: number; size: number; }[]; +} + +interface QrModalProps { + exit: (err: string | null) => void; + setPreview: ( + media: HTMLImageElement | HTMLVideoElement | null, + location?: QRCode["location"] + ) => Promise; + closeMain: () => void; +} +type QrModalPropsRef = MutableRefObject; + +const limitSize = (width: number, height: number) => { + if (width > height) { + const w = Math.min(width, 1280); + return { w, h: (height / width) * w }; + } else { + const h = Math.min(height, 1280); + return { h, w: (width / height) * h }; + } +}; + const { getVideoDeviceId } = findByPropsLazy("getVideoDeviceId"); -const tokenRegex = /^https:\/\/discord\.com\/ra\/(.+)$/; - +const tokenRegex = /^https:\/\/discord\.com\/ra\/([\w-]+)$/; const verifyUrl = async ( token: string, - exit: (err: string | null) => void, - closeMain: () => void + { current: modalProps }: QrModalPropsRef ) => { // yay let handshake: string | null = null; @@ -52,36 +81,41 @@ const verifyUrl = async ( if (res.ok && res.status === 200) handshake = res.body?.handshake_token; } catch { } + modalProps.setPreview(null); openVerifyModal( handshake, () => { - exit(null); + modalProps.exit(null); RestAPI.post({ url: "/users/@me/remote-auth/cancel", body: { handshake_token: handshake }, }); }, - closeMain + modalProps.closeMain ); }; -const handleProcessImage = ( - file: File, - exit: (err: string | null) => void, - closeMain: () => void -) => { +const handleProcessImage = (file: File, modalPropsRef: QrModalPropsRef) => { + const { current: modalProps } = modalPropsRef; + const reader = new FileReader(); reader.addEventListener("load", () => { if (!reader.result) return; const img = new Image(); img.addEventListener("load", () => { + modalProps.setPreview(img); + const { w, h } = limitSize(img.width, img.height); + img.width = w; + img.height = h; + const canvas = document.createElement("canvas"); canvas.width = img.width; canvas.height = img.height; const ctx = canvas.getContext("2d")!; - ctx.drawImage(img, 0, 0); + ctx.drawImage(img, 0, 0, canvas.width, canvas.height); + const { data, width, height } = ctx.getImageData( 0, 0, @@ -92,10 +126,15 @@ const handleProcessImage = ( const token = code?.data.match(tokenRegex)?.[1]; if (token) - verifyUrl(token, exit, closeMain).catch(e => - exit(e?.message) - ); - else exit(null); + modalProps + .setPreview(img, code.location) + .then(() => + verifyUrl(token, modalPropsRef).catch(e => + modalProps.exit(e?.message) + ) + ); + else modalProps.exit(null); + canvas.remove(); }); img.src = reader.result as string; @@ -105,9 +144,103 @@ const handleProcessImage = ( function QrModal(props: ModalProps) { const [state, setState] = useState(LoginStateType.Idle); - const inputRef = useRef(null); + const [preview, setPreview] = useState(null); const error = useRef(null); + const inputRef = useRef(null); + + const modalProps = useRef({ + exit: err => { + error.current = err; + setState(LoginStateType.Idle); + setPreview(null); + }, + setPreview: (media, location) => + new Promise(res => { + if (!media) return res(setPreview(null)); + + const size = {} as Preview["size"]; + if (media.width > media.height) { + size.width = 34; + size.height = (media.height / media.width) * 34; + } else { + size.height = 34; + size.width = (media.width / media.height) * 34; + } + + let source: ReactElement; + if (media instanceof HTMLImageElement) + source = ( + + ); + else + source = ( +