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 1221 additions and 931 deletions
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}>
......@@ -45,7 +51,12 @@ export function OnboardingModal({ onClose, callback }: Props) {
<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: var(--monoscape-font);
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 { useHistory } from "react-router-dom";
import Radio from "../../../components/ui/Radio";
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 Modal, { Action } from "../../../components/ui/Modal";
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 Modal, { Action } from "../../../components/ui/Modal";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import { useContext, useEffect, useState } from "preact/hooks";
import UserIcon from "../../../components/common/user/UserIcon";
import Message from "../../../components/common/messaging/Message";
import { TextReact } from "../../../lib/i18n";
import { takeError } from "../../revoltjs/util";
import { useIntermediate } from "../Intermediate";
interface Props {
onClose: () => void;
......@@ -25,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}
......@@ -33,89 +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: "unfriend_user", target: Users.User } |
{ type: "block_user", target: Users.User } |
{ type: "create_channel", target: Servers.Server }
)
| { 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 'unfriend_user':
case 'block_user': {
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', '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']
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];
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 = client.users.get(client.channels.getRecipient(props.target._id))?.username; break;
default: name = props.target.name;
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[0]}`}
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.${event[1]}`} />,
children: (
<Text
id={`app.special.modals.actions.${event[1]}`}
/>
),
onClick: async () => {
setProcessing(true);
try {
switch (props.type) {
case 'unfriend_user':
await client.users.removeFriend(props.target._id); break;
case 'block_user':
await client.users.blockUser(props.target._id); break;
case 'leave_group':
case 'close_dm':
case 'delete_channel':
await client.channels.delete(props.target._id); break;
case 'leave_server':
case 'delete_server':
await client.servers.delete(props.target._id); break;
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();
......@@ -123,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={<TextReact id={`app.special.modals.prompt.${event[0]}_long`} fields={{ name: <b>{ name }</b> }} />}
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
......@@ -187,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
......@@ -257,40 +336,59 @@ 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 client.servers.banUser(props.target._id, props.user, { reason });
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,
},
{ 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={
<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');
case "create_channel": {
const [name, setName] = useState("");
const [type, setType] = useState<"Text" | "Voice">("Text");
const history = useHistory();
return (
......@@ -301,46 +399,67 @@ export function SpecialPromptModal(props: SpecialProps) {
{
confirmation: true,
contrast: true,
text: <Text id="app.special.modals.actions.create" />,
children: (
<Text id="app.special.modals.actions.create" />
),
onClick: async () => {
setProcessing(true);
try {
const channel = await client.servers.createChannel(
props.target._id,
{
const channel =
await props.target.createChannel({
type,
name,
nonce: ulid()
}
nonce: ulid(),
});
history.push(
`/server/${props.target._id}/channel/${channel._id}`,
);
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={<>
<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)} />
</>}
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/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 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 EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions";
import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
import Modal from "../../../components/ui/Modal";
import { useClient } from "../../revoltjs/RevoltClient";
interface Props {
onClose: () => void;
......@@ -12,31 +16,45 @@ interface Props {
attachment?: Attachment;
}
type ImageMetadata = AttachmentMetadata & { type: "Image" };
export function ImageViewer({ attachment, embed, onClose }: Props) {
// ! FIXME: temp code
// ! add proxy function to client
function proxyImage(url: string) {
return 'https://jan.revolt.chat/proxy?url=' + encodeURIComponent(url);
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;
}
if (attachment && attachment.metadata.type !== "Image") return null;
const client = useContext(AppContext);
const client = useClient();
return (
<Modal visible={true} onClose={onClose} noBackground>
<div className={styles.viewer}>
{ attachment &&
{attachment && (
<>
<img src={client.generateFileURL(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)} />
<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 { Text } from "preact-i18n";
import { observer } from "mobx-react-lite";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./UserPicker.module.scss";
import { useUsers } from "../../revoltjs/hooks";
import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
import { Friend } from "../../../pages/friends/Friend";
interface Props {
users: string[];
users: User[];
onClose: () => void;
}
export function PendingRequests({ users: ids, onClose }: Props) {
const users = useUsers(ids);
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
.filter(x => typeof x !== 'undefined')
.map(x => <Friend user={x!} key={x!._id} />) }
{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 Modal from "../../../components/ui/Modal";
import { User, Users } from "revolt.js/dist/api/objects";
import UserCheckbox from "../../../components/common/user/UserCheckbox";
import Modal from "../../../components/ui/Modal";
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),
);
}
}}
......
......@@ -7,7 +7,7 @@
.header {
background-size: cover;
border-radius: 8px 8px 0 0;
border-radius: var(--border-radius) var(--border-radius) 0 0;
background-position: center;
background-color: var(--secondary-background);
......@@ -35,7 +35,7 @@
display: flex;
flex-direction: column;
> * {
* {
min-width: 0;
overflow: hidden;
white-space: nowrap;
......@@ -57,13 +57,13 @@
gap: 8px;
display: flex;
padding: 0 1.5em;
font-size: .875rem;
font-size: 0.875rem;
> div {
padding: 8px;
cursor: pointer;
border-bottom: 2px solid transparent;
transition: border-bottom .3s;
transition: border-bottom 0.3s;
&[data-active="true"] {
border-bottom: 2px solid var(--foreground);
......@@ -81,7 +81,10 @@
height: 100%;
display: flex;
padding: 1em 1.5em;
max-width: 560px;
max-height: 240px;
overflow-y: auto;
flex-direction: column;
background: var(--primary-background);
......@@ -140,11 +143,11 @@
padding: 12px;
display: flex;
cursor: pointer;
border-radius: 4px;
align-items: center;
transition: background-color 0.1s;
color: var(--secondary-foreground);
border-radius: var(--border-radius);
background-color: var(--secondary-background);
transition: background-color .1s;
&:hover {
background-color: var(--primary-background);
......
import { decodeTime } from "ulid";
import { Money } from "@styled-icons/boxicons-regular";
import { Envelope, Edit, UserPlus, Shield } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Link, useHistory } from "react-router-dom";
import { Localizer, Text } from "preact-i18n";
import { Profile, RelationshipStatus } from "revolt-api/types/Users";
import { UserPermission } from "revolt.js/dist/api/permissions";
import { Route } from "revolt.js/dist/api/routes";
import styles from "./UserProfile.module.scss";
import { Localizer, Text } from "preact-i18n";
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks";
import ChannelIcon from "../../../components/common/ChannelIcon";
import Tooltip from "../../../components/common/Tooltip";
import UserIcon from "../../../components/common/user/UserIcon";
import UserStatus from "../../../components/common/user/UserStatus";
import IconButton from "../../../components/ui/IconButton";
import Modal from "../../../components/ui/Modal";
import { Route } from "revolt.js/dist/api/routes";
import { Users } from "revolt.js/dist/api/objects";
import { useIntermediate } from "../Intermediate";
import Preloader from "../../../components/ui/Preloader";
import Tooltip from '../../../components/common/Tooltip';
import IconButton from "../../../components/ui/IconButton";
import Markdown from '../../../components/markdown/Markdown';
import { UserPermission } from "revolt.js/dist/api/permissions";
import UserIcon from '../../../components/common/user/UserIcon';
import ChannelIcon from '../../../components/common/ChannelIcon';
import UserStatus from '../../../components/common/user/UserStatus';
import { Envelope, Edit, UserPlus, Shield, Money } from "@styled-icons/boxicons-regular";
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks";
import { AppContext, ClientStatus, StatusContext } from "../../revoltjs/RevoltClient";
import { useChannels, useForceUpdate, useUserPermission, useUsers } from "../../revoltjs/hooks";
import Markdown from "../../../components/markdown/Markdown";
import {
ClientStatus,
StatusContext,
useClient,
} from "../../revoltjs/RevoltClient";
import { useIntermediate } from "../Intermediate";
interface Props {
user_id: string;
dummy?: boolean;
onClose: () => void;
dummyProfile?: Users.Profile;
onClose?: () => void;
dummyProfile?: Profile;
}
enum Badges {
......@@ -31,301 +38,316 @@ enum Badges {
Translator = 2,
Supporter = 4,
ResponsibleDisclosure = 8,
EarlyAdopter = 256
EarlyAdopter = 256,
}
export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) {
const { openScreen, writeClipboard } = useIntermediate();
export const UserProfile = observer(
({ user_id, onClose, dummy, dummyProfile }: Props) => {
const { openScreen, 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 [profile, setProfile] = useState<undefined | null | Profile>(
undefined,
);
const [mutual, setMutual] = useState<
undefined | null | Route<"GET", "/users/id/mutual">["response"]
>(undefined);
const history = useHistory();
const client = useContext(AppContext);
const status = useContext(StatusContext);
const [tab, setTab] = useState("profile");
const history = useHistory();
const client = useClient();
const status = useContext(StatusContext);
const [tab, setTab] = useState("profile");
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;
}
const user = client.users.get(user_id);
if (!user) {
if (onClose) useEffect(onClose, []);
return null;
}
const permissions = useUserPermission(user!._id, ctx);
const users = mutual?.users.map((id) => client.users.get(id));
useLayoutEffect(() => {
if (!user_id) return;
if (typeof profile !== 'undefined') setProfile(undefined);
if (typeof mutual !== 'undefined') setMutual(undefined);
}, [user_id]);
const mutualGroups = [...client.channels.values()].filter(
(channel) =>
channel?.channel_type === "Group" &&
channel.recipient_ids!.includes(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);
if (!user_id) return;
if (typeof profile !== "undefined") setProfile(undefined);
if (typeof mutual !== "undefined") setMutual(undefined);
// eslint-disable-next-line
}, [user_id]);
if (permissions & UserPermission.ViewProfile) {
client.users
.fetchProfile(user_id)
.then(data => setProfile(data))
.catch(() => {});
useEffect(() => {
if (dummy) {
setProfile(dummyProfile);
}
}
}, [profile, status]);
}, [dummy, dummyProfile]);
const mutualGroups = channels.filter(
channel =>
channel?.channel_type === "Group" &&
channel.recipients.includes(user_id)
);
useEffect(() => {
if (dummy) return;
if (
status === ClientStatus.ONLINE &&
typeof mutual === "undefined"
) {
setMutual(null);
user.fetchMutual().then(setMutual);
}
}, [mutual, status, dummy, user]);
const backgroundURL = profile && client.users.getBackgroundURL(profile, { width: 1000 }, true);
const badges = (user.badges ?? 0) | (decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0);
useEffect(() => {
if (dummy) return;
if (
status === ClientStatus.ONLINE &&
typeof profile === "undefined"
) {
setProfile(null);
return (
<Modal visible
border={dummy}
padding={false}
onClose={onClose}
dontModal={dummy}>
<div
className={styles.header}
data-force={
profile?.background
? "light"
: undefined
if (user.permission & UserPermission.ViewProfile) {
user.fetchProfile().then(setProfile);
}
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>
}
}, [profile, status, dummy, user]);
const backgroundURL =
profile &&
client.generateFileURL(profile.background, { width: 1000 }, true);
const badges = user.badges ?? 0;
return (
<Modal
visible
border={dummy}
padding={false}
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 animate />
<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} tooltip />
</span>
)}
</div>
{user.relationship === RelationshipStatus.Friend && (
<Localizer>
<Tooltip
content={
<Text id="app.context_menu.message_user" />
}>
<IconButton
onClick={() => {
onClose?.();
history.push(`/open/${user_id}`);
}}>
<Envelope size={30} />
</IconButton>
</Tooltip>
</Localizer>
)}
{user.relationship === RelationshipStatus.User && (
<IconButton
onClick={() => {
onClose?.();
if (dummy) return;
history.push(`/settings/profile`);
}}>
<Edit size={28} />
</IconButton>
)}
{(user.relationship === RelationshipStatus.Incoming ||
user.relationship === RelationshipStatus.None) && (
<IconButton onClick={() => user.addFriend()}>
<UserPlus size={28} />
</IconButton>
)}
</div>
{user.relationship === Users.Relationship.Friend && (
<Localizer>
<Tooltip
content={
<Text id="app.context_menu.message_user" />
}
>
<IconButton
onClick={() => {
onClose();
history.push(`/open/${user_id}`);
}}>
<Envelope size={30} />
</IconButton>
</Tooltip>
</Localizer>
)}
{user.relationship === Users.Relationship.User && (
<IconButton
onClick={() => {
onClose();
if (dummy) return;
history.push(`/settings/profile`);
}}>
<Edit size={28} />
</IconButton>
)}
{(user.relationship === Users.Relationship.Incoming ||
user.relationship === Users.Relationship.None) && (
<IconButton onClick={() => client.users.addFriend(user.username)}>
<UserPlus size={28} />
</IconButton>
)}
</div>
<div className={styles.tabs}>
<div
data-active={tab === "profile"}
onClick={() => setTab("profile")}
>
<Text id="app.special.popovers.user_profile.profile" />
<div className={styles.tabs}>
<div
data-active={tab === "profile"}
onClick={() => setTab("profile")}>
<Text id="app.special.popovers.user_profile.profile" />
</div>
{user.relationship !== RelationshipStatus.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>
{ 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" />
}
>
<Money 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 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" />
}>
<Money
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>
)}
{ 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 ? (
{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 && (
<div
onClick={() =>
openScreen({
id: "profile",
user_id: x._id,
})
}
className={styles.entry}
key={x._id}>
<UserIcon
size={32}
target={x}
/>
<span>{x.username}</span>
</div>
),
)
)}
</div>
) : (
<Preloader type="ring" />
))}
{tab === "groups" && (
<div className={styles.entries}>
{users.length === 0 ? (
{mutualGroups.length === 0 ? (
<div className={styles.empty}>
<Text id="app.special.popovers.user_profile.no_users" />
<Text id="app.special.popovers.user_profile.no_groups" />
</div>
) : (
users.map(
x =>
x && (
<div onClick={() => openScreen({ id: 'profile', user_id: x._id })}
className={styles.entry}
key={x._id}>
<UserIcon size={32} target={x} />
<span>{x.username}</span>
</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>
) : (
<Preloader type="ring" />
))}
{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>
);
}
)}
</div>
</Modal>
);
},
);
import { useContext } from "preact/hooks";
import { Redirect } from "react-router-dom";
import { Children } from "../../types/Preact";
import { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { OperationsContext } from "./RevoltClient";
interface Props {
......
......@@ -10,7 +10,7 @@
&.banner {
.image {
border-radius: 4px;
border-radius: var(--border-radius);
}
.modify {
......
import { Text } from "preact-i18n";
import { takeError } from "./util";
import classNames from "classnames";
import { AppContext } from "./RevoltClient";
import styles from './FileUploads.module.scss';
import { Plus } from "@styled-icons/boxicons-regular";
import { Pencil } from "@styled-icons/boxicons-solid";
import Axios, { AxiosRequestConfig } from "axios";
import styles from "./FileUploads.module.scss";
import classNames from "classnames";
import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks";
import Preloader from "../../components/ui/Preloader";
import { determineFileSize } from "../../lib/fileSize";
import IconButton from '../../components/ui/IconButton';
import { Plus, X, XCircle } from "@styled-icons/boxicons-regular";
import { Pencil } from "@styled-icons/boxicons-solid";
import IconButton from "../../components/ui/IconButton";
import Preloader from "../../components/ui/Preloader";
import { useIntermediate } from "../intermediate/Intermediate";
import { AppContext } from "./RevoltClient";
import { takeError } from "./util";
type Props = {
maxFileSize: number
remove: () => Promise<void>
fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners'
maxFileSize: number;
remove: () => Promise<void>;
fileType: "backgrounds" | "icons" | "avatars" | "attachments" | "banners";
} & (
{ behaviour: 'ask', onChange: (file: File) => void } |
{ behaviour: 'upload', onUpload: (id: string) => Promise<void> } |
{ behaviour: 'multi', onChange: (files: File[]) => void, append?: (files: File[]) => void }
) & (
{ style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } |
{ style: 'attachment', attached: boolean, uploading: boolean, cancel: () => void, size?: number }
)
export async function uploadFile(autumnURL: string, tag: string, file: File, config?: AxiosRequestConfig) {
| { behaviour: "ask"; onChange: (file: File) => void }
| { behaviour: "upload"; onUpload: (id: string) => Promise<void> }
| {
behaviour: "multi";
onChange: (files: File[]) => void;
append?: (files: File[]) => void;
}
) &
(
| {
style: "icon" | "banner";
defaultPreview?: string;
previewURL?: string;
width?: number;
height?: number;
}
| {
style: "attachment";
attached: boolean;
uploading: boolean;
cancel: () => void;
size?: number;
}
);
export async function uploadFile(
autumnURL: string,
tag: string,
file: File,
config?: AxiosRequestConfig,
) {
const formData = new FormData();
formData.append("file", file);
const res = await Axios.post(autumnURL + "/" + tag, formData, {
const res = await Axios.post(`${autumnURL}/${tag}`, formData, {
headers: {
"Content-Type": "multipart/form-data"
"Content-Type": "multipart/form-data",
},
...config
...config,
});
return res.data.id;
}
export function grabFiles(maxFileSize: number, cb: (files: File[]) => void, tooLarge: () => void, multiple?: boolean) {
export function grabFiles(
maxFileSize: number,
cb: (files: File[]) => void,
tooLarge: () => void,
multiple?: boolean,
) {
const input = document.createElement("input");
input.type = "file";
input.multiple = multiple ?? false;
input.onchange = async e => {
const files = (e.target as any)?.files;
input.onchange = async (e) => {
const files = (e.currentTarget as HTMLInputElement)?.files;
if (!files) return;
for (let file of files) {
for (const file of files) {
if (file.size > maxFileSize) {
return tooLarge();
}
......@@ -64,65 +95,76 @@ export function FileUploader(props: Props) {
const { openScreen } = useIntermediate();
const client = useContext(AppContext);
const [ uploading, setUploading ] = useState(false);
const [uploading, setUploading] = useState(false);
function onClick() {
if (uploading) return;
grabFiles(maxFileSize, async files => {
setUploading(true);
grabFiles(
maxFileSize,
async (files) => {
setUploading(true);
try {
if (props.behaviour === 'multi') {
props.onChange(files);
} else if (props.behaviour === 'ask') {
props.onChange(files[0]);
} else {
await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, files[0]));
try {
if (props.behaviour === "multi") {
props.onChange(files);
} else if (props.behaviour === "ask") {
props.onChange(files[0]);
} else {
await props.onUpload(
await uploadFile(
client.configuration!.features.autumn.url,
fileType,
files[0],
),
);
}
} catch (err) {
return openScreen({ id: "error", error: takeError(err) });
} finally {
setUploading(false);
}
} catch (err) {
return openScreen({ id: "error", error: takeError(err) });
} finally {
setUploading(false);
}
}, () =>
openScreen({ id: "error", error: "FileTooLarge" }),
props.behaviour === 'multi');
},
() => openScreen({ id: "error", error: "FileTooLarge" }),
props.behaviour === "multi",
);
}
function removeOrUpload() {
if (uploading) return;
if (props.style === 'attachment') {
if (props.style === "attachment") {
if (props.attached) {
props.remove();
} else {
onClick();
}
} else if (props.previewURL) {
props.remove();
} else {
if (props.previewURL) {
props.remove();
} else {
onClick();
}
onClick();
}
}
if (props.behaviour === 'multi' && props.append) {
if (props.behaviour === "multi" && props.append) {
// eslint-disable-next-line
useEffect(() => {
// File pasting.
function paste(e: ClipboardEvent) {
const items = e.clipboardData?.items;
if (typeof items === "undefined") return;
if (props.behaviour !== 'multi' || !props.append) return;
if (props.behaviour !== "multi" || !props.append) return;
let files = [];
const files = [];
for (const item of items) {
if (!item.type.startsWith("text/")) {
const blob = item.getAsFile();
if (blob) {
if (blob.size > props.maxFileSize) {
openScreen({ id: 'error', error: 'FileTooLarge' });
openScreen({
id: "error",
error: "FileTooLarge",
});
}
files.push(blob);
......@@ -143,14 +185,14 @@ export function FileUploader(props: Props) {
// File dropping.
function drop(e: DragEvent) {
e.preventDefault();
if (props.behaviour !== 'multi' || !props.append) return;
if (props.behaviour !== "multi" || !props.append) return;
const dropped = e.dataTransfer?.files;
if (dropped) {
let files = [];
const files = [];
for (const item of dropped) {
if (item.size > props.maxFileSize) {
openScreen({ id: 'error', error: 'FileTooLarge' });
openScreen({ id: "error", error: "FileTooLarge" });
}
files.push(item);
......@@ -169,41 +211,63 @@ export function FileUploader(props: Props) {
document.removeEventListener("dragover", dragover);
document.removeEventListener("drop", drop);
};
}, [ props.append ]);
}, [openScreen, props, props.append]);
}
if (props.style === 'icon' || props.style === 'banner') {
if (props.style === "icon" || props.style === "banner") {
const { style, previewURL, defaultPreview, width, height } = props;
return (
<div className={classNames(styles.uploader,
{ [styles.icon]: style === 'icon',
[styles.banner]: style === 'banner' })}
<div
className={classNames(styles.uploader, {
[styles.icon]: style === "icon",
[styles.banner]: style === "banner",
})}
data-uploading={uploading}>
<div className={styles.image}
style={{ backgroundImage:
style === 'icon' ? `url('${previewURL ?? defaultPreview}')` :
(previewURL ? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')` : 'black'),
width, height
<div
className={styles.image}
style={{
backgroundImage:
style === "icon"
? `url('${previewURL ?? defaultPreview}')`
: previewURL
? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')`
: "black",
width,
height,
}}
onClick={onClick}>
{ uploading ?
{uploading ? (
<div className={styles.uploading}>
<Preloader type="ring" />
</div> :
</div>
) : (
<div className={styles.edit}>
<Pencil size={30} />
</div> }
</div>
)}
</div>
<div className={styles.modify}>
<span onClick={removeOrUpload}>{
uploading ? <Text id="app.main.channel.uploading_file" /> :
props.previewURL ? <Text id="app.settings.actions.remove" /> :
<Text id="app.settings.actions.upload" /> }</span>
<span className={styles.small}><Text id="app.settings.actions.max_filesize" fields={{ filesize: determineFileSize(maxFileSize) }} /></span>
<span onClick={removeOrUpload}>
{uploading ? (
<Text id="app.main.channel.uploading_file" />
) : props.previewURL ? (
<Text id="app.settings.actions.remove" />
) : (
<Text id="app.settings.actions.upload" />
)}
</span>
<span className={styles.small}>
<Text
id="app.settings.actions.max_filesize"
fields={{
filesize: determineFileSize(maxFileSize),
}}
/>
</span>
</div>
</div>
)
} else if (props.style === 'attachment') {
);
} else if (props.style === "attachment") {
const { attached, uploading, cancel, size } = props;
return (
<IconButton
......@@ -211,10 +275,11 @@ export function FileUploader(props: Props) {
if (uploading) return cancel();
if (attached) return remove();
onClick();
}}>
{ uploading ? <XCircle size={size} /> : attached ? <X size={size} /> : <Plus size={size} />}
}}
rotate={uploading || attached ? "45deg" : undefined}>
<Plus size={size} />
</IconButton>
)
);
}
return null;
......
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { Presence, RelationshipStatus } from "revolt-api/types/Users";
import { SYSTEM_USER_ID } from "revolt.js";
import { Message } from "revolt.js/dist/maps/Messages";
import { User } from "revolt.js/dist/maps/Users";
import { decodeTime } from "ulid";
import { SoundContext } from "../Settings";
import { AppContext } from "./RevoltClient";
import { useCallback, useContext, useEffect } from "preact/hooks";
import { useTranslation } from "../../lib/i18n";
import { Users } from "revolt.js/dist/api/objects";
import { useContext, useEffect } from "preact/hooks";
import { connectState } from "../../redux/connector";
import { Message, SYSTEM_USER_ID, User } from "revolt.js";
import {
getNotificationState,
Notifications,
shouldNotify,
} from "../../redux/reducers/notifications";
import { NotificationOptions } from "../../redux/reducers/settings";
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { getNotificationState, Notifications, shouldNotify } from "../../redux/reducers/notifications";
import { SoundContext } from "../Settings";
import { AppContext } from "./RevoltClient";
interface Props {
options?: NotificationOptions;
......@@ -17,11 +27,14 @@ interface Props {
const notifications: { [key: string]: Notification } = {};
async function createNotification(title: string, options: globalThis.NotificationOptions) {
async function createNotification(
title: string,
options: globalThis.NotificationOptions,
) {
try {
return new Notification(title, options);
} catch (err) {
let sw = await navigator.serviceWorker.getRegistration();
const sw = await navigator.serviceWorker.getRegistration();
sw?.showNotification(title, options);
}
}
......@@ -38,167 +51,225 @@ function Notifier({ options, notifs }: Props) {
const history = useHistory();
const playSound = useContext(SoundContext);
async function message(msg: Message) {
if (msg.author === client.user!._id) return;
if (msg.channel === channel_id && document.hasFocus()) return;
if (client.user!.status?.presence === Users.Presence.Busy) return;
const channel = client.channels.get(msg.channel);
const author = client.users.get(msg.author);
if (!channel) return;
if (author?.relationship === Users.Relationship.Blocked) return;
const notifState = getNotificationState(notifs, channel);
if (!shouldNotify(notifState, msg, client.user!._id)) return;
playSound('message');
if (!showNotification) return;
let title;
switch (channel.channel_type) {
case "SavedMessages":
return;
case "DirectMessage":
title = `@${author?.username}`;
break;
case "Group":
if (author?._id === SYSTEM_USER_ID) {
title = channel.name;
} else {
title = `@${author?.username} - ${channel.name}`;
}
break;
case "TextChannel":
const server = client.servers.get(channel.server);
title = `@${author?.username} (#${channel.name}, ${server?.name})`;
break;
default:
title = msg.channel;
break;
}
const message = useCallback(
async (msg: Message) => {
if (msg.author_id === client.user!._id) return;
if (msg.channel_id === channel_id && document.hasFocus()) return;
if (client.user!.status?.presence === Presence.Busy) return;
if (msg.author?.relationship === RelationshipStatus.Blocked) return;
let image;
if (msg.attachments) {
let imageAttachment = msg.attachments.find(x => x.metadata.type === 'Image');
if (imageAttachment) {
image = client.generateFileURL(imageAttachment, { max_side: 720 });
}
}
const notifState = getNotificationState(notifs, msg.channel!);
if (!shouldNotify(notifState, msg, client.user!._id)) return;
let body, icon;
if (typeof msg.content === "string") {
body = client.markdownToText(msg.content);
icon = client.users.getAvatarURL(msg.author, { max_side: 256 });
} else {
let users = client.users;
switch (msg.content.type) {
case "user_added":
case "user_remove":
body = translate(
`app.main.channel.system.${msg.content.type === 'user_added' ? 'added_by' : 'removed_by'}`,
{ user: users.get(msg.content.id)?.username, other_user: users.get(msg.content.by)?.username }
);
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
playSound("message");
if (!showNotification) return;
let title;
switch (msg.channel?.channel_type) {
case "SavedMessages":
return;
case "DirectMessage":
title = `@${msg.author?.username}`;
break;
case "user_joined":
case "user_left":
case "user_kicked":
case "user_banned":
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.id)?.username }
);
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
case "Group":
if (msg.author?._id === SYSTEM_USER_ID) {
title = msg.channel.name;
} else {
title = `@${msg.author?.username} - ${msg.channel.name}`;
}
break;
case "channel_renamed":
body = translate(
`app.main.channel.system.channel_renamed`,
{ user: users.get(msg.content.by)?.username, name: msg.content.name }
);
icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
case "TextChannel":
title = `@${msg.author?.username} (#${msg.channel.name}, ${msg.channel.server?.name})`;
break;
case "channel_description_changed":
case "channel_icon_changed":
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.by)?.username }
);
icon = client.users.getAvatarURL(msg.content.by, { max_side: 256 });
default:
title = msg.channel?._id;
break;
}
}
let notif = await createNotification(title, {
icon,
image,
body,
timestamp: decodeTime(msg._id),
tag: msg.channel,
badge: '/assets/icons/android-chrome-512x512.png',
silent: true
});
if (notif) {
notif.addEventListener("click", () => {
window.focus();
const id = msg.channel;
if (id !== channel_id) {
let channel = client.channels.get(id);
if (channel) {
if (channel.channel_type === 'TextChannel') {
history.push(`/server/${channel.server}/channel/${id}`);
} else {
history.push(`/channel/${id}`);
let image;
if (msg.attachments) {
const imageAttachment = msg.attachments.find(
(x) => x.metadata.type === "Image",
);
if (imageAttachment) {
image = client.generateFileURL(imageAttachment, {
max_side: 720,
});
}
}
let body, icon;
if (typeof msg.content === "string") {
body = client.markdownToText(msg.content);
icon = msg.author?.generateAvatarURL({ max_side: 256 });
} else {
const users = client.users;
switch (msg.content.type) {
case "user_added":
case "user_remove":
{
const user = users.get(msg.content.id);
body = translate(
`app.main.channel.system.${
msg.content.type === "user_added"
? "added_by"
: "removed_by"
}`,
{
user: user?.username,
other_user: users.get(msg.content.by)
?.username,
},
);
icon = user?.generateAvatarURL({
max_side: 256,
});
}
}
break;
case "user_joined":
case "user_left":
case "user_kicked":
case "user_banned":
{
const user = users.get(msg.content.id);
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: user?.username },
);
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
case "channel_renamed":
{
const user = users.get(msg.content.by);
body = translate(
`app.main.channel.system.channel_renamed`,
{
user: users.get(msg.content.by)?.username,
name: msg.content.name,
},
);
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
case "channel_description_changed":
case "channel_icon_changed":
{
const user = users.get(msg.content.by);
body = translate(
`app.main.channel.system.${msg.content.type}`,
{ user: users.get(msg.content.by)?.username },
);
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
}
}
const notif = await createNotification(title!, {
icon,
image,
body,
timestamp: decodeTime(msg._id),
tag: msg.channel?._id,
badge: "/assets/icons/android-chrome-512x512.png",
silent: true,
});
notifications[msg.channel] = notif;
notif.addEventListener(
"close",
() => delete notifications[msg.channel]
);
}
}
if (notif) {
notif.addEventListener("click", () => {
window.focus();
const id = msg.channel_id;
if (id !== channel_id) {
const channel = client.channels.get(id);
if (channel) {
if (channel.channel_type === "TextChannel") {
history.push(
`/server/${channel.server_id}/channel/${id}`,
);
} else {
history.push(`/channel/${id}`);
}
}
}
});
async function relationship(user: User, property: string) {
if (client.user?.status?.presence === Users.Presence.Busy) return;
if (property !== "relationship") return;
if (!showNotification) return;
let event;
switch (user.relationship) {
case Users.Relationship.Incoming:
event = translate("notifications.sent_request", { person: user.username });
break;
case Users.Relationship.Friend:
event = translate("notifications.now_friends", { person: user.username });
break;
default:
return;
}
notifications[msg.channel_id] = notif;
notif.addEventListener(
"close",
() => delete notifications[msg.channel_id],
);
}
},
[
history,
showNotification,
translate,
channel_id,
client,
notifs,
playSound,
],
);
let notif = await createNotification(event, {
icon: client.users.getAvatarURL(user._id, { max_side: 256 }),
badge: '/assets/icons/android-chrome-512x512.png',
timestamp: +new Date()
});
const relationship = useCallback(
async (user: User) => {
if (client.user?.status?.presence === Presence.Busy) return;
if (!showNotification) return;
notif?.addEventListener("click", () => {
history.push(`/friends`);
});
}
let event;
switch (user.relationship) {
case RelationshipStatus.Incoming:
event = translate("notifications.sent_request", {
person: user.username,
});
break;
case RelationshipStatus.Friend:
event = translate("notifications.now_friends", {
person: user.username,
});
break;
default:
return;
}
const notif = await createNotification(event, {
icon: user.generateAvatarURL({ max_side: 256 }),
badge: "/assets/icons/android-chrome-512x512.png",
timestamp: +new Date(),
});
notif?.addEventListener("click", () => {
history.push(`/friends`);
});
},
[client.user?.status?.presence, history, showNotification, translate],
);
useEffect(() => {
client.addListener("message", message);
client.users.addListener("mutation", relationship);
client.addListener("user/relationship", relationship);
return () => {
client.removeListener("message", message);
client.users.removeListener("mutation", relationship);
client.removeListener("user/relationship", relationship);
};
}, [client, playSound, guild_id, channel_id, showNotification, notifs]);
}, [
client,
playSound,
guild_id,
channel_id,
showNotification,
notifs,
message,
relationship,
]);
useEffect(() => {
function visChange() {
......@@ -216,21 +287,21 @@ function Notifier({ options, notifs }: Props) {
document.removeEventListener("visibilitychange", visChange);
}, [guild_id, channel_id]);
return <></>;
return null;
}
const NotifierComponent = connectState(
Notifier,
state => {
(state) => {
return {
options: state.settings.notification,
notifs: state.notifications
notifs: state.notifications,
};
},
true
true,
);
export default function Notifications() {
export default function NotificationsComponent() {
return (
<Switch>
<Route path="/server/:server/channel/:channel">
......
import { Text } from "preact-i18n";
import { WifiOff } from "@styled-icons/boxicons-regular";
import styled from "styled-components";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { WifiOff } from "@styled-icons/boxicons-regular";
import Preloader from "../../components/ui/Preloader";
import { Children } from "../../types/Preact";
import { ClientStatus, StatusContext } from "./RevoltClient";
interface Props {
......@@ -40,5 +43,5 @@ export default function RequiresOnline(props: Props) {
</Base>
);
return <>{ props.children }</>;
return <>{props.children}</>;
}
import { openDB } from 'idb';
/* eslint-disable react-hooks/rules-of-hooks */
import { Client } from "revolt.js";
import { takeError } from "./util";
import { createContext } from "preact";
import { Children } from "../../types/Preact";
import { useHistory } from 'react-router-dom';
import { Route } from "revolt.js/dist/api/routes";
import { createContext } from "preact";
import { useContext, useEffect, useMemo, useState } from "preact/hooks";
import { SingletonMessageRenderer } from "../../lib/renderer/Singleton";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import Preloader from "../../components/ui/Preloader";
import { WithDispatcher } from "../../redux/reducers";
import { AuthState } from "../../redux/reducers/auth";
import { useEffect, useMemo, useState } from "preact/hooks";
import { useIntermediate } from '../intermediate/Intermediate';
import Preloader from "../../components/ui/Preloader";
import { Children } from "../../types/Preact";
import { useIntermediate } from "../intermediate/Intermediate";
import { registerEvents, setReconnectDisallowed } from "./events";
import { SingletonMessageRenderer } from '../../lib/renderer/Singleton';
import { takeError } from "./util";
export enum ClientStatus {
INIT,
......@@ -30,79 +34,62 @@ export interface ClientOperations {
logout: (shouldRequest?: boolean) => Promise<void>;
loggedIn: () => boolean;
ready: () => boolean;
openDM: (user_id: string) => Promise<string>;
}
export const AppContext = createContext<Client>(undefined as any);
export const StatusContext = createContext<ClientStatus>(undefined as any);
export const OperationsContext = createContext<ClientOperations>(undefined as any);
// By the time they are used, they should all be initialized.
// Currently the app does not render until a client is built and the other two are always initialized on first render.
// - insert's words
export const AppContext = createContext<Client>(null!);
export const StatusContext = createContext<ClientStatus>(null!);
export const OperationsContext = createContext<ClientOperations>(null!);
type Props = WithDispatcher & {
type Props = {
auth: AuthState;
children: Children;
};
function Context({ auth, children, dispatcher }: Props) {
const history = useHistory();
function Context({ auth, children }: Props) {
const { openScreen } = useIntermediate();
const [status, setStatus] = useState(ClientStatus.INIT);
const [client, setClient] = useState<Client>(undefined as unknown as Client);
const [client, setClient] = useState<Client>(
undefined as unknown as Client,
);
useEffect(() => {
(async () => {
let db;
try {
// Match sw.ts#L23
db = await openDB('state', 3, {
upgrade(db) {
for (let store of [ "channels", "servers", "users", "members" ]) {
db.createObjectStore(store, {
keyPath: '_id'
});
}
},
});
} catch (err) {
console.error('Failed to open IndexedDB store, continuing without.');
}
const client = new Client({
autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV,
db
});
setClient(client);
SingletonMessageRenderer.subscribe(client);
setStatus(ClientStatus.LOADING);
})();
}, [ ]);
}, []);
if (status === ClientStatus.INIT) return null;
const operations: ClientOperations = useMemo(() => {
return {
login: async data => {
login: async (data) => {
setReconnectDisallowed(true);
try {
const onboarding = await client.login(data);
setReconnectDisallowed(false);
const login = () =>
dispatcher({
dispatch({
type: "LOGIN",
session: client.session as any
session: client.session!, // This [null assertion] is ok, we should have a session by now. - insert's words
});
if (onboarding) {
openScreen({
id: "onboarding",
callback: async (username: string) => {
await (onboarding as any)(username, true);
login();
}
callback: async (username: string) =>
onboarding(username, true).then(login),
});
} else {
login();
......@@ -112,11 +99,11 @@ function Context({ auth, children, dispatcher }: Props) {
throw err;
}
},
logout: async shouldRequest => {
dispatcher({ type: "LOGOUT" });
logout: async (shouldRequest) => {
dispatch({ type: "LOGOUT" });
client.reset();
dispatcher({ type: "RESET" });
dispatch({ type: "RESET" });
openScreen({ id: "none" });
setStatus(ClientStatus.READY);
......@@ -132,28 +119,20 @@ function Context({ auth, children, dispatcher }: Props) {
}
},
loggedIn: () => typeof auth.active !== "undefined",
ready: () => (
operations.loggedIn() &&
typeof client.user !== "undefined"
),
openDM: async (user_id: string) => {
let channel = await client.users.openDM(user_id);
history.push(`/channel/${channel!._id}`);
return channel!._id;
}
}
}, [ client, auth.active ]);
ready: () =>
operations.loggedIn() && typeof client.user !== "undefined",
};
}, [client, auth.active, openScreen]);
useEffect(() => registerEvents({ operations, dispatcher }, setStatus, client), [ client ]);
useEffect(
() => registerEvents({ operations }, setStatus, client),
[client, operations],
);
useEffect(() => {
(async () => {
if (client.db) {
await client.restore();
}
if (auth.active) {
dispatcher({ type: "QUEUE_FAIL_ALL" });
dispatch({ type: "QUEUE_FAIL_ALL" });
const active = auth.accounts[auth.active];
client.user = client.users.get(active.session.user_id);
......@@ -161,21 +140,20 @@ function Context({ auth, children, dispatcher }: Props) {
return setStatus(ClientStatus.OFFLINE);
}
if (operations.ready())
setStatus(ClientStatus.CONNECTING);
if (operations.ready()) setStatus(ClientStatus.CONNECTING);
if (navigator.onLine) {
await client
.fetchConfiguration()
.catch(() =>
console.error("Failed to connect to API server.")
console.error("Failed to connect to API server."),
);
}
try {
await client.fetchConfiguration();
const callback = await client.useExistingSession(
active.session
active.session,
);
if (callback) {
......@@ -193,7 +171,7 @@ function Context({ auth, children, dispatcher }: Props) {
}
} else {
try {
await client.fetchConfiguration()
await client.fetchConfiguration();
} catch (err) {
console.error("Failed to connect to API server.");
}
......@@ -201,6 +179,7 @@ function Context({ auth, children, dispatcher }: Props) {
setStatus(ClientStatus.READY);
}
})();
// eslint-disable-next-line
}, []);
if (status === ClientStatus.LOADING) {
......@@ -211,20 +190,18 @@ function Context({ auth, children, dispatcher }: Props) {
<AppContext.Provider value={client}>
<StatusContext.Provider value={status}>
<OperationsContext.Provider value={operations}>
{ children }
{children}
</OperationsContext.Provider>
</StatusContext.Provider>
</AppContext.Provider>
);
}
export default connectState<{ children: Children }>(
Context,
state => {
return {
auth: state.auth,
sync: state.sync
};
},
true
);
export default connectState<{ children: Children }>(Context, (state) => {
return {
auth: state.auth,
sync: state.sync,
};
});
export const useClient = () => useContext(AppContext);
/**
* This file monitors the message cache to delete any queued messages that have already sent.
*/
import { Message } from "revolt.js/dist/maps/Messages";
import { Message } from "revolt.js";
import { AppContext } from "./RevoltClient";
import { Typing } from "../../redux/reducers/typing";
import { useContext, useEffect } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector";
import { WithDispatcher } from "../../redux/reducers";
import { QueuedMessage } from "../../redux/reducers/queue";
type Props = WithDispatcher & {
import { AppContext } from "./RevoltClient";
type Props = {
messages: QueuedMessage[];
typing: Typing
};
function StateMonitor(props: Props) {
const client = useContext(AppContext);
useEffect(() => {
props.dispatcher({
type: 'QUEUE_DROP_ALL'
dispatch({
type: "QUEUE_DROP_ALL",
});
}, [ ]);
}, []);
useEffect(() => {
function add(msg: Message) {
if (!msg.nonce) return;
if (!props.messages.find(x => x.id === msg.nonce)) return;
if (!props.messages.find((x) => x.id === msg.nonce)) return;
props.dispatcher({
type: 'QUEUE_REMOVE',
nonce: msg.nonce
dispatch({
type: "QUEUE_REMOVE",
nonce: msg.nonce,
});
}
client.addListener('message', add);
return () => client.removeListener('message', add);
}, [ props.messages ]);
useEffect(() => {
function removeOld() {
if (!props.typing) return;
for (let channel of Object.keys(props.typing)) {
let users = props.typing[channel];
for (let user of users) {
if (+ new Date() > user.started + 5000) {
props.dispatcher({
type: 'TYPING_STOP',
channel,
user: user.id
});
}
}
}
}
removeOld();
let interval = setInterval(removeOld, 1000);
return () => clearInterval(interval);
}, [ props.typing ]);
client.addListener("message", add);
return () => client.removeListener("message", add);
}, [client, props.messages]);
return <></>;
return null;
}
export default connectState(
StateMonitor,
state => {
return {
messages: [...state.queue],
typing: state.typing
};
},
true
);
export default connectState(StateMonitor, (state) => {
return {
messages: [...state.queue],
};
});