Merge pull request #159 from Snazzah/bots-look-cool

This commit is contained in:
Paul Makles 2021-09-03 11:04:53 +01:00 committed by GitHub
commit 8ad1130a00
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
10 changed files with 585 additions and 113 deletions

View file

@ -1,4 +1,5 @@
{ {
"editor.defaultFormatter": "esbenp.prettier-vscode", "editor.defaultFormatter": "esbenp.prettier-vscode",
"editor.formatOnSave": true "editor.formatOnSave": true,
"compile-hero.disable-compile-files-on-did-save-code": true
} }

View file

@ -51,7 +51,7 @@ const CheckboxDescription = styled.span`
color: var(--secondary-foreground); color: var(--secondary-foreground);
`; `;
const Checkmark = styled.div<{ checked: boolean }>` const Checkmark = styled.div<{ checked: boolean; contrast?: boolean }>`
margin: 4px; margin: 4px;
width: 24px; width: 24px;
height: 24px; height: 24px;
@ -66,6 +66,16 @@ const Checkmark = styled.div<{ checked: boolean }>`
color: var(--secondary-background); color: var(--secondary-background);
} }
${(props) =>
props.contrast &&
css`
background: var(--primary-background);
svg {
color: var(--primary-background);
}
`}
${(props) => ${(props) =>
props.checked && props.checked &&
css` css`
@ -76,6 +86,7 @@ const Checkmark = styled.div<{ checked: boolean }>`
export interface CheckboxProps { export interface CheckboxProps {
checked: boolean; checked: boolean;
disabled?: boolean; disabled?: boolean;
contrast?: boolean;
className?: string; className?: string;
children: Children; children: Children;
description?: Children; description?: Children;
@ -100,7 +111,7 @@ export default function Checkbox(props: CheckboxProps) {
!props.disabled && props.onChange(!props.checked) !props.disabled && props.onChange(!props.checked)
} }
/> />
<Checkmark checked={props.checked} className="check"> <Checkmark checked={props.checked} contrast={props.contrast} className="check">
<Check size={20} /> <Check size={20} />
</Checkmark> </Checkmark>
</CheckboxBase> </CheckboxBase>

View file

@ -16,6 +16,7 @@ import { Action } from "../../components/ui/Modal";
import { Children } from "../../types/Preact"; import { Children } from "../../types/Preact";
import Modals from "./Modals"; import Modals from "./Modals";
import { Bot } from "revolt-api/types/Bots";
export type Screen = export type Screen =
| { id: "none" } | { id: "none" }
@ -24,6 +25,7 @@ export type Screen =
| { id: "signed_out" } | { id: "signed_out" }
| { id: "error"; error: string } | { id: "error"; error: string }
| { id: "clipboard"; text: string } | { id: "clipboard"; text: string }
| { id: "token_reveal"; token: string; username: string }
| { id: "external_link_prompt"; link: string } | { id: "external_link_prompt"; link: string }
| { | {
id: "_prompt"; id: "_prompt";
@ -89,6 +91,7 @@ export type Screen =
| { id: "channel_info"; channel: Channel } | { id: "channel_info"; channel: Channel }
| { id: "pending_requests"; users: User[] } | { id: "pending_requests"; users: User[] }
| { id: "modify_account"; field: "username" | "email" | "password" } | { id: "modify_account"; field: "username" | "email" | "password" }
| { id: "create_bot"; onCreate: (bot: Bot) => void }
| { | {
id: "server_identity"; id: "server_identity";
server: Server; server: Server;

View file

@ -10,6 +10,7 @@ import { OnboardingModal } from "./modals/Onboarding";
import { PromptModal } from "./modals/Prompt"; import { PromptModal } from "./modals/Prompt";
import { SignedOutModal } from "./modals/SignedOut"; import { SignedOutModal } from "./modals/SignedOut";
import {ExternalLinkModal} from "./modals/ExternalLinkPrompt"; import {ExternalLinkModal} from "./modals/ExternalLinkPrompt";
import { TokenRevealModal } from "./modals/TokenReveal";
export interface Props { export interface Props {
screen: Screen; screen: Screen;
@ -33,6 +34,8 @@ export default function Modals({ screen, openScreen }: Props) {
return <SignedOutModal onClose={onClose} {...screen} />; return <SignedOutModal onClose={onClose} {...screen} />;
case "clipboard": case "clipboard":
return <ClipboardModal onClose={onClose} {...screen} />; return <ClipboardModal onClose={onClose} {...screen} />;
case "token_reveal":
return <TokenRevealModal onClose={onClose} {...screen} />;
case "onboarding": case "onboarding":
return <OnboardingModal onClose={onClose} {...screen} />; return <OnboardingModal onClose={onClose} {...screen} />;
case "external_link_prompt": case "external_link_prompt":

View file

@ -8,6 +8,7 @@ import { IntermediateContext, useIntermediate } from "./Intermediate";
import { SpecialInputModal } from "./modals/Input"; import { SpecialInputModal } from "./modals/Input";
import { SpecialPromptModal } from "./modals/Prompt"; import { SpecialPromptModal } from "./modals/Prompt";
import { ChannelInfo } from "./popovers/ChannelInfo"; import { ChannelInfo } from "./popovers/ChannelInfo";
import { CreateBotModal } from "./popovers/CreateBot";
import { ImageViewer } from "./popovers/ImageViewer"; import { ImageViewer } from "./popovers/ImageViewer";
import { ModifyAccountModal } from "./popovers/ModifyAccount"; import { ModifyAccountModal } from "./popovers/ModifyAccount";
import { PendingRequests } from "./popovers/PendingRequests"; import { PendingRequests } from "./popovers/PendingRequests";
@ -42,6 +43,9 @@ export default function Popovers() {
case "modify_account": case "modify_account":
// @ts-expect-error someone figure this out :) // @ts-expect-error someone figure this out :)
return <ModifyAccountModal onClose={onClose} {...screen} />; return <ModifyAccountModal onClose={onClose} {...screen} />;
case "create_bot":
// @ts-expect-error someone figure this out :)
return <CreateBotModal onClose={onClose} {...screen} />;
case "special_prompt": case "special_prompt":
// @ts-expect-error someone figure this out :) // @ts-expect-error someone figure this out :)
return <SpecialPromptModal onClose={onClose} {...screen} />; return <SpecialPromptModal onClose={onClose} {...screen} />;

View file

@ -0,0 +1,32 @@
import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
interface Props {
onClose: () => void;
token: string;
username: string;
}
export function TokenRevealModal({ onClose, token, username }: Props) {
return (
<Modal
visible={true}
onClose={onClose}
title={
<Text
id={"app.special.modals.token_reveal"}
fields={{ name: username }}
/>
}
actions={[
{
onClick: onClose,
confirmation: true,
children: <Text id="app.special.modals.actions.close" />,
},
]}>
<code style={{ userSelect: "all" }}>{token}</code>
</Modal>
);
}

View file

@ -0,0 +1,81 @@
import { SubmitHandler, useForm } from "react-hook-form";
import { Bot } from "revolt-api/types/Bots";
import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks";
import Modal from "../../../components/ui/Modal";
import Overline from "../../../components/ui/Overline";
import FormField from "../../../pages/login/FormField";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
onCreate: (bot: Bot) => void;
}
interface FormInputs {
name: string;
}
export function CreateBotModal({ onClose, onCreate }: Props) {
const client = useContext(AppContext);
const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined);
const onSubmit: SubmitHandler<FormInputs> = async ({ name }) => {
try {
const { bot } = await client.bots.create({ name });
onCreate(bot);
onClose();
} catch (err) {
setError(takeError(err));
}
};
return (
<Modal
visible={true}
onClose={onClose}
title={<Text id="app.special.popovers.create_bot.title" />}
actions={[
{
confirmation: true,
contrast: true,
accent: true,
onClick: handleSubmit(onSubmit),
children: <Text id="app.special.modals.actions.create" />,
},
{
plain: true,
onClick: onClose,
children: <Text id="app.special.modals.actions.cancel" />,
},
]}>
{/* Preact / React typing incompatabilities */}
<form
onSubmit={(e) => {
e.preventDefault();
handleSubmit(
onSubmit,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)(e as any);
}}>
<FormField
type="username"
name="name"
register={register}
showOverline
error={errors.name?.message}
/>
{error && (
<Overline type="error" error={error}>
<Text id="app.special.popovers.create_bot.error" />
</Overline>
)}
</form>
</Modal>
);
}

View file

@ -67,7 +67,16 @@ export default function FormField({
}, },
} }
: type === "username" : type === "username"
? { required: "RequiredField" } ? {
validate: (value: string) =>
value.length === 0
? "RequiredField"
: value.length < 2
? "TooShort"
: value.length > 32
? "TooLong"
: undefined,
}
: { required: "RequiredField" }, : { required: "RequiredField" },
)} )}
/> />

View file

@ -1,17 +1,29 @@
import { Key, Clipboard, Globe, Plus } from "@styled-icons/boxicons-regular";
import { LockAlt } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Bot } from "revolt-api/types/Bots"; import { Bot } from "revolt-api/types/Bots";
import { User } from "revolt.js/dist/maps/Users";
import styled from "styled-components";
import styles from "./Panes.module.scss";
import { Text } from "preact-i18n";
import { useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { internalEmit } from "../../../lib/eventEmitter";
import { stopPropagation } from "../../../lib/stopPropagation";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { FileUploader } from "../../../context/revoltjs/FileUploads";
import { useClient } from "../../../context/revoltjs/RevoltClient"; import { useClient } from "../../../context/revoltjs/RevoltClient";
import UserShort from "../../../components/common/user/UserShort"; import Tooltip from "../../../components/common/Tooltip";
import UserIcon from "../../../components/common/user/UserIcon";
import Button from "../../../components/ui/Button"; import Button from "../../../components/ui/Button";
import Checkbox from "../../../components/ui/Checkbox"; import Checkbox from "../../../components/ui/Checkbox";
import InputBox from "../../../components/ui/InputBox"; import InputBox from "../../../components/ui/InputBox";
import Overline from "../../../components/ui/Overline";
import Tip from "../../../components/ui/Tip"; import Tip from "../../../components/ui/Tip";
import CategoryButton from "../../../components/ui/fluent/CategoryButton";
import type { AxiosError } from "axios";
interface Data { interface Data {
_id: string; _id: string;
@ -20,50 +32,308 @@ interface Data {
interactions_url?: string; interactions_url?: string;
} }
function BotEditor({ bot }: { bot: Data }) { interface Changes {
name?: string;
public?: boolean;
interactions_url?: string;
remove?: "InteractionsURL";
}
const BotBadge = styled.div`
display: inline-block;
height: 1.3em;
padding: 0px 4px;
font-size: 0.7em;
user-select: none;
margin-inline-start: 2px;
text-transform: uppercase;
color: var(--foreground);
background: var(--accent);
border-radius: calc(var(--border-radius) / 2);
`;
interface Props {
bot: Bot;
onDelete(): void;
onUpdate(changes: Changes): void;
}
function BotCard({ bot, onDelete, onUpdate }: Props) {
const client = useClient(); const client = useClient();
const [data, setData] = useState<Data>(bot); const [user, setUser] = useState<User>(client.users.get(bot._id)!);
const [data, setData] = useState<Data>({
_id: bot._id,
username: user.username,
public: bot.public,
interactions_url: bot.interactions_url,
});
const [error, setError] = useState<string | JSX.Element>("");
const [saving, setSaving] = useState(false);
const [editMode, setEditMode] = useState(false);
const [usernameRef, setUsernameRef] = useState<HTMLInputElement | null>(
null,
);
const [interactionsRef, setInteractionsRef] =
useState<HTMLInputElement | null>(null);
const { writeClipboard, openScreen } = useIntermediate();
function save() { async function save() {
const changes: Record<string, string | boolean | undefined> = {}; const changes: Changes = {};
if (data.username !== bot.username) changes.name = data.username; if (data.username !== user!.username) changes.name = data.username;
if (data.public !== bot.public) changes.public = data.public; if (data.public !== bot.public) changes.public = data.public;
if (data.interactions_url !== bot.interactions_url) if (data.interactions_url === "") changes.remove = "InteractionsURL";
else if (data.interactions_url !== bot.interactions_url)
changes.interactions_url = data.interactions_url; changes.interactions_url = data.interactions_url;
setSaving(true);
setError("");
try {
await client.bots.edit(bot._id, changes);
onUpdate(changes);
setEditMode(false);
} catch (e) {
const err = e as AxiosError;
if (err.isAxiosError && err.response?.data?.type) {
switch (err.response.data.type) {
case "UsernameTaken":
setError("That username is taken!");
break;
default:
setError(`Error: ${err.response.data.type}`);
break;
}
} else setError(err.toString());
}
setSaving(false);
}
client.bots.edit(bot._id, changes); async function editBotAvatar(avatar?: string) {
setSaving(true);
setError("");
await client.request("PATCH", "/users/id", {
headers: { "x-bot-token": bot.token },
transformRequest: (data, headers) => {
// Remove user headers for this request
delete headers["x-user-id"];
delete headers["x-session-token"];
return data;
},
data: JSON.stringify(avatar ? { avatar } : { remove: "Avatar" }),
});
const res = await client.bots.fetch(bot._id);
if (!avatar) res.user.update({}, "Avatar");
setUser(res.user);
setSaving(false);
} }
return ( return (
<div> <div key={bot._id} className={styles.botCard}>
<p> <div className={styles.infoheader}>
<InputBox <div className={styles.container}>
value={data.username} {!editMode ? (
onChange={(e) => <UserIcon
setData({ ...data, username: e.currentTarget.value }) className={styles.avatar}
target={user}
size={48}
onClick={() =>
openScreen({
id: "profile",
user_id: user._id,
})
}
/>
) : (
<FileUploader
width={64}
height={64}
style="icon"
fileType="avatars"
behaviour="upload"
maxFileSize={4_000_000}
onUpload={(avatar) => editBotAvatar(avatar)}
remove={() => editBotAvatar()}
defaultPreview={user.generateAvatarURL(
{ max_side: 256 },
true,
)}
previewURL={user.generateAvatarURL(
{ max_side: 256 },
true,
)}
/>
)}
{!editMode ? (
<div className={styles.userDetail}>
<div className={styles.userName}>
{user!.username}{" "}
<BotBadge>
<Text id="app.main.channel.bot" />
</BotBadge>
</div>
<div className={styles.userid}>
<Tooltip
content={<Text id="app.special.copy" />}>
<a
onClick={() =>
writeClipboard(user!._id)
}>
{user!._id}
</a>
</Tooltip>
</div>
</div>
) : (
<InputBox
ref={setUsernameRef}
value={data.username}
disabled={saving}
onChange={(e) =>
setData({
...data,
username: e.currentTarget.value,
})
}
/>
)}
</div>
{!editMode && (
<Tooltip
content={<Text id={`app.settings.pages.bots.${bot.public ? 'public' : 'private'}_bot_tip`} />
}>
{bot.public ? (
<Globe size={24} />
) : (
<LockAlt size={24} />
)}
</Tooltip>
)}
<Button
disabled={saving}
onClick={() => {
if (editMode) {
setData({
_id: bot._id,
username: user!.username,
public: bot.public,
interactions_url: bot.interactions_url,
});
usernameRef!.value = user!.username;
interactionsRef!.value = bot.interactions_url || "";
setError("");
setEditMode(false);
} else setEditMode(true);
}}
contrast>
<Text id={`app.special.modals.actions.${editMode ? 'cancel' : 'edit'}`} />
</Button>
</div>
{!editMode && (
<CategoryButton
account
icon={<Key size={24} />}
onClick={() => writeClipboard(bot.token)}
description={
<>
{"••••••••••••••••••••••••••••••••••••"}{" "}
<a
onClick={(ev) =>
stopPropagation(
ev,
openScreen({
id: "token_reveal",
token: bot.token,
username: user!.username,
}),
)
}>
<Text id="app.special.modals.actions.reveal" />
</a>
</>
} }
/> action={<Clipboard size={18} />}>
</p> <Text id="app.settings.pages.bots.token" />
<p> </CategoryButton>
<Checkbox )}
checked={data.public} {editMode && (
onChange={(v) => setData({ ...data, public: v })}> <div className={styles.botSection}>
is public <Checkbox
</Checkbox> checked={data.public}
</p> disabled={saving}
<p>interactions url: (reserved for the future)</p> contrast
<p> description={<Text id="app.settings.pages.bots.public_bot_desc" />}
<InputBox onChange={(v) => setData({ ...data, public: v })}>
value={data.interactions_url} <Text id="app.settings.pages.bots.public_bot" />
onChange={(e) => </Checkbox>
setData({ <h3><Text id="app.settings.pages.bots.interactions_url" /></h3>
...data, <h5><Text id="app.settings.pages.bots.reserved" /></h5>
interactions_url: e.currentTarget.value, <InputBox
}) ref={setInteractionsRef}
} value={data.interactions_url}
/> disabled={saving}
</p> onChange={(e) =>
<Button onClick={save}>save</Button> setData({
...data,
interactions_url: e.currentTarget.value,
})
}
/>
</div>
)}
{error && (
<div className={styles.botSection}>
<Tip error hideSeparator>
{error}
</Tip>
</div>
)}
<div className={styles.buttonRow}>
{editMode && (
<>
<Button accent onClick={save}>
<Text id="app.special.modals.actions.save" />
</Button>
<Button
error
onClick={async () => {
setSaving(true);
await client.bots.delete(bot._id);
onDelete();
}}>
<Text id="app.special.modals.actions.delete" />
</Button>
</>
)}
{!editMode && (
<>
<Button
onClick={() =>
writeClipboard(
`${window.origin}/bot/${bot._id}`,
)
}>
<Text id="app.settings.pages.bots.copy_invite" />
</Button>
<Button
accent
onClick={() =>
internalEmit(
"Intermediate",
"navigate",
`/bot/${bot._id}`,
)
}>
<Text id="app.settings.pages.bots.add_bot" />
</Button>
</>
)}
</div>
</div> </div>
); );
} }
@ -77,82 +347,52 @@ export const MyBots = observer(() => {
// eslint-disable-next-line // eslint-disable-next-line
}, []); }, []);
const [name, setName] = useState(""); const { openScreen } = useIntermediate();
const { writeClipboard } = useIntermediate();
return ( return (
<div> <div className={styles.myBots}>
<Tip warning hideSeparator> <CategoryButton
This section is under construction. account
</Tip> icon={<Plus size={24} />}
<Overline>create a new bot</Overline> onClick={() =>
<p> openScreen({
<InputBox id: "create_bot",
value={name} onCreate: (bot) => setBots([...(bots ?? []), bot]),
contrast })
onChange={(e) => setName(e.currentTarget.value)} }
/> action="chevron">
</p> <Text id="app.settings.pages.bots.create_bot" />
<p> </CategoryButton>
<Button
contrast
onClick={() =>
name.length > 0 &&
client.bots
.create({ name })
.then(({ bot }) => setBots([...(bots ?? []), bot]))
}>
create
</Button>
</p>
<Overline>my bots</Overline>
{bots?.map((bot) => { {bots?.map((bot) => {
const user = client.users.get(bot._id);
return ( return (
<div <BotCard
key={bot._id} key={bot._id}
style={{ bot={bot}
background: "var(--secondary-background)", onDelete={() =>
margin: "8px", setBots(bots.filter((x) => x._id !== bot._id))
padding: "12px", }
}}> onUpdate={(changes: Changes) =>
<UserShort user={user} /> setBots(
<p> bots.map((x) => {
token:{" "} if (x._id === bot._id) {
<code style={{ userSelect: "all" }}> if (
{bot.token} "public" in changes &&
</code> typeof changes.public === "boolean"
</p> )
<BotEditor x.public = changes.public;
bot={{ if ("interactions_url" in changes)
...bot, x.interactions_url =
username: user!.username, changes.interactions_url;
}} if (
/> changes.remove === "InteractionsURL"
<Button )
error x.interactions_url = undefined;
onClick={() => }
client.bots return x;
.delete(bot._id) }),
.then(() => )
setBots( }
bots.filter( />
(x) => x._id !== bot._id,
),
),
)
}>
delete
</Button>
<Button
onClick={() =>
writeClipboard(
`${window.origin}/bot/${bot._id}`,
)
}>
copy invite link
</Button>
</div>
); );
})} })}
</div> </div>

View file

@ -523,6 +523,94 @@
} }
} }
.myBots {
.botCard {
background: var(--secondary-background);
margin: 8px 0;
padding: 12px;
border-radius: var(--border-radius);
h5 { margin-bottom: 1em }
h3 { margin-bottom: 0 }
}
.botSection {
margin: 20px 0;
display: flex;
flex-direction: column;
gap: 5px;
label { margin-top: 0 }
}
.infoheader {
gap: 8px;
width: 100%;
padding: 6px 5px;
display: flex;
overflow: hidden;
align-items: center;
border-radius: var(--border-radius);
.container {
display: flex;
gap: 16px;
align-items: center;
flex-direction: row;
width: 100%;
}
.userDetail {
display: flex;
flex-grow: 1;
gap: 2px;
flex-direction: column;
font-size: 1.2rem;
font-weight: 600;
text-overflow: ellipsis;
white-space: nowrap;
overflow: hidden;
}
.userName {
display: flex;
flex-direction: row;
align-items: center;
gap: 5px;
}
.avatar {
cursor: pointer;
transition: 0.2s ease filter;
&:hover {
filter: brightness(80%);
}
}
.userid {
font-size: 12px;
font-weight: 600;
display: flex;
align-items: center;
gap: 4px;
color: var(--tertiary-foreground);
a {
color: inherit;
cursor: pointer;
}
}
}
.buttonRow {
display: flex;
flex-direction: row;
gap: 10px;
}
}
section { section {
margin-bottom: 20px; margin-bottom: 20px;
} }