mirror of
https://github.com/Equicord/Equicord.git
synced 2025-06-09 14:43:03 -04:00
Unstable
This commit is contained in:
parent
a0f3747187
commit
a0eb38934c
10 changed files with 10984 additions and 0 deletions
124
src/equicordplugins/loginWithQR/index.tsx
Normal file
124
src/equicordplugins/loginWithQR/index.tsx
Normal file
|
@ -0,0 +1,124 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2024 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
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 { cl, QrCodeCameraIcon } from "./ui";
|
||||||
|
import openQrModal from "./ui/modals/QrModal";
|
||||||
|
|
||||||
|
export let jsQR: (img: Uint8ClampedArray, width: number, height: number) => {
|
||||||
|
binaryData: number[];
|
||||||
|
data: string;
|
||||||
|
chunks: any[];
|
||||||
|
version: number;
|
||||||
|
location: object;
|
||||||
|
};
|
||||||
|
|
||||||
|
import("./jsQR.js").then(x => (jsQR = x.default));
|
||||||
|
|
||||||
|
export default definePlugin({
|
||||||
|
name: "LoginWithQR",
|
||||||
|
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,
|
||||||
|
description: "Scan a QR code",
|
||||||
|
component() {
|
||||||
|
if (!Vencord.Plugins.plugins.LoginWithQR.started)
|
||||||
|
return (
|
||||||
|
<Forms.FormText>
|
||||||
|
Scan a login QR code
|
||||||
|
</Forms.FormText>
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Button size={Button.Sizes.SMALL} onClick={openQrModal}>
|
||||||
|
{i18n.Messages.USER_SETTINGS_SCAN_QR_CODE}
|
||||||
|
</Button>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
patches: [
|
||||||
|
// Prevent paste event from firing when the QRModal is open
|
||||||
|
{
|
||||||
|
find: ".clipboardData&&(",
|
||||||
|
replacement: {
|
||||||
|
// Find the handleGlobalPaste & handlePaste functions and prevent
|
||||||
|
// them from firing when the modal is open. Does this have any
|
||||||
|
// side effects? Maybe
|
||||||
|
match: /handle(Global)?Paste:(\i)(}|,)/g,
|
||||||
|
replace: "handle$1Paste:(...args)=>!$self.qrModalOpen&&$2(...args)$3",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Insert a Scan QR Code button in the My Account tab
|
||||||
|
{
|
||||||
|
find: "UserSettingsAccountProfileCard",
|
||||||
|
replacement: {
|
||||||
|
// Find the Edit User Profile button and insert our custom button.
|
||||||
|
// A bit jank, but whatever
|
||||||
|
match: /,(.{11}\.Button,.{58}\.USER_SETTINGS_EDIT_USER_PROFILE}\))/,
|
||||||
|
replace: ",$self.insertScanQrButton($1)",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Insert a Scan QR Code MenuItem in the simplified user popout
|
||||||
|
{
|
||||||
|
find: "Messages.MULTI_ACCOUNT_MENU_LABEL",
|
||||||
|
replacement: {
|
||||||
|
// Insert our own MenuItem before the Switch Account button
|
||||||
|
match: /children:\[(.{54}id:"switch-account")/,
|
||||||
|
replace: "children:[$self.ScanQrMenuItem,$1",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
// Add a Scan QR entry to the settings TabBar
|
||||||
|
{
|
||||||
|
find: ".BILLING_SETTINGS,",
|
||||||
|
replacement: {
|
||||||
|
match: /((\i\.settings)\.forEach.+?(\i).push\(.+}\)}\))/,
|
||||||
|
replace: (_, original, settings, elements) =>
|
||||||
|
`${original},${settings}?.[0]=="ACCOUNT"` +
|
||||||
|
`&&${elements}.push({section:"CUSTOM",element:$self.ScanQrTabBarComponent})`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
qrModalOpen: false,
|
||||||
|
insertScanQrButton: (button: ReactElement) => (
|
||||||
|
<div className={cl("settings-btns")}>
|
||||||
|
<Button size={Button.Sizes.SMALL} onClick={openQrModal}>
|
||||||
|
{i18n.Messages.USER_SETTINGS_SCAN_QR_CODE}
|
||||||
|
</Button>
|
||||||
|
{button}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
get ScanQrMenuItem() {
|
||||||
|
const { menuItemFocused, subMenuIcon } = findByProps("menuItemFocused") as Record<string, string>;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Menu.MenuGroup>
|
||||||
|
<Menu.MenuItem
|
||||||
|
id="scan-qr"
|
||||||
|
label={i18n.Messages.USER_SETTINGS_SCAN_QR_CODE}
|
||||||
|
icon={QrCodeCameraIcon}
|
||||||
|
action={openQrModal}
|
||||||
|
showIconFirst
|
||||||
|
focusedClassName={menuItemFocused}
|
||||||
|
subMenuIconClassName={subMenuIcon}
|
||||||
|
/>
|
||||||
|
</Menu.MenuGroup>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
ScanQrTabBarComponent: () => (
|
||||||
|
<TabBar.Item id="Scan QR Code" onClick={openQrModal}>
|
||||||
|
{i18n.Messages.USER_SETTINGS_SCAN_QR_CODE}
|
||||||
|
</TabBar.Item>
|
||||||
|
),
|
||||||
|
});
|
10134
src/equicordplugins/loginWithQR/jsQR.js
Normal file
10134
src/equicordplugins/loginWithQR/jsQR.js
Normal file
File diff suppressed because it is too large
Load diff
47
src/equicordplugins/loginWithQR/ui/index.ts
Normal file
47
src/equicordplugins/loginWithQR/ui/index.ts
Normal file
|
@ -0,0 +1,47 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2024 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import "./style.css";
|
||||||
|
|
||||||
|
import { classNameFactory } from "@api/Styles";
|
||||||
|
import { proxyLazy } from "@utils/lazy";
|
||||||
|
import { findByPropsLazy } from "@webpack";
|
||||||
|
import { Forms } from "@webpack/common";
|
||||||
|
import { ComponentType, HTMLAttributes } from "react";
|
||||||
|
|
||||||
|
export enum SpinnerTypes {
|
||||||
|
WANDERING_CUBES = "wanderingCubes",
|
||||||
|
CHASING_DOTS = "chasingDots",
|
||||||
|
PULSING_ELLIPSIS = "pulsingEllipsis",
|
||||||
|
SPINNING_CIRCLE = "spinningCircle",
|
||||||
|
SPINNING_CIRCLE_SIMPLE = "spinningCircleSimple",
|
||||||
|
LOW_MOTION = "lowMotion",
|
||||||
|
}
|
||||||
|
|
||||||
|
type Spinner = ComponentType<Omit<HTMLAttributes<HTMLDivElement>, "children"> & {
|
||||||
|
type?: SpinnerTypes;
|
||||||
|
animated?: boolean;
|
||||||
|
className?: string;
|
||||||
|
itemClassName?: string;
|
||||||
|
"aria-label"?: string;
|
||||||
|
}> & {
|
||||||
|
Type: typeof SpinnerTypes;
|
||||||
|
};
|
||||||
|
|
||||||
|
// https://github.com/Kyuuhachi/VencordPlugins/blob/main/MessageLinkTooltip/index.tsx#L11-L33
|
||||||
|
export const { Spinner } = proxyLazy(() => Forms as any as {
|
||||||
|
Spinner: Spinner,
|
||||||
|
SpinnerTypes: typeof SpinnerTypes;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const { QrCodeCameraIcon } = findByPropsLazy("QrCodeCameraIcon") as {
|
||||||
|
QrCodeCameraIcon: ComponentType<{
|
||||||
|
size: number;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const cl = classNameFactory("qrlogin-");
|
||||||
|
|
351
src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx
Normal file
351
src/equicordplugins/loginWithQR/ui/modals/QrModal.tsx
Normal file
|
@ -0,0 +1,351 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2024 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ModalContent,
|
||||||
|
ModalHeader,
|
||||||
|
ModalProps,
|
||||||
|
ModalRoot,
|
||||||
|
ModalSize,
|
||||||
|
openModal,
|
||||||
|
} from "@utils/modal";
|
||||||
|
import { findByPropsLazy } from "@webpack";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
i18n,
|
||||||
|
RestAPI,
|
||||||
|
Text,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "@webpack/common";
|
||||||
|
|
||||||
|
import { jsQR } from "../../";
|
||||||
|
import { cl, Spinner, SpinnerTypes } from "..";
|
||||||
|
import openVerifyModal from "./VerifyModal";
|
||||||
|
|
||||||
|
enum LoginStateType {
|
||||||
|
Idle,
|
||||||
|
Loading,
|
||||||
|
Camera,
|
||||||
|
}
|
||||||
|
|
||||||
|
const { getVideoDeviceId } = findByPropsLazy("getVideoDeviceId");
|
||||||
|
|
||||||
|
const tokenRegex = /^https:\/\/discord\.com\/ra\/(.+)$/;
|
||||||
|
|
||||||
|
const verifyUrl = async (
|
||||||
|
token: string,
|
||||||
|
exit: (err: string | null) => void,
|
||||||
|
closeMain: () => void
|
||||||
|
) => {
|
||||||
|
// yay
|
||||||
|
let handshake: string | null = null;
|
||||||
|
try {
|
||||||
|
const res = await RestAPI.post({
|
||||||
|
url: "/users/@me/remote-auth",
|
||||||
|
body: { fingerprint: token },
|
||||||
|
});
|
||||||
|
if (res.ok && res.status === 200) handshake = res.body?.handshake_token;
|
||||||
|
} catch { }
|
||||||
|
|
||||||
|
openVerifyModal(
|
||||||
|
handshake,
|
||||||
|
() => {
|
||||||
|
exit(null);
|
||||||
|
RestAPI.post({
|
||||||
|
url: "/users/@me/remote-auth/cancel",
|
||||||
|
body: { handshake_token: handshake },
|
||||||
|
});
|
||||||
|
},
|
||||||
|
closeMain
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleProcessImage = (
|
||||||
|
file: File,
|
||||||
|
exit: (err: string | null) => void,
|
||||||
|
closeMain: () => void
|
||||||
|
) => {
|
||||||
|
const reader = new FileReader();
|
||||||
|
reader.addEventListener("load", () => {
|
||||||
|
if (!reader.result) return;
|
||||||
|
|
||||||
|
const img = new Image();
|
||||||
|
img.addEventListener("load", () => {
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = img.width;
|
||||||
|
canvas.height = img.height;
|
||||||
|
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
ctx.drawImage(img, 0, 0);
|
||||||
|
const { data, width, height } = ctx.getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height
|
||||||
|
);
|
||||||
|
const code = jsQR(data, width, height);
|
||||||
|
|
||||||
|
const token = code?.data.match(tokenRegex)?.[1];
|
||||||
|
if (token)
|
||||||
|
verifyUrl(token, exit, closeMain).catch(e =>
|
||||||
|
exit(e?.message)
|
||||||
|
);
|
||||||
|
else exit(null);
|
||||||
|
canvas.remove();
|
||||||
|
});
|
||||||
|
img.src = reader.result as string;
|
||||||
|
});
|
||||||
|
reader.readAsDataURL(file);
|
||||||
|
};
|
||||||
|
|
||||||
|
function QrModal(props: ModalProps) {
|
||||||
|
const [state, setState] = useState(LoginStateType.Idle);
|
||||||
|
const inputRef = useRef<HTMLInputElement>(null);
|
||||||
|
const error = useRef<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const plugin = Vencord.Plugins.plugins.LoginWithQR as any;
|
||||||
|
|
||||||
|
plugin.qrModalOpen = true;
|
||||||
|
return () => void (plugin.qrModalOpen = false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const callback = (e: ClipboardEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
if (state !== LoginStateType.Idle || !e.clipboardData) return;
|
||||||
|
|
||||||
|
for (const item of e.clipboardData.items) {
|
||||||
|
if (item.kind === "file" && item.type.startsWith("image/")) {
|
||||||
|
setState(LoginStateType.Loading);
|
||||||
|
handleProcessImage(
|
||||||
|
item.getAsFile()!,
|
||||||
|
err => (
|
||||||
|
(error.current = err), setState(LoginStateType.Idle)
|
||||||
|
),
|
||||||
|
props.onClose
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state === LoginStateType.Idle)
|
||||||
|
document.addEventListener("paste", callback);
|
||||||
|
return () => document.removeEventListener("paste", callback);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (state !== LoginStateType.Camera) return;
|
||||||
|
|
||||||
|
const exit = (err: string | null) => (
|
||||||
|
(error.current = err), setState(LoginStateType.Idle)
|
||||||
|
);
|
||||||
|
|
||||||
|
const video = document.createElement("video");
|
||||||
|
const canvas = document.createElement("canvas");
|
||||||
|
canvas.width = 1280;
|
||||||
|
canvas.height = 720;
|
||||||
|
const ctx = canvas.getContext("2d")!;
|
||||||
|
|
||||||
|
let stream: MediaStream;
|
||||||
|
let snapshotTimeout: number;
|
||||||
|
let stopped = false;
|
||||||
|
|
||||||
|
const stop = (stream: MediaStream) => (
|
||||||
|
stream.getTracks().forEach(track => track.stop()),
|
||||||
|
setState(LoginStateType.Idle)
|
||||||
|
);
|
||||||
|
const snapshot = () => {
|
||||||
|
ctx.drawImage(video, 0, 0, canvas.width, canvas.height);
|
||||||
|
const { data, width, height } = ctx.getImageData(
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
canvas.width,
|
||||||
|
canvas.height
|
||||||
|
);
|
||||||
|
const code = jsQR(data, width, height);
|
||||||
|
|
||||||
|
const token = code?.data.match(tokenRegex)?.[1];
|
||||||
|
if (token) {
|
||||||
|
setState(LoginStateType.Loading);
|
||||||
|
verifyUrl(token, exit, props.onClose).catch(e =>
|
||||||
|
exit(e?.message)
|
||||||
|
);
|
||||||
|
} else snapshotTimeout = setTimeout(snapshot, 1000) as any;
|
||||||
|
};
|
||||||
|
|
||||||
|
navigator.mediaDevices
|
||||||
|
.getUserMedia({
|
||||||
|
audio: false,
|
||||||
|
video: {
|
||||||
|
deviceId: getVideoDeviceId(),
|
||||||
|
width: canvas.width,
|
||||||
|
height: canvas.height,
|
||||||
|
frameRate: 5
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(str => {
|
||||||
|
if (stopped) return stop(str);
|
||||||
|
|
||||||
|
stream = str;
|
||||||
|
video.srcObject = str;
|
||||||
|
video.addEventListener("loadedmetadata", () => {
|
||||||
|
video.play();
|
||||||
|
snapshot();
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.catch(() => setState(LoginStateType.Idle));
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
stopped = true;
|
||||||
|
clearTimeout(snapshotTimeout);
|
||||||
|
if (stream) stop(stream);
|
||||||
|
|
||||||
|
video.remove();
|
||||||
|
canvas.remove();
|
||||||
|
};
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalRoot size={ModalSize.DYNAMIC} {...props}>
|
||||||
|
<ModalHeader separator={false} className={cl("modal-header")}>
|
||||||
|
<Text
|
||||||
|
color="header-primary"
|
||||||
|
variant="heading-lg/semibold"
|
||||||
|
tag="h1"
|
||||||
|
style={{ flexGrow: 1 }}
|
||||||
|
>
|
||||||
|
{i18n.Messages.USER_SETTINGS_SCAN_QR_CODE}
|
||||||
|
</Text>
|
||||||
|
</ModalHeader>
|
||||||
|
<ModalContent scrollbarType="none">
|
||||||
|
<div
|
||||||
|
className={cl(
|
||||||
|
"modal-filepaste",
|
||||||
|
state === LoginStateType.Camera &&
|
||||||
|
"modal-filepaste-disabled"
|
||||||
|
)}
|
||||||
|
onClick={() =>
|
||||||
|
state === LoginStateType.Idle &&
|
||||||
|
inputRef.current?.click()
|
||||||
|
}
|
||||||
|
onDragEnter={e =>
|
||||||
|
e.currentTarget.classList.add(
|
||||||
|
cl("modal-filepaste-drop")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDragLeave={e =>
|
||||||
|
e.currentTarget.classList.remove(
|
||||||
|
cl("modal-filepaste-drop")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
onDrop={e => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.currentTarget.classList.remove(
|
||||||
|
cl("modal-filepaste-drop")
|
||||||
|
);
|
||||||
|
|
||||||
|
if (state !== LoginStateType.Idle) return;
|
||||||
|
|
||||||
|
for (const item of e.dataTransfer.files) {
|
||||||
|
if (item.type.startsWith("image/")) {
|
||||||
|
setState(LoginStateType.Loading);
|
||||||
|
handleProcessImage(
|
||||||
|
item,
|
||||||
|
err => (
|
||||||
|
(error.current = err),
|
||||||
|
setState(LoginStateType.Idle)
|
||||||
|
),
|
||||||
|
props.onClose
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
role="button"
|
||||||
|
>
|
||||||
|
{state === LoginStateType.Loading ? (
|
||||||
|
<Spinner type={SpinnerTypes.WANDERING_CUBES} />
|
||||||
|
) : error.current ? (
|
||||||
|
<Text color="text-danger" variant="heading-md/semibold">
|
||||||
|
{error.current}
|
||||||
|
</Text>
|
||||||
|
) : state === LoginStateType.Camera ? (
|
||||||
|
<Text
|
||||||
|
color="header-primary"
|
||||||
|
variant="heading-md/semibold"
|
||||||
|
>
|
||||||
|
Scanning...
|
||||||
|
</Text>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Text
|
||||||
|
color="header-primary"
|
||||||
|
variant="heading-md/semibold"
|
||||||
|
>
|
||||||
|
Drag and drop an image here, or click to select
|
||||||
|
an image
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
color="text-muted"
|
||||||
|
variant="heading-sm/medium"
|
||||||
|
>
|
||||||
|
Or paste an image from your clipboard!
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
onChange={e => {
|
||||||
|
if (!e.target.files || state !== LoginStateType.Idle)
|
||||||
|
return;
|
||||||
|
|
||||||
|
for (const item of e.target.files) {
|
||||||
|
if (item.type.startsWith("image/")) {
|
||||||
|
setState(LoginStateType.Loading);
|
||||||
|
handleProcessImage(
|
||||||
|
item,
|
||||||
|
err => (
|
||||||
|
(error.current = err),
|
||||||
|
setState(LoginStateType.Idle)
|
||||||
|
),
|
||||||
|
props.onClose
|
||||||
|
);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
ref={inputRef}
|
||||||
|
style={{ display: "none" }}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
size={Button.Sizes.MEDIUM}
|
||||||
|
className={cl("modal-button")}
|
||||||
|
disabled={state === LoginStateType.Loading}
|
||||||
|
onClick={() => {
|
||||||
|
if (state === LoginStateType.Idle)
|
||||||
|
setState(LoginStateType.Camera);
|
||||||
|
else if (state === LoginStateType.Camera)
|
||||||
|
setState(LoginStateType.Idle);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{state === LoginStateType.Camera
|
||||||
|
? "Stop scanning"
|
||||||
|
: "Scan using webcamera"}
|
||||||
|
</Button>
|
||||||
|
</ModalContent>
|
||||||
|
</ModalRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function openQrModal() {
|
||||||
|
return openModal(props => <QrModal {...props} />);
|
||||||
|
}
|
240
src/equicordplugins/loginWithQR/ui/modals/VerifyModal.tsx
Normal file
240
src/equicordplugins/loginWithQR/ui/modals/VerifyModal.tsx
Normal file
|
@ -0,0 +1,240 @@
|
||||||
|
/*
|
||||||
|
* Vencord, a Discord client mod
|
||||||
|
* Copyright (c) 2024 Vendicated and contributors
|
||||||
|
* SPDX-License-Identifier: GPL-3.0-or-later
|
||||||
|
*/
|
||||||
|
|
||||||
|
import {
|
||||||
|
ModalContent,
|
||||||
|
ModalFooter,
|
||||||
|
ModalProps,
|
||||||
|
ModalRoot,
|
||||||
|
ModalSize,
|
||||||
|
openModal,
|
||||||
|
} from "@utils/modal";
|
||||||
|
import { findByPropsLazy } from "@webpack";
|
||||||
|
import {
|
||||||
|
Button,
|
||||||
|
i18n,
|
||||||
|
RestAPI,
|
||||||
|
Text,
|
||||||
|
useEffect,
|
||||||
|
useRef,
|
||||||
|
useState,
|
||||||
|
} from "@webpack/common";
|
||||||
|
|
||||||
|
import { cl } from "..";
|
||||||
|
|
||||||
|
const { Controller } = findByPropsLazy("Controller");
|
||||||
|
|
||||||
|
enum VerifyState {
|
||||||
|
Verifying,
|
||||||
|
LoggedIn,
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AbortStatus {
|
||||||
|
Abort,
|
||||||
|
Fail,
|
||||||
|
Success,
|
||||||
|
}
|
||||||
|
|
||||||
|
function VerifyModal({
|
||||||
|
token,
|
||||||
|
onAbort,
|
||||||
|
closeMain,
|
||||||
|
...props
|
||||||
|
}: {
|
||||||
|
token: string | null;
|
||||||
|
onAbort: () => void;
|
||||||
|
closeMain: () => void;
|
||||||
|
} & ModalProps) {
|
||||||
|
const [state, setState] = useState(
|
||||||
|
!token ? VerifyState.NotFound : VerifyState.Verifying
|
||||||
|
);
|
||||||
|
useEffect(() => () => void (state !== VerifyState.LoggedIn && onAbort()), []);
|
||||||
|
|
||||||
|
const [inProgress, setInProgress] = useState(false);
|
||||||
|
const buttonRef = useRef<HTMLButtonElement>();
|
||||||
|
const controllerRef = useRef(new Controller({ progress: "0%" })).current;
|
||||||
|
|
||||||
|
const holdDuration = 1000;
|
||||||
|
let timeout: any;
|
||||||
|
const startInput = () => {
|
||||||
|
if (!buttonRef.current) return;
|
||||||
|
|
||||||
|
controllerRef.start({
|
||||||
|
progress: "100%",
|
||||||
|
config: {
|
||||||
|
duration: holdDuration,
|
||||||
|
// https://easings.net/#easeInOutSine
|
||||||
|
easing: (t: number) => -(Math.cos(Math.PI * t) - 1) / 2,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
timeout = setTimeout(() => {
|
||||||
|
if (state !== VerifyState.Verifying) return;
|
||||||
|
|
||||||
|
setInProgress(true);
|
||||||
|
RestAPI.post({
|
||||||
|
url: "/users/@me/remote-auth/finish",
|
||||||
|
body: {
|
||||||
|
handshake_token: token,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.then(() => {
|
||||||
|
closeMain();
|
||||||
|
setState(VerifyState.LoggedIn);
|
||||||
|
})
|
||||||
|
.catch(() => setState(VerifyState.NotFound))
|
||||||
|
.finally(() => setInProgress(false));
|
||||||
|
}, holdDuration + 250);
|
||||||
|
};
|
||||||
|
|
||||||
|
const endInput = () => {
|
||||||
|
if (!buttonRef.current) return;
|
||||||
|
|
||||||
|
controllerRef.start({
|
||||||
|
progress: "0%",
|
||||||
|
config: {
|
||||||
|
duration: 696,
|
||||||
|
// https://easings.net/#easeOutCubic
|
||||||
|
easing: (t: number) => 1 - Math.pow(1 - t, 3),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
clearTimeout(timeout);
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let frame: number;
|
||||||
|
const update = () => {
|
||||||
|
buttonRef.current?.style.setProperty(
|
||||||
|
"--progress",
|
||||||
|
controllerRef.get().progress
|
||||||
|
);
|
||||||
|
|
||||||
|
frame = requestAnimationFrame(update);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (state === VerifyState.Verifying) requestAnimationFrame(update);
|
||||||
|
return () => cancelAnimationFrame(frame);
|
||||||
|
}, [state]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ModalRoot size={ModalSize.DYNAMIC} {...props}>
|
||||||
|
<ModalContent scrollbarType="none" className={cl("device-content")}>
|
||||||
|
{state === VerifyState.LoggedIn ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
className={cl("device-image")}
|
||||||
|
src="https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_succeeded.png"
|
||||||
|
key="img-success"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="heading-xl/bold"
|
||||||
|
color="header-primary"
|
||||||
|
tag="h1"
|
||||||
|
className={cl("device-header")}
|
||||||
|
>
|
||||||
|
{i18n.Messages.QR_CODE_LOGIN_SUCCESS}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="text-md/semibold"
|
||||||
|
color="text-normal"
|
||||||
|
style={{ width: "30rem" }}
|
||||||
|
>
|
||||||
|
{i18n.Messages.QR_CODE_LOGIN_SUCCESS_FLAVOR}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : state === VerifyState.NotFound ? (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
className={cl("device-image")}
|
||||||
|
src="https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_not_found.png"
|
||||||
|
key="img-not_found"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="heading-xl/bold"
|
||||||
|
color="header-primary"
|
||||||
|
tag="h1"
|
||||||
|
className={cl("device-header")}
|
||||||
|
>
|
||||||
|
{i18n.Messages.QR_CODE_NOT_FOUND}
|
||||||
|
</Text>
|
||||||
|
<Text
|
||||||
|
variant="text-md/semibold"
|
||||||
|
color="text-normal"
|
||||||
|
style={{ width: "30rem" }}
|
||||||
|
>
|
||||||
|
{i18n.Messages.QR_CODE_NOT_FOUND_DESCRIPTION}
|
||||||
|
</Text>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<img
|
||||||
|
className={cl("device-image")}
|
||||||
|
src="https://github.com/nexpid/Themelings/raw/data/icons/images/native/img_remote_auth_loaded.png"
|
||||||
|
key="img-loaded"
|
||||||
|
draggable={false}
|
||||||
|
/>
|
||||||
|
<Text
|
||||||
|
variant="heading-xl/bold"
|
||||||
|
color="header-primary"
|
||||||
|
tag="h1"
|
||||||
|
className={cl("device-header")}
|
||||||
|
>
|
||||||
|
{i18n.Messages.QR_CODE_LOGIN_CONFIRM}
|
||||||
|
</Text>
|
||||||
|
<Text variant="text-md/semibold" color="text-danger">
|
||||||
|
Never scan a login QR code from another user or
|
||||||
|
application.
|
||||||
|
</Text>
|
||||||
|
<Button
|
||||||
|
size={Button.Sizes.LARGE}
|
||||||
|
color={Button.Colors.RED}
|
||||||
|
className={cl("device-confirm")}
|
||||||
|
style={{
|
||||||
|
["--duration" as any]: `${holdDuration}ms`,
|
||||||
|
}}
|
||||||
|
onPointerDown={startInput}
|
||||||
|
onPointerUp={endInput}
|
||||||
|
// @ts-expect-error stop whining about refs
|
||||||
|
buttonRef={buttonRef}
|
||||||
|
disabled={inProgress}
|
||||||
|
>
|
||||||
|
Hold to confirm login
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</ModalContent>
|
||||||
|
<ModalFooter className={cl("device-footer")}>
|
||||||
|
{state === VerifyState.LoggedIn ? (
|
||||||
|
<Button onClick={props.onClose}>
|
||||||
|
{i18n.Messages.QR_CODE_LOGIN_FINISH_BUTTON}
|
||||||
|
</Button>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
color={Button.Colors.LINK}
|
||||||
|
look={Button.Looks.LINK}
|
||||||
|
onClick={props.onClose}
|
||||||
|
>
|
||||||
|
{state === VerifyState.NotFound
|
||||||
|
? i18n.Messages.CLOSE
|
||||||
|
: i18n.Messages.CANCEL}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</ModalFooter>
|
||||||
|
</ModalRoot>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function openVerifyModal(
|
||||||
|
token: string | null,
|
||||||
|
onAbort: () => void,
|
||||||
|
closeMain: () => void
|
||||||
|
) {
|
||||||
|
return openModal(props => (
|
||||||
|
<VerifyModal {...props} token={token} onAbort={onAbort} closeMain={closeMain} />
|
||||||
|
));
|
||||||
|
}
|
88
src/equicordplugins/loginWithQR/ui/style.css
Normal file
88
src/equicordplugins/loginWithQR/ui/style.css
Normal file
|
@ -0,0 +1,88 @@
|
||||||
|
.qrlogin-modal-header {
|
||||||
|
padding: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-modal-filepaste {
|
||||||
|
width: 30rem;
|
||||||
|
height: 13rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
background-color: var(--background-secondary);
|
||||||
|
border-style: dashed;
|
||||||
|
border-color: var(--background-modifier-accent);
|
||||||
|
border-width: 4px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
cursor: pointer;
|
||||||
|
overflow: hidden;
|
||||||
|
transition: 200ms background-color ease-in-out, 200ms border-color ease-in-out,
|
||||||
|
200ms opacity ease-in-out;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-modal-filepaste * {
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-modal-filepaste-drop {
|
||||||
|
background-color: var(--mention-background);
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-modal-filepaste-disabled {
|
||||||
|
background-color: var(--background-secondary) !important;
|
||||||
|
opacity: 0.5;
|
||||||
|
cursor: unset;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-modal-button {
|
||||||
|
margin: 1rem 0;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-device-content {
|
||||||
|
padding: 24px;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-device-image {
|
||||||
|
height: 8rem;
|
||||||
|
user-select: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-device-header {
|
||||||
|
margin: 6px 0 12px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-device-confirm {
|
||||||
|
margin-top: 18px;
|
||||||
|
width: 20rem;
|
||||||
|
background: linear-gradient(
|
||||||
|
to right,
|
||||||
|
var(--button-danger-background) calc(var(--progress) - 1%),
|
||||||
|
var(--button-danger-background-active) var(--progress)
|
||||||
|
) !important;
|
||||||
|
|
||||||
|
--progress: 0%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-device-footer {
|
||||||
|
justify-content: space-between;
|
||||||
|
}
|
||||||
|
|
||||||
|
.qrlogin-settings-btns {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
[data-tab-id="Scan QR Code"]::before {
|
||||||
|
/* stylelint-disable-next-line property-no-vendor-prefix */
|
||||||
|
-webkit-mask: var(--si-scan-qr-code) center/contain no-repeat !important;
|
||||||
|
mask: var(--si-scan-qr-code) center/contain no-repeat !important;
|
||||||
|
|
||||||
|
--si-scan-qr-code: url("data:image/svg+xml;base64,PHN2ZyBhcmlhLWhpZGRlbj0idHJ1ZSIgcm9sZT0iaW1nIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIGZpbGw9Im5vbmUiIHZpZXdCb3g9IjAgMCAyNCAyNCI+PHBhdGggZmlsbD0icmVkIiBkPSJNNCA2YzAtMS4xLjktMiAyLTJoM2ExIDEgMCAwIDAgMC0ySDZhNCA0IDAgMCAwLTQgNHYzYTEgMSAwIDAgMCAyIDBWNlpNNCAxOGMwIDEuMS45IDIgMiAyaDNhMSAxIDAgMSAxIDAgMkg2YTQgNCAwIDAgMS00LTR2LTNhMSAxIDAgMSAxIDIgMHYzWk0yMCA2YTIgMiAwIDAgMC0yLTJoLTNhMSAxIDAgMSAxIDAtMmgzYTQgNCAwIDAgMSA0IDR2M2ExIDEgMCAxIDEtMiAwVjZaIiBjbGFzcz0iIj48L3BhdGg+PHBhdGggZmlsbD0icmVkIiBmaWxsLXJ1bGU9ImV2ZW5vZGQiIGQ9Ik01IDdjMC0xLjEuOS0yIDItMmgyYTIgMiAwIDAgMSAyIDJ2MmEyIDIgMCAwIDEtMiAySDdhMiAyIDAgMCAxLTItMlY3Wm0yIDBoMnYySDdWN1pNNSAxNWMwLTEuMS45LTIgMi0yaDJhMiAyIDAgMCAxIDIgMnYyYTIgMiAwIDAgMS0yIDJIN2EyIDIgMCAwIDEtMi0ydi0yWm0yIDBoMnYySDd2LTJaTTEzIDdjMC0xLjEuOS0yIDItMmgyYTIgMiAwIDAgMSAyIDJ2MmEyIDIgMCAwIDEtMiAyaC0yYTIgMiAwIDAgMS0yLTJWN1ptMiAwaDJ2MmgtMlY3Wk0xNy4wOCAxM2ExLjUgMS41IDAgMCAwLTEuNDIgMS4wM2MtLjA5LjI1LS4zLjQ3LS41Ni40N0gxNWEyIDIgMCAwIDAtMiAyVjIwYzAgMS4xLjkgMiAyIDJoNmEyIDIgMCAwIDAgMi0ydi0zLjVhMiAyIDAgMCAwLTItMmgtLjFjLS4yNiAwLS40Ny0uMjItLjU2LS40N0ExLjUgMS41IDAgMCAwIDE4LjkyIDEzaC0xLjg0Wk0yMCAxOGEyIDIgMCAxIDEtNCAwIDIgMiAwIDAgMSA0IDBaIiBjbGlwLXJ1bGU9ImV2ZW5vZGQiIGNsYXNzPSIiPjwvcGF0aD48L3N2Zz4=")
|
||||||
|
}
|
Loading…
Add table
Add a link
Reference in a new issue