feat: port UserProfile, Onboarding, CreateBot to legacy

This commit is contained in:
Paul Makles
2022-06-30 20:39:00 +01:00
parent 0d3f29515e
commit 401b2d4990
19 changed files with 155 additions and 129 deletions

View File

@@ -5,6 +5,8 @@ import { state } from "../../mobx/State";
import { __thisIsAHack } from "../../context/intermediate/Intermediate";
import { modalController } from "../modals/ModalController";
/**
* Current lifecycle state
*/
@@ -189,8 +191,8 @@ export default class Session {
);
if (onboarding) {
__thisIsAHack({
id: "onboarding",
modalController.push({
type: "onboarding",
callback: async (username: string) =>
this.client!.completeOnboarding(
{ username },

View File

@@ -16,7 +16,6 @@ import { getApplicationState } from "../../mobx/State";
import { history } from "../../context/history";
import { __thisIsAHack } from "../../context/intermediate/Intermediate";
// import { determineLink } from "../../lib/links";
import Changelog from "./components/Changelog";
import ChannelInfo from "./components/ChannelInfo";
import Clipboard from "./components/Clipboard";
@@ -34,6 +33,10 @@ import ServerInfo from "./components/ServerInfo";
import ShowToken from "./components/ShowToken";
import SignOutSessions from "./components/SignOutSessions";
import SignedOut from "./components/SignedOut";
import { UserPicker } from "./components/UserPicker";
import { CreateBotModal } from "./components/legacy/CreateBot";
import { OnboardingModal } from "./components/legacy/Onboarding";
import { UserProfile } from "./components/legacy/UserProfile";
import { Modal } from "./types";
type Components = Record<string, React.FC<any>>;
@@ -191,7 +194,7 @@ class ModalControllerExtended extends ModalController<Modal> {
switch (link.type) {
case "profile": {
__thisIsAHack({ id: "profile", user_id: link.id });
this.push({ type: "user_profile", user_id: link.id });
break;
}
case "navigate": {
@@ -222,6 +225,7 @@ export const modalController = new ModalControllerExtended({
changelog: Changelog,
channel_info: ChannelInfo,
clipboard: Clipboard,
create_bot: CreateBotModal,
error: Error,
image_viewer: ImageViewer,
link_warning: LinkWarning,
@@ -229,6 +233,7 @@ export const modalController = new ModalControllerExtended({
mfa_recovery: MFARecovery,
mfa_enable_totp: MFAEnableTOTP,
modify_account: ModifyAccount,
onboarding: OnboardingModal,
out_of_date: OutOfDate,
pending_friend_requests: PendingFriendRequests,
server_identity: ServerIdentity,
@@ -236,4 +241,6 @@ export const modalController = new ModalControllerExtended({
show_token: ShowToken,
signed_out: SignedOut,
sign_out_sessions: SignOutSessions,
user_picker: UserPicker,
user_profile: UserProfile,
});

View File

@@ -0,0 +1,68 @@
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useMemo, useState } from "preact/hooks";
import { Modal } from "@revoltchat/ui";
import UserCheckbox from "../../../components/common/user/UserCheckbox";
import { useClient } from "../../client/ClientController";
import { ModalProps } from "../types";
const List = styled.div`
max-width: 100%;
max-height: 360px;
overflow-y: scroll;
`;
export function UserPicker({
callback,
omit,
...props
}: ModalProps<"user_picker">) {
const [selected, setSelected] = useState<string[]>([]);
const omitted = useMemo(
() => new Set([...(omit || []), "00000000000000000000000000"]),
[omit],
);
const client = useClient();
return (
<Modal
{...props}
title={<Text id="app.special.popovers.user_picker.select" />}
actions={[
{
children: <Text id="app.special.modals.actions.ok" />,
onClick: () => callback(selected).then(() => true),
},
]}>
<List>
{[...client.users.values()]
.filter(
(x) =>
x &&
x.relationship === "Friend" &&
!omitted.has(x._id),
)
.map((x) => (
<UserCheckbox
key={x._id}
user={x}
value={selected.includes(x._id)}
onChange={(v) => {
if (v) {
setSelected([...selected, x._id]);
} else {
setSelected(
selected.filter((y) => y !== x._id),
);
}
}}
/>
))}
</List>
</Modal>
);
}

View File

@@ -0,0 +1,86 @@
import { SubmitHandler, useForm } from "react-hook-form";
import { API } from "revolt.js";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import { Category, Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../../lib/js";
import { I18nError } from "../../../../context/Locale";
import { takeError } from "../../../../context/revoltjs/util";
import FormField from "../../../../pages/login/FormField";
import { useClient } from "../../../client/ClientController";
import { modalController } from "../../ModalController";
import { ModalProps } from "../../types";
interface FormInputs {
name: string;
}
export function CreateBotModal({
onCreate,
...props
}: ModalProps<"create_bot">) {
const client = useClient();
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);
modalController.close();
} catch (err) {
setError(takeError(err));
}
};
return (
<Modal
{...props}
title={<Text id="app.special.popovers.create_bot.title" />}
actions={[
{
confirmation: true,
palette: "accent",
onClick: async () => {
await handleSubmit(onSubmit)();
return true;
},
children: <Text id="app.special.modals.actions.create" />,
},
{
palette: "plain",
onClick: noopTrue,
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 && (
<Category>
<Text id="app.special.popovers.create_bot.failed" />{" "}
&middot; <I18nError error={error} />
</Category>
)}
</form>
</Modal>
);
}

View File

@@ -0,0 +1,54 @@
.onboarding {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--background);
display: flex;
align-items: center;
flex-direction: column;
div {
flex: 1;
&.header {
gap: 8px;
padding: 3em;
display: flex;
text-align: center;
h1 {
margin: 0;
}
img {
max-height: 80px;
}
}
&.form {
flex-grow: 1;
max-width: 420px;
img {
margin: auto;
display: block;
max-height: 420px;
border-radius: var(--border-radius);
}
input {
width: 100%;
}
button {
display: block;
margin: 24px 0;
margin-left: auto;
}
}
}
}

View File

@@ -0,0 +1,79 @@
import { SubmitHandler, useForm } from "react-hook-form";
import styles from "./Onboarding.module.scss";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import { Button, Preloader } from "@revoltchat/ui";
import { takeError } from "../../../../context/revoltjs/util";
import wideSVG from "/assets/wide.svg";
import FormField from "../../../../pages/login/FormField";
import { ModalProps } from "../../types";
interface FormInputs {
username: string;
}
export function OnboardingModal({
callback,
...props
}: ModalProps<"onboarding">) {
const { handleSubmit, register } = useForm<FormInputs>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
const onSubmit: SubmitHandler<FormInputs> = ({ username }) => {
setLoading(true);
callback(username, true)
.then(() => props.onClose())
.catch((err: unknown) => {
setError(takeError(err));
setLoading(false);
});
};
return (
<div className={styles.onboarding}>
<div className={styles.header}>
<h1>
<Text id="app.special.modals.onboarding.welcome" />
<br />
<img src={wideSVG} loading="eager" />
</h1>
</div>
<div className={styles.form}>
{loading ? (
<Preloader type="spinner" />
) : (
<>
<p>
<Text id="app.special.modals.onboarding.pick" />
</p>
<form
onSubmit={
handleSubmit(
onSubmit,
) as unknown as JSX.GenericEventHandler<HTMLFormElement>
}>
<div>
<FormField
type="username"
register={register}
showOverline
error={error}
/>
</div>
<Button type="submit">
<Text id="app.special.modals.actions.continue" />
</Button>
</form>
</>
)}
</div>
<div />
</div>
);
}

View File

@@ -0,0 +1,177 @@
.modal {
height: 460px;
display: flex;
padding: 0 !important;
flex-direction: column;
}
.header {
background-size: cover;
border-radius: var(--border-radius) var(--border-radius) 0 0;
background-position: center;
background-color: var(--secondary-background);
&[data-force="light"] {
color: white;
}
&[data-force="dark"] {
color: black;
}
}
.profile {
gap: 16px;
width: 560px;
display: flex;
padding: 20px;
max-width: 100%;
align-items: center;
flex-direction: row;
> svg {
cursor: pointer;
}
.details {
flex-grow: 1;
min-width: 0;
display: flex;
flex-direction: column;
* {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
.username {
font-size: 22px;
font-weight: 600;
}
.status {
font-size: 13px;
> div {
display: inline !important;
}
}
}
}
.tabs {
gap: 8px;
display: flex;
padding: 0 1.5em;
font-size: 0.875rem;
font-weight: 500;
> div {
padding: 8px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: border-bottom 0.3s;
&[data-active="true"] {
border-bottom: 2px solid var(--foreground);
cursor: default;
}
&:hover:not([data-active="true"]) {
border-bottom: 2px solid var(--tertiary-foreground);
}
}
}
.content {
gap: 8px;
min-height: 240px;
max-height: 240px;
height: 100%;
display: flex;
padding: 1em 1.5em;
max-width: 560px;
overflow-y: auto;
flex-direction: column;
background: var(--primary-background);
border-radius: 0 0 var(--border-radius) var(--border-radius);
.markdown {
user-select: text;
}
.empty {
display: flex;
gap: 10px;
flex-direction: column;
align-items: center;
justify-content: center;
opacity: 0.5;
flex-grow: 1;
font-size: 14px;
font-weight: 500;
}
.category {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
color: var(--tertiary-foreground);
margin-bottom: 8px;
&:not(:first-child) {
margin-top: 8px;
}
}
> div {
> span {
font-size: 14px;
}
}
}
.entries {
gap: 8px;
display: flex;
flex-direction: column;
a {
min-width: 0;
}
}
.entry {
gap: 12px;
font-weight: 500;
min-width: 0;
padding: 12px;
display: flex;
cursor: pointer;
align-items: center;
transition: background-color 0.1s;
color: var(--foreground);
border-radius: var(--border-radius);
background-color: var(--secondary-background);
&:hover {
background-color: var(--primary-background);
}
img {
width: 32px;
height: 32px;
border-radius: var(--border-radius-half);
}
span {
min-width: 0;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
}
}

View File

@@ -0,0 +1,443 @@
import { ListUl } from "@styled-icons/boxicons-regular";
import {
Envelope,
Edit,
UserPlus,
UserX,
Group,
InfoCircle,
} from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Link, useHistory } from "react-router-dom";
import { UserPermission, API } from "revolt.js";
import styles from "./UserProfile.module.scss";
import { Localizer, Text } from "preact-i18n";
import { useEffect, useLayoutEffect, useState } from "preact/hooks";
import {
Button,
Category,
Error,
IconButton,
Modal,
Preloader,
} from "@revoltchat/ui";
import { noop } from "../../../../lib/js";
import ChannelIcon from "../../../../components/common/ChannelIcon";
import ServerIcon from "../../../../components/common/ServerIcon";
import Tooltip from "../../../../components/common/Tooltip";
import UserBadges from "../../../../components/common/user/UserBadges";
import UserIcon from "../../../../components/common/user/UserIcon";
import { Username } from "../../../../components/common/user/UserShort";
import UserStatus from "../../../../components/common/user/UserStatus";
import Markdown from "../../../../components/markdown/Markdown";
import { useSession } from "../../../../controllers/client/ClientController";
import { modalController } from "../../../../controllers/modals/ModalController";
import { ModalProps } from "../../types";
export const UserProfile = observer(
({
user_id,
dummy,
dummyProfile,
...props
}: ModalProps<"user_profile">) => {
const [profile, setProfile] = useState<
undefined | null | API.UserProfile
>(undefined);
const [mutual, setMutual] = useState<
undefined | null | API.MutualResponse
>(undefined);
const [isPublicBot, setIsPublicBot] = useState<
undefined | null | boolean
>();
const history = useHistory();
const session = useSession()!;
const client = session.client!;
const [tab, setTab] = useState("profile");
const user = client.users.get(user_id);
if (!user) {
if (props.onClose) useEffect(props.onClose, []);
return null;
}
const users = mutual?.users.map((id) => client.users.get(id));
const mutualGroups = [...client.channels.values()].filter(
(channel) =>
channel?.channel_type === "Group" &&
channel.recipient_ids!.includes(user_id),
);
const mutualServers = mutual?.servers.map((id) =>
client.servers.get(id),
);
useLayoutEffect(() => {
if (!user_id) return;
if (typeof profile !== "undefined") setProfile(undefined);
if (typeof mutual !== "undefined") setMutual(undefined);
if (typeof isPublicBot !== "undefined") setIsPublicBot(undefined);
// eslint-disable-next-line
}, [user_id]);
useEffect(() => {
if (dummy) {
setProfile(dummyProfile);
}
}, [dummy, dummyProfile]);
useEffect(() => {
if (dummy) return;
if (session.state === "Online" && typeof mutual === "undefined") {
setMutual(null);
user.fetchMutual().then(setMutual);
}
}, [mutual, session.state, dummy, user]);
useEffect(() => {
if (dummy) return;
if (session.state === "Online" && typeof profile === "undefined") {
setProfile(null);
if (user.permission & UserPermission.ViewProfile) {
user.fetchProfile().then(setProfile).catch(noop);
}
}
}, [profile, session.state, dummy, user]);
useEffect(() => {
if (
session.state === "Online" &&
user.bot &&
typeof isPublicBot === "undefined"
) {
setIsPublicBot(null);
client.bots
.fetchPublic(user._id)
.then(() => setIsPublicBot(true))
.catch(noop);
}
}, [isPublicBot, session.state, user, client.bots]);
const backgroundURL =
profile &&
client.generateFileURL(
profile.background as any,
{ width: 1000 },
true,
);
const badges = user.badges ?? 0;
const flags = user.flags ?? 0;
const children = (
<>
<div
className={styles.header}
data-force={profile?.background ? "light" : undefined}
style={{
backgroundImage:
backgroundURL &&
`linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`,
paddingBottom: "1px",
}}>
<div className={styles.profile}>
<UserIcon
size={80}
target={user}
status
animate
hover={typeof user.avatar !== "undefined"}
onClick={() =>
user.avatar &&
modalController.push({
type: "image_viewer",
attachment: user.avatar,
})
}
/>
<div className={styles.details}>
<Localizer>
<span
className={styles.username}
onClick={() =>
modalController.writeText(user.username)
}>
@{user.username}
</span>
</Localizer>
{user.status?.text && (
<span className={styles.status}>
<UserStatus user={user} tooltip />
</span>
)}
</div>
{isPublicBot && (
<Link to={`/bot/${user._id}`}>
<Button
palette="accent"
compact
onClick={props.onClose}>
Add to server
</Button>
</Link>
)}
{user.relationship === "Friend" && (
<Localizer>
<Tooltip
content={
<Text id="app.context_menu.message_user" />
}>
<IconButton
onClick={() => {
props.onClose?.();
history.push(`/open/${user_id}`);
}}>
<Envelope size={30} />
</IconButton>
</Tooltip>
</Localizer>
)}
{user.relationship === "User" && !dummy && (
<IconButton
onClick={() => {
props.onClose?.();
history.push(`/settings/profile`);
}}>
<Edit size={28} />
</IconButton>
)}
{!user.bot &&
flags != 2 &&
flags != 4 &&
(user.relationship === "Incoming" ||
user.relationship === "None" ||
user.relationship === null) && (
<IconButton onClick={() => user.addFriend()}>
<UserPlus size={28} />
</IconButton>
)}
{user.relationship === "Outgoing" && (
<IconButton onClick={() => user.removeFriend()}>
<UserX size={28} />
</IconButton>
)}
</div>
<div className={styles.tabs}>
<div
data-active={tab === "profile"}
onClick={() => setTab("profile")}>
<Text id="app.special.popovers.user_profile.profile" />
</div>
{user.relationship !== "User" && (
<>
<div
data-active={tab === "friends"}
onClick={() => setTab("friends")}>
<Text id="app.special.popovers.user_profile.mutual_friends" />
</div>
<div
data-active={tab === "groups"}
onClick={() => setTab("groups")}>
<Text id="app.special.popovers.user_profile.mutual_groups" />
</div>
<div
data-active={tab === "servers"}
onClick={() => setTab("servers")}>
<Text id="app.special.popovers.user_profile.mutual_servers" />
</div>
</>
)}
</div>
</div>
<div className={styles.content}>
{tab === "profile" &&
(profile?.content ||
badges > 0 ||
flags > 0 ||
user.bot ? (
<div>
{flags & 1 ? (
/** ! FIXME: i18n this area */
<Category>
<Error error="User is suspended" />
</Category>
) : undefined}
{flags & 2 ? (
<Category>
<Error error="User deleted their account" />
</Category>
) : undefined}
{flags & 4 ? (
<Category>
<Error error="User is banned" />
</Category>
) : undefined}
{user.bot ? (
<>
<div className={styles.category}>
bot owner
</div>
<div
onClick={() =>
user.bot &&
modalController.push({
type: "user_profile",
user_id: user.bot.owner,
})
}
className={styles.entry}
key={user.bot.owner}>
<UserIcon
size={32}
target={client.users.get(
user.bot.owner,
)}
/>
<span>
<Username
user={client.users.get(
user.bot.owner,
)}
/>
</span>
</div>
</>
) : undefined}
{badges > 0 && (
<div className={styles.category}>
<Text id="app.special.popovers.user_profile.sub.badges" />
</div>
)}
{badges > 0 && (
<UserBadges
badges={badges}
uid={user._id}
/>
)}
{profile?.content && (
<div className={styles.category}>
<Text id="app.special.popovers.user_profile.sub.information" />
</div>
)}
<div className={styles.markdown}>
<Markdown content={profile?.content} />
</div>
{/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/}
</div>
) : (
<div className={styles.empty}>
<InfoCircle size={72} />
<Text id="app.special.popovers.user_profile.empty" />
</div>
))}
{tab === "friends" &&
(users ? (
users.length === 0 ? (
<div className={styles.empty}>
<UserPlus size={72} />
<Text id="app.special.popovers.user_profile.no_users" />
</div>
) : (
<div className={styles.entries}>
{users.map(
(x) =>
x && (
<div
onClick={() =>
modalController.push({
type: "user_profile",
user_id: x._id,
})
}
className={styles.entry}
key={x._id}>
<UserIcon
size={32}
target={x}
status
/>
<span>{x.username}</span>
</div>
),
)}
</div>
)
) : (
<Preloader type="ring" />
))}
{tab === "groups" &&
(mutualGroups.length === 0 ? (
<div className={styles.empty}>
<Group size="72" />
<Text id="app.special.popovers.user_profile.no_groups" />
</div>
) : (
<div className={styles.entries}>
{mutualGroups.map(
(x) =>
x?.channel_type === "Group" && (
<Link to={`/channel/${x._id}`}>
<div
className={styles.entry}
key={x._id}>
<ChannelIcon
target={x}
size={32}
/>
<span>{x.name}</span>
</div>
</Link>
),
)}
</div>
))}
{tab === "servers" &&
(!mutualServers || mutualServers.length === 0 ? (
<div className={styles.empty}>
<ListUl size="72" />
<Text id="app.special.popovers.user_profile.no_servers" />
</div>
) : (
<div className={styles.entries}>
{mutualServers.map(
(x) =>
x && (
<Link to={`/server/${x._id}`}>
<div
className={styles.entry}
key={x._id}>
<ServerIcon
target={x}
size={32}
/>
<span>{x.name}</span>
</div>
</Link>
),
)}
</div>
))}
</div>
</>
);
if (dummy) return <div>{children}</div>;
return (
<Modal
{...props}
nonDismissable={dummy}
transparent
maxWidth="560px">
{children}
</Modal>
);
},
);

View File

@@ -85,6 +85,28 @@ export type Modal = {
embed?: API.Image;
attachment?: API.File;
}
| {
type: "user_picker";
omit?: string[];
callback: (users: string[]) => Promise<void>;
}
| {
type: "user_profile";
user_id: string;
dummy?: boolean;
dummyProfile?: API.UserProfile;
}
| {
type: "create_bot";
onCreate: (bot: API.Bot) => void;
}
| {
type: "onboarding";
callback: (
username: string,
loginAfterSuccess?: true,
) => Promise<void>;
}
);
export type ModalProps<T extends Modal["type"]> = Modal & { type: T } & {