merge: pull request #717 from revoltchat/chore/client-fsm

This commit is contained in:
Paul Makles 2022-07-01 15:12:04 +01:00 committed by GitHub
commit 5dfe72c093
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
126 changed files with 1761 additions and 1369 deletions

2
external/lang vendored

@ -1 +1 @@
Subproject commit 50838167d7d253de9d08715e6a6070c3ddc9fcc2 Subproject commit d4bc47b729c7e69ce97216469692b39f4cd1640e

View file

@ -106,6 +106,7 @@
"eslint": "^7.28.0", "eslint": "^7.28.0",
"eslint-config-preact": "^1.1.4", "eslint-config-preact": "^1.1.4",
"eslint-plugin-jsdoc": "^39.3.2", "eslint-plugin-jsdoc": "^39.3.2",
"eslint-plugin-mobx": "^0.0.8",
"eventemitter3": "^4.0.7", "eventemitter3": "^4.0.7",
"history": "4", "history": "4",
"json-stringify-deterministic": "^1.0.2", "json-stringify-deterministic": "^1.0.2",

View file

@ -3,9 +3,8 @@ import styled, { css } from "styled-components/macro";
import { StateUpdater, useState } from "preact/hooks"; import { StateUpdater, useState } from "preact/hooks";
import { useClient } from "../../context/revoltjs/RevoltClient";
import { emojiDictionary } from "../../assets/emojis"; import { emojiDictionary } from "../../assets/emojis";
import { useClient } from "../../controllers/client/ClientController";
import ChannelIcon from "./ChannelIcon"; import ChannelIcon from "./ChannelIcon";
import Emoji from "./Emoji"; import Emoji from "./Emoji";
import UserIcon from "./user/UserIcon"; import UserIcon from "./user/UserIcon";

View file

