diff --git a/external/lang b/external/lang index 50838167..d4bc47b7 160000 --- a/external/lang +++ b/external/lang @@ -1 +1 @@ -Subproject commit 50838167d7d253de9d08715e6a6070c3ddc9fcc2 +Subproject commit d4bc47b729c7e69ce97216469692b39f4cd1640e diff --git a/package.json b/package.json index 7e7091a4..bfbf31e5 100644 --- a/package.json +++ b/package.json @@ -106,6 +106,7 @@ "eslint": "^7.28.0", "eslint-config-preact": "^1.1.4", "eslint-plugin-jsdoc": "^39.3.2", + "eslint-plugin-mobx": "^0.0.8", "eventemitter3": "^4.0.7", "history": "4", "json-stringify-deterministic": "^1.0.2", diff --git a/src/components/common/AutoComplete.tsx b/src/components/common/AutoComplete.tsx index 2b7d5196..72c0d885 100644 --- a/src/components/common/AutoComplete.tsx +++ b/src/components/common/AutoComplete.tsx @@ -3,9 +3,8 @@ import styled, { css } from "styled-components/macro"; import { StateUpdater, useState } from "preact/hooks"; -import { useClient } from "../../context/revoltjs/RevoltClient"; - import { emojiDictionary } from "../../assets/emojis"; +import { useClient } from "../../controllers/client/ClientController"; import ChannelIcon from "./ChannelIcon"; import Emoji from "./Emoji"; import UserIcon from "./user/UserIcon"; diff --git a/src/components/common/ChannelIcon.tsx b/src/components/common/ChannelIcon.tsx index 92fafcaa..d11d6633 100644 --- a/src/components/common/ChannelIcon.tsx +++ b/src/components/common/ChannelIcon.tsx @@ -2,12 +2,9 @@ import { Hash, VolumeFull } from "@styled-icons/boxicons-regular"; import { observer } from "mobx-react-lite"; import { Channel } from "revolt.js"; -import { useContext } from "preact/hooks"; - -import { AppContext } from "../../context/revoltjs/RevoltClient"; - import fallback from "./assets/group.png"; +import { useClient } from "../../controllers/client/ClientController"; import { ImageIconBase, IconBaseProps } from "./IconBase"; interface Props extends IconBaseProps { @@ -22,7 +19,7 @@ export default observer( keyof Props | "children" | "as" >, ) => { - const client = useContext(AppContext); + const client = useClient(); const { size, diff --git a/src/components/common/ServerHeader.tsx b/src/components/common/ServerHeader.tsx index 0c5b3f0e..69ff5a7b 100644 --- a/src/components/common/ServerHeader.tsx +++ b/src/components/common/ServerHeader.tsx @@ -9,6 +9,7 @@ import { Text } from "preact-i18n"; import { IconButton } from "@revoltchat/ui"; +import { modalController } from "../../controllers/modals/ModalController"; import Tooltip from "./Tooltip"; interface Props { @@ -60,6 +61,9 @@ const ServerBanner = styled.div>` overflow: hidden; text-overflow: ellipsis; flex-grow: 1; + + cursor: pointer; + color: var(--foreground); } } `; @@ -121,7 +125,13 @@ export default observer(({ server }: Props) => { ) : undefined} -
{server.name}
+ + modalController.push({ type: "server_info", server }) + }> + {server.name} + {server.havePermission("ManageServer") && ( diff --git a/src/components/common/ServerIcon.tsx b/src/components/common/ServerIcon.tsx index 282e9d73..a9fc1913 100644 --- a/src/components/common/ServerIcon.tsx +++ b/src/components/common/ServerIcon.tsx @@ -4,8 +4,7 @@ import styled from "styled-components/macro"; import { useContext } from "preact/hooks"; -import { AppContext } from "../../context/revoltjs/RevoltClient"; - +import { useClient } from "../../controllers/client/ClientController"; import { IconBaseProps, ImageIconBase } from "./IconBase"; interface Props extends IconBaseProps { @@ -34,7 +33,7 @@ export default observer( keyof Props | "children" | "as" >, ) => { - const client = useContext(AppContext); + const client = useClient(); const { target, attachment, size, animate, server_name, ...imgProps } = props; diff --git a/src/components/common/messaging/Message.tsx b/src/components/common/messaging/Message.tsx index a29238a0..8f684141 100644 --- a/src/components/common/messaging/Message.tsx +++ b/src/components/common/messaging/Message.tsx @@ -14,8 +14,8 @@ import { QueuedMessage } from "../../../mobx/stores/MessageQueue"; import { I18nError } from "../../../context/Locale"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { useClient } from "../../../context/revoltjs/RevoltClient"; +import { modalController } from "../../../controllers/modals/ModalController"; import Markdown from "../../markdown/Markdown"; import UserIcon from "../user/UserIcon"; import { Username } from "../user/UserShort"; @@ -52,7 +52,7 @@ const Message = observer( queued, hideReply, }: Props) => { - const client = useClient(); + const client = message.client; const user = message.author; const { openScreen } = useIntermediate(); @@ -70,7 +70,10 @@ const Message = observer( : undefined; const openProfile = () => - openScreen({ id: "profile", user_id: message.author_id }); + modalController.push({ + type: "user_profile", + user_id: message.author_id, + }); const handleUserClick = (e: MouseEvent) => { if (e.shiftKey && user?._id) { diff --git a/src/components/common/messaging/MessageBox.tsx b/src/components/common/messaging/MessageBox.tsx index 0c7acc9d..86c6cf58 100644 --- a/src/components/common/messaging/MessageBox.tsx +++ b/src/components/common/messaging/MessageBox.tsx @@ -24,15 +24,15 @@ import { import { useApplicationState } from "../../../mobx/State"; import { Reply } from "../../../mobx/stores/MessageQueue"; -import { modalController } from "../../../context/modals"; import { FileUploader, grabFiles, uploadFile, } from "../../../context/revoltjs/FileUploads"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; import { takeError } from "../../../context/revoltjs/util"; +import { useClient } from "../../../controllers/client/ClientController"; +import { modalController } from "../../../controllers/modals/ModalController"; import AutoComplete, { useAutoComplete } from "../AutoComplete"; import { PermissionTooltip } from "../Tooltip"; import FilePreview from "./bars/FilePreview"; @@ -148,7 +148,7 @@ export default observer(({ channel }: Props) => { }); const [typing, setTyping] = useState(false); const [replies, setReplies] = useState([]); - const client = useContext(AppContext); + const client = useClient(); const translate = useTranslation(); const renderer = getRenderer(channel); diff --git a/src/components/common/messaging/attachments/Attachment.tsx b/src/components/common/messaging/attachments/Attachment.tsx index 7e9400dc..de112e4d 100644 --- a/src/components/common/messaging/attachments/Attachment.tsx +++ b/src/components/common/messaging/attachments/Attachment.tsx @@ -3,10 +3,9 @@ import { API } from "revolt.js"; import styles from "./Attachment.module.scss"; import classNames from "classnames"; import { useTriggerEvents } from "preact-context-menu"; -import { useContext, useState } from "preact/hooks"; - -import { AppContext } from "../../../../context/revoltjs/RevoltClient"; +import { useState } from "preact/hooks"; +import { useClient } from "../../../../controllers/client/ClientController"; import AttachmentActions from "./AttachmentActions"; import { SizedGrid } from "./Grid"; import ImageFile from "./ImageFile"; @@ -21,7 +20,7 @@ interface Props { const MAX_ATTACHMENT_WIDTH = 480; export default function Attachment({ attachment, hasContent }: Props) { - const client = useContext(AppContext); + const client = useClient(); const { filename, metadata } = attachment; const [spoiler, setSpoiler] = useState(filename.startsWith("SPOILER_")); diff --git a/src/components/common/messaging/attachments/AttachmentActions.tsx b/src/components/common/messaging/attachments/AttachmentActions.tsx index f2d1781d..b6e5699d 100644 --- a/src/components/common/messaging/attachments/AttachmentActions.tsx +++ b/src/components/common/messaging/attachments/AttachmentActions.tsx @@ -15,14 +15,14 @@ import { IconButton } from "@revoltchat/ui"; import { determineFileSize } from "../../../../lib/fileSize"; -import { AppContext } from "../../../../context/revoltjs/RevoltClient"; +import { useClient } from "../../../../controllers/client/ClientController"; interface Props { attachment: API.File; } export default function AttachmentActions({ attachment }: Props) { - const client = useContext(AppContext); + const client = useClient(); const { filename, metadata, size } = attachment; const url = client.generateFileURL(attachment); diff --git a/src/components/common/messaging/attachments/ImageFile.tsx b/src/components/common/messaging/attachments/ImageFile.tsx index 252387b2..7c2364d2 100644 --- a/src/components/common/messaging/attachments/ImageFile.tsx +++ b/src/components/common/messaging/attachments/ImageFile.tsx @@ -2,10 +2,10 @@ import { API } from "revolt.js"; import styles from "./Attachment.module.scss"; import classNames from "classnames"; -import { useContext, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; -import { useIntermediate } from "../../../../context/intermediate/Intermediate"; -import { AppContext } from "../../../../context/revoltjs/RevoltClient"; +import { useClient } from "../../../../controllers/client/ClientController"; +import { modalController } from "../../../../controllers/modals/ModalController"; enum ImageLoadingState { Loading, @@ -19,8 +19,7 @@ type Props = JSX.HTMLAttributes & { export default function ImageFile({ attachment, ...props }: Props) { const [loading, setLoading] = useState(ImageLoadingState.Loading); - const client = useContext(AppContext); - const { openScreen } = useIntermediate(); + const client = useClient(); const url = client.generateFileURL(attachment)!; return ( @@ -32,7 +31,9 @@ export default function ImageFile({ attachment, ...props }: Props) { className={classNames(styles.image, { [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")} onLoad={() => setLoading(ImageLoadingState.Loaded)} onError={() => setLoading(ImageLoadingState.Error)} diff --git a/src/components/common/messaging/attachments/TextFile.tsx b/src/components/common/messaging/attachments/TextFile.tsx index b119bcae..ff417f89 100644 --- a/src/components/common/messaging/attachments/TextFile.tsx +++ b/src/components/common/messaging/attachments/TextFile.tsx @@ -3,15 +3,12 @@ import { API } from "revolt.js"; import styles from "./Attachment.module.scss"; 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 RequiresOnline from "../../../../context/revoltjs/RequiresOnline"; -import { - AppContext, - StatusContext, -} from "../../../../context/revoltjs/RevoltClient"; +import { useClient } from "../../../../controllers/client/ClientController"; +import RequiresOnline from "../../../../controllers/client/jsx/RequiresOnline"; interface Props { attachment: API.File; @@ -23,9 +20,8 @@ export default function TextFile({ attachment }: Props) { const [gated, setGated] = useState(attachment.size > 100_000); const [content, setContent] = useState(undefined); const [loading, setLoading] = useState(false); - const status = useContext(StatusContext); - const client = useContext(AppContext); + const client = useClient(); const url = client.generateFileURL(attachment)!; useEffect(() => { @@ -56,7 +52,7 @@ export default function TextFile({ attachment }: Props) { setLoading(false); }); } - }, [content, loading, gated, status, attachment._id, attachment.size, url]); + }, [content, loading, gated, attachment._id, attachment.size, url]); return (
{ - const client = useClient(); + const client = message.client; const { openScreen, writeClipboard } = useIntermediate(); const isAuthor = message.author_id === client.user!._id; diff --git a/src/components/common/messaging/embed/Embed.tsx b/src/components/common/messaging/embed/Embed.tsx index 10201989..822f30b2 100644 --- a/src/components/common/messaging/embed/Embed.tsx +++ b/src/components/common/messaging/embed/Embed.tsx @@ -5,8 +5,9 @@ import classNames from "classnames"; import { useContext } from "preact/hooks"; 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 Markdown from "../../../markdown/Markdown"; import Attachment from "../attachments/Attachment"; @@ -24,7 +25,7 @@ const MAX_PREVIEW_SIZE = 150; export default function Embed({ embed }: Props) { const client = useClient(); - const { openScreen, openLink } = useIntermediate(); + const { openLink } = useIntermediate(); const maxWidth = Math.min( useContext(MessageAreaWidthContext) - CONTAINER_PADDING, MAX_EMBED_WIDTH, @@ -191,7 +192,9 @@ export default function Embed({ embed }: Props) { type="text/html" frameBorder="0" loading="lazy" - onClick={() => openScreen({ id: "image_viewer", embed })} + onClick={() => + modalController.push({ type: "image_viewer", embed }) + } onMouseDown={(ev) => ev.button === 1 && openLink(embed.url)} /> ); diff --git a/src/components/common/messaging/embed/EmbedInvite.tsx b/src/components/common/messaging/embed/EmbedInvite.tsx index 974f8d71..63700c8c 100644 --- a/src/components/common/messaging/embed/EmbedInvite.tsx +++ b/src/components/common/messaging/embed/EmbedInvite.tsx @@ -1,5 +1,4 @@ import { Group } from "@styled-icons/boxicons-solid"; -import { reaction } from "mobx"; import { observer } from "mobx-react-lite"; import { useHistory } from "react-router-dom"; import { Message, API } from "revolt.js"; @@ -12,14 +11,13 @@ import { Button, Category, Preloader } from "@revoltchat/ui"; import { isTouchscreenDevice } from "../../../../lib/isTouchscreenDevice"; import { I18nError } from "../../../../context/Locale"; -import { - AppContext, - ClientStatus, - StatusContext, -} from "../../../../context/revoltjs/RevoltClient"; import { takeError } from "../../../../context/revoltjs/util"; import ServerIcon from "../../../../components/common/ServerIcon"; +import { + useClient, + useSession, +} from "../../../../controllers/client/ClientController"; const EmbedInviteBase = styled.div` width: 400px; @@ -78,8 +76,8 @@ type Props = { export function EmbedInvite({ code }: Props) { const history = useHistory(); - const client = useContext(AppContext); - const status = useContext(StatusContext); + const session = useSession()!; + const client = session.client!; const [processing, setProcessing] = useState(false); const [error, setError] = useState(undefined); const [joinError, setJoinError] = useState(undefined); @@ -90,7 +88,7 @@ export function EmbedInvite({ code }: Props) { useEffect(() => { if ( typeof invite === "undefined" && - (status === ClientStatus.ONLINE || status === ClientStatus.READY) + (session.state === "Online" || session.state === "Ready") ) { client .fetchInvite(code) @@ -99,7 +97,7 @@ export function EmbedInvite({ code }: Props) { ) .catch((err) => setError(takeError(err))); } - }, [client, code, invite, status]); + }, [client, code, invite, session.state]); if (typeof invite === "undefined") { return error ? ( diff --git a/src/components/common/messaging/embed/EmbedMedia.tsx b/src/components/common/messaging/embed/EmbedMedia.tsx index f602318b..201b3d84 100644 --- a/src/components/common/messaging/embed/EmbedMedia.tsx +++ b/src/components/common/messaging/embed/EmbedMedia.tsx @@ -3,8 +3,8 @@ import { API } from "revolt.js"; import styles from "./Embed.module.scss"; -import { useIntermediate } from "../../../../context/intermediate/Intermediate"; -import { useClient } from "../../../../context/revoltjs/RevoltClient"; +import { useClient } from "../../../../controllers/client/ClientController"; +import { modalController } from "../../../../controllers/modals/ModalController"; interface Props { embed: API.Embed; @@ -14,7 +14,6 @@ interface Props { export default function EmbedMedia({ embed, width, height }: Props) { if (embed.type !== "Website") return null; - const { openScreen } = useIntermediate(); const client = useClient(); switch (embed.special?.type) { @@ -117,8 +116,8 @@ export default function EmbedMedia({ embed, width, height }: Props) { loading="lazy" style={{ width, height }} onClick={() => - openScreen({ - id: "image_viewer", + modalController.push({ + type: "image_viewer", embed: embed.image!, }) } diff --git a/src/components/common/user/UserIcon.tsx b/src/components/common/user/UserIcon.tsx index 3c19ecdb..3c96b9a6 100644 --- a/src/components/common/user/UserIcon.tsx +++ b/src/components/common/user/UserIcon.tsx @@ -6,10 +6,9 @@ import styled, { css } from "styled-components/macro"; import { useApplicationState } from "../../../mobx/State"; -import { useClient } from "../../../context/revoltjs/RevoltClient"; - import fallback from "../assets/user.png"; +import { useClient } from "../../../controllers/client/ClientController"; import IconBase, { IconBaseProps } from "../IconBase"; type VoiceStatus = "muted" | "deaf"; @@ -56,8 +55,7 @@ export default observer( keyof Props | "children" | "as" >, ) => { - // ! TODO: this is temporary code - const client = useClient() ?? useApplicationState().client!; + const client = useClient(); const { target, diff --git a/src/components/common/user/UserShort.tsx b/src/components/common/user/UserShort.tsx index 881d763f..bbe3670a 100644 --- a/src/components/common/user/UserShort.tsx +++ b/src/components/common/user/UserShort.tsx @@ -8,9 +8,8 @@ import { Text } from "preact-i18n"; import { internalEmit } from "../../../lib/eventEmitter"; -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 UserIcon from "./UserIcon"; const BotBadge = styled.div` @@ -125,9 +124,9 @@ export default function UserShort({ masquerade?: API.Masquerade; showServerIdentity?: boolean; }) { - const { openScreen } = useIntermediate(); const openProfile = () => - user && openScreen({ id: "profile", user_id: user._id }); + user && + modalController.push({ type: "user_profile", user_id: user._id }); const handleUserClick = (e: MouseEvent) => { if (e.shiftKey && user?._id) { diff --git a/src/components/markdown/Markdown.module.scss b/src/components/markdown/Markdown.module.scss index c107fcfa..cff232a1 100644 --- a/src/components/markdown/Markdown.module.scss +++ b/src/components/markdown/Markdown.module.scss @@ -1,4 +1,6 @@ .markdown { + user-select: text; + :global(.emoji) { object-fit: contain; diff --git a/src/components/markdown/Renderer.tsx b/src/components/markdown/Renderer.tsx index 47787a8e..bcce42b8 100644 --- a/src/components/markdown/Renderer.tsx +++ b/src/components/markdown/Renderer.tsx @@ -15,9 +15,9 @@ import { determineLink } from "../../lib/links"; import { dayjs } from "../../context/Locale"; import { useIntermediate } from "../../context/intermediate/Intermediate"; -import { AppContext } from "../../context/revoltjs/RevoltClient"; import { emojiDictionary } from "../../assets/emojis"; +import { useClient } from "../../controllers/client/ClientController"; import { generateEmoji } from "../common/Emoji"; import { MarkdownProps } from "./Markdown"; import Prism from "./prism"; @@ -118,7 +118,7 @@ const RE_CHANNELS = /<#([A-z0-9]{26})>/g; const RE_TIME = //g; export default function Renderer({ content, disallowBigEmoji }: MarkdownProps) { - const client = useContext(AppContext); + const client = useClient(); const { openLink } = useIntermediate(); if (typeof content === "undefined") return null; diff --git a/src/components/navigation/BottomNavigation.tsx b/src/components/navigation/BottomNavigation.tsx index 2daf28a7..1a1b44ad 100644 --- a/src/components/navigation/BottomNavigation.tsx +++ b/src/components/navigation/BottomNavigation.tsx @@ -9,8 +9,7 @@ import ConditionalLink from "../../lib/ConditionalLink"; import { useApplicationState } from "../../mobx/State"; -import { useClient } from "../../context/revoltjs/RevoltClient"; - +import { useClient } from "../../controllers/client/ClientController"; import UserIcon from "../common/user/UserIcon"; const Base = styled.div` diff --git a/src/components/navigation/items/ConnectionStatus.tsx b/src/components/navigation/items/ConnectionStatus.tsx index 899b3d8e..46a9f6cc 100644 --- a/src/components/navigation/items/ConnectionStatus.tsx +++ b/src/components/navigation/items/ConnectionStatus.tsx @@ -1,45 +1,47 @@ +import { observer } from "mobx-react-lite"; + import { Text } from "preact-i18n"; -import { useContext } from "preact/hooks"; -import { Banner } from "@revoltchat/ui"; +import { Banner, Button, Column } from "@revoltchat/ui"; -import { - ClientStatus, - StatusContext, - useClient, -} from "../../../context/revoltjs/RevoltClient"; +import { useSession } from "../../../controllers/client/ClientController"; -export default function ConnectionStatus() { - const status = useContext(StatusContext); - const client = useClient(); +function ConnectionStatus() { + const session = useSession()!; - if (status === ClientStatus.OFFLINE) { + if (session.state === "Offline") { return ( ); - } else if (status === ClientStatus.DISCONNECTED) { + } else if (session.state === "Disconnected") { return ( -
- client.websocket.connect()}> - - + + + +
); - } else if (status === ClientStatus.CONNECTING) { - return ( - - - - ); - } else if (status === ClientStatus.RECONNECTING) { + } else if (session.state === "Connecting") { return ( ); } + return null; } + +export default observer(ConnectionStatus); diff --git a/src/components/navigation/left/HomeSidebar.tsx b/src/components/navigation/left/HomeSidebar.tsx index 220f542c..9dcfb8e9 100644 --- a/src/components/navigation/left/HomeSidebar.tsx +++ b/src/components/navigation/left/HomeSidebar.tsx @@ -21,10 +21,10 @@ import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { useApplicationState } from "../../../mobx/State"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; import placeholderSVG from "../items/placeholder.svg"; +import { useClient } from "../../../controllers/client/ClientController"; import { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; import ButtonItem, { ChannelButton } from "../items/ButtonItem"; import ConnectionStatus from "../items/ConnectionStatus"; @@ -46,7 +46,7 @@ const Navbar = styled.div` export default observer(() => { const { pathname } = useLocation(); - const client = useContext(AppContext); + const client = useClient(); const state = useApplicationState(); const { channel: channel_id } = useParams<{ channel: string }>(); const { openScreen } = useIntermediate(); diff --git a/src/components/navigation/left/ServerListSidebar.tsx b/src/components/navigation/left/ServerListSidebar.tsx index 23d2b869..ed703a31 100644 --- a/src/components/navigation/left/ServerListSidebar.tsx +++ b/src/components/navigation/left/ServerListSidebar.tsx @@ -8,7 +8,8 @@ import { ServerList } from "@revoltchat/ui"; import { useApplicationState } from "../../../mobx/State"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { useClient } from "../../../context/revoltjs/RevoltClient"; + +import { useClient } from "../../../controllers/client/ClientController"; /** * Server list sidebar shim component diff --git a/src/components/navigation/left/ServerSidebar.tsx b/src/components/navigation/left/ServerSidebar.tsx index 4a771701..b2b602ff 100644 --- a/src/components/navigation/left/ServerSidebar.tsx +++ b/src/components/navigation/left/ServerSidebar.tsx @@ -14,8 +14,7 @@ import { isTouchscreenDevice } from "../../../lib/isTouchscreenDevice"; import { useApplicationState } from "../../../mobx/State"; -import { useClient } from "../../../context/revoltjs/RevoltClient"; - +import { useClient } from "../../../controllers/client/ClientController"; import CollapsibleSection from "../../common/CollapsibleSection"; import ServerHeader from "../../common/ServerHeader"; import { ChannelButton } from "../items/ButtonItem"; diff --git a/src/components/navigation/right/MemberList.tsx b/src/components/navigation/right/MemberList.tsx index 8b9495ba..3913c20b 100644 --- a/src/components/navigation/right/MemberList.tsx +++ b/src/components/navigation/right/MemberList.tsx @@ -8,11 +8,7 @@ import { memo } from "preact/compat"; import { internalEmit } from "../../../lib/eventEmitter"; -import { - Screen, - useIntermediate, -} from "../../../context/intermediate/Intermediate"; - +import { modalController } from "../../../controllers/modals/ModalController"; import { UserButton } from "../items/ButtonItem"; export type MemberListGroup = { @@ -55,15 +51,7 @@ const NoOomfie = styled.div` `; const ItemContent = memo( - ({ - item, - context, - openScreen, - }: { - item: User; - context: Channel; - openScreen: (screen: Screen) => void; - }) => ( + ({ item, context }: { item: User; context: Channel }) => ( `, "mention", ); - } else - [ - openScreen({ - id: "profile", - user_id: item._id, - }), - ]; + } else { + modalController.push({ + type: "user_profile", + user_id: item._id, + }); + } }} /> ), @@ -96,8 +83,6 @@ export default function MemberList({ entries: MemberListGroup[]; context: Channel; }) { - const { openScreen } = useIntermediate(); - return ( x.users.length)} @@ -137,7 +122,8 @@ export default function MemberList({ server, see issue{" "} + target="_blank" + rel="noreferrer"> #128 {" "} for when this will be resolved. @@ -158,11 +144,7 @@ export default function MemberList({ return (
- +
); }} diff --git a/src/components/navigation/right/MemberSidebar.tsx b/src/components/navigation/right/MemberSidebar.tsx index 37333b45..88e71b95 100644 --- a/src/components/navigation/right/MemberSidebar.tsx +++ b/src/components/navigation/right/MemberSidebar.tsx @@ -4,14 +4,12 @@ import { observer } from "mobx-react-lite"; import { useParams } from "react-router-dom"; import { Channel, Server, User, API } from "revolt.js"; -import { useContext, useEffect, useState } from "preact/hooks"; +import { useEffect, useLayoutEffect, useState } from "preact/hooks"; import { - ClientStatus, - StatusContext, + useSession, useClient, -} from "../../../context/revoltjs/RevoltClient"; - +} from "../../../controllers/client/ClientController"; import { GenericSidebarBase } from "../SidebarBase"; import MemberList, { MemberListGroup } from "./MemberList"; @@ -205,18 +203,18 @@ function shouldSkipOffline(id: string) { export const ServerMemberSidebar = observer( ({ channel }: { channel: Channel }) => { - const client = useClient(); - const status = useContext(StatusContext); + const session = useSession()!; + const client = session.client!; useEffect(() => { 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); channel .server!.syncMembers(shouldSkipOffline(server_id)) .catch(() => FETCHED.delete(server_id)); } - }, [status, channel]); + }, [session.state, channel]); const entries = useEntries( channel, diff --git a/src/components/navigation/right/Search.tsx b/src/components/navigation/right/Search.tsx index 13fb3d88..f12cb7d2 100644 --- a/src/components/navigation/right/Search.tsx +++ b/src/components/navigation/right/Search.tsx @@ -7,8 +7,7 @@ import { useEffect, useState } from "preact/hooks"; 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 { GenericSidebarBase, GenericSidebarList } from "../SidebarBase"; diff --git a/src/components/settings/account/AccountManagement.tsx b/src/components/settings/account/AccountManagement.tsx index 7af4b367..6c9c5310 100644 --- a/src/components/settings/account/AccountManagement.tsx +++ b/src/components/settings/account/AccountManagement.tsx @@ -2,18 +2,16 @@ import { Block } from "@styled-icons/boxicons-regular"; import { Trash } from "@styled-icons/boxicons-solid"; import { Text } from "preact-i18n"; -import { useContext } from "preact/hooks"; import { CategoryButton } from "@revoltchat/ui"; -import { modalController } from "../../../context/modals"; import { - LogOutContext, + clientController, useClient, -} from "../../../context/revoltjs/RevoltClient"; +} from "../../../controllers/client/ClientController"; +import { modalController } from "../../../controllers/modals/ModalController"; export default function AccountManagement() { - const logOut = useContext(LogOutContext); const client = useClient(); const callback = (route: "disable" | "delete") => () => @@ -26,7 +24,7 @@ export default function AccountManagement() { "X-MFA-Ticket": ticket.token, }, }) - .then(() => logOut(true)), + .then(clientController.logoutCurrent), ); return ( diff --git a/src/components/settings/account/EditAccount.tsx b/src/components/settings/account/EditAccount.tsx index d2ccdeac..2bc24994 100644 --- a/src/components/settings/account/EditAccount.tsx +++ b/src/components/settings/account/EditAccount.tsx @@ -3,7 +3,7 @@ import { Envelope, Key, Pencil } from "@styled-icons/boxicons-solid"; import { observer } from "mobx-react-lite"; import { Text } from "preact-i18n"; -import { useContext, useEffect, useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { AccountDetail, @@ -12,26 +12,22 @@ import { HiddenValue, } from "@revoltchat/ui"; -import { modalController } from "../../../context/modals"; -import { - ClientStatus, - StatusContext, - useClient, -} from "../../../context/revoltjs/RevoltClient"; +import { useSession } from "../../../controllers/client/ClientController"; +import { modalController } from "../../../controllers/modals/ModalController"; export default observer(() => { - const client = useClient(); - const status = useContext(StatusContext); + const session = useSession()!; + const client = session.client!; const [email, setEmail] = useState("..."); useEffect(() => { - if (email === "..." && status === ClientStatus.ONLINE) { + if (email === "..." && session.state === "Online") { client.api .get("/auth/account/") .then((account) => setEmail(account.email)); } - }, [client, email, status]); + }, [client, email, session.state]); return ( <> diff --git a/src/components/settings/account/MultiFactorAuthentication.tsx b/src/components/settings/account/MultiFactorAuthentication.tsx index 1b7bfbf8..5d794bf8 100644 --- a/src/components/settings/account/MultiFactorAuthentication.tsx +++ b/src/components/settings/account/MultiFactorAuthentication.tsx @@ -3,18 +3,15 @@ import { Lock } from "@styled-icons/boxicons-solid"; import { API } from "revolt.js"; 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 { modalController } from "../../../context/modals"; -import { - ClientStatus, - StatusContext, - useClient, -} from "../../../context/revoltjs/RevoltClient"; import { takeError } from "../../../context/revoltjs/util"; +import { useSession } from "../../../controllers/client/ClientController"; +import { modalController } from "../../../controllers/modals/ModalController"; + /** * Temporary helper function for Axios config * @param token Token @@ -33,8 +30,8 @@ export function toConfig(token: string) { */ export default function MultiFactorAuthentication() { // Pull in prerequisites - const client = useClient(); - const status = useContext(StatusContext); + const session = useSession()!; + const client = session.client!; // Keep track of MFA state const [mfa, setMFA] = useState(); @@ -42,13 +39,13 @@ export default function MultiFactorAuthentication() { // Fetch the current MFA status on account useEffect(() => { - if (!mfa && status === ClientStatus.ONLINE) { - client.api + if (!mfa && session.state === "Online") { + client!.api .get("/auth/mfa/") .then(setMFA) .catch((err) => setError(takeError(err))); } - }, [client, mfa, status]); + }, [mfa, client, session.state]); // Action called when recovery code button is pressed const recoveryAction = useCallback(async () => { diff --git a/src/context/DO_NOT_TOUCH.md b/src/context/DO_NOT_TOUCH.md new file mode 100644 index 00000000..db75657b --- /dev/null +++ b/src/context/DO_NOT_TOUCH.md @@ -0,0 +1,2 @@ +hello do not touch `intermediate` or `revoltjs` folders +they are being rewritten diff --git a/src/context/history.ts b/src/context/history.ts index 5e816997..0e4d1268 100644 --- a/src/context/history.ts +++ b/src/context/history.ts @@ -3,3 +3,14 @@ import { createBrowserHistory } from "history"; export const history = createBrowserHistory({ 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], +}; diff --git a/src/context/index.tsx b/src/context/index.tsx index 663fdfaa..51b0564f 100644 --- a/src/context/index.tsx +++ b/src/context/index.tsx @@ -6,21 +6,20 @@ import { useEffect, useState } from "preact/hooks"; 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 Theme from "./Theme"; import { history } from "./history"; import Intermediate from "./intermediate/Intermediate"; -import ModalRenderer from "./modals/ModalRenderer"; -import Client from "./revoltjs/RevoltClient"; -import SyncManager from "./revoltjs/SyncManager"; const uiContext = { Link, Text: Text as any, Trigger: ContextMenuTrigger, - emitAction: () => {}, + emitAction: () => void {}, }; /** @@ -31,7 +30,7 @@ export default function Context({ children }: { children: Children }) { const [ready, setReady] = useState(false); useEffect(() => { - hydrateState().then(() => setReady(true)); + state.hydrate().then(() => setReady(true)); }, []); if (!ready) return ; @@ -41,10 +40,8 @@ export default function Context({ children }: { children: Children }) { - - {children} - - + {children} + diff --git a/src/context/intermediate/Intermediate.tsx b/src/context/intermediate/Intermediate.tsx index 3b20ff0c..e67ca0ae 100644 --- a/src/context/intermediate/Intermediate.tsx +++ b/src/context/intermediate/Intermediate.tsx @@ -18,7 +18,7 @@ import { determineLink } from "../../lib/links"; import { useApplicationState } from "../../mobx/State"; -import { modalController } from "../modals"; +import { modalController } from "../../controllers/modals/ModalController"; import Modals from "./Modals"; export type Screen = @@ -159,7 +159,7 @@ export default function Intermediate(props: Props) { useEffect(() => { 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 subs = [ diff --git a/src/context/intermediate/Modals.tsx b/src/context/intermediate/Modals.tsx index 4f59c974..48e18245 100644 --- a/src/context/intermediate/Modals.tsx +++ b/src/context/intermediate/Modals.tsx @@ -1,7 +1,6 @@ //import { isModalClosing } from "../../components/ui/Modal"; import { Screen } from "./Intermediate"; import { InputModal } from "./modals/Input"; -import { OnboardingModal } from "./modals/Onboarding"; import { PromptModal } from "./modals/Prompt"; export interface Props { @@ -20,8 +19,6 @@ export default function Modals({ screen, openScreen }: Props) { return ; case "_input": return ; - case "onboarding": - return ; } return null; diff --git a/src/context/intermediate/Popovers.tsx b/src/context/intermediate/Popovers.tsx index f5d8f347..08386727 100644 --- a/src/context/intermediate/Popovers.tsx +++ b/src/context/intermediate/Popovers.tsx @@ -3,11 +3,6 @@ import { useContext } from "preact/hooks"; import { IntermediateContext, useIntermediate } from "./Intermediate"; import { SpecialInputModal } from "./modals/Input"; 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() { const { screen } = useContext(IntermediateContext); @@ -19,20 +14,6 @@ export default function Popovers() { //: internalEmit("Modal", "close"); switch (screen.id) { - case "profile": - // @ts-expect-error someone figure this out :) - return ; - case "user_picker": - // @ts-expect-error someone figure this out :) - return ; - case "image_viewer": - return ; - case "channel_info": - // @ts-expect-error someone figure this out :) - return ; - case "create_bot": - // @ts-expect-error someone figure this out :) - return ; case "special_prompt": // @ts-expect-error someone figure this out :) return ; diff --git a/src/context/intermediate/modals/Input.tsx b/src/context/intermediate/modals/Input.tsx index e34cad30..d4e20b69 100644 --- a/src/context/intermediate/modals/Input.tsx +++ b/src/context/intermediate/modals/Input.tsx @@ -6,8 +6,8 @@ import { useContext, useState } from "preact/hooks"; import { Category, InputBox, Modal } from "@revoltchat/ui"; +import { useClient } from "../../../controllers/client/ClientController"; import { I18nError } from "../../Locale"; -import { AppContext } from "../../revoltjs/RevoltClient"; import { takeError } from "../../revoltjs/util"; interface Props { @@ -89,7 +89,7 @@ type SpecialProps = { onClose: () => void } & ( export function SpecialInputModal(props: SpecialProps) { const history = useHistory(); - const client = useContext(AppContext); + const client = useClient(); const { onClose } = props; switch (props.type) { diff --git a/src/context/intermediate/modals/Prompt.tsx b/src/context/intermediate/modals/Prompt.tsx index 31fc2d59..4f9986c1 100644 --- a/src/context/intermediate/modals/Prompt.tsx +++ b/src/context/intermediate/modals/Prompt.tsx @@ -5,7 +5,7 @@ import { ulid } from "ulid"; import styles from "./Prompt.module.scss"; 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 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 UserIcon from "../../../components/common/user/UserIcon"; +import { useClient } from "../../../controllers/client/ClientController"; import { I18nError } from "../../Locale"; -import { AppContext } from "../../revoltjs/RevoltClient"; import { takeError } from "../../revoltjs/util"; import { useIntermediate } from "../Intermediate"; @@ -81,7 +81,7 @@ type SpecialProps = { onClose: () => void } & ( ); export const SpecialPromptModal = observer((props: SpecialProps) => { - const client = useContext(AppContext); + const client = useClient(); const history = useHistory(); const [processing, setProcessing] = useState(false); const [error, setError] = useState(undefined); diff --git a/src/context/intermediate/popovers/ChannelInfo.module.scss b/src/context/intermediate/popovers/ChannelInfo.module.scss deleted file mode 100644 index ff37d169..00000000 --- a/src/context/intermediate/popovers/ChannelInfo.module.scss +++ /dev/null @@ -1,16 +0,0 @@ -.info { - .header { - display: flex; - align-items: center; - flex-direction: row; - - h1 { - margin: 0; - flex-grow: 1; - } - - div { - cursor: pointer; - } - } -} diff --git a/src/context/intermediate/popovers/ChannelInfo.tsx b/src/context/intermediate/popovers/ChannelInfo.tsx deleted file mode 100644 index d2a37f42..00000000 --- a/src/context/intermediate/popovers/ChannelInfo.tsx +++ /dev/null @@ -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 ( - -
-
-

{getChannelName(channel, true)}

-
- -
-
-

- -

-
-
- ); -}); diff --git a/src/context/intermediate/popovers/ImageViewer.module.scss b/src/context/intermediate/popovers/ImageViewer.module.scss deleted file mode 100644 index a450d0a7..00000000 --- a/src/context/intermediate/popovers/ImageViewer.module.scss +++ /dev/null @@ -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; - } -} diff --git a/src/context/revoltjs/CheckAuth.tsx b/src/context/revoltjs/CheckAuth.tsx index be5c8eb8..eec8e8e5 100644 --- a/src/context/revoltjs/CheckAuth.tsx +++ b/src/context/revoltjs/CheckAuth.tsx @@ -1,8 +1,9 @@ +import { observer } from "mobx-react-lite"; 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 { auth?: boolean; @@ -11,18 +12,30 @@ interface Props { children: Children; } -export const CheckAuth = (props: Props) => { - const auth = useApplicationState().auth; - const client = useClient(); - const ready = auth.isLoggedIn() && !!client?.user; +/** + * Check that we are logged in or out and redirect accordingly. + * Also prevent render until the client is ready to display. + */ +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; return ; - } else if (!props.auth && ready) { + } else if (!props.auth && loggedIn) { if (props.blockRender) return null; return ; } + // Block render if client is getting ready to work. + if ( + props.auth && + clientController.isLoggedIn() && + !clientController.isReady() + ) { + return ; + } + return <>{props.children}; -}; +}); diff --git a/src/context/revoltjs/FileUploads.tsx b/src/context/revoltjs/FileUploads.tsx index 63c24928..4ab39cbc 100644 --- a/src/context/revoltjs/FileUploads.tsx +++ b/src/context/revoltjs/FileUploads.tsx @@ -5,17 +5,15 @@ import Axios, { AxiosRequestConfig } from "axios"; import styles from "./FileUploads.module.scss"; import classNames from "classnames"; import { Text } from "preact-i18n"; -import { useContext, useEffect, useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { IconButton, Preloader } from "@revoltchat/ui"; 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 { modalController } from "../modals"; -import { AppContext } from "./RevoltClient"; import { takeError } from "./util"; type BehaviourType = @@ -115,7 +113,7 @@ export function grabFiles( export function FileUploader(props: Props) { const { fileType, maxFileSize, remove } = props; const { openScreen } = useIntermediate(); - const client = useApplicationState().client!; + const client = useClient(); const [uploading, setUploading] = useState(false); diff --git a/src/context/revoltjs/Notifications.tsx b/src/context/revoltjs/Notifications.tsx deleted file mode 100644 index 48178c63..00000000 --- a/src/context/revoltjs/Notifications.tsx +++ /dev/null @@ -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 ( - - - - - - - - - - - - ); -} diff --git a/src/context/revoltjs/RevoltClient.tsx b/src/context/revoltjs/RevoltClient.tsx deleted file mode 100644 index 733b1c9e..00000000 --- a/src/context/revoltjs/RevoltClient.tsx +++ /dev/null @@ -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; -} - -export const AppContext = createContext(null!); -export const StatusContext = createContext(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(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 ; - } - - return ( - - - - {children} - - - - ); -}); - -export const useClient = () => useContext(AppContext); diff --git a/src/context/revoltjs/StateMonitor.tsx b/src/context/revoltjs/StateMonitor.tsx deleted file mode 100644 index 34400f0b..00000000 --- a/src/context/revoltjs/StateMonitor.tsx +++ /dev/null @@ -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; -} diff --git a/src/context/revoltjs/SyncManager.tsx b/src/context/revoltjs/SyncManager.tsx deleted file mode 100644 index 6ed39af4..00000000 --- a/src/context/revoltjs/SyncManager.tsx +++ /dev/null @@ -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 <>; -} diff --git a/src/context/revoltjs/events.ts b/src/context/revoltjs/events.ts index a4121bc9..8232036f 100644 --- a/src/context/revoltjs/events.ts +++ b/src/context/revoltjs/events.ts @@ -1,15 +1,6 @@ -import { Client, Server } from "revolt.js"; +export const _ = ""; -import { StateUpdater } from "preact/hooks"; - -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( +/*export function registerEvents( state: State, setStatus: StateUpdater, client: Client, @@ -86,4 +77,4 @@ export function registerEvents( window.removeEventListener("online", online); window.removeEventListener("offline", offline); }; -} +}*/ diff --git a/src/controllers/client/ClientController.tsx b/src/controllers/client/ClientController.tsx new file mode 100644 index 00000000..fc51c4f6 --- /dev/null +++ b/src/controllers/client/ClientController.tsx @@ -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; + + /** + * User ID of active session + */ + private current: Nullable; + + 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; +} diff --git a/src/controllers/client/Session.tsx b/src/controllers/client/Session.tsx new file mode 100644 index 00000000..442b65d0 --- /dev/null +++ b/src/controllers/client/Session.tsx @@ -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; + } +} diff --git a/src/controllers/client/jsx/Binder.tsx b/src/controllers/client/jsx/Binder.tsx new file mode 100644 index 00000000..cbec7403 --- /dev/null +++ b/src/controllers/client/jsx/Binder.tsx @@ -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); diff --git a/src/context/revoltjs/RequiresOnline.tsx b/src/controllers/client/jsx/RequiresOnline.tsx similarity index 73% rename from src/context/revoltjs/RequiresOnline.tsx rename to src/controllers/client/jsx/RequiresOnline.tsx index 4835bd42..d8ed18ec 100644 --- a/src/context/revoltjs/RequiresOnline.tsx +++ b/src/controllers/client/jsx/RequiresOnline.tsx @@ -2,11 +2,10 @@ import { WifiOff } from "@styled-icons/boxicons-regular"; import styled from "styled-components/macro"; import { Text } from "preact-i18n"; -import { useContext } from "preact/hooks"; import { Preloader } from "@revoltchat/ui"; -import { ClientStatus, StatusContext } from "./RevoltClient"; +import { useSession } from "../ClientController"; interface Props { children: Children; @@ -29,10 +28,12 @@ const Base = styled.div` `; export default function RequiresOnline(props: Props) { - const status = useContext(StatusContext); + const session = useSession(); - if (status === ClientStatus.CONNECTING) return ; - if (status !== ClientStatus.ONLINE && status !== ClientStatus.READY) + if (!session || session.state === "Connecting") + return ; + + if (!(session.state === "Online" || session.state === "Ready")) return ( diff --git a/src/context/modals/index.tsx b/src/controllers/modals/ModalController.tsx similarity index 83% rename from src/context/modals/index.tsx rename to src/controllers/modals/ModalController.tsx index c2c062d0..a8169d0e 100644 --- a/src/context/modals/index.tsx +++ b/src/controllers/modals/ModalController.tsx @@ -9,15 +9,18 @@ import type { Client, API } from "revolt.js"; import { ulid } from "ulid"; 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 ChannelInfo from "./components/ChannelInfo"; import Clipboard from "./components/Clipboard"; import Error from "./components/Error"; +import ImageViewer from "./components/ImageViewer"; import LinkWarning from "./components/LinkWarning"; import MFAEnableTOTP from "./components/MFAEnableTOTP"; import MFAFlow from "./components/MFAFlow"; @@ -26,9 +29,14 @@ import ModifyAccount from "./components/ModifyAccount"; import OutOfDate from "./components/OutOfDate"; import PendingFriendRequests from "./components/PendingFriendRequests"; import ServerIdentity from "./components/ServerIdentity"; +import ServerInfo from "./components/ServerInfo"; import ShowToken from "./components/ShowToken"; import SignOutSessions from "./components/SignOutSessions"; 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"; type Components = Record>; @@ -51,6 +59,11 @@ class ModalController { rendered: computed, isVisible: computed, }); + + this.close = this.close.bind(this); + + // Inject globally + injectController("modal", this); } /** @@ -77,6 +90,13 @@ class ModalController { ); } + /** + * Close the top modal + */ + close() { + this.pop("close"); + } + /** * Remove the keyed modal from the stack */ @@ -174,7 +194,7 @@ class ModalControllerExtended extends ModalController { switch (link.type) { case "profile": { - __thisIsAHack({ id: "profile", user_id: link.id }); + this.push({ type: "user_profile", user_id: link.id }); break; } case "navigate": { @@ -203,17 +223,24 @@ class ModalControllerExtended extends ModalController { export const modalController = new ModalControllerExtended({ changelog: Changelog, + channel_info: ChannelInfo, clipboard: Clipboard, + create_bot: CreateBotModal, error: Error, + image_viewer: ImageViewer, link_warning: LinkWarning, mfa_flow: MFAFlow, mfa_recovery: MFARecovery, mfa_enable_totp: MFAEnableTOTP, modify_account: ModifyAccount, + onboarding: OnboardingModal, out_of_date: OutOfDate, pending_friend_requests: PendingFriendRequests, server_identity: ServerIdentity, + server_info: ServerInfo, show_token: ShowToken, signed_out: SignedOut, sign_out_sessions: SignOutSessions, + user_picker: UserPicker, + user_profile: UserProfile, }); diff --git a/src/context/modals/ModalRenderer.tsx b/src/controllers/modals/ModalRenderer.tsx similarity index 91% rename from src/context/modals/ModalRenderer.tsx rename to src/controllers/modals/ModalRenderer.tsx index 0092d93b..712a4dc4 100644 --- a/src/context/modals/ModalRenderer.tsx +++ b/src/controllers/modals/ModalRenderer.tsx @@ -2,7 +2,7 @@ import { observer } from "mobx-react-lite"; import { useEffect } from "preact/hooks"; -import { modalController } from "."; +import { modalController } from "./ModalController"; export default observer(() => { useEffect(() => { diff --git a/src/context/modals/components/Changelog.tsx b/src/controllers/modals/components/Changelog.tsx similarity index 100% rename from src/context/modals/components/Changelog.tsx rename to src/controllers/modals/components/Changelog.tsx diff --git a/src/controllers/modals/components/ChannelInfo.tsx b/src/controllers/modals/components/ChannelInfo.tsx new file mode 100644 index 00000000..0e2149b0 --- /dev/null +++ b/src/controllers/modals/components/ChannelInfo.tsx @@ -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 ( + + +

{`#${channel.name}`}

+
+ + + + + }> + +
+ ); +} diff --git a/src/context/modals/components/Clipboard.tsx b/src/controllers/modals/components/Clipboard.tsx similarity index 100% rename from src/context/modals/components/Clipboard.tsx rename to src/controllers/modals/components/Clipboard.tsx diff --git a/src/context/modals/components/Error.tsx b/src/controllers/modals/components/Error.tsx similarity index 100% rename from src/context/modals/components/Error.tsx rename to src/controllers/modals/components/Error.tsx diff --git a/src/context/intermediate/popovers/ImageViewer.tsx b/src/controllers/modals/components/ImageViewer.tsx similarity index 58% rename from src/context/intermediate/popovers/ImageViewer.tsx rename to src/controllers/modals/components/ImageViewer.tsx index c6f8d91a..526ec9c4 100644 --- a/src/context/intermediate/popovers/ImageViewer.tsx +++ b/src/controllers/modals/components/ImageViewer.tsx @@ -1,23 +1,40 @@ -/* eslint-disable react-hooks/rules-of-hooks */ -import { API } from "revolt.js"; - -import styles from "./ImageViewer.module.scss"; +import styled from "styled-components"; import { Modal } from "@revoltchat/ui"; import AttachmentActions from "../../../components/common/messaging/attachments/AttachmentActions"; import EmbedMediaActions from "../../../components/common/messaging/embed/EmbedMediaActions"; -import { useClient } from "../../revoltjs/RevoltClient"; +import { useClient } from "../../client/ClientController"; +import { ModalProps } from "../types"; -interface Props { - onClose: () => void; - embed?: API.Image; - attachment?: API.File; -} +const Viewer = styled.div` + display: flex; + overflow: hidden; + 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") { console.warn( `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; } - const client = useClient(); - return ( - -
+ + {attachment && ( <> @@ -54,7 +67,7 @@ export function ImageViewer({ attachment, embed, onClose }: Props) { )} -
+
); } diff --git a/src/context/modals/components/LinkWarning.tsx b/src/controllers/modals/components/LinkWarning.tsx similarity index 100% rename from src/context/modals/components/LinkWarning.tsx rename to src/controllers/modals/components/LinkWarning.tsx diff --git a/src/context/modals/components/MFAEnableTOTP.tsx b/src/controllers/modals/components/MFAEnableTOTP.tsx similarity index 100% rename from src/context/modals/components/MFAEnableTOTP.tsx rename to src/controllers/modals/components/MFAEnableTOTP.tsx diff --git a/src/context/modals/components/MFAFlow.tsx b/src/controllers/modals/components/MFAFlow.tsx similarity index 100% rename from src/context/modals/components/MFAFlow.tsx rename to src/controllers/modals/components/MFAFlow.tsx diff --git a/src/context/modals/components/MFARecovery.tsx b/src/controllers/modals/components/MFARecovery.tsx similarity index 97% rename from src/context/modals/components/MFARecovery.tsx rename to src/controllers/modals/components/MFARecovery.tsx index bed9afd1..33a11809 100644 --- a/src/context/modals/components/MFARecovery.tsx +++ b/src/controllers/modals/components/MFARecovery.tsx @@ -7,8 +7,8 @@ import { Modal } from "@revoltchat/ui"; import { noopTrue } from "../../../lib/js"; -import { modalController } from ".."; import { toConfig } from "../../../components/settings/account/MultiFactorAuthentication"; +import { modalController } from "../ModalController"; import { ModalProps } from "../types"; /** diff --git a/src/context/modals/components/ModifyAccount.tsx b/src/controllers/modals/components/ModifyAccount.tsx similarity index 94% rename from src/context/modals/components/ModifyAccount.tsx rename to src/controllers/modals/components/ModifyAccount.tsx index 282c1e13..70dd3d11 100644 --- a/src/context/modals/components/ModifyAccount.tsx +++ b/src/controllers/modals/components/ModifyAccount.tsx @@ -1,17 +1,16 @@ import { SubmitHandler, useForm } from "react-hook-form"; import { Text } from "preact-i18n"; -import { useContext, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; import { Category, Error, Modal } from "@revoltchat/ui"; import { noopTrue } from "../../../lib/js"; -import { useApplicationState } from "../../../mobx/State"; +import { takeError } from "../../../context/revoltjs/util"; import FormField from "../../../pages/login/FormField"; -import { AppContext } from "../../revoltjs/RevoltClient"; -import { takeError } from "../../revoltjs/util"; +import { useClient } from "../../client/ClientController"; import { ModalProps } from "../types"; interface FormInputs { @@ -29,7 +28,7 @@ export default function ModifyAccount({ field, ...props }: ModalProps<"modify_account">) { - const client = useApplicationState().client!; + const client = useClient(); const [processing, setProcessing] = useState(false); const { handleSubmit, register, errors } = useForm(); const [error, setError] = useState(undefined); diff --git a/src/context/modals/components/OutOfDate.tsx b/src/controllers/modals/components/OutOfDate.tsx similarity index 100% rename from src/context/modals/components/OutOfDate.tsx rename to src/controllers/modals/components/OutOfDate.tsx diff --git a/src/context/modals/components/PendingFriendRequests.tsx b/src/controllers/modals/components/PendingFriendRequests.tsx similarity index 100% rename from src/context/modals/components/PendingFriendRequests.tsx rename to src/controllers/modals/components/PendingFriendRequests.tsx diff --git a/src/context/modals/components/ServerIdentity.tsx b/src/controllers/modals/components/ServerIdentity.tsx similarity index 98% rename from src/context/modals/components/ServerIdentity.tsx rename to src/controllers/modals/components/ServerIdentity.tsx index 50c923ce..5a8dfc9a 100644 --- a/src/context/modals/components/ServerIdentity.tsx +++ b/src/controllers/modals/components/ServerIdentity.tsx @@ -19,7 +19,8 @@ import { import { noop } from "../../../lib/js"; -import { FileUploader } from "../../revoltjs/FileUploads"; +import { FileUploader } from "../../../context/revoltjs/FileUploads"; + import { ModalProps } from "../types"; const Preview = styled(Centred)` diff --git a/src/controllers/modals/components/ServerInfo.tsx b/src/controllers/modals/components/ServerInfo.tsx new file mode 100644 index 00000000..786026ad --- /dev/null +++ b/src/controllers/modals/components/ServerInfo.tsx @@ -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 ( + + +

{server.name}

+
+ + + + + } + actions={[ + { + onClick: () => + modalController.push({ + type: "server_identity", + member: server.member!, + }), + children: "Edit Identity", + palette: "primary", + }, + { + onClick: () => report(server), + children: , + palette: "error", + }, + ]}> + +
+ ); +} diff --git a/src/context/modals/components/ShowToken.tsx b/src/controllers/modals/components/ShowToken.tsx similarity index 100% rename from src/context/modals/components/ShowToken.tsx rename to src/controllers/modals/components/ShowToken.tsx diff --git a/src/context/modals/components/SignOutSessions.tsx b/src/controllers/modals/components/SignOutSessions.tsx similarity index 100% rename from src/context/modals/components/SignOutSessions.tsx rename to src/controllers/modals/components/SignOutSessions.tsx diff --git a/src/context/modals/components/SignedOut.tsx b/src/controllers/modals/components/SignedOut.tsx similarity index 100% rename from src/context/modals/components/SignedOut.tsx rename to src/controllers/modals/components/SignedOut.tsx diff --git a/src/context/intermediate/popovers/UserPicker.tsx b/src/controllers/modals/components/UserPicker.tsx similarity index 67% rename from src/context/intermediate/popovers/UserPicker.tsx rename to src/controllers/modals/components/UserPicker.tsx index d1aeecf8..b4cc6633 100644 --- a/src/context/intermediate/popovers/UserPicker.tsx +++ b/src/controllers/modals/components/UserPicker.tsx @@ -1,41 +1,50 @@ -import styles from "./UserPicker.module.scss"; +import styled from "styled-components"; + import { Text } from "preact-i18n"; -import { useState } from "preact/hooks"; +import { useMemo, useState } from "preact/hooks"; import { Modal } from "@revoltchat/ui"; import UserCheckbox from "../../../components/common/user/UserCheckbox"; -import { useClient } from "../../revoltjs/RevoltClient"; +import { useClient } from "../../client/ClientController"; +import { ModalProps } from "../types"; -interface Props { - omit?: string[]; - onClose: () => void; - callback: (users: string[]) => Promise; -} +const List = styled.div` + max-width: 100%; + max-height: 360px; + overflow-y: scroll; +`; -export function UserPicker(props: Props) { +export function UserPicker({ + callback, + omit, + ...props +}: ModalProps<"user_picker">) { const [selected, setSelected] = useState([]); - const omit = [...(props.omit || []), "00000000000000000000000000"]; + const omitted = useMemo( + () => new Set([...(omit || []), "00000000000000000000000000"]), + [omit], + ); const client = useClient(); return ( } - onClose={props.onClose} actions={[ { children: , - onClick: () => props.callback(selected).then(() => true), + onClick: () => callback(selected).then(() => true), }, ]}> -
+ {[...client.users.values()] .filter( (x) => x && x.relationship === "Friend" && - !omit.includes(x._id), + !omitted.has(x._id), ) .map((x) => ( ))} -
+
); } diff --git a/src/context/intermediate/popovers/CreateBot.tsx b/src/controllers/modals/components/legacy/CreateBot.tsx similarity index 76% rename from src/context/intermediate/popovers/CreateBot.tsx rename to src/controllers/modals/components/legacy/CreateBot.tsx index 45837755..b880ef5a 100644 --- a/src/context/intermediate/popovers/CreateBot.tsx +++ b/src/controllers/modals/components/legacy/CreateBot.tsx @@ -2,26 +2,29 @@ import { SubmitHandler, useForm } from "react-hook-form"; import { API } from "revolt.js"; import { Text } from "preact-i18n"; -import { useContext, useState } from "preact/hooks"; +import { useState } from "preact/hooks"; import { Category, Modal } from "@revoltchat/ui"; -import FormField from "../../../pages/login/FormField"; -import { I18nError } from "../../Locale"; -import { AppContext } from "../../revoltjs/RevoltClient"; -import { takeError } from "../../revoltjs/util"; +import { noopTrue } from "../../../../lib/js"; -interface Props { - onClose: () => void; - onCreate: (bot: API.Bot) => void; -} +import { I18nError } from "../../../../context/Locale"; +import { takeError } from "../../../../context/revoltjs/util"; + +import FormField from "../../../../pages/login/FormField"; +import { useClient } from "../../../client/ClientController"; +import { modalController } from "../../ModalController"; +import { ModalProps } from "../../types"; interface FormInputs { name: string; } -export function CreateBotModal({ onClose, onCreate }: Props) { - const client = useContext(AppContext); +export function CreateBotModal({ + onCreate, + ...props +}: ModalProps<"create_bot">) { + const client = useClient(); const { handleSubmit, register, errors } = useForm(); const [error, setError] = useState(undefined); @@ -29,7 +32,7 @@ export function CreateBotModal({ onClose, onCreate }: Props) { try { const { bot } = await client.bots.create({ name }); onCreate(bot); - onClose(); + modalController.close(); } catch (err) { setError(takeError(err)); } @@ -37,7 +40,7 @@ export function CreateBotModal({ onClose, onCreate }: Props) { return ( } actions={[ { @@ -51,7 +54,7 @@ export function CreateBotModal({ onClose, onCreate }: Props) { }, { palette: "plain", - onClick: onClose, + onClick: noopTrue, children: , }, ]}> diff --git a/src/context/intermediate/modals/Onboarding.module.scss b/src/controllers/modals/components/legacy/Onboarding.module.scss similarity index 87% rename from src/context/intermediate/modals/Onboarding.module.scss rename to src/controllers/modals/components/legacy/Onboarding.module.scss index 887b4224..42ebfbdd 100644 --- a/src/context/intermediate/modals/Onboarding.module.scss +++ b/src/controllers/modals/components/legacy/Onboarding.module.scss @@ -1,5 +1,12 @@ .onboarding { - height: 100vh; + position: fixed; + top: 0; + left: 0; + width: 100%; + height: 100%; + + background: var(--background); + display: flex; align-items: center; flex-direction: column; diff --git a/src/context/intermediate/modals/Onboarding.tsx b/src/controllers/modals/components/legacy/Onboarding.tsx similarity index 87% rename from src/context/intermediate/modals/Onboarding.tsx rename to src/controllers/modals/components/legacy/Onboarding.tsx index af21e930..53cc681f 100644 --- a/src/context/intermediate/modals/Onboarding.tsx +++ b/src/controllers/modals/components/legacy/Onboarding.tsx @@ -6,21 +6,21 @@ import { useState } from "preact/hooks"; import { Button, Preloader } from "@revoltchat/ui"; +import { takeError } from "../../../../context/revoltjs/util"; + import wideSVG from "/assets/wide.svg"; -import FormField from "../../../pages/login/FormField"; -import { takeError } from "../../revoltjs/util"; - -interface Props { - onClose: () => void; - callback: (username: string, loginAfterSuccess?: true) => Promise; -} +import FormField from "../../../../pages/login/FormField"; +import { ModalProps } from "../../types"; interface FormInputs { username: string; } -export function OnboardingModal({ onClose, callback }: Props) { +export function OnboardingModal({ + callback, + ...props +}: ModalProps<"onboarding">) { const { handleSubmit, register } = useForm(); const [loading, setLoading] = useState(false); const [error, setError] = useState(undefined); @@ -28,7 +28,7 @@ export function OnboardingModal({ onClose, callback }: Props) { const onSubmit: SubmitHandler = ({ username }) => { setLoading(true); callback(username, true) - .then(() => onClose()) + .then(() => props.onClose()) .catch((err: unknown) => { setError(takeError(err)); setLoading(false); diff --git a/src/context/intermediate/popovers/UserProfile.module.scss b/src/controllers/modals/components/legacy/UserProfile.module.scss similarity index 100% rename from src/context/intermediate/popovers/UserProfile.module.scss rename to src/controllers/modals/components/legacy/UserProfile.module.scss diff --git a/src/context/intermediate/popovers/UserProfile.tsx b/src/controllers/modals/components/legacy/UserProfile.tsx similarity index 88% rename from src/context/intermediate/popovers/UserProfile.tsx rename to src/controllers/modals/components/legacy/UserProfile.tsx index e5244099..a7cc7f8e 100644 --- a/src/context/intermediate/popovers/UserProfile.tsx +++ b/src/controllers/modals/components/legacy/UserProfile.tsx @@ -13,7 +13,7 @@ import { UserPermission, API } from "revolt.js"; import styles from "./UserProfile.module.scss"; import { Localizer, Text } from "preact-i18n"; -import { useContext, useEffect, useLayoutEffect, useState } from "preact/hooks"; +import { useEffect, useLayoutEffect, useState } from "preact/hooks"; import { Button, @@ -24,34 +24,27 @@ import { Preloader, } from "@revoltchat/ui"; -import { noop } from "../../../lib/js"; +import { noop } from "../../../../lib/js"; -import ChannelIcon from "../../../components/common/ChannelIcon"; -import ServerIcon from "../../../components/common/ServerIcon"; -import Tooltip from "../../../components/common/Tooltip"; -import UserBadges from "../../../components/common/user/UserBadges"; -import UserIcon from "../../../components/common/user/UserIcon"; -import { Username } from "../../../components/common/user/UserShort"; -import UserStatus from "../../../components/common/user/UserStatus"; -import Markdown from "../../../components/markdown/Markdown"; -import { - ClientStatus, - StatusContext, - useClient, -} from "../../revoltjs/RevoltClient"; -import { useIntermediate } from "../Intermediate"; - -interface Props { - user_id: string; - dummy?: boolean; - onClose?: () => void; - dummyProfile?: API.UserProfile; -} +import ChannelIcon from "../../../../components/common/ChannelIcon"; +import ServerIcon from "../../../../components/common/ServerIcon"; +import Tooltip from "../../../../components/common/Tooltip"; +import UserBadges from "../../../../components/common/user/UserBadges"; +import UserIcon from "../../../../components/common/user/UserIcon"; +import { Username } from "../../../../components/common/user/UserShort"; +import UserStatus from "../../../../components/common/user/UserStatus"; +import Markdown from "../../../../components/markdown/Markdown"; +import { useSession } from "../../../../controllers/client/ClientController"; +import { modalController } from "../../../../controllers/modals/ModalController"; +import { ModalProps } from "../../types"; 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< undefined | null | API.UserProfile >(undefined); @@ -63,13 +56,13 @@ export const UserProfile = observer( >(); const history = useHistory(); - const client = useClient(); - const status = useContext(StatusContext); + const session = useSession()!; + const client = session.client!; const [tab, setTab] = useState("profile"); const user = client.users.get(user_id); if (!user) { - if (onClose) useEffect(onClose, []); + if (props.onClose) useEffect(props.onClose, []); return null; } @@ -101,32 +94,26 @@ export const UserProfile = observer( useEffect(() => { if (dummy) return; - if ( - status === ClientStatus.ONLINE && - typeof mutual === "undefined" - ) { + if (session.state === "Online" && typeof mutual === "undefined") { setMutual(null); user.fetchMutual().then(setMutual); } - }, [mutual, status, dummy, user]); + }, [mutual, session.state, dummy, user]); useEffect(() => { if (dummy) return; - if ( - status === ClientStatus.ONLINE && - typeof profile === "undefined" - ) { + if (session.state === "Online" && typeof profile === "undefined") { setProfile(null); if (user.permission & UserPermission.ViewProfile) { user.fetchProfile().then(setProfile).catch(noop); } } - }, [profile, status, dummy, user]); + }, [profile, session.state, dummy, user]); useEffect(() => { if ( - status === ClientStatus.ONLINE && + session.state === "Online" && user.bot && typeof isPublicBot === "undefined" ) { @@ -136,7 +123,7 @@ export const UserProfile = observer( .then(() => setIsPublicBot(true)) .catch(noop); } - }, [isPublicBot, status, user, client.bots]); + }, [isPublicBot, session.state, user, client.bots]); const backgroundURL = profile && @@ -169,8 +156,8 @@ export const UserProfile = observer( hover={typeof user.avatar !== "undefined"} onClick={() => user.avatar && - openScreen({ - id: "image_viewer", + modalController.push({ + type: "image_viewer", attachment: user.avatar, }) } @@ -180,7 +167,7 @@ export const UserProfile = observer( - writeClipboard(user.username) + modalController.writeText(user.username) }> @{user.username} @@ -196,7 +183,7 @@ export const UserProfile = observer( @@ -209,7 +196,7 @@ export const UserProfile = observer( }> { - onClose?.(); + props.onClose?.(); history.push(`/open/${user_id}`); }}> @@ -220,7 +207,7 @@ export const UserProfile = observer( {user.relationship === "User" && !dummy && ( { - onClose?.(); + props.onClose?.(); history.push(`/settings/profile`); }}> @@ -300,8 +287,8 @@ export const UserProfile = observer(
user.bot && - openScreen({ - id: "profile", + modalController.push({ + type: "user_profile", user_id: user.bot.owner, }) } @@ -364,8 +351,8 @@ export const UserProfile = observer( x && (
- openScreen({ - id: "profile", + modalController.push({ + type: "user_profile", user_id: x._id, }) } @@ -445,7 +432,7 @@ export const UserProfile = observer( return ( diff --git a/src/context/modals/types.ts b/src/controllers/modals/types.ts similarity index 66% rename from src/context/modals/types.ts rename to src/controllers/modals/types.ts index d95297ac..2471b82c 100644 --- a/src/context/modals/types.ts +++ b/src/controllers/modals/types.ts @@ -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 = { key?: string; @@ -72,6 +72,41 @@ export type Modal = { | { 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; + } + | { + 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; + } ); export type ModalProps = Modal & { type: T } & { diff --git a/src/controllers/safety/index.ts b/src/controllers/safety/index.ts new file mode 100644 index 00000000..bfcb2471 --- /dev/null +++ b/src/controllers/safety/index.ts @@ -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!`, + )}`, + ); +} diff --git a/src/lib/ContextMenus.tsx b/src/lib/ContextMenus.tsx index 5028bddd..6c1d1ddc 100644 --- a/src/lib/ContextMenus.tsx +++ b/src/lib/ContextMenus.tsx @@ -19,7 +19,6 @@ import { openContextMenu, } from "preact-context-menu"; import { Text } from "preact-i18n"; -import { useContext } from "preact/hooks"; import { IconButton, LineDivider } from "@revoltchat/ui"; @@ -28,17 +27,13 @@ import { QueuedMessage } from "../mobx/stores/MessageQueue"; import { NotificationState } from "../mobx/stores/NotificationOptions"; 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 CMNotifications from "./contextmenu/CMNotifications"; import Tooltip from "../components/common/Tooltip"; import UserStatus from "../components/common/user/UserStatus"; +import { useSession } from "../controllers/client/ClientController"; +import { modalController } from "../controllers/modals/ModalController"; import { internalEmit } from "./eventEmitter"; import { getRenderer } from "./renderer/Singleton"; @@ -122,12 +117,12 @@ type Action = // Tip: This should just be split into separate context menus per logical area. export default function ContextMenus() { const { openScreen, writeClipboard } = useIntermediate(); - const client = useContext(AppContext); + const session = useSession()!; + const client = session.client!; const userId = client.user!._id; - const status = useContext(StatusContext); - const isOnline = status === ClientStatus.ONLINE; const state = useApplicationState(); const history = useHistory(); + const isOnline = session.state === "Online"; function contextClick(data?: Action) { if (typeof data === "undefined") return; @@ -319,7 +314,10 @@ export default function ContextMenus() { break; case "view_profile": - openScreen({ id: "profile", user_id: data.user._id }); + modalController.push({ + type: "user_profile", + user_id: data.user._id, + }); break; case "message_user": diff --git a/src/lib/FakeClient.tsx b/src/lib/FakeClient.tsx deleted file mode 100644 index 861ecec3..00000000 --- a/src/lib/FakeClient.tsx +++ /dev/null @@ -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 {children}; -}); diff --git a/src/lib/renderer/Singleton.ts b/src/lib/renderer/Singleton.ts index f30b9ec0..b59e7a4c 100644 --- a/src/lib/renderer/Singleton.ts +++ b/src/lib/renderer/Singleton.ts @@ -1,8 +1,6 @@ /* eslint-disable react-hooks/rules-of-hooks */ import { action, makeAutoObservable } from "mobx"; -import { Channel } from "revolt.js"; -import { Message } from "revolt.js"; -import { Nullable } from "revolt.js"; +import { Channel, Message, Nullable } from "revolt.js"; import { SimpleRenderer } from "./simple/SimpleRenderer"; import { RendererRoutines, ScrollState } from "./types"; diff --git a/src/lib/window.ts b/src/lib/window.ts new file mode 100644 index 00000000..7ee0ce3f --- /dev/null +++ b/src/lib/window.ts @@ -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, + }; +} diff --git a/src/mobx/State.ts b/src/mobx/State.ts index e742ac44..02a60e7e 100644 --- a/src/mobx/State.ts +++ b/src/mobx/State.ts @@ -1,11 +1,13 @@ // @ts-expect-error No typings. import stringify from "json-stringify-deterministic"; import localforage from "localforage"; -import { makeAutoObservable, reaction, runInAction } from "mobx"; -import { Client } from "revolt.js"; +import { action, makeAutoObservable, reaction, runInAction } from "mobx"; +import { Client, ClientboundNotification } from "revolt.js"; import { reportError } from "../lib/ErrorBoundary"; +import { injectWindow } from "../lib/window"; +import { clientController } from "../controllers/client/ClientController"; import Persistent from "./interfaces/Persistent"; import Syncable from "./interfaces/Syncable"; import Auth from "./stores/Auth"; @@ -24,6 +26,7 @@ import Sync, { Data as DataSync, SyncKeys } from "./stores/Sync"; export const MIGRATIONS = { REDUX: 1640305719826, + MULTI_SERVER_CONFIG: 1656350006152, }; /** @@ -36,7 +39,10 @@ export default class State { locale: LocaleOptions; experiments: Experiments; layout: Layout; - config: ServerConfig; + /** + * DEPRECATED + */ + private config: ServerConfig; notifications: NotificationOptions; queue: MessageQueue; settings: Settings; @@ -47,8 +53,6 @@ export default class State { private persistent: [string, Persistent][] = []; private disabled: Set = new Set(); - client?: Client; - /** * Construct new State. */ @@ -60,21 +64,21 @@ export default class State { this.experiments = new Experiments(); this.layout = new Layout(); this.config = new ServerConfig(); - this.notifications = new NotificationOptions(); + this.notifications = new NotificationOptions(this); this.queue = new MessageQueue(); this.settings = new Settings(); this.sync = new Sync(this); this.plugins = new Plugins(this); this.ordering = new Ordering(this); - makeAutoObservable(this, { - client: false, - }); + makeAutoObservable(this); this.register(); 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. * @returns Function to dispose of listeners @@ -138,8 +156,28 @@ export default class State { registerListeners(client?: Client) { // If a client is present currently, expose it and provide it to plugins. if (client) { - this.client = client; - this.plugins.onClient(client); + // Register message listener for clearing queue. + 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. @@ -225,8 +263,20 @@ export default class State { }); return () => { - // Stop exposing the client. - this.client = undefined; + // Remove any listeners attached to client. + 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. listeners.forEach((x) => x()); @@ -253,6 +303,9 @@ export default class State { // Post-hydration, init plugins. 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.experiments = new Experiments(); this.layout = new Layout(); - this.notifications = new NotificationOptions(); + this.notifications = new NotificationOptions(this); this.queue = new MessageQueue(); this.settings = new Settings(); this.sync = new Sync(this); @@ -277,13 +330,7 @@ export default class State { } } -let state: State; - -export async function hydrateState() { - state = new State(); - (window as any).state = state; - await state.hydrate(); -} +export const state = new State(); /** * Get the application state diff --git a/src/mobx/stores/Auth.ts b/src/mobx/stores/Auth.ts index 71cea92c..12574350 100644 --- a/src/mobx/stores/Auth.ts +++ b/src/mobx/stores/Auth.ts @@ -1,19 +1,18 @@ import { action, computed, makeAutoObservable, ObservableMap } from "mobx"; -import { API } from "revolt.js"; -import { Nullable } from "revolt.js"; import { mapToRecord } from "../../lib/conversion"; +import { clientController } from "../../controllers/client/ClientController"; import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; interface Account { session: Session; + apiUrl?: string; } export interface Data { sessions: Record; - current?: string; } /** @@ -22,14 +21,12 @@ export interface Data { */ export default class Auth implements Store, Persistent { private sessions: ObservableMap; - private current: Nullable; /** * Construct new Auth store. */ constructor() { this.sessions = new ObservableMap(); - this.current = null; // Inject session token if it is provided. if (import.meta.env.VITE_SESSION_TOKEN) { @@ -40,8 +37,6 @@ export default class Auth implements Store, Persistent { token: import.meta.env.VITE_SESSION_TOKEN as string, }, }); - - this.current = "0"; } makeAutoObservable(this); @@ -54,7 +49,6 @@ export default class Auth implements Store, Persistent { @action toJSON() { return { sessions: JSON.parse(JSON.stringify(mapToRecord(this.sessions))), - current: this.current ?? undefined, }; } @@ -72,19 +66,15 @@ export default class Auth implements Store, Persistent { 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. * @param session Session + * @param apiUrl Custom API URL */ - @action setSession(session: Session) { - this.sessions.set(session.user_id, { session }); - this.current = session.user_id; + @action setSession(session: Session, apiUrl?: string) { + this.sessions.set(session.user_id, { session, apiUrl }); } /** @@ -92,34 +82,39 @@ export default class Auth implements Store, Persistent { * @param user_id User ID tied to session */ @action removeSession(user_id: string) { - if (user_id == this.current) { - 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. */ - @action logout() { + /*@action logout() { this.current && this.removeSession(this.current); - } + }*/ /** * Get current session. * @returns Current session */ - @computed getSession() { + /*@computed getSession() { if (!this.current) return; return this.sessions.get(this.current)!.session; - } + }*/ /** * Check whether we are currently logged in. * @returns Whether we are logged in */ @computed isLoggedIn() { - return this.current !== null; + // ! FIXME: temp proxy info + return clientController.getActiveSession()?.ready; } } diff --git a/src/mobx/stores/Changelog.ts b/src/mobx/stores/Changelog.ts index 410e1518..553117e0 100644 --- a/src/mobx/stores/Changelog.ts +++ b/src/mobx/stores/Changelog.ts @@ -1,8 +1,7 @@ import { action, makeAutoObservable, runInAction } from "mobx"; -import { modalController } from "../../context/modals"; - import { latestChangelog } from "../../assets/changelogs"; +import { modalController } from "../../controllers/modals/ModalController"; import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; import Syncable from "../interfaces/Syncable"; diff --git a/src/mobx/stores/MessageQueue.ts b/src/mobx/stores/MessageQueue.ts index 953427ed..c4fd5d26 100644 --- a/src/mobx/stores/MessageQueue.ts +++ b/src/mobx/stores/MessageQueue.ts @@ -5,6 +5,7 @@ import { makeAutoObservable, observable, } from "mobx"; +import { Message } from "revolt.js"; import Store from "../interfaces/Store"; @@ -47,6 +48,8 @@ export default class MessageQueue implements Store { constructor() { this.messages = observable.array([]); makeAutoObservable(this); + + this.onMessage = this.onMessage.bind(this); } get id() { @@ -105,4 +108,16 @@ export default class MessageQueue implements Store { @computed get(channel: string) { 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); + } } diff --git a/src/mobx/stores/NotificationOptions.ts b/src/mobx/stores/NotificationOptions.ts index 2b6285a2..91a5fe35 100644 --- a/src/mobx/stores/NotificationOptions.ts +++ b/src/mobx/stores/NotificationOptions.ts @@ -1,8 +1,14 @@ 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 { history, routeInformation } from "../../context/history"; + +import State from "../State"; import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; import Syncable from "../interfaces/Syncable"; @@ -37,22 +43,54 @@ export interface Data { channel?: Record; } +/** + * 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. */ export default class NotificationOptions implements Store, Persistent, Syncable { + private state: State; + private activeNotifications: Record; + private server: ObservableMap; private channel: ObservableMap; /** * Construct new Experiments store. */ - constructor() { + constructor(state: State) { this.server = 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() { @@ -209,6 +247,245 @@ export default class NotificationOptions 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) { this.hydrate(data as Data); } diff --git a/src/mobx/stores/Ordering.ts b/src/mobx/stores/Ordering.ts index 8e2ccb11..a72e2ddb 100644 --- a/src/mobx/stores/Ordering.ts +++ b/src/mobx/stores/Ordering.ts @@ -2,6 +2,7 @@ import { action, computed, makeAutoObservable } from "mobx"; import { reorder } from "@revoltchat/ui"; +import { clientController } from "../../controllers/client/ClientController"; import State from "../State"; import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; @@ -63,18 +64,19 @@ export default class Ordering implements Store, Persistent, Syncable { * All known servers with ordering applied */ @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 out = []; for (const id of ordered) { if (known.delete(id)) { - out.push(this.state.client!.servers.get(id)!); + out.push(client!.servers.get(id)!); } } for (const id of known) { - out.push(this.state.client!.servers.get(id)!); + out.push(client!.servers.get(id)!); } return out; diff --git a/src/mobx/stores/Plugins.ts b/src/mobx/stores/Plugins.ts index 08a480d5..e6b3bbb3 100644 --- a/src/mobx/stores/Plugins.ts +++ b/src/mobx/stores/Plugins.ts @@ -41,7 +41,6 @@ type Plugin = { * ```typescript * function (state: State) { * return { - * onClient: (client: Client) => {}, * onUnload: () => {} * } * } @@ -59,7 +58,6 @@ type Plugin = { type Instance = { format: 1; - onClient?: (client: Client) => {}; onUnload?: () => void; }; @@ -124,7 +122,7 @@ export default class Plugins implements Store, Persistent { * @param id Plugin Id */ @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 { * @returns Plugin Instance */ private getInstance(plugin: Pick) { - 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 { 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) { this.load(plugin.namespace, plugin.id); @@ -173,7 +171,7 @@ export default class Plugins implements Store, Persistent { */ remove(namespace: string, id: string) { 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 { if (!plugin) throw "Unknown plugin!"; try { - const ns = `${plugin.namespace }/${ plugin.id}`; + const ns = `${plugin.namespace}/${plugin.id}`; const instance: Instance = eval(plugin.entrypoint)(); this.instances.set(ns, { @@ -198,10 +196,6 @@ export default class Plugins implements Store, Persistent { ...plugin, enabled: true, }); - - if (this.state.client) { - instance.onClient?.(this.state.client); - } } catch (error) { console.error(`Failed to load ${namespace}/${id}!`); console.error(error); @@ -217,7 +211,7 @@ export default class Plugins implements Store, Persistent { const plugin = this.get(namespace, id); if (!plugin) throw "Unknown plugin!"; - const ns = `${plugin.namespace }/${ plugin.id}`; + const ns = `${plugin.namespace}/${plugin.id}`; const loaded = this.getInstance(plugin); if (loaded) { loaded.onUnload?.(); @@ -235,13 +229,4 @@ export default class Plugins implements Store, Persistent { localforage.removeItem("revite:plugins"); window.location.reload(); } - - /** - * Push client through to plugins - */ - onClient(client: Client) { - for (const instance of this.instances.values()) { - instance.onClient?.(client); - } - } } diff --git a/src/mobx/stores/ServerConfig.ts b/src/mobx/stores/ServerConfig.ts index 4212e82d..17ee1230 100644 --- a/src/mobx/stores/ServerConfig.ts +++ b/src/mobx/stores/ServerConfig.ts @@ -1,7 +1,5 @@ import { action, computed, makeAutoObservable } from "mobx"; -import { API } from "revolt.js"; -import { Client } from "revolt.js"; -import { Nullable } from "revolt.js"; +import { API, Client, Nullable } from "revolt.js"; import { isDebug } from "../../revision"; import Persistent from "../interfaces/Persistent"; diff --git a/src/mobx/stores/Settings.ts b/src/mobx/stores/Settings.ts index f775a366..344d65a7 100644 --- a/src/mobx/stores/Settings.ts +++ b/src/mobx/stores/Settings.ts @@ -4,8 +4,7 @@ import { mapToRecord } from "../../lib/conversion"; import { Fonts, MonospaceFonts, Overrides } from "../../context/Theme"; -import { EmojiPack } from "../../components/common/Emoji"; -import { MIGRATIONS } from "../State"; +import { EmojiPack, setGlobalEmojiPack } from "../../components/common/Emoji"; import Persistent from "../interfaces/Persistent"; import Store from "../interfaces/Store"; import Syncable from "../interfaces/Syncable"; @@ -79,6 +78,11 @@ export default class Settings * @param value Value */ @action set(key: T, value: ISettings[T]) { + // Emoji needs to be immediately applied. + if (key === 'appearance:emoji') { + setGlobalEmojiPack(value as EmojiPack); + } + this.data.set(key, value); } diff --git a/src/pages/Open.tsx b/src/pages/Open.tsx index 20e5265f..cf724c84 100644 --- a/src/pages/Open.tsx +++ b/src/pages/Open.tsx @@ -2,24 +2,20 @@ import { useHistory, useParams } from "react-router-dom"; import { Text } from "preact-i18n"; -import { useContext, useEffect } from "preact/hooks"; +import { useEffect } from "preact/hooks"; import { Header } from "@revoltchat/ui"; -import { modalController } from "../context/modals"; -import { - AppContext, - ClientStatus, - StatusContext, -} from "../context/revoltjs/RevoltClient"; +import { useSession } from "../controllers/client/ClientController"; +import { modalController } from "../controllers/modals/ModalController"; export default function Open() { const history = useHistory(); - const client = useContext(AppContext); - const status = useContext(StatusContext); + const session = useSession()!; + const client = session.client!; const { id } = useParams<{ id: string }>(); - if (status !== ClientStatus.ONLINE) { + if (session.state !== "Online") { return (
diff --git a/src/pages/RevoltApp.tsx b/src/pages/RevoltApp.tsx index c4b3631b..c04917ab 100644 --- a/src/pages/RevoltApp.tsx +++ b/src/pages/RevoltApp.tsx @@ -8,8 +8,6 @@ import ContextMenus from "../lib/ContextMenus"; import { isTouchscreenDevice } from "../lib/isTouchscreenDevice"; 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 BottomNavigation from "../components/navigation/BottomNavigation"; @@ -77,12 +75,6 @@ const Routes = styled.div.attrs({ "data-component": "routes" })<{ background: var(--primary-background); - /*background-color: rgba( - var(--primary-background-rgb), - max(var(--min-opacity), 0.75) - );*/ - //backdrop-filter: blur(10px); - ${() => isTouchscreenDevice && css` @@ -234,8 +226,6 @@ export default function App() { - - diff --git a/src/pages/app.tsx b/src/pages/app.tsx index 89f05b31..2251e942 100644 --- a/src/pages/app.tsx +++ b/src/pages/app.tsx @@ -5,7 +5,6 @@ import { lazy, Suspense } from "preact/compat"; import { Masks, Preloader } from "@revoltchat/ui"; import ErrorBoundary from "../lib/ErrorBoundary"; -import FakeClient from "../lib/FakeClient"; import Context from "../context"; import { CheckAuth } from "../context/revoltjs/CheckAuth"; @@ -16,46 +15,53 @@ const Login = lazy(() => import("./login/Login")); const ConfirmDelete = lazy(() => import("./login/ConfirmDelete")); const RevoltApp = lazy(() => import("./RevoltApp")); +const LoadSuspense: React.FC = ({ children }) => ( + // @ts-expect-error Typing issue between Preact and Preact. + }>{children} +); + export function App() { return ( - {/* - // @ts-expect-error typings mis-match between preact... and preact? */} - }> - - + + + + + + - - - - - + + + + - - - - - - - - - - - - - + + + + + + + + + + + + + - - - - + + + + + + - - - - + + + + ); diff --git a/src/pages/channels/Channel.tsx b/src/pages/channels/Channel.tsx index 49d4028f..3545fd55 100644 --- a/src/pages/channels/Channel.tsx +++ b/src/pages/channels/Channel.tsx @@ -16,8 +16,6 @@ import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; import { useApplicationState } from "../../mobx/State"; import { SIDEBAR_MEMBERS } from "../../mobx/stores/Layout"; -import { useClient } from "../../context/revoltjs/RevoltClient"; - import AgeGate from "../../components/common/AgeGate"; import MessageBox from "../../components/common/messaging/MessageBox"; 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 RightSidebar from "../../components/navigation/RightSidebar"; import { PageHeader } from "../../components/ui/Header"; +import { useClient } from "../../controllers/client/ClientController"; import ChannelHeader from "./ChannelHeader"; import { MessageArea } from "./messaging/MessageArea"; import VoiceHeader from "./voice/VoiceHeader"; diff --git a/src/pages/channels/ChannelHeader.tsx b/src/pages/channels/ChannelHeader.tsx index 46860e4b..e2def382 100644 --- a/src/pages/channels/ChannelHeader.tsx +++ b/src/pages/channels/ChannelHeader.tsx @@ -1,19 +1,18 @@ import { At, Hash } from "@styled-icons/boxicons-regular"; import { Notepad, Group } from "@styled-icons/boxicons-solid"; import { observer } from "mobx-react-lite"; -import { Channel } from "revolt.js"; -import { User } from "revolt.js"; +import { Channel, User } from "revolt.js"; import styled from "styled-components/macro"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; -import { useIntermediate } from "../../context/intermediate/Intermediate"; import { getChannelName } from "../../context/revoltjs/util"; import { useStatusColour } from "../../components/common/user/UserIcon"; import UserStatus from "../../components/common/user/UserStatus"; import Markdown from "../../components/markdown/Markdown"; import { PageHeader } from "../../components/ui/Header"; +import { modalController } from "../../controllers/modals/ModalController"; import HeaderActions from "./actions/HeaderActions"; export interface ChannelHeaderProps { @@ -65,8 +64,6 @@ const Info = styled.div` `; export default observer(({ channel }: ChannelHeaderProps) => { - const { openScreen } = useIntermediate(); - const name = getChannelName(channel); let icon, recipient: User | undefined; switch (channel.channel_type) { @@ -114,8 +111,8 @@ export default observer(({ channel }: ChannelHeaderProps) => { - openScreen({ - id: "channel_info", + modalController.push({ + type: "channel_info", channel, }) }> diff --git a/src/pages/channels/actions/HeaderActions.tsx b/src/pages/channels/actions/HeaderActions.tsx index c3bddff1..04cbe198 100644 --- a/src/pages/channels/actions/HeaderActions.tsx +++ b/src/pages/channels/actions/HeaderActions.tsx @@ -24,6 +24,7 @@ import { SIDEBAR_MEMBERS } from "../../../mobx/stores/Layout"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; import UpdateIndicator from "../../../components/common/UpdateIndicator"; +import { modalController } from "../../../controllers/modals/ModalController"; import { ChannelHeaderProps } from "../ChannelHeader"; const Container = styled.div` @@ -114,8 +115,8 @@ export default function HeaderActions({ channel }: ChannelHeaderProps) { <> - openScreen({ - id: "user_picker", + modalController.push({ + type: "user_picker", omit: channel.recipient_ids!, callback: async (users) => { for (const user of users) { diff --git a/src/pages/channels/messaging/MessageArea.tsx b/src/pages/channels/messaging/MessageArea.tsx index 4a1e031e..c35878d9 100644 --- a/src/pages/channels/messaging/MessageArea.tsx +++ b/src/pages/channels/messaging/MessageArea.tsx @@ -24,12 +24,9 @@ import { getRenderer } from "../../../lib/renderer/Singleton"; import { ScrollState } from "../../../lib/renderer/types"; 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 MessageRenderer from "./MessageRenderer"; @@ -65,7 +62,7 @@ export const MESSAGE_AREA_PADDING = 82; export const MessageArea = observer(({ last_id, channel }: Props) => { const history = useHistory(); - const status = useContext(StatusContext); + const session = useSession()!; const { focusTaken } = useContext(IntermediateContext); // ? 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. useEffect(() => { - switch (status) { - case ClientStatus.ONLINE: + switch (session.state) { + case "Online": if (renderer.state === "WAITING_FOR_NETWORK") { renderer.init(); } else { @@ -222,13 +219,13 @@ export const MessageArea = observer(({ last_id, channel }: Props) => { } break; - case ClientStatus.OFFLINE: - case ClientStatus.DISCONNECTED: - case ClientStatus.CONNECTING: + case "Offline": + case "Disconnected": + case "Connecting": renderer.markStale(); break; } - }, [renderer, status]); + }, [renderer, session.state]); // ? When the container is scrolled. // ? Also handle StayAtBottom diff --git a/src/pages/channels/messaging/MessageRenderer.tsx b/src/pages/channels/messaging/MessageRenderer.tsx index b97fac8c..950c56d5 100644 --- a/src/pages/channels/messaging/MessageRenderer.tsx +++ b/src/pages/channels/messaging/MessageRenderer.tsx @@ -3,9 +3,7 @@ import { X } from "@styled-icons/boxicons-regular"; import dayjs from "dayjs"; import isEqual from "lodash.isequal"; import { observer } from "mobx-react-lite"; -import { API } from "revolt.js"; -import { Message as MessageI } from "revolt.js"; -import { Nullable } from "revolt.js"; +import { API, Message as MessageI, Nullable } from "revolt.js"; import styled from "styled-components/macro"; import { decodeTime } from "ulid"; @@ -19,11 +17,10 @@ import { ChannelRenderer } from "../../../lib/renderer/Singleton"; import { useApplicationState } from "../../../mobx/State"; -import RequiresOnline from "../../../context/revoltjs/RequiresOnline"; -import { useClient } from "../../../context/revoltjs/RevoltClient"; - import Message from "../../../components/common/messaging/Message"; import { SystemMessage } from "../../../components/common/messaging/SystemMessage"; +import { useClient } from "../../../controllers/client/ClientController"; +import RequiresOnline from "../../../controllers/client/jsx/RequiresOnline"; import ConversationStart from "./ConversationStart"; import MessageEditor from "./MessageEditor"; diff --git a/src/pages/channels/voice/VoiceHeader.tsx b/src/pages/channels/voice/VoiceHeader.tsx index 0d906868..242e1041 100644 --- a/src/pages/channels/voice/VoiceHeader.tsx +++ b/src/pages/channels/voice/VoiceHeader.tsx @@ -17,10 +17,10 @@ import { Button } from "@revoltchat/ui"; import { voiceState, VoiceStatus } from "../../../lib/vortex/VoiceState"; import { useIntermediate } from "../../../context/intermediate/Intermediate"; -import { useClient } from "../../../context/revoltjs/RevoltClient"; import Tooltip from "../../../components/common/Tooltip"; import UserIcon from "../../../components/common/user/UserIcon"; +import { useClient } from "../../../controllers/client/ClientController"; interface Props { id: string; diff --git a/src/pages/developer/Developer.tsx b/src/pages/developer/Developer.tsx index 46733c44..c9e7f617 100644 --- a/src/pages/developer/Developer.tsx +++ b/src/pages/developer/Developer.tsx @@ -1,18 +1,17 @@ import { Wrench } from "@styled-icons/boxicons-solid"; -import { useContext, useEffect, useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import PaintCounter from "../../lib/PaintCounter"; import { TextReact } from "../../lib/i18n"; -import { AppContext } from "../../context/revoltjs/RevoltClient"; - import { PageHeader } from "../../components/ui/Header"; +import { useClient } from "../../controllers/client/ClientController"; export default function Developer() { // const voice = useContext(VoiceContext); - const client = useContext(AppContext); + const client = useClient(); const userPermission = client.user!.permission; const [ping, setPing] = useState(client.websocket.ping); const [crash, setCrash] = useState(false); diff --git a/src/pages/friends/Friend.tsx b/src/pages/friends/Friend.tsx index 182ffdea..f93d3531 100644 --- a/src/pages/friends/Friend.tsx +++ b/src/pages/friends/Friend.tsx @@ -18,6 +18,7 @@ import { useIntermediate } from "../../context/intermediate/Intermediate"; import UserIcon from "../../components/common/user/UserIcon"; import UserStatus from "../../components/common/user/UserStatus"; +import { modalController } from "../../controllers/modals/ModalController"; interface Props { user: User; @@ -128,7 +129,12 @@ export const Friend = observer(({ user }: Props) => { return (
openScreen({ id: "profile", user_id: user._id })} + onClick={() => + modalController.push({ + type: "user_profile", + user_id: user._id, + }) + } {...useTriggerEvents("Menu", { user: user._id, })}> diff --git a/src/pages/friends/Friends.tsx b/src/pages/friends/Friends.tsx index aa3af078..3b6fa516 100644 --- a/src/pages/friends/Friends.tsx +++ b/src/pages/friends/Friends.tsx @@ -13,13 +13,13 @@ import { TextReact } from "../../lib/i18n"; import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; import { useIntermediate } from "../../context/intermediate/Intermediate"; -import { modalController } from "../../context/modals"; -import { useClient } from "../../context/revoltjs/RevoltClient"; import CollapsibleSection from "../../components/common/CollapsibleSection"; import Tooltip from "../../components/common/Tooltip"; import UserIcon from "../../components/common/user/UserIcon"; import { PageHeader } from "../../components/ui/Header"; +import { useClient } from "../../controllers/client/ClientController"; +import { modalController } from "../../controllers/modals/ModalController"; import { Friend } from "./Friend"; export default observer(() => { diff --git a/src/pages/home/Home.tsx b/src/pages/home/Home.tsx index 858161c8..9690af9d 100644 --- a/src/pages/home/Home.tsx +++ b/src/pages/home/Home.tsx @@ -15,7 +15,7 @@ import styled from "styled-components/macro"; import styles from "./Home.module.scss"; import "./snow.scss"; import { Text } from "preact-i18n"; -import { useContext, useMemo } from "preact/hooks"; +import { useMemo } from "preact/hooks"; import { CategoryButton } from "@revoltchat/ui"; @@ -24,11 +24,11 @@ import { isTouchscreenDevice } from "../../lib/isTouchscreenDevice"; import { useApplicationState } from "../../mobx/State"; import { useIntermediate } from "../../context/intermediate/Intermediate"; -import { AppContext } from "../../context/revoltjs/RevoltClient"; import wideSVG from "/assets/wide.svg"; import { PageHeader } from "../../components/ui/Header"; +import { useClient } from "../../controllers/client/ClientController"; const Overlay = styled.div` display: grid; @@ -45,7 +45,7 @@ const Overlay = styled.div` export default observer(() => { const { openScreen } = useIntermediate(); - const client = useContext(AppContext); + const client = useClient(); const state = useApplicationState(); const seasonalTheme = state.settings.get("appearance:seasonal", true); diff --git a/src/pages/invite/Invite.tsx b/src/pages/invite/Invite.tsx index c92ae072..d7307efb 100644 --- a/src/pages/invite/Invite.tsx +++ b/src/pages/invite/Invite.tsx @@ -4,7 +4,7 @@ import { API } from "revolt.js"; import styles from "./Invite.module.scss"; import { Text } from "preact-i18n"; -import { useContext, useEffect, useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { Button, Category, Error, Preloader } from "@revoltchat/ui"; @@ -12,24 +12,23 @@ import { TextReact } from "../../lib/i18n"; import { useApplicationState } from "../../mobx/State"; -import RequiresOnline from "../../context/revoltjs/RequiresOnline"; -import { - AppContext, - ClientStatus, - StatusContext, -} from "../../context/revoltjs/RevoltClient"; import { takeError } from "../../context/revoltjs/util"; import ServerIcon from "../../components/common/ServerIcon"; import UserIcon from "../../components/common/user/UserIcon"; +import { + useClient, + useSession, +} from "../../controllers/client/ClientController"; +import RequiresOnline from "../../controllers/client/jsx/RequiresOnline"; export default function Invite() { const history = useHistory(); - const client = useContext(AppContext); + const session = useSession(); + const client = useClient(); const layout = useApplicationState().layout; - const status = useContext(StatusContext); const { code } = useParams<{ code: string }>(); const [processing, setProcessing] = useState(false); const [error, setError] = useState(undefined); @@ -44,7 +43,7 @@ export default function Invite() { .then((data) => setInvite(data)) .catch((err) => setError(takeError(err))); } - }, [client, code, invite, status]); + }, [code, invite]); if (code === undefined) return ; @@ -153,7 +152,7 @@ export default function Invite() {
{/*

Badges

diff --git a/src/pages/settings/panes/Sessions.tsx b/src/pages/settings/panes/Sessions.tsx index 73e39bc3..d3f6e13c 100644 --- a/src/pages/settings/panes/Sessions.tsx +++ b/src/pages/settings/panes/Sessions.tsx @@ -16,7 +16,7 @@ import { decodeTime } from "ulid"; import styles from "./Panes.module.scss"; import { Text } from "preact-i18n"; -import { useContext, useEffect, useState } from "preact/hooks"; +import { useEffect, useState } from "preact/hooks"; import { Button, @@ -27,13 +27,14 @@ import { } from "@revoltchat/ui"; import { dayjs } from "../../../context/Locale"; -import { modalController } from "../../../context/modals"; -import { AppContext } from "../../../context/revoltjs/RevoltClient"; + +import { useClient } from "../../../controllers/client/ClientController"; +import { modalController } from "../../../controllers/modals/ModalController"; dayjs.extend(relativeTime); export function Sessions() { - const client = useContext(AppContext); + const client = useClient(); const deviceId = typeof client.session === "object" ? client.session._id : undefined; diff --git a/src/types/revolt-api.d.ts b/src/types/revolt-api.d.ts index ae1bb349..577b1b52 100644 --- a/src/types/revolt-api.d.ts +++ b/src/types/revolt-api.d.ts @@ -5,3 +5,5 @@ declare type Session = { name: string; user_id: string; }; + +declare type SessionPrivate = Session; diff --git a/src/updateWorker.ts b/src/updateWorker.ts index 770a2a90..5043ef90 100644 --- a/src/updateWorker.ts +++ b/src/updateWorker.ts @@ -7,8 +7,7 @@ import { useEffect, useState } from "preact/hooks"; import { internalEmit, internalSubscribe } from "./lib/eventEmitter"; -import { modalController } from "./context/modals"; - +import { modalController } from "./controllers/modals/ModalController"; import { APP_VERSION } from "./version"; const INTERVAL_HOUR = 36e5; diff --git a/yarn.lock b/yarn.lock index 9dda2751..bb225a9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -3572,6 +3572,7 @@ __metadata: eslint: ^7.28.0 eslint-config-preact: ^1.1.4 eslint-plugin-jsdoc: ^39.3.2 + eslint-plugin-mobx: ^0.0.8 eventemitter3: ^4.0.7 fs-extra: ^10.0.0 history: 4 @@ -4375,6 +4376,15 @@ __metadata: languageName: node linkType: hard +"eslint-plugin-mobx@npm:^0.0.8": + version: 0.0.8 + resolution: "eslint-plugin-mobx@npm:0.0.8" + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0 + checksum: c4d9977ccbde2041ad4fec405b36763cd0bc738c7cc4bde6054b560d4b0d3929371927fedf5a37f9b039c712a382f62455931ca255a142fd9089ce8b53d0d66b + languageName: node + linkType: hard + "eslint-plugin-react-hooks@npm:^4.2.0": version: 4.2.0 resolution: "eslint-plugin-react-hooks@npm:4.2.0"