mirror of
https://github.com/stoatchat/for-legacy-web.git
synced 2026-03-06 17:11:55 +00:00
Port modal / popover context.
This commit is contained in:
@@ -2,20 +2,23 @@ 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 Locale from "./Locale";
|
||||
import Theme from "./Theme";
|
||||
|
||||
export default function Context({ children }: { children: Children }) {
|
||||
return (
|
||||
<BrowserRouter>
|
||||
<State>
|
||||
<ClientContext>
|
||||
<Locale>
|
||||
<Theme>{children}</Theme>
|
||||
</Locale>
|
||||
</ClientContext>
|
||||
</State>
|
||||
</BrowserRouter>
|
||||
<State>
|
||||
<Locale>
|
||||
<Intermediate>
|
||||
<BrowserRouter>
|
||||
<ClientContext>
|
||||
<Theme>{children}</Theme>
|
||||
</ClientContext>
|
||||
</BrowserRouter>
|
||||
</Intermediate>
|
||||
</Locale>
|
||||
</State>
|
||||
);
|
||||
}
|
||||
|
||||
133
src/context/intermediate/Intermediate.tsx
Normal file
133
src/context/intermediate/Intermediate.tsx
Normal file
@@ -0,0 +1,133 @@
|
||||
import { Attachment, Channels, EmbedImage, Servers } from "revolt.js/dist/api/objects";
|
||||
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
|
||||
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';
|
||||
|
||||
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" } |
|
||||
{ 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>;
|
||||
};
|
||||
|
||||
export const IntermediateContext = createContext({
|
||||
screen: { id: "none" } as Screen,
|
||||
focusTaken: false
|
||||
});
|
||||
|
||||
export const IntermediateActionsContext = createContext({
|
||||
openScreen: (screen: Screen) => {},
|
||||
writeClipboard: (text: string) => {}
|
||||
});
|
||||
|
||||
interface Props {
|
||||
children: Children;
|
||||
}
|
||||
|
||||
export default function Intermediate(props: Props) {
|
||||
const [screen, openScreen] = useState<Screen>({ id: "none" });
|
||||
const history = useHistory();
|
||||
|
||||
const value = {
|
||||
screen,
|
||||
focusTaken: screen.id !== 'none'
|
||||
};
|
||||
|
||||
const actions = useMemo(() => {
|
||||
return {
|
||||
openScreen: (screen: Screen) => openScreen(screen),
|
||||
writeClipboard: (text: string) => {
|
||||
if (navigator.clipboard) {
|
||||
navigator.clipboard.writeText(text);
|
||||
} else {
|
||||
actions.openScreen({ id: "clipboard", text });
|
||||
}
|
||||
}
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
// 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);
|
||||
|
||||
return () => {
|
||||
// InternalEventEmitter.removeListener("openProfile", openProfile);
|
||||
// InternalEventEmitter.removeListener("navigate", navigate);
|
||||
};
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<IntermediateContext.Provider value={value}>
|
||||
<IntermediateActionsContext.Provider value={actions}>
|
||||
{props.children}
|
||||
<Modals
|
||||
{...value}
|
||||
{...actions}
|
||||
key={
|
||||
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);
|
||||
|
||||
return false;
|
||||
}}
|
||||
/>*/}
|
||||
</IntermediateActionsContext.Provider>
|
||||
</IntermediateContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export const useIntermediate = () => useContext(IntermediateActionsContext);
|
||||
41
src/context/intermediate/Modals.tsx
Normal file
41
src/context/intermediate/Modals.tsx
Normal file
@@ -0,0 +1,41 @@
|
||||
import { Screen } from "./Intermediate";
|
||||
|
||||
import { ErrorModal } from "./modals/Error";
|
||||
import { SignedOutModal } from "./modals/SignedOut";
|
||||
import { ClipboardModal } from "./modals/Clipboard";
|
||||
import { OnboardingModal } from "./modals/Onboarding";
|
||||
import { ModifyAccountModal } from "./modals/ModifyAccount";
|
||||
import { InputModal, SpecialInputModal } from "./modals/Input";
|
||||
import { PromptModal, SpecialPromptModal } from "./modals/Prompt";
|
||||
|
||||
export interface Props {
|
||||
screen: Screen;
|
||||
openScreen: (id: any) => void;
|
||||
}
|
||||
|
||||
export default function Modals({ screen, openScreen }: Props) {
|
||||
const onClose = () => openScreen({ id: "none" });
|
||||
|
||||
switch (screen.id) {
|
||||
case "_prompt":
|
||||
return <PromptModal onClose={onClose} {...screen} />;
|
||||
case "special_prompt":
|
||||
return <SpecialPromptModal onClose={onClose} {...screen} />;
|
||||
case "_input":
|
||||
return <InputModal onClose={onClose} {...screen} />;
|
||||
case "special_input":
|
||||
return <SpecialInputModal onClose={onClose} {...screen} />;
|
||||
case "error":
|
||||
return <ErrorModal onClose={onClose} {...screen} />;
|
||||
case "signed_out":
|
||||
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} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
27
src/context/intermediate/Popovers.tsx
Normal file
27
src/context/intermediate/Popovers.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { IntermediateContext, useIntermediate } from "./Intermediate";
|
||||
import { useContext } from "preact/hooks";
|
||||
|
||||
import { UserPicker } from "./popovers/UserPicker";
|
||||
import { UserProfile } from "./popovers/UserProfile";
|
||||
import { ImageViewer } from "./popovers/ImageViewer";
|
||||
import { ChannelInfo } from "./popovers/ChannelInfo";
|
||||
|
||||
export default function Popovers() {
|
||||
const { screen } = useContext(IntermediateContext);
|
||||
const { openScreen } = useIntermediate();
|
||||
|
||||
const onClose = () => openScreen({ id: "none" });
|
||||
|
||||
switch (screen.id) {
|
||||
case "profile":
|
||||
return <UserProfile {...screen} onClose={onClose} />;
|
||||
case "user_picker":
|
||||
return <UserPicker {...screen} onClose={onClose} />;
|
||||
case "image_viewer":
|
||||
return <ImageViewer {...screen} onClose={onClose} />;
|
||||
case "channel_info":
|
||||
return <ChannelInfo {...screen} onClose={onClose} />;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
32
src/context/intermediate/modals/Clipboard.tsx
Normal file
32
src/context/intermediate/modals/Clipboard.tsx
Normal file
@@ -0,0 +1,32 @@
|
||||
import { Text } from "preact-i18n";
|
||||
import Modal from "../../../components/ui/Modal";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
text: string;
|
||||
}
|
||||
|
||||
export function ClipboardModal({ onClose, text }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
onClose={onClose}
|
||||
title={<Text id="app.special.modals.clipboard.unavailable" />}
|
||||
actions={[
|
||||
{
|
||||
onClick: onClose,
|
||||
confirmation: true,
|
||||
text: <Text id="app.special.modals.actions.close" />
|
||||
}
|
||||
]}
|
||||
>
|
||||
{location.protocol !== "https:" && (
|
||||
<p>
|
||||
<Text id="app.special.modals.clipboard.https" />
|
||||
</p>
|
||||
)}
|
||||
<Text id="app.special.modals.clipboard.copy" />{" "}
|
||||
<code style={{ userSelect: "all" }}>{text}</code>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
30
src/context/intermediate/modals/Error.tsx
Normal file
30
src/context/intermediate/modals/Error.tsx
Normal file
@@ -0,0 +1,30 @@
|
||||
import { Text } from "preact-i18n";
|
||||
import Modal from "../../../components/ui/Modal";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
error: string;
|
||||
}
|
||||
|
||||
export function ErrorModal({ onClose, error }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
onClose={() => false}
|
||||
title={<Text id="app.special.modals.error" />}
|
||||
actions={[
|
||||
{
|
||||
onClick: onClose,
|
||||
confirmation: true,
|
||||
text: <Text id="app.special.modals.actions.ok" />
|
||||
},
|
||||
{
|
||||
onClick: () => location.reload(),
|
||||
text: <Text id="app.special.modals.actions.reload" />
|
||||
}
|
||||
]}
|
||||
>
|
||||
<Text id={`error.${error}`}>{error}</Text>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
149
src/context/intermediate/modals/Input.tsx
Normal file
149
src/context/intermediate/modals/Input.tsx
Normal file
@@ -0,0 +1,149 @@
|
||||
import { ulid } from "ulid";
|
||||
import { Text } from "preact-i18n";
|
||||
import { useHistory } from "react-router";
|
||||
import Modal from "../../../components/ui/Modal";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
question: Children;
|
||||
field: Children;
|
||||
defaultValue?: string;
|
||||
callback: (value: string) => Promise<void>;
|
||||
}
|
||||
|
||||
export function InputModal({
|
||||
onClose,
|
||||
question,
|
||||
field,
|
||||
defaultValue,
|
||||
callback
|
||||
}: Props) {
|
||||
const [processing, setProcessing] = useState(false);
|
||||
const [value, setValue] = useState(defaultValue ?? "");
|
||||
const [error, setError] = useState<undefined | string>(undefined);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
title={question}
|
||||
disabled={processing}
|
||||
actions={[
|
||||
{
|
||||
text: <Text id="app.special.modals.actions.ok" />,
|
||||
onClick: () => {
|
||||
setProcessing(true);
|
||||
callback(value)
|
||||
.then(onClose)
|
||||
.catch(err => {
|
||||
setError(takeError(err));
|
||||
setProcessing(false)
|
||||
})
|
||||
}
|
||||
},
|
||||
{
|
||||
text: <Text id="app.special.modals.actions.cancel" />,
|
||||
onClick: onClose
|
||||
}
|
||||
]}
|
||||
onClose={onClose}
|
||||
>
|
||||
<Overline error={error} block>
|
||||
{field}
|
||||
</Overline>
|
||||
<InputBox
|
||||
value={value}
|
||||
onChange={e => setValue(e.currentTarget.value)}
|
||||
/>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
|
||||
type SpecialProps = { onClose: () => void } & (
|
||||
{ type: "create_group" | "create_server" | "set_custom_status" } |
|
||||
{ type: "create_channel", server: string }
|
||||
)
|
||||
|
||||
export function SpecialInputModal(props: SpecialProps) {
|
||||
const history = useHistory();
|
||||
const client = useContext(AppContext);
|
||||
|
||||
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(
|
||||
{
|
||||
name,
|
||||
nonce: ulid(),
|
||||
users: []
|
||||
}
|
||||
);
|
||||
|
||||
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(
|
||||
{
|
||||
name,
|
||||
nonce: ulid()
|
||||
}
|
||||
);
|
||||
|
||||
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 "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
|
||||
}
|
||||
})
|
||||
}
|
||||
/>;
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
120
src/context/intermediate/modals/ModifyAccount.tsx
Normal file
120
src/context/intermediate/modals/ModifyAccount.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
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 Overline from "../../../components/ui/Overline";
|
||||
import { AppContext } from "../../revoltjs/RevoltClient";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
field: "username" | "email" | "password";
|
||||
}
|
||||
|
||||
export function ModifyAccountModal({ onClose, field }: Props) {
|
||||
const client = useContext(AppContext);
|
||||
const { handleSubmit, register, errors } = useForm();
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
async function onSubmit({
|
||||
password,
|
||||
new_username,
|
||||
new_email,
|
||||
new_password
|
||||
}: {
|
||||
password: string;
|
||||
new_username: string;
|
||||
new_email: string;
|
||||
new_password: string;
|
||||
}) {
|
||||
try {
|
||||
if (field === "email") {
|
||||
await client.req("POST", "/auth/change/email", {
|
||||
password,
|
||||
new_email
|
||||
});
|
||||
onClose();
|
||||
} else if (field === "password") {
|
||||
await client.req("POST", "/auth/change/password", {
|
||||
password,
|
||||
new_password
|
||||
});
|
||||
onClose();
|
||||
} else if (field === "username") {
|
||||
await client.req("PATCH", "/users/id/username", {
|
||||
username: new_username,
|
||||
password
|
||||
});
|
||||
onClose();
|
||||
}
|
||||
} catch (err) {
|
||||
setError(takeError(err));
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
onClose={onClose}
|
||||
title={<Text id={`app.special.modals.account.change.${field}`} />}
|
||||
actions={[
|
||||
{
|
||||
confirmation: true,
|
||||
onClick: handleSubmit(onSubmit),
|
||||
text:
|
||||
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}>
|
||||
{field === "email" && (
|
||||
<FormField
|
||||
type="email"
|
||||
name="new_email"
|
||||
register={register}
|
||||
showOverline
|
||||
error={errors.new_email?.message}
|
||||
/>
|
||||
)}
|
||||
{field === "password" && (
|
||||
<FormField
|
||||
type="password"
|
||||
name="new_password"
|
||||
register={register}
|
||||
showOverline
|
||||
error={errors.new_password?.message}
|
||||
/>
|
||||
)}
|
||||
{field === "username" && (
|
||||
<FormField
|
||||
type="username"
|
||||
name="new_username"
|
||||
register={register}
|
||||
showOverline
|
||||
error={errors.new_username?.message}
|
||||
/>
|
||||
)}
|
||||
<FormField
|
||||
type="current_password"
|
||||
register={register}
|
||||
showOverline
|
||||
error={errors.current_password?.message}
|
||||
/>
|
||||
{error && (
|
||||
<Overline type="error" error={error}>
|
||||
<Text id="app.special.modals.account.failed" />
|
||||
</Overline>
|
||||
)}
|
||||
</form>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
40
src/context/intermediate/modals/Onboarding.module.scss
Normal file
40
src/context/intermediate/modals/Onboarding.module.scss
Normal file
@@ -0,0 +1,40 @@
|
||||
.onboarding {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
|
||||
div {
|
||||
flex: 1;
|
||||
|
||||
&.header {
|
||||
padding: 3em;
|
||||
text-align: center;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&.form {
|
||||
flex-grow: 1;
|
||||
max-width: 420px;
|
||||
|
||||
img {
|
||||
margin: auto;
|
||||
display: block;
|
||||
max-height: 420px;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
button {
|
||||
display: block;
|
||||
margin: 24px 0;
|
||||
margin-left: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
66
src/context/intermediate/modals/Onboarding.tsx
Normal file
66
src/context/intermediate/modals/Onboarding.tsx
Normal file
@@ -0,0 +1,66 @@
|
||||
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 Button from "../../../components/ui/Button";
|
||||
import FormField from "../../../pages/login/FormField";
|
||||
import Preloader from "../../../components/ui/Preloader";
|
||||
|
||||
// import WideSvg from "../../../assets/wide.svg";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
|
||||
}
|
||||
|
||||
export function OnboardingModal({ onClose, callback }: Props) {
|
||||
const { handleSubmit, register } = useForm();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | undefined>(undefined);
|
||||
|
||||
async function onSubmit({ username }: { username: string }) {
|
||||
setLoading(true);
|
||||
callback(username, true)
|
||||
.then(onClose)
|
||||
.catch((err: any) => {
|
||||
setError(takeError(err));
|
||||
setLoading(false);
|
||||
});
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.onboarding}>
|
||||
<div className={styles.header}>
|
||||
<h1>
|
||||
<Text id="app.special.modals.onboarding.welcome" />
|
||||
</h1>
|
||||
</div>
|
||||
<div className={styles.form}>
|
||||
{loading ? (
|
||||
<Preloader />
|
||||
) : (
|
||||
<>
|
||||
<p>
|
||||
<Text id="app.special.modals.onboarding.pick" />
|
||||
</p>
|
||||
<form onSubmit={handleSubmit(onSubmit) as any}>
|
||||
<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>
|
||||
);
|
||||
}
|
||||
18
src/context/intermediate/modals/Prompt.module.scss
Normal file
18
src/context/intermediate/modals/Prompt.module.scss
Normal file
@@ -0,0 +1,18 @@
|
||||
.invite {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
code {
|
||||
padding: 1em;
|
||||
user-select: all;
|
||||
font-size: 1.4em;
|
||||
text-align: center;
|
||||
font-family: "Fira Mono";
|
||||
}
|
||||
}
|
||||
|
||||
.column {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: column;
|
||||
}
|
||||
234
src/context/intermediate/modals/Prompt.tsx
Normal file
234
src/context/intermediate/modals/Prompt.tsx
Normal file
@@ -0,0 +1,234 @@
|
||||
import { Text } from "preact-i18n";
|
||||
import styles from './Prompt.module.scss';
|
||||
import { Children } from "../../../types/Preact";
|
||||
import { IntermediateContext, useIntermediate } from "../Intermediate";
|
||||
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 { AppContext } from "../../revoltjs/RevoltClient";
|
||||
import { takeError } from "../../revoltjs/util";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
question: Children;
|
||||
content?: Children;
|
||||
disabled?: boolean;
|
||||
actions: Action[];
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export function PromptModal({ onClose, question, content, actions, disabled, error }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
title={question}
|
||||
actions={actions}
|
||||
onClose={onClose}
|
||||
disabled={disabled}>
|
||||
{ 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 }
|
||||
)
|
||||
|
||||
export function SpecialPromptModal(props: SpecialProps) {
|
||||
const client = useContext(AppContext);
|
||||
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_message':
|
||||
case 'delete_channel': {
|
||||
const EVENTS = {
|
||||
'close_dm': 'confirm_close_dm',
|
||||
'delete_server': 'confirm_delete',
|
||||
'delete_channel': 'confirm_delete',
|
||||
'delete_message': 'confirm_delete_message',
|
||||
'leave_group': 'confirm_leave',
|
||||
'leave_server': 'confirm_leave'
|
||||
};
|
||||
|
||||
let event = EVENTS[props.type];
|
||||
let name = props.type === 'close_dm' ? client.users.get(client.channels.getRecipient(props.target._id))?.username :
|
||||
props.type === 'delete_message' ? undefined : props.target.name;
|
||||
|
||||
return (
|
||||
<PromptModal
|
||||
onClose={onClose}
|
||||
question={<Text
|
||||
id={props.type === 'delete_message' ? 'app.context_menu.delete_message' : `app.special.modals.prompt.${event}`}
|
||||
fields={{ name }}
|
||||
/>}
|
||||
actions={[
|
||||
{
|
||||
confirmation: true,
|
||||
style: 'contrast-error',
|
||||
text: <Text id="app.special.modals.actions.delete" />,
|
||||
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 if (props.type === 'delete_message') {
|
||||
await client.channels.deleteMessage(props.target.channel, props.target._id);
|
||||
} else {
|
||||
await client.servers.delete(props.target._id);
|
||||
}
|
||||
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(takeError(err));
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ text: <Text id="app.special.modals.actions.cancel" />, onClick: onClose }
|
||||
]}
|
||||
content={<Text id={`app.special.modals.prompt.${event}_long`} />}
|
||||
disabled={processing}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case "create_invite": {
|
||||
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)))
|
||||
.finally(() => setProcessing(false));
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<PromptModal
|
||||
onClose={onClose}
|
||||
question={<Text id={`app.context_menu.create_invite`} />}
|
||||
actions={[
|
||||
{
|
||||
text: <Text id="app.special.modals.actions.ok" />,
|
||||
confirmation: true,
|
||||
onClick: onClose
|
||||
},
|
||||
{
|
||||
text: <Text id="app.context_menu.copy_link" />,
|
||||
onClick: () => writeClipboard(`${window.location.protocol}//${window.location.host}/invite/${code}`)
|
||||
}
|
||||
]}
|
||||
content={
|
||||
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>
|
||||
}
|
||||
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" />,
|
||||
style: 'contrast-error',
|
||||
confirmation: true,
|
||||
onClick: async () => {
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
await client.servers.members.kickMember(props.target._id, props.user);
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(takeError(err));
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ 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>}
|
||||
disabled={processing}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
case "ban_member": {
|
||||
const [ reason, setReason ] = useState<string | undefined>(undefined);
|
||||
const user = client.users.get(props.user);
|
||||
|
||||
return (
|
||||
<PromptModal
|
||||
onClose={onClose}
|
||||
question={<Text id={`app.context_menu.ban_member`} />}
|
||||
actions={[
|
||||
{
|
||||
text: <Text id="app.special.modals.actions.ban" />,
|
||||
style: 'contrast-error',
|
||||
confirmation: true,
|
||||
onClick: async () => {
|
||||
setProcessing(true);
|
||||
|
||||
try {
|
||||
await client.servers.banUser(props.target._id, props.user, { reason });
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(takeError(err));
|
||||
setProcessing(false);
|
||||
}
|
||||
}
|
||||
},
|
||||
{ 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>}
|
||||
disabled={processing}
|
||||
error={error}
|
||||
/>
|
||||
)
|
||||
}
|
||||
default: return null;
|
||||
}
|
||||
}
|
||||
23
src/context/intermediate/modals/SignedOut.tsx
Normal file
23
src/context/intermediate/modals/SignedOut.tsx
Normal file
@@ -0,0 +1,23 @@
|
||||
import { Text } from "preact-i18n";
|
||||
import Modal from "../../../components/ui/Modal";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export function SignedOutModal({ onClose }: Props) {
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
onClose={onClose}
|
||||
title={<Text id="app.special.modals.signed_out" />}
|
||||
actions={[
|
||||
{
|
||||
onClick: onClose,
|
||||
confirmation: true,
|
||||
text: <Text id="app.special.modals.actions.ok" />
|
||||
}
|
||||
]}
|
||||
/>
|
||||
);
|
||||
}
|
||||
16
src/context/intermediate/popovers/ChannelInfo.module.scss
Normal file
16
src/context/intermediate/popovers/ChannelInfo.module.scss
Normal file
@@ -0,0 +1,16 @@
|
||||
.info {
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-direction: row;
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
flex-grow: 1;
|
||||
}
|
||||
|
||||
div {
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
}
|
||||
38
src/context/intermediate/popovers/ChannelInfo.tsx
Normal file
38
src/context/intermediate/popovers/ChannelInfo.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import { X } from "@styled-icons/feather";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
channel_id: string;
|
||||
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') {
|
||||
onClose();
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal visible={true} onClose={onClose}>
|
||||
<div className={styles.info}>
|
||||
<div className={styles.header}>
|
||||
<h1>{ getChannelName(ctx.client, channel, [ ], true) }</h1>
|
||||
<div onClick={onClose}>
|
||||
<X size={36} />
|
||||
</div>
|
||||
</div>
|
||||
<p>
|
||||
<Markdown content={channel.description} />
|
||||
</p>
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
.viewer {
|
||||
img {
|
||||
max-width: 90vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
}
|
||||
46
src/context/intermediate/popovers/ImageViewer.tsx
Normal file
46
src/context/intermediate/popovers/ImageViewer.tsx
Normal file
@@ -0,0 +1,46 @@
|
||||
import styles from "./ImageViewer.module.scss";
|
||||
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";
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
embed?: EmbedImage;
|
||||
attachment?: Attachment;
|
||||
}
|
||||
|
||||
export function ImageViewer({ attachment, embed, onClose }: Props) {
|
||||
if (attachment && attachment.metadata.type !== "Image") return null;
|
||||
const client = useContext(AppContext);
|
||||
|
||||
useEffect(() => {
|
||||
function keyDown(e: KeyboardEvent) {
|
||||
if (e.key === "Escape") {
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
document.body.addEventListener("keydown", keyDown);
|
||||
return () => document.body.removeEventListener("keydown", keyDown);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<Modal visible={true} onClose={onClose} noBackground>
|
||||
<div className={styles.viewer}>
|
||||
{ attachment &&
|
||||
<>
|
||||
<img src={client.generateFileURL(attachment)} />
|
||||
{/*<AttachmentActions attachment={attachment} />*/}
|
||||
</>
|
||||
}
|
||||
{ embed &&
|
||||
<>
|
||||
{/*<img src={proxyImage(embed.url)} />*/}
|
||||
{/*<EmbedMediaActions embed={embed} />*/}
|
||||
</>
|
||||
}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
21
src/context/intermediate/popovers/UserPicker.module.scss
Normal file
21
src/context/intermediate/popovers/UserPicker.module.scss
Normal file
@@ -0,0 +1,21 @@
|
||||
.list {
|
||||
width: 400px;
|
||||
max-width: 100%;
|
||||
max-height: 360px;
|
||||
overflow-y: scroll;
|
||||
|
||||
// ! FIXME: very temporary code
|
||||
> label {
|
||||
> span {
|
||||
align-items: flex-start !important;
|
||||
> span {
|
||||
display: flex;
|
||||
padding: 4px;
|
||||
flex-direction: row;
|
||||
gap: 10px;
|
||||
justify-content: flex-start;
|
||||
align-items: center;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
64
src/context/intermediate/popovers/UserPicker.tsx
Normal file
64
src/context/intermediate/popovers/UserPicker.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
import { Text } from "preact-i18n";
|
||||
import { useState } from "preact/hooks";
|
||||
import styles from "./UserPicker.module.scss";
|
||||
import { useUsers } from "../../revoltjs/hooks";
|
||||
import Modal from "../../../components/ui/Modal";
|
||||
import { User, Users } from "revolt.js/dist/api/objects";
|
||||
import UserCheckbox from "../../../components/common/UserCheckbox";
|
||||
|
||||
interface Props {
|
||||
omit?: string[];
|
||||
onClose: () => void;
|
||||
callback: (users: string[]) => Promise<void>;
|
||||
}
|
||||
|
||||
export function UserPicker(props: Props) {
|
||||
const [selected, setSelected] = useState<string[]>([]);
|
||||
const omit = [...(props.omit || []), "00000000000000000000000000"];
|
||||
|
||||
const users = useUsers();
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible={true}
|
||||
title={<Text id="app.special.popovers.user_picker.select" />}
|
||||
onClose={props.onClose}
|
||||
actions={[
|
||||
{
|
||||
text: <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 => (
|
||||
<UserCheckbox
|
||||
user={x}
|
||||
checked={x.selected}
|
||||
onChange={v => {
|
||||
if (v) {
|
||||
setSelected([...selected, x._id]);
|
||||
} else {
|
||||
setSelected(
|
||||
selected.filter(y => y !== x._id)
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
165
src/context/intermediate/popovers/UserProfile.module.scss
Normal file
165
src/context/intermediate/popovers/UserProfile.module.scss
Normal file
@@ -0,0 +1,165 @@
|
||||
.modal {
|
||||
height: 460px;
|
||||
display: flex;
|
||||
padding: 0 !important;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.header {
|
||||
background-size: cover;
|
||||
border-radius: 8px 8px 0 0;
|
||||
background-position: center;
|
||||
|
||||
&[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;
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.tabs {
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
padding: 0 1.5em;
|
||||
font-size: .875rem;
|
||||
|
||||
> div {
|
||||
padding: 8px;
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
transition: border-bottom .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;
|
||||
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 8px 8px;
|
||||
|
||||
.empty {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.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: 15px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.badges {
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
margin-top: 4px;
|
||||
flex-direction: row;
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
cursor: pointer;
|
||||
}
|
||||
}
|
||||
|
||||
.entries {
|
||||
gap: 8px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
|
||||
a {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.entry {
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 12px;
|
||||
display: flex;
|
||||
cursor: pointer;
|
||||
border-radius: 4px;
|
||||
align-items: center;
|
||||
color: var(--secondary-foreground);
|
||||
background-color: var(--secondary-background);
|
||||
transition: background-color .1s;
|
||||
|
||||
&:hover {
|
||||
background-color: var(--primary-background);
|
||||
}
|
||||
|
||||
img {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
span {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
white-space: nowrap;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
}
|
||||
}
|
||||
341
src/context/intermediate/popovers/UserProfile.tsx
Normal file
341
src/context/intermediate/popovers/UserProfile.tsx
Normal file
@@ -0,0 +1,341 @@
|
||||
import Modal from "../../../components/ui/Modal";
|
||||
import { Localizer, Text } from "preact-i18n";
|
||||
import styles from "./UserProfile.module.scss";
|
||||
import Preloader from "../../../components/ui/Preloader";
|
||||
import { Route } from "revolt.js/dist/api/routes";
|
||||
import { Users } from "revolt.js/dist/api/objects";
|
||||
import { IntermediateContext, useIntermediate } from "../Intermediate";
|
||||
import { Globe, Mail, Edit, UserPlus, Shield } from "@styled-icons/feather";
|
||||
import { Link, useHistory } from "react-router-dom";
|
||||
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks";
|
||||
import { decodeTime } from "ulid";
|
||||
import { CashStack } from "@styled-icons/bootstrap";
|
||||
import { AppContext, ClientStatus, StatusContext } from "../../revoltjs/RevoltClient";
|
||||
import { useChannels, useForceUpdate, useUser, useUsers } from "../../revoltjs/hooks";
|
||||
import UserIcon from '../../../components/common/UserIcon';
|
||||
import UserStatus from '../../../components/common/UserStatus';
|
||||
import Tooltip from '../../../components/common/Tooltip';
|
||||
import ChannelIcon from '../../../components/common/ChannelIcon';
|
||||
import Markdown from '../../../components/markdown/Markdown';
|
||||
|
||||
interface Props {
|
||||
user_id: string;
|
||||
dummy?: boolean;
|
||||
onClose: () => void;
|
||||
dummyProfile?: Users.Profile;
|
||||
}
|
||||
|
||||
enum Badges {
|
||||
Developer = 1,
|
||||
Translator = 2,
|
||||
Supporter = 4,
|
||||
ResponsibleDisclosure = 8,
|
||||
EarlyAdopter = 256
|
||||
}
|
||||
|
||||
export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) {
|
||||
const { writeClipboard } = useIntermediate();
|
||||
|
||||
const [profile, setProfile] = useState<undefined | null | Users.Profile>(
|
||||
undefined
|
||||
);
|
||||
const [mutual, setMutual] = useState<
|
||||
undefined | null | Route<"GET", "/users/id/mutual">["response"]
|
||||
>(undefined);
|
||||
|
||||
const client = useContext(AppContext);
|
||||
const status = useContext(StatusContext);
|
||||
const [tab, setTab] = useState("profile");
|
||||
const history = useHistory();
|
||||
|
||||
const ctx = useForceUpdate();
|
||||
const all_users = useUsers(undefined, ctx);
|
||||
const channels = useChannels(undefined, ctx);
|
||||
|
||||
const user = all_users.find(x => x!._id === user_id);
|
||||
const users = mutual?.users ? all_users.filter(x => mutual.users.includes(x!._id)) : undefined;
|
||||
|
||||
if (!user) {
|
||||
useEffect(onClose, []);
|
||||
return null;
|
||||
}
|
||||
|
||||
useLayoutEffect(() => {
|
||||
if (!user_id) return;
|
||||
if (typeof profile !== 'undefined') setProfile(undefined);
|
||||
if (typeof mutual !== 'undefined') setMutual(undefined);
|
||||
}, [user_id]);
|
||||
|
||||
if (dummy) {
|
||||
useLayoutEffect(() => {
|
||||
setProfile(dummyProfile);
|
||||
}, [dummyProfile]);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (dummy) return;
|
||||
if (
|
||||
status === ClientStatus.ONLINE &&
|
||||
typeof mutual === "undefined"
|
||||
) {
|
||||
setMutual(null);
|
||||
client.users
|
||||
.fetchMutual(user_id)
|
||||
.then(data => setMutual(data));
|
||||
}
|
||||
}, [mutual, status]);
|
||||
|
||||
useEffect(() => {
|
||||
if (dummy) return;
|
||||
if (
|
||||
status === ClientStatus.ONLINE &&
|
||||
typeof profile === "undefined"
|
||||
) {
|
||||
setProfile(null);
|
||||
|
||||
// ! FIXME: in the future, also check if mutual guilds
|
||||
// ! maybe just allow mutual group to allow profile viewing
|
||||
/*if (
|
||||
user.relationship === Users.Relationship.Friend ||
|
||||
user.relationship === Users.Relationship.User
|
||||
) {*/
|
||||
client.users
|
||||
.fetchProfile(user_id)
|
||||
.then(data => setProfile(data))
|
||||
.catch(() => {});
|
||||
//}
|
||||
}
|
||||
}, [profile, status]);
|
||||
|
||||
const mutualGroups = channels.filter(
|
||||
channel =>
|
||||
channel?.channel_type === "Group" &&
|
||||
channel.recipients.includes(user_id)
|
||||
);
|
||||
|
||||
const backgroundURL = profile && client.users.getBackgroundURL(profile, { width: 1000 }, true);
|
||||
const badges = (user.badges ?? 0) | (decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0);
|
||||
|
||||
return (
|
||||
<Modal
|
||||
visible
|
||||
border={dummy}
|
||||
onClose={onClose}
|
||||
dontModal={dummy}
|
||||
>
|
||||
<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}')`
|
||||
}}
|
||||
>
|
||||
<div className={styles.profile}>
|
||||
<UserIcon size={80} target={user} status />
|
||||
<div className={styles.details}>
|
||||
<Localizer>
|
||||
<span
|
||||
className={styles.username}
|
||||
onClick={() => writeClipboard(user.username)}>
|
||||
@{user.username}
|
||||
</span>
|
||||
</Localizer>
|
||||
{user.status?.text && (
|
||||
<span className={styles.status}>
|
||||
<UserStatus user={user} />
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{user.relationship === Users.Relationship.Friend && (
|
||||
<Localizer>
|
||||
<Tooltip
|
||||
content={
|
||||
<Text id="app.context_menu.message_user" />
|
||||
}
|
||||
>
|
||||
{/*<IconButton
|
||||
onClick={() => {
|
||||
onClose();
|
||||
history.push(`/open/${user_id}`);
|
||||
}}
|
||||
>*/}
|
||||
<Mail size={30} strokeWidth={1.5} />
|
||||
{/*</IconButton>*/}
|
||||
</Tooltip>
|
||||
</Localizer>
|
||||
)}
|
||||
{user.relationship === Users.Relationship.User && (
|
||||
/*<IconButton
|
||||
onClick={() => {
|
||||
onClose();
|
||||
if (dummy) return;
|
||||
history.push(`/settings/profile`);
|
||||
}}
|
||||
>*/
|
||||
<Edit size={28} strokeWidth={1.5} />
|
||||
/*</IconButton>*/
|
||||
)}
|
||||
{(user.relationship === Users.Relationship.Incoming ||
|
||||
user.relationship === Users.Relationship.None) && (
|
||||
/*<IconButton
|
||||
onClick={() => client.users.addFriend(user.username)}
|
||||
>*/
|
||||
<UserPlus size={28} strokeWidth={1.5} />
|
||||
/*</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 !== Users.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>
|
||||
</div>
|
||||
<div className={styles.content}>
|
||||
{tab === "profile" &&
|
||||
<div>
|
||||
{ !(profile?.content || (badges > 0)) &&
|
||||
<div className={styles.empty}><Text id="app.special.popovers.user_profile.empty" /></div> }
|
||||
{ (badges > 0) && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.badges" /></div> }
|
||||
{ (badges > 0) && (
|
||||
<div className={styles.badges}>
|
||||
<Localizer>
|
||||
{badges & Badges.Developer ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<Text id="app.navigation.tabs.dev" />
|
||||
}
|
||||
>
|
||||
<img src="/assets/badges/developer.svg" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{badges & Badges.Translator ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<Text id="app.special.popovers.user_profile.badges.translator" />
|
||||
}
|
||||
>
|
||||
<img src="/assets/badges/translator.svg" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{badges & Badges.EarlyAdopter ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<Text id="app.special.popovers.user_profile.badges.early_adopter" />
|
||||
}
|
||||
>
|
||||
<img src="/assets/badges/early_adopter.svg" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{badges & Badges.Supporter ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<Text id="app.special.popovers.user_profile.badges.supporter" />
|
||||
}
|
||||
>
|
||||
<CashStack size={32} color="#efab44" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
{badges & Badges.ResponsibleDisclosure ? (
|
||||
<Tooltip
|
||||
content={
|
||||
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
|
||||
}
|
||||
>
|
||||
<Shield size={32} color="gray" />
|
||||
</Tooltip>
|
||||
) : (
|
||||
<></>
|
||||
)}
|
||||
</Localizer>
|
||||
</div>
|
||||
)}
|
||||
{ profile?.content && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.information" /></div> }
|
||||
<Markdown content={profile?.content} />
|
||||
{/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/}
|
||||
</div>}
|
||||
{tab === "friends" &&
|
||||
(users ? (
|
||||
<div className={styles.entries}>
|
||||
{users.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Text id="app.special.popovers.user_profile.no_users" />
|
||||
</div>
|
||||
) : (
|
||||
users.map(
|
||||
x =>
|
||||
x && (
|
||||
//<LinkProfile user_id={x._id}>
|
||||
<div
|
||||
className={styles.entry}
|
||||
key={x._id}
|
||||
>
|
||||
<UserIcon size={32} target={x} />
|
||||
<span>{x.username}</span>
|
||||
</div>
|
||||
//</LinkProfile>
|
||||
)
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<Preloader />
|
||||
))}
|
||||
{tab === "groups" && (
|
||||
<div className={styles.entries}>
|
||||
{mutualGroups.length === 0 ? (
|
||||
<div className={styles.empty}>
|
||||
<Text id="app.special.popovers.user_profile.no_groups" />
|
||||
</div>
|
||||
) : (
|
||||
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>
|
||||
)}
|
||||
</div>
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ReactNode } from "react";
|
||||
import { useContext } from "preact/hooks";
|
||||
import { Redirect } from "react-router-dom";
|
||||
|
||||
import { AppContext } from "./RevoltClient";
|
||||
import { OperationsContext } from "./RevoltClient";
|
||||
|
||||
interface Props {
|
||||
auth?: boolean;
|
||||
@@ -10,7 +10,7 @@ interface Props {
|
||||
}
|
||||
|
||||
export const CheckAuth = (props: Props) => {
|
||||
const { operations } = useContext(AppContext);
|
||||
const operations = useContext(OperationsContext);
|
||||
|
||||
if (props.auth && !operations.ready()) {
|
||||
return <Redirect to="/login" />;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import { openDB } from 'idb';
|
||||
import { Client } from "revolt.js";
|
||||
import { takeError } from "./error";
|
||||
import { takeError } from "./util";
|
||||
import { createContext } from "preact";
|
||||
import { Children } from "../../types/Preact";
|
||||
import { Route } from "revolt.js/dist/api/routes";
|
||||
import { useEffect, useState } from "preact/hooks";
|
||||
import { useEffect, useMemo, useState } from "preact/hooks";
|
||||
import { connectState } from "../../redux/connector";
|
||||
import Preloader from "../../components/ui/Preloader";
|
||||
import { WithDispatcher } from "../../redux/reducers";
|
||||
@@ -30,13 +30,9 @@ export interface ClientOperations {
|
||||
ready: () => boolean;
|
||||
}
|
||||
|
||||
export interface AppState {
|
||||
client: Client;
|
||||
status: ClientStatus;
|
||||
operations: ClientOperations;
|
||||
}
|
||||
|
||||
export const AppContext = createContext<AppState>(undefined as any);
|
||||
export const AppContext = createContext<Client>(undefined as any);
|
||||
export const StatusContext = createContext<ClientStatus>(undefined as any);
|
||||
export const OperationsContext = createContext<ClientOperations>(undefined as any);
|
||||
|
||||
type Props = WithDispatcher & {
|
||||
auth: AuthState;
|
||||
@@ -78,10 +74,8 @@ function Context({ auth, sync, children, dispatcher }: Props) {
|
||||
|
||||
if (status === ClientStatus.INIT) return null;
|
||||
|
||||
const value: AppState = {
|
||||
client,
|
||||
status,
|
||||
operations: {
|
||||
const operations: ClientOperations = useMemo(() => {
|
||||
return {
|
||||
login: async data => {
|
||||
setReconnectDisallowed(true);
|
||||
|
||||
@@ -131,14 +125,14 @@ function Context({ auth, sync, children, dispatcher }: Props) {
|
||||
},
|
||||
loggedIn: () => typeof auth.active !== "undefined",
|
||||
ready: () => (
|
||||
value.operations.loggedIn() &&
|
||||
operations.loggedIn() &&
|
||||
typeof client.user !== "undefined"
|
||||
)
|
||||
}
|
||||
};
|
||||
}, [ client, auth.active ]);
|
||||
|
||||
useEffect(
|
||||
() => registerEvents({ ...value, dispatcher }, setStatus, client),
|
||||
() => registerEvents({ operations, dispatcher }, setStatus, client),
|
||||
[ client ]
|
||||
);
|
||||
|
||||
@@ -155,7 +149,7 @@ function Context({ auth, sync, children, dispatcher }: Props) {
|
||||
return setStatus(ClientStatus.OFFLINE);
|
||||
}
|
||||
|
||||
if (value.operations.ready())
|
||||
if (operations.ready())
|
||||
setStatus(ClientStatus.CONNECTING);
|
||||
|
||||
if (navigator.onLine) {
|
||||
@@ -194,7 +188,7 @@ function Context({ auth, sync, children, dispatcher }: Props) {
|
||||
setStatus(ClientStatus.DISCONNECTED);
|
||||
const error = takeError(err);
|
||||
if (error === "Forbidden") {
|
||||
value.operations.logout(true);
|
||||
operations.logout(true);
|
||||
// openScreen({ id: "signed_out" });
|
||||
} else {
|
||||
// openScreen({ id: "error", error });
|
||||
@@ -217,8 +211,12 @@ function Context({ auth, sync, children, dispatcher }: Props) {
|
||||
}
|
||||
|
||||
return (
|
||||
<AppContext.Provider value={value}>
|
||||
{ children }
|
||||
<AppContext.Provider value={client}>
|
||||
<StatusContext.Provider value={status}>
|
||||
<OperationsContext.Provider value={operations}>
|
||||
{ children }
|
||||
</OperationsContext.Provider>
|
||||
</StatusContext.Provider>
|
||||
</AppContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,18 +0,0 @@
|
||||
export function takeError(
|
||||
error: any
|
||||
): string {
|
||||
const type = error?.response?.data?.type;
|
||||
let id = type;
|
||||
if (!type) {
|
||||
if (error?.response?.status === 403) {
|
||||
return "Unauthorized";
|
||||
} else if (error && (!!error.isAxiosError && !error.response)) {
|
||||
return "NetworkError";
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
return "UnknownError";
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
@@ -2,7 +2,7 @@ import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"
|
||||
import { WithDispatcher } from "../../redux/reducers";
|
||||
import { Client, Message } from "revolt.js/dist";
|
||||
import {
|
||||
AppState,
|
||||
ClientOperations,
|
||||
ClientStatus
|
||||
} from "./RevoltClient";
|
||||
import { StateUpdater } from "preact/hooks";
|
||||
@@ -17,7 +17,7 @@ export function setReconnectDisallowed(allowed: boolean) {
|
||||
export function registerEvents({
|
||||
operations,
|
||||
dispatcher
|
||||
}: AppState & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) {
|
||||
}: { operations: ClientOperations } & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) {
|
||||
const listeners = {
|
||||
connecting: () =>
|
||||
operations.ready() && setStatus(ClientStatus.CONNECTING),
|
||||
|
||||
@@ -9,7 +9,7 @@ export interface HookContext {
|
||||
}
|
||||
|
||||
export function useForceUpdate(context?: HookContext): HookContext {
|
||||
const { client } = useContext(AppContext);
|
||||
const client = useContext(AppContext);
|
||||
if (context) return context;
|
||||
const [, updateState] = useState({});
|
||||
return { client, forceUpdate: useCallback(() => updateState({}), []) };
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
import { Message } from "revolt.js/dist/api/objects";
|
||||
|
||||
export type MessageObject = Omit<Message, "edited"> & { edited?: string };
|
||||
export function mapMessage(message: Partial<Message>) {
|
||||
const { edited, ...msg } = message;
|
||||
return {
|
||||
...msg,
|
||||
edited: edited?.$date,
|
||||
} as MessageObject;
|
||||
}
|
||||
49
src/context/revoltjs/util.tsx
Normal file
49
src/context/revoltjs/util.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { Channel, Message, User } from "revolt.js/dist/api/objects";
|
||||
import { Children } from "../../types/Preact";
|
||||
import { Text } from "preact-i18n";
|
||||
import { Client } from "revolt.js";
|
||||
|
||||
export function takeError(
|
||||
error: any
|
||||
): string {
|
||||
const type = error?.response?.data?.type;
|
||||
let id = type;
|
||||
if (!type) {
|
||||
if (error?.response?.status === 403) {
|
||||
return "Unauthorized";
|
||||
} else if (error && (!!error.isAxiosError && !error.response)) {
|
||||
return "NetworkError";
|
||||
}
|
||||
|
||||
console.error(error);
|
||||
return "UnknownError";
|
||||
}
|
||||
|
||||
return id;
|
||||
}
|
||||
|
||||
export function getChannelName(client: Client, channel: Channel, users: User[], prefixType?: boolean): Children {
|
||||
if (channel.channel_type === "SavedMessages")
|
||||
return <Text id="app.navigation.tabs.saved" />;
|
||||
|
||||
if (channel.channel_type === "DirectMessage") {
|
||||
let uid = client.channels.getRecipient(channel._id);
|
||||
|
||||
return <>{prefixType && "@"}{users.find(x => x._id === uid)?.username}</>;
|
||||
}
|
||||
|
||||
if (channel.channel_type === "TextChannel" && prefixType) {
|
||||
return <>#{channel.name}</>;
|
||||
}
|
||||
|
||||
return <>{channel.name}</>;
|
||||
}
|
||||
|
||||
export type MessageObject = Omit<Message, "edited"> & { edited?: string };
|
||||
export function mapMessage(message: Partial<Message>) {
|
||||
const { edited, ...msg } = message;
|
||||
return {
|
||||
...msg,
|
||||
edited: edited?.$date,
|
||||
} as MessageObject;
|
||||
}
|
||||
Reference in New Issue
Block a user