@ -2,12 +2,9 @@ import { Hash, VolumeFull } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js"; import { Channel } from "revolt.js";
import { useContext } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import fallback from "./assets/group.png"; import fallback from "./assets/group.png";
import { useClient } from "../../controllers/client/ClientController";
import { ImageIconBase, IconBaseProps } from "./IconBase"; import { ImageIconBase, IconBaseProps } from "./IconBase";
interface Props extends IconBaseProps<Channel> { interface Props extends IconBaseProps<Channel> {
@ -22,7 +19,7 @@ export default observer(
keyof Props | "children" | "as" keyof Props | "children" | "as"
>, >,
) => { ) => {
const client = useContext(AppContext); const client = useClient();
const { const {
size, size,

View file

@ -9,6 +9,7 @@ import { Text } from "preact-i18n";
import { IconButton } from "@revoltchat/ui"; import { IconButton } from "@revoltchat/ui";
import { modalController } from "../../controllers/modals/ModalController";
import Tooltip from "./Tooltip"; import Tooltip from "./Tooltip";
interface Props { interface Props {
@ -60,6 +61,9 @@ const ServerBanner = styled.div<Omit<Props, "server">>`
overflow: hidden; overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
flex-grow: 1; flex-grow: 1;
cursor: pointer;
color: var(--foreground);
} }
} }
`; `;
@ -121,7 +125,13 @@ export default observer(({ server }: Props) => {
</svg> </svg>
</Tooltip> </Tooltip>
) : undefined} ) : undefined}
<div className="title">{server.name}</div> <a
className="title"
onClick={() =>
modalController.push({ type: "server_info", server })
}>
{server.name}
</a>
{server.havePermission("ManageServer") && ( {server.havePermission("ManageServer") && (
<Link to={`/server/${server._id}/settings`}> <Link to={`/server/${server._id}/settings`}>
<IconButton> <IconButton>

View file

@ -4,8 +4,7 @@ import styled from "styled-components/macro";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { AppContext } from "../../context/revoltjs/RevoltClient"; import { useClient } from "../../controllers/client/ClientController";
import { IconBaseProps, ImageIconBase } from "./IconBase"; import { IconBaseProps, ImageIconBase } from "./IconBase";
interface Props extends IconBaseProps<Server> { interface Props extends IconBaseProps<Server> {
@ -34,7 +33,7 @@ export default observer(
keyof Props | "children" | "as" keyof Props | "children" | "as"
>, >,
) => { ) => {
const client = useContext(AppContext); const client = useClient();
const { target, attachment, size, animate, server_name, ...imgProps } = const { target, attachment, size, animate, server_name, ...imgProps } =
props; props;

View file

@ -14,8 +14,8 @@ import { QueuedMessage } from "../../../mobx/stores/MessageQueue";
import { I18nError } from "../../../context/Locale"; import { I18nError } from "../../../context/Locale";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { modalController } from "../../../controllers/modals/ModalController";
import Markdown from "../../markdown/Markdown"; import Markdown from "../../markdown/Markdown";
import UserIcon from "../user/UserIcon"; import UserIcon from "../user/UserIcon";
import { Username } from "../user/UserShort"; import { Username } from "../user/UserShort";
@ -52,7 +52,7 @@ const Message = observer(
queued, queued,
hideReply, hideReply,
}: Props) => { }: Props) => {
const client = useClient(); const client = message.client;
const user = message.author; const user = message.author;
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
@ -70,7 +70,10 @@ const Message = observer(
: undefined; : undefined;
const openProfile = () => const openProfile = () =>
openScreen({ id: "profile", user_id: message.author_id }); modalController.push({
type: "user_profile",
user_id: message.author_id,
});
const handleUserClick = (e: MouseEvent) => { const handleUserClick = (e: MouseEvent) => {
if (e.shiftKey && user?._id) { if (e.shiftKey && user?._id) {

View file

@ -24,15 +24,15 @@ import {
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { Reply } from "../../../mobx/stores/MessageQueue"; import { Reply } from "../../../mobx/stores/MessageQueue";
import { modalController } from "../../../context/modals";
import { import {
FileUploader, FileUploader,
grabFiles, grabFiles,
uploadFile, uploadFile,
} from "../../../context/revoltjs/FileUploads"; } from "../../../context/revoltjs/FileUploads";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../context/revoltjs/util"; import { takeError } from "../../../context/revoltjs/util";
import { useClient } from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController";
import AutoComplete, { useAutoComplete } from "../AutoComplete"; import AutoComplete, { useAutoComplete } from "../AutoComplete";
import { PermissionTooltip } from "../Tooltip"; import { PermissionTooltip } from "../Tooltip";
import FilePreview from "./bars/FilePreview"; import FilePreview from "./bars/FilePreview";
@ -148,7 +148,7 @@ export default observer(({ channel }: Props) => {
}); });
const [typing, setTyping] = useState<boolean | number>(false); const [typing, setTyping] = useState<boolean | number>(false);
const [replies, setReplies] = useState<Reply[]>([]); const [replies, setReplies] = useState<Reply[]>([]);
const client = useContext(AppContext); const client = useClient();
const translate = useTranslation(); const translate = useTranslation();
const renderer = getRenderer(channel); const renderer = getRenderer(channel);

View file

@ -3,10 +3,9 @@ import { API } from "revolt.js";
import styles from "./Attachment.module.scss"; import styles from "./Attachment.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { useTriggerEvents } from "preact-context-menu"; import { useTriggerEvents } from "preact-context-menu";
import { useContext, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { AppContext } from "../../../../context/revoltjs/RevoltClient";
import { useClient } from "../../../../controllers/client/ClientController";
import AttachmentActions from "./AttachmentActions"; import AttachmentActions from "./AttachmentActions";
import { SizedGrid } from "./Grid"; import { SizedGrid } from "./Grid";
import ImageFile from "./ImageFile"; import ImageFile from "./ImageFile";
@ -21,7 +20,7 @@ interface Props {
const MAX_ATTACHMENT_WIDTH = 480; const MAX_ATTACHMENT_WIDTH = 480;
export default function Attachment({ attachment, hasContent }: Props) { export default function Attachment({ attachment, hasContent }: Props) {
const client = useContext(AppContext); const client = useClient();
const { filename, metadata } = attachment; const { filename, metadata } = attachment;
const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_")); const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_"));

View file

@ -15,14 +15,14 @@ import { IconButton } from "@revoltchat/ui";
import { determineFileSize } from "../../../../lib/fileSize"; import { determineFileSize } from "../../../../lib/fileSize";
import { AppContext } from "../../../../context/revoltjs/RevoltClient"; import { useClient } from "../../../../controllers/client/ClientController";
interface Props { interface Props {
attachment: API.File; attachment: API.File;
} }
export default function AttachmentActions({ attachment }: Props) { export default function AttachmentActions({ attachment }: Props) {
const client = useContext(AppContext); const client = useClient();
const { filename, metadata, size } = attachment; const { filename, metadata, size } = attachment;
const url = client.generateFileURL(attachment); const url = client.generateFileURL(attachment);

View file

@ -2,10 +2,10 @@ import { API } from "revolt.js";
import styles from "./Attachment.module.scss"; import styles from "./Attachment.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { useContext, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { useIntermediate } from "../../../../context/intermediate/Intermediate"; import { useClient } from "../../../../controllers/client/ClientController";
import { AppContext } from "../../../../context/revoltjs/RevoltClient"; import { modalController } from "../../../../controllers/modals/ModalController";
enum ImageLoadingState { enum ImageLoadingState {
Loading, Loading,
@ -19,8 +19,7 @@ type Props = JSX.HTMLAttributes<HTMLImageElement> & {
export default function ImageFile({ attachment, ...props }: Props) { export default function ImageFile({ attachment, ...props }: Props) {
const [loading, setLoading] = useState(ImageLoadingState.Loading); const [loading, setLoading] = useState(ImageLoadingState.Loading);
const client = useContext(AppContext); const client = useClient();
const { openScreen } = useIntermediate();
const url = client.generateFileURL(attachment)!; const url = client.generateFileURL(attachment)!;
return ( return (
@ -32,7 +31,9 @@ export default function ImageFile({ attachment, ...props }: Props) {
className={classNames(styles.image, { className={classNames(styles.image, {
[styles.loading]: loading !== ImageLoadingState.Loaded, [styles.loading]: loading !== ImageLoadingState.Loaded,
})} })}
onClick={() => openScreen({ id: "image_viewer", attachment })} onClick={() =>
modalController.push({ type: "image_viewer", attachment })
}
onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")} onMouseDown={(ev) => ev.button === 1 && window.open(url, "_blank")}
onLoad={() => setLoading(ImageLoadingState.Loaded)} onLoad={() => setLoading(ImageLoadingState.Loaded)}
onError={() => setLoading(ImageLoadingState.Error)} onError={() => setLoading(ImageLoadingState.Error)}

View file

@ -3,15 +3,12 @@ import { API } from "revolt.js";
import styles from "./Attachment.module.scss"; import styles from "./Attachment.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Button, Preloader } from "@revoltchat/ui"; import { Button, Preloader } from "@revoltchat/ui";
import RequiresOnline from "../../../../context/revoltjs/RequiresOnline"; import { useClient } from "../../../../controllers/client/ClientController";
import { import RequiresOnline from "../../../../controllers/client/jsx/RequiresOnline";
AppContext,
StatusContext,
} from "../../../../context/revoltjs/RevoltClient";
interface Props { interface Props {
attachment: API.File; attachment: API.File;
@ -23,9 +20,8 @@ export default function TextFile({ attachment }: Props) {
const [gated, setGated] = useState(attachment.size > 100_000); const [gated, setGated] = useState(attachment.size > 100_000);
const [content, setContent] = useState<undefined | string>(undefined); const [content, setContent] = useState<undefined | string>(undefined);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const status = useContext(StatusContext);
const client = useContext(AppContext);
const client = useClient();
const url = client.generateFileURL(attachment)!; const url = client.generateFileURL(attachment)!;
useEffect(() => { useEffect(() => {
@ -56,7 +52,7 @@ export default function TextFile({ attachment }: Props) {
setLoading(false); setLoading(false);
}); });
} }
}, [content, loading, gated, status, attachment._id, attachment.size, url]); }, [content, loading, gated, attachment._id, attachment.size, url]);
return ( return (
<div <div

View file

@ -7,7 +7,6 @@ import {
Notification, Notification,
} from "@styled-icons/boxicons-solid"; } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Permission } from "revolt.js";
import { Message as MessageObject } from "revolt.js"; import { Message as MessageObject } from "revolt.js";
import styled from "styled-components"; import styled from "styled-components";
@ -24,8 +23,8 @@ import {
Screen, Screen,
useIntermediate, useIntermediate,
} from "../../../../context/intermediate/Intermediate"; } from "../../../../context/intermediate/Intermediate";
import { useClient } from "../../../../context/revoltjs/RevoltClient";
import { useClient } from "../../../../controllers/client/ClientController";
import Tooltip from "../../../common/Tooltip"; import Tooltip from "../../../common/Tooltip";
interface Props { interface Props {
@ -88,7 +87,7 @@ const Divider = styled.div`
`; `;
export const MessageOverlayBar = observer(({ message, queued }: Props) => { export const MessageOverlayBar = observer(({ message, queued }: Props) => {
const client = useClient(); const client = message.client;
const { openScreen, writeClipboard } = useIntermediate(); const { openScreen, writeClipboard } = useIntermediate();
const isAuthor = message.author_id === client.user!._id; const isAuthor = message.author_id === client.user!._id;

View file

@ -5,8 +5,9 @@ import classNames from "classnames";
import { useContext } from "preact/hooks"; import { useContext } from "preact/hooks";
import { useIntermediate } from "../../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../../context/intermediate/Intermediate";
import { useClient } from "../../../../context/revoltjs/RevoltClient";
import { useClient } from "../../../../controllers/client/ClientController";
import { modalController } from "../../../../controllers/modals/ModalController";
import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea"; import { MessageAreaWidthContext } from "../../../../pages/channels/messaging/MessageArea";
import Markdown from "../../../markdown/Markdown"; import Markdown from "../../../markdown/Markdown";
import Attachment from "../attachments/Attachment"; import Attachment from "../attachments/Attachment";
@ -24,7 +25,7 @@ const MAX_PREVIEW_SIZE = 150;
export default function Embed({ embed }: Props) { export default function Embed({ embed }: Props) {
const client = useClient(); const client = useClient();
const { openScreen, openLink } = useIntermediate(); const { openLink } = useIntermediate();
const maxWidth = Math.min( const maxWidth = Math.min(
useContext(MessageAreaWidthContext) - CONTAINER_PADDING, useContext(MessageAreaWidthContext) - CONTAINER_PADDING,
MAX_EMBED_WIDTH, MAX_EMBED_WIDTH,
@ -191,7 +192,9 @@ export default function Embed({ embed }: Props) {
type="text/html" type="text/html"
frameBorder="0" frameBorder="0"
loading="lazy" loading="lazy"
onClick={() => openScreen({ id: "image_viewer", embed })} onClick={() =>
modalController.push({ type: "image_viewer", embed })
}
onMouseDown={(ev) => ev.button === 1 && openLink(embed.url)} onMouseDown={(ev) => ev.button === 1 && openLink(embed.url)}
/> />
); );

View file

@ -1,5 +1,4 @@
import { Group } from "@styled-icons/boxicons-solid"; import { Group } from "@styled-icons/boxicons-solid";
import { reaction } from "mobx";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { useHistory } from "react-router-dom"; import { useHistory } from "react-router-dom";
import { Message, API } from "revolt.js"; import { Message, API } from "revolt.js";
@ -12,14 +11,13 @@ import { Button, Category, Preloader } from "@revoltchat/ui";
import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice";
import { I18nError } from "../../../../context/Locale"; import { I18nError } from "../../../../context/Locale";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../../context/revoltjs/util"; import { takeError } from "../../../../context/revoltjs/util";
import ServerIcon from "../../../../components/common/ServerIcon"; import ServerIcon from "../../../../components/common/ServerIcon";
import {
useClient,
useSession,
} from "../../../../controllers/client/ClientController";
const EmbedInviteBase = styled.div` const EmbedInviteBase = styled.div`
width: 400px; width: 400px;
@ -78,8 +76,8 @@ type Props = {
export function EmbedInvite({ code }: Props) { export function EmbedInvite({ code }: Props) {
const history = useHistory(); const history = useHistory();
const client = useContext(AppContext); const session = useSession()!;
const status = useContext(StatusContext); const client = session.client!;
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
const [joinError, setJoinError] = useState<string | undefined>(undefined); const [joinError, setJoinError] = useState<string | undefined>(undefined);
@ -90,7 +88,7 @@ export function EmbedInvite({ code }: Props) {
useEffect(() => { useEffect(() => {
if ( if (
typeof invite === "undefined" && typeof invite === "undefined" &&
(status === ClientStatus.ONLINE || status === ClientStatus.READY) (session.state === "Online" || session.state === "Ready")
) { ) {
client client
.fetchInvite(code) .fetchInvite(code)
@ -99,7 +97,7 @@ export function EmbedInvite({ code }: Props) {
) )
.catch((err) => setError(takeError(err))); .catch((err) => setError(takeError(err)));
} }
}, [client, code, invite, status]); }, [client, code, invite, session.state]);
if (typeof invite === "undefined") { if (typeof invite === "undefined") {
return error ? ( return error ? (

View file

@ -3,8 +3,8 @@ import { API } from "revolt.js";
import styles from "./Embed.module.scss"; import styles from "./Embed.module.scss";
import { useIntermediate } from "../../../../context/intermediate/Intermediate"; import { useClient } from "../../../../controllers/client/ClientController";
import { useClient } from "../../../../context/revoltjs/RevoltClient"; import { modalController } from "../../../../controllers/modals/ModalController";
interface Props { interface Props {
embed: API.Embed; embed: API.Embed;
@ -14,7 +14,6 @@ interface Props {
export default function EmbedMedia({ embed, width, height }: Props) { export default function EmbedMedia({ embed, width, height }: Props) {
if (embed.type !== "Website") return null; if (embed.type !== "Website") return null;
const { openScreen } = useIntermediate();
const client = useClient(); const client = useClient();
switch (embed.special?.type) { switch (embed.special?.type) {
@ -117,8 +116,8 @@ export default function EmbedMedia({ embed, width, height }: Props) {
loading="lazy" loading="lazy"
style={{ width, height }} style={{ width, height }}
onClick={() => onClick={() =>
openScreen({ modalController.push({
id: "image_viewer", type: "image_viewer",
embed: embed.image!, embed: embed.image!,
}) })
} }

View file

@ -6,10 +6,9 @@ import styled, { css } from "styled-components/macro";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import fallback from "../assets/user.png"; import fallback from "../assets/user.png";
import { useClient } from "../../../controllers/client/ClientController";
import IconBase, { IconBaseProps } from "../IconBase"; import IconBase, { IconBaseProps } from "../IconBase";
type VoiceStatus = "muted" | "deaf"; type VoiceStatus = "muted" | "deaf";
@ -56,8 +55,7 @@ export default observer(
keyof Props | "children" | "as" keyof Props | "children" | "as"
>, >,
) => { ) => {
// ! TODO: this is temporary code const client = useClient();
const client = useClient() ?? useApplicationState().client!;
const { const {
target, target,

View file

@ -8,9 +8,8 @@ import { Text } from "preact-i18n";
import { internalEmit } from "../../../lib/eventEmitter"; import { internalEmit } from "../../../lib/eventEmitter";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useClient } from "../../../controllers/client/ClientController";
import { useClient } from "../../../context/revoltjs/RevoltClient"; import { modalController } from "../../../controllers/modals/ModalController";
import UserIcon from "./UserIcon"; import UserIcon from "./UserIcon";
const BotBadge = styled.div` const BotBadge = styled.div`
@ -125,9 +124,9 @@ export default function UserShort({
masquerade?: API.Masquerade; masquerade?: API.Masquerade;
showServerIdentity?: boolean; showServerIdentity?: boolean;
}) { }) {
const { openScreen } = useIntermediate();
const openProfile = () => const openProfile = () =>
user && openScreen({ id: "profile", user_id: user._id }); user &&
modalController.push({ type: "user_profile", user_id: user._id });
const handleUserClick = (e: MouseEvent) => { const handleUserClick = (e: MouseEvent) => {
if (e.shiftKey && user?._id) { if (e.shiftKey && user?._id) {

View file

@ -1,4 +1,6 @@
.markdown { .markdown {
user-select: text;
:global(.emoji) { :global(.emoji) {
object-fit: contain; object-fit: contain;

View file

@ -15,9 +15,9 @@ import { determineLink } from "../../lib/links";
import { dayjs } from "../../context/Locale"; import { dayjs } from "../../context/Locale";
import { useIntermediate } from "../../context/intermediate/Intermediate"; import { useIntermediate } from "../../context/intermediate/Intermediate";
import { AppContext } from "../../context/revoltjs/RevoltClient";
import { emojiDictionary } from "../../assets/emojis"; import { emojiDictionary } from "../../assets/emojis";
import { useClient } from "../../controllers/client/ClientController";
import { generateEmoji } from "../common/Emoji"; import { generateEmoji } from "../common/Emoji";
import { MarkdownProps } from "./Markdown"; import { MarkdownProps } from "./Markdown";
import Prism from "./prism"; import Prism from "./prism";
@ -118,7 +118,7 @@ const RE_CHANNELS = /<#([A-z0-9]{26})>/g;
const RE_TIME = /<t:([0-9]+):(\w)>/g; const RE_TIME = /<t:([0-9]+):(\w)>/g;
export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) { export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) {
const client = useContext(AppContext); const client = useClient();
const { openLink } = useIntermediate(); const { openLink } = useIntermediate();
if (typeof content === "undefined") return null; if (typeof content === "undefined") return null;

View file

@ -9,8 +9,7 @@ import ConditionalLink from "../../lib/ConditionalLink";
import { useApplicationState } from "../../mobx/State"; import { useApplicationState } from "../../mobx/State";
import { useClient } from "../../context/revoltjs/RevoltClient"; import { useClient } from "../../controllers/client/ClientController";
import UserIcon from "../common/user/UserIcon"; import UserIcon from "../common/user/UserIcon";
const Base = styled.div` const Base = styled.div`

View file

@ -1,45 +1,47 @@
import { observer } from "mobx-react-lite";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { Banner } from "@revoltchat/ui"; import { Banner, Button, Column } from "@revoltchat/ui";
import { import { useSession } from "../../../controllers/client/ClientController";
ClientStatus,
StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient";
export default function ConnectionStatus() { function ConnectionStatus() {
const status = useContext(StatusContext); const session = useSession()!;
const client = useClient();
if (status === ClientStatus.OFFLINE) { if (session.state === "Offline") {
return ( return (
<Banner> <Banner>
<Text id="app.special.status.offline" /> <Text id="app.special.status.offline" />
</Banner> </Banner>
); );
} else if (status === ClientStatus.DISCONNECTED) { } else if (session.state === "Disconnected") {
return ( return (
<Banner> <Banner>
<Text id="app.special.status.disconnected" /> <br /> <Column centred>
<a onClick={() => client.websocket.connect()}> <Text id="app.special.status.disconnected" />
<Text id="app.special.status.reconnect" /> <Button
</a> compact
palette="secondary"
onClick={() =>
session.emit({
action: "RETRY",
})
}>
<Text id="app.status.reconnect" />
</Button>
</Column>
</Banner> </Banner>
); );
} else if (status === ClientStatus.CONNECTING) { } else if (session.state === "Connecting") {
return (
<Banner>
<Text id="app.special.status.connecting" />
</Banner>
);
} else if (status === ClientStatus.RECONNECTING) {
return ( return (
<Banner> <Banner>
<Text id="app.special.status.reconnecting" /> <Text id="app.special.status.reconnecting" />
</Banner> </Banner>
); );
} }
return null; return null;
} }
export default observer(ConnectionStatus);

View file

@ -21,10 +21,10 @@ import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { AppContext } from "../../../context/revoltjs/RevoltClient";
import placeholderSVG from "../items/placeholder.svg"; import placeholderSVG from "../items/placeholder.svg";
import { useClient } from "../../../controllers/client/ClientController";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";
import ButtonItem, { ChannelButton } from "../items/ButtonItem"; import ButtonItem, { ChannelButton } from "../items/ButtonItem";
import ConnectionStatus from "../items/ConnectionStatus"; import ConnectionStatus from "../items/ConnectionStatus";
@ -46,7 +46,7 @@ const Navbar = styled.div`
export default observer(() => { export default observer(() => {
const { pathname } = useLocation(); const { pathname } = useLocation();
const client = useContext(AppContext); const client = useClient();
const state = useApplicationState(); const state = useApplicationState();
const { channel: channel_id } = useParams<{ channel: string }>(); const { channel: channel_id } = useParams<{ channel: string }>();
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();

View file

@ -8,7 +8,8 @@ import { ServerList } from "@revoltchat/ui";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import { useClient } from "../../../context/revoltjs/RevoltClient";
import { useClient } from "../../../controllers/client/ClientController";
/** /**
* Server list sidebar shim component * Server list sidebar shim component

View file

@ -14,8 +14,7 @@ import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../../mobx/State"; import { useApplicationState } from "../../../mobx/State";
import { useClient } from "../../../context/revoltjs/RevoltClient"; import { useClient } from "../../../controllers/client/ClientController";
import CollapsibleSection from "../../common/CollapsibleSection"; import CollapsibleSection from "../../common/CollapsibleSection";
import ServerHeader from "../../common/ServerHeader"; import ServerHeader from "../../common/ServerHeader";
import { ChannelButton } from "../items/ButtonItem"; import { ChannelButton } from "../items/ButtonItem";

View file

@ -8,11 +8,7 @@ import { memo } from "preact/compat";
import { internalEmit } from "../../../lib/eventEmitter"; import { internalEmit } from "../../../lib/eventEmitter";
import { import { modalController } from "../../../controllers/modals/ModalController";
Screen,
useIntermediate,
} from "../../../context/intermediate/Intermediate";
import { UserButton } from "../items/ButtonItem"; import { UserButton } from "../items/ButtonItem";
export type MemberListGroup = { export type MemberListGroup = {
@ -55,15 +51,7 @@ const NoOomfie = styled.div`
`; `;
const ItemContent = memo( const ItemContent = memo(
({ ({ item, context }: { item: User; context: Channel }) => (
item,
context,
openScreen,
}: {
item: User;
context: Channel;
openScreen: (screen: Screen) => void;
}) => (
<UserButton <UserButton
key={item._id} key={item._id}
user={item} user={item}
@ -77,13 +65,12 @@ const ItemContent = memo(
`<@${item._id}>`, `<@${item._id}>`,
"mention", "mention",
); );
} else } else {
[ modalController.push({
openScreen({ type: "user_profile",
id: "profile",
user_id: item._id, user_id: item._id,
}), });
]; }
}} }}
/> />
), ),
@ -96,8 +83,6 @@ export default function MemberList({
entries: MemberListGroup[]; entries: MemberListGroup[];
context: Channel; context: Channel;
}) { }) {
const { openScreen } = useIntermediate();
return ( return (
<GroupedVirtuoso <GroupedVirtuoso
groupCounts={entries.map((x) => x.users.length)} groupCounts={entries.map((x) => x.users.length)}
@ -137,7 +122,8 @@ export default function MemberList({
server, see issue{" "} server, see issue{" "}
<a <a
href="https://github.com/revoltchat/delta/issues/128" href="https://github.com/revoltchat/delta/issues/128"
target="_blank" rel="noreferrer"> target="_blank"
rel="noreferrer">
#128 #128
</a>{" "} </a>{" "}
for when this will be resolved. for when this will be resolved.
@ -158,11 +144,7 @@ export default function MemberList({
return ( return (
<div> <div>
<ItemContent <ItemContent item={item} context={context} />
item={item}
context={context}
openScreen={openScreen}
/>
</div> </div>
); );
}} }}

View file

@ -4,14 +4,12 @@ import { observer } from "mobx-react-lite";
import { useParams } from "react-router-dom"; import { useParams } from "react-router-dom";
import { Channel, Server, User, API } from "revolt.js"; import { Channel, Server, User, API } from "revolt.js";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useLayoutEffect, useState } from "preact/hooks";
import { import {
ClientStatus, useSession,
StatusContext,
useClient, useClient,
} from "../../../context/revoltjs/RevoltClient"; } from "../../../controllers/client/ClientController";
import { GenericSidebarBase } from "../SidebarBase"; import { GenericSidebarBase } from "../SidebarBase";
import MemberList, { MemberListGroup } from "./MemberList"; import MemberList, { MemberListGroup } from "./MemberList";
@ -205,18 +203,18 @@ function shouldSkipOffline(id: string) {
export const ServerMemberSidebar = observer( export const ServerMemberSidebar = observer(
({ channel }: { channel: Channel }) => { ({ channel }: { channel: Channel }) => {
const client = useClient(); const session = useSession()!;
const status = useContext(StatusContext); const client = session.client!;
useEffect(() => { useEffect(() => {
const server_id = channel.server_id!; const server_id = channel.server_id!;
if (status === ClientStatus.ONLINE && !FETCHED.has(server_id)) { if (session.state === "Online" && !FETCHED.has(server_id)) {
FETCHED.add(server_id); FETCHED.add(server_id);
channel channel
.server!.syncMembers(shouldSkipOffline(server_id)) .server!.syncMembers(shouldSkipOffline(server_id))
.catch(() => FETCHED.delete(server_id)); .catch(() => FETCHED.delete(server_id));
} }
}, [status, channel]); }, [session.state, channel]);
const entries = useEntries( const entries = useEntries(
channel, channel,

View file

@ -7,8 +7,7 @@ import { useEffect, useState } from "preact/hooks";
import { Button, Category, Error, InputBox, Preloader } from "@revoltchat/ui"; import { Button, Category, Error, InputBox, Preloader } from "@revoltchat/ui";
import { useClient } from "../../../context/revoltjs/RevoltClient"; import { useClient } from "../../../controllers/client/ClientController";
import Message from "../../common/messaging/Message"; import Message from "../../common/messaging/Message";
import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase";

View file

@ -2,18 +2,16 @@ import { Block } from "@styled-icons/boxicons-regular";
import { Trash } from "@styled-icons/boxicons-solid"; import { Trash } from "@styled-icons/boxicons-solid";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { CategoryButton } from "@revoltchat/ui"; import { CategoryButton } from "@revoltchat/ui";
import { modalController } from "../../../context/modals";
import { import {
LogOutContext, clientController,
useClient, useClient,
} from "../../../context/revoltjs/RevoltClient"; } from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController";
export default function AccountManagement() { export default function AccountManagement() {
const logOut = useContext(LogOutContext);
const client = useClient(); const client = useClient();
const callback = (route: "disable" | "delete") => () => const callback = (route: "disable" | "delete") => () =>
@ -26,7 +24,7 @@ export default function AccountManagement() {
"X-MFA-Ticket": ticket.token, "X-MFA-Ticket": ticket.token,
}, },
}) })
.then(() => logOut(true)), .then(clientController.logoutCurrent),
); );
return ( return (

View file

@ -3,7 +3,7 @@ import { Envelope, Key, Pencil } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { import {
AccountDetail, AccountDetail,
@ -12,26 +12,22 @@ import {
HiddenValue, HiddenValue,
} from "@revoltchat/ui"; } from "@revoltchat/ui";
import { modalController } from "../../../context/modals"; import { useSession } from "../../../controllers/client/ClientController";
import { import { modalController } from "../../../controllers/modals/ModalController";
ClientStatus,
StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient";
export default observer(() => { export default observer(() => {
const client = useClient(); const session = useSession()!;
const status = useContext(StatusContext); const client = session.client!;
const [email, setEmail] = useState("..."); const [email, setEmail] = useState("...");
useEffect(() => { useEffect(() => {
if (email === "..." && status === ClientStatus.ONLINE) { if (email === "..." && session.state === "Online") {
client.api client.api
.get("/auth/account/") .get("/auth/account/")
.then((account) => setEmail(account.email)); .then((account) => setEmail(account.email));
} }
}, [client, email, status]); }, [client, email, session.state]);
return ( return (
<> <>

View file

@ -3,18 +3,15 @@ import { Lock } from "@styled-icons/boxicons-solid";
import { API } from "revolt.js"; import { API } from "revolt.js";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useCallback, useContext, useEffect, useState } from "preact/hooks"; import { useCallback, useEffect, useState } from "preact/hooks";
import { Category, CategoryButton, Error, Tip } from "@revoltchat/ui"; import { Category, CategoryButton, Error, Tip } from "@revoltchat/ui";
import { modalController } from "../../../context/modals";
import {
ClientStatus,
StatusContext,
useClient,
} from "../../../context/revoltjs/RevoltClient";
import { takeError } from "../../../context/revoltjs/util"; import { takeError } from "../../../context/revoltjs/util";
import { useSession } from "../../../controllers/client/ClientController";
import { modalController } from "../../../controllers/modals/ModalController";
/** /**
* Temporary helper function for Axios config * Temporary helper function for Axios config
* @param token Token * @param token Token
@ -33,8 +30,8 @@ export function toConfig(token: string) {
*/ */
export default function MultiFactorAuthentication() { export default function MultiFactorAuthentication() {
// Pull in prerequisites // Pull in prerequisites
const client = useClient(); const session = useSession()!;
const status = useContext(StatusContext); const client = session.client!;
// Keep track of MFA state // Keep track of MFA state
const [mfa, setMFA] = useState<API.MultiFactorStatus>(); const [mfa, setMFA] = useState<API.MultiFactorStatus>();
@ -42,13 +39,13 @@ export default function MultiFactorAuthentication() {
// Fetch the current MFA status on account // Fetch the current MFA status on account
useEffect(() => { useEffect(() => {
if (!mfa && status === ClientStatus.ONLINE) { if (!mfa && session.state === "Online") {
client.api client!.api
.get("/auth/mfa/") .get("/auth/mfa/")
.then(setMFA) .then(setMFA)
.catch((err) => setError(takeError(err))); .catch((err) => setError(takeError(err)));
} }
}, [client, mfa, status]); }, [mfa, client, session.state]);
// Action called when recovery code button is pressed // Action called when recovery code button is pressed
const recoveryAction = useCallback(async () => { const recoveryAction = useCallback(async () => {

View file

@ -0,0 +1,2 @@
hello do not touch `intermediate` or `revoltjs` folders
they are being rewritten

View file

@ -3,3 +3,14 @@ import { createBrowserHistory } from "history";
export const history = createBrowserHistory({ export const history = createBrowserHistory({
basename: import.meta.env.BASE_URL, basename: import.meta.env.BASE_URL,
}); });
export const routeInformation = {
getServer: () =>
/server\/([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26})/.exec(
history.location.pathname,
)?.[1],
getChannel: () =>
/channel\/([0123456789ABCDEFGHJKMNPQRSTVWXYZ]{26})/.exec(
history.location.pathname,
)?.[1],
};

View file

@ -6,21 +6,20 @@ import { useEffect, useState } from "preact/hooks";
import { Preloader, UIProvider } from "@revoltchat/ui"; import { Preloader, UIProvider } from "@revoltchat/ui";
import { hydrateState } from "../mobx/State"; import { state } from "../mobx/State";
import Binder from "../controllers/client/jsx/Binder";
import ModalRenderer from "../controllers/modals/ModalRenderer";
import Locale from "./Locale"; import Locale from "./Locale";
import Theme from "./Theme"; import Theme from "./Theme";
import { history } from "./history"; import { history } from "./history";
import Intermediate from "./intermediate/Intermediate"; import Intermediate from "./intermediate/Intermediate";
import ModalRenderer from "./modals/ModalRenderer";
import Client from "./revoltjs/RevoltClient";
import SyncManager from "./revoltjs/SyncManager";
const uiContext = { const uiContext = {
Link, Link,
Text: Text as any, Text: Text as any,
Trigger: ContextMenuTrigger, Trigger: ContextMenuTrigger,
emitAction: () => {}, emitAction: () => void {},
}; };
/** /**
@ -31,7 +30,7 @@ export default function Context({ children }: { children: Children }) {
const [ready, setReady] = useState(false); const [ready, setReady] = useState(false);
useEffect(() => { useEffect(() => {
hydrateState().then(() => setReady(true)); state.hydrate().then(() => setReady(true));
}, []); }, []);
if (!ready) return <Preloader type="spinner" />; if (!ready) return <Preloader type="spinner" />;
@ -41,10 +40,8 @@ export default function Context({ children }: { children: Children }) {
<UIProvider value={uiContext}> <UIProvider value={uiContext}>
<Locale> <Locale>
<Intermediate> <Intermediate>
<Client>
{children} {children}
<SyncManager /> <Binder />
</Client>
</Intermediate> </Intermediate>
<ModalRenderer /> <ModalRenderer />
</Locale> </Locale>

View file

@ -18,7 +18,7 @@ import { determineLink } from "../../lib/links";
import { useApplicationState } from "../../mobx/State"; import { useApplicationState } from "../../mobx/State";
import { modalController } from "../modals"; import { modalController } from "../../controllers/modals/ModalController";
import Modals from "./Modals"; import Modals from "./Modals";
export type Screen = export type Screen =
@ -159,7 +159,7 @@ export default function Intermediate(props: Props) {
useEffect(() => { useEffect(() => {
const openProfile = (user_id: string) => const openProfile = (user_id: string) =>
openScreen({ id: "profile", user_id }); modalController.push({ type: "user_profile", user_id });
const navigate = (path: string) => history.push(path); const navigate = (path: string) => history.push(path);
const subs = [ const subs = [

View file

@ -1,7 +1,6 @@
//import { isModalClosing } from "../../components/ui/Modal"; //import { isModalClosing } from "../../components/ui/Modal";
import { Screen } from "./Intermediate"; import { Screen } from "./Intermediate";
import { InputModal } from "./modals/Input"; import { InputModal } from "./modals/Input";
import { OnboardingModal } from "./modals/Onboarding";
import { PromptModal } from "./modals/Prompt"; import { PromptModal } from "./modals/Prompt";
export interface Props { export interface Props {
@ -20,8 +19,6 @@ export default function Modals({ screen, openScreen }: Props) {
return <PromptModal onClose={onClose} {...screen} />; return <PromptModal onClose={onClose} {...screen} />;
case "_input": case "_input":
return <InputModal onClose={onClose} {...screen} />; return <InputModal onClose={onClose} {...screen} />;
case "onboarding":
return <OnboardingModal onClose={onClose} {...screen} />;
} }
return null; return null;

View file

@ -3,11 +3,6 @@ import { useContext } from "preact/hooks";
import { IntermediateContext, useIntermediate } from "./Intermediate"; import { IntermediateContext, useIntermediate } from "./Intermediate";
import { SpecialInputModal } from "./modals/Input"; import { SpecialInputModal } from "./modals/Input";
import { SpecialPromptModal } from "./modals/Prompt"; import { SpecialPromptModal } from "./modals/Prompt";
import { ChannelInfo } from "./popovers/ChannelInfo";
import { CreateBotModal } from "./popovers/CreateBot";
import { ImageViewer } from "./popovers/ImageViewer";
import { UserPicker } from "./popovers/UserPicker";
import { UserProfile } from "./popovers/UserProfile";
export default function Popovers() { export default function Popovers() {
const { screen } = useContext(IntermediateContext); const { screen } = useContext(IntermediateContext);
@ -19,20 +14,6 @@ export default function Popovers() {
//: internalEmit("Modal", "close"); //: internalEmit("Modal", "close");
switch (screen.id) { switch (screen.id) {
case "profile":
// @ts-expect-error someone figure this out :)
return <UserProfile {...screen} onClose={onClose} />;
case "user_picker":
// @ts-expect-error someone figure this out :)
return <UserPicker {...screen} onClose={onClose} />;
case "image_viewer":
return <ImageViewer {...screen} onClose={onClose} />;
case "channel_info":
// @ts-expect-error someone figure this out :)
return <ChannelInfo {...screen} onClose={onClose} />;
case "create_bot":
// @ts-expect-error someone figure this out :)
return <CreateBotModal onClose={onClose} {...screen} />;
case "special_prompt": case "special_prompt":
// @ts-expect-error someone figure this out :) // @ts-expect-error someone figure this out :)
return <SpecialPromptModal onClose={onClose} {...screen} />; return <SpecialPromptModal onClose={onClose} {...screen} />;

View file

@ -6,8 +6,8 @@ import { useContext, useState } from "preact/hooks";
import { Category, InputBox, Modal } from "@revoltchat/ui"; import { Category, InputBox, Modal } from "@revoltchat/ui";
import { useClient } from "../../../controllers/client/ClientController";
import { I18nError } from "../../Locale"; import { I18nError } from "../../Locale";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util"; import { takeError } from "../../revoltjs/util";
interface Props { interface Props {
@ -89,7 +89,7 @@ type SpecialProps = { onClose: () => void } & (
export function SpecialInputModal(props: SpecialProps) { export function SpecialInputModal(props: SpecialProps) {
const history = useHistory(); const history = useHistory();
const client = useContext(AppContext); const client = useClient();
const { onClose } = props; const { onClose } = props;
switch (props.type) { switch (props.type) {

View file

@ -5,7 +5,7 @@ import { ulid } from "ulid";
import styles from "./Prompt.module.scss"; import styles from "./Prompt.module.scss";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { Category, Modal, InputBox, Radio } from "@revoltchat/ui"; import { Category, Modal, InputBox, Radio } from "@revoltchat/ui";
import type { Action } from "@revoltchat/ui/esm/components/design/atoms/display/Modal"; import type { Action } from "@revoltchat/ui/esm/components/design/atoms/display/Modal";
@ -14,8 +14,8 @@ import { TextReact } from "../../../lib/i18n";
import Message from "../../../components/common/messaging/Message"; import Message from "../../../components/common/messaging/Message";
import UserIcon from "../../../components/common/user/UserIcon"; import UserIcon from "../../../components/common/user/UserIcon";
import { useClient } from "../../../controllers/client/ClientController";
import { I18nError } from "../../Locale"; import { I18nError } from "../../Locale";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util"; import { takeError } from "../../revoltjs/util";
import { useIntermediate } from "../Intermediate"; import { useIntermediate } from "../Intermediate";
@ -81,7 +81,7 @@ type SpecialProps = { onClose: () => void } & (
); );
export const SpecialPromptModal = observer((props: SpecialProps) => { export const SpecialPromptModal = observer((props: SpecialProps) => {
const client = useContext(AppContext); const client = useClient();
const history = useHistory(); const history = useHistory();
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const [error, setError] = useState<undefined | string>(undefined); const [error, setError] = useState<undefined | string>(undefined);

View file

@ -1,16 +0,0 @@
.info {
.header {
display: flex;
align-items: center;
flex-direction: row;
h1 {
margin: 0;
flex-grow: 1;
}
div {
cursor: pointer;
}
}
}

View file

@ -1,41 +0,0 @@
import { X } from "@styled-icons/boxicons-regular";
import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js";
import styles from "./ChannelInfo.module.scss";
import { Modal } from "@revoltchat/ui";
import Markdown from "../../../components/markdown/Markdown";
import { getChannelName } from "../../revoltjs/util";
interface Props {
channel: Channel;
onClose: () => void;
}
export const ChannelInfo = observer(({ channel, onClose }: Props) => {
if (
channel.channel_type === "DirectMessage" ||
channel.channel_type === "SavedMessages"
) {
onClose();
return null;
}
return (
<Modal onClose={onClose}>
<div className={styles.info}>
<div className={styles.header}>
<h1>{getChannelName(channel, true)}</h1>
<div onClick={onClose}>
<X size={36} />
</div>
</div>
<p>
<Markdown content={channel.description!} />
</p>
</div>
</Modal>
);
});

View file

@ -1,20 +0,0 @@
.viewer {
display: flex;
overflow: hidden;
flex-direction: column;
border-end-end-radius: 4px;
border-end-start-radius: 4px;
max-width: 100vw;
img {
width: auto;
height: auto;
max-width: 90vw;
max-height: 75vh;
object-fit: contain;
border-bottom: thin solid var(--tertiary-foreground);
-webkit-touch-callout: default;
}
}

View file

@ -1,8 +1,9 @@
import { observer } from "mobx-react-lite";
import { Redirect } from "react-router-dom"; import { Redirect } from "react-router-dom";
import { useApplicationState } from "../../mobx/State"; import { Preloader } from "@revoltchat/ui";
import { useClient } from "./RevoltClient"; import { clientController } from "../../controllers/client/ClientController";
interface Props { interface Props {
auth?: boolean; auth?: boolean;
@ -11,18 +12,30 @@ interface Props {
children: Children; children: Children;
} }
export const CheckAuth = (props: Props) => { /**
const auth = useApplicationState().auth; * Check that we are logged in or out and redirect accordingly.
const client = useClient(); * Also prevent render until the client is ready to display.
const ready = auth.isLoggedIn() && !!client?.user; */
export const CheckAuth = observer((props: Props) => {
const loggedIn = clientController.isLoggedIn();
if (props.auth && !ready) { // Redirect if logged out on authenticated page or vice-versa.
if (props.auth && !loggedIn) {
if (props.blockRender) return null; if (props.blockRender) return null;
return <Redirect to="/login" />; return <Redirect to="/login" />;
} else if (!props.auth && ready) { } else if (!props.auth && loggedIn) {
if (props.blockRender) return null; if (props.blockRender) return null;
return <Redirect to="/" />; return <Redirect to="/" />;
} }
// Block render if client is getting ready to work.
if (
props.auth &&
clientController.isLoggedIn() &&
!clientController.isReady()
) {
return <Preloader type="spinner" />;
}
return <>{props.children}</>; return <>{props.children}</>;
}; });

View file

@ -5,17 +5,15 @@ import Axios, { AxiosRequestConfig } from "axios";
import styles from "./FileUploads.module.scss"; import styles from "./FileUploads.module.scss";
import classNames from "classnames"; import classNames from "classnames";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect, useState } from "preact/hooks"; import { useEffect, useState } from "preact/hooks";
import { IconButton, Preloader } from "@revoltchat/ui"; import { IconButton, Preloader } from "@revoltchat/ui";
import { determineFileSize } from "../../lib/fileSize"; import { determineFileSize } from "../../lib/fileSize";
import { useApplicationState } from "../../mobx/State"; import { useClient } from "../../controllers/client/ClientController";
import { modalController } from "../../controllers/modals/ModalController";
import { useIntermediate } from "../intermediate/Intermediate"; import { useIntermediate } from "../intermediate/Intermediate";
import { modalController } from "../modals";
import { AppContext } from "./RevoltClient";
import { takeError } from "./util"; import { takeError } from "./util";
type BehaviourType = type BehaviourType =
@ -115,7 +113,7 @@ export function grabFiles(
export function FileUploader(props: Props) { export function FileUploader(props: Props) {
const { fileType, maxFileSize, remove } = props; const { fileType, maxFileSize, remove } = props;
const { openScreen } = useIntermediate(); const { openScreen } = useIntermediate();
const client = useApplicationState().client!; const client = useClient();
const [uploading, setUploading] = useState(false); const [uploading, setUploading] = useState(false);

View file

@ -1,296 +0,0 @@
import { Route, Switch, useHistory, useParams } from "react-router-dom";
import { Message, User } from "revolt.js";
import { decodeTime } from "ulid";
import { useCallback, useContext, useEffect } from "preact/hooks";
import { useTranslation } from "../../lib/i18n";
import { useApplicationState } from "../../mobx/State";
import { AppContext } from "./RevoltClient";
const notifications: { [key: string]: Notification } = {};
async function createNotification(
title: string,
options: globalThis.NotificationOptions,
) {
try {
return new Notification(title, options);
} catch (err) {
const sw = await navigator.serviceWorker.getRegistration();
sw?.showNotification(title, options);
}
}
function Notifier() {
const translate = useTranslation();
const state = useApplicationState();
const notifs = state.notifications;
const showNotification = state.settings.get("notifications:desktop");
const client = useContext(AppContext);
const { guild: guild_id, channel: channel_id } = useParams<{
guild: string;
channel: string;
}>();
const history = useHistory();
const message = useCallback(
async (msg: Message) => {
if (msg.channel_id === channel_id && document.hasFocus()) return;
if (!notifs.shouldNotify(msg)) return;
state.settings.sounds.playSound("message");
if (!showNotification) return;
const effectiveName = msg.masquerade?.name ?? msg.author?.username;
let title;
switch (msg.channel?.channel_type) {
case "SavedMessages":
return;
case "DirectMessage":
title = `@${effectiveName}`;
break;
case "Group":
if (msg.author?._id === "00000000000000000000000000") {
title = msg.channel.name;
} else {
title = `@${effectiveName} - ${msg.channel.name}`;
}
break;
case "TextChannel":
title = `@${effectiveName} (#${msg.channel.name}, ${msg.channel.server?.name})`;
break;
default:
title = msg.channel?._id;
break;
}
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 (msg.content) {
body = client.markdownToText(msg.content);
if (msg.masquerade?.avatar) {
icon = client.proxyFile(msg.masquerade.avatar);
} else {
icon = msg.author?.generateAvatarURL({ max_side: 256 });
}
} else if (msg.system) {
const users = client.users;
switch (msg.system.type) {
case "user_added":
case "user_remove":
{
const user = users.get(msg.system.id);
body = translate(
`app.main.channel.system.${
msg.system.type === "user_added"
? "added_by"
: "removed_by"
}`,
{
user: user?.username,
other_user: users.get(msg.system.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.system.id);
body = translate(
`app.main.channel.system.${msg.system.type}`,
{ user: user?.username },
);
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
case "channel_renamed":
{
const user = users.get(msg.system.by);
body = translate(
`app.main.channel.system.channel_renamed`,
{
user: users.get(msg.system.by)?.username,
name: msg.system.name,
},
);
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
case "channel_description_changed":
case "channel_icon_changed":
{
const user = users.get(msg.system.by);
body = translate(
`app.main.channel.system.${msg.system.type}`,
{ user: users.get(msg.system.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,
});
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}`);
}
}
}
});
notifications[msg.channel_id] = notif;
notif.addEventListener(
"close",
() => delete notifications[msg.channel_id],
);
}
},
[
history,
showNotification,
translate,
channel_id,
client,
notifs,
state,
],
);
const relationship = useCallback(
async (user: User) => {
if (client.user?.status?.presence === "Busy") return;
if (!showNotification) return;
let event;
switch (user.relationship) {
case "Incoming":
event = translate("notifications.sent_request", {
person: user.username,
});
break;
case "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.addListener("user/relationship", relationship);
return () => {
client.removeListener("message", message);
client.removeListener("user/relationship", relationship);
};
}, [
client,
state,
guild_id,
channel_id,
showNotification,
notifs,
message,
relationship,
]);
useEffect(() => {
function visChange() {
if (document.visibilityState === "visible") {
if (notifications[channel_id]) {
notifications[channel_id].close();
}
}
}
visChange();
document.addEventListener("visibilitychange", visChange);
return () =>
document.removeEventListener("visibilitychange", visChange);
}, [guild_id, channel_id]);
return null;
}
export default function NotificationsComponent() {
return (
<Switch>
<Route path="/server/:server/channel/:channel">
<Notifier />
</Route>
<Route path="/channel/:channel">
<Notifier />
</Route>
<Route path="/">
<Notifier />
</Route>
</Switch>
);
}

View file

@ -1,104 +0,0 @@
/* eslint-disable react-hooks/rules-of-hooks */
import { observer } from "mobx-react-lite";
import { Client } from "revolt.js";
import { createContext } from "preact";
import { useCallback, useContext, useEffect, useState } from "preact/hooks";
import { Preloader } from "@revoltchat/ui";
import { useApplicationState } from "../../mobx/State";
import { modalController } from "../modals";
import { registerEvents } from "./events";
import { takeError } from "./util";
export enum ClientStatus {
READY,
LOADING,
OFFLINE,
DISCONNECTED,
CONNECTING,
RECONNECTING,
ONLINE,
}
export interface ClientOperations {
logout: (shouldRequest?: boolean) => Promise<void>;
}
export const AppContext = createContext<Client>(null!);
export const StatusContext = createContext<ClientStatus>(null!);
export const LogOutContext = createContext<(avoidReq?: boolean) => void>(null!);
type Props = {
children: Children;
};
export default observer(({ children }: Props) => {
const state = useApplicationState();
const [client, setClient] = useState<Client>(null!);
const [status, setStatus] = useState(ClientStatus.LOADING);
const [loaded, setLoaded] = useState(false);
const logout = useCallback(
(avoidReq?: boolean) => {
setLoaded(false);
client.logout(avoidReq);
},
[client],
);
useEffect(() => {
if (navigator.onLine) {
state.config.createClient().api.get("/").then(state.config.set);
}
}, []);
useEffect(() => {
if (state.auth.isLoggedIn()) {
setLoaded(false);
const client = state.config.createClient();
setClient(client);
client
.useExistingSession(state.auth.getSession()!)
.catch((err) => {
const error = takeError(err);
if (error === "Forbidden" || error === "Unauthorized") {
client.logout(true);
modalController.push({ type: "signed_out" });
} else {
setStatus(ClientStatus.DISCONNECTED);
modalController.push({
type: "error",
error,
});
}
})
.finally(() => setLoaded(true));
} else {
setStatus(ClientStatus.READY);
setLoaded(true);
}
}, [state.auth.getSession()]);
useEffect(() => registerEvents(state, setStatus, client), [client]);
useEffect(() => state.registerListeners(client), [client]);
if (!loaded || status === ClientStatus.LOADING) {
return <Preloader type="spinner" />;
}
return (
<AppContext.Provider value={client}>
<StatusContext.Provider value={status}>
<LogOutContext.Provider value={logout}>
{children}
</LogOutContext.Provider>
</StatusContext.Provider>
</AppContext.Provider>
);
});
export const useClient = () => useContext(AppContext);

View file

@ -1,39 +0,0 @@
/**
* This file monitors the message cache to delete any queued messages that have already sent.
*/
import { Message } from "revolt.js";
import { useContext, useEffect } from "preact/hooks";
import { useApplicationState } from "../../mobx/State";
import { setGlobalEmojiPack } from "../../components/common/Emoji";
import { AppContext } from "./RevoltClient";
export default function StateMonitor() {
const client = useContext(AppContext);
const state = useApplicationState();
useEffect(() => {
function add(msg: Message) {
if (!msg.nonce) return;
if (
!state.queue.get(msg.channel_id).find((x) => x.id === msg.nonce)
)
return;
state.queue.remove(msg.nonce);
}
client.addListener("message", add);
return () => client.removeListener("message", add);
}, [client]);
// Set global emoji pack.
useEffect(() => {
const v = state.settings.get("appearance:emoji");
v && setGlobalEmojiPack(v);
}, [state.settings.get("appearance:emoji")]);
return null;
}

View file

@ -1,46 +0,0 @@
/**
* This file monitors changes to settings and syncs them to the server.
*/
import { ClientboundNotification } from "revolt.js";
import { useEffect } from "preact/hooks";
import { reportError } from "../../lib/ErrorBoundary";
import { useApplicationState } from "../../mobx/State";
import { useClient } from "./RevoltClient";
export default function SyncManager() {
const client = useClient();
const state = useApplicationState();
// Sync settings from Revolt.
useEffect(() => {
if (client) {
state.sync
.pull(client)
.catch(console.error)
.finally(() => state.changelog.checkForUpdates());
}
}, [client]);
// Take data updates from Revolt.
useEffect(() => {
if (!client) return;
function onPacket(packet: ClientboundNotification) {
if (packet.type === "UserSettingsUpdate") {
try {
state.sync.apply(packet.update);
} catch (err) {
reportError(err as any, "failed_sync_apply");
}
}
}
client.addListener("packet", onPacket);
return () => client.removeListener("packet", onPacket);
}, [client]);
return <></>;
}

View file

@ -1,15 +1,6 @@
import { Client, Server } from "revolt.js"; export const _ = "";
import { StateUpdater } from "preact/hooks"; /*export function registerEvents(
import { deleteRenderer } from "../../lib/renderer/Singleton";
import State from "../../mobx/State";
import { resetMemberSidebarFetched } from "../../components/navigation/right/MemberSidebar";
import { ClientStatus } from "./RevoltClient";
export function registerEvents(
state: State, state: State,
setStatus: StateUpdater<ClientStatus>, setStatus: StateUpdater<ClientStatus>,
client: Client, client: Client,
@ -86,4 +77,4 @@ export function registerEvents(
window.removeEventListener("online", online); window.removeEventListener("online", online);
window.removeEventListener("offline", offline); window.removeEventListener("offline", offline);
}; };
} }*/

View file

@ -0,0 +1,316 @@
import { detect } from "detect-browser";
import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { API, Client, Nullable } from "revolt.js";
import { injectController } from "../../lib/window";
import { state } from "../../mobx/State";
import Auth from "../../mobx/stores/Auth";
import { resetMemberSidebarFetched } from "../../components/navigation/right/MemberSidebar";
import { modalController } from "../modals/ModalController";
import Session from "./Session";
/**
* Controls the lifecycles of clients
*/
class ClientController {
/**
* API client
*/
private apiClient: Client;
/**
* Server configuration
*/
private configuration: API.RevoltConfig | null;
/**
* Map of user IDs to sessions
*/
private sessions: ObservableMap<string, Session>;
/**
* User ID of active session
*/
private current: Nullable<string>;
constructor() {
this.apiClient = new Client({
apiURL: import.meta.env.VITE_API_URL,
});
// ! FIXME: loop until success infinitely
this.apiClient
.fetchConfiguration()
.then(() => (this.configuration = this.apiClient.configuration!));
this.configuration = null;
this.sessions = new ObservableMap();
this.current = null;
makeAutoObservable(this);
this.login = this.login.bind(this);
this.logoutCurrent = this.logoutCurrent.bind(this);
// Inject globally
injectController("client", this);
}
@action pickNextSession() {
this.switchAccount(
this.current ?? this.sessions.keys().next().value ?? null,
);
}
/**
* Hydrate sessions and start client lifecycles.
* @param auth Authentication store
*/
@action hydrate(auth: Auth) {
for (const entry of auth.getAccounts()) {
this.addSession(entry, "existing");
}
this.pickNextSession();
}
/**
* Get the currently selected session
* @returns Active Session
*/
@computed getActiveSession() {
return this.sessions.get(this.current!);
}
/**
* Get the currently ready client
* @returns Ready Client
*/
@computed getReadyClient() {
const session = this.getActiveSession();
return session && session.ready ? session.client! : undefined;
}
/**
* Get an unauthenticated instance of the Revolt.js Client
* @returns API Client
*/
@computed getAnonymousClient() {
return this.apiClient;
}
/**
* Get the next available client (either from session or API)
* @returns Revolt.js Client
*/
@computed getAvailableClient() {
return this.getActiveSession()?.client ?? this.apiClient;
}
/**
* Fetch server configuration
* @returns Server Configuration
*/
@computed getServerConfig() {
return this.configuration;
}
/**
* Check whether we are logged in right now
* @returns Whether we are logged in
*/
@computed isLoggedIn() {
return this.current !== null;
}
/**
* Check whether we are currently ready
* @returns Whether we are ready to render
*/
@computed isReady() {
return this.getActiveSession()?.ready;
}
/**
* Start a new client lifecycle
* @param entry Session Information
* @param knowledge Whether the session is new or existing
*/
@action addSession(
entry: { session: SessionPrivate; apiUrl?: string },
knowledge: "new" | "existing",
) {
const user_id = entry.session.user_id!;
const session = new Session();
this.sessions.set(user_id, session);
this.pickNextSession();
session
.emit({
action: "LOGIN",
session: entry.session,
apiUrl: entry.apiUrl,
configuration: this.configuration!,
knowledge,
})
.catch((error) => {
if (error === "Forbidden" || error === "Unauthorized") {
this.sessions.delete(user_id);
state.auth.removeSession(user_id);
modalController.push({ type: "signed_out" });
session.destroy();
}
});
}
/**
* Login given a set of credentials
* @param credentials Credentials
*/
async login(credentials: API.DataLogin) {
const browser = detect();
// Generate a friendly name for this browser
let friendly_name;
if (browser) {
let { name } = browser;
const { os } = browser;
let isiPad;
if (window.isNative) {
friendly_name = `Revolt Desktop on ${os}`;
} else {
if (name === "ios") {
name = "safari";
} else if (name === "fxios") {
name = "firefox";
} else if (name === "crios") {
name = "chrome";
}
if (os === "Mac OS" && navigator.maxTouchPoints > 0)
isiPad = true;
friendly_name = `${name} on ${isiPad ? "iPadOS" : os}`;
}
} else {
friendly_name = "Unknown Device";
}
// Try to login with given credentials
let session = await this.apiClient.api.post("/auth/session/login", {
...credentials,
friendly_name,
});
// Prompt for MFA verificaiton if necessary
if (session.result === "MFA") {
const { allowed_methods } = session;
while (session.result === "MFA") {
const mfa_response: API.MFAResponse | undefined =
await new Promise((callback) =>
modalController.push({
type: "mfa_flow",
state: "unknown",
available_methods: allowed_methods,
callback,
}),
);
if (typeof mfa_response === "undefined") {
break;
}
try {
session = await this.apiClient.api.post(
"/auth/session/login",
{
mfa_response,
mfa_ticket: session.ticket,
friendly_name,
},
);
} catch (err) {
console.error("Failed login:", err);
}
}
if (session.result === "MFA") {
throw "Cancelled";
}
}
// Start client lifecycle
this.addSession(
{
session,
},
"new",
);
}
/**
* Log out of a specific user session
* @param user_id Target User ID
*/
@action logout(user_id: string) {
const session = this.sessions.get(user_id);
if (session) {
if (user_id === this.current) {
this.current = null;
}
this.sessions.delete(user_id);
this.pickNextSession();
session.destroy();
}
}
/**
* Logout of the current session
*/
@action logoutCurrent() {
if (this.current) {
this.logout(this.current);
}
}
/**
* Switch to another user session
* @param user_id Target User ID
*/
@action switchAccount(user_id: string) {
this.current = user_id;
// This will allow account switching to work more seamlessly,
// maybe it'll be properly / fully implemented at some point.
resetMemberSidebarFetched();
}
}
export const clientController = new ClientController();
/**
* Get the currently active session.
* @returns Session
*/
export function useSession() {
return clientController.getActiveSession();
}
/**
* Get the currently active client or an unauthorised
* client for API requests, whichever is available.
* @returns Revolt.js Client
*/
export function useClient() {
return clientController.getAvailableClient();
}
/**
* Get unauthorised client for API requests.
* @returns Revolt.js Client
*/
export function useApi() {
return clientController.getAnonymousClient().api;
}

View file

@ -0,0 +1,277 @@
import { action, computed, makeAutoObservable } from "mobx";
import { API, Client } from "revolt.js";
import { state } from "../../mobx/State";
import { __thisIsAHack } from "../../context/intermediate/Intermediate";
import { modalController } from "../modals/ModalController";
/**
* Current lifecycle state
*/
type State = "Ready" | "Connecting" | "Online" | "Disconnected" | "Offline";
/**
* Possible transitions between states
*/
type Transition =
| {
action: "LOGIN";
apiUrl?: string;
session: SessionPrivate;
configuration?: API.RevoltConfig;
knowledge: "new" | "existing";
}
| {
action:
| "SUCCESS"
| "DISCONNECT"
| "RETRY"
| "LOGOUT"
| "ONLINE"
| "OFFLINE";
};
/**
* Client lifecycle finite state machine
*/
export default class Session {
state: State = window.navigator.onLine ? "Ready" : "Offline";
user_id: string | null = null;
client: Client | null = null;
/**
* Create a new Session
*/
constructor() {
makeAutoObservable(this);
this.onDropped = this.onDropped.bind(this);
this.onReady = this.onReady.bind(this);
this.onOnline = this.onOnline.bind(this);
this.onOffline = this.onOffline.bind(this);
window.addEventListener("online", this.onOnline);
window.addEventListener("offline", this.onOffline);
}
/**
* Initiate logout and destroy client
*/
@action destroy() {
if (this.client) {
this.client.logout(false);
this.state = "Ready";
this.client = null;
}
}
/**
* Called when user's browser signals it is online
*/
private onOnline() {
this.emit({
action: "ONLINE",
});
}
/**
* Called when user's browser signals it is offline
*/
private onOffline() {
this.emit({
action: "OFFLINE",
});
}
/**
* Called when the client signals it has disconnected
*/
private onDropped() {
this.emit({
action: "DISCONNECT",
});
}
/**
* Called when the client signals it has received the Ready packet
*/
private onReady() {
this.emit({
action: "SUCCESS",
});
}
/**
* Create a new Revolt.js Client for this Session
* @param apiUrl Optionally specify an API URL
*/
private createClient(apiUrl?: string) {
this.client = new Client({
unreads: true,
autoReconnect: false,
onPongTimeout: "EXIT",
apiURL: apiUrl ?? import.meta.env.VITE_API_URL,
});
this.client.addListener("dropped", this.onDropped);
this.client.addListener("ready", this.onReady);
}
/**
* Destroy the client including any listeners.
*/
private destroyClient() {
this.client!.removeAllListeners();
this.client!.logout();
this.user_id = null;
this.client = null;
}
/**
* Ensure we are in one of the given states
* @param state Possible states
*/
private assert(...state: State[]) {
let found = false;
for (const target of state) {
if (this.state === target) {
found = true;
break;
}
}
if (!found) {
throw `State must be ${state} in order to transition! (currently ${this.state})`;
}
}
/**
* Continue logging in provided onboarding is successful
* @param data Transition Data
*/
private async continueLogin(data: Transition & { action: "LOGIN" }) {
try {
await this.client!.useExistingSession(data.session);
this.user_id = this.client!.user!._id;
state.auth.setSession(data.session);
} catch (err) {
this.state = "Ready";
throw err;
}
}
/**
* Transition to a new state by a certain action
* @param data Transition Data
*/
@action async emit(data: Transition) {
console.info(`[FSM ${this.user_id ?? "Anonymous"}]`, data);
switch (data.action) {
// Login with session
case "LOGIN": {
this.assert("Ready");
this.state = "Connecting";
this.createClient(data.apiUrl);
if (data.configuration) {
this.client!.configuration = data.configuration;
}
if (data.knowledge === "new") {
await this.client!.fetchConfiguration();
this.client!.session = data.session;
(this.client! as any).$updateHeaders();
const { onboarding } = await this.client!.api.get(
"/onboard/hello",
);
if (onboarding) {
modalController.push({
type: "onboarding",
callback: async (username: string) =>
this.client!.completeOnboarding(
{ username },
false,
).then(() => this.continueLogin(data)),
});
return;
}
}
this.continueLogin(data);
break;
}
// Ready successfully received
case "SUCCESS": {
this.assert("Connecting");
this.state = "Online";
break;
}
// Client got disconnected
case "DISCONNECT": {
if (navigator.onLine) {
this.assert("Online");
this.state = "Disconnected";
setTimeout(() => {
// Check we are still disconnected before retrying.
if (this.state === "Disconnected") {
this.emit({
action: "RETRY",
});
}
}, 1000);
}
break;
}
// We should try reconnecting
case "RETRY": {
this.assert("Disconnected");
this.client!.websocket.connect();
this.state = "Connecting";
break;
}
// User instructed logout
case "LOGOUT": {
this.assert("Connecting", "Online", "Disconnected");
this.state = "Ready";
this.destroyClient();
break;
}
// Browser went offline
case "OFFLINE": {
this.state = "Offline";
break;
}
// Browser went online
case "ONLINE": {
this.assert("Offline");
if (this.client) {
this.state = "Disconnected";
this.emit({
action: "RETRY",
});
} else {
this.state = "Ready";
}
break;
}
}
}
/**
* Whether we are ready to render.
* @returns Boolean
*/
@computed get ready() {
return !!this.client?.user;
}
}

View file

@ -0,0 +1,18 @@
import { observer } from "mobx-react-lite";
import { useEffect } from "preact/hooks";
import { state } from "../../../mobx/State";
import { clientController } from "../ClientController";
/**
* Also binds listeners from state to the current client.
*/
const Binder: React.FC = () => {
const client = clientController.getReadyClient();
useEffect(() => state.registerListeners(client!), [client]);
return null;
};
export default observer(Binder);

View file

@ -2,11 +2,10 @@ import { WifiOff } from "@styled-icons/boxicons-regular";
import styled from "styled-components/macro"; import styled from "styled-components/macro";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { Preloader } from "@revoltchat/ui"; import { Preloader } from "@revoltchat/ui";
import { ClientStatus, StatusContext } from "./RevoltClient"; import { useSession } from "../ClientController";
interface Props { interface Props {
children: Children; children: Children;
@ -29,10 +28,12 @@ const Base = styled.div`
`; `;
export default function RequiresOnline(props: Props) { export default function RequiresOnline(props: Props) {
const status = useContext(StatusContext); const session = useSession();
if (status === ClientStatus.CONNECTING) return <Preloader type="ring" />; if (!session || session.state === "Connecting")
if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY) return <Preloader type="ring" />;
if (!(session.state === "Online" || session.state === "Ready"))
return ( return (
<Base> <Base>
<WifiOff size={16} /> <WifiOff size={16} />

View file

@ -9,15 +9,18 @@ import type { Client, API } from "revolt.js";
import { ulid } from "ulid"; import { ulid } from "ulid";
import { determineLink } from "../../lib/links"; import { determineLink } from "../../lib/links";
import { injectController } from "../../lib/window";
import { getApplicationState, useApplicationState } from "../../mobx/State"; import { getApplicationState } from "../../mobx/State";
import { history } from "../../context/history";
import { __thisIsAHack } from "../../context/intermediate/Intermediate";
import { history } from "../history";
import { __thisIsAHack } from "../intermediate/Intermediate";
// import { determineLink } from "../../lib/links";
import Changelog from "./components/Changelog"; import Changelog from "./components/Changelog";
import ChannelInfo from "./components/ChannelInfo";
import Clipboard from "./components/Clipboard"; import Clipboard from "./components/Clipboard";
import Error from "./components/Error"; import Error from "./components/Error";
import ImageViewer from "./components/ImageViewer";
import LinkWarning from "./components/LinkWarning"; import LinkWarning from "./components/LinkWarning";
import MFAEnableTOTP from "./components/MFAEnableTOTP"; import MFAEnableTOTP from "./components/MFAEnableTOTP";
import MFAFlow from "./components/MFAFlow"; import MFAFlow from "./components/MFAFlow";
@ -26,9 +29,14 @@ import ModifyAccount from "./components/ModifyAccount";
import OutOfDate from "./components/OutOfDate"; import OutOfDate from "./components/OutOfDate";
import PendingFriendRequests from "./components/PendingFriendRequests"; import PendingFriendRequests from "./components/PendingFriendRequests";
import ServerIdentity from "./components/ServerIdentity"; import ServerIdentity from "./components/ServerIdentity";
import ServerInfo from "./components/ServerInfo";
import ShowToken from "./components/ShowToken"; import ShowToken from "./components/ShowToken";
import SignOutSessions from "./components/SignOutSessions"; import SignOutSessions from "./components/SignOutSessions";
import SignedOut from "./components/SignedOut"; import SignedOut from "./components/SignedOut";
import { UserPicker } from "./components/UserPicker";
import { CreateBotModal } from "./components/legacy/CreateBot";
import { OnboardingModal } from "./components/legacy/Onboarding";
import { UserProfile } from "./components/legacy/UserProfile";
import { Modal } from "./types"; import { Modal } from "./types";
type Components = Record<string, React.FC<any>>; type Components = Record<string, React.FC<any>>;
@ -51,6 +59,11 @@ class ModalController<T extends Modal> {
rendered: computed, rendered: computed,
isVisible: computed, isVisible: computed,
}); });
this.close = this.close.bind(this);
// Inject globally
injectController("modal", this);
} }
/** /**
@ -77,6 +90,13 @@ class ModalController<T extends Modal> {
); );
} }
/**
* Close the top modal
*/
close() {
this.pop("close");
}
/** /**
* Remove the keyed modal from the stack * Remove the keyed modal from the stack
*/ */
@ -174,7 +194,7 @@ class ModalControllerExtended extends ModalController<Modal> {
switch (link.type) { switch (link.type) {
case "profile": { case "profile": {
__thisIsAHack({ id: "profile", user_id: link.id }); this.push({ type: "user_profile", user_id: link.id });
break; break;
} }
case "navigate": { case "navigate": {
@ -203,17 +223,24 @@ class ModalControllerExtended extends ModalController<Modal> {
export const modalController = new ModalControllerExtended({ export const modalController = new ModalControllerExtended({
changelog: Changelog, changelog: Changelog,
channel_info: ChannelInfo,
clipboard: Clipboard, clipboard: Clipboard,
create_bot: CreateBotModal,
error: Error, error: Error,
image_viewer: ImageViewer,
link_warning: LinkWarning, link_warning: LinkWarning,
mfa_flow: MFAFlow, mfa_flow: MFAFlow,
mfa_recovery: MFARecovery, mfa_recovery: MFARecovery,
mfa_enable_totp: MFAEnableTOTP, mfa_enable_totp: MFAEnableTOTP,
modify_account: ModifyAccount, modify_account: ModifyAccount,
onboarding: OnboardingModal,
out_of_date: OutOfDate, out_of_date: OutOfDate,
pending_friend_requests: PendingFriendRequests, pending_friend_requests: PendingFriendRequests,
server_identity: ServerIdentity, server_identity: ServerIdentity,
server_info: ServerInfo,
show_token: ShowToken, show_token: ShowToken,
signed_out: SignedOut, signed_out: SignedOut,
sign_out_sessions: SignOutSessions, sign_out_sessions: SignOutSessions,
user_picker: UserPicker,
user_profile: UserProfile,
}); });

View file

@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite";
import { useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import { modalController } from "."; import { modalController } from "./ModalController";
export default observer(() => { export default observer(() => {
useEffect(() => { useEffect(() => {

View file

@ -0,0 +1,29 @@
import { X } from "@styled-icons/boxicons-regular";
import { Column, H1, IconButton, Modal, Row } from "@revoltchat/ui";
import Markdown from "../../../components/markdown/Markdown";
import { modalController } from "../ModalController";
import { ModalProps } from "../types";
export default function ChannelInfo({
channel,
...props
}: ModalProps<"channel_info">) {
return (
<Modal
{...props}
title={
<Row centred>
<Column grow>
<H1>{`#${channel.name}`}</H1>
</Column>
<IconButton onClick={modalController.close}>
<X size={36} />
</IconButton>
</Row>
}>
<Markdown content={channel.description!} />
</Modal>
);
}

View file

@ -1,23 +1,40 @@
/* eslint-disable react-hooks/rules-of-hooks */ import styled from "styled-components";
import { API } from "revolt.js";
import styles from "./ImageViewer.module.scss";
import { Modal } from "@revoltchat/ui"; import { Modal } from "@revoltchat/ui";
import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions"; import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions";
import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions"; import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions";
import { useClient } from "../../revoltjs/RevoltClient"; import { useClient } from "../../client/ClientController";
import { ModalProps } from "../types";
interface Props { const Viewer = styled.div`
onClose: () => void; display: flex;
embed?: API.Image; overflow: hidden;
attachment?: API.File; flex-direction: column;
} border-end-end-radius: 4px;
border-end-start-radius: 4px;
type ImageMetadata = API.Metadata & { type: "Image" }; max-width: 100vw;
img {
width: auto;
height: auto;
max-width: 90vw;
max-height: 75vh;
object-fit: contain;
border-bottom: thin solid var(--tertiary-foreground);
-webkit-touch-callout: default;
}
`;
export default function ImageViewer({
embed,
attachment,
...props
}: ModalProps<"image_viewer">) {
const client = useClient();
export function ImageViewer({ attachment, embed, onClose }: Props) {
if (attachment && attachment.metadata.type !== "Image") { if (attachment && attachment.metadata.type !== "Image") {
console.warn( console.warn(
`Attempted to use a non valid attatchment type in the image viewer: ${attachment.metadata.type}`, `Attempted to use a non valid attatchment type in the image viewer: ${attachment.metadata.type}`,
@ -25,20 +42,16 @@ export function ImageViewer({ attachment, embed, onClose }: Props) {
return null; return null;
} }
const client = useClient();
return ( return (
<Modal onClose={onClose} transparent maxHeight="100vh" maxWidth="100vw"> <Modal {...props} transparent maxHeight="100vh" maxWidth="100vw">
<div className={styles.viewer}> <Viewer>
{attachment && ( {attachment && (
<> <>
<img <img
loading="eager" loading="eager"
src={client.generateFileURL(attachment)} src={client.generateFileURL(attachment)}
width={(attachment.metadata as ImageMetadata).width} width={(attachment.metadata as any).width}
height={ height={(attachment.metadata as any).height}
(attachment.metadata as ImageMetadata).height
}
/> />
<AttachmentActions attachment={attachment} /> <AttachmentActions attachment={attachment} />
</> </>
@ -54,7 +67,7 @@ export function ImageViewer({ attachment, embed, onClose }: Props) {
<EmbedMediaActions embed={embed} /> <EmbedMediaActions embed={embed} />
</> </>
)} )}
</div> </Viewer>
</Modal> </Modal>
); );
} }

View file

@ -7,8 +7,8 @@ import { Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js"; import { noopTrue } from "../../../lib/js";
import { modalController } from "..";
import { toConfig } from "../../../components/settings/account/MultiFactorAuthentication"; import { toConfig } from "../../../components/settings/account/MultiFactorAuthentication";
import { modalController } from "../ModalController";
import { ModalProps } from "../types"; import { ModalProps } from "../types";
/** /**

View file

@ -1,17 +1,16 @@
import { SubmitHandler, useForm } from "react-hook-form"; import { SubmitHandler, useForm } from "react-hook-form";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Category, Error, Modal } from "@revoltchat/ui"; import { Category, Error, Modal } from "@revoltchat/ui";
import { noopTrue } from "../../../lib/js"; import { noopTrue } from "../../../lib/js";
import { useApplicationState } from "../../../mobx/State"; import { takeError } from "../../../context/revoltjs/util";
import FormField from "../../../pages/login/FormField"; import FormField from "../../../pages/login/FormField";
import { AppContext } from "../../revoltjs/RevoltClient"; import { useClient } from "../../client/ClientController";
import { takeError } from "../../revoltjs/util";
import { ModalProps } from "../types"; import { ModalProps } from "../types";
interface FormInputs { interface FormInputs {
@ -29,7 +28,7 @@ export default function ModifyAccount({
field, field,
...props ...props
}: ModalProps<"modify_account">) { }: ModalProps<"modify_account">) {
const client = useApplicationState().client!; const client = useClient();
const [processing, setProcessing] = useState(false); const [processing, setProcessing] = useState(false);
const { handleSubmit, register, errors } = useForm<FormInputs>(); const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);

View file

@ -19,7 +19,8 @@ import {
import { noop } from "../../../lib/js"; import { noop } from "../../../lib/js";
import { FileUploader } from "../../revoltjs/FileUploads"; import { FileUploader } from "../../../context/revoltjs/FileUploads";
import { ModalProps } from "../types"; import { ModalProps } from "../types";
const Preview = styled(Centred)` const Preview = styled(Centred)`

View file

@ -0,0 +1,48 @@
import { X } from "@styled-icons/boxicons-regular";
import { Text } from "preact-i18n";
import { Column, H1, IconButton, Modal, Row } from "@revoltchat/ui";
import Markdown from "../../../components/markdown/Markdown";
import { report } from "../../safety";
import { modalController } from "../ModalController";
import { ModalProps } from "../types";
export default function ServerInfo({
server,
...props
}: ModalProps<"server_info">) {
return (
<Modal
{...props}
title={
<Row centred>
<Column grow>
<H1>{server.name}</H1>
</Column>
<IconButton onClick={modalController.close}>
<X size={36} />
</IconButton>
</Row>
}
actions={[
{
onClick: () =>
modalController.push({
type: "server_identity",
member: server.member!,
}),
children: "Edit Identity",
palette: "primary",
},
{
onClick: () => report(server),
children: <Text id="app.special.modals.actions.report" />,
palette: "error",
},
]}>
<Markdown content={server.description!} />
</Modal>
);
}

View file

@ -1,41 +1,50 @@
import styles from "./UserPicker.module.scss"; import styled from "styled-components";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useState } from "preact/hooks"; import { useMemo, useState } from "preact/hooks";
import { Modal } from "@revoltchat/ui"; import { Modal } from "@revoltchat/ui";
import UserCheckbox from "../../../components/common/user/UserCheckbox"; import UserCheckbox from "../../../components/common/user/UserCheckbox";
import { useClient } from "../../revoltjs/RevoltClient"; import { useClient } from "../../client/ClientController";
import { ModalProps } from "../types";
interface Props { const List = styled.div`
omit?: string[]; max-width: 100%;
onClose: () => void; max-height: 360px;
callback: (users: string[]) => Promise<void>; overflow-y: scroll;
} `;
export function UserPicker(props: Props) { export function UserPicker({
callback,
omit,
...props
}: ModalProps<"user_picker">) {
const [selected, setSelected] = useState<string[]>([]); const [selected, setSelected] = useState<string[]>([]);
const omit = [...(props.omit || []), "00000000000000000000000000"]; const omitted = useMemo(
() => new Set([...(omit || []), "00000000000000000000000000"]),
[omit],
);
const client = useClient(); const client = useClient();
return ( return (
<Modal <Modal
{...props}
title={<Text id="app.special.popovers.user_picker.select" />} title={<Text id="app.special.popovers.user_picker.select" />}
onClose={props.onClose}
actions={[ actions={[
{ {
children: <Text id="app.special.modals.actions.ok" />, children: <Text id="app.special.modals.actions.ok" />,
onClick: () => props.callback(selected).then(() => true), onClick: () => callback(selected).then(() => true),
}, },
]}> ]}>
<div className={styles.list}> <List>
{[...client.users.values()] {[...client.users.values()]
.filter( .filter(
(x) => (x) =>
x && x &&
x.relationship === "Friend" && x.relationship === "Friend" &&
!omit.includes(x._id), !omitted.has(x._id),
) )
.map((x) => ( .map((x) => (
<UserCheckbox <UserCheckbox
@ -53,7 +62,7 @@ export function UserPicker(props: Props) {
}} }}
/> />
))} ))}
</div> </List>
</Modal> </Modal>
); );
} }

View file

@ -2,26 +2,29 @@ import { SubmitHandler, useForm } from "react-hook-form";
import { API } from "revolt.js"; import { API } from "revolt.js";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useState } from "preact/hooks"; import { useState } from "preact/hooks";
import { Category, Modal } from "@revoltchat/ui"; import { Category, Modal } from "@revoltchat/ui";
import FormField from "../../../pages/login/FormField"; import { noopTrue } from "../../../../lib/js";
import { I18nError } from "../../Locale";
import { AppContext } from "../../revoltjs/RevoltClient";
import { takeError } from "../../revoltjs/util";
interface Props { import { I18nError } from "../../../../context/Locale";
onClose: () => void; import { takeError } from "../../../../context/revoltjs/util";
onCreate: (bot: API.Bot) => void;
} import FormField from "../../../../pages/login/FormField";
import { useClient } from "../../../client/ClientController";
import { modalController } from "../../ModalController";
import { ModalProps } from "../../types";
interface FormInputs { interface FormInputs {
name: string; name: string;
} }
export function CreateBotModal({ onClose, onCreate }: Props) { export function CreateBotModal({
const client = useContext(AppContext); onCreate,
...props
}: ModalProps<"create_bot">) {
const client = useClient();
const { handleSubmit, register, errors } = useForm<FormInputs>(); const { handleSubmit, register, errors } = useForm<FormInputs>();
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
@ -29,7 +32,7 @@ export function CreateBotModal({ onClose, onCreate }: Props) {
try { try {
const { bot } = await client.bots.create({ name }); const { bot } = await client.bots.create({ name });
onCreate(bot); onCreate(bot);
onClose(); modalController.close();
} catch (err) { } catch (err) {
setError(takeError(err)); setError(takeError(err));
} }
@ -37,7 +40,7 @@ export function CreateBotModal({ onClose, onCreate }: Props) {
return ( return (
<Modal <Modal
onClose={onClose} {...props}
title={<Text id="app.special.popovers.create_bot.title" />} title={<Text id="app.special.popovers.create_bot.title" />}
actions={[ actions={[
{ {
@ -51,7 +54,7 @@ export function CreateBotModal({ onClose, onCreate }: Props) {
}, },
{ {
palette: "plain", palette: "plain",
onClick: onClose, onClick: noopTrue,
children: <Text id="app.special.modals.actions.cancel" />, children: <Text id="app.special.modals.actions.cancel" />,
}, },
]}> ]}>

View file

@ -1,5 +1,12 @@
.onboarding { .onboarding {
height: 100vh; position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background: var(--background);
display: flex; display: flex;
align-items: center; align-items: center;
flex-direction: column; flex-direction: column;

View file

@ -6,21 +6,21 @@ import { useState } from "preact/hooks";
import { Button, Preloader } from "@revoltchat/ui"; import { Button, Preloader } from "@revoltchat/ui";
import { takeError } from "../../../../context/revoltjs/util";
import wideSVG from "/assets/wide.svg"; import wideSVG from "/assets/wide.svg";
import FormField from "../../../pages/login/FormField"; import FormField from "../../../../pages/login/FormField";
import { takeError } from "../../revoltjs/util"; import { ModalProps } from "../../types";
interface Props {
onClose: () => void;
callback: (username: string, loginAfterSuccess?: true) => Promise<void>;
}
interface FormInputs { interface FormInputs {
username: string; username: string;
} }
export function OnboardingModal({ onClose, callback }: Props) { export function OnboardingModal({
callback,
...props
}: ModalProps<"onboarding">) {
const { handleSubmit, register } = useForm<FormInputs>(); const { handleSubmit, register } = useForm<FormInputs>();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | undefined>(undefined); const [error, setError] = useState<string | undefined>(undefined);
@ -28,7 +28,7 @@ export function OnboardingModal({ onClose, callback }: Props) {
const onSubmit: SubmitHandler<FormInputs> = ({ username }) => { const onSubmit: SubmitHandler<FormInputs> = ({ username }) => {
setLoading(true); setLoading(true);
callback(username, true) callback(username, true)
.then(() => onClose()) .then(() => props.onClose())
.catch((err: unknown) => { .catch((err: unknown) => {
setError(takeError(err)); setError(takeError(err));
setLoading(false); setLoading(false);

View file

@ -13,7 +13,7 @@ import { UserPermission, API } from "revolt.js";
import styles from "./UserProfile.module.scss"; import styles from "./UserProfile.module.scss";
import { Localizer, Text } from "preact-i18n"; import { Localizer, Text } from "preact-i18n";
import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks"; import { useEffect, useLayoutEffect, useState } from "preact/hooks";
import { import {
Button, Button,
@ -24,34 +24,27 @@ import {
Preloader, Preloader,
} from "@revoltchat/ui"; } from "@revoltchat/ui";
import { noop } from "../../../lib/js"; import { noop } from "../../../../lib/js";
import ChannelIcon from "../../../components/common/ChannelIcon"; import ChannelIcon from "../../../../components/common/ChannelIcon";
import ServerIcon from "../../../components/common/ServerIcon"; import ServerIcon from "../../../../components/common/ServerIcon";
import Tooltip from "../../../components/common/Tooltip"; import Tooltip from "../../../../components/common/Tooltip";
import UserBadges from "../../../components/common/user/UserBadges"; import UserBadges from "../../../../components/common/user/UserBadges";
import UserIcon from "../../../components/common/user/UserIcon"; import UserIcon from "../../../../components/common/user/UserIcon";
import { Username } from "../../../components/common/user/UserShort"; import { Username } from "../../../../components/common/user/UserShort";
import UserStatus from "../../../components/common/user/UserStatus"; import UserStatus from "../../../../components/common/user/UserStatus";
import Markdown from "../../../components/markdown/Markdown"; import Markdown from "../../../../components/markdown/Markdown";
import { import { useSession } from "../../../../controllers/client/ClientController";
ClientStatus, import { modalController } from "../../../../controllers/modals/ModalController";
StatusContext, import { ModalProps } from "../../types";
useClient,
} from "../../revoltjs/RevoltClient";
import { useIntermediate } from "../Intermediate";
interface Props {
user_id: string;
dummy?: boolean;
onClose?: () => void;
dummyProfile?: API.UserProfile;
}
export const UserProfile = observer( export const UserProfile = observer(
({ user_id, onClose, dummy, dummyProfile }: Props) => { ({
const { openScreen, writeClipboard } = useIntermediate(); user_id,
dummy,
dummyProfile,
...props
}: ModalProps<"user_profile">) => {
const [profile, setProfile] = useState< const [profile, setProfile] = useState<
undefined | null | API.UserProfile undefined | null | API.UserProfile
>(undefined); >(undefined);
@ -63,13 +56,13 @@ export const UserProfile = observer(
>(); >();
const history = useHistory(); const history = useHistory();
const client = useClient(); const session = useSession()!;
const status = useContext(StatusContext); const client = session.client!;
const [tab, setTab] = useState("profile"); const [tab, setTab] = useState("profile");
const user = client.users.get(user_id); const user = client.users.get(user_id);
if (!user) { if (!user) {
if (onClose) useEffect(onClose, []); if (props.onClose) useEffect(props.onClose, []);
return null; return null;
} }
@ -101,32 +94,26 @@ export const UserProfile = observer(
useEffect(() => { useEffect(() => {
if (dummy) return; if (dummy) return;
if ( if (session.state === "Online" && typeof mutual === "undefined") {
status === ClientStatus.ONLINE &&
typeof mutual === "undefined"
) {
setMutual(null); setMutual(null);
user.fetchMutual().then(setMutual); user.fetchMutual().then(setMutual);
} }
}, [mutual, status, dummy, user]); }, [mutual, session.state, dummy, user]);
useEffect(() => { useEffect(() => {
if (dummy) return; if (dummy) return;
if ( if (session.state === "Online" && typeof profile === "undefined") {
status === ClientStatus.ONLINE &&
typeof profile === "undefined"
) {
setProfile(null); setProfile(null);
if (user.permission & UserPermission.ViewProfile) { if (user.permission & UserPermission.ViewProfile) {
user.fetchProfile().then(setProfile).catch(noop); user.fetchProfile().then(setProfile).catch(noop);
} }
} }
}, [profile, status, dummy, user]); }, [profile, session.state, dummy, user]);
useEffect(() => { useEffect(() => {
if ( if (
status === ClientStatus.ONLINE && session.state === "Online" &&
user.bot && user.bot &&
typeof isPublicBot === "undefined" typeof isPublicBot === "undefined"
) { ) {
@ -136,7 +123,7 @@ export const UserProfile = observer(
.then(() => setIsPublicBot(true)) .then(() => setIsPublicBot(true))
.catch(noop); .catch(noop);
} }
}, [isPublicBot, status, user, client.bots]); }, [isPublicBot, session.state, user, client.bots]);
const backgroundURL = const backgroundURL =
profile && profile &&
@ -169,8 +156,8 @@ export const UserProfile = observer(
hover={typeof user.avatar !== "undefined"} hover={typeof user.avatar !== "undefined"}
onClick={() => onClick={() =>
user.avatar && user.avatar &&
openScreen({ modalController.push({
id: "image_viewer", type: "image_viewer",
attachment: user.avatar, attachment: user.avatar,
}) })
} }
@ -180,7 +167,7 @@ export const UserProfile = observer(
<span <span
className={styles.username} className={styles.username}
onClick={() => onClick={() =>
writeClipboard(user.username) modalController.writeText(user.username)
}> }>
@{user.username} @{user.username}
</span> </span>
@ -196,7 +183,7 @@ export const UserProfile = observer(
<Button <Button
palette="accent" palette="accent"
compact compact
onClick={onClose}> onClick={props.onClose}>
Add to server Add to server
</Button> </Button>
</Link> </Link>
@ -209,7 +196,7 @@ export const UserProfile = observer(
}> }>
<IconButton <IconButton
onClick={() => { onClick={() => {
onClose?.(); props.onClose?.();
history.push(`/open/${user_id}`); history.push(`/open/${user_id}`);
}}> }}>
<Envelope size={30} /> <Envelope size={30} />
@ -220,7 +207,7 @@ export const UserProfile = observer(
{user.relationship === "User" && !dummy && ( {user.relationship === "User" && !dummy && (
<IconButton <IconButton
onClick={() => { onClick={() => {
onClose?.(); props.onClose?.();
history.push(`/settings/profile`); history.push(`/settings/profile`);
}}> }}>
<Edit size={28} /> <Edit size={28} />
@ -300,8 +287,8 @@ export const UserProfile = observer(
<div <div
onClick={() => onClick={() =>
user.bot && user.bot &&
openScreen({ modalController.push({
id: "profile", type: "user_profile",
user_id: user.bot.owner, user_id: user.bot.owner,
}) })
} }
@ -364,8 +351,8 @@ export const UserProfile = observer(
x && ( x && (
<div <div
onClick={() => onClick={() =>
openScreen({ modalController.push({
id: "profile", type: "user_profile",
user_id: x._id, user_id: x._id,
}) })
} }
@ -445,7 +432,7 @@ export const UserProfile = observer(
return ( return (
<Modal <Modal
onClose={onClose} {...props}
nonDismissable={dummy} nonDismissable={dummy}
transparent transparent
maxWidth="560px"> maxWidth="560px">

View file

@ -1,4 +1,4 @@
import { API, Client, User, Member } from "revolt.js"; import { API, Client, User, Member, Channel, Server } from "revolt.js";
export type Modal = { export type Modal = {
key?: string; key?: string;
@ -72,6 +72,41 @@ export type Modal = {
| { | {
type: "signed_out"; type: "signed_out";
} }
| {
type: "channel_info";
channel: Channel;
}
| {
type: "server_info";
server: Server;
}
| {
type: "image_viewer";
embed?: API.Image;
attachment?: API.File;
}
| {
type: "user_picker";
omit?: string[];
callback: (users: string[]) => Promise<void>;
}
| {
type: "user_profile";
user_id: string;
dummy?: boolean;
dummyProfile?: API.UserProfile;
}
| {
type: "create_bot";
onCreate: (bot: API.Bot) => void;
}
| {
type: "onboarding";
callback: (
username: string,
loginAfterSuccess?: true,
) => Promise<void>;
}
); );
export type ModalProps<T extends Modal["type"]> = Modal & { type: T } & { export type ModalProps<T extends Modal["type"]> = Modal & { type: T } & {

View file

@ -0,0 +1,16 @@
import { Server } from "revolt.js";
export function report(object: Server) {
let type;
if (object instanceof Server) {
type = "Server";
}
window.open(
`mailto:abuse@revolt.chat?subject=${encodeURIComponent(
`${type} Report`,
)}&body=${encodeURIComponent(
`${type} ID: ${object._id}\nWrite more information here!`,
)}`,
);
}

View file

@ -19,7 +19,6 @@ import {
openContextMenu, openContextMenu,
} from "preact-context-menu"; } from "preact-context-menu";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext } from "preact/hooks";
import { IconButton, LineDivider } from "@revoltchat/ui"; import { IconButton, LineDivider } from "@revoltchat/ui";
@ -28,17 +27,13 @@ import { QueuedMessage } from "../mobx/stores/MessageQueue";
import { NotificationState } from "../mobx/stores/NotificationOptions"; import { NotificationState } from "../mobx/stores/NotificationOptions";
import { Screen, useIntermediate } from "../context/intermediate/Intermediate"; import { Screen, useIntermediate } from "../context/intermediate/Intermediate";
import { modalController } from "../context/modals";
import {
AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
import { takeError } from "../context/revoltjs/util"; import { takeError } from "../context/revoltjs/util";
import CMNotifications from "./contextmenu/CMNotifications"; import CMNotifications from "./contextmenu/CMNotifications";
import Tooltip from "../components/common/Tooltip"; import Tooltip from "../components/common/Tooltip";
import UserStatus from "../components/common/user/UserStatus"; import UserStatus from "../components/common/user/UserStatus";
import { useSession } from "../controllers/client/ClientController";
import { modalController } from "../controllers/modals/ModalController";
import { internalEmit } from "./eventEmitter"; import { internalEmit } from "./eventEmitter";
import { getRenderer } from "./renderer/Singleton"; import { getRenderer } from "./renderer/Singleton";
@ -122,12 +117,12 @@ type Action =
// Tip: This should just be split into separate context menus per logical area. // Tip: This should just be split into separate context menus per logical area.
export default function ContextMenus() { export default function ContextMenus() {
const { openScreen, writeClipboard } = useIntermediate(); const { openScreen, writeClipboard } = useIntermediate();
const client = useContext(AppContext); const session = useSession()!;
const client = session.client!;
const userId = client.user!._id; const userId = client.user!._id;
const status = useContext(StatusContext);
const isOnline = status === ClientStatus.ONLINE;
const state = useApplicationState(); const state = useApplicationState();
const history = useHistory(); const history = useHistory();
const isOnline = session.state === "Online";
function contextClick(data?: Action) { function contextClick(data?: Action) {
if (typeof data === "undefined") return; if (typeof data === "undefined") return;
@ -319,7 +314,10 @@ export default function ContextMenus() {
break; break;
case "view_profile": case "view_profile":
openScreen({ id: "profile", user_id: data.user._id }); modalController.push({
type: "user_profile",
user_id: data.user._id,
});
break; break;
case "message_user": case "message_user":

View file

@ -1,13 +0,0 @@
import { observer } from "mobx-react-lite";
import { useMemo } from "preact/hooks";
import { useApplicationState } from "../mobx/State";
import { AppContext } from "../context/revoltjs/RevoltClient";
export default observer(({ children }: { children: Children }) => {
const config = useApplicationState().config;
const client = useMemo(() => config.createClient(), [config.get()]);
return <AppContext.Provider value={client}>{children}</AppContext.Provider>;
});

View file

@ -1,8 +1,6 @@
/* eslint-disable react-hooks/rules-of-hooks */ /* eslint-disable react-hooks/rules-of-hooks */
import { action, makeAutoObservable } from "mobx"; import { action, makeAutoObservable } from "mobx";
import { Channel } from "revolt.js"; import { Channel, Message, Nullable } from "revolt.js";
import { Message } from "revolt.js";
import { Nullable } from "revolt.js";
import { SimpleRenderer } from "./simple/SimpleRenderer"; import { SimpleRenderer } from "./simple/SimpleRenderer";
import { RendererRoutines, ScrollState } from "./types"; import { RendererRoutines, ScrollState } from "./types";

20
src/lib/window.ts Normal file
View file

@ -0,0 +1,20 @@
/**
* Inject a key into the window's globals.
* @param key Key
* @param value Value
*/
export function injectWindow(key: string, value: any) {
(window as any)[key] = value;
}
/**
* Inject a controller into the global controllers object.
* @param key Key
* @param value Value
*/
export function injectController(key: string, value: any) {
(window as any).controllers = {
...((window as any).controllers ?? {}),
[key]: value,
};
}

View file

@ -1,11 +1,13 @@
// @ts-expect-error No typings. // @ts-expect-error No typings.
import stringify from "json-stringify-deterministic"; import stringify from "json-stringify-deterministic";
import localforage from "localforage"; import localforage from "localforage";
import { makeAutoObservable, reaction, runInAction } from "mobx"; import { action, makeAutoObservable, reaction, runInAction } from "mobx";
import { Client } from "revolt.js"; import { Client, ClientboundNotification } from "revolt.js";
import { reportError } from "../lib/ErrorBoundary"; import { reportError } from "../lib/ErrorBoundary";
import { injectWindow } from "../lib/window";
import { clientController } from "../controllers/client/ClientController";
import Persistent from "./interfaces/Persistent"; import Persistent from "./interfaces/Persistent";
import Syncable from "./interfaces/Syncable"; import Syncable from "./interfaces/Syncable";
import Auth from "./stores/Auth"; import Auth from "./stores/Auth";
@ -24,6 +26,7 @@ import Sync, { Data as DataSync, SyncKeys } from "./stores/Sync";
export const MIGRATIONS = { export const MIGRATIONS = {
REDUX: 1640305719826, REDUX: 1640305719826,
MULTI_SERVER_CONFIG: 1656350006152,
}; };
/** /**
@ -36,7 +39,10 @@ export default class State {
locale: LocaleOptions; locale: LocaleOptions;
experiments: Experiments; experiments: Experiments;
layout: Layout; layout: Layout;
config: ServerConfig; /**
* DEPRECATED
*/
private config: ServerConfig;
notifications: NotificationOptions; notifications: NotificationOptions;
queue: MessageQueue; queue: MessageQueue;
settings: Settings; settings: Settings;
@ -47,8 +53,6 @@ export default class State {
private persistent: [string, Persistent<unknown>][] = []; private persistent: [string, Persistent<unknown>][] = [];
private disabled: Set<string> = new Set(); private disabled: Set<string> = new Set();
client?: Client;
/** /**
* Construct new State. * Construct new State.
*/ */
@ -60,21 +64,21 @@ export default class State {
this.experiments = new Experiments(); this.experiments = new Experiments();
this.layout = new Layout(); this.layout = new Layout();
this.config = new ServerConfig(); this.config = new ServerConfig();
this.notifications = new NotificationOptions(); this.notifications = new NotificationOptions(this);
this.queue = new MessageQueue(); this.queue = new MessageQueue();
this.settings = new Settings(); this.settings = new Settings();
this.sync = new Sync(this); this.sync = new Sync(this);
this.plugins = new Plugins(this); this.plugins = new Plugins(this);
this.ordering = new Ordering(this); this.ordering = new Ordering(this);
makeAutoObservable(this, { makeAutoObservable(this);
client: false,
});
this.register(); this.register();
this.setDisabled = this.setDisabled.bind(this); this.setDisabled = this.setDisabled.bind(this);
this.onPacket = this.onPacket.bind(this);
this.client = undefined; // Inject into window
injectWindow("state", this);
} }
/** /**
@ -131,6 +135,20 @@ export default class State {
} }
} }
/**
* Consume packets from the client.
* @param packet Inbound Packet
*/
@action onPacket(packet: ClientboundNotification) {
if (packet.type === "UserSettingsUpdate") {
try {
this.sync.apply(packet.update);
} catch (err) {
reportError(err as any, "failed_sync_apply");
}
}
}
/** /**
* Register reaction listeners for persistent data stores. * Register reaction listeners for persistent data stores.
* @returns Function to dispose of listeners * @returns Function to dispose of listeners
@ -138,8 +156,28 @@ export default class State {
registerListeners(client?: Client) { registerListeners(client?: Client) {
// If a client is present currently, expose it and provide it to plugins. // If a client is present currently, expose it and provide it to plugins.
if (client) { if (client) {
this.client = client; // Register message listener for clearing queue.
this.plugins.onClient(client); client.addListener("message", this.queue.onMessage);
// Register listener for incoming packets.
client.addListener("packet", this.onPacket);
// Register events for notifications.
client.addListener("message", this.notifications.onMessage);
client.addListener(
"user/relationship",
this.notifications.onRelationship,
);
document.addEventListener(
"visibilitychange",
this.notifications.onVisibilityChange,
);
// Sync settings from remote server.
state.sync
.pull(client)
.catch(console.error)
.finally(() => state.changelog.checkForUpdates());
} }
// Register all the listeners required for saving and syncing state. // Register all the listeners required for saving and syncing state.
@ -225,8 +263,20 @@ export default class State {
}); });
return () => { return () => {
// Stop exposing the client. // Remove any listeners attached to client.
this.client = undefined; if (client) {
client.removeListener("message", this.queue.onMessage);
client.removeListener("packet", this.onPacket);
client.removeListener("message", this.notifications.onMessage);
client.removeListener(
"user/relationship",
this.notifications.onRelationship,
);
document.removeEventListener(
"visibilitychange",
this.notifications.onVisibilityChange,
);
}
// Wipe all listeners. // Wipe all listeners.
listeners.forEach((x) => x()); listeners.forEach((x) => x());
@ -253,6 +303,9 @@ export default class State {
// Post-hydration, init plugins. // Post-hydration, init plugins.
this.plugins.init(); this.plugins.init();
// Push authentication information forwards to client controller.
clientController.hydrate(this.auth);
} }
/** /**
@ -263,7 +316,7 @@ export default class State {
this.draft = new Draft(); this.draft = new Draft();
this.experiments = new Experiments(); this.experiments = new Experiments();
this.layout = new Layout(); this.layout = new Layout();
this.notifications = new NotificationOptions(); this.notifications = new NotificationOptions(this);
this.queue = new MessageQueue(); this.queue = new MessageQueue();
this.settings = new Settings(); this.settings = new Settings();
this.sync = new Sync(this); this.sync = new Sync(this);
@ -277,13 +330,7 @@ export default class State {
} }
} }
let state: State; export const state = new State();
export async function hydrateState() {
state = new State();
(window as any).state = state;
await state.hydrate();
}
/** /**
* Get the application state * Get the application state

View file

@ -1,19 +1,18 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { API } from "revolt.js";
import { Nullable } from "revolt.js";
import { mapToRecord } from "../../lib/conversion"; import { mapToRecord } from "../../lib/conversion";
import { clientController } from "../../controllers/client/ClientController";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
interface Account { interface Account {
session: Session; session: Session;
apiUrl?: string;
} }
export interface Data { export interface Data {
sessions: Record<string, Account>; sessions: Record<string, Account>;
current?: string;
} }
/** /**
@ -22,14 +21,12 @@ export interface Data {
*/ */
export default class Auth implements Store, Persistent<Data> { export default class Auth implements Store, Persistent<Data> {
private sessions: ObservableMap<string, Account>; private sessions: ObservableMap<string, Account>;
private current: Nullable<string>;
/** /**
* Construct new Auth store. * Construct new Auth store.
*/ */
constructor() { constructor() {
this.sessions = new ObservableMap(); this.sessions = new ObservableMap();
this.current = null;
// Inject session token if it is provided. // Inject session token if it is provided.
if (import.meta.env.VITE_SESSION_TOKEN) { if (import.meta.env.VITE_SESSION_TOKEN) {
@ -40,8 +37,6 @@ export default class Auth implements Store, Persistent<Data> {
token: import.meta.env.VITE_SESSION_TOKEN as string, token: import.meta.env.VITE_SESSION_TOKEN as string,
}, },
}); });
this.current = "0";
} }
makeAutoObservable(this); makeAutoObservable(this);
@ -54,7 +49,6 @@ export default class Auth implements Store, Persistent<Data> {
@action toJSON() { @action toJSON() {
return { return {
sessions: JSON.parse(JSON.stringify(mapToRecord(this.sessions))), sessions: JSON.parse(JSON.stringify(mapToRecord(this.sessions))),
current: this.current ?? undefined,
}; };
} }
@ -72,19 +66,15 @@ export default class Auth implements Store, Persistent<Data> {
this.sessions.set(id, v[id]), this.sessions.set(id, v[id]),
); );
} }
if (data.current && this.sessions.has(data.current)) {
this.current = data.current;
}
} }
/** /**
* Add a new session to the auth manager. * Add a new session to the auth manager.
* @param session Session * @param session Session
* @param apiUrl Custom API URL
*/ */
@action setSession(session: Session) { @action setSession(session: Session, apiUrl?: string) {
this.sessions.set(session.user_id, { session }); this.sessions.set(session.user_id, { session, apiUrl });
this.current = session.user_id;
} }
/** /**
@ -92,34 +82,39 @@ export default class Auth implements Store, Persistent<Data> {
* @param user_id User ID tied to session * @param user_id User ID tied to session
*/ */
@action removeSession(user_id: string) { @action removeSession(user_id: string) {
if (user_id == this.current) { this.sessions.delete(user_id);
this.current = null;
} }
this.sessions.delete(user_id); /**
* Get all known accounts.
* @returns Array of accounts
*/
@computed getAccounts() {
return [...this.sessions.values()];
} }
/** /**
* Remove current session. * Remove current session.
*/ */
@action logout() { /*@action logout() {
this.current && this.removeSession(this.current); this.current && this.removeSession(this.current);
} }*/
/** /**
* Get current session. * Get current session.
* @returns Current session * @returns Current session
*/ */
@computed getSession() { /*@computed getSession() {
if (!this.current) return; if (!this.current) return;
return this.sessions.get(this.current)!.session; return this.sessions.get(this.current)!.session;
} }*/
/** /**
* Check whether we are currently logged in. * Check whether we are currently logged in.
* @returns Whether we are logged in * @returns Whether we are logged in
*/ */
@computed isLoggedIn() { @computed isLoggedIn() {
return this.current !== null; // ! FIXME: temp proxy info
return clientController.getActiveSession()?.ready;
} }
} }

View file

@ -1,8 +1,7 @@
import { action, makeAutoObservable, runInAction } from "mobx"; import { action, makeAutoObservable, runInAction } from "mobx";
import { modalController } from "../../context/modals";
import { latestChangelog } from "../../assets/changelogs"; import { latestChangelog } from "../../assets/changelogs";
import { modalController } from "../../controllers/modals/ModalController";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
import Syncable from "../interfaces/Syncable"; import Syncable from "../interfaces/Syncable";

View file

@ -5,6 +5,7 @@ import {
makeAutoObservable, makeAutoObservable,
observable, observable,
} from "mobx"; } from "mobx";
import { Message } from "revolt.js";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
@ -47,6 +48,8 @@ export default class MessageQueue implements Store {
constructor() { constructor() {
this.messages = observable.array([]); this.messages = observable.array([]);
makeAutoObservable(this); makeAutoObservable(this);
this.onMessage = this.onMessage.bind(this);
} }
get id() { get id() {
@ -105,4 +108,16 @@ export default class MessageQueue implements Store {
@computed get(channel: string) { @computed get(channel: string) {
return this.messages.filter((x) => x.channel === channel); return this.messages.filter((x) => x.channel === channel);
} }
/**
* Handle an incoming Message
* @param message Message
*/
@action onMessage(message: Message) {
if (!message.nonce) return;
if (!this.get(message.channel_id).find((x) => x.id === message.nonce))
return;
this.remove(message.nonce);
}
} }

View file

@ -1,8 +1,14 @@
import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; import { action, computed, makeAutoObservable, ObservableMap } from "mobx";
import { Channel, Message, Server } from "revolt.js"; import { Channel, Message, Server, User } from "revolt.js";
import { decodeTime } from "ulid";
import { translate } from "preact-i18n";
import { mapToRecord } from "../../lib/conversion"; import { mapToRecord } from "../../lib/conversion";
import { history, routeInformation } from "../../context/history";
import State from "../State";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
import Syncable from "../interfaces/Syncable"; import Syncable from "../interfaces/Syncable";
@ -37,22 +43,54 @@ export interface Data {
channel?: Record<string, NotificationState>; channel?: Record<string, NotificationState>;
} }
/**
* Create a notification either directly or using service worker.
* @param title Notification Title
* @param options Notification Options
* @returns Notification
*/
async function createNotification(
title: string,
options: globalThis.NotificationOptions,
) {
try {
return new Notification(title, options);
} catch (err) {
const sw = await navigator.serviceWorker.getRegistration();
sw?.showNotification(title, options);
}
}
/** /**
* Manages the user's notification preferences. * Manages the user's notification preferences.
*/ */
export default class NotificationOptions export default class NotificationOptions
implements Store, Persistent<Data>, Syncable implements Store, Persistent<Data>, Syncable
{ {
private state: State;
private activeNotifications: Record<string, Notification>;
private server: ObservableMap<string, NotificationState>; private server: ObservableMap<string, NotificationState>;
private channel: ObservableMap<string, NotificationState>; private channel: ObservableMap<string, NotificationState>;
/** /**
* Construct new Experiments store. * Construct new Experiments store.
*/ */
constructor() { constructor(state: State) {
this.server = new ObservableMap(); this.server = new ObservableMap();
this.channel = new ObservableMap(); this.channel = new ObservableMap();
makeAutoObservable(this);
makeAutoObservable(this, {
onMessage: false,
onRelationship: false,
});
this.state = state;
this.activeNotifications = {};
this.onMessage = this.onMessage.bind(this);
this.onRelationship = this.onRelationship.bind(this);
this.onVisibilityChange = this.onVisibilityChange.bind(this);
} }
get id() { get id() {
@ -209,6 +247,245 @@ export default class NotificationOptions
return false; return false;
} }
/**
* Handle incoming messages and create a notification.
* @param message Message
*/
async onMessage(message: Message) {
// Ignore if we are currently looking and focused on the channel.
if (
message.channel_id === routeInformation.getChannel() &&
document.hasFocus()
)
return;
// Ignore if muted.
if (!this.shouldNotify(message)) return;
// Play a sound and skip notif if disabled.
this.state.settings.sounds.playSound("message");
if (!this.state.settings.get("notifications:desktop")) return;
const effectiveName =
message.masquerade?.name ?? message.author?.username;
let title;
switch (message.channel?.channel_type) {
case "SavedMessages":
return;
case "DirectMessage":
title = `@${effectiveName}`;
break;
case "Group":
if (message.author?._id === "00000000000000000000000000") {
title = message.channel.name;
} else {
title = `@${effectiveName} - ${message.channel.name}`;
}
break;
case "TextChannel":
title = `@${effectiveName} (#${message.channel.name}, ${message.channel.server?.name})`;
break;
default:
title = message.channel?._id;
break;
}
let image;
if (message.attachments) {
const imageAttachment = message.attachments.find(
(x) => x.metadata.type === "Image",
);
if (imageAttachment) {
image = message.client.generateFileURL(imageAttachment, {
max_side: 720,
});
}
}
let body, icon;
if (message.content) {
body = message.client.markdownToText(message.content);
if (message.masquerade?.avatar) {
icon = message.client.proxyFile(message.masquerade.avatar);
} else {
icon = message.author?.generateAvatarURL({ max_side: 256 });
}
} else if (message.system) {
const users = message.client.users;
// ! FIXME: I've had to strip translations while
// ! I move stuff into the new project structure
switch (message.system.type) {
case "user_added":
case "user_remove":
{
const user = users.get(message.system.id);
body = `${user?.username} ${
message.system.type === "user_added"
? "added by"
: "removed by"
} ${users.get(message.system.by)?.username}`;
/*body = translate(
`app.main.channel.system.${
message.system.type === "user_added"
? "added_by"
: "removed_by"
}`,
{
user: user?.username,
other_user: users.get(message.system.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(message.system.id);
body = `${user?.username}`;
/*body = translate(
`app.main.channel.system.${message.system.type}`,
{ user: user?.username },
);*/
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
case "channel_renamed":
{
const user = users.get(message.system.by);
body = `${user?.username} renamed channel to ${message.system.name}`;
/*body = translate(
`app.main.channel.system.channel_renamed`,
{
user: users.get(message.system.by)?.username,
name: message.system.name,
},
);*/
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
case "channel_description_changed":
case "channel_icon_changed":
{
const user = users.get(message.system.by);
/*body = translate(
`app.main.channel.system.${message.system.type}`,
{ user: users.get(message.system.by)?.username },
);*/
body = `${users.get(message.system.by)?.username}`;
icon = user?.generateAvatarURL({
max_side: 256,
});
}
break;
}
}
const notif = await createNotification(title!, {
icon,
image,
body,
timestamp: decodeTime(message._id),
tag: message.channel?._id,
badge: "/assets/icons/android-chrome-512x512.png",
silent: true,
});
if (notif) {
notif.addEventListener("click", () => {
window.focus();
const id = message.channel_id;
if (id !== routeInformation.getChannel()) {
const channel = message.client.channels.get(id);
if (channel) {
if (channel.channel_type === "TextChannel") {
history.push(
`/server/${channel.server_id}/channel/${id}`,
);
} else {
history.push(`/channel/${id}`);
}
}
}
});
this.activeNotifications[message.channel_id] = notif;
notif.addEventListener(
"close",
() => delete this.activeNotifications[message.channel_id],
);
}
}
/**
* Handle user relationship changes.
* @param user User relationship changed with
*/
async onRelationship(user: User) {
// Ignore if disabled.
if (!this.state.settings.get("notifications:desktop")) return;
// Check whether we are busy.
// This is checked by `shouldNotify` in the case of messages.
if (user.status?.presence === "Busy") {
return false;
}
let event;
switch (user.relationship) {
case "Incoming":
/*event = translate("notifications.sent_request", {
person: user.username,
});*/
event = `${user.username} sent you a friend request`;
break;
case "Friend":
/*event = translate("notifications.now_friends", {
person: user.username,
});*/
event = `Now friends with ${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`);
});
}
/**
* Called when document visibility changes.
*/
onVisibilityChange() {
if (document.visibilityState === "visible") {
const channel_id = routeInformation.getChannel()!;
if (this.activeNotifications[channel_id]) {
this.activeNotifications[channel_id].close();
}
}
}
@action apply(_key: "notifications", data: unknown, _revision: number) { @action apply(_key: "notifications", data: unknown, _revision: number) {
this.hydrate(data as Data); this.hydrate(data as Data);
} }

View file

@ -2,6 +2,7 @@ import { action, computed, makeAutoObservable } from "mobx";
import { reorder } from "@revoltchat/ui"; import { reorder } from "@revoltchat/ui";
import { clientController } from "../../controllers/client/ClientController";
import State from "../State"; import State from "../State";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
@ -63,18 +64,19 @@ export default class Ordering implements Store, Persistent<Data>, Syncable {
* All known servers with ordering applied * All known servers with ordering applied
*/ */
@computed get orderedServers() { @computed get orderedServers() {
const known = new Set(this.state.client?.servers.keys() ?? []); const client = clientController.getReadyClient();
const known = new Set(client?.servers.keys() ?? []);
const ordered = [...this.servers]; const ordered = [...this.servers];
const out = []; const out = [];
for (const id of ordered) { for (const id of ordered) {
if (known.delete(id)) { if (known.delete(id)) {
out.push(this.state.client!.servers.get(id)!); out.push(client!.servers.get(id)!);
} }
} }
for (const id of known) { for (const id of known) {
out.push(this.state.client!.servers.get(id)!); out.push(client!.servers.get(id)!);
} }
return out; return out;

View file

@ -41,7 +41,6 @@ type Plugin = {
* ```typescript * ```typescript
* function (state: State) { * function (state: State) {
* return { * return {
* onClient: (client: Client) => {},
* onUnload: () => {} * onUnload: () => {}
* } * }
* } * }
@ -59,7 +58,6 @@ type Plugin = {
type Instance = { type Instance = {
format: 1; format: 1;
onClient?: (client: Client) => {};
onUnload?: () => void; onUnload?: () => void;
}; };
@ -124,7 +122,7 @@ export default class Plugins implements Store, Persistent<Data> {
* @param id Plugin Id * @param id Plugin Id
*/ */
@computed get(namespace: string, id: string) { @computed get(namespace: string, id: string) {
return this.plugins.get(`${namespace }/${ id}`); return this.plugins.get(`${namespace}/${id}`);
} }
/** /**
@ -133,7 +131,7 @@ export default class Plugins implements Store, Persistent<Data> {
* @returns Plugin Instance * @returns Plugin Instance
*/ */
private getInstance(plugin: Pick<Plugin, "namespace" | "id">) { private getInstance(plugin: Pick<Plugin, "namespace" | "id">) {
return this.instances.get(`${plugin.namespace }/${ plugin.id}`); return this.instances.get(`${plugin.namespace}/${plugin.id}`);
} }
/** /**
@ -159,7 +157,7 @@ export default class Plugins implements Store, Persistent<Data> {
this.unload(plugin.namespace, plugin.id); this.unload(plugin.namespace, plugin.id);
} }
this.plugins.set(`${plugin.namespace }/${ plugin.id}`, plugin); this.plugins.set(`${plugin.namespace}/${plugin.id}`, plugin);
if (typeof plugin.enabled === "undefined" || plugin) { if (typeof plugin.enabled === "undefined" || plugin) {
this.load(plugin.namespace, plugin.id); this.load(plugin.namespace, plugin.id);
@ -173,7 +171,7 @@ export default class Plugins implements Store, Persistent<Data> {
*/ */
remove(namespace: string, id: string) { remove(namespace: string, id: string) {
this.unload(namespace, id); this.unload(namespace, id);
this.plugins.delete(`${namespace }/${ id}`); this.plugins.delete(`${namespace}/${id}`);
} }
/** /**
@ -186,7 +184,7 @@ export default class Plugins implements Store, Persistent<Data> {
if (!plugin) throw "Unknown plugin!"; if (!plugin) throw "Unknown plugin!";
try { try {
const ns = `${plugin.namespace }/${ plugin.id}`; const ns = `${plugin.namespace}/${plugin.id}`;
const instance: Instance = eval(plugin.entrypoint)(); const instance: Instance = eval(plugin.entrypoint)();
this.instances.set(ns, { this.instances.set(ns, {
@ -198,10 +196,6 @@ export default class Plugins implements Store, Persistent<Data> {
...plugin, ...plugin,
enabled: true, enabled: true,
}); });
if (this.state.client) {
instance.onClient?.(this.state.client);
}
} catch (error) { } catch (error) {
console.error(`Failed to load ${namespace}/${id}!`); console.error(`Failed to load ${namespace}/${id}!`);
console.error(error); console.error(error);
@ -217,7 +211,7 @@ export default class Plugins implements Store, Persistent<Data> {
const plugin = this.get(namespace, id); const plugin = this.get(namespace, id);
if (!plugin) throw "Unknown plugin!"; if (!plugin) throw "Unknown plugin!";
const ns = `${plugin.namespace }/${ plugin.id}`; const ns = `${plugin.namespace}/${plugin.id}`;
const loaded = this.getInstance(plugin); const loaded = this.getInstance(plugin);
if (loaded) { if (loaded) {
loaded.onUnload?.(); loaded.onUnload?.();
@ -235,13 +229,4 @@ export default class Plugins implements Store, Persistent<Data> {
localforage.removeItem("revite:plugins"); localforage.removeItem("revite:plugins");
window.location.reload(); window.location.reload();
} }
/**
* Push client through to plugins
*/
onClient(client: Client) {
for (const instance of this.instances.values()) {
instance.onClient?.(client);
}
}
} }

View file

@ -1,7 +1,5 @@
import { action, computed, makeAutoObservable } from "mobx"; import { action, computed, makeAutoObservable } from "mobx";
import { API } from "revolt.js"; import { API, Client, Nullable } from "revolt.js";
import { Client } from "revolt.js";
import { Nullable } from "revolt.js";
import { isDebug } from "../../revision"; import { isDebug } from "../../revision";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";

View file

@ -4,8 +4,7 @@ import { mapToRecord } from "../../lib/conversion";
import { Fonts, MonospaceFonts, Overrides } from "../../context/Theme"; import { Fonts, MonospaceFonts, Overrides } from "../../context/Theme";
import { EmojiPack } from "../../components/common/Emoji"; import { EmojiPack, setGlobalEmojiPack } from "../../components/common/Emoji";
import { MIGRATIONS } from "../State";
import Persistent from "../interfaces/Persistent"; import Persistent from "../interfaces/Persistent";
import Store from "../interfaces/Store"; import Store from "../interfaces/Store";
import Syncable from "../interfaces/Syncable"; import Syncable from "../interfaces/Syncable";
@ -79,6 +78,11 @@ export default class Settings
* @param value Value * @param value Value
*/ */
@action set<T extends keyof ISettings>(key: T, value: ISettings[T]) { @action set<T extends keyof ISettings>(key: T, value: ISettings[T]) {
// Emoji needs to be immediately applied.
if (key === 'appearance:emoji') {
setGlobalEmojiPack(value as EmojiPack);
}
this.data.set(key, value); this.data.set(key, value);
} }

View file

@ -2,24 +2,20 @@
import { useHistory, useParams } from "react-router-dom"; import { useHistory, useParams } from "react-router-dom";
import { Text } from "preact-i18n"; import { Text } from "preact-i18n";
import { useContext, useEffect } from "preact/hooks"; import { useEffect } from "preact/hooks";
import { Header } from "@revoltchat/ui"; import { Header } from "@revoltchat/ui";
import { modalController } from "../context/modals"; import { useSession } from "../controllers/client/ClientController";
import { import { modalController } from "../controllers/modals/ModalController";
AppContext,
ClientStatus,
StatusContext,
} from "../context/revoltjs/RevoltClient";
export default function Open() { export default function Open() {
const history = useHistory(); const history = useHistory();
const client = useContext(AppContext); const session = useSession()!;
const status = useContext(StatusContext); const client = session.client!;
const { id } = useParams<{ id: string }>(); const { id } = useParams<{ id: string }>();
if (status !== ClientStatus.ONLINE) { if (session.state !== "Online") {
return ( return (
<Header palette="primary"> <Header palette="primary">
<Text id="general.loading" /> <Text id="general.loading" />

View file

@ -8,8 +8,6 @@ import ContextMenus from "../lib/ContextMenus";
import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../lib/isTouchscreenDevice";
import Popovers from "../context/intermediate/Popovers"; import Popovers from "../context/intermediate/Popovers";
import Notifications from "../context/revoltjs/Notifications";
import StateMonitor from "../context/revoltjs/StateMonitor";
import { Titlebar } from "../components/native/Titlebar"; import { Titlebar } from "../components/native/Titlebar";
import BottomNavigation from "../components/navigation/BottomNavigation"; import BottomNavigation from "../components/navigation/BottomNavigation";
@ -77,12 +75,6 @@ const Routes = styled.div.attrs({ "data-component": "routes" })<{
background: var(--primary-background); background: var(--primary-background);
/*background-color: rgba(
var(--primary-background-rgb),
max(var(--min-opacity), 0.75)
);*/
//backdrop-filter: blur(10px);
${() => ${() =>
isTouchscreenDevice && isTouchscreenDevice &&
css` css`
@ -234,8 +226,6 @@ export default function App() {
</Routes> </Routes>
<ContextMenus /> <ContextMenus />
<Popovers /> <Popovers />
<Notifications />
<StateMonitor />
</OverlappingPanels> </OverlappingPanels>
</AppContainer> </AppContainer>
</> </>

View file

@ -5,7 +5,6 @@ import { lazy, Suspense } from "preact/compat";
import { Masks, Preloader } from "@revoltchat/ui"; import { Masks, Preloader } from "@revoltchat/ui";
import ErrorBoundary from "../lib/ErrorBoundary"; import ErrorBoundary from "../lib/ErrorBoundary";
import FakeClient from "../lib/FakeClient";
import Context from "../context"; import Context from "../context";
import { CheckAuth } from "../context/revoltjs/CheckAuth"; import { CheckAuth } from "../context/revoltjs/CheckAuth";
@ -16,29 +15,33 @@ const Login = lazy(() => import("./login/Login"));
const ConfirmDelete = lazy(() => import("./login/ConfirmDelete")); const ConfirmDelete = lazy(() => import("./login/ConfirmDelete"));
const RevoltApp = lazy(() => import("./RevoltApp")); const RevoltApp = lazy(() => import("./RevoltApp"));
const LoadSuspense: React.FC = ({ children }) => (
// @ts-expect-error Typing issue between Preact and Preact.
<Suspense fallback={<Preloader type="ring" />}>{children}</Suspense>
);
export function App() { export function App() {
return ( return (
<ErrorBoundary section="client"> <ErrorBoundary section="client">
<Context> <Context>
<Masks /> <Masks />
{/*
// @ts-expect-error typings mis-match between preact... and preact? */}
<Suspense fallback={<Preloader type="spinner" />}>
<Switch> <Switch>
<Route path="/login/verify/:token"> <Route path="/login/verify/:token">
<Login /> <Login />
</Route> </Route>
<Route path="/login/reset/:token"> <Route path="/login/reset/:token">
<LoadSuspense>
<Login /> <Login />
</LoadSuspense>
</Route> </Route>
<Route path="/delete/:token"> <Route path="/delete/:token">
<LoadSuspense>
<ConfirmDelete /> <ConfirmDelete />
</LoadSuspense>
</Route> </Route>
<Route path="/invite/:code"> <Route path="/invite/:code">
<CheckAuth blockRender> <CheckAuth blockRender>
<FakeClient>
<Invite /> <Invite />
</FakeClient>
</CheckAuth> </CheckAuth>
<CheckAuth auth blockRender> <CheckAuth auth blockRender>
<Invite /> <Invite />
@ -46,16 +49,19 @@ export function App() {
</Route> </Route>
<Route path="/login"> <Route path="/login">
<CheckAuth> <CheckAuth>
<LoadSuspense>
<Login /> <Login />
</LoadSuspense>
</CheckAuth> </CheckAuth>
</Route> </Route>
<Route path="/"> <Route path="/">
<CheckAuth auth> <CheckAuth auth>
<LoadSuspense>
<RevoltApp /> <RevoltApp />
</LoadSuspense>
</CheckAuth> </CheckAuth>
</Route> </Route>
</Switch> </Switch>
</Suspense>
</Context> </Context>
</ErrorBoundary> </ErrorBoundary>
); );

View file

@ -16,8 +16,6 @@ import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useApplicationState } from "../../mobx/State"; import { useApplicationState } from "../../mobx/State";
import { SIDEBAR_MEMBERS } from "../../mobx/stores/Layout"; import { SIDEBAR_MEMBERS } from "../../mobx/stores/Layout";
import { useClient } from "../../context/revoltjs/RevoltClient";
import AgeGate from "../../components/common/AgeGate"; import AgeGate from "../../components/common/AgeGate";
import MessageBox from "../../components/common/messaging/MessageBox"; import MessageBox from "../../components/common/messaging/MessageBox";
import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom"; import JumpToBottom from "../../components/common/messaging/bars/JumpToBottom";
@ -25,6 +23,7 @@ import NewMessages from "../../components/common/messaging/bars/NewMessages";
import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator"; import TypingIndicator from "../../components/common/messaging/bars/TypingIndicator";
import RightSidebar from "../../components/navigation/RightSidebar"; import RightSidebar from "../../components/navigation/RightSidebar";
import { PageHeader } from "../../components/ui/Header"; import { PageHeader } from "../../components/ui/Header";
import { useClient } from "../../controllers/client/ClientController";
import ChannelHeader from "./ChannelHeader"; import ChannelHeader from "./ChannelHeader";
import { MessageArea } from "./messaging/MessageArea"; import { MessageArea } from "./messaging/MessageArea";
import VoiceHeader from "./voice/VoiceHeader"; import VoiceHeader from "./voice/VoiceHeader";

View file

@ -1,19 +1,18 @@
import { At, Hash } from "@styled-icons/boxicons-regular"; import { At, Hash } from "@styled-icons/boxicons-regular";
import { Notepad, Group } from "@styled-icons/boxicons-solid"; import { Notepad, Group } from "@styled-icons/boxicons-solid";
import { observer } from "mobx-react-lite"; import { observer } from "mobx-react-lite";
import { Channel } from "revolt.js"; import { Channel, User } from "revolt.js";
import { User } from "revolt.js";
import styled from "styled-components/macro"; import styled from "styled-components/macro";
import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice";
import { useIntermediate } from "../../context/intermediate/Intermediate";
import { getChannelName } from "../../context/revoltjs/util"; import { getChannelName } from "../../context/revoltjs/util";
import { useStatusColour } from "../../components/common/user/UserIcon"; import { useStatusColour } from "../../components/common/user/UserIcon";
import UserStatus from "../../components/common/user/UserStatus"; import UserStatus from "../../components/common/user/UserStatus";
import Markdown from "../../components/markdown/Markdown"; import Markdown from "../../components/markdown/Markdown";
import { PageHeader } from "../../components/ui/Header"; import { PageHeader } from "../../components/ui/Header";
import { modalController } from "../../controllers/modals/ModalController";
import HeaderActions from "./actions/HeaderActions"; import HeaderActions from "./actions/HeaderActions";
export interface ChannelHeaderProps { export interface ChannelHeaderProps {
@ -65,8 +64,6 @@ const Info = styled.div`
`; `;
export default observer(({ channel }: ChannelHeaderProps) => { export default observer(({ channel }: ChannelHeaderProps) => {
const { openScreen } = useIntermediate();
const name = getChannelName(channel); const name = getChannelName(channel);
let icon, recipient: User | undefined; let icon, recipient: User | undefined;
switch (channel.channel_type) { switch (channel.channel_type) {
@ -114,8 +111,8 @@ export default observer(({ channel }: ChannelHeaderProps) => {
<span <span
className="desc" className="desc"
onClick={() => onClick={() =>
openScreen({ modalController.push({
id: "channel_info", type: "channel_info",
channel, channel,
}) })
}> }>

View file

@ -24,6 +24,7 @@ import { SIDEBAR_MEMBERS } from "../../../mobx/stores/Layout";
import { useIntermediate } from "../../../context/intermediate/Intermediate"; import { useIntermediate } from "../../../context/intermediate/Intermediate";
import UpdateIndicator from "../../../components/common/UpdateIndicator"; import UpdateIndicator from "../../../components/common/UpdateIndicator";
import { modalController } from "../../../controllers/modals/ModalController";
import { ChannelHeaderProps } from "../ChannelHeader"; import { ChannelHeaderProps } from "../ChannelHeader";
const Container = styled.div` const Container = styled.div`
@ -114,8 +115,8 @@ export default function HeaderActions({ channel }: ChannelHeaderProps) {
<> <>
<IconButton <IconButton
onClick={() => onClick={() =>
openScreen({ modalController.push({
id: "user_picker", type: "user_picker",
omit: channel.recipient_ids!, omit: channel.recipient_ids!,
callback: async (users) => { callback: async (users) => {
for (const user of users) { for (const user of users) {

View file

@ -24,12 +24,9 @@ import { getRenderer } from "../../../lib/renderer/Singleton";
import { ScrollState } from "../../../lib/renderer/types"; import { ScrollState } from "../../../lib/renderer/types";
import { IntermediateContext } from "../../../context/intermediate/Intermediate"; import { IntermediateContext } from "../../../context/intermediate/Intermediate";
import RequiresOnline from "../../../context/revoltjs/RequiresOnline";
import {
ClientStatus,
StatusContext,
} from "../../../context/revoltjs/RevoltClient";
import { useSession } from "../../../controllers/client/ClientController";
import RequiresOnline from "../../../controllers/client/jsx/RequiresOnline";
import ConversationStart from "./ConversationStart"; import ConversationStart from "./ConversationStart";
import MessageRenderer from "./MessageRenderer"; import MessageRenderer from "./MessageRenderer";
@ -65,7 +62,7 @@ export const MESSAGE_AREA_PADDING = 82;
export const MessageArea = observer(({ last_id, channel }: Props) => { export const MessageArea = observer(({ last_id, channel }: Props) => {
const history = useHistory(); const history = useHistory();
const status = useContext(StatusContext); const session = useSession()!;
const { focusTaken } = useContext(IntermediateContext); const { focusTaken } = useContext(IntermediateContext);
// ? Required data for message links. // ? Required data for message links.
@ -213,8 +210,8 @@ export const MessageArea = observer(({ last_id, channel }: Props) => {
// ? If we are waiting for network, try again. // ? If we are waiting for network, try again.
useEffect(() => { useEffect(() => {
switch (status) { switch (session.state) {
case ClientStatus.ONLINE: case "Online":
if (renderer.state === "WAITING_FOR_NETWORK") { if (renderer.state === "WAITING_FOR_NETWORK") {
renderer.init(); renderer.init();
} else { } else {
@ -222,13 +219,13 @@ export const MessageArea = observer(({ last_id, channel }: Props) => {
} }
break; break;
case ClientStatus.OFFLINE: case "Offline":
case ClientStatus.DISCONNECTED: case "Disconnected":
case ClientStatus.CONNECTING: case "Connecting":
renderer.markStale(); renderer.markStale();
break; break;
} }
}, [renderer, status]); }, [renderer, session.state]);
// ? When the container is scrolled. // ? When the container is scrolled.
// ? Also handle StayAtBottom // ? Also handle StayAtBottom

Some files were not shown because too many files have changed in this diff Show more