Skip to content
import { faMessage, faWarning } from "@fortawesome/free-solid-svg-icons";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { Button, Link, Spinner, User } from "@nextui-org/react";
import { ClientConfig } from "@sc07-canvas/lib/src/net";
import { MouseEvent, useEffect, useMemo, useState } from "react";
import { toast } from "react-toastify";
import { useAppContext } from "../../contexts/AppContext";
export interface IUser {
sub: string;
display_name?: string;
picture_url?: string;
profile_url?: string;
isAdmin: boolean;
isModerator: boolean;
}
const getMatrixLink = (user: IUser, config: ClientConfig) => {
return `${config.chat.element_host}/#/user/@${user.sub.replace("@", "=40")}:${config.chat.matrix_homeserver}`;
};
/**
* Small UserCard that shows profile picture, display name, full username, message box and (currently unused) view profile button
* @param param0
* @returns
*/
export const UserCard = ({ user }: { user: IUser }) => {
const { config, setProfile } = useAppContext();
const [messageStatus, setMessageStatus] = useState<
"loading" | "no_account" | "has_account" | "error"
>("loading");
useEffect(() => {
if (!config) {
console.warn("[UserCard] config is not available yet");
return;
}
setMessageStatus("loading");
fetch(
`https://${config.chat.matrix_homeserver}/_matrix/client/v3/profile/${encodeURIComponent(`@${user.sub.replace("@", "=40")}:${config.chat.matrix_homeserver}`)}`
)
.then((req) => {
if (req.status === 200) {
setMessageStatus("has_account");
} else {
setMessageStatus("no_account");
}
})
.catch((e) => {
console.error(
"Error while getting Matrix account details for " + user.sub,
e
);
setMessageStatus("error");
toast.error(
"Error while getting Matrix account details for " + user.sub
);
});
}, [user, config]);
const handleMatrixClick = (e: MouseEvent) => {
if (messageStatus === "no_account") {
e.preventDefault();
toast.info("This user has not setup chat yet, you cannot message them");
}
};
const openProfile = () => {
setProfile(user.sub);
};
const name = useMemo(() => {
if (!user || !user.sub) {
return "Unknown";
}
const regex = /^(.*)@/;
const match = user.sub.match(regex);
if (match) {
return match[1];
}
return "Unknown";
}, [user]);
return (
<div className="flex flex-col gap-1">
<div className="flex flex-row space-between p-2">
<User
name={user?.display_name || name}
description={user?.sub || "Unknown"}
avatarProps={{
showFallback: true,
name: undefined,
src: user?.picture_url,
}}
/>
<div className="ml-auto">
{config && (
<Button
isIconOnly
as={Link}
href={getMatrixLink(user, config)}
target="_blank"
onClick={handleMatrixClick}
>
{messageStatus === "loading" ? (
<Spinner />
) : (
<FontAwesomeIcon
icon={messageStatus === "error" ? faWarning : faMessage}
color="inherit"
/>
)}
</Button>
)}
</div>
</div>
<Button size="sm" onPress={openProfile}>
View Profile
</Button>
</div>
);
};
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 (
<div className="flex flex-col p-2">
<header className="flex flex-col gap-2">
<div className="flex items-center">
<Switch
size="sm"
isSelected={loadChat || false}
onValueChange={setLoadChat}
/>
<h2 className="text-xl">Chat</h2>
</div>
<p className="text-default-600 text-xs">Chatting with other canvas users</p>
</header>
<section>
<React.Suspense>{loadChat &&
<div className="mt-4">
<InnerChatSettings />
</div>
}</React.Suspense>
</section>
</div>
);
};
import { faGear } from "@fortawesome/free-solid-svg-icons";
import { useAppContext } from "../../contexts/AppContext";
import { SidebarBase } from "../SidebarBase";
import { Button, Divider } from "@nextui-org/react";
import { TemplateSettings } from "./TemplateSettings";
import { ChatSettings } from "./ChatSettings";
import { OverlaySettings } from "../Overlay/OverlaySettings";
export const SettingsSidebar = () => {
const { settingsSidebar, setSettingsSidebar, setShowKeybinds } = useAppContext();
return (
<SidebarBase shown={settingsSidebar} setSidebarShown={setSettingsSidebar} icon={faGear} title="Settings" description="Configuration options for customizing your experience" side="Right">
<div className="p-4 flex flex-col gap-4">
<TemplateSettings />
<Divider />
<ChatSettings />
<Divider />
<OverlaySettings />
<Divider />
<section>
<Button
onPress={() => {
setShowKeybinds(true);
setSettingsSidebar(false);
}}
>
Keybinds
</Button>
</section>
</div>
</SidebarBase>
)
};
\ No newline at end of file
import { useTemplateContext } from "../../contexts/TemplateContext";
import { Input, Select, SelectItem, Slider, Switch } from "@nextui-org/react";
export const TemplateSettings = () => {
const {
enable,
setEnable,
url,
setURL,
width,
setWidth,
x,
setX,
y,
setY,
opacity,
setOpacity,
style,
setStyle,
showMobileTools,
setShowMobileTools,
} = useTemplateContext();
return (
<div className="flex flex-col p-2">
<header className="flex flex-col gap-2">
<div className="flex items-center">
<Switch
size="sm"
isSelected={enable || false}
onValueChange={setEnable}
/>
<h2 className="text-xl">Template</h2>
</div>
<p className="text-default-600 text-xs">Displaying an image over the canvas to help guide placing</p>
</header>
{enable && <section className="flex flex-col gap-2 mt-4">
<Input
label="Template URL"
size="sm"
value={url || ""}
onValueChange={setURL}
/>
<Input
label="Template Width"
size="sm"
type="number"
min="1"
max={10_000}
value={width?.toString() || ""}
onValueChange={(v) => setWidth(parseInt(v))}
/>
<div className="flex flex-row gap-1">
<Input
label="Template X"
size="sm"
type="number"
value={x?.toString() || ""}
onValueChange={(v) => setX(parseInt(v))}
/>
<Input
label="Template Y"
size="sm"
type="number"
value={y?.toString() || ""}
onValueChange={(v) => setY(parseInt(v))}
/>
</div>
<Slider
label="Template Opacity"
step={1}
minValue={0}
maxValue={100}
value={opacity ?? 100}
onChange={(v) => setOpacity(v as number)}
getValue={(v) => v + "%"}
/>
<Select
label="Template Style"
size="sm"
selectedKeys={[style]}
onChange={(e) => setStyle(e.target.value as any)}
>
<SelectItem key="SOURCE">Source</SelectItem>
<SelectItem key="ONE_TO_ONE">One-to-one</SelectItem>
<SelectItem key="ONE_TO_ONE_INCORRECT">
One-to-one (keep incorrect)
</SelectItem>
<SelectItem key="DOTTED_SMALL">Dotted Small</SelectItem>
<SelectItem key="DOTTED_BIG">Dotted Big</SelectItem>
<SelectItem key="SYMBOLS">Symbols</SelectItem>
<SelectItem key="NUMBERS">Numbers</SelectItem>
</Select>
{style !== "ONE_TO_ONE" && (
<div>
<b>Warning:</b> Template color picking only
<br />
works with one-to-one template style
</div>
)}
<Switch
className="md:hidden"
isSelected={showMobileTools}
onValueChange={setShowMobileTools}
>
Show Mobile Tools
</Switch>
</section>}
</div>
);
};
import { motion } from "framer-motion";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconProp } from "@fortawesome/fontawesome-svg-core";
import { Button, Divider } from "@nextui-org/react";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { JSX } from "react";
/**
* Information sidebar
*
* TODO: add customization for this post-event (#46)
*
* @returns
*/
export const SidebarBase = ({
children,
shown,
icon,
setSidebarShown,
title,
description,
side,
}: {
children: string | JSX.Element | JSX.Element[];
icon: IconProp;
shown: boolean;
setSidebarShown: (value: boolean) => void;
title: string;
description: string;
side: "Left" | "Right";
}) => {
return (
<div>
<motion.div
className={`absolute w-screen h-screen z-50 left-0 top-0 bg-black pointer-events-none`}
initial={{ opacity: 0, visibility: "hidden" }}
animate={{
opacity: shown ? 0.25 : 0,
visibility: shown ? "visible" : "hidden",
}}
transition={{ type: "spring", stiffness: 50 }}
/>
<motion.div
className={`min-w-[20rem] max-w-[75vw] md:max-w-[30vw] bg-white dark:bg-black flex flex-col justify-between fixed ${side === "Left" ? "left-0" : "right-0"} h-full shadow-xl overflow-y-auto z-50 top-0`}
initial={{ x: side === "Left" ? "-150%" : "150%" }}
animate={{
x: shown
? side === "Left"
? "-50%"
: "50%"
: side === "Left"
? "-150%"
: "150%",
}}
transition={{ type: "spring", stiffness: 50 }}
/>
<motion.div
className={`min-w-[20rem] max-w-[75vw] md:max-w-[30vw] bg-white dark:bg-black text-black dark:text-white flex flex-col fixed ${side === "Left" ? "left-0" : "right-0"} h-full shadow-xl overflow-y-auto z-50 top-0`}
initial={{ x: side === "Left" ? "-100%" : "100%" }}
animate={{ x: shown ? 0 : side === "Left" ? "-100%" : "100%" }}
transition={{ type: "spring", stiffness: 50 }}
>
<header className="flex p-4 justify-between items-center">
<div>
<div className="flex items-center gap-2">
<FontAwesomeIcon icon={icon} size="lg" />
<div>
<h1 className="text-xl">{title}</h1>
<p className="text-xs text-default-600">{description}</p>
</div>
</div>
</div>
<Button
size="sm"
isIconOnly
onClick={() => setSidebarShown(false)}
variant="solid"
className="ml-4"
>
<FontAwesomeIcon icon={faXmark} />
</Button>
</header>
<Divider />
{children}
</motion.div>
</div>
);
};
import { Switch } from "@nextui-org/react";
import { useTemplateContext } from "../../contexts/TemplateContext";
export const MobileTemplateButtons = () => {
const { enable, setEnable, url } = useTemplateContext();
return (
<div className="md:hidden toolbar-box top-[-10px] right-[10px]">
{url && (
<div className="md:hidden rounded-xl bg-gray-300 p-2">
<Switch isSelected={enable} onValueChange={setEnable}>
Template
</Switch>
</div>
)}
</div>
);
};
#template {
width: 100px;
> * {
width: 100%;
// height: 100%;
position: absolute;
top: 0;
left: 0;
}
}
import { useEffect, useRef } from "react";
import { Template as TemplateCl } from "../../lib/template";
import { useAppContext } from "../../contexts/AppContext";
import { useTemplateContext } from "../../contexts/TemplateContext";
import { Canvas } from "../../lib/canvas";
export const Template = () => {
const { config } = useAppContext();
const { enable, url, width, setWidth, x, y, opacity, setX, setY, style } =
useTemplateContext();
const templateHolder = useRef<HTMLDivElement>(null);
const instance = useRef<TemplateCl>();
useEffect(() => {
if (!templateHolder?.current) {
console.warn("No templateHolder, cannot initialize");
return;
}
const templateHolderRef = templateHolder.current;
instance.current = new TemplateCl(config!, templateHolder.current);
instance.current.on("autoDetectWidth", (width) => {
setWidth(width);
});
let startLocation: { clientX: number; clientY: number } | undefined;
let offset: [x: number, y: number] = [0, 0];
const handleMouseDown = (e: MouseEvent) => {
if (!e.altKey) return;
startLocation = { clientX: e.clientX, clientY: e.clientY };
offset = [e.offsetX, e.offsetY];
Canvas.instance?.getPanZoom().panning.setEnabled(false);
};
const handleMouseMove = (e: MouseEvent) => {
if (!startLocation) return;
if (!Canvas.instance) {
console.warn(
"[Template#handleMouseMove] Canvas.instance is not defined"
);
return;
}
const deltaX = e.clientX - startLocation.clientX;
const deltaY = e.clientY - startLocation.clientY;
const newX = startLocation.clientX + deltaX;
const newY = startLocation.clientY + deltaY;
const [canvasX, canvasY] = Canvas.instance.screenToPos(newX, newY);
templateHolderRef.style.setProperty("left", canvasX - offset[0] + "px");
templateHolderRef.style.setProperty("top", canvasY - offset[1] + "px");
};
const handleMouseUp = (_e: MouseEvent) => {
startLocation = undefined;
Canvas.instance?.getPanZoom().panning.setEnabled(true);
const x = parseInt(
templateHolderRef.style.getPropertyValue("left").replace("px", "") ||
"0"
);
const y = parseInt(
templateHolderRef.style.getPropertyValue("top").replace("px", "") || "0"
);
setX(x);
setY(y);
};
templateHolder.current.addEventListener("mousedown", handleMouseDown);
document.addEventListener("mousemove", handleMouseMove);
document.addEventListener("mouseup", handleMouseUp);
return () => {
instance.current?.destroy();
templateHolderRef?.removeEventListener("mousedown", handleMouseDown);
document.removeEventListener("mousemove", handleMouseMove);
document.removeEventListener("mouseup", handleMouseUp);
};
}, []);
useEffect(() => {
if (!instance.current) {
console.warn(
"[Template] Received template enable but no instance exists"
);
return;
}
instance.current.setOption("enable", enable);
if (enable && url) {
instance.current.loadImage(url).then(() => {
console.log("[Template] enable: load image finished");
});
}
}, [enable]);
useEffect(() => {
if (!instance.current) {
console.warn(
"[Template] Recieved template url update but no template instance exists"
);
return;
}
if (!url) {
console.warn("[Template] Received template url blank");
return;
}
if (!enable) {
console.info("[Template] Got template URL but not enabled, ignoring");
return;
}
instance.current.loadImage(url).then(() => {
console.log("[Template] Template loader finished");
});
}, [url]);
useEffect(() => {
if (!instance.current) {
console.warn("[Template] Received template width with no instance");
return;
}
instance.current.setOption("width", width);
instance.current.rasterizeTemplate();
}, [width]);
useEffect(() => {
if (!instance.current) {
console.warn("[Template] Received style update with no instance");
return;
}
instance.current.setOption("style", style);
}, [style]);
return (
<div
id="template"
className="board-overlay"
ref={templateHolder}
style={{
top: y,
left: x,
opacity: opacity / 100,
}}
></div>
);
};
import { useTheme } from "next-themes";
import { ToastContainer } from "react-toastify";
export const ToastWrapper = () => {
const { theme } = useTheme()
return (
<ToastContainer
position="top-left"
theme={theme}
/>
);
};
\ No newline at end of file
......@@ -6,11 +6,11 @@ import {
useDisclosure,
} from "@nextui-org/react";
import { CanvasLib } from "@sc07-canvas/lib/src/canvas";
import { useAppContext } from "../contexts/AppContext";
import { Canvas } from "../lib/canvas";
import { useAppContext } from "../../contexts/AppContext";
import { Canvas } from "../../lib/canvas";
import { useEffect, useState } from "react";
import { ClientConfig } from "@sc07-canvas/lib/src/net";
import network from "../lib/network";
import network from "../../lib/network";
const getTimeLeft = (pixels: { available: number }, config: ClientConfig) => {
// this implementation matches the server's implementation
......@@ -18,6 +18,7 @@ const getTimeLeft = (pixels: { available: number }, config: ClientConfig) => {
const cooldown = CanvasLib.getPixelCooldown(pixels.available + 1, config);
const pixelExpiresAt =
Canvas.instance?.lastPlace && Canvas.instance.lastPlace + cooldown * 1000;
const pixelCooldown = pixelExpiresAt && (Date.now() - pixelExpiresAt) / 1000;
if (!pixelCooldown) return undefined;
......@@ -27,7 +28,7 @@ const getTimeLeft = (pixels: { available: number }, config: ClientConfig) => {
};
const PlaceCountdown = () => {
const { pixels, config } = useAppContext();
const { pixels, config } = useAppContext<true>();
const [timeLeft, setTimeLeft] = useState(getTimeLeft(pixels, config));
useEffect(() => {
......@@ -43,7 +44,7 @@ const PlaceCountdown = () => {
return (
<>
{timeLeft
? pixels.available + 1 < config.canvas.pixel.maxStack && timeLeft + "s"
? pixels.available < config.canvas.pixel.maxStack && timeLeft + "s"
: ""}
</>
);
......@@ -57,7 +58,7 @@ const OnlineCount = () => {
setOnline(count);
}
network.waitFor("online").then(([count]) => setOnline(count));
network.waitForState("online").then(([count]) => setOnline(count));
network.on("online", handleOnline);
return () => {
......@@ -69,22 +70,22 @@ const OnlineCount = () => {
};
export const CanvasMeta = () => {
const { canvasPosition, cursorPosition, pixels, config } = useAppContext();
const { canvasPosition, cursor, pixels, config } = useAppContext<true>();
const { isOpen, onOpen, onOpenChange } = useDisclosure();
return (
<>
<div id="canvas-meta">
<div id="canvas-meta" className="toolbar-box">
{canvasPosition && (
<span>
<button className="btn-link" onClick={onOpen}>
({canvasPosition.x}, {canvasPosition.y})
</button>
{cursorPosition && (
{cursor.x !== undefined && cursor.y !== undefined && (
<>
{" "}
<span className="canvas-meta--cursor-pos">
(Cursor: {cursorPosition.x}, {cursorPosition.y})
(Cursor: {cursor.x}, {cursor.y})
</span>
</>
)}
......
#pallete {
#toolbar {
position: fixed;
left: 0;
bottom: 0;
width: 100%;
}
#pallete {
display: flex;
gap: 10px;
padding: 10px;
background-color: #fff;
z-index: 10;
position: relative;
.pallete-colors {
// display: flex;
......@@ -38,7 +40,6 @@
vertical-align: top;
font-size: 2rem;
line-height: 1;
color: #000;
border: 0;
background: transparent;
......@@ -47,7 +48,7 @@
.pallete-color {
width: 36px;
height: 36px;
border: 2px solid #000;
border: 2px solid;
border-radius: 3px;
transition: transform 0.25s;
cursor: pointer;
......
import { useEffect, useState } from "react";
import { useAppContext } from "../contexts/AppContext";
import { Canvas } from "../lib/canvas";
import { IPalleteContext } from "../types";
import { useEffect } from "react";
import { useAppContext } from "../../contexts/AppContext";
import { Canvas } from "../../lib/canvas";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faXmark } from "@fortawesome/free-solid-svg-icons";
import { CanvasMeta } from "./CanvasMeta";
import { KeybindManager } from "../../lib/keybinds";
import { Button, Link } from "@nextui-org/react";
export const Pallete = () => {
const { config, user } = useAppContext();
const [pallete, setPallete] = useState<IPalleteContext>({});
export const Palette = () => {
const { config, user, cursor, setCursor } = useAppContext<true>();
useEffect(() => {
if (!Canvas.instance) return;
Canvas.instance?.updateCursor(cursor.color);
}, [cursor]);
Canvas.instance.updatePallete(pallete);
}, [pallete]);
useEffect(() => {
const handleDeselect = () => {
setCursor((v) => ({
...v,
color: undefined,
}));
};
return (
<div id="pallete">
<CanvasMeta />
KeybindManager.addListener("DESELECT_COLOR", handleDeselect);
return () => {
KeybindManager.removeListener("DESELECT_COLOR", handleDeselect);
};
}, []);
return (
<div id="pallete" className="bg-[#fff] dark:bg-[#000]">
<div className="pallete-colors">
<button
aria-label="Deselect Color"
className="pallete-color--deselect"
title="Deselect Color"
onClick={() => {
setPallete(({ color, ...pallete }) => {
return pallete;
setCursor(({ color, ...cursor }) => {
return cursor;
});
}}
>
......@@ -37,7 +47,7 @@ export const Pallete = () => {
<button
key={color.id}
aria-label={color.name}
className={["pallete-color", color.id === pallete.color && "active"]
className={["pallete-color", color.id === cursor.color && "active"]
.filter((a) => a)
.join(" ")}
style={{
......@@ -45,9 +55,9 @@ export const Pallete = () => {
}}
title={color.name}
onClick={() => {
setPallete((pallete) => {
setCursor((cursor) => {
return {
...pallete,
...cursor,
color: color.id,
};
});
......@@ -58,10 +68,21 @@ export const Pallete = () => {
{!user && (
<div className="pallete-user-overlay">
You are not logged in
<a href="/api/login" className="user-login">
Login
</a>
{import.meta.env.VITE_INCLUDE_EVENT_INFO ? (
<>The event has ended</>
) : (
<div className="flex gap-3 items-center">
You are not logged in
<Button
as={Link}
href="/api/login"
className="user-login"
variant="faded"
>
Login
</Button>
</div>
)}
</div>
)}
</div>
......
import { useAppContext } from "../../contexts/AppContext";
import { useTemplateContext } from "../../contexts/TemplateContext";
import { MobileTemplateButtons } from "../Templating/MobileTemplateButtons";
import { CanvasMeta } from "./CanvasMeta";
import { Palette } from "./Palette";
import { UndoButton } from "./UndoButton";
/**
* Wrapper for everything aligned at the bottom of the screen
*/
export const ToolbarWrapper = () => {
const { config } = useAppContext();
const { showMobileTools } = useTemplateContext();
if (!config) return <></>;
return (
<div id="toolbar">
<CanvasMeta />
<UndoButton />
{showMobileTools && <MobileTemplateButtons />}
<Palette />
</div>
);
};
import { Button } from "@nextui-org/react";
import { useAppContext } from "../../contexts/AppContext";
import network from "../../lib/network";
import { useEffect, useState } from "react";
import { toast } from "react-toastify";
export const UndoButton = () => {
const { undo, config } = useAppContext<true>();
/**
* percentage of time left (0 <= x <= 1)
*/
const [progress, setProgress] = useState(0.5);
useEffect(() => {
if (!undo) {
setProgress(1);
return;
}
const timer = setInterval(() => {
let diff = undo.expireAt - Date.now();
let percentage = diff / config.canvas.undo.grace_period;
setProgress(percentage);
if (percentage <= 0) {
clearInterval(timer);
}
}, 100);
return () => {
clearInterval(timer);
};
}, [undo]);
// ref-ify this?
function execUndo() {
network.socket.emitWithAck("undo").then((data) => {
if (data.success) {
console.log("Undo pixel successful");
} else {
console.log("Undo pixel error", data);
switch (data.error) {
case "pixel_covered":
toast.error("You cannot undo a covered pixel");
break;
case "unavailable":
toast.error("You have no undo available");
break;
default:
toast.error("Undo error: " + data.error);
}
}
});
}
return (
<div
className="absolute z-0"
style={{
top: undo?.available && progress >= 0 ? "-10px" : "100%",
left: "50%",
transform: "translateY(-100%) translateX(-50%)",
transition: "all 0.25s ease-in-out",
}}
>
<Button onPress={execUndo}>
<span className="z-[1]">Undo</span>
<div
className="absolute top-0 left-0 h-full bg-white/50 transition-all"
style={{ width: progress * 100 + "%" }}
></div>
</Button>
</div>
);
};
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
useDisclosure,
} from "@nextui-org/react";
/**
* Welcome popup
*
* TODO: customization post-event (#46)
*
* @returns
*/
export const WelcomeModal = () => {
const { isOpen, onClose } = useDisclosure({
defaultOpen: !localStorage.getItem("hide_welcome"),
});
const handleClose = () => {
localStorage.setItem("hide_welcome", "true");
onClose();
};
return (
<Modal
isOpen={isOpen}
onClose={handleClose}
isDismissable={false}
placement="center"
>
<ModalContent>
{(onClose) => (
<>
<ModalHeader>Welcome</ModalHeader>
<ModalBody>
<h1 className="text-4xl text-center">Welcome to Canvas!</h1>
<p>
Canvas is a collaborative pixel placing event that uses
Fediverse accounts
</p>
<p>More information can be found in the top left</p>
</ModalBody>
<ModalFooter>
<Button onPress={onClose}>Close</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
};
import {
import React, {
PropsWithChildren,
createContext,
useContext,
useEffect,
useState,
} from "react";
import {
AuthSession,
ClientConfig,
IAppContext,
ICanvasPosition,
IPosition,
} from "@sc07-canvas/lib/src/net";
import { AuthSession, ClientConfig } from "@sc07-canvas/lib/src/net";
import Network from "../lib/network";
import { Spinner } from "@nextui-org/react";
import { api } from "../lib/utils";
interface IAppContext {
config?: ClientConfig;
user?: AuthSession;
connected: boolean;
canvasPosition?: ICanvasPosition;
setCanvasPosition: (v: ICanvasPosition) => void;
cursor: ICursor;
setCursor: React.Dispatch<React.SetStateAction<ICursor>>;
pixels: { available: number };
undo?: { available: true; expireAt: number };
loadChat: boolean;
setLoadChat: (v: boolean) => void;
infoSidebar: boolean;
setInfoSidebar: (v: boolean) => void;
settingsSidebar: boolean;
setSettingsSidebar: (v: boolean) => void;
pixelWhois?: { x: number; y: number; surrounding: string[][] };
setPixelWhois: (v: this["pixelWhois"]) => void;
showKeybinds: boolean;
setShowKeybinds: (v: boolean) => void;
blankOverlay: IMapOverlay;
setBlankOverlay: React.Dispatch<React.SetStateAction<IMapOverlay>>;
heatmapOverlay: IMapOverlay;
setHeatmapOverlay: React.Dispatch<React.SetStateAction<IMapOverlay>>;
pixelPulses: boolean;
setPixelPulses: (state: boolean) => void;
profile?: string; // sub
setProfile: (v?: string) => void;
hasAdmin: boolean;
showModModal: boolean;
setShowModModal: React.Dispatch<React.SetStateAction<boolean>>;
}
interface ICanvasPosition {
x: number;
y: number;
zoom: number;
}
interface ICursor {
x?: number;
y?: number;
color?: number;
}
interface IMapOverlay {
enabled: boolean;
/**
* opacity of the overlay
* 0.0 - 1.0
*/
opacity: number;
loading: boolean;
}
const appContext = createContext<IAppContext>({} as any);
export const useAppContext = () => useContext(appContext);
type WithRequiredProperty<Type, Key extends keyof Type> = Type & {
[Property in Key]-?: Type[Property];
};
type AppContext<ConfigExists extends boolean> = ConfigExists extends true
? WithRequiredProperty<IAppContext, "config">
: IAppContext;
/**
* Get app context
*
* @template ConfigExists If the config is already known to be available in this context
* @returns
*/
export const useAppContext = <ConfigExists extends boolean = false>() =>
useContext<AppContext<ConfigExists>>(appContext as any);
// eslint-disable-next-line @typescript-eslint/no-redeclare
export const AppContext = ({ children }: PropsWithChildren) => {
const [config, setConfig] = useState<ClientConfig>(undefined as any);
const [auth, setAuth] = useState<AuthSession>();
const [canvasPosition, setCanvasPosition] = useState<ICanvasPosition>();
const [cursorPosition, setCursorPosition] = useState<IPosition>();
const [cursor, setCursor] = useState<ICursor>({});
const [connected, setConnected] = useState(false);
// --- settings ---
const [loadChat, _setLoadChat] = useState(false);
const [pixels, setPixels] = useState({ available: 0 });
const [undo, setUndo] = useState<{ available: true; expireAt: number }>();
// overlays visible
const [infoSidebar, setInfoSidebar] = useState(false);
const [settingsSidebar, setSettingsSidebar] = useState(false);
const [pixelWhois, setPixelWhois] = useState<{
x: number;
y: number;
surrounding: string[][];
}>();
const [showKeybinds, setShowKeybinds] = useState(false);
const [blankOverlay, setBlankOverlay] = useState<IMapOverlay>({
enabled: false,
opacity: 1,
loading: false,
});
const [heatmapOverlay, setHeatmapOverlay] = useState<IMapOverlay>({
enabled: false,
opacity: 1,
loading: false,
});
const [pixelPulses, setPixelPulses] = useState<boolean>(false);
const [profile, setProfile] = useState<string>();
const [hasAdmin, setHasAdmin] = useState(false);
const [showModModal, setShowModModal] = useState(false);
useEffect(() => {
function loadSettings() {
setLoadChat(
localStorage.getItem("matrix.enable") === null
? true
: localStorage.getItem("matrix.enable") === "true"
);
}
function handleConfig(config: ClientConfig) {
console.info("Server sent config", config);
setConfig(config);
}
......@@ -40,20 +154,60 @@ export const AppContext = ({ children }: PropsWithChildren) => {
setPixels(pixels);
}
function handleUndo(
data: { available: false } | { available: true; expireAt: number }
) {
if (data.available) {
setUndo({ available: true, expireAt: data.expireAt });
} else {
setUndo(undefined);
}
}
function handleConnect() {
setConnected(true);
}
function handleDisconnect() {
setConnected(false);
}
api<{}>("/api/admin/check").then(({ status, data }) => {
if (status === 200) {
if (data.success) {
setHasAdmin(true);
}
}
});
Network.on("user", handleUser);
Network.on("config", handleConfig);
Network.waitFor("pixels").then(([data]) => handlePixels(data));
Network.waitForState("pixels").then(([data]) => handlePixels(data));
Network.on("pixels", handlePixels);
Network.on("undo", handleUndo);
Network.on("connected", handleConnect);
Network.on("disconnected", handleDisconnect);
Network.socket.connect();
loadSettings();
return () => {
Network.off("user", handleUser);
Network.off("config", handleConfig);
Network.off("pixels", handlePixels);
Network.off("undo", handleUndo);
Network.off("connected", handleConnect);
Network.off("disconnected", handleDisconnect);
};
}, []);
const setLoadChat = (v: boolean) => {
_setLoadChat(v);
localStorage.setItem("matrix.enable", v ? "true" : "false");
};
return (
<appContext.Provider
value={{
......@@ -61,12 +215,40 @@ export const AppContext = ({ children }: PropsWithChildren) => {
user: auth,
canvasPosition,
setCanvasPosition,
cursorPosition,
setCursorPosition,
cursor,
setCursor,
pixels,
settingsSidebar,
setSettingsSidebar,
undo,
loadChat,
setLoadChat,
connected,
hasAdmin,
pixelWhois,
setPixelWhois,
showKeybinds,
setShowKeybinds,
blankOverlay,
setBlankOverlay,
heatmapOverlay,
setHeatmapOverlay,
pixelPulses,
setPixelPulses,
profile,
setProfile,
infoSidebar,
setInfoSidebar,
showModModal,
setShowModModal,
}}
>
{config ? children : "Loading..."}
{!config && (
<div className="fixed top-0 left-0 w-full h-full z-[49] backdrop-blur-sm bg-black/30 text-white flex items-center justify-center">
<Spinner label="Loading..." />
</div>
)}
{children}
</appContext.Provider>
);
};
import {
PropsWithChildren,
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { useAppContext } from "./AppContext";
import { toast } from "react-toastify";
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 { config } = useAppContext();
const checkInterval = useRef<ReturnType<typeof setInterval>>();
const checkNotifs = useRef<ReturnType<typeof setInterval>>();
const [user, setUser] = useState<IMatrixUser>();
const [notifs, setNotifs] = useState(0);
const doLogin = () => {
if (!config) {
console.warn("[ChatContext#doLogin] has no config instance");
return;
}
if (user?.userId) {
console.log("[ChatContext#doLogin] user logged in, opening element...");
window.open(config.chat.element_host);
return;
}
const redirectUrl =
window.location.protocol + "//" + window.location.host + "/chat_callback";
window.addEventListener("focus", handleWindowFocus);
checkInterval.current = setInterval(checkForAccessToken, 500);
window.open(
`https://${config.chat.matrix_homeserver}/_matrix/client/v3/login/sso/redirect?redirectUrl=${encodeURIComponent(redirectUrl)}`,
"_blank"
);
};
const doLogout = async () => {
if (!config) {
console.warn("[ChatContext#doLogout] has no config instance");
return;
}
await fetch(
`https://${config.chat.matrix_homeserver}/_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 * 60);
return () => {
window.removeEventListener("focus", handleWindowFocus);
if (checkInterval.current) clearInterval(checkInterval.current);
if (checkNotifs.current) clearInterval(checkNotifs.current);
};
}, [config?.chat]);
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 });
toast.success("Logged into chat");
checkIfInGeneral();
};
const checkIfInGeneral = async () => {
const generalAlias = config?.chat.general_alias;
if (!generalAlias) {
console.log("[ChatContext#checkIfInGeneral] no general alias in config");
return;
}
const accessToken = localStorage.getItem("matrix.access_token");
if (!accessToken) return;
const joinReq = await fetch(
`https://${config.chat.matrix_homeserver}/_matrix/client/v3/join/${generalAlias}`,
{
method: "POST",
headers: {
Authorization: "Bearer " + accessToken,
"Content-Type": "application/json",
},
body: JSON.stringify({
reason: "Auto-joined via Canvas client",
}),
}
);
const joinRes = await joinReq.json();
console.log(
"[ChatContext#checkIfInGeneral] auto-join general response",
joinRes
);
if (joinReq.status === 200) {
toast.success(`Joined chat ${decodeURIComponent(generalAlias)}!`);
} else if (joinReq.status === 403) {
toast.error(
"Failed to join general chat! " +
joinRes.errcode +
" - " +
joinRes.error
);
} else if (joinReq.status === 429) {
toast.warn("Auto-join general chat got ratelimited");
} else {
toast.error(
"Failed to join general chat! " +
joinRes.errcode +
" - " +
joinRes.error
);
}
};
const checkForNotifs = async () => {
if (!config) {
console.warn("[ChatContext#checkForNotifs] no config instance");
return;
}
const accessToken = localStorage.getItem("matrix.access_token");
if (!accessToken) return;
const notifReq = await fetch(
`https://${config.chat.matrix_homeserver}/_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>
);
};
import {
PropsWithChildren,
createContext,
useContext,
useEffect,
useRef,
useState,
} from "react";
import { IRouterData, Router } from "../lib/router";
import { KeybindManager } from "../lib/keybinds";
import { TemplateStyle } from "../lib/template";
interface ITemplate {
/**
* If the template is being used
*/
enable: boolean;
/**
* URL of the template image being used
*/
url?: string;
/**
* Width of the template being displayed
*
* @default min(template.width,canvas.width)
*/
width?: number;
x: number;
y: number;
opacity: number;
style: TemplateStyle;
showMobileTools: boolean;
setEnable(v: boolean): void;
setURL(v?: string): void;
setWidth(v?: number): void;
setX(v: number): void;
setY(v: number): void;
setOpacity(v: number): void;
setStyle(style: TemplateStyle): void;
setShowMobileTools(v: boolean): void;
}
const templateContext = createContext<ITemplate>({} as any);
export const useTemplateContext = () => useContext(templateContext);
export const TemplateContext = ({ children }: PropsWithChildren) => {
const routerData = Router.get();
const [enable, setEnable] = useState(!!routerData.template?.url);
const [url, setURL] = useState<string | undefined>(routerData.template?.url);
const [width, setWidth] = useState<number | undefined>(
routerData.template?.width
);
const [x, setX] = useState(routerData.template?.x || 0);
const [y, setY] = useState(routerData.template?.y || 0);
const [opacity, setOpacity] = useState(100);
const [style, setStyle] = useState<TemplateStyle>(
routerData.template?.style || "ONE_TO_ONE"
);
const [showMobileTools, setShowMobileTools] = useState(true);
const initAt = useRef<number>();
useEffect(() => {
initAt.current = Date.now();
const handleNavigate = (data: IRouterData) => {
if (data.template) {
setEnable(true);
setURL(data.template.url);
setWidth(data.template.width);
setX(data.template.x || 0);
setY(data.template.y || 0);
setStyle(data.template.style || "ONE_TO_ONE");
} else {
setEnable(false);
}
};
const handleToggleTemplate = () => {
setEnable((en) => !en);
};
Router.on("navigate", handleNavigate);
KeybindManager.on("TOGGLE_TEMPLATE", handleToggleTemplate);
return () => {
Router.off("navigate", handleNavigate);
KeybindManager.on("TOGGLE_TEMPLATE", handleToggleTemplate);
};
}, []);
useEffect(() => {
Router.setTemplate({ enabled: enable, width, x, y, url, style });
if (!initAt.current) {
console.debug("TemplateContext updating router but no initAt");
} else if (Date.now() - initAt.current < 2 * 1000) {
console.debug(
"TemplateContext updating router too soon after init",
Date.now() - initAt.current
);
}
if (initAt.current && Date.now() - initAt.current > 2 * 1000)
Router.queueUpdate();
}, [enable, width, x, y, url, style]);
return (
<templateContext.Provider
value={{
enable,
setEnable,
url,
setURL,
width,
setWidth,
x,
setX,
y,
setY,
opacity,
setOpacity,
style,
setStyle,
showMobileTools,
setShowMobileTools,
}}
>
{children}
</templateContext.Provider>
);
};
......@@ -4,12 +4,15 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta http-equiv="X-UA-Compatible" content="ie=edge" />
<title>canvas</title>
<title>Canvas</title>
<link rel="stylesheet" href="./style.scss" />
</head>
<body>
<!-- Mastodon verification -->
<a href="https://social.fediverse.events/@canvas" rel="me"></a>
<div id="root"></div>
<script type="module" src="./index.tsx"></script>
......
import "./lib/sentry";
import React from "react";
import { createRoot } from "react-dom/client";
import { NextUIProvider } from "@nextui-org/react";
import { ThemeProvider } from "next-themes";
import App from "./components/App";
import * as Sentry from "@sentry/react";
let ErrorBoundary = ({ children }: React.PropsWithChildren) => <>{children}</>;
if (__SENTRY_DSN__) {
ErrorBoundary = ({ children }) => {
return <Sentry.ErrorBoundary showDialog>{children}</Sentry.ErrorBoundary>;
};
}
const root = createRoot(document.getElementById("root")!);
root.render(
<React.StrictMode>
<NextUIProvider>
<App />
</NextUIProvider>
<ErrorBoundary>
<NextUIProvider>
<ThemeProvider attribute="class" defaultTheme="system">
<div className="w-screen h-screen bg-[#ddd] dark:bg-[#060606]">
<App />
</div>
</ThemeProvider>
</NextUIProvider>
</ErrorBoundary>
</React.StrictMode>
);