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,107 @@
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

@@ -0,0 +1,32 @@
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

@@ -0,0 +1,29 @@
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

@@ -0,0 +1,53 @@
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

@@ -0,0 +1,93 @@
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

@@ -0,0 +1,225 @@
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

@@ -0,0 +1,82 @@
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 { toConfig } from "../../../components/settings/account/MultiFactorAuthentication";
import { modalController } from "../ModalController";
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

@@ -0,0 +1,155 @@
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 { AppContext } from "../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../context/revoltjs/util";
import FormField from "../../../pages/login/FormField";
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

@@ -0,0 +1,51 @@
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

@@ -0,0 +1,21 @@
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

@@ -0,0 +1,139 @@
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 "../../../context/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

@@ -0,0 +1,35 @@
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

@@ -0,0 +1,42 @@
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

@@ -0,0 +1,26 @@
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" />,
},
]}
/>
);
}