Skip to content
Snippets Groups Projects

Compare revisions

Changes are shown as if the source revision was being merged into the target revision. Learn more about comparing revisions.

Source

Select target project
No results found

Target

Select target project
No results found
Show changes
Showing
with 1024 additions and 420 deletions
import { Channel } from "revolt.js/dist/maps/Channels";
import { createContext } from "preact";
import {
useCallback,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from "preact/hooks";
import type { ProduceType, VoiceUser } from "../lib/vortex/Types";
import type VoiceClient from "../lib/vortex/VoiceClient";
import { Children } from "../types/Preact";
import { SoundContext } from "./Settings";
export enum VoiceStatus {
LOADING = 0,
UNAVAILABLE,
ERRORED,
READY = 3,
CONNECTING = 4,
AUTHENTICATING,
RTC_CONNECTING,
CONNECTED,
// RECONNECTING
}
export interface VoiceOperations {
connect: (channel: Channel) => Promise<Channel>;
disconnect: () => void;
isProducing: (type: ProduceType) => boolean;
startProducing: (type: ProduceType) => Promise<void>;
stopProducing: (type: ProduceType) => Promise<void> | undefined;
}
export interface VoiceState {
roomId?: string;
status: VoiceStatus;
participants?: Readonly<Map<string, VoiceUser>>;
}
// They should be present from first render. - insert's words
export const VoiceContext = createContext<VoiceState>(null!);
export const VoiceOperationsContext = createContext<VoiceOperations>(null!);
type Props = {
children: Children;
};
export default function Voice({ children }: Props) {
const [client, setClient] = useState<VoiceClient | undefined>(undefined);
const [state, setState] = useState<VoiceState>({
status: VoiceStatus.LOADING,
participants: new Map(),
});
const setStatus = useCallback(
(status: VoiceStatus, roomId?: string) => {
setState({
status,
roomId: roomId ?? client?.roomId,
participants: client?.participants ?? new Map(),
});
},
[client?.participants, client?.roomId],
);
useEffect(() => {
import("../lib/vortex/VoiceClient")
.then(({ default: VoiceClient }) => {
const client = new VoiceClient();
setClient(client);
if (!client?.supported()) {
setStatus(VoiceStatus.UNAVAILABLE);
} else {
setStatus(VoiceStatus.READY);
}
})
.catch((err) => {
console.error("Failed to load voice library!", err);
setStatus(VoiceStatus.UNAVAILABLE);
});
}, [setStatus]);
const isConnecting = useRef(false);
const operations: VoiceOperations = useMemo(() => {
return {
connect: async (channel) => {
if (!client?.supported()) throw new Error("RTC is unavailable");
isConnecting.current = true;
setStatus(VoiceStatus.CONNECTING, channel._id);
try {
const call = await channel.joinCall();
if (!isConnecting.current) {
setStatus(VoiceStatus.READY);
return channel;
}
// ! TODO: use configuration to check if voso is enabled
// await client.connect("wss://voso.revolt.chat/ws");
await client.connect(
"wss://voso.revolt.chat/ws",
channel._id,
);
setStatus(VoiceStatus.AUTHENTICATING);
await client.authenticate(call.token);
setStatus(VoiceStatus.RTC_CONNECTING);
await client.initializeTransports();
} catch (error) {
console.error(error);
setStatus(VoiceStatus.READY);
return channel;
}
setStatus(VoiceStatus.CONNECTED);
isConnecting.current = false;
return channel;
},
disconnect: () => {
if (!client?.supported()) throw new Error("RTC is unavailable");
// if (status <= VoiceStatus.READY) return;
// this will not update in this context
isConnecting.current = false;
client.disconnect();
setStatus(VoiceStatus.READY);
},
isProducing: (type: ProduceType) => {
switch (type) {
case "audio":
return client?.audioProducer !== undefined;
}
},
startProducing: async (type: ProduceType) => {
switch (type) {
case "audio": {
if (client?.audioProducer !== undefined)
return console.log("No audio producer."); // ! TODO: let the user know
if (navigator.mediaDevices === undefined)
return console.log("No media devices."); // ! TODO: let the user know
const mediaStream =
await navigator.mediaDevices.getUserMedia({
audio: true,
});
await client?.startProduce(
mediaStream.getAudioTracks()[0],
"audio",
);
return;
}
}
},
stopProducing: (type: ProduceType) => {
return client?.stopProduce(type);
},
};
}, [client, setStatus]);
const playSound = useContext(SoundContext);
useEffect(() => {
if (!client?.supported()) return;
// ! TODO: message for fatal:
// ! get rid of these force updates
// ! handle it through state or smth
function stateUpdate() {
setStatus(state.status);
}
client.on("startProduce", stateUpdate);
client.on("stopProduce", stateUpdate);
client.on("userJoined", () => {
playSound("call_join");
stateUpdate();
});
client.on("userLeft", () => {
playSound("call_leave");
stateUpdate();
});
client.on("userStartProduce", stateUpdate);
client.on("userStopProduce", stateUpdate);
client.on("close", stateUpdate);
return () => {
client.removeListener("startProduce", stateUpdate);
client.removeListener("stopProduce", stateUpdate);
client.removeListener("userJoined", stateUpdate);
client.removeListener("userLeft", stateUpdate);
client.removeListener("userStartProduce", stateUpdate);
client.removeListener("userStopProduce", stateUpdate);
client.removeListener("close", stateUpdate);
};
}, [client, state, playSound, setStatus]);
return (
<VoiceContext.Provider value={state}>
<VoiceOperationsContext.Provider value={operations}>
{children}
</VoiceOperationsContext.Provider>
</VoiceContext.Provider>
);
}
import { BrowserRouter as Router } from "react-router-dom";
import State from "../redux/State";
import { Children } from "../types/Preact";
import { BrowserRouter } from "react-router-dom";
import Intermediate from './intermediate/Intermediate';
import ClientContext from './revoltjs/RevoltClient';
import { Children } from "../types/Preact";
import Locale from "./Locale";
import Settings from "./Settings";
import Theme from "./Theme";
import Voice from "./Voice";
import Intermediate from "./intermediate/Intermediate";
import Client from "./revoltjs/RevoltClient";
export default function Context({ children }: { children: Children }) {
return (
<State>
<Locale>
<Intermediate>
<BrowserRouter>
<ClientContext>
<Theme>{children}</Theme>
</ClientContext>
</BrowserRouter>
</Intermediate>
</Locale>
</State>
<Router>
<State>
<Theme>
<Settings>
<Locale>
<Intermediate>
<Client>
<Voice>{children}</Voice>
</Client>
</Intermediate>
</Locale>
</Settings>
</Theme>
</State>
</Router>
);
}
import { Attachment, Channels, EmbedImage, Servers } from "revolt.js/dist/api/objects";
import { Prompt } from "react-router";
import { useHistory } from "react-router-dom";
import type { Attachment } from "revolt-api/types/Autumn";
import type { EmbedImage } from "revolt-api/types/January";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Message } from "revolt.js/dist/maps/Messages";
import { Server } from "revolt.js/dist/maps/Servers";
import { User } from "revolt.js/dist/maps/Users";
import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { internalSubscribe } from "../../lib/eventEmitter";
import { Action } from "../../components/ui/Modal";
import { useHistory } from "react-router-dom";
import { Children } from "../../types/Preact";
import { createContext } from "preact";
import Modals from './Modals';
import Modals from "./Modals";
export type Screen =
| { id: "none" }
// Modals
| { id: "signed_out" }
| { id: "error"; error: string }
| { id: "clipboard"; text: string }
| { id: "modify_account"; field: "username" | "email" | "password" }
| { id: "_prompt"; question: Children; content?: Children; actions: Action[] }
| ({ id: "special_prompt" } & (
{ type: "leave_group", target: Channels.GroupChannel } |
{ type: "close_dm", target: Channels.DirectMessageChannel } |
{ type: "leave_server", target: Servers.Server } |
{ type: "delete_server", target: Servers.Server } |
{ type: "delete_channel", target: Channels.TextChannel } |
{ type: "delete_message", target: Channels.Message } |
{ type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } |
{ type: "kick_member", target: Servers.Server, user: string } |
{ type: "ban_member", target: Servers.Server, user: string }
)) |
({ id: "special_input" } & (
{ type: "create_group" | "create_server" | "set_custom_status" | "add_friend" } |
{ type: "create_channel", server: string }
))
| {
id: "_input";
question: Children;
field: Children;
defaultValue?: string;
callback: (value: string) => Promise<void>;
}
| {
id: "onboarding";
callback: (
username: string,
loginAfterSuccess?: true
) => Promise<void>;
}
// Pop-overs
| { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage; }
| { id: "profile"; user_id: string }
| { id: "channel_info"; channel_id: string }
| {
id: "user_picker";
omit?: string[];
callback: (users: string[]) => Promise<void>;
};
| { id: "none" }
// Modals
| { id: "signed_out" }
| { id: "error"; error: string }
| { id: "clipboard"; text: string }
| {
id: "_prompt";
question: Children;
content?: Children;
actions: Action[];
}
| ({ id: "special_prompt" } & (
| { type: "leave_group"; target: Channel }
| { type: "close_dm"; target: Channel }
| { type: "leave_server"; target: Server }
| { type: "delete_server"; target: Server }
| { type: "delete_channel"; target: Channel }
| { type: "delete_message"; target: Message }
| {
type: "create_invite";
target: Channel;
}
| { type: "kick_member"; target: Server; user: User }
| { type: "ban_member"; target: Server; user: User }
| { type: "unfriend_user"; target: User }
| { type: "block_user"; target: User }
| { type: "create_channel"; target: Server }
))
| ({ id: "special_input" } & (
| {
type:
| "create_group"
| "create_server"
| "set_custom_status"
| "add_friend";
}
| {
type: "create_role";
server: Server;
callback: (id: string) => void;
}
))
| {
id: "_input";
question: Children;
field: Children;
defaultValue?: string;
callback: (value: string) => Promise<void>;
}
| {
id: "onboarding";
callback: (
username: string,
loginAfterSuccess?: true,
) => Promise<void>;
}
// Pop-overs
| { id: "image_viewer"; attachment?: Attachment; embed?: EmbedImage }
| { id: "modify_account"; field: "username" | "email" | "password" }
| { id: "profile"; user_id: string }
| { id: "channel_info"; channel: Channel }
| { id: "pending_requests"; users: User[] }
| {
id: "user_picker";
omit?: string[];
callback: (users: string[]) => Promise<void>;
};
export const IntermediateContext = createContext({
screen: { id: "none" } as Screen,
focusTaken: false
screen: { id: "none" },
focusTaken: false,
});
export const IntermediateActionsContext = createContext({
openScreen: (screen: Screen) => {},
writeClipboard: (text: string) => {}
export const IntermediateActionsContext = createContext<{
openScreen: (screen: Screen) => void;
writeClipboard: (text: string) => void;
}>({
openScreen: null!,
writeClipboard: null!,
});
interface Props {
......@@ -75,7 +111,7 @@ export default function Intermediate(props: Props) {
const value = {
screen,
focusTaken: screen.id !== 'none'
focusTaken: screen.id !== "none",
};
const actions = useMemo(() => {
......@@ -87,28 +123,35 @@ export default function Intermediate(props: Props) {
} else {
actions.openScreen({ id: "clipboard", text });
}
}
}
},
};
}, []);
useEffect(() => {
// const openProfile = (user_id: string) =>
// openScreen({ id: "profile", user_id });
// const navigate = (path: string) => history.push(path);
const openProfile = (user_id: string) =>
openScreen({ id: "profile", user_id });
const navigate = (path: string) => history.push(path);
// InternalEventEmitter.addListener("openProfile", openProfile);
// InternalEventEmitter.addListener("navigate", navigate);
const subs = [
internalSubscribe(
"Intermediate",
"openProfile",
openProfile as (...args: unknown[]) => void,
),
internalSubscribe(
"Intermediate",
"navigate",
navigate as (...args: unknown[]) => void,
),
];
return () => {
// InternalEventEmitter.removeListener("openProfile", openProfile);
// InternalEventEmitter.removeListener("navigate", navigate);
};
}, []);
return () => subs.map((unsub) => unsub());
}, [history]);
return (
<IntermediateContext.Provider value={value}>
<IntermediateActionsContext.Provider value={actions}>
{props.children}
{screen.id !== "onboarding" && props.children}
<Modals
{...value}
{...actions}
......@@ -116,15 +159,28 @@ export default function Intermediate(props: Props) {
screen.id
} /** By specifying a key, we reset state whenever switching screen. */
/>
{/*<Prompt
when={screen.id !== 'none'}
message={() => {
openScreen({ id: 'none' });
setTimeout(() => history.push(history.location), 0);
<Prompt
when={[
"modify_account",
"special_prompt",
"special_input",
"image_viewer",
"profile",
"channel_info",
"pending_requests",
"user_picker",
].includes(screen.id)}
message={(_, action) => {
if (action === "POP") {
openScreen({ id: "none" });
setTimeout(() => history.push(history.location), 0);
return false;
return false;
}
return true;
}}
/>*/}
/>
</IntermediateActionsContext.Provider>
</IntermediateContext.Provider>
);
......
import { Screen } from "./Intermediate";
import { internalEmit } from "../../lib/eventEmitter";
import { isModalClosing } from "../../components/ui/Modal";
import { Screen } from "./Intermediate";
import { ClipboardModal } from "./modals/Clipboard";
import { ErrorModal } from "./modals/Error";
import { InputModal } from "./modals/Input";
import { OnboardingModal } from "./modals/Onboarding";
import { PromptModal } from "./modals/Prompt";
import { SignedOutModal } from "./modals/SignedOut";
import { ClipboardModal } from "./modals/Clipboard";
import { OnboardingModal } from "./modals/Onboarding";
import { ModifyAccountModal } from "./modals/ModifyAccount";
export interface Props {
screen: Screen;
openScreen: (id: any) => void;
openScreen: (screen: Screen) => void;
}
export default function Modals({ screen, openScreen }: Props) {
const onClose = () => openScreen({ id: "none" });
const onClose = () =>
isModalClosing || screen.id === "onboarding"
? openScreen({ id: "none" })
: internalEmit("Modal", "close");
switch (screen.id) {
case "_prompt":
......@@ -27,8 +32,6 @@ export default function Modals({ screen, openScreen }: Props) {
return <SignedOutModal onClose={onClose} {...screen} />;
case "clipboard":
return <ClipboardModal onClose={onClose} {...screen} />;
case "modify_account":
return <ModifyAccountModal onClose={onClose} {...screen} />;
case "onboarding":
return <OnboardingModal onClose={onClose} {...screen} />;
}
......
import { IntermediateContext, useIntermediate } from "./Intermediate";
import { useContext } from "preact/hooks";
import { UserPicker } from "./popovers/UserPicker";
import { internalEmit } from "../../lib/eventEmitter";
import { isModalClosing } from "../../components/ui/Modal";
import { IntermediateContext, useIntermediate } from "./Intermediate";
import { SpecialInputModal } from "./modals/Input";
import { SpecialPromptModal } from "./modals/Prompt";
import { UserProfile } from "./popovers/UserProfile";
import { ImageViewer } from "./popovers/ImageViewer";
import { ChannelInfo } from "./popovers/ChannelInfo";
import { ImageViewer } from "./popovers/ImageViewer";
import { ModifyAccountModal } from "./popovers/ModifyAccount";
import { PendingRequests } from "./popovers/PendingRequests";
import { UserPicker } from "./popovers/UserPicker";
import { UserProfile } from "./popovers/UserProfile";
export default function Popovers() {
const { screen } = useContext(IntermediateContext);
const { openScreen } = useIntermediate();
const onClose = () => openScreen({ id: "none" });
const onClose = () =>
isModalClosing
? openScreen({ id: "none" })
: internalEmit("Modal", "close");
switch (screen.id) {
case "profile":
......@@ -23,6 +32,10 @@ export default function Popovers() {
return <ImageViewer {...screen} onClose={onClose} />;
case "channel_info":
return <ChannelInfo {...screen} onClose={onClose} />;
case "pending_requests":
return <PendingRequests {...screen} onClose={onClose} />;
case "modify_account":
return <ModifyAccountModal onClose={onClose} {...screen} />;
case "special_prompt":
return <SpecialPromptModal onClose={onClose} {...screen} />;
case "special_input":
......
import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
interface Props {
......@@ -16,10 +17,9 @@ export function ClipboardModal({ onClose, text }: Props) {
{
onClick: onClose,
confirmation: true,
text: <Text id="app.special.modals.actions.close" />
}
]}
>
children: <Text id="app.special.modals.actions.close" />,
},
]}>
{location.protocol !== "https:" && (
<p>
<Text id="app.special.modals.clipboard.https" />
......
import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
interface Props {
......@@ -16,14 +17,13 @@ export function ErrorModal({ onClose, error }: Props) {
{
onClick: onClose,
confirmation: true,
text: <Text id="app.special.modals.actions.ok" />
children: <Text id="app.special.modals.actions.ok" />,
},
{
onClick: () => location.reload(),
text: <Text id="app.special.modals.actions.reload" />
}
]}
>
children: <Text id="app.special.modals.actions.reload" />,
},
]}>
<Text id={`error.${error}`}>{error}</Text>
</Modal>
);
......
import { useHistory } from "react-router";
import { Server } from "revolt.js/dist/maps/Servers";
import { ulid } from "ulid";
import { Text } from "preact-i18n";
import { useHistory } from "react-router";
import { useContext, useState } from "preact/hooks";
import InputBox from "../../../components/ui/InputBox";
import Modal from "../../../components/ui/Modal";
import Overline from "../../../components/ui/Overline";
import { Children } from "../../../types/Preact";
import { takeError } from "../../revoltjs/util";
import { useContext, useState } from "preact/hooks";
import Overline from '../../../components/ui/Overline';
import InputBox from '../../../components/ui/InputBox';
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
......@@ -22,7 +26,7 @@ export function InputModal({
question,
field,
defaultValue,
callback
callback,
}: Props) {
const [processing, setProcessing] = useState(false);
const [value, setValue] = useState(defaultValue ?? "");
......@@ -35,39 +39,51 @@ export function InputModal({
disabled={processing}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
confirmation: true,
children: <Text id="app.special.modals.actions.ok" />,
onClick: () => {
setProcessing(true);
callback(value)
.then(onClose)
.catch(err => {
.catch((err) => {
setError(takeError(err));
setProcessing(false)
})
}
setProcessing(false);
});
},
},
{
text: <Text id="app.special.modals.actions.cancel" />,
onClick: onClose
}
children: <Text id="app.special.modals.actions.cancel" />,
onClick: onClose,
},
]}
onClose={onClose}
>
{ field ? <Overline error={error} block>
{field}
</Overline> : (error && <Overline error={error} type="error" block />) }
<InputBox
value={value}
onChange={e => setValue(e.currentTarget.value)}
/>
onClose={onClose}>
<form>
{field ? (
<Overline error={error} block>
{field}
</Overline>
) : (
error && <Overline error={error} type="error" block />
)}
<InputBox
value={value}
onChange={(e) => setValue(e.currentTarget.value)}
/>
</form>
</Modal>
);
}
type SpecialProps = { onClose: () => void } & (
{ type: "create_group" | "create_server" | "set_custom_status" | "add_friend" } |
{ type: "create_channel", server: string }
)
| {
type:
| "create_group"
| "create_server"
| "set_custom_status"
| "add_friend";
}
| { type: "create_role"; server: Server; callback: (id: string) => void }
);
export function SpecialInputModal(props: SpecialProps) {
const history = useHistory();
......@@ -76,83 +92,90 @@ export function SpecialInputModal(props: SpecialProps) {
const { onClose } = props;
switch (props.type) {
case "create_group": {
return <InputModal
onClose={onClose}
question={<Text id="app.main.groups.create" />}
field={<Text id="app.main.groups.name" />}
callback={async name => {
const group = await client.channels.createGroup(
{
return (
<InputModal
onClose={onClose}
question={<Text id="app.main.groups.create" />}
field={<Text id="app.main.groups.name" />}
callback={async (name) => {
const group = await client.channels.createGroup({
name,
nonce: ulid(),
users: []
}
);
users: [],
});
history.push(`/channel/${group._id}`);
}}
/>;
history.push(`/channel/${group._id}`);
}}
/>
);
}
case "create_server": {
return <InputModal
onClose={onClose}
question={<Text id="app.main.servers.create" />}
field={<Text id="app.main.servers.name" />}
callback={async name => {
const server = await client.servers.createServer(
{
return (
<InputModal
onClose={onClose}
question={<Text id="app.main.servers.create" />}
field={<Text id="app.main.servers.name" />}
callback={async (name) => {
const server = await client.servers.createServer({
name,
nonce: ulid()
}
);
nonce: ulid(),
});
history.push(`/server/${server._id}`);
}}
/>;
history.push(`/server/${server._id}`);
}}
/>
);
}
case "create_channel": {
return <InputModal
onClose={onClose}
question={<Text id="app.context_menu.create_channel" />}
field={<Text id="app.main.servers.channel_name" />}
callback={async name => {
const channel = await client.servers.createChannel(
props.server,
{
name,
nonce: ulid()
}
);
history.push(`/server/${props.server}/channel/${channel._id}`);
}}
/>;
case "create_role": {
return (
<InputModal
onClose={onClose}
question={
<Text id="app.settings.permissions.create_role" />
}
field={<Text id="app.settings.permissions.role_name" />}
callback={async (name) => {
const role = await props.server.createRole(name);
props.callback(role.id);
}}
/>
);
}
case "set_custom_status": {
return <InputModal
onClose={onClose}
question={<Text id="app.context_menu.set_custom_status" />}
field={<Text id="app.context_menu.custom_status" />}
defaultValue={client.user?.status?.text}
callback={text =>
client.users.editUser({
status: {
...client.user?.status,
text
}
})
}
/>;
return (
<InputModal
onClose={onClose}
question={<Text id="app.context_menu.set_custom_status" />}
field={<Text id="app.context_menu.custom_status" />}
defaultValue={client.user?.status?.text}
callback={(text) =>
client.users.edit({
status: {
...client.user?.status,
text: text.trim().length > 0 ? text : undefined,
},
})
}
/>
);
}
case "add_friend": {
return <InputModal
onClose={onClose}
question={"Add Friend"}
callback={username =>
client.users.addFriend(username)
}
/>;
return (
<InputModal
onClose={onClose}
question={"Add Friend"}
callback={(username) =>
client
.req(
"PUT",
`/users/${username}/friend` as "/users/id/friend",
)
.then(undefined)
}
/>
);
}
default: return null;
default:
return null;
}
}
.onboarding {
height: 100vh;
display: flex;
align-items: center;
flex-direction: column;
......@@ -7,7 +8,9 @@
flex: 1;
&.header {
gap: 8px;
padding: 3em;
display: flex;
text-align: center;
h1 {
......@@ -23,7 +26,7 @@
margin: auto;
display: block;
max-height: 420px;
border-radius: 8px;
border-radius: var(--border-radius);
}
input {
......
import { SubmitHandler, useForm } from "react-hook-form";
import styles from "./Onboarding.module.scss";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import { useForm } from "react-hook-form";
import styles from "./Onboarding.module.scss";
import { takeError } from "../../revoltjs/util";
import wideSVG from "../../../assets/wide.svg";
import Button from "../../../components/ui/Button";
import FormField from "../../../pages/login/FormField";
import Preloader from "../../../components/ui/Preloader";
import wideSVG from '../../../assets/wide.svg';
import FormField from "../../../pages/login/FormField";
import { takeError } from "../../revoltjs/util";
interface Props {
onClose: () => void;
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
}
interface FormInputs {
username: string;
}
export function OnboardingModal({ onClose, callback }: Props) {
const { handleSubmit, register } = useForm();
const { handleSubmit, register } = useForm<FormInputs>();
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined);
async function onSubmit({ username }: { username: string }) {
const onSubmit: SubmitHandler<FormInputs> = ({ username }) => {
setLoading(true);
callback(username, true)
.then(onClose)
.catch((err: any) => {
.then(() => 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" />
<img src={wideSVG} />
<img src={wideSVG} loading="eager" />
</h1>
</div>
<div className={styles.form}>
{loading ? (
<Preloader />
<Preloader type="spinner" />
) : (
<>
<p>
<Text id="app.special.modals.onboarding.pick" />
</p>
<form onSubmit={handleSubmit(onSubmit) as any}>
<form
onSubmit={
handleSubmit(
onSubmit,
) as JSX.GenericEventHandler<HTMLFormElement>
}>
<div>
<FormField
type="username"
......
......@@ -7,7 +7,7 @@
user-select: all;
font-size: 1.4em;
text-align: center;
font-family: "Fira Mono";
font-family: var(--monospace-font);
}
}
......
import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom";
import { Channel } from "revolt.js/dist/maps/Channels";
import { Message as MessageI } from "revolt.js/dist/maps/Messages";
import { Server } from "revolt.js/dist/maps/Servers";
import { User } from "revolt.js/dist/maps/Users";
import { ulid } from "ulid";
import styles from "./Prompt.module.scss";
import { Text } from "preact-i18n";
import styles from './Prompt.module.scss';
import { Children } from "../../../types/Preact";
import { useIntermediate } from "../Intermediate";
import { useContext, useEffect, useState } from "preact/hooks";
import { TextReact } from "../../../lib/i18n";
import Message from "../../../components/common/messaging/Message";
import UserIcon from "../../../components/common/user/UserIcon";
import InputBox from "../../../components/ui/InputBox";
import Overline from "../../../components/ui/Overline";
import UserIcon from "../../../components/common/UserIcon";
import Modal, { Action } from "../../../components/ui/Modal";
import { Channels, Servers } from "revolt.js/dist/api/objects";
import { useContext, useEffect, useState } from "preact/hooks";
import Overline from "../../../components/ui/Overline";
import Radio from "../../../components/ui/Radio";
import { Children } from "../../../types/Preact";
import { AppContext } from "../../revoltjs/RevoltClient";
import { mapMessage, takeError } from "../../revoltjs/util";
import Message from "../../../components/common/messaging/Message";
import { takeError } from "../../revoltjs/util";
import { useIntermediate } from "../Intermediate";
interface Props {
onClose: () => void;
......@@ -21,7 +33,14 @@ interface Props {
error?: string;
}
export function PromptModal({ onClose, question, content, actions, disabled, error }: Props) {
export function PromptModal({
onClose,
question,
content,
actions,
disabled,
error,
}: Props) {
return (
<Modal
visible={true}
......@@ -29,68 +48,107 @@ export function PromptModal({ onClose, question, content, actions, disabled, err
actions={actions}
onClose={onClose}
disabled={disabled}>
{ error && <Overline error={error} type="error" /> }
{ content }
{error && <Overline error={error} type="error" />}
{content}
</Modal>
);
}
type SpecialProps = { onClose: () => void } & (
{ type: "leave_group", target: Channels.GroupChannel } |
{ type: "close_dm", target: Channels.DirectMessageChannel } |
{ type: "leave_server", target: Servers.Server } |
{ type: "delete_server", target: Servers.Server } |
{ type: "delete_channel", target: Channels.TextChannel } |
{ type: "delete_message", target: Channels.Message } |
{ type: "create_invite", target: Channels.TextChannel | Channels.GroupChannel } |
{ type: "kick_member", target: Servers.Server, user: string } |
{ type: "ban_member", target: Servers.Server, user: string }
)
| { type: "leave_group"; target: Channel }
| { type: "close_dm"; target: Channel }
| { type: "leave_server"; target: Server }
| { type: "delete_server"; target: Server }
| { type: "delete_channel"; target: Channel }
| { type: "delete_message"; target: MessageI }
| {
type: "create_invite";
target: Channel;
}
| { type: "kick_member"; target: Server; user: User }
| { type: "ban_member"; target: Server; user: User }
| { type: "unfriend_user"; target: User }
| { type: "block_user"; target: User }
| { type: "create_channel"; target: Server }
);
export function SpecialPromptModal(props: SpecialProps) {
export const SpecialPromptModal = observer((props: SpecialProps) => {
const client = useContext(AppContext);
const [ processing, setProcessing ] = useState(false);
const [ error, setError ] = useState<undefined | string>(undefined);
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<undefined | string>(undefined);
const { onClose } = props;
switch (props.type) {
case 'leave_group':
case 'close_dm':
case 'leave_server':
case 'delete_server':
case 'delete_channel': {
case "leave_group":
case "close_dm":
case "leave_server":
case "delete_server":
case "delete_channel":
case "unfriend_user":
case "block_user": {
const EVENTS = {
'close_dm': 'confirm_close_dm',
'delete_server': 'confirm_delete',
'delete_channel': 'confirm_delete',
'leave_group': 'confirm_leave',
'leave_server': 'confirm_leave'
close_dm: ["confirm_close_dm", "close"],
delete_server: ["confirm_delete", "delete"],
delete_channel: ["confirm_delete", "delete"],
leave_group: ["confirm_leave", "leave"],
leave_server: ["confirm_leave", "leave"],
unfriend_user: ["unfriend_user", "remove"],
block_user: ["block_user", "block"],
};
let event = EVENTS[props.type];
let name = props.type === 'close_dm' ? client.users.get(client.channels.getRecipient(props.target._id))?.username : props.target.name;
const event = EVENTS[props.type];
let name;
switch (props.type) {
case "unfriend_user":
case "block_user":
name = props.target.username;
break;
case "close_dm":
name = props.target.recipient?.username;
break;
default:
name = props.target.name;
}
return (
<PromptModal
onClose={onClose}
question={<Text
id={`app.special.modals.prompt.${event}`}
fields={{ name }}
/>}
question={
<Text
id={`app.special.modals.prompt.${event[0]}`}
fields={{ name }}
/>
}
actions={[
{
confirmation: true,
contrast: true,
error: true,
text: <Text id="app.special.modals.actions.delete" />,
children: (
<Text
id={`app.special.modals.actions.${event[1]}`}
/>
),
onClick: async () => {
setProcessing(true);
try {
if (props.type === 'leave_group' || props.type === 'close_dm' || props.type === 'delete_channel') {
await client.channels.delete(props.target._id);
} else {
await client.servers.delete(props.target._id);
switch (props.type) {
case "unfriend_user":
await props.target.removeFriend();
break;
case "block_user":
await props.target.blockUser();
break;
case "leave_group":
case "close_dm":
case "delete_channel":
props.target.delete();
break;
case "leave_server":
case "delete_server":
props.target.delete();
break;
}
onClose();
......@@ -98,63 +156,89 @@ export function SpecialPromptModal(props: SpecialProps) {
setError(takeError(err));
setProcessing(false);
}
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
]}
content={<Text id={`app.special.modals.prompt.${event}_long`} />}
content={
<TextReact
id={`app.special.modals.prompt.${event[0]}_long`}
fields={{ name: <b>{name}</b> }}
/>
}
disabled={processing}
error={error}
/>
)
);
}
case 'delete_message': {
case "delete_message": {
return (
<PromptModal
onClose={onClose}
question={<Text id={'app.context_menu.delete_message'} />}
question={<Text id={"app.context_menu.delete_message"} />}
actions={[
{
confirmation: true,
contrast: true,
error: true,
text: <Text id="app.special.modals.actions.delete" />,
children: (
<Text id="app.special.modals.actions.delete" />
),
onClick: async () => {
setProcessing(true);
try {
await client.channels.deleteMessage(props.target.channel, props.target._id);
props.target.delete();
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
plain: true,
},
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
]}
content={<>
<Text id={`app.special.modals.prompt.confirm_delete_message_long`} />
<Message message={mapMessage(props.target)} head={true} contrast />
</>}
content={
<>
<Text
id={`app.special.modals.prompt.confirm_delete_message_long`}
/>
<Message
message={props.target}
head={true}
contrast
/>
</>
}
disabled={processing}
error={error}
/>
)
);
}
case "create_invite": {
const [ code, setCode ] = useState('abcdef');
const [code, setCode] = useState("abcdef");
const { writeClipboard } = useIntermediate();
useEffect(() => {
setProcessing(true);
client.channels.createInvite(props.target._id)
.then(code => setCode(code))
.catch(err => setError(takeError(err)))
props.target
.createInvite()
.then((code) => setCode(code))
.catch((err) => setError(takeError(err)))
.finally(() => setProcessing(false));
}, []);
}, [props.target]);
return (
<PromptModal
......@@ -162,69 +246,89 @@ export function SpecialPromptModal(props: SpecialProps) {
question={<Text id={`app.context_menu.create_invite`} />}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
children: (
<Text id="app.special.modals.actions.ok" />
),
confirmation: true,
onClick: onClose
onClick: onClose,
},
{
text: <Text id="app.context_menu.copy_link" />,
onClick: () => writeClipboard(`${window.location.protocol}//${window.location.host}/invite/${code}`)
}
children: <Text id="app.context_menu.copy_link" />,
onClick: () =>
writeClipboard(
`${window.location.protocol}//${window.location.host}/invite/${code}`,
),
},
]}
content={
processing ?
processing ? (
<Text id="app.special.modals.prompt.create_invite_generate" />
: <div className={styles.invite}>
<Text id="app.special.modals.prompt.create_invite_created" />
<code>{code}</code>
) : (
<div className={styles.invite}>
<Text id="app.special.modals.prompt.create_invite_created" />
<code>{code}</code>
</div>
)
}
disabled={processing}
error={error}
/>
)
);
}
case "kick_member": {
const user = client.users.get(props.user);
return (
<PromptModal
onClose={onClose}
question={<Text id={`app.context_menu.kick_member`} />}
actions={[
{
text: <Text id="app.special.modals.actions.kick" />,
children: (
<Text id="app.special.modals.actions.kick" />
),
contrast: true,
error: true,
confirmation: true,
onClick: async () => {
setProcessing(true);
try {
await client.servers.members.kickMember(props.target._id, props.user);
client.members
.getKey({
server: props.target._id,
user: props.user._id,
})
?.kick();
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
]}
content={<div className={styles.column}>
<UserIcon target={user} size={64} />
<Text
id="app.special.modals.prompt.confirm_kick"
fields={{ name: user?.username }} />
</div>}
content={
<div className={styles.column}>
<UserIcon target={props.user} size={64} />
<Text
id="app.special.modals.prompt.confirm_kick"
fields={{ name: props.user?.username }}
/>
</div>
}
disabled={processing}
error={error}
/>
)
);
}
case "ban_member": {
const [ reason, setReason ] = useState<string | undefined>(undefined);
const user = client.users.get(props.user);
const [reason, setReason] = useState<string | undefined>(undefined);
return (
<PromptModal
......@@ -232,37 +336,130 @@ export function SpecialPromptModal(props: SpecialProps) {
question={<Text id={`app.context_menu.ban_member`} />}
actions={[
{
text: <Text id="app.special.modals.actions.ban" />,
children: (
<Text id="app.special.modals.actions.ban" />
),
contrast: true,
error: true,
confirmation: true,
onClick: async () => {
setProcessing(true);
try {
await props.target.banUser(props.user._id, {
reason,
});
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
]}
content={
<div className={styles.column}>
<UserIcon target={props.user} size={64} />
<Text
id="app.special.modals.prompt.confirm_ban"
fields={{ name: props.user?.username }}
/>
<Overline>
<Text id="app.special.modals.prompt.confirm_ban_reason" />
</Overline>
<InputBox
value={reason ?? ""}
onChange={(e) =>
setReason(e.currentTarget.value)
}
/>
</div>
}
disabled={processing}
error={error}
/>
);
}
case "create_channel": {
const [name, setName] = useState("");
const [type, setType] = useState<"Text" | "Voice">("Text");
const history = useHistory();
return (
<PromptModal
onClose={onClose}
question={<Text id="app.context_menu.create_channel" />}
actions={[
{
confirmation: true,
contrast: true,
children: (
<Text id="app.special.modals.actions.create" />
),
onClick: async () => {
setProcessing(true);
try {
await client.servers.banUser(props.target._id, props.user, { reason });
const channel =
await props.target.createChannel({
type,
name,
nonce: ulid(),
});
history.push(
`/server/${props.target._id}/channel/${channel._id}`,
);
onClose();
} catch (err) {
setError(takeError(err));
setProcessing(false);
}
}
},
},
{
children: (
<Text id="app.special.modals.actions.cancel" />
),
onClick: onClose,
},
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
]}
content={<div className={styles.column}>
<UserIcon target={user} size={64} />
<Text
id="app.special.modals.prompt.confirm_ban"
fields={{ name: user?.username }} />
<Overline><Text id="app.special.modals.prompt.confirm_ban_reason" /></Overline>
<InputBox value={reason ?? ''} onChange={e => setReason(e.currentTarget.value)} />
</div>}
content={
<>
<Overline block type="subtle">
<Text id="app.main.servers.channel_type" />
</Overline>
<Radio
checked={type === "Text"}
onSelect={() => setType("Text")}>
<Text id="app.main.servers.text_channel" />
</Radio>
<Radio
checked={type === "Voice"}
onSelect={() => setType("Voice")}>
<Text id="app.main.servers.voice_channel" />
</Radio>
<Overline block type="subtle">
<Text id="app.main.servers.channel_name" />
</Overline>
<InputBox
value={name}
onChange={(e) => setName(e.currentTarget.value)}
/>
</>
}
disabled={processing}
error={error}
/>
)
);
}
default: return null;
default:
return null;
}
}
});
import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
interface Props {
......@@ -15,8 +16,8 @@ export function SignedOutModal({ onClose }: Props) {
{
onClick: onClose,
confirmation: true,
text: <Text id="app.special.modals.actions.ok" />
}
children: <Text id="app.special.modals.actions.ok" />,
},
]}
/>
);
......
import { X } from "@styled-icons/feather";
import { X } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import styles from "./ChannelInfo.module.scss";
import Modal from "../../../components/ui/Modal";
import { getChannelName } from "../../revoltjs/util";
import Markdown from "../../../components/markdown/Markdown";
import { useChannel, useForceUpdate } from "../../revoltjs/hooks";
import { getChannelName } from "../../revoltjs/util";
interface Props {
channel_id: string;
channel: Channel;
onClose: () => void;
}
export function ChannelInfo({ channel_id, onClose }: Props) {
const ctx = useForceUpdate();
const channel = useChannel(channel_id, ctx);
if (!channel) return null;
if (channel.channel_type === "DirectMessage" || channel.channel_type === 'SavedMessages') {
export const ChannelInfo = observer(({ channel, onClose }: Props) => {
if (
channel.channel_type === "DirectMessage" ||
channel.channel_type === "SavedMessages"
) {
onClose();
return null;
}
......@@ -24,15 +27,15 @@ export function ChannelInfo({ channel_id, onClose }: Props) {
<Modal visible={true} onClose={onClose}>
<div className={styles.info}>
<div className={styles.header}>
<h1>{ getChannelName(ctx.client, channel, true) }</h1>
<h1>{getChannelName(channel, true)}</h1>
<div onClick={onClose}>
<X size={36} />
</div>
</div>
<p>
<Markdown content={channel.description} />
<Markdown content={channel.description!} />
</p>
</div>
</Modal>
);
}
});
.viewer {
display: flex;
flex-direction: column;
border-end-end-radius: 4px;
border-end-start-radius: 4px;
overflow: hidden;
img {
width: auto;
height: auto;
max-width: 90vw;
max-height: 90vh;
max-height: 75vh;
border-bottom: thin solid var(--tertiary-foreground);
object-fit: contain;
}
}
/* eslint-disable react-hooks/rules-of-hooks */
import { Attachment, AttachmentMetadata } from "revolt-api/types/Autumn";
import { EmbedImage } from "revolt-api/types/January";
import styles from "./ImageViewer.module.scss";
import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions";
import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
import Modal from "../../../components/ui/Modal";
import { useContext, useEffect } from "preact/hooks";
import { AppContext } from "../../revoltjs/RevoltClient";
import { Attachment, EmbedImage } from "revolt.js/dist/api/objects";
import { useClient } from "../../revoltjs/RevoltClient";
interface Props {
onClose: () => void;
......@@ -10,36 +16,45 @@ interface Props {
attachment?: Attachment;
}
export function ImageViewer({ attachment, embed, onClose }: Props) {
if (attachment && attachment.metadata.type !== "Image") return null;
const client = useContext(AppContext);
type ImageMetadata = AttachmentMetadata & { type: "Image" };
useEffect(() => {
function keyDown(e: KeyboardEvent) {
if (e.key === "Escape") {
onClose();
}
}
export function ImageViewer({ attachment, embed, onClose }: Props) {
if (attachment && attachment.metadata.type !== "Image") {
console.warn(
`Attempted to use a non valid attatchment type in the image viewer: ${attachment.metadata.type}`,
);
return null;
}
document.body.addEventListener("keydown", keyDown);
return () => document.body.removeEventListener("keydown", keyDown);
}, []);
const client = useClient();
return (
<Modal visible={true} onClose={onClose} noBackground>
<div className={styles.viewer}>
{ attachment &&
{attachment && (
<>
<img src={client.generateFileURL(attachment)} />
{/*<AttachmentActions attachment={attachment} />*/}
<img
loading="eager"
src={client.generateFileURL(attachment)}
width={(attachment.metadata as ImageMetadata).width}
height={
(attachment.metadata as ImageMetadata).height
}
/>
<AttachmentActions attachment={attachment} />
</>
}
{ embed &&
)}
{embed && (
<>
{/*<img src={proxyImage(embed.url)} />*/}
{/*<EmbedMediaActions embed={embed} />*/}
<img
loading="eager"
src={client.proxyFile(embed.url)}
width={embed.width}
height={embed.height}
/>
<EmbedMediaActions embed={embed} />
</>
}
)}
</div>
</Modal>
);
......
import { SubmitHandler, useForm } from "react-hook-form";
import { Text } from "preact-i18n";
import { useForm } from "react-hook-form";
import Modal from "../../../components/ui/Modal";
import { takeError } from "../../revoltjs/util";
import { useContext, useState } from "preact/hooks";
import FormField from '../../../pages/login/FormField';
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;
field: "username" | "email" | "password";
}
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 function ModifyAccountModal({ onClose, field }: Props) {
const client = useContext(AppContext);
const { handleSubmit, register, errors } = useForm();
const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined);
async function onSubmit({
const onSubmit: SubmitHandler<FormInputs> = async ({
password,
new_username,
new_email,
new_password
}: {
password: string;
new_username: string;
new_email: string;
new_password: string;
}) {
new_password,
}) => {
try {
if (field === "email") {
await client.req("POST", "/auth/change/email", {
password,
new_email
new_email,
});
onClose();
} else if (field === "password") {
await client.req("POST", "/auth/change/password", {
password,
new_password
new_password,
});
onClose();
} else if (field === "username") {
await client.req("PATCH", "/users/id/username", {
username: new_username,
password
password,
});
onClose();
}
} catch (err) {
setError(takeError(err));
}
}
};
return (
<Modal
......@@ -62,20 +71,27 @@ export function ModifyAccountModal({ onClose, field }: Props) {
{
confirmation: true,
onClick: handleSubmit(onSubmit),
text:
children:
field === "email" ? (
<Text id="app.special.modals.actions.send_email" />
) : (
<Text id="app.special.modals.actions.update" />
)
),
},
{
onClick: onClose,
text: <Text id="app.special.modals.actions.close" />
}
]}
>
<form onSubmit={handleSubmit(onSubmit) as any}>
children: <Text id="app.special.modals.actions.close" />,
},
]}>
{/* 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"
......
import { observer } from "mobx-react-lite";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./UserPicker.module.scss";
import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
import { Friend } from "../../../pages/friends/Friend";
interface Props {
users: User[];
onClose: () => void;
}
export const PendingRequests = observer(({ users, onClose }: Props) => {
return (
<Modal
visible={true}
title={<Text id="app.special.friends.pending" />}
onClose={onClose}>
<div className={styles.list}>
{users.map((x) => (
<Friend user={x!} key={x!._id} />
))}
</div>
</Modal>
);
});
......@@ -4,7 +4,6 @@
max-height: 360px;
overflow-y: scroll;
// ! FIXME: very temporary code
> label {
> span {
align-items: flex-start !important;
......@@ -18,4 +17,4 @@
}
}
}
}
\ No newline at end of file
}
import { RelationshipStatus } from "revolt-api/types/Users";
import styles from "./UserPicker.module.scss";
import { Text } from "preact-i18n";
import { useState } from "preact/hooks";
import styles from "./UserPicker.module.scss";
import { useUsers } from "../../revoltjs/hooks";
import UserCheckbox from "../../../components/common/user/UserCheckbox";
import Modal from "../../../components/ui/Modal";
import { User, Users } from "revolt.js/dist/api/objects";
import UserCheckbox from "../../../components/common/UserCheckbox";
import { useClient } from "../../revoltjs/RevoltClient";
interface Props {
omit?: string[];
......@@ -16,7 +19,7 @@ export function UserPicker(props: Props) {
const [selected, setSelected] = useState<string[]>([]);
const omit = [...(props.omit || []), "00000000000000000000000000"];
const users = useUsers();
const client = useClient();
return (
<Modal
......@@ -25,34 +28,29 @@ export function UserPicker(props: Props) {
onClose={props.onClose}
actions={[
{
text: <Text id="app.special.modals.actions.ok" />,
onClick: () => props.callback(selected).then(props.onClose)
}
]}
>
children: <Text id="app.special.modals.actions.ok" />,
onClick: () => props.callback(selected).then(props.onClose),
},
]}>
<div className={styles.list}>
{(users.filter(
x =>
x &&
x.relationship === Users.Relationship.Friend &&
!omit.includes(x._id)
) as User[])
.map(x => {
return {
...x,
selected: selected.includes(x._id)
};
})
.map(x => (
{[...client.users.values()]
.filter(
(x) =>
x &&
x.relationship === RelationshipStatus.Friend &&
!omit.includes(x._id),
)
.map((x) => (
<UserCheckbox
key={x._id}
user={x}
checked={x.selected}
onChange={v => {
checked={selected.includes(x._id)}
onChange={(v) => {
if (v) {
setSelected([...selected, x._id]);
} else {
setSelected(
selected.filter(y => y !== x._id)
selected.filter((y) => y !== x._id),
);
}
}}
......