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 1134 additions and 1061 deletions
import { X } from "@styled-icons/feather"; import { X } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js/dist/maps/Channels";
import styles from "./ChannelInfo.module.scss"; import styles from "./ChannelInfo.module.scss";
import Modal from "../../../components/ui/Modal"; import Modal from "../../../components/ui/Modal";
import { getChannelName } from "../../revoltjs/util";
import Markdown from "../../../components/markdown/Markdown"; import Markdown from "../../../components/markdown/Markdown";
import { useChannel, useForceUpdate } from "../../revoltjs/hooks"; import { getChannelName } from "../../revoltjs/util";
interface Props { interface Props {
channel_id: string; channel: Channel;
onClose: () => void; onClose: () => void;
} }
export function ChannelInfo({ channel_id, onClose }: Props) { export const ChannelInfo = observer(({ channel, onClose }: Props) => {
const ctx = useForceUpdate(); if (
const channel = useChannel(channel_id, ctx); channel.channel_type === "DirectMessage" ||
if (!channel) return null; channel.channel_type === "SavedMessages"
) {
if (channel.channel_type === "DirectMessage" || channel.channel_type === 'SavedMessages') {
onClose(); onClose();
return null; return null;
} }
...@@ -24,15 +27,15 @@ export function ChannelInfo({ channel_id, onClose }: Props) { ...@@ -24,15 +27,15 @@ export function ChannelInfo({ channel_id, onClose }: Props) {
<Modal visible={true} onClose={onClose}> <Modal visible={true} onClose={onClose}>
<div className={styles.info}> <div className={styles.info}>
<div className={styles.header}> <div className={styles.header}>
<h1>{ getChannelName(ctx.client, channel, true) }</h1> <h1>{getChannelName(channel, true)}</h1>
<div onClick={onClose}> <div onClick={onClose}>
<X size={36} /> <X size={36} />
</div> </div>
</div> </div>
<p> <p>
<Markdown content={channel.description} /> <Markdown content={channel.description!} />
</p> </p>
</div> </div>
</Modal> </Modal>
); );
} });
.viewer { .viewer {
display: flex;
flex-direction: column;
border-end-end-radius: 4px;
border-end-start-radius: 4px;
overflow: hidden;
img { img {
width: auto;
height: auto;
max-width: 90vw; 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 styles from "./ImageViewer.module.scss";
import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions";
import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
import Modal from "../../../components/ui/Modal"; import Modal from "../../../components/ui/Modal";
import { useContext, useEffect } from "preact/hooks";
import { AppContext } from "../../revoltjs/RevoltClient"; import { useClient } from "../../revoltjs/RevoltClient";
import { Attachment, EmbedImage } from "revolt.js/dist/api/objects";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
...@@ -10,36 +16,45 @@ interface Props { ...@@ -10,36 +16,45 @@ interface Props {
attachment?: Attachment; attachment?: Attachment;
} }
export function ImageViewer({ attachment, embed, onClose }: Props) { type ImageMetadata = AttachmentMetadata & { type: "Image" };
if (attachment && attachment.metadata.type !== "Image") return null;
const client = useContext(AppContext);
useEffect(() => { export function ImageViewer({ attachment, embed, onClose }: Props) {
function keyDown(e: KeyboardEvent) { if (attachment && attachment.metadata.type !== "Image") {
if (e.key === "Escape") { console.warn(
onClose(); `Attempted to use a non valid attatchment type in the image viewer: ${attachment.metadata.type}`,
} );
} return null;
}
document.body.addEventListener("keydown", keyDown); const client = useClient();
return () => document.body.removeEventListener("keydown", keyDown);
}, []);
return ( return (
<Modal visible={true} onClose={onClose} noBackground> <Modal visible={true} onClose={onClose} noBackground>
<div className={styles.viewer}> <div className={styles.viewer}>
{ attachment && {attachment && (
<> <>
<img src={client.generateFileURL(attachment)} /> <img
{/*<AttachmentActions attachment={attachment} />*/} 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
{/*<EmbedMediaActions embed={embed} />*/} loading="eager"
src={client.proxyFile(embed.url)}
width={embed.width}
height={embed.height}
/>
<EmbedMediaActions embed={embed} />
</> </>
} )}
</div> </div>
</Modal> </Modal>
); );
......
import { SubmitHandler, useForm } from "react-hook-form";
import { Text } from "preact-i18n"; 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 { useContext, useState } from "preact/hooks";
import FormField from '../../../pages/login/FormField';
import Modal from "../../../components/ui/Modal";
import Overline from "../../../components/ui/Overline"; import Overline from "../../../components/ui/Overline";
import FormField from "../../../pages/login/FormField";
import { AppContext } from "../../revoltjs/RevoltClient"; import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props { interface Props {
onClose: () => void; onClose: () => void;
field: "username" | "email" | "password"; 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) { export function ModifyAccountModal({ onClose, field }: Props) {
const client = useContext(AppContext); const client = useContext(AppContext);
const { handleSubmit, register, errors } = useForm(); const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
async function onSubmit({ const onSubmit: SubmitHandler<FormInputs> = async ({
password, password,
new_username, new_username,
new_email, new_email,
new_password new_password,
}: { }) => {
password: string;
new_username: string;
new_email: string;
new_password: string;
}) {
try { try {
if (field === "email") { if (field === "email") {
await client.req("POST", "/auth/change/email", { await client.req("POST", "/auth/change/email", {
password, password,
new_email new_email,
}); });
onClose(); onClose();
} else if (field === "password") { } else if (field === "password") {
await client.req("POST", "/auth/change/password", { await client.req("POST", "/auth/change/password", {
password, password,
new_password new_password,
}); });
onClose(); onClose();
} else if (field === "username") { } else if (field === "username") {
await client.req("PATCH", "/users/id/username", { await client.req("PATCH", "/users/id/username", {
username: new_username, username: new_username,
password password,
}); });
onClose(); onClose();
} }
} catch (err) { } catch (err) {
setError(takeError(err)); setError(takeError(err));
} }
} };
return ( return (
<Modal <Modal
...@@ -62,20 +71,27 @@ export function ModifyAccountModal({ onClose, field }: Props) { ...@@ -62,20 +71,27 @@ export function ModifyAccountModal({ onClose, field }: Props) {
{ {
confirmation: true, confirmation: true,
onClick: handleSubmit(onSubmit), onClick: handleSubmit(onSubmit),
text: children:
field === "email" ? ( field === "email" ? (
<Text id="app.special.modals.actions.send_email" /> <Text id="app.special.modals.actions.send_email" />
) : ( ) : (
<Text id="app.special.modals.actions.update" /> <Text id="app.special.modals.actions.update" />
) ),
}, },
{ {
onClick: onClose, onClick: onClose,
text: <Text id="app.special.modals.actions.close" /> children: <Text id="app.special.modals.actions.close" />,
} },
]} ]}>
> {/* Preact / React typing incompatabilities */}
<form onSubmit={handleSubmit(onSubmit) as any}> <form
onSubmit={(e) => {
e.preventDefault();
handleSubmit(
onSubmit,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
)(e as any);
}}>
{field === "email" && ( {field === "email" && (
<FormField <FormField
type="email" type="email"
......
import { observer } from "mobx-react-lite";
import { User } from "revolt.js/dist/maps/Users";
import styles from "./UserPicker.module.scss";
import { Text } from "preact-i18n";
import Modal from "../../../components/ui/Modal";
import { Friend } from "../../../pages/friends/Friend";
interface Props {
users: User[];
onClose: () => void;
}
export const PendingRequests = observer(({ users, onClose }: Props) => {
return (
<Modal
visible={true}
title={<Text id="app.special.friends.pending" />}
onClose={onClose}>
<div className={styles.list}>
{users.map((x) => (
<Friend user={x!} key={x!._id} />
))}
</div>
</Modal>
);
});
...@@ -4,7 +4,6 @@ ...@@ -4,7 +4,6 @@
max-height: 360px; max-height: 360px;
overflow-y: scroll; overflow-y: scroll;
// ! FIXME: very temporary code
> label { > label {
> span { > span {
align-items: flex-start !important; align-items: flex-start !important;
...@@ -18,4 +17,4 @@ ...@@ -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 { Text } from "preact-i18n";
import { useState } from "preact/hooks"; 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 UserCheckbox from "../../../components/common/user/UserCheckbox";
import Modal from "../../../components/ui/Modal";
import { useClient } from "../../revoltjs/RevoltClient";
interface Props { interface Props {
omit?: string[]; omit?: string[];
...@@ -16,7 +19,7 @@ export function UserPicker(props: Props) { ...@@ -16,7 +19,7 @@ export function UserPicker(props: Props) {
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const omit = [...(props.omit || []), "00000000000000000000000000"]; const omit = [...(props.omit || []), "00000000000000000000000000"];
const users = useUsers(); const client = useClient();
return ( return (
<Modal <Modal
...@@ -25,34 +28,29 @@ export function UserPicker(props: Props) { ...@@ -25,34 +28,29 @@ export function UserPicker(props: Props) {
onClose={props.onClose} onClose={props.onClose}
actions={[ actions={[
{ {
text: <Text id="app.special.modals.actions.ok" />, children: <Text id="app.special.modals.actions.ok" />,
onClick: () => props.callback(selected).then(props.onClose) onClick: () => props.callback(selected).then(props.onClose),
} },
]} ]}>
>
<div className={styles.list}> <div className={styles.list}>
{(users.filter( {[...client.users.values()]
x => .filter(
x && (x) =>
x.relationship === Users.Relationship.Friend && x &&
!omit.includes(x._id) x.relationship === RelationshipStatus.Friend &&
) as User[]) !omit.includes(x._id),
.map(x => { )
return { .map((x) => (
...x,
selected: selected.includes(x._id)
};
})
.map(x => (
<UserCheckbox <UserCheckbox
key={x._id}
user={x} user={x}
checked={x.selected} checked={selected.includes(x._id)}
onChange={v => { onChange={(v) => {
if (v) { if (v) {
setSelected([...selected, x._id]); setSelected([...selected, x._id]);
} else { } else {
setSelected( setSelected(
selected.filter(y => y !== x._id) selected.filter((y) => y !== x._id),
); );
} }
}} }}
......
...@@ -7,7 +7,7 @@ ...@@ -7,7 +7,7 @@
.header { .header {
background-size: cover; background-size: cover;
border-radius: 8px 8px 0 0; border-radius: var(--border-radius) var(--border-radius) 0 0;
background-position: center; background-position: center;
background-color: var(--secondary-background); background-color: var(--secondary-background);
...@@ -35,7 +35,7 @@ ...@@ -35,7 +35,7 @@
display: flex; display: flex;
flex-direction: column; flex-direction: column;
> * { * {
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
white-space: nowrap; white-space: nowrap;
...@@ -57,13 +57,13 @@ ...@@ -57,13 +57,13 @@
gap: 8px; gap: 8px;
display: flex; display: flex;
padding: 0 1.5em; padding: 0 1.5em;
font-size: .875rem; font-size: 0.875rem;
> div { > div {
padding: 8px; padding: 8px;
cursor: pointer; cursor: pointer;
border-bottom: 2px solid transparent; border-bottom: 2px solid transparent;
transition: border-bottom .3s; transition: border-bottom 0.3s;
&[data-active="true"] { &[data-active="true"] {
border-bottom: 2px solid var(--foreground); border-bottom: 2px solid var(--foreground);
...@@ -81,7 +81,10 @@ ...@@ -81,7 +81,10 @@
height: 100%; height: 100%;
display: flex; display: flex;
padding: 1em 1.5em; padding: 1em 1.5em;
max-width: 560px; max-width: 560px;
max-height: 240px;
overflow-y: auto; overflow-y: auto;
flex-direction: column; flex-direction: column;
background: var(--primary-background); background: var(--primary-background);
...@@ -140,11 +143,11 @@ ...@@ -140,11 +143,11 @@
padding: 12px; padding: 12px;
display: flex; display: flex;
cursor: pointer; cursor: pointer;
border-radius: 4px;
align-items: center; align-items: center;
transition: background-color 0.1s;
color: var(--secondary-foreground); color: var(--secondary-foreground);
border-radius: var(--border-radius);
background-color: var(--secondary-background); background-color: var(--secondary-background);
transition: background-color .1s;
&:hover { &:hover {
background-color: var(--primary-background); background-color: var(--primary-background);
......
import { decodeTime } from "ulid"; import { Money } from "@styled-icons/boxicons-regular";
import { Localizer, Text } from "preact-i18n"; import { Envelope, Edit, UserPlus, Shield } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite";
import { Link, useHistory } from "react-router-dom";
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 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 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 { Link, useHistory } from "react-router-dom";
import { CashStack } from "@styled-icons/bootstrap";
import Preloader from "../../../components/ui/Preloader"; import Preloader from "../../../components/ui/Preloader";
import Tooltip from '../../../components/common/Tooltip';
import Markdown from '../../../components/markdown/Markdown'; import Markdown from "../../../components/markdown/Markdown";
import UserIcon from '../../../components/common/user/UserIcon'; import {
import ChannelIcon from '../../../components/common/ChannelIcon'; ClientStatus,
import UserStatus from '../../../components/common/user/UserStatus'; StatusContext,
import { Mail, Edit, UserPlus, Shield } from "@styled-icons/feather"; useClient,
import { useChannels, useForceUpdate, useUsers } from "../../revoltjs/hooks"; } from "../../revoltjs/RevoltClient";
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks"; import { useIntermediate } from "../Intermediate";
import { AppContext, ClientStatus, StatusContext } from "../../revoltjs/RevoltClient";
interface Props { interface Props {
user_id: string; user_id: string;
dummy?: boolean; dummy?: boolean;
onClose: () => void; onClose?: () => void;
dummyProfile?: Users.Profile; dummyProfile?: Profile;
} }
enum Badges { enum Badges {
...@@ -30,312 +38,316 @@ enum Badges { ...@@ -30,312 +38,316 @@ enum Badges {
Translator = 2, Translator = 2,
Supporter = 4, Supporter = 4,
ResponsibleDisclosure = 8, ResponsibleDisclosure = 8,
EarlyAdopter = 256 EarlyAdopter = 256,
} }
export function UserProfile({ user_id, onClose, dummy, dummyProfile }: Props) { export const UserProfile = observer(
const { writeClipboard } = useIntermediate(); ({ user_id, onClose, dummy, dummyProfile }: Props) => {
const { openScreen, writeClipboard } = useIntermediate();
const [profile, setProfile] = useState<undefined | null | Users.Profile>( const [profile, setProfile] = useState<undefined | null | Profile>(
undefined undefined,
); );
const [mutual, setMutual] = useState< const [mutual, setMutual] = useState<
undefined | null | Route<"GET", "/users/id/mutual">["response"] undefined | null | Route<"GET", "/users/id/mutual">["response"]
>(undefined); >(undefined);
const client = useContext(AppContext); const history = useHistory();
const status = useContext(StatusContext); const client = useClient();
const [tab, setTab] = useState("profile"); const status = useContext(StatusContext);
const history = useHistory(); const [tab, setTab] = useState("profile");
const ctx = useForceUpdate(); const user = client.users.get(user_id);
const all_users = useUsers(undefined, ctx); if (!user) {
const channels = useChannels(undefined, ctx); if (onClose) useEffect(onClose, []);
return null;
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) { const users = mutual?.users.map((id) => client.users.get(id));
useEffect(onClose, []);
return null;
}
useLayoutEffect(() => { const mutualGroups = [...client.channels.values()].filter(
if (!user_id) return; (channel) =>
if (typeof profile !== 'undefined') setProfile(undefined); channel?.channel_type === "Group" &&
if (typeof mutual !== 'undefined') setMutual(undefined); channel.recipient_ids!.includes(user_id),
}, [user_id]); );
if (dummy) {
useLayoutEffect(() => { useLayoutEffect(() => {
setProfile(dummyProfile); if (!user_id) return;
}, [dummyProfile]); if (typeof profile !== "undefined") setProfile(undefined);
} if (typeof mutual !== "undefined") setMutual(undefined);
// eslint-disable-next-line
}, [user_id]);
useEffect(() => { useEffect(() => {
if (dummy) return; if (dummy) {
if ( setProfile(dummyProfile);
status === ClientStatus.ONLINE && }
typeof mutual === "undefined" }, [dummy, dummyProfile]);
) {
setMutual(null);
client.users
.fetchMutual(user_id)
.then(data => setMutual(data));
}
}, [mutual, status]);
useEffect(() => { useEffect(() => {
if (dummy) return; if (dummy) return;
if ( if (
status === ClientStatus.ONLINE && status === ClientStatus.ONLINE &&
typeof profile === "undefined" typeof mutual === "undefined"
) { ) {
setProfile(null); setMutual(null);
user.fetchMutual().then(setMutual);
}
}, [mutual, status, dummy, user]);
// ! FIXME: in the future, also check if mutual guilds useEffect(() => {
// ! maybe just allow mutual group to allow profile viewing if (dummy) return;
/*if ( if (
user.relationship === Users.Relationship.Friend || status === ClientStatus.ONLINE &&
user.relationship === Users.Relationship.User typeof profile === "undefined"
) {*/ ) {
client.users setProfile(null);
.fetchProfile(user_id)
.then(data => setProfile(data))
.catch(() => {});
//}
}
}, [profile, status]);
const mutualGroups = channels.filter( if (user.permission & UserPermission.ViewProfile) {
channel => user.fetchProfile().then(setProfile);
channel?.channel_type === "Group" && }
channel.recipients.includes(user_id) }
); }, [profile, status, dummy, user]);
const backgroundURL = profile && client.users.getBackgroundURL(profile, { width: 1000 }, true); const backgroundURL =
const badges = (user.badges ?? 0) | (decodeTime(user._id) < 1623751765790 ? Badges.EarlyAdopter : 0); profile &&
client.generateFileURL(profile.background, { width: 1000 }, true);
const badges = user.badges ?? 0;
return ( return (
<Modal <Modal
visible visible
border={dummy} border={dummy}
onClose={onClose} padding={false}
dontModal={dummy} onClose={onClose}
> dontModal={dummy}>
<div <div
className={styles.header} className={styles.header}
data-force={ data-force={profile?.background ? "light" : undefined}
profile?.background style={{
? "light" backgroundImage:
: undefined backgroundURL &&
} `linear-gradient( rgba(0, 0, 0, 0.7), rgba(0, 0, 0, 0.7) ), url('${backgroundURL}')`,
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}>
<div className={styles.profile}> <Localizer>
<UserIcon size={80} target={user} status /> <span
<div className={styles.details}> className={styles.username}
<Localizer> onClick={() =>
<span writeClipboard(user.username)
className={styles.username} }>
onClick={() => writeClipboard(user.username)}> @{user.username}
@{user.username} </span>
</span> </Localizer>
</Localizer> {user.status?.text && (
{user.status?.text && ( <span className={styles.status}>
<span className={styles.status}> <UserStatus user={user} tooltip />
<UserStatus user={user} /> </span>
</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> </div>
{user.relationship === Users.Relationship.Friend && ( <div className={styles.tabs}>
<Localizer> <div
<Tooltip data-active={tab === "profile"}
content={ onClick={() => setTab("profile")}>
<Text id="app.context_menu.message_user" /> <Text id="app.special.popovers.user_profile.profile" />
} </div>
> {user.relationship !== RelationshipStatus.User && (
{/*<IconButton <>
onClick={() => { <div
onClose(); data-active={tab === "friends"}
history.push(`/open/${user_id}`); onClick={() => setTab("friends")}>
}} <Text id="app.special.popovers.user_profile.mutual_friends" />
>*/} </div>
<Mail size={30} strokeWidth={1.5} /> <div
{/*</IconButton>*/} data-active={tab === "groups"}
</Tooltip> onClick={() => setTab("groups")}>
</Localizer> <Text id="app.special.popovers.user_profile.mutual_groups" />
)} </div>
{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> </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> <div className={styles.content}>
<div className={styles.content}> {tab === "profile" && (
{tab === "profile" && <div>
<div> {!(profile?.content || badges > 0) && (
{ !(profile?.content || (badges > 0)) && <div className={styles.empty}>
<div className={styles.empty}><Text id="app.special.popovers.user_profile.empty" /></div> } <Text id="app.special.popovers.user_profile.empty" />
{ (badges > 0) && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.badges" /></div> } </div>
{ (badges > 0) && ( )}
<div className={styles.badges}> {badges > 0 && (
<Localizer> <div className={styles.category}>
{badges & Badges.Developer ? ( <Text id="app.special.popovers.user_profile.sub.badges" />
<Tooltip </div>
content={ )}
<Text id="app.navigation.tabs.dev" /> {badges > 0 && (
} <div className={styles.badges}>
> <Localizer>
<img src="/assets/badges/developer.svg" /> {badges & Badges.Developer ? (
</Tooltip> <Tooltip
) : ( content={
<></> <Text id="app.navigation.tabs.dev" />
)} }>
{badges & Badges.Translator ? ( <img src="/assets/badges/developer.svg" />
<Tooltip </Tooltip>
content={ ) : (
<Text id="app.special.popovers.user_profile.badges.translator" /> <></>
} )}
> {badges & Badges.Translator ? (
<img src="/assets/badges/translator.svg" /> <Tooltip
</Tooltip> content={
) : ( <Text id="app.special.popovers.user_profile.badges.translator" />
<></> }>
)} <img src="/assets/badges/translator.svg" />
{badges & Badges.EarlyAdopter ? ( </Tooltip>
<Tooltip ) : (
content={ <></>
<Text id="app.special.popovers.user_profile.badges.early_adopter" /> )}
} {badges & Badges.EarlyAdopter ? (
> <Tooltip
<img src="/assets/badges/early_adopter.svg" /> content={
</Tooltip> <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" /> {badges & Badges.Supporter ? (
} <Tooltip
> content={
<CashStack size={32} color="#efab44" /> <Text id="app.special.popovers.user_profile.badges.supporter" />
</Tooltip> }>
) : ( <Money
<></> size={32}
)} color="#efab44"
{badges & Badges.ResponsibleDisclosure ? ( />
<Tooltip </Tooltip>
content={ ) : (
<Text id="app.special.popovers.user_profile.badges.responsible_disclosure" /> <></>
} )}
> {badges &
<Shield size={32} color="gray" /> Badges.ResponsibleDisclosure ? (
</Tooltip> <Tooltip
) : ( content={
<></> <Text id="app.special.popovers.user_profile.badges.responsible_disclosure" />
)} }>
</Localizer> <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> </div>
)} )}
{ profile?.content && <div className={styles.category}><Text id="app.special.popovers.user_profile.sub.information" /></div> } {tab === "friends" &&
<Markdown content={profile?.content} /> (users ? (
{/*<div className={styles.category}><Text id="app.special.popovers.user_profile.sub.connections" /></div>*/} <div className={styles.entries}>
</div>} {users.length === 0 ? (
{tab === "friends" && <div className={styles.empty}>
(users ? ( <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}> <div className={styles.entries}>
{users.length === 0 ? ( {mutualGroups.length === 0 ? (
<div className={styles.empty}> <div className={styles.empty}>
<Text id="app.special.popovers.user_profile.no_users" /> <Text id="app.special.popovers.user_profile.no_groups" />
</div> </div>
) : ( ) : (
users.map( mutualGroups.map(
x => (x) =>
x && ( x?.channel_type === "Group" && (
//<LinkProfile user_id={x._id}> <Link to={`/channel/${x._id}`}>
<div <div
className={styles.entry} className={styles.entry}
key={x._id} key={x._id}>
> <ChannelIcon
<UserIcon size={32} target={x} /> target={x}
<span>{x.username}</span> size={32}
/>
<span>{x.name}</span>
</div> </div>
//</LinkProfile> </Link>
) ),
) )
)} )}
</div> </div>
) : ( )}
<Preloader /> </div>
))} </Modal>
{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>
);
}
import { useContext } from "preact/hooks";
import { Redirect } from "react-router-dom"; 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"; import { OperationsContext } from "./RevoltClient";
interface Props { interface Props {
......
...@@ -10,7 +10,7 @@ ...@@ -10,7 +10,7 @@
&.banner { &.banner {
.image { .image {
border-radius: 4px; border-radius: var(--border-radius);
} }
.modify { .modify {
......
import { Text } from "preact-i18n"; import { Plus } from "@styled-icons/boxicons-regular";
import { takeError } from "./util"; import { Pencil } from "@styled-icons/boxicons-solid";
import classNames from "classnames";
import { AppContext } from "./RevoltClient";
import styles from './FileUploads.module.scss';
import Axios, { AxiosRequestConfig } from "axios"; 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 { useContext, useEffect, useState } from "preact/hooks";
import Preloader from "../../components/ui/Preloader";
import { determineFileSize } from "../../lib/fileSize"; import { determineFileSize } from "../../lib/fileSize";
import IconButton from '../../components/ui/IconButton';
import { Edit, Plus, X, XCircle } from "@styled-icons/feather"; import IconButton from "../../components/ui/IconButton";
import Preloader from "../../components/ui/Preloader";
import { useIntermediate } from "../intermediate/Intermediate"; import { useIntermediate } from "../intermediate/Intermediate";
import { AppContext } from "./RevoltClient";
import { takeError } from "./util";
type Props = { type Props = {
maxFileSize: number maxFileSize: number;
remove: () => Promise<void> remove: () => Promise<void>;
fileType: 'backgrounds' | 'icons' | 'avatars' | 'attachments' | 'banners' fileType: "backgrounds" | "icons" | "avatars" | "attachments" | "banners";
} & ( } & (
{ behaviour: 'ask', onChange: (file: File) => void } | | { behaviour: "ask"; onChange: (file: File) => void }
{ behaviour: 'upload', onUpload: (id: string) => Promise<void> } | | { behaviour: "upload"; onUpload: (id: string) => Promise<void> }
{ behaviour: 'multi', onChange: (files: File[]) => void, append?: (files: File[]) => void } | {
) & ( behaviour: "multi";
{ style: 'icon' | 'banner', defaultPreview?: string, previewURL?: string, width?: number, height?: number } | onChange: (files: File[]) => void;
{ style: 'attachment', attached: boolean, uploading: boolean, cancel: () => void, size?: number } append?: (files: File[]) => void;
) }
) &
export async function uploadFile(autumnURL: string, tag: string, file: File, config?: AxiosRequestConfig) { (
| {
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(); const formData = new FormData();
formData.append("file", file); formData.append("file", file);
const res = await Axios.post(autumnURL + "/" + tag, formData, { const res = await Axios.post(`${autumnURL}/${tag}`, formData, {
headers: { headers: {
"Content-Type": "multipart/form-data" "Content-Type": "multipart/form-data",
}, },
...config ...config,
}); });
return res.data.id; 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"); const input = document.createElement("input");
input.type = "file"; input.type = "file";
input.multiple = multiple ?? false; input.multiple = multiple ?? false;
input.onchange = async e => { input.onchange = async (e) => {
const files = (e.target as any)?.files; const files = (e.currentTarget as HTMLInputElement)?.files;
if (!files) return; if (!files) return;
for (let file of files) { for (const file of files) {
if (file.size > maxFileSize) { if (file.size > maxFileSize) {
return tooLarge(); return tooLarge();
} }
...@@ -63,65 +95,76 @@ export function FileUploader(props: Props) { ...@@ -63,65 +95,76 @@ export function FileUploader(props: Props) {
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const client = useContext(AppContext); const client = useContext(AppContext);
const [ uploading, setUploading ] = useState(false); const [uploading, setUploading] = useState(false);
function onClick() { function onClick() {
if (uploading) return; if (uploading) return;
grabFiles(maxFileSize, async files => { grabFiles(
setUploading(true); maxFileSize,
async (files) => {
setUploading(true);
try { try {
if (props.behaviour === 'multi') { if (props.behaviour === "multi") {
props.onChange(files); props.onChange(files);
} else if (props.behaviour === 'ask') { } else if (props.behaviour === "ask") {
props.onChange(files[0]); props.onChange(files[0]);
} else { } else {
await props.onUpload(await uploadFile(client.configuration!.features.autumn.url, fileType, files[0])); 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) }); () => openScreen({ id: "error", error: "FileTooLarge" }),
} finally { props.behaviour === "multi",
setUploading(false); );
}
}, () =>
openScreen({ id: "error", error: "FileTooLarge" }),
props.behaviour === 'multi');
} }
function removeOrUpload() { function removeOrUpload() {
if (uploading) return; if (uploading) return;
if (props.style === 'attachment') { if (props.style === "attachment") {
if (props.attached) { if (props.attached) {
props.remove(); props.remove();
} else { } else {
onClick(); onClick();
} }
} else if (props.previewURL) {
props.remove();
} else { } else {
if (props.previewURL) { onClick();
props.remove();
} else {
onClick();
}
} }
} }
if (props.behaviour === 'multi' && props.append) { if (props.behaviour === "multi" && props.append) {
// eslint-disable-next-line
useEffect(() => { useEffect(() => {
// File pasting. // File pasting.
function paste(e: ClipboardEvent) { function paste(e: ClipboardEvent) {
const items = e.clipboardData?.items; const items = e.clipboardData?.items;
if (typeof items === "undefined") return; 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) { for (const item of items) {
if (!item.type.startsWith("text/")) { if (!item.type.startsWith("text/")) {
const blob = item.getAsFile(); const blob = item.getAsFile();
if (blob) { if (blob) {
if (blob.size > props.maxFileSize) { if (blob.size > props.maxFileSize) {
openScreen({ id: 'error', error: 'FileTooLarge' }); openScreen({
id: "error",
error: "FileTooLarge",
});
} }
files.push(blob); files.push(blob);
...@@ -142,14 +185,14 @@ export function FileUploader(props: Props) { ...@@ -142,14 +185,14 @@ export function FileUploader(props: Props) {
// File dropping. // File dropping.
function drop(e: DragEvent) { function drop(e: DragEvent) {
e.preventDefault(); e.preventDefault();
if (props.behaviour !== 'multi' || !props.append) return; if (props.behaviour !== "multi" || !props.append) return;
const dropped = e.dataTransfer?.files; const dropped = e.dataTransfer?.files;
if (dropped) { if (dropped) {
let files = []; const files = [];
for (const item of dropped) { for (const item of dropped) {
if (item.size > props.maxFileSize) { if (item.size > props.maxFileSize) {
openScreen({ id: 'error', error: 'FileTooLarge' }); openScreen({ id: "error", error: "FileTooLarge" });
} }
files.push(item); files.push(item);
...@@ -168,41 +211,63 @@ export function FileUploader(props: Props) { ...@@ -168,41 +211,63 @@ export function FileUploader(props: Props) {
document.removeEventListener("dragover", dragover); document.removeEventListener("dragover", dragover);
document.removeEventListener("drop", drop); 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; const { style, previewURL, defaultPreview, width, height } = props;
return ( return (
<div className={classNames(styles.uploader, <div
{ [styles.icon]: style === 'icon', className={classNames(styles.uploader, {
[styles.banner]: style === 'banner' })} [styles.icon]: style === "icon",
[styles.banner]: style === "banner",
})}
data-uploading={uploading}> data-uploading={uploading}>
<div className={styles.image} <div
style={{ backgroundImage: className={styles.image}
style === 'icon' ? `url('${previewURL ?? defaultPreview}')` : style={{
(previewURL ? `linear-gradient( rgba(0, 0, 0, 0.5), rgba(0, 0, 0, 0.5) ), url('${previewURL}')` : 'black'), backgroundImage:
width, height 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}> onClick={onClick}>
{ uploading ? {uploading ? (
<div className={styles.uploading}> <div className={styles.uploading}>
<Preloader /> <Preloader type="ring" />
</div> : </div>
) : (
<div className={styles.edit}> <div className={styles.edit}>
<Edit size={30} /> <Pencil size={30} />
</div> } </div>
)}
</div> </div>
<div className={styles.modify}> <div className={styles.modify}>
<span onClick={removeOrUpload}>{ <span onClick={removeOrUpload}>
uploading ? <Text id="app.main.channel.uploading_file" /> : {uploading ? (
props.previewURL ? <Text id="app.settings.actions.remove" /> : <Text id="app.main.channel.uploading_file" />
<Text id="app.settings.actions.upload" /> }</span> ) : props.previewURL ? (
<span className={styles.small}><Text id="app.settings.actions.max_filesize" fields={{ filesize: determineFileSize(maxFileSize) }} /></span> <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>
</div> </div>
) );
} else if (props.style === 'attachment') { } else if (props.style === "attachment") {
const { attached, uploading, cancel, size } = props; const { attached, uploading, cancel, size } = props;
return ( return (
<IconButton <IconButton
...@@ -210,10 +275,11 @@ export function FileUploader(props: Props) { ...@@ -210,10 +275,11 @@ export function FileUploader(props: Props) {
if (uploading) return cancel(); if (uploading) return cancel();
if (attached) return remove(); if (attached) return remove();
onClick(); onClick();
}}> }}
{ uploading ? <XCircle size={size} /> : attached ? <X size={size} /> : <Plus size={size} />} rotate={uploading || attached ? "45deg" : undefined}>
<Plus size={size} />
</IconButton> </IconButton>
) );
} }
return null; 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 { decodeTime } from "ulid";
import { AppContext } from "./RevoltClient";
import { useCallback, useContext, useEffect } from "preact/hooks";
import { useTranslation } from "../../lib/i18n"; 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 { connectState } from "../../redux/connector";
import { playSound } from "../../assets/sounds/Audio"; import {
import { Message, SYSTEM_USER_ID, User } from "revolt.js"; getNotificationState,
Notifications,
shouldNotify,
} from "../../redux/reducers/notifications";
import { NotificationOptions } from "../../redux/reducers/settings"; import { NotificationOptions } from "../../redux/reducers/settings";
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { SoundContext } from "../Settings";
import { AppContext } from "./RevoltClient";
interface Props { interface Props {
options?: NotificationOptions; options?: NotificationOptions;
notifs: Notifications;
} }
const notifications: { [key: string]: Notification } = {}; const notifications: { [key: string]: Notification } = {};
async function createNotification(title: string, options: globalThis.NotificationOptions) { async function createNotification(
title: string,
options: globalThis.NotificationOptions,
) {
try { try {
return new Notification(title, options); return new Notification(title, options);
} catch (err) { } catch (err) {
let sw = await navigator.serviceWorker.getRegistration(); const sw = await navigator.serviceWorker.getRegistration();
sw?.showNotification(title, options); sw?.showNotification(title, options);
} }
} }
function Notifier(props: Props) { function Notifier({ options, notifs }: Props) {
const translate = useTranslation(); const translate = useTranslation();
const showNotification = props.options?.desktopEnabled ?? false; const showNotification = options?.desktopEnabled ?? false;
// const playIncoming = props.options?.soundEnabled ?? true;
// const playOutgoing = props.options?.outgoingSoundEnabled ?? true;
const client = useContext(AppContext); const client = useContext(AppContext);
const { guild: guild_id, channel: channel_id } = useParams<{ const { guild: guild_id, channel: channel_id } = useParams<{
...@@ -36,164 +49,227 @@ function Notifier(props: Props) { ...@@ -36,164 +49,227 @@ function Notifier(props: Props) {
channel: string; channel: string;
}>(); }>();
const history = useHistory(); const history = useHistory();
const playSound = useContext(SoundContext);
async function message(msg: Message) { const message = useCallback(
if (msg.author === client.user!._id) return; async (msg: Message) => {
if (msg.channel === channel_id && document.hasFocus()) return; if (msg.author_id === client.user!._id) return;
if (client.user?.status?.presence === Users.Presence.Busy) return; if (msg.channel_id === channel_id && document.hasFocus()) return;
if (client.user!.status?.presence === Presence.Busy) return;
// Sounds.playInbound(); if (msg.author?.relationship === RelationshipStatus.Blocked) return;
playSound('message');
if (!showNotification) return;
const channel = client.channels.get(msg.channel);
const author = client.users.get(msg.author);
if (author?.relationship === Users.Relationship.Blocked) 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;
}
let image; const notifState = getNotificationState(notifs, msg.channel!);
if (msg.attachments) { if (!shouldNotify(notifState, msg, client.user!._id)) return;
let imageAttachment = msg.attachments.find(x => x.metadata.type === 'Image');
if (imageAttachment) { playSound("message");
image = client.generateFileURL(imageAttachment, { max_side: 720 }); if (!showNotification) return;
}
}
let body, icon; let title;
if (typeof msg.content === "string") { switch (msg.channel?.channel_type) {
body = client.markdownToText(msg.content); case "SavedMessages":
icon = client.users.getAvatarURL(msg.author, { max_side: 256 }); return;
} else { case "DirectMessage":
let users = client.users; title = `@${msg.author?.username}`;
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 });
break; break;
case "user_joined": case "Group":
case "user_left": if (msg.author?._id === SYSTEM_USER_ID) {
case "user_kicked": title = msg.channel.name;
case "user_banned": } else {
body = translate( title = `@${msg.author?.username} - ${msg.channel.name}`;
`app.main.channel.system.${msg.content.type}`, }
{ user: users.get(msg.content.id)?.username }
);
icon = client.users.getAvatarURL(msg.content.id, { max_side: 256 });
break; break;
case "channel_renamed": case "TextChannel":
body = translate( title = `@${msg.author?.username} (#${msg.channel.name}, ${msg.channel.server?.name})`;
`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 });
break; break;
case "channel_description_changed": default:
case "channel_icon_changed": title = msg.channel?._id;
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 });
break; break;
} }
}
let notif = await createNotification(title, { let image;
icon, if (msg.attachments) {
image, const imageAttachment = msg.attachments.find(
body, (x) => x.metadata.type === "Image",
timestamp: decodeTime(msg._id), );
tag: msg.channel, if (imageAttachment) {
badge: '/assets/icons/android-chrome-512x512.png', image = client.generateFileURL(imageAttachment, {
silent: true max_side: 720,
}); });
}
if (notif) { }
notif.addEventListener("click", () => {
const id = msg.channel; let body, icon;
if (id !== channel_id) { if (typeof msg.content === "string") {
let channel = client.channels.get(id); body = client.markdownToText(msg.content);
if (channel) { icon = msg.author?.generateAvatarURL({ max_side: 256 });
if (channel.channel_type === 'TextChannel') { } else {
history.push(`/server/${channel.server}/channel/${id}`); const users = client.users;
} else { switch (msg.content.type) {
history.push(`/channel/${id}`); 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; if (notif) {
notif.addEventListener( notif.addEventListener("click", () => {
"close", window.focus();
() => delete notifications[msg.channel] 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) { notifications[msg.channel_id] = notif;
if (client.user?.status?.presence === Users.Presence.Busy) return; notif.addEventListener(
if (property !== "relationship") return; "close",
if (!showNotification) return; () => delete notifications[msg.channel_id],
);
let event; }
switch (user.relationship) { },
case Users.Relationship.Incoming: [
event = translate("notifications.sent_request", { person: user.username }); history,
break; showNotification,
case Users.Relationship.Friend: translate,
event = translate("notifications.now_friends", { person: user.username }); channel_id,
break; client,
default: notifs,
return; playSound,
} ],
);
let notif = await createNotification(event, { const relationship = useCallback(
icon: client.users.getAvatarURL(user._id, { max_side: 256 }), async (user: User) => {
badge: '/assets/icons/android-chrome-512x512.png', if (client.user?.status?.presence === Presence.Busy) return;
timestamp: +new Date() if (!showNotification) return;
});
notif?.addEventListener("click", () => { let event;
history.push(`/friends`); 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(() => { useEffect(() => {
client.addListener("message", message); client.addListener("message", message);
client.users.addListener("mutation", relationship); client.addListener("user/relationship", relationship);
return () => { return () => {
client.removeListener("message", message); client.removeListener("message", message);
client.users.removeListener("mutation", relationship); client.removeListener("user/relationship", relationship);
}; };
}, [client, guild_id, channel_id, showNotification]); }, [
client,
playSound,
guild_id,
channel_id,
showNotification,
notifs,
message,
relationship,
]);
useEffect(() => { useEffect(() => {
function visChange() { function visChange() {
...@@ -211,22 +287,26 @@ function Notifier(props: Props) { ...@@ -211,22 +287,26 @@ function Notifier(props: Props) {
document.removeEventListener("visibilitychange", visChange); document.removeEventListener("visibilitychange", visChange);
}, [guild_id, channel_id]); }, [guild_id, channel_id]);
return <></>; return null;
} }
const NotifierComponent = connectState( const NotifierComponent = connectState(
Notifier, Notifier,
state => { (state) => {
return { return {
options: state.settings.notification options: state.settings.notification,
notifs: state.notifications,
}; };
}, },
true true,
); );
export default function Notifications() { export default function NotificationsComponent() {
return ( return (
<Switch> <Switch>
<Route path="/server/:server/channel/:channel">
<NotifierComponent />
</Route>
<Route path="/channel/:channel"> <Route path="/channel/:channel">
<NotifierComponent /> <NotifierComponent />
</Route> </Route>
......
import { Text } from "preact-i18n"; import { WifiOff } from "@styled-icons/boxicons-regular";
import styled from "styled-components"; import styled from "styled-components";
import { Text } from "preact-i18n";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { Children } from "../../types/Preact";
import { WifiOff } from "@styled-icons/feather";
import Preloader from "../../components/ui/Preloader"; import Preloader from "../../components/ui/Preloader";
import { Children } from "../../types/Preact";
import { ClientStatus, StatusContext } from "./RevoltClient"; import { ClientStatus, StatusContext } from "./RevoltClient";
interface Props { interface Props {
...@@ -29,7 +32,7 @@ const Base = styled.div` ...@@ -29,7 +32,7 @@ const Base = styled.div`
export default function RequiresOnline(props: Props) { export default function RequiresOnline(props: Props) {
const status = useContext(StatusContext); const status = useContext(StatusContext);
if (status === ClientStatus.CONNECTING) return <Preloader />; if (status === ClientStatus.CONNECTING) return <Preloader type="ring" />;
if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY) if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY)
return ( return (
<Base> <Base>
...@@ -40,5 +43,5 @@ export default function RequiresOnline(props: Props) { ...@@ -40,5 +43,5 @@ export default function RequiresOnline(props: Props) {
</Base> </Base>
); );
return <>{ props.children }</>; return <>{props.children}</>;
} }
import { openDB } from 'idb'; /* eslint-disable react-hooks/rules-of-hooks */
import { Client } from "revolt.js"; import { Client } from "revolt.js";
import { takeError } from "./util";
import { createContext } from "preact";
import { Children } from "../../types/Preact";
import { Route } from "revolt.js/dist/api/routes"; 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 { connectState } from "../../redux/connector";
import Preloader from "../../components/ui/Preloader";
import { WithDispatcher } from "../../redux/reducers";
import { AuthState } from "../../redux/reducers/auth"; import { AuthState } from "../../redux/reducers/auth";
import { SyncOptions } from "../../redux/reducers/sync";
import { useEffect, useMemo, useState } from "preact/hooks"; import Preloader from "../../components/ui/Preloader";
import { useIntermediate } from '../intermediate/Intermediate';
import { Children } from "../../types/Preact";
import { useIntermediate } from "../intermediate/Intermediate";
import { registerEvents, setReconnectDisallowed } from "./events"; import { registerEvents, setReconnectDisallowed } from "./events";
import { SingletonMessageRenderer } from '../../lib/renderer/Singleton'; import { takeError } from "./util";
export enum ClientStatus { export enum ClientStatus {
INIT, INIT,
...@@ -32,74 +36,60 @@ export interface ClientOperations { ...@@ -32,74 +36,60 @@ export interface ClientOperations {
ready: () => boolean; ready: () => boolean;
} }
export const AppContext = createContext<Client>(undefined as any); // By the time they are used, they should all be initialized.
export const StatusContext = createContext<ClientStatus>(undefined as any); // Currently the app does not render until a client is built and the other two are always initialized on first render.
export const OperationsContext = createContext<ClientOperations>(undefined as any); // - 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; auth: AuthState;
sync: SyncOptions;
children: Children; children: Children;
}; };
function Context({ auth, sync, children, dispatcher }: Props) { function Context({ auth, children }: Props) {
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const [status, setStatus] = useState(ClientStatus.INIT); 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(() => { useEffect(() => {
(async () => { (async () => {
let db;
try {
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({ const client = new Client({
autoReconnect: false, autoReconnect: false,
apiURL: import.meta.env.VITE_API_URL, apiURL: import.meta.env.VITE_API_URL,
debug: import.meta.env.DEV, debug: import.meta.env.DEV,
db
}); });
setClient(client); setClient(client);
SingletonMessageRenderer.subscribe(client); SingletonMessageRenderer.subscribe(client);
setStatus(ClientStatus.LOADING); setStatus(ClientStatus.LOADING);
})(); })();
}, [ ]); }, []);
if (status === ClientStatus.INIT) return null; if (status === ClientStatus.INIT) return null;
const operations: ClientOperations = useMemo(() => { const operations: ClientOperations = useMemo(() => {
return { return {
login: async data => { login: async (data) => {
setReconnectDisallowed(true); setReconnectDisallowed(true);
try { try {
const onboarding = await client.login(data); const onboarding = await client.login(data);
setReconnectDisallowed(false); setReconnectDisallowed(false);
const login = () => const login = () =>
dispatcher({ dispatch({
type: "LOGIN", 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) { if (onboarding) {
openScreen({ openScreen({
id: "onboarding", id: "onboarding",
callback: async (username: string) => { callback: async (username: string) =>
await (onboarding as any)(username, true); onboarding(username, true).then(login),
login();
}
}); });
} else { } else {
login(); login();
...@@ -109,16 +99,11 @@ function Context({ auth, sync, children, dispatcher }: Props) { ...@@ -109,16 +99,11 @@ function Context({ auth, sync, children, dispatcher }: Props) {
throw err; throw err;
} }
}, },
logout: async shouldRequest => { logout: async (shouldRequest) => {
dispatcher({ type: "LOGOUT" }); dispatch({ type: "LOGOUT" });
delete client.user; client.reset();
// ! FIXME: write procedure client.clear(); dispatch({ type: "RESET" });
client.users.clear();
client.channels.clear();
client.servers.clear();
client.servers.members.clear();
dispatcher({ type: "RESET" });
openScreen({ id: "none" }); openScreen({ id: "none" });
setStatus(ClientStatus.READY); setStatus(ClientStatus.READY);
...@@ -134,23 +119,20 @@ function Context({ auth, sync, children, dispatcher }: Props) { ...@@ -134,23 +119,20 @@ function Context({ auth, sync, children, dispatcher }: Props) {
} }
}, },
loggedIn: () => typeof auth.active !== "undefined", loggedIn: () => typeof auth.active !== "undefined",
ready: () => ( ready: () =>
operations.loggedIn() && operations.loggedIn() && typeof client.user !== "undefined",
typeof client.user !== "undefined" };
) }, [client, auth.active, openScreen]);
}
}, [ client, auth.active ]);
useEffect(() => registerEvents({ operations, dispatcher }, setStatus, client), [ client ]); useEffect(
() => registerEvents({ operations }, setStatus, client),
[client, operations],
);
useEffect(() => { useEffect(() => {
(async () => { (async () => {
if (client.db) {
await client.restore();
}
if (auth.active) { if (auth.active) {
dispatcher({ type: "QUEUE_FAIL_ALL" }); dispatch({ type: "QUEUE_FAIL_ALL" });
const active = auth.accounts[auth.active]; const active = auth.accounts[auth.active];
client.user = client.users.get(active.session.user_id); client.user = client.users.get(active.session.user_id);
...@@ -158,21 +140,20 @@ function Context({ auth, sync, children, dispatcher }: Props) { ...@@ -158,21 +140,20 @@ function Context({ auth, sync, children, dispatcher }: Props) {
return setStatus(ClientStatus.OFFLINE); return setStatus(ClientStatus.OFFLINE);
} }
if (operations.ready()) if (operations.ready()) setStatus(ClientStatus.CONNECTING);
setStatus(ClientStatus.CONNECTING);
if (navigator.onLine) { if (navigator.onLine) {
await client await client
.fetchConfiguration() .fetchConfiguration()
.catch(() => .catch(() =>
console.error("Failed to connect to API server.") console.error("Failed to connect to API server."),
); );
} }
try { try {
await client.fetchConfiguration(); await client.fetchConfiguration();
const callback = await client.useExistingSession( const callback = await client.useExistingSession(
active.session active.session,
); );
if (callback) { if (callback) {
...@@ -190,7 +171,7 @@ function Context({ auth, sync, children, dispatcher }: Props) { ...@@ -190,7 +171,7 @@ function Context({ auth, sync, children, dispatcher }: Props) {
} }
} else { } else {
try { try {
await client.fetchConfiguration() await client.fetchConfiguration();
} catch (err) { } catch (err) {
console.error("Failed to connect to API server."); console.error("Failed to connect to API server.");
} }
...@@ -198,30 +179,29 @@ function Context({ auth, sync, children, dispatcher }: Props) { ...@@ -198,30 +179,29 @@ function Context({ auth, sync, children, dispatcher }: Props) {
setStatus(ClientStatus.READY); setStatus(ClientStatus.READY);
} }
})(); })();
// eslint-disable-next-line
}, []); }, []);
if (status === ClientStatus.LOADING) { if (status === ClientStatus.LOADING) {
return <Preloader />; return <Preloader type="spinner" />;
} }
return ( return (
<AppContext.Provider value={client}> <AppContext.Provider value={client}>
<StatusContext.Provider value={status}> <StatusContext.Provider value={status}>
<OperationsContext.Provider value={operations}> <OperationsContext.Provider value={operations}>
{ children } {children}
</OperationsContext.Provider> </OperationsContext.Provider>
</StatusContext.Provider> </StatusContext.Provider>
</AppContext.Provider> </AppContext.Provider>
); );
} }
export default connectState<{ children: Children }>( export default connectState<{ children: Children }>(Context, (state) => {
Context, return {
state => { auth: state.auth,
return { sync: state.sync,
auth: state.auth, };
sync: state.sync });
};
}, export const useClient = () => useContext(AppContext);
true
);
/** /**
* This file monitors the message cache to delete any queued messages that have already sent. * 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 { useContext, useEffect } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import { WithDispatcher } from "../../redux/reducers";
import { QueuedMessage } from "../../redux/reducers/queue"; import { QueuedMessage } from "../../redux/reducers/queue";
type Props = WithDispatcher & { import { AppContext } from "./RevoltClient";
type Props = {
messages: QueuedMessage[]; messages: QueuedMessage[];
typing: Typing
}; };
function StateMonitor(props: Props) { function StateMonitor(props: Props) {
const client = useContext(AppContext); const client = useContext(AppContext);
useEffect(() => { useEffect(() => {
props.dispatcher({ dispatch({
type: 'QUEUE_DROP_ALL' type: "QUEUE_DROP_ALL",
}); });
}, [ ]); }, []);
useEffect(() => { useEffect(() => {
function add(msg: Message) { function add(msg: Message) {
if (!msg.nonce) return; 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({ dispatch({
type: 'QUEUE_REMOVE', type: "QUEUE_REMOVE",
nonce: msg.nonce nonce: msg.nonce,
}); });
} }
client.addListener('message', add); client.addListener("message", add);
return () => client.removeListener('message', add); return () => client.removeListener("message", add);
}, [ props.messages ]); }, [client, 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 ]);
return <></>; return null;
} }
export default connectState( export default connectState(StateMonitor, (state) => {
StateMonitor, return {
state => { messages: [...state.queue],
return { };
messages: [...state.queue], });
typing: state.typing
};
},
true
);
/** /**
* This file monitors changes to settings and syncs them to the server. * This file monitors changes to settings and syncs them to the server.
*/ */
import isEqual from "lodash.isequal"; import isEqual from "lodash.isequal";
import { Language } from "../Locale"; import { UserSettings } from "revolt-api/types/Sync";
import { Sync } from "revolt.js/dist/api/objects"; import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { useContext, useEffect } from "preact/hooks";
import { useCallback, useContext, useEffect, useMemo } from "preact/hooks";
import { dispatch } from "../../redux";
import { connectState } from "../../redux/connector"; import { connectState } from "../../redux/connector";
import { WithDispatcher } from "../../redux/reducers"; import { Notifications } from "../../redux/reducers/notifications";
import { Settings } from "../../redux/reducers/settings"; import { Settings } from "../../redux/reducers/settings";
import {
DEFAULT_ENABLED_SYNC,
SyncData,
SyncKeys,
SyncOptions,
} from "../../redux/reducers/sync";
import { Language } from "../Locale";
import { AppContext, ClientStatus, StatusContext } from "./RevoltClient"; import { AppContext, ClientStatus, StatusContext } from "./RevoltClient";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { DEFAULT_ENABLED_SYNC, SyncData, SyncKeys, SyncOptions } from "../../redux/reducers/sync";
type Props = WithDispatcher & { type Props = {
settings: Settings, settings: Settings;
locale: Language, locale: Language;
sync: SyncOptions sync: SyncOptions;
notifications: Notifications;
}; };
var lastValues: { [key in SyncKeys]?: any } = { }; const lastValues: { [key in SyncKeys]?: unknown } = {};
export function mapSync(packet: Sync.UserSettings, revision?: { [key: string]: number }) { export function mapSync(
let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = {}; packet: UserSettings,
for (let key of Object.keys(packet)) { revision?: Record<string, number>,
let [ timestamp, obj ] = packet[key]; ) {
if (timestamp < (revision ?? {} as any)[key] ?? 0) { const update: { [key in SyncKeys]?: [number, SyncData[key]] } = {};
for (const key of Object.keys(packet)) {
const [timestamp, obj] = packet[key];
if (timestamp < (revision ?? {})[key] ?? 0) {
continue; continue;
} }
let object; let object;
if (obj[0] === '{') { if (obj[0] === "{") {
object = JSON.parse(obj) object = JSON.parse(obj);
} else { } else {
object = obj; object = obj;
} }
lastValues[key as SyncKeys] = object; lastValues[key as SyncKeys] = object;
update[key as SyncKeys] = [ timestamp, object ]; update[key as SyncKeys] = [timestamp, object];
} }
return update; return update;
...@@ -50,38 +62,57 @@ function SyncManager(props: Props) { ...@@ -50,38 +62,57 @@ function SyncManager(props: Props) {
useEffect(() => { useEffect(() => {
if (status === ClientStatus.ONLINE) { if (status === ClientStatus.ONLINE) {
client client
.syncFetchSettings(DEFAULT_ENABLED_SYNC.filter(x => !props.sync?.disabled?.includes(x))) .syncFetchSettings(
.then(data => { DEFAULT_ENABLED_SYNC.filter(
props.dispatcher({ (x) => !props.sync?.disabled?.includes(x),
type: 'SYNC_UPDATE', ),
update: mapSync(data) )
.then((data) => {
dispatch({
type: "SYNC_UPDATE",
update: mapSync(data),
}); });
}); });
client client
.syncFetchUnreads() .syncFetchUnreads()
.then(unreads => props.dispatcher({ type: 'UNREADS_SET', unreads })); .then((unreads) => dispatch({ type: "UNREADS_SET", unreads }));
} }
}, [ status ]); }, [client, props.sync?.disabled, status]);
function syncChange(key: SyncKeys, data: any) { const syncChange = useCallback(
let timestamp = + new Date(); (key: SyncKeys, data: unknown) => {
props.dispatcher({ const timestamp = +new Date();
type: 'SYNC_SET_REVISION', dispatch({
key, type: "SYNC_SET_REVISION",
timestamp key,
}); timestamp,
});
client.syncSetSettings({
[key]: data client.syncSetSettings(
}, timestamp); {
} [key]: data as string,
},
let disabled = props.sync.disabled ?? []; timestamp,
for (let [key, object] of [ ['appearance', props.settings.appearance], ['theme', props.settings.theme], ['locale', props.locale] ] as [SyncKeys, any][]) { );
},
[client],
);
const disabled = useMemo(
() => props.sync.disabled ?? [],
[props.sync.disabled],
);
for (const [key, object] of [
["appearance", props.settings.appearance],
["theme", props.settings.theme],
["locale", props.locale],
["notifications", props.notifications],
] as [SyncKeys, unknown][]) {
// eslint-disable-next-line react-hooks/rules-of-hooks
useEffect(() => { useEffect(() => {
if (disabled.indexOf(key) === -1) { if (disabled.indexOf(key) === -1) {
if (typeof lastValues[key] !== 'undefined') { if (typeof lastValues[key] !== "undefined") {
if (!isEqual(lastValues[key], object)) { if (!isEqual(lastValues[key], object)) {
syncChange(key, object); syncChange(key, object);
} }
...@@ -89,36 +120,34 @@ function SyncManager(props: Props) { ...@@ -89,36 +120,34 @@ function SyncManager(props: Props) {
} }
lastValues[key] = object; lastValues[key] = object;
}, [ disabled, object ]); }, [key, syncChange, disabled, object]);
} }
useEffect(() => { useEffect(() => {
function onPacket(packet: ClientboundNotification) { function onPacket(packet: ClientboundNotification) {
if (packet.type === 'UserSettingsUpdate') { if (packet.type === "UserSettingsUpdate") {
let update: { [key in SyncKeys]?: [ number, SyncData[key] ] } = mapSync(packet.update, props.sync.revision); const update: { [key in SyncKeys]?: [number, SyncData[key]] } =
mapSync(packet.update, props.sync.revision);
props.dispatcher({ dispatch({
type: 'SYNC_UPDATE', type: "SYNC_UPDATE",
update update,
}); });
} }
} }
client.addListener('packet', onPacket); client.addListener("packet", onPacket);
return () => client.removeListener('packet', onPacket); return () => client.removeListener("packet", onPacket);
}, [ disabled, props.sync ]); }, [client, disabled, props.sync]);
return <></>; return null;
} }
export default connectState( export default connectState(SyncManager, (state) => {
SyncManager, return {
state => { settings: state.settings,
return { locale: state.locale,
settings: state.settings, sync: state.sync,
locale: state.locale, notifications: state.notifications,
sync: state.sync };
}; });
},
true
);
import { Client } from "revolt.js/dist";
import { Message } from "revolt.js/dist/maps/Messages";
import { ClientboundNotification } from "revolt.js/dist/websocket/notifications"; import { ClientboundNotification } from "revolt.js/dist/websocket/notifications";
import { WithDispatcher } from "../../redux/reducers";
import { Client, Message } from "revolt.js/dist";
import {
ClientOperations,
ClientStatus
} from "./RevoltClient";
import { StateUpdater } from "preact/hooks"; import { StateUpdater } from "preact/hooks";
export var preventReconnect = false; import { dispatch } from "../../redux";
import { ClientOperations, ClientStatus } from "./RevoltClient";
export let preventReconnect = false;
let preventUntil = 0; let preventUntil = 0;
export function setReconnectDisallowed(allowed: boolean) { export function setReconnectDisallowed(allowed: boolean) {
preventReconnect = allowed; preventReconnect = allowed;
} }
export function registerEvents({ export function registerEvents(
operations, { operations }: { operations: ClientOperations },
dispatcher setStatus: StateUpdater<ClientStatus>,
}: { operations: ClientOperations } & WithDispatcher, setStatus: StateUpdater<ClientStatus>, client: Client) { client: Client,
const listeners = { ) {
function attemptReconnect() {
if (preventReconnect) return;
function reconnect() {
preventUntil = +new Date() + 2000;
client.websocket.connect().catch((err) => console.error(err));
}
if (+new Date() > preventUntil) {
setTimeout(reconnect, 2000);
} else {
reconnect();
}
}
// eslint-disable-next-line @typescript-eslint/no-explicit-any
let listeners: Record<string, (...args: any[]) => void> = {
connecting: () => connecting: () =>
operations.ready() && setStatus(ClientStatus.CONNECTING), operations.ready() && setStatus(ClientStatus.CONNECTING),
dropped: () => { dropped: () => {
operations.ready() && setStatus(ClientStatus.DISCONNECTED); if (operations.ready()) {
setStatus(ClientStatus.DISCONNECTED);
if (preventReconnect) return; attemptReconnect();
function reconnect() {
preventUntil = +new Date() + 2000;
client.websocket.connect().catch(err => console.error(err));
}
if (+new Date() > preventUntil) {
setTimeout(reconnect, 2000);
} else {
reconnect();
} }
}, },
packet: (packet: ClientboundNotification) => { packet: (packet: ClientboundNotification) => {
switch (packet.type) { switch (packet.type) {
case "ChannelStartTyping": {
if (packet.user === client.user?._id) return;
dispatcher({
type: "TYPING_START",
channel: packet.id,
user: packet.user
});
break;
}
case "ChannelStopTyping": {
if (packet.user === client.user?._id) return;
dispatcher({
type: "TYPING_STOP",
channel: packet.id,
user: packet.user
});
break;
}
case "ChannelAck": { case "ChannelAck": {
dispatcher({ dispatch({
type: "UNREADS_MARK_READ", type: "UNREADS_MARK_READ",
channel: packet.id, channel: packet.id,
message: packet.message_id, message: packet.message_id,
request: false
}); });
break; break;
} }
...@@ -71,51 +60,62 @@ export function registerEvents({ ...@@ -71,51 +60,62 @@ export function registerEvents({
}, },
message: (message: Message) => { message: (message: Message) => {
if (message.mentions?.includes(client.user!._id)) { if (message.mention_ids?.includes(client.user!._id)) {
dispatcher({ dispatch({
type: "UNREADS_MENTION", type: "UNREADS_MENTION",
channel: message.channel, channel: message.channel_id,
message: message._id message: message._id,
}); });
} }
}, },
ready: () => { ready: () => setStatus(ClientStatus.ONLINE),
setStatus(ClientStatus.ONLINE);
}
}; };
let listenerFunc: { [key: string]: Function };
if (import.meta.env.DEV) { if (import.meta.env.DEV) {
listenerFunc = {}; listeners = new Proxy(listeners, {
for (const listener of Object.keys(listeners)) { get:
listenerFunc[listener] = (...args: any[]) => { (target, listener) =>
console.debug(`Calling ${listener} with`, args); (...args: unknown[]) => {
(listeners as any)[listener](...args); console.debug(`Calling ${listener.toString()} with`, args);
}; Reflect.get(target, listener)(...args);
} },
} else { });
listenerFunc = listeners;
} }
for (const listener of Object.keys(listenerFunc)) { // TODO: clean this a bit and properly handle types
client.addListener(listener, (listenerFunc as any)[listener]); for (const listener in listeners) {
client.addListener(listener, listeners[listener]);
} }
/*const online = () => const online = () => {
operations.ready() && setStatus(ClientStatus.RECONNECTING); if (operations.ready()) {
const offline = () => setStatus(ClientStatus.RECONNECTING);
operations.ready() && setStatus(ClientStatus.OFFLINE); setReconnectDisallowed(false);
attemptReconnect();
}
};
const offline = () => {
if (operations.ready()) {
setReconnectDisallowed(true);
client.websocket.disconnect();
setStatus(ClientStatus.OFFLINE);
}
};
window.addEventListener("online", online); window.addEventListener("online", online);
window.addEventListener("offline", offline); window.addEventListener("offline", offline);
return () => { return () => {
for (const listener of Object.keys(listenerFunc)) { for (const listener in listeners) {
RevoltClient.removeListener(listener, (listenerFunc as any)[listener]); client.removeListener(
listener,
listeners[listener as keyof typeof listeners],
);
} }
window.removeEventListener("online", online); window.removeEventListener("online", online);
window.removeEventListener("offline", offline); window.removeEventListener("offline", offline);
};*/ };
} }
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { Channels, Servers, Users } from "revolt.js/dist/api/objects";
import { Client, PermissionCalculator } from 'revolt.js';
import { AppContext } from "./RevoltClient";
export interface HookContext {
client: Client,
forceUpdate: () => void
}
export function useForceUpdate(context?: HookContext): HookContext {
const client = useContext(AppContext);
if (context) return context;
const H = useState(undefined);
var updateState: (_: undefined) => void;
if (Array.isArray(H)) {
let [, u] = H;
updateState = u;
} else {
console.warn('Failed to construct using useState.');
console.warn(H);
updateState = ()=>{};
}
return { client, forceUpdate: useCallback(() => updateState(undefined), []) };
}
function useObject(type: string, id?: string | string[], context?: HookContext) {
const ctx = useForceUpdate(context);
function mutation(target: string) {
if (typeof id === 'string' ? target === id :
Array.isArray(id) ? id.includes(target) : true) {
ctx.forceUpdate();
}
}
const map = (ctx.client as any)[type];
useEffect(() => {
map.addListener("update", mutation);
return () => map.removeListener("update", mutation);
}, [id]);
return typeof id === 'string' ? map.get(id)
: Array.isArray(id) ? id.map(x => map.get(x))
: map.toArray();
}
export function useUser(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('users', id, context) as Readonly<Users.User> | undefined;
}
export function useSelf(context?: HookContext) {
const ctx = useForceUpdate(context);
return useUser(ctx.client.user!._id, ctx);
}
export function useUsers(ids?: string[], context?: HookContext) {
return useObject('users', ids, context) as (Readonly<Users.User> | undefined)[];
}
export function useChannel(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('channels', id, context) as Readonly<Channels.Channel> | undefined;
}
export function useChannels(ids?: string[], context?: HookContext) {
return useObject('channels', ids, context) as (Readonly<Channels.Channel> | undefined)[];
}
export function useServer(id?: string, context?: HookContext) {
if (typeof id === "undefined") return;
return useObject('servers', id, context) as Readonly<Servers.Server> | undefined;
}
export function useServers(ids?: string[], context?: HookContext) {
return useObject('servers', ids, context) as (Readonly<Servers.Server> | undefined)[];
}
export function useDMs(context?: HookContext) {
const ctx = useForceUpdate(context);
function mutation(target: string) {
let channel = ctx.client.channels.get(target);
if (channel) {
if ((channel.channel_type === 'DirectMessage' && channel.active) || channel.channel_type === 'Group') {
ctx.forceUpdate();
}
}
}
const map = ctx.client.channels;
useEffect(() => {
map.addListener("update", mutation);
return () => map.removeListener("update", mutation);
}, []);
return map
.toArray()
.filter(x => x.channel_type === 'DirectMessage' || x.channel_type === 'Group' || x.channel_type === 'SavedMessages') as (Channels.GroupChannel | Channels.DirectMessageChannel | Channels.SavedMessagesChannel)[];
}
export function useUserPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.users.addListener("update", mutation);
return () => ctx.client.users.removeListener("update", mutation);
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forUser(id);
}
export function useChannelPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.channels.addListener("update", mutation);
return () => ctx.client.channels.removeListener("update", mutation);
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forChannel(id);
}
export function useServerPermission(id: string, context?: HookContext) {
const ctx = useForceUpdate(context);
const mutation = (target: string) => (target === id) && ctx.forceUpdate();
useEffect(() => {
ctx.client.servers.addListener("update", mutation);
return () => ctx.client.servers.removeListener("update", mutation);
}, [id]);
let calculator = new PermissionCalculator(ctx.client);
return calculator.forServer(id);
}
import { Channel, Message, User } from "revolt.js/dist/api/objects"; import { Channel } from "revolt.js/dist/maps/Channels";
import { Children } from "../../types/Preact";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { Client } from "revolt.js";
export function takeError( import { Children } from "../../types/Preact";
error: any
): string { // eslint-disable-next-line @typescript-eslint/no-explicit-any
export function takeError(error: any): string {
const type = error?.response?.data?.type; const type = error?.response?.data?.type;
let id = type; const id = type;
if (!type) { if (!type) {
if (error?.response?.status === 403) { if (error?.response?.status === 403) {
return "Unauthorized"; return "Unauthorized";
} else if (error && (!!error.isAxiosError && !error.response)) { } else if (error && !!error.isAxiosError && !error.response) {
return "NetworkError"; return "NetworkError";
} }
...@@ -22,13 +22,20 @@ export function takeError( ...@@ -22,13 +22,20 @@ export function takeError(
return id; return id;
} }
export function getChannelName(client: Client, channel: Channel, prefixType?: boolean): Children { export function getChannelName(
channel: Channel,
prefixType?: boolean,
): Children {
if (channel.channel_type === "SavedMessages") if (channel.channel_type === "SavedMessages")
return <Text id="app.navigation.tabs.saved" />; return <Text id="app.navigation.tabs.saved" />;
if (channel.channel_type === "DirectMessage") { if (channel.channel_type === "DirectMessage") {
let uid = client.channels.getRecipient(channel._id); return (
return <>{prefixType && "@"}{client.users.get(uid)?.username}</>; <>
{prefixType && "@"}
{channel.recipient!.username}
</>
);
} }
if (channel.channel_type === "TextChannel" && prefixType) { if (channel.channel_type === "TextChannel" && prefixType) {
...@@ -37,12 +44,3 @@ export function getChannelName(client: Client, channel: Channel, prefixType?: bo ...@@ -37,12 +44,3 @@ export function getChannelName(client: Client, channel: Channel, prefixType?: bo
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;
}