chore(refactor): rename context/modals to controllers/modals

This commit is contained in:
Paul Makles
2022-06-27 17:56:06 +01:00
parent 95ebd935ed
commit 1cfcb20d4d
38 changed files with 39 additions and 31 deletions

View File

@@ -0,0 +1,2 @@
hello do not touch `intermediate` or `revoltjs` folders
they are being rewritten

View File

@@ -8,11 +8,11 @@ import { Preloader, UIProvider } from "@revoltchat/ui";
import { hydrateState } from "../mobx/State";
import ModalRenderer from "../controllers/modals/ModalRenderer";
import Locale from "./Locale";
import Theme from "./Theme";
import { history } from "./history";
import Intermediate from "./intermediate/Intermediate";
import ModalRenderer from "./modals/ModalRenderer";
import Client from "./revoltjs/RevoltClient";
import SyncManager from "./revoltjs/SyncManager";

View File

@@ -18,7 +18,7 @@ import { determineLink } from "../../lib/links";
import { useApplicationState } from "../../mobx/State";
import { modalController } from "../modals";
import { modalController } from "../../controllers/modals/ModalController";
import Modals from "./Modals";
export type Screen =

View File

@@ -1,22 +0,0 @@
import { observer } from "mobx-react-lite";
import { useEffect } from "preact/hooks";
import { modalController } from ".";
export default observer(() => {
useEffect(() => {
function keyUp(event: KeyboardEvent) {
if (event.key === "Escape") {
modalController.pop("close");
} else if (event.key === "Enter") {
modalController.pop("confirm");
}
}
document.addEventListener("keyup", keyUp);
return () => document.removeEventListener("keyup", keyUp);
}, []);
return modalController.rendered;
});

View File

@@ -1,107 +0,0 @@
import dayjs from "dayjs";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useMemo, useState } from "preact/hooks";
import { CategoryButton, Column, Modal } from "@revoltchat/ui";
import type { Action } from "@revoltchat/ui/esm/components/design/atoms/display/Modal";
import { noopTrue } from "../../../lib/js";
import {
changelogEntries,
changelogEntryArray,
ChangelogPost,
} from "../../../assets/changelogs";
import { ModalProps } from "../types";
const Image = styled.img`
border-radius: var(--border-radius);
`;
function RenderLog({ post }: { post: ChangelogPost }) {
return (
<Column>
{post.content.map((entry) =>
typeof entry === "string" ? (
<span>{entry}</span>
) : (
<Image src={entry.src} />
),
)}
</Column>
);
}
/**
* Changelog modal
*/
export default function Changelog({
initial,
onClose,
signal,
}: ModalProps<"changelog">) {
const [log, setLog] = useState(initial);
const entry = useMemo(
() => (log ? changelogEntries[log] : undefined),
[log],
);
const actions = useMemo(() => {
const arr: Action[] = [
{
palette: "primary",
children: <Text id="app.special.modals.actions.close" />,
onClick: noopTrue,
},
];
if (log) {
arr.push({
palette: "plain-secondary",
children: <Text id="app.special.modals.changelogs.older" />,
onClick: () => {
setLog(undefined);
return false;
},
});
}
return arr;
}, [log]);
return (
<Modal
title={
entry?.title ?? (
<Text id="app.special.modals.changelogs.title" />
)
}
description={
entry ? (
dayjs(entry.date).calendar()
) : (
<Text id="app.special.modals.changelogs.description" />
)
}
actions={actions}
onClose={onClose}
signal={signal}>
{entry ? (
<RenderLog post={entry} />
) : (
<Column>
{changelogEntryArray.map((entry, index) => (
<CategoryButton
key={index}
onClick={() => setLog(index + 1)}>
{entry.title}
</CategoryButton>
))}
</Column>
)}
</Modal>
);
}

View File

