diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx index e162521..4a7d188 100644 --- a/packages/client/src/components/App.tsx +++ b/packages/client/src/components/App.tsx @@ -1,22 +1,128 @@ import { Header } from "./Header"; -import { AppContext } from "../contexts/AppContext"; +import { AppContext, useAppContext } from "../contexts/AppContext"; import { CanvasWrapper } from "./CanvasWrapper"; import { TemplateContext } from "../contexts/TemplateContext"; import { SettingsSidebar } from "./Settings/SettingsSidebar"; import { DebugModal } from "./Debug/DebugModal"; import { ToolbarWrapper } from "./Toolbar/ToolbarWrapper"; +import React, { lazy, useEffect } from "react"; +import { ChatContext } from "../contexts/ChatContext"; + +const Chat = lazy(() => import("./Chat/Chat")); + +const DynamicallyLoadChat = () => { + const { loadChat } = useAppContext(); + + return {loadChat && }; +}; const App = () => { + useEffect(() => { + // detect auth callback for chat, regardless of it being loaded + // callback token expires quickly, so we should exchange it as quick as possible + (async () => { + const params = new URLSearchParams(window.location.search); + if (params.has("loginToken")) { + // login button opens a new tab that redirects here + // if we're that tab, we should try to close this tab when we're done + // should work because this tab is opened by JS + const shouldCloseWindow = + window.location.pathname.startsWith("/chat_callback"); + + // token provided by matrix's /sso/redirect + const token = params.get("loginToken")!; + + // immediately remove from url to prevent reloading + window.history.replaceState({}, "", "/"); + + const loginReq = await fetch( + `https://${import.meta.env.VITE_MATRIX_HOST}/_matrix/client/v3/login`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + type: "m.login.token", + token, + }), + } + ); + + const loginRes = await loginReq.json(); + + console.log("[Chat] Matrix login", loginReq.status); + + switch (loginReq.status) { + case 200: { + // success + console.log("[Chat] Logged in successfully", loginRes); + + localStorage.setItem( + "matrix.access_token", + loginRes.access_token + "" + ); + localStorage.setItem("matrix.device_id", loginRes.device_id + ""); + localStorage.setItem("matrix.user_id", loginRes.user_id + ""); + + if (shouldCloseWindow) { + console.log( + "[Chat] Path matches autoclose, attempting to close window..." + ); + window.close(); + alert("You can close this window and return to the other tab :)"); + } else { + console.log( + "[Chat] Path doesn't match autoclose, not doing anything" + ); + } + break; + } + case 400: + case 403: + console.log("[Chat] Matrix login", loginRes); + alert( + "[Chat] Failed to login\n" + + loginRes.errcode + + " " + + loginRes.error + ); + break; + case 429: + alert( + "[Chat] Failed to login, ratelimited.\nTry again in " + + Math.floor(loginRes.retry_after_ms / 1000) + + "s\n" + + loginRes.errcode + + " " + + loginRes.error + ); + break; + default: + alert( + "Error " + + loginReq.status + + " returned when trying to login to chat" + ); + } + } + })(); + }, []); + return ( - -
- - + + +
+ + - - - + {/* */} + + + + + ); }; diff --git a/packages/client/src/components/Chat/Chat.tsx b/packages/client/src/components/Chat/Chat.tsx new file mode 100644 index 0000000..d270935 --- /dev/null +++ b/packages/client/src/components/Chat/Chat.tsx @@ -0,0 +1,13 @@ +import { useEffect, useRef, useState } from "react"; + +const Chat = () => { + const ref = useRef(null); + + return ( +
+ chat +
+ ); +}; + +export default Chat; diff --git a/packages/client/src/components/Chat/InnerChatSettings.tsx b/packages/client/src/components/Chat/InnerChatSettings.tsx new file mode 100644 index 0000000..090f6e4 --- /dev/null +++ b/packages/client/src/components/Chat/InnerChatSettings.tsx @@ -0,0 +1,22 @@ +import { Button } from "@nextui-org/react"; +import { useChatContext } from "../../contexts/ChatContext"; + +const InnerChatSettings = () => { + const { user, doLogin, doLogout } = useChatContext(); + + return ( + <> + {!user && } + {user && ( + <> +
+
{user.userId}
+ +
+ + )} + + ); +}; + +export default InnerChatSettings; diff --git a/packages/client/src/components/Chat/OpenChatButton.tsx b/packages/client/src/components/Chat/OpenChatButton.tsx new file mode 100644 index 0000000..0d41b76 --- /dev/null +++ b/packages/client/src/components/Chat/OpenChatButton.tsx @@ -0,0 +1,19 @@ +import { Badge, Button } from "@nextui-org/react"; +import { useChatContext } from "../../contexts/ChatContext"; + +const OpenChatButton = () => { + const { notificationCount } = useChatContext(); + + return ( + + + + ); +}; + +export default OpenChatButton; diff --git a/packages/client/src/components/Header.tsx b/packages/client/src/components/Header.tsx index 2f2788e..aed29a4 100644 --- a/packages/client/src/components/Header.tsx +++ b/packages/client/src/components/Header.tsx @@ -2,6 +2,15 @@ import { Button } from "@nextui-org/react"; import { useAppContext } from "../contexts/AppContext"; import { User } from "./Header/User"; import { Debug } from "@sc07-canvas/lib/src/debug"; +import React, { lazy } from "react"; + +const OpenChatButton = lazy(() => import("./Chat/OpenChatButton")); + +const DynamicChat = () => { + const { loadChat } = useAppContext(); + + return {loadChat && }; +}; export const Header = () => { const { setSettingsSidebar } = useAppContext(); @@ -14,6 +23,7 @@ export const Header = () => { +
); diff --git a/packages/client/src/components/Settings/ChatSettings.tsx b/packages/client/src/components/Settings/ChatSettings.tsx new file mode 100644 index 0000000..da65f53 --- /dev/null +++ b/packages/client/src/components/Settings/ChatSettings.tsx @@ -0,0 +1,21 @@ +import { Switch } from "@nextui-org/react"; +import { useAppContext } from "../../contexts/AppContext"; +import React, { lazy } from "react"; + +const InnerChatSettings = lazy(() => import("../Chat/InnerChatSettings")); + +export const ChatSettings = () => { + const { loadChat, setLoadChat } = useAppContext(); + + return ( + <> +
+ +

Chat

+
+
+ {loadChat && } +
+ + ); +}; diff --git a/packages/client/src/components/Settings/SettingsSidebar.tsx b/packages/client/src/components/Settings/SettingsSidebar.tsx index 09cdf3a..adbfd76 100644 --- a/packages/client/src/components/Settings/SettingsSidebar.tsx +++ b/packages/client/src/components/Settings/SettingsSidebar.tsx @@ -3,6 +3,7 @@ import { useAppContext } from "../../contexts/AppContext"; import { TemplateSettings } from "./TemplateSettings"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark"; +import { ChatSettings } from "./ChatSettings"; export const SettingsSidebar = () => { const { settingsSidebar, setSettingsSidebar } = useAppContext(); @@ -21,6 +22,7 @@ export const SettingsSidebar = () => {
abc
+ ); }; diff --git a/packages/client/src/contexts/AppContext.tsx b/packages/client/src/contexts/AppContext.tsx index 1f4b590..e14b9c5 100644 --- a/packages/client/src/contexts/AppContext.tsx +++ b/packages/client/src/contexts/AppContext.tsx @@ -24,6 +24,9 @@ export const AppContext = ({ children }: PropsWithChildren) => { const [canvasPosition, setCanvasPosition] = useState(); const [cursorPosition, setCursorPosition] = useState(); + // --- settings --- + const [loadChat, _setLoadChat] = useState(false); + const [pixels, setPixels] = useState({ available: 0 }); const [undo, setUndo] = useState<{ available: true; expireAt: number }>(); @@ -31,6 +34,10 @@ export const AppContext = ({ children }: PropsWithChildren) => { const [settingsSidebar, setSettingsSidebar] = useState(false); useEffect(() => { + function loadSettings() { + setLoadChat(localStorage.getItem("matrix.enable") === "true"); + } + function handleConfig(config: ClientConfig) { console.info("Server sent config", config); setConfig(config); @@ -62,6 +69,8 @@ export const AppContext = ({ children }: PropsWithChildren) => { Network.socket.connect(); + loadSettings(); + return () => { Network.off("user", handleUser); Network.off("config", handleConfig); @@ -69,6 +78,11 @@ export const AppContext = ({ children }: PropsWithChildren) => { }; }, []); + const setLoadChat = (v: boolean) => { + _setLoadChat(v); + localStorage.setItem("matrix.enable", v ? "true" : "false"); + }; + return ( { settingsSidebar, setSettingsSidebar, undo, + loadChat, + setLoadChat, }} > {config ? children : "Loading..."} diff --git a/packages/client/src/contexts/ChatContext.tsx b/packages/client/src/contexts/ChatContext.tsx new file mode 100644 index 0000000..9e5adcf --- /dev/null +++ b/packages/client/src/contexts/ChatContext.tsx @@ -0,0 +1,124 @@ +import { + PropsWithChildren, + createContext, + useContext, + useEffect, + useRef, + useState, +} from "react"; + +interface IMatrixUser { + userId: string; +} + +export interface IChatContext { + user?: IMatrixUser; + notificationCount: number; + + doLogin: () => void; + doLogout: () => Promise; +} + +const chatContext = createContext({} as any); + +export const useChatContext = () => useContext(chatContext); + +export const ChatContext = ({ children }: PropsWithChildren) => { + const checkInterval = useRef>(); + const checkNotifs = useRef>(); + + const [user, setUser] = useState(); + const [notifs, setNotifs] = useState(0); + + const doLogin = () => { + const redirectUrl = + window.location.protocol + "//" + window.location.host + "/chat_callback"; + + window.addEventListener("focus", handleWindowFocus); + checkInterval.current = setInterval(checkForAccessToken, 500); + + window.open( + `https://${import.meta.env.VITE_MATRIX_HOST}/_matrix/client/v3/login/sso/redirect?redirectUrl=${encodeURIComponent(redirectUrl)}`, + "_blank" + ); + }; + + const doLogout = async () => { + await fetch( + `https://${import.meta.env.VITE_MATRIX_HOST}/_matrix/client/v3/logout`, + { + method: "POST", + headers: { + Authorization: + "Bearer " + localStorage.getItem("matrix.access_token"), + }, + } + ); + + localStorage.removeItem("matrix.access_token"); + localStorage.removeItem("matrix.device_id"); + localStorage.removeItem("matrix.user_id"); + setUser(undefined); + }; + + useEffect(() => { + checkForAccessToken(); + checkNotifs.current = setInterval(checkForNotifs, 1000); + + return () => { + window.removeEventListener("focus", handleWindowFocus); + if (checkInterval.current) clearInterval(checkInterval.current); + if (checkNotifs.current) clearInterval(checkNotifs.current); + }; + }, []); + + const handleWindowFocus = () => { + console.log("[Chat] Window has gained focus"); + + checkForAccessToken(); + }; + + const checkForAccessToken = () => { + const accessToken = localStorage.getItem("matrix.access_token"); + const deviceId = localStorage.getItem("matrix.device_id"); + const userId = localStorage.getItem("matrix.user_id"); + + if (!accessToken || !deviceId || !userId) return; + + // access token acquired + + window.removeEventListener("focus", handleWindowFocus); + if (checkInterval.current) clearInterval(checkInterval.current); + + console.log("[Chat] access token has been acquired"); + setUser({ userId }); + }; + + const checkForNotifs = async () => { + const accessToken = localStorage.getItem("matrix.access_token"); + if (!accessToken) return; + + const notifReq = await fetch( + `https://${import.meta.env.VITE_MATRIX_HOST}/_matrix/client/v3/notifications?limit=10`, + { + headers: { + Authorization: "Bearer " + accessToken, + }, + } + ); + + const notifRes = await notifReq.json(); + + const notificationCount = + notifRes?.notifications?.filter((n: any) => !n.read).length || 0; + setNotifs(notificationCount); + }; + + return ( + + {children} + + ); +}; diff --git a/packages/client/src/vite-env.d.ts b/packages/client/src/vite-env.d.ts index 58fa559..ada34ba 100644 --- a/packages/client/src/vite-env.d.ts +++ b/packages/client/src/vite-env.d.ts @@ -2,6 +2,7 @@ interface ImportMetaEnv { readonly VITE_API_HOST: string; + readonly VITE_MATRIX_HOST: string; } interface ImportMeta {