chat initial (related #6)

This commit is contained in:
Grant 2024-05-23 14:58:58 -06:00
parent 5ba2eb0888
commit 732feacd5b
10 changed files with 342 additions and 8 deletions

View File

@ -1,22 +1,128 @@
import { Header } from "./Header"; import { Header } from "./Header";
import { AppContext } from "../contexts/AppContext"; import { AppContext, useAppContext } from "../contexts/AppContext";
import { CanvasWrapper } from "./CanvasWrapper"; import { CanvasWrapper } from "./CanvasWrapper";
import { TemplateContext } from "../contexts/TemplateContext"; import { TemplateContext } from "../contexts/TemplateContext";
import { SettingsSidebar } from "./Settings/SettingsSidebar"; import { SettingsSidebar } from "./Settings/SettingsSidebar";
import { DebugModal } from "./Debug/DebugModal"; import { DebugModal } from "./Debug/DebugModal";
import { ToolbarWrapper } from "./Toolbar/ToolbarWrapper"; 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 <React.Suspense>{loadChat && <Chat />}</React.Suspense>;
};
const App = () => { 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 ( return (
<AppContext> <AppContext>
<ChatContext>
<TemplateContext> <TemplateContext>
<Header /> <Header />
<CanvasWrapper /> <CanvasWrapper />
<ToolbarWrapper /> <ToolbarWrapper />
{/* <DynamicallyLoadChat /> */}
<DebugModal /> <DebugModal />
<SettingsSidebar /> <SettingsSidebar />
</TemplateContext> </TemplateContext>
</ChatContext>
</AppContext> </AppContext>
); );
}; };

View File

@ -0,0 +1,13 @@
import { useEffect, useRef, useState } from "react";
const Chat = () => {
const ref = useRef<HTMLDivElement | null>(null);
return (
<div ref={ref} style={{ position: "fixed", top: 0, left: 0, zIndex: 999 }}>
chat
</div>
);
};
export default Chat;

View File

@ -0,0 +1,22 @@
import { Button } from "@nextui-org/react";
import { useChatContext } from "../../contexts/ChatContext";
const InnerChatSettings = () => {
const { user, doLogin, doLogout } = useChatContext();
return (
<>
{!user && <Button onClick={doLogin}>Login</Button>}
{user && (
<>
<div className="flex gap-1">
<div className="flex-grow">{user.userId}</div>
<Button onClick={doLogout}>Logout</Button>
</div>
</>
)}
</>
);
};
export default InnerChatSettings;

View File

@ -0,0 +1,19 @@
import { Badge, Button } from "@nextui-org/react";
import { useChatContext } from "../../contexts/ChatContext";
const OpenChatButton = () => {
const { notificationCount } = useChatContext();
return (
<Badge
content={notificationCount}
isInvisible={notificationCount === 0}
color="danger"
size="sm"
>
<Button>Chat</Button>
</Badge>
);
};
export default OpenChatButton;

View File

@ -2,6 +2,15 @@ import { Button } from "@nextui-org/react";
import { useAppContext } from "../contexts/AppContext"; import { useAppContext } from "../contexts/AppContext";
import { User } from "./Header/User"; import { User } from "./Header/User";
import { Debug } from "@sc07-canvas/lib/src/debug"; 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 <React.Suspense>{loadChat && <OpenChatButton />}</React.Suspense>;
};
export const Header = () => { export const Header = () => {
const { setSettingsSidebar } = useAppContext(); const { setSettingsSidebar } = useAppContext();
@ -14,6 +23,7 @@ export const Header = () => {
<User /> <User />
<Button onClick={() => setSettingsSidebar(true)}>Settings</Button> <Button onClick={() => setSettingsSidebar(true)}>Settings</Button>
<Button onClick={() => Debug.openDebugTools()}>debug</Button> <Button onClick={() => Debug.openDebugTools()}>debug</Button>
<DynamicChat />
</div> </div>
</header> </header>
); );

View File

@ -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 (
<>
<header>
<Switch size="sm" isSelected={loadChat} onValueChange={setLoadChat} />
<h2>Chat</h2>
</header>
<section>
<React.Suspense>{loadChat && <InnerChatSettings />}</React.Suspense>
</section>
</>
);
};

View File

@ -3,6 +3,7 @@ import { useAppContext } from "../../contexts/AppContext";
import { TemplateSettings } from "./TemplateSettings"; import { TemplateSettings } from "./TemplateSettings";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark"; import { faXmark } from "@fortawesome/free-solid-svg-icons/faXmark";
import { ChatSettings } from "./ChatSettings";
export const SettingsSidebar = () => { export const SettingsSidebar = () => {
const { settingsSidebar, setSettingsSidebar } = useAppContext(); const { settingsSidebar, setSettingsSidebar } = useAppContext();
@ -21,6 +22,7 @@ export const SettingsSidebar = () => {
</header> </header>
<section>abc</section> <section>abc</section>
<TemplateSettings /> <TemplateSettings />
<ChatSettings />
</div> </div>
); );
}; };

View File

@ -24,6 +24,9 @@ export const AppContext = ({ children }: PropsWithChildren) => {
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>(); const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
const [cursorPosition, setCursorPosition] = useState<IPosition>(); const [cursorPosition, setCursorPosition] = useState<IPosition>();
// --- settings ---
const [loadChat, _setLoadChat] = useState(false);
const [pixels, setPixels] = useState({ available: 0 }); const [pixels, setPixels] = useState({ available: 0 });
const [undo, setUndo] = useState<{ available: true; expireAt: number }>(); const [undo, setUndo] = useState<{ available: true; expireAt: number }>();
@ -31,6 +34,10 @@ export const AppContext = ({ children }: PropsWithChildren) => {
const [settingsSidebar, setSettingsSidebar] = useState(false); const [settingsSidebar, setSettingsSidebar] = useState(false);
useEffect(() => { useEffect(() => {
function loadSettings() {
setLoadChat(localStorage.getItem("matrix.enable") === "true");
}
function handleConfig(config: ClientConfig) { function handleConfig(config: ClientConfig) {
console.info("Server sent config", config); console.info("Server sent config", config);
setConfig(config); setConfig(config);
@ -62,6 +69,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
Network.socket.connect(); Network.socket.connect();
loadSettings();
return () => { return () => {
Network.off("user", handleUser); Network.off("user", handleUser);
Network.off("config", handleConfig); 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 ( return (
<appContext.Provider <appContext.Provider
value={{ value={{
@ -82,6 +96,8 @@ export const AppContext = ({ children }: PropsWithChildren) => {
settingsSidebar, settingsSidebar,
setSettingsSidebar, setSettingsSidebar,
undo, undo,
loadChat,
setLoadChat,
}} }}
> >
{config ? children : "Loading..."} {config ? children : "Loading..."}

View File

@ -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<void>;
}
const chatContext = createContext<IChatContext>({} as any);
export const useChatContext = () => useContext(chatContext);
export const ChatContext = ({ children }: PropsWithChildren) => {
const checkInterval = useRef<ReturnType<typeof setInterval>>();
const checkNotifs = useRef<ReturnType<typeof setInterval>>();
const [user, setUser] = useState<IMatrixUser>();
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 (
<chatContext.Provider
value={{ user, notificationCount: notifs, doLogin, doLogout }}
>
{children}
</chatContext.Provider>
);
};

View File

@ -2,6 +2,7 @@
interface ImportMetaEnv { interface ImportMetaEnv {
readonly VITE_API_HOST: string; readonly VITE_API_HOST: string;
readonly VITE_MATRIX_HOST: string;
} }
interface ImportMeta { interface ImportMeta {