diff --git a/.vscode/settings.json b/.vscode/settings.json index 3734ef64..a0484054 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,5 @@ { "editor.defaultFormatter": "esbenp.prettier-vscode", - "editor.formatOnSave": true -} \ No newline at end of file + "editor.formatOnSave": true, + "compile-hero.disable-compile-files-on-did-save-code": true +} diff --git a/src/components/ui/Checkbox.tsx b/src/components/ui/Checkbox.tsx index 8f52ac1b..1117a613 100644 --- a/src/components/ui/Checkbox.tsx +++ b/src/components/ui/Checkbox.tsx @@ -51,7 +51,7 @@ const CheckboxDescription = styled.span` color: var(--secondary-foreground); `; -const Checkmark = styled.div<{ checked: boolean }>` +const Checkmark = styled.div<{ checked: boolean; contrast?: boolean }>` margin: 4px; width: 24px; height: 24px; @@ -66,6 +66,16 @@ const Checkmark = styled.div<{ checked: boolean }>` color: var(--secondary-background); } + ${(props) => + props.contrast && + css` + background: var(--primary-background); + + svg { + color: var(--primary-background); + } + `} + ${(props) => props.checked && css` @@ -76,6 +86,7 @@ const Checkmark = styled.div<{ checked: boolean }>` export interface CheckboxProps { checked: boolean; disabled?: boolean; + contrast?: boolean; className?: string; children: Children; description?: Children; @@ -100,7 +111,7 @@ export default function Checkbox(props: CheckboxProps) { !props.disabled && props.onChange(!props.checked) } /> - + diff --git a/src/context/intermediate/Intermediate.tsx b/src/context/intermediate/Intermediate.tsx index e6a3eb49..05211969 100644 --- a/src/context/intermediate/Intermediate.tsx +++ b/src/context/intermediate/Intermediate.tsx @@ -16,6 +16,7 @@ import { Action } from "../../components/ui/Modal"; import { Children } from "../../types/Preact"; import Modals from "./Modals"; +import { Bot } from "revolt-api/types/Bots"; export type Screen = | { id: "none" } @@ -24,6 +25,7 @@ export type Screen = | { id: "signed_out" } | { id: "error"; error: string } | { id: "clipboard"; text: string } + | { id: "token_reveal"; token: string; username: string } | { id: "external_link_prompt"; link: string } | { id: "_prompt"; @@ -89,6 +91,7 @@ export type Screen = | { id: "channel_info"; channel: Channel } | { id: "pending_requests"; users: User[] } | { id: "modify_account"; field: "username" | "email" | "password" } + | { id: "create_bot"; onCreate: (bot: Bot) => void } | { id: "server_identity"; server: Server; diff --git a/src/context/intermediate/Modals.tsx b/src/context/intermediate/Modals.tsx index 8815a16d..f8b57109 100644 --- a/src/context/intermediate/Modals.tsx +++ b/src/context/intermediate/Modals.tsx @@ -10,6 +10,7 @@ import { OnboardingModal } from "./modals/Onboarding"; import { PromptModal } from "./modals/Prompt"; import { SignedOutModal } from "./modals/SignedOut"; import {ExternalLinkModal} from "./modals/ExternalLinkPrompt"; +import { TokenRevealModal } from "./modals/TokenReveal"; export interface Props { screen: Screen; @@ -33,6 +34,8 @@ export default function Modals({ screen, openScreen }: Props) { return ; case "clipboard": return ; + case "token_reveal": + return ; case "onboarding": return ; case "external_link_prompt": diff --git a/src/context/intermediate/Popovers.tsx b/src/context/intermediate/Popovers.tsx index 0834a1f8..2d0d8996 100644 --- a/src/context/intermediate/Popovers.tsx +++ b/src/context/intermediate/Popovers.tsx @@ -8,6 +8,7 @@ import { IntermediateContext, useIntermediate } from "./Intermediate"; import { SpecialInputModal } from "./modals/Input"; import { SpecialPromptModal } from "./modals/Prompt"; import { ChannelInfo } from "./popovers/ChannelInfo"; +import { CreateBotModal } from "./popovers/CreateBot"; import { ImageViewer } from "./popovers/ImageViewer"; import { ModifyAccountModal } from "./popovers/ModifyAccount"; import { PendingRequests } from "./popovers/PendingRequests"; @@ -42,6 +43,9 @@ export default function Popovers() { case "modify_account": // @ts-expect-error someone figure this out :) return ; + case "create_bot": + // @ts-expect-error someone figure this out :) + return ; case "special_prompt": // @ts-expect-error someone figure this out :) return ; diff --git a/src/context/intermediate/modals/TokenReveal.tsx b/src/context/intermediate/modals/TokenReveal.tsx new file mode 100644 index 00000000..a0d8edfd --- /dev/null +++ b/src/context/intermediate/modals/TokenReveal.tsx @@ -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 ( + + } + actions={[ + { + onClick: onClose, + confirmation: true, + children: , + }, + ]}> + {token} + + ); +} diff --git a/src/context/intermediate/popovers/CreateBot.tsx b/src/context/intermediate/popovers/CreateBot.tsx new file mode 100644 index 00000000..48000382 --- /dev/null +++ b/src/context/intermediate/popovers/CreateBot.tsx @@ -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(); + const [error, setError] = useState(undefined); + + const onSubmit: SubmitHandler = async ({ name }) => { + try { + const { bot } = await client.bots.create({ name }); + onCreate(bot); + onClose(); + } catch (err) { + setError(takeError(err)); + } + }; + + return ( + } + actions={[ + { + confirmation: true, + contrast: true, + accent: true, + onClick: handleSubmit(onSubmit), + children: , + }, + { + plain: true, + onClick: onClose, + children: , + }, + ]}> + {/* Preact / React typing incompatabilities */} +
{ + e.preventDefault(); + handleSubmit( + onSubmit, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + )(e as any); + }}> + + {error && ( + + + + )} + +
+ ); +} diff --git a/src/pages/login/FormField.tsx b/src/pages/login/FormField.tsx index 719346c7..9f8b9131 100644 --- a/src/pages/login/FormField.tsx +++ b/src/pages/login/FormField.tsx @@ -67,7 +67,16 @@ export default function FormField({ }, } : type === "username" - ? { required: "RequiredField" } + ? { + validate: (value: string) => + value.length === 0 + ? "RequiredField" + : value.length < 2 + ? "TooShort" + : value.length > 32 + ? "TooLong" + : undefined, + } : { required: "RequiredField" }, )} /> diff --git a/src/pages/settings/panes/MyBots.tsx b/src/pages/settings/panes/MyBots.tsx index 5e558954..09b05e1f 100644 --- a/src/pages/settings/panes/MyBots.tsx +++ b/src/pages/settings/panes/MyBots.tsx @@ -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 { 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 { internalEmit } from "../../../lib/eventEmitter"; +import { stopPropagation } from "../../../lib/stopPropagation"; + import { useIntermediate } from "../../../context/intermediate/Intermediate"; +import { FileUploader } from "../../../context/revoltjs/FileUploads"; 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 Checkbox from "../../../components/ui/Checkbox"; import InputBox from "../../../components/ui/InputBox"; -import Overline from "../../../components/ui/Overline"; import Tip from "../../../components/ui/Tip"; +import CategoryButton from "../../../components/ui/fluent/CategoryButton"; +import type { AxiosError } from "axios"; interface Data { _id: string; @@ -20,50 +32,308 @@ interface Data { 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 [data, setData] = useState(bot); + const [user, setUser] = useState(client.users.get(bot._id)!); + const [data, setData] = useState({ + _id: bot._id, + username: user.username, + public: bot.public, + interactions_url: bot.interactions_url, + }); + const [error, setError] = useState(""); + const [saving, setSaving] = useState(false); + const [editMode, setEditMode] = useState(false); + const [usernameRef, setUsernameRef] = useState( + null, + ); + const [interactionsRef, setInteractionsRef] = + useState(null); + const { writeClipboard, openScreen } = useIntermediate(); - function save() { - const changes: Record = {}; - if (data.username !== bot.username) changes.name = data.username; + async function save() { + const changes: Changes = {}; + if (data.username !== user!.username) changes.name = data.username; 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; + 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 ( -
-

- - setData({ ...data, username: e.currentTarget.value }) +

+
+
+ {!editMode ? ( + + openScreen({ + id: "profile", + user_id: user._id, + }) + } + /> + ) : ( + editBotAvatar(avatar)} + remove={() => editBotAvatar()} + defaultPreview={user.generateAvatarURL( + { max_side: 256 }, + true, + )} + previewURL={user.generateAvatarURL( + { max_side: 256 }, + true, + )} + /> + )} + + {!editMode ? ( +
+
+ {user!.username}{" "} + + + +
+ + +
+ ) : ( + + setData({ + ...data, + username: e.currentTarget.value, + }) + } + /> + )} +
+ + {!editMode && ( + + }> + {bot.public ? ( + + ) : ( + + )} + + )} + +
+ {!editMode && ( + } + onClick={() => writeClipboard(bot.token)} + description={ + <> + {"••••••••••••••••••••••••••••••••••••"}{" "} + + stopPropagation( + ev, + openScreen({ + id: "token_reveal", + token: bot.token, + username: user!.username, + }), + ) + }> + + + } - /> -

-

- setData({ ...data, public: v })}> - is public - -

-

interactions url: (reserved for the future)

-

- - setData({ - ...data, - interactions_url: e.currentTarget.value, - }) - } - /> -

- + action={}> + +
+ )} + {editMode && ( +
+ } + onChange={(v) => setData({ ...data, public: v })}> + + +

+
+ + setData({ + ...data, + interactions_url: e.currentTarget.value, + }) + } + /> +
+ )} + + {error && ( +
+ + {error} + +
+ )} + +
+ {editMode && ( + <> + + + + )} + {!editMode && ( + <> + + + + )} +
); } @@ -77,82 +347,52 @@ export const MyBots = observer(() => { // eslint-disable-next-line }, []); - const [name, setName] = useState(""); - const { writeClipboard } = useIntermediate(); + const { openScreen } = useIntermediate(); return ( -
- - This section is under construction. - - create a new bot -

- setName(e.currentTarget.value)} - /> -

-

- -

- my bots +
+ } + onClick={() => + openScreen({ + id: "create_bot", + onCreate: (bot) => setBots([...(bots ?? []), bot]), + }) + } + action="chevron"> + + {bots?.map((bot) => { - const user = client.users.get(bot._id); return ( -
- -

- token:{" "} - - {bot.token} - -

- - - -
+ bot={bot} + onDelete={() => + setBots(bots.filter((x) => x._id !== bot._id)) + } + onUpdate={(changes: Changes) => + setBots( + bots.map((x) => { + if (x._id === bot._id) { + if ( + "public" in changes && + typeof changes.public === "boolean" + ) + x.public = changes.public; + if ("interactions_url" in changes) + x.interactions_url = + changes.interactions_url; + if ( + changes.remove === "InteractionsURL" + ) + x.interactions_url = undefined; + } + return x; + }), + ) + } + /> ); })}
diff --git a/src/pages/settings/panes/Panes.module.scss b/src/pages/settings/panes/Panes.module.scss index 3dc66c45..545e9997 100644 --- a/src/pages/settings/panes/Panes.module.scss +++ b/src/pages/settings/panes/Panes.module.scss @@ -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 { margin-bottom: 20px; }