@@ -1,32 +0,0 @@
import { Text } from "preact-i18n";
import { Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js";
import { ModalProps } from "../types";
export default function Clipboard({ text, ...props }: ModalProps<"clipboard">) {
return (
<Modal
{...props}
title={<Text id="app.special.modals.clipboard.unavailable" />}
actions={[
{
onClick: noopTrue,
confirmation: true,
children: <Text id="app.special.modals.actions.close" />,
},
]}>
{location.protocol !== "https:" && (
<p>
<Text id="app.special.modals.clipboard.https" />
</p>
)}
<Text id="app.special.modals.clipboard.copy" />{" "}
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{text}
</code>
</Modal>
);
}

View File

@@ -1,29 +0,0 @@
import { Text } from "preact-i18n";
import { Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js";
import { ModalProps } from "../types";
export default function Error({ error, ...props }: ModalProps<"error">) {
return (
<Modal
{...props}
title={<Text id="app.special.modals.error" />}
actions={[
{
onClick: noopTrue,
confirmation: true,
children: <Text id="app.special.modals.actions.ok" />,
},
{
palette: "plain-secondary",
onClick: () => location.reload(),
children: <Text id="app.special.modals.actions.reload" />,
},
]}>
<Text id={`error.${error}`}>{error}</Text>
</Modal>
);
}

View File

@@ -1,53 +0,0 @@
import { Text } from "preact-i18n";
import { Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js";
import { useApplicationState } from "../../../mobx/State";
import { ModalProps } from "../types";
export default function LinkWarning({
link,
callback,
...props
}: ModalProps<"link_warning">) {
const settings = useApplicationState().settings;
return (
<Modal
{...props}
title={<Text id={"app.special.modals.external_links.title"} />}
actions={[
{
onClick: callback,
confirmation: true,
palette: "accent",
children: "Continue",
},
{
onClick: noopTrue,
confirmation: false,
children: "Cancel",
},
{
onClick: () => {
try {
const url = new URL(link);
settings.security.addTrustedOrigin(url.hostname);
} catch (e) {}
return callback();
},
palette: "plain",
children: (
<Text id="app.special.modals.external_links.trust_domain" />
),
},
]}>
<Text id="app.special.modals.external_links.short" /> <br />
<a>{link}</a>
</Modal>
);
}

View File

@@ -1,93 +0,0 @@
import { QRCodeSVG } from "qrcode.react";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import { Category, Centred, Column, InputBox, Modal } from "@revoltchat/ui";
import { ModalProps } from "../types";
const Code = styled.code`
user-select: all;
`;
const Qr = styled.div`
border-radius: 4px;
background: white;
width: 140px;
height: 140px;
display: grid;
place-items: center;
`;
/**
* TOTP enable modal
*/
export default function MFAEnableTOTP({
identifier,
secret,
callback,
onClose,
signal,
}: ModalProps<"mfa_enable_totp">) {
const uri = `otpauth://totp/Revolt:${identifier}?secret=${secret}&issuer=Revolt`;
const [value, setValue] = useState("");
return (
<Modal
title={<Text id="app.special.modals.mfa.enable_totp" />}
description={<Text id="app.special.modals.mfa.prompt_totp" />}
actions={[
{
palette: "primary",
children: <Text id="app.special.modals.actions.continue" />,
onClick: () => {
callback(value.trim().replace(/\s/g, ""));
return true;
},
confirmation: true,
},
{
palette: "plain",
children: <Text id="app.special.modals.actions.cancel" />,
onClick: () => {
callback();
return true;
},
},
]}
onClose={() => {
callback();
onClose();
}}
signal={signal}
nonDismissable>
<Column>
<Centred>
<Qr>
<QRCodeSVG
value={uri}
bgColor="white"
fgColor="black"
/>
</Qr>
</Centred>
<Centred>
<Code>{secret}</Code>
</Centred>
</Column>
<Category compact>
<Text id="app.special.modals.mfa.enter_code" />
</Category>
<InputBox
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
</Modal>
);
}

View File

@@ -1,225 +0,0 @@
import { Archive } from "@styled-icons/boxicons-regular";
import { Key, Keyboard } from "@styled-icons/boxicons-solid";
import { API } from "revolt.js";
import { Text } from "preact-i18n";
import {
useCallback,
useEffect,
useLayoutEffect,
useState,
} from "preact/hooks";
import {
Category,
CategoryButton,
InputBox,
Modal,
Preloader,
} from "@revoltchat/ui";
import { ModalProps } from "../types";
/**
* Mapping of MFA methods to icons
*/
const ICONS: Record<API.MFAMethod, React.FC<any>> = {
Password: Keyboard,
Totp: Key,
Recovery: Archive,
};
/**
* Component for handling challenge entry
*/
function ResponseEntry({
type,
value,
onChange,
}: {
type: API.MFAMethod;
value?: API.MFAResponse;
onChange: (v: API.MFAResponse) => void;
}) {
return (
<>
<Category compact>
<Text id={`login.${type.toLowerCase()}`} />
</Category>
{type === "Password" && (
<InputBox
type="password"
value={(value as { password: string })?.password}
onChange={(e) =>
onChange({ password: e.currentTarget.value })
}
/>
)}
{type === "Totp" && (
<InputBox
value={(value as { totp_code: string })?.totp_code}
onChange={(e) =>
onChange({ totp_code: e.currentTarget.value })
}
/>
)}
{type === "Recovery" && (
<InputBox
value={(value as { recovery_code: string })?.recovery_code}
onChange={(e) =>
onChange({ recovery_code: e.currentTarget.value })
}
/>
)}
</>
);
}
/**
* MFA ticket creation flow
*/
export default function MFAFlow({
onClose,
signal,
...props
}: ModalProps<"mfa_flow">) {
const [methods, setMethods] = useState<API.MFAMethod[] | undefined>(
props.state === "unknown" ? props.available_methods : undefined,
);
// Current state of the modal
const [selectedMethod, setSelected] = useState<API.MFAMethod>();
const [response, setResponse] = useState<API.MFAResponse>();
// Fetch available methods if they have not been provided.
useEffect(() => {
if (!methods && props.state === "known") {
props.client.api.get("/auth/mfa/methods").then(setMethods);
}
}, []);
// Always select first available method if only one available.
useLayoutEffect(() => {
if (methods && methods.length === 1) {
setSelected(methods[0]);
}
}, [methods]);
// Callback to generate a new ticket or send response back up the chain.
const generateTicket = useCallback(async () => {
if (response) {
if (props.state === "known") {
const ticket = await props.client.api.put(
"/auth/mfa/ticket",
response,
);
props.callback(ticket);
} else {
props.callback(response);
}
return true;
}
return false;
}, [response]);
return (
<Modal
title={<Text id="app.special.modals.confirm" />}
description={
<Text
id={`app.special.modals.mfa.${
selectedMethod ? "confirm" : "select_method"
}`}
/>
}
actions={
selectedMethod
? [
{
palette: "primary",
children: (
<Text id="app.special.modals.actions.confirm" />
),
onClick: generateTicket,
confirmation: true,
},
{
palette: "plain",
children: (
<Text
id={`app.special.modals.actions.${
methods!.length === 1
? "cancel"
: "back"
}`}
/>
),
onClick: () => {
if (methods!.length === 1) {
props.callback();
return true;
}
setSelected(undefined);
},
},
]
: [
{
palette: "plain",
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: () => {
props.callback();
return true;
},
},
]
}
// If we are logging in or have selected a method,
// don't allow the user to dismiss the modal by clicking off.
// This is to just generally prevent annoying situations
// where you accidentally close the modal while logging in
// or when switching to your password manager.
nonDismissable={
props.state === "unknown" ||
typeof selectedMethod !== "undefined"
}
signal={signal}
onClose={() => {
props.callback();
onClose();
}}>
{methods ? (
selectedMethod ? (
<ResponseEntry
type={selectedMethod}
value={response}
onChange={setResponse}
/>
) : (
methods.map((method) => {
const Icon = ICONS[method];
return (
<CategoryButton
key={method}
action="chevron"
icon={<Icon size={24} />}
onClick={() => setSelected(method)}>
<Text id={`login.${method.toLowerCase()}`} />
</CategoryButton>
);
})
)
) : (
<Preloader type="ring" />
)}
</Modal>
);
}

View File

@@ -1,82 +0,0 @@
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useCallback, useState } from "preact/hooks";
import { Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js";
import { modalController } from "..";
import { toConfig } from "../../../components/settings/account/MultiFactorAuthentication";
import { ModalProps } from "../types";
/**
* List of recovery codes
*/
const List = styled.div`
display: grid;
text-align: center;
grid-template-columns: 1fr 1fr;
font-family: var(--monospace-font), monospace;
span {
user-select: text;
}
`;
/**
* Recovery codes modal
*/
export default function MFARecovery({
codes,
client,
onClose,
signal,
}: ModalProps<"mfa_recovery">) {
// Keep track of changes to recovery codes
const [known, setCodes] = useState(codes);
// Subroutine to reset recovery codes
const reset = useCallback(async () => {
const ticket = await modalController.mfaFlow(client);
if (ticket) {
const codes = await client.api.patch(
"/auth/mfa/recovery",
undefined,
toConfig(ticket.token),
);
setCodes(codes);
}
return false;
}, [client]);
return (
<Modal
title={<Text id="app.special.modals.mfa.recovery_codes" />}
description={<Text id="app.special.modals.mfa.save_codes" />}
actions={[
{
palette: "primary",
children: <Text id="app.special.modals.actions.done" />,
onClick: noopTrue,
confirmation: true,
},
{
palette: "plain",
children: <Text id="app.special.modals.actions.reset" />,
onClick: reset,
},
]}
onClose={onClose}
signal={signal}>
<List>
{known.map((code) => (
<span key={code}>{code}</span>
))}
</List>
</Modal>
);
}

View File

@@ -1,154 +0,0 @@
import { SubmitHandler, useForm } from "react-hook-form";
import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks";
import { Category, Error, Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js";
import { useApplicationState } from "../../../mobx/State";
import FormField from "../../../pages/login/FormField";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
import { ModalProps } from "../types";
interface FormInputs {
password: string;
new_email: string;
new_username: string;
new_password: string;
// TODO: figure out if this is correct or not
// it wasn't in the types before this was typed but the element itself was there
current_password?: string;
}
export default function ModifyAccount({
field,
...props
}: ModalProps<"modify_account">) {
const client = useApplicationState().client!;
const [processing, setProcessing] = useState(false);
const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined);
const onSubmit: SubmitHandler<FormInputs> = async ({
password,
new_username,
new_email,
new_password,
}) => {
if (processing) return;
setProcessing(true);
try {
if (field === "email") {
await client.api.patch("/auth/account/change/email", {
current_password: password,
email: new_email,
});
props.onClose();
} else if (field === "password") {
await client.api.patch("/auth/account/change/password", {
current_password: password,
password: new_password,
});
props.onClose();
} else if (field === "username") {
await client.api.patch("/users/@me/username", {
username: new_username,
password,
});
props.onClose();
}
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
};
return (
<Modal
{...props}
title={<Text id={`app.special.modals.account.change.${field}`} />}
disabled={processing}
actions={[
{
confirmation: true,
onClick: () => void handleSubmit(onSubmit)(),
children:
field === "email" ? (
<Text id="app.special.modals.actions.send_email" />
) : (
<Text id="app.special.modals.actions.update" />
),
},
{
onClick: noopTrue,
children: <Text id="app.special.modals.actions.cancel" />,
palette: "plain",
},
]}>
{/* Preact / React typing incompatabilities */}
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit(
onSubmit,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)(e as any);
}}>
{field === "email" && (
<FormField
type="email"
name="new_email"
register={register}
showOverline
error={errors.new_email?.message}
disabled={processing}
/>
)}
{field === "password" && (
<FormField
type="password"
name="new_password"
register={register}
showOverline
error={errors.new_password?.message}
autoComplete="new-password"
disabled={processing}
/>
)}
{field === "username" && (
<FormField
type="username"
name="new_username"
register={register}
showOverline
error={errors.new_username?.message}
disabled={processing}
/>
)}
<FormField
type="current_password"
register={register}
showOverline
error={errors.current_password?.message}
autoComplete="current-password"
disabled={processing}
/>
{error && (
<Category compact>
<Error
error={
<Text id="app.special.modals.account.failed" />
}
/>
</Category>
)}
</form>
</Modal>
);
}

View File

@@ -1,51 +0,0 @@
import { Text } from "preact-i18n";
import { Modal } from "@revoltchat/ui";
import { noop, noopTrue } from "../../../lib/js";
import { APP_VERSION } from "../../../version";
import { ModalProps } from "../types";
/**
* Out-of-date indicator which instructs users
* that their client needs to be updated
*/
export default function OutOfDate({
onClose,
version,
}: ModalProps<"out_of_date">) {
return (
<Modal
title={<Text id="app.special.modals.out_of_date.title" />}
description={
<>
<Text id="app.special.modals.out_of_date.description" />
<br />
<Text
id="app.special.modals.out_of_date.version"
fields={{ client: APP_VERSION, server: version }}
/>
</>
}
actions={[
{
palette: "plain",
onClick: noop,
children: (
<Text id="app.special.modals.out_of_date.attempting" />
),
},
{
palette: "plain-secondary",
onClick: noopTrue,
children: (
<Text id="app.special.modals.out_of_date.ignore" />
),
},
]}
onClose={onClose}
nonDismissable
/>
);
}

View File

@@ -1,21 +0,0 @@
import { Text } from "preact-i18n";
import { Column, Modal } from "@revoltchat/ui";
import { Friend } from "../../../pages/friends/Friend";
import { ModalProps } from "../types";
export default function PendingFriendRequests({
users,
...props
}: ModalProps<"pending_friend_requests">) {
return (
<Modal {...props} title={<Text id="app.special.friends.pending" />}>
<Column>
{users.map((x) => (
<Friend user={x!} key={x!._id} />
))}
</Column>
</Modal>
);
}

View File

@@ -1,138 +0,0 @@
import { X } from "@styled-icons/boxicons-regular";
import { Save } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useMemo, useState } from "preact/hooks";
import {
Button,
Category,
Centred,
Column,
InputBox,
Modal,
Row,
Message,
} from "@revoltchat/ui";
import { noop } from "../../../lib/js";
import { FileUploader } from "../../revoltjs/FileUploads";
import { ModalProps } from "../types";
const Preview = styled(Centred)`
flex-grow: 1;
border-radius: var(--border-radius);
background: var(--secondary-background);
> div {
padding: 0;
}
`;
export default observer(
({ member, ...props }: ModalProps<"server_identity">) => {
const [nickname, setNickname] = useState(member.nickname ?? "");
const message: any = useMemo(() => {
return {
author: member.user!,
member: {
...member,
nickname,
},
};
}, []);
return (
<Modal
{...props}
title={
<Text
id={"app.special.popovers.server_identity.title"}
fields={{ server: member.server!.name }}
/>
}>
<Column gap="18px">
<Column>
<Category compact>
<Text id="app.special.popovers.server_identity.nickname" />
</Category>
<Row centred>
<InputBox
value={nickname}
placeholder={member.user!.username}
onChange={(e) =>
setNickname(e.currentTarget.value)
}
/>
<Button
compact="icon"
palette="secondary"
disabled={
nickname === member.nickname || !nickname
}
onClick={() => member.edit({ nickname })}>
<Save size={24} />
</Button>
<Button
compact="icon"
palette="secondary"
disabled={!member.nickname}
onClick={() =>
member
.edit({ remove: ["Nickname"] })
.then(() => setNickname(""))
}>
<X size={24} />
</Button>
</Row>
</Column>
<Row gap="18px">
<Column>
<Category compact>
<Text id="app.special.popovers.server_identity.avatar" />
</Category>
<FileUploader
width={80}
height={80}
style="icon"
fileType="avatars"
behaviour="upload"
maxFileSize={4_000_000}
onUpload={(avatar) =>
member.edit({ avatar }).then(noop)
}
remove={() =>
member
.edit({ remove: ["Avatar"] })
.then(noop)
}
defaultPreview={member.user?.generateAvatarURL(
{
max_side: 256,
},
false,
)}
previewURL={member.client.generateFileURL(
member.avatar ?? undefined,
{ max_side: 256 },
true,
)}
desaturateDefault
/>
</Column>
<Column grow>
<Category compact>Preview</Category>
<Preview>
<Message message={message} head />
</Preview>
</Column>
</Row>
</Column>
</Modal>
);
},
);

View File

@@ -1,35 +0,0 @@
import { Text } from "preact-i18n";
import { Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js";
import { ModalProps } from "../types";
export default function ShowToken({
name,
token,
...props
}: ModalProps<"show_token">) {
return (
<Modal
{...props}
title={
<Text
id={"app.special.modals.token_reveal"}
fields={{ name }}
/>
}
actions={[
{
onClick: noopTrue,
confirmation: true,
children: <Text id="app.special.modals.actions.close" />,
},
]}>
<code style={{ userSelect: "all", wordBreak: "break-all" }}>
{token}
</code>
</Modal>
);
}

View File

@@ -1,42 +0,0 @@
import { Text } from "preact-i18n";
import { useCallback } from "preact/hooks";
import { Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js";
import { ModalProps } from "../types";
/**
* Confirm whether a user wants to sign out of all other sessions
*/
export default function SignOutSessions(
props: ModalProps<"sign_out_sessions">,
) {
const onClick = useCallback(() => {
props.onDeleting();
props.client.api.delete("/auth/session/all").then(props.onDelete);
return true;
}, []);
return (
<Modal
{...props}
title={<Text id={"app.special.modals.sessions.title"} />}
actions={[
{
onClick: noopTrue,
palette: "accent",
confirmation: true,
children: <Text id="app.special.modals.actions.back" />,
},
{
onClick,
confirmation: true,
children: <Text id="app.special.modals.sessions.accept" />,
},
]}>
<Text id="app.special.modals.sessions.short" /> <br />
</Modal>
);
}

View File

@@ -1,26 +0,0 @@
import { Text } from "preact-i18n";
import { Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js";
import { ModalProps } from "../types";
/**
* Indicate that the user has been signed out of their account
*/
export default function SignedOut(props: ModalProps<"signed_out">) {
return (
<Modal
{...props}
title={<Text id="app.special.modals.signed_out" />}
actions={[
{
onClick: noopTrue,
confirmation: true,
children: <Text id="app.special.modals.actions.ok" />,
},
]}
/>
);
}

View File

@@ -1,219 +0,0 @@
import {
action,
computed,
makeObservable,
observable,
runInAction,
} from "mobx";
import type { Client, API } from "revolt.js";
import { ulid } from "ulid";
import { determineLink } from "../../lib/links";
import { getApplicationState, useApplicationState } from "../../mobx/State";
import { history } from "../history";
import { __thisIsAHack } from "../intermediate/Intermediate";
// import { determineLink } from "../../lib/links";
import Changelog from "./components/Changelog";
import Clipboard from "./components/Clipboard";
import Error from "./components/Error";
import LinkWarning from "./components/LinkWarning";
import MFAEnableTOTP from "./components/MFAEnableTOTP";
import MFAFlow from "./components/MFAFlow";
import MFARecovery from "./components/MFARecovery";
import ModifyAccount from "./components/ModifyAccount";
import OutOfDate from "./components/OutOfDate";
import PendingFriendRequests from "./components/PendingFriendRequests";
import ServerIdentity from "./components/ServerIdentity";
import ShowToken from "./components/ShowToken";
import SignOutSessions from "./components/SignOutSessions";
import SignedOut from "./components/SignedOut";
import { Modal } from "./types";
type Components = Record<string, React.FC<any>>;
/**
* Handles layering and displaying modals to the user.
*/
class ModalController<T extends Modal> {
stack: T[] = [];
components: Components;
constructor(components: Components) {
this.components = components;
makeObservable(this, {
stack: observable,
push: action,
pop: action,
remove: action,
rendered: computed,
isVisible: computed,
});
}
/**
* Display a new modal on the stack
* @param modal Modal data
*/
push(modal: T) {
this.stack = [
...this.stack,
{
...modal,
key: ulid(),
},
];
}
/**
* Remove the top modal from the screen
* @param signal What action to trigger
*/
pop(signal: "close" | "confirm" | "force") {
this.stack = this.stack.map((entry, index) =>
index === this.stack.length - 1 ? { ...entry, signal } : entry,
);
}
/**
* Remove the keyed modal from the stack
*/
remove(key: string) {
this.stack = this.stack.filter((x) => x.key !== key);
}
/**
* Render modals
*/
get rendered() {
return (
<>
{this.stack.map((modal) => {
const Component = this.components[modal.type];
return (
// ESLint does not understand spread operator
// eslint-disable-next-line
<Component
{...modal}
onClose={() => this.remove(modal.key!)}
/>
);
})}
</>
);
}
/**
* Whether a modal is currently visible
*/
get isVisible() {
return this.stack.length > 0;
}
}
/**
* Modal controller with additional helpers.
*/
class ModalControllerExtended extends ModalController<Modal> {
/**
* Perform MFA flow
* @param client Client
*/
mfaFlow(client: Client) {
return runInAction(
() =>
new Promise((callback: (ticket?: API.MFATicket) => void) =>
this.push({
type: "mfa_flow",
state: "known",
client,
callback,
}),
),
);
}
/**
* Open TOTP secret modal
* @param client Client
*/
mfaEnableTOTP(secret: string, identifier: string) {
return runInAction(
() =>
new Promise((callback: (value?: string) => void) =>
this.push({
type: "mfa_enable_totp",
identifier,
secret,
callback,
}),
),
);
}
/**
* Write text to the clipboard
* @param text Text to write
*/
writeText(text: string) {
if (navigator.clipboard) {
navigator.clipboard.writeText(text);
} else {
this.push({
type: "clipboard",
text,
});
}
}
openLink(href?: string, trusted?: boolean) {
const link = determineLink(href);
const settings = getApplicationState().settings;
switch (link.type) {
case "profile": {
__thisIsAHack({ id: "profile", user_id: link.id });
break;
}
case "navigate": {
history.push(link.path);
break;
}
case "external": {
if (
!trusted &&
!settings.security.isTrustedOrigin(link.url.hostname)
) {
modalController.push({
type: "link_warning",
link: link.href,
callback: () => this.openLink(href, true) as true,
});
} else {
window.open(link.href, "_blank", "noreferrer");
}
}
}
return true;
}
}
export const modalController = new ModalControllerExtended({
changelog: Changelog,
clipboard: Clipboard,
error: Error,
link_warning: LinkWarning,
mfa_flow: MFAFlow,
mfa_recovery: MFARecovery,
mfa_enable_totp: MFAEnableTOTP,
modify_account: ModifyAccount,
out_of_date: OutOfDate,
pending_friend_requests: PendingFriendRequests,
server_identity: ServerIdentity,
show_token: ShowToken,
signed_out: SignedOut,
sign_out_sessions: SignOutSessions,
});

View File

@@ -1,80 +0,0 @@
import { API, Client, User, Member } from "revolt.js";
export type Modal = {
key?: string;
} & (
| ({
type: "mfa_flow";
} & (
| {
state: "known";
client: Client;
callback: (ticket?: API.MFATicket) => void;
}
| {
state: "unknown";
available_methods: API.MFAMethod[];
callback: (response?: API.MFAResponse) => void;
}
))
| { type: "mfa_recovery"; codes: string[]; client: Client }
| {
type: "mfa_enable_totp";
identifier: string;
secret: string;
callback: (code?: string) => void;
}
| {
type: "out_of_date";
version: string;
}
| {
type: "changelog";
initial?: number;
}
| {
type: "sign_out_sessions";
client: Client;
onDelete: () => void;
onDeleting: () => void;
}
| {
type: "show_token";
name: string;
token: string;
}
| {
type: "error";
error: string;
}
| {
type: "clipboard";
text: string;
}
| {
type: "link_warning";
link: string;
callback: () => true;
}
| {
type: "pending_friend_requests";
users: User[];
}
| {
type: "modify_account";
client: Client;
field: "username" | "email" | "password";
}
| {
type: "server_identity";
member: Member;
}
| {
type: "signed_out";
}
);
export type ModalProps<T extends Modal["type"]> = Modal & { type: T } & {
onClose: () => void;
signal?: "close" | "confirm";
};

View File

@@ -13,8 +13,8 @@ import { determineFileSize } from "../../lib/fileSize";
import { useApplicationState } from "../../mobx/State";
import { modalController } from "../../controllers/modals/ModalController";
import { useIntermediate } from "../intermediate/Intermediate";
import { modalController } from "../modals";
import { AppContext } from "./RevoltClient";
import { takeError } from "./util";

View File

@@ -9,7 +9,7 @@ import { Preloader } from "@revoltchat/ui";
import { useApplicationState } from "../../mobx/State";
import { modalController } from "../modals";
import { modalController } from "../../controllers/modals/ModalController";
import { registerEvents } from "./events";
import { takeError } from "./util";