From 6f7aad5da87cc51a7e343794432ef6661d48b756 Mon Sep 17 00:00:00 2001 From: Grant Date: Mon, 3 Jun 2024 21:05:22 -0600 Subject: [PATCH] pixel whois, keybinds & fix coords not showing on hover (CanvasMeta) (related #11) --- packages/client/src/components/App.tsx | 3 + .../client/src/components/CanvasWrapper.tsx | 66 +++++- .../src/components/PixelWhoisSidebar.tsx | 188 ++++++++++++++++++ .../src/components/Profile/UserCard.tsx | 100 ++++++++++ packages/client/src/contexts/AppContext.tsx | 7 + packages/client/src/lib/canvas.ts | 46 ++++- packages/client/src/lib/keybinds.ts | 126 ++++++++++++ packages/client/src/lib/utils.ts | 8 +- packages/lib/src/net.ts | 3 + packages/lib/src/renderer/PanZoom.ts | 14 ++ packages/server/src/api/client.ts | 44 ++++ packages/server/src/lib/Canvas.ts | 9 + 12 files changed, 609 insertions(+), 5 deletions(-) create mode 100644 packages/client/src/components/PixelWhoisSidebar.tsx create mode 100644 packages/client/src/components/Profile/UserCard.tsx create mode 100644 packages/client/src/lib/keybinds.ts diff --git a/packages/client/src/components/App.tsx b/packages/client/src/components/App.tsx index 2b580a9..56ff2aa 100644 --- a/packages/client/src/components/App.tsx +++ b/packages/client/src/components/App.tsx @@ -11,6 +11,8 @@ import { ChatContext } from "../contexts/ChatContext"; import "react-toastify/dist/ReactToastify.css"; import { ToastContainer } from "react-toastify"; import { AuthErrors } from "./AuthErrors"; +import "../lib/keybinds"; +import { PixelWhoisSidebar } from "./PixelWhoisSidebar"; const Chat = lazy(() => import("./Chat/Chat")); @@ -34,6 +36,7 @@ const AppInner = () => { + diff --git a/packages/client/src/components/CanvasWrapper.tsx b/packages/client/src/components/CanvasWrapper.tsx index aeefb52..b7b890a 100644 --- a/packages/client/src/components/CanvasWrapper.tsx +++ b/packages/client/src/components/CanvasWrapper.tsx @@ -8,6 +8,7 @@ import throttle from "lodash.throttle"; import { IPosition } from "@sc07-canvas/lib/src/net"; import { Template } from "./Template"; import { IRouterData, Router } from "../lib/router"; +import { KeybindManager } from "../lib/keybinds"; export const CanvasWrapper = () => { const { config } = useAppContext(); @@ -26,14 +27,64 @@ export const CanvasWrapper = () => { const CanvasInner = () => { const canvasRef = useRef(); const canvas = useRef(); - const { config, setCanvasPosition, setCursorPosition } = useAppContext(); + const { config, setCanvasPosition, setCursorPosition, setPixelWhois } = + useAppContext(); const PanZoom = useContext(RendererContext); useEffect(() => { if (!canvasRef.current) return; canvas.current = new Canvas(canvasRef.current!, PanZoom); + const handlePixelWhois = ({ + clientX, + clientY, + }: { + clientX: number; + clientY: number; + }) => { + if (!canvas.current) { + console.warn( + "[CanvasWrapper#handlePixelWhois] canvas instance does not exist" + ); + return; + } + + const [x, y] = canvas.current.screenToPos(clientX, clientY); + if (x < 0 || y < 0) return; // discard if out of bounds + + // canvas size can dynamically change, so we need to check the current config + // we're depending on canvas.instance's config so we don't have to use a react dependency + if (canvas.current.hasConfig()) { + const { + canvas: { + size: [width, height], + }, + } = canvas.current.getConfig(); + + if (x >= width || y >= height) return; // out of bounds + } else { + // although this should never happen, log it + console.warn( + "[CanvasWrapper#handlePixelWhois] canvas config is not available yet" + ); + } + + // ....... + // ....... + // ....... + // ...x... + // ....... + // ....... + // ....... + const surrounding = canvas.current.getSurroundingPixels(x, y, 3); + + setPixelWhois({ x, y, surrounding }); + }; + + KeybindManager.on("PIXEL_WHOIS", handlePixelWhois); + return () => { + KeybindManager.off("PIXEL_WHOIS", handlePixelWhois); canvas.current!.destroy(); }; }, [PanZoom, setCursorPosition]); @@ -139,6 +190,19 @@ const CanvasInner = () => { ); } + if (canvas.current) { + const pos = canvas.current?.panZoomTransformToCanvas(); + setCanvasPosition({ + x: pos.canvasX, + y: pos.canvasY, + zoom: state.scale >> 0, + }); + } else { + console.warn( + "[CanvasWrapper] handleViewportMove has no canvas instance" + ); + } + Router.queueUpdate(); }; diff --git a/packages/client/src/components/PixelWhoisSidebar.tsx b/packages/client/src/components/PixelWhoisSidebar.tsx new file mode 100644 index 0000000..4db4814 --- /dev/null +++ b/packages/client/src/components/PixelWhoisSidebar.tsx @@ -0,0 +1,188 @@ +import { Button, Spinner } from "@nextui-org/react"; +import { useAppContext } from "../contexts/AppContext"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { faXmark } from "@fortawesome/free-solid-svg-icons"; +import { ComponentPropsWithoutRef, useEffect, useRef, useState } from "react"; +import { api } from "../lib/utils"; +import { UserCard } from "./Profile/UserCard"; + +interface IPixel { + userId: string; + x: number; + y: number; + color: string; + createdAt: Date; +} + +interface IUser { + sub: string; + display_name?: string; + picture_url?: string; + profile_url?: string; + isAdmin: boolean; + isModerator: boolean; +} + +interface IInstance { + hostname: string; + name?: string; + logo_url?: string; + banner_url?: string; +} + +export const PixelWhoisSidebar = () => { + const { pixelWhois, setPixelWhois } = useAppContext(); + const [loading, setLoading] = useState(true); + const [whois, setWhois] = useState<{ + pixel: IPixel; + otherPixels: number; + user: IUser | null; + instance: IInstance | null; + }>(); + + useEffect(() => { + if (!pixelWhois) return; + setLoading(true); + setWhois(undefined); + + api< + { + pixel: IPixel; + otherPixels: number; + user: IUser | null; + instance: IInstance | null; + }, + "no_pixel" + >(`/api/canvas/pixel/${pixelWhois.x}/${pixelWhois.y}`) + .then(({ status, data }) => { + if (status === 200) { + if (data.success) { + setWhois({ + pixel: data.pixel, + otherPixels: data.otherPixels, + user: data.user, + instance: data.instance, + }); + } else { + // error wahhhhhh + } + } else { + // error wahhhh + } + }) + .finally(() => { + setLoading(false); + }); + }, [pixelWhois]); + + return ( +
+ {loading && ( +
+
+ + Loading +
+
+ )} +
+

Pixel Whois

+
+ +
+
+
+ +
+
+
{whois?.user && }
+
+ + + + + + + + + +
Placed At{whois?.pixel.createdAt?.toString()}
Covered Pixels{whois?.otherPixels}
+
+
+ ); +}; + +const SmallCanvas = ({ + surrounding, + ...props +}: { + surrounding: string[][] | undefined; +} & ComponentPropsWithoutRef<"canvas">) => { + const canvasRef = useRef(null); + + useEffect(() => { + if (!canvasRef.current) { + console.warn("[SmallCanvas] canvasRef unavailable"); + return; + } + + const ctx = canvasRef.current.getContext("2d"); + if (!ctx) { + console.warn("[SmallCanvas] canvas context unavailable"); + return; + } + + ctx.fillStyle = "#fff"; + ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); + + ctx.fillStyle = "rgba(0,0,0,0.2)"; + ctx.fillRect(0, 0, canvasRef.current.width, canvasRef.current.height); + + if (surrounding) { + const PIXEL_WIDTH = canvasRef.current.width / surrounding[0].length; + const middle: [x: number, y: number] = [ + Math.floor(surrounding[0].length / 2), + Math.floor(surrounding.length / 2), + ]; + + for (let y = 0; y < surrounding.length; y++) { + for (let x = 0; x < surrounding[y].length; x++) { + let color = surrounding[y][x]; + ctx.beginPath(); + ctx.rect(x * PIXEL_WIDTH, y * PIXEL_WIDTH, PIXEL_WIDTH, PIXEL_WIDTH); + + ctx.fillStyle = color; + ctx.fill(); + } + } + + ctx.beginPath(); + ctx.rect( + middle[0] * PIXEL_WIDTH, + middle[1] * PIXEL_WIDTH, + PIXEL_WIDTH, + PIXEL_WIDTH + ); + ctx.strokeStyle = "#f00"; + ctx.lineWidth = 4; + ctx.stroke(); + } + }, [surrounding]); + + return ( + (canvasRef.current = r)} + {...props} + /> + ); +}; diff --git a/packages/client/src/components/Profile/UserCard.tsx b/packages/client/src/components/Profile/UserCard.tsx new file mode 100644 index 0000000..58a0ace --- /dev/null +++ b/packages/client/src/components/Profile/UserCard.tsx @@ -0,0 +1,100 @@ +import { faMessage, faWarning, faX } from "@fortawesome/free-solid-svg-icons"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { Button, Link, Spinner } from "@nextui-org/react"; +import { MouseEvent, useEffect, useState } from "react"; +import { toast } from "react-toastify"; + +interface IUser { + sub: string; + display_name?: string; + picture_url?: string; + profile_url?: string; + isAdmin: boolean; + isModerator: boolean; +} + +const MATRIX_HOST = import.meta.env.VITE_MATRIX_HOST!; // eg aftermath.gg +const ELEMENT_HOST = import.meta.env.VITE_ELEMENT_HOST!; // eg https://chat.fediverse.events + +const getMatrixLink = (user: IUser) => { + return `${ELEMENT_HOST}/#/user/@${user.sub.replace("@", "=40")}:${MATRIX_HOST}`; +}; + +/** + * 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 [messageStatus, setMessageStatus] = useState< + "loading" | "no_account" | "has_account" | "error" + >("loading"); + + useEffect(() => { + setMessageStatus("loading"); + + fetch( + `https://${MATRIX_HOST}/_matrix/client/v3/profile/${encodeURIComponent(`@${user.sub.replace("@", "=40")}:${MATRIX_HOST}`)}` + ) + .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]); + + const handleMatrixClick = (e: MouseEvent) => { + if (messageStatus === "no_account") { + e.preventDefault(); + + toast.info("This user has not setup chat yet, you cannot message them"); + } + }; + + return ( +
+
+ {`${user?.sub}'s +
+ {user?.display_name} + {user?.sub} +
+
+ +
+
+ +
+ ); +}; diff --git a/packages/client/src/contexts/AppContext.tsx b/packages/client/src/contexts/AppContext.tsx index e13040e..0cf0374 100644 --- a/packages/client/src/contexts/AppContext.tsx +++ b/packages/client/src/contexts/AppContext.tsx @@ -35,6 +35,11 @@ export const AppContext = ({ children }: PropsWithChildren) => { // overlays visible const [settingsSidebar, setSettingsSidebar] = useState(false); + const [pixelWhois, setPixelWhois] = useState<{ + x: number; + y: number; + surrounding: string[][]; + }>(); const [hasAdmin, setHasAdmin] = useState(false); @@ -130,6 +135,8 @@ export const AppContext = ({ children }: PropsWithChildren) => { setLoadChat, connected, hasAdmin, + pixelWhois, + setPixelWhois, }} > {!config && ( diff --git a/packages/client/src/lib/canvas.ts b/packages/client/src/lib/canvas.ts index 9c4685c..b6ac59d 100644 --- a/packages/client/src/lib/canvas.ts +++ b/packages/client/src/lib/canvas.ts @@ -88,9 +88,51 @@ export class Canvas extends EventEmitter { return !!this.config; } + getConfig() { + return this.config; + } + + /** + * Get nearby pixels + * @param x + * @param y + * @param around (x,y) +- around + */ + getSurroundingPixels(x: number, y: number, around: number = 3) { + let pixels = []; + + for (let offsetY = 0; offsetY <= around + 1; offsetY++) { + let arr = []; + for (let offsetX = 0; offsetX <= around + 1; offsetX++) { + let targetX = x + (offsetX - around + 1); + let targetY = y + (offsetY - around + 1); + let pixel = this.pixels[targetX + "_" + targetY]; + + if (pixel) { + arr.push("#" + (this.Pallete.getColor(pixel.color)?.hex || "ffffff")); + } else { + arr.push("transparent"); + } + } + pixels.push(arr); + } + + return pixels; + } + handleMouseDown(e: ClickEvent) { - const [x, y] = this.screenToPos(e.clientX, e.clientY); - this.place(x, y); + if (!e.alt && !e.ctrl && !e.meta && !e.shift && e.button === "LCLICK") { + const [x, y] = this.screenToPos(e.clientX, e.clientY); + this.place(x, y); + } else { + // KeybindManager.handleInteraction({ + // key: e.button, + // alt: e.alt, + // ctrl: e.ctrl, + // meta: e.meta, + // shift: e.meta + // }, ) + } } handleMouseMove(e: HoverEvent) { diff --git a/packages/client/src/lib/keybinds.ts b/packages/client/src/lib/keybinds.ts new file mode 100644 index 0000000..6dfebff --- /dev/null +++ b/packages/client/src/lib/keybinds.ts @@ -0,0 +1,126 @@ +import EventEmitter from "eventemitter3"; +import { EnforceObjectType } from "./utils"; + +interface IKeybind { + key: KeyboardEvent["code"] | "LCLICK" | "RCLICK" | "MCLICK"; + + alt?: boolean; + ctrl?: boolean; + meta?: boolean; + shift?: boolean; +} + +interface EmittedKeybind { + clientX: number; + clientY: number; +} + +export const enforceObjectType: EnforceObjectType = (v) => v; + +const KEYBINDS = enforceObjectType({ + PIXEL_WHOIS: { + key: "LCLICK", + shift: true, + }, +}); + +class KeybindManager_ extends EventEmitter<{ + [k in keyof typeof KEYBINDS]: (args: EmittedKeybind) => void; +}> { + constructor() { + super(); + // setup listeners + + document.addEventListener("keyup", this.handleKeyup); + document.addEventListener("click", this.handleClick); + } + + destroy() { + // remove listeners + // this is global and doesn't depend on any elements, so this shouldn't need to be called + } + + handleKeyup = (e: KeyboardEvent) => { + // discard if in an input element + + const blacklistedElements = ["INPUT"]; + + if (e.target instanceof HTMLElement) { + if (blacklistedElements.indexOf(e.target.tagName) > -1) { + return; + } + } + + let isHandled = this.handleInteraction( + { + key: e.code, + alt: e.altKey, + ctrl: e.ctrlKey, + meta: e.metaKey, + shift: e.shiftKey, + }, + { + clientX: -1, + clientY: -1, + } + ); + + if (isHandled) e.preventDefault(); + }; + + handleClick = (e: MouseEvent) => { + let button: "LCLICK" | "RCLICK" | "MCLICK" = ["LCLICK", "MCLICK", "RCLICK"][ + e.button + ] as any; + + let isHandled = this.handleInteraction( + { + key: button, + alt: e.altKey, + ctrl: e.ctrlKey, + meta: e.metaKey, + shift: e.shiftKey, + }, + { + clientX: e.clientX, + clientY: e.clientY, + } + ); + + if (isHandled) e.preventDefault(); + }; + + /** + * Handle interaction + * @param key + * @returns if handled + */ + handleInteraction(key: IKeybind, emit: EmittedKeybind): boolean { + let isHandled = false; + + for (const [name_, keybind] of Object.entries(KEYBINDS)) { + const name: keyof typeof KEYBINDS = name_ as any; + + if (keybind.key !== key.key) continue; + if (typeof keybind.alt !== "undefined" && keybind.alt !== key.alt) + continue; + if (typeof keybind.ctrl !== "undefined" && keybind.ctrl !== key.ctrl) + continue; + if (typeof keybind.meta !== "undefined" && keybind.meta !== key.meta) + continue; + if (typeof keybind.shift !== "undefined" && keybind.shift !== key.shift) + continue; + + this.emit(name, emit); + isHandled = true; + } + + return isHandled; + } + + getKeybind(key: keyof typeof KEYBINDS) { + return KEYBINDS[key]; + } +} + +export const KeybindManager = new KeybindManager_(); diff --git a/packages/client/src/lib/utils.ts b/packages/client/src/lib/utils.ts index c2c30f9..1d89258 100644 --- a/packages/client/src/lib/utils.ts +++ b/packages/client/src/lib/utils.ts @@ -1,11 +1,11 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export const api = async ( +export const api = async ( endpoint: string, method: "GET" | "POST" = "GET", body?: unknown ): Promise<{ status: number; - data: ({ success: true } & T) | { success: false; error: string }; + data: ({ success: true } & T) | { success: false; error: Error }; }> => { const API_HOST = import.meta.env.VITE_API_HOST || ""; @@ -32,3 +32,7 @@ export const api = async ( data, }; }; + +export type EnforceObjectType = ( + v: V +) => { [k in keyof V]: T }; diff --git a/packages/lib/src/net.ts b/packages/lib/src/net.ts index 274796f..c4f7955 100644 --- a/packages/lib/src/net.ts +++ b/packages/lib/src/net.ts @@ -44,6 +44,9 @@ export interface IAppContext { setLoadChat: (v: boolean) => void; connected: boolean; + pixelWhois?: { x: number; y: number; surrounding: string[][] }; + setPixelWhois: (v: this["pixelWhois"]) => void; + hasAdmin: boolean; } diff --git a/packages/lib/src/renderer/PanZoom.ts b/packages/lib/src/renderer/PanZoom.ts index f812263..a221461 100644 --- a/packages/lib/src/renderer/PanZoom.ts +++ b/packages/lib/src/renderer/PanZoom.ts @@ -89,8 +89,15 @@ interface ISetup { // TODO: move these event interfaces out export interface ClickEvent { + button: "LCLICK" | "MCLICK" | "RCLICK"; + clientX: number; clientY: number; + + alt: boolean; + ctrl: boolean; + meta: boolean; + shift: boolean; } export interface HoverEvent { @@ -596,8 +603,15 @@ export class PanZoom extends EventEmitter { // difference from the start position to the up position is very very slow, // so it's most likely intended to be a click this.emit("click", { + button: ["LCLICK", "MCLICK", "RCLICK"][e.button] as any, + clientX: e.clientX, clientY: e.clientY, + + alt: e.altKey, + ctrl: e.ctrlKey, + meta: e.metaKey, + shift: e.shiftKey, }); } } diff --git a/packages/server/src/api/client.ts b/packages/server/src/api/client.ts index 652e87c..90a92c1 100644 --- a/packages/server/src/api/client.ts +++ b/packages/server/src/api/client.ts @@ -190,4 +190,48 @@ app.get("/callback", async (req, res) => { res.redirect("/"); }); +// TODO: Ratelimiting #40 +app.get("/canvas/pixel/:x/:y", async (req, res) => { + const x = parseInt(req.params.x); + const y = parseInt(req.params.y); + + if (isNaN(x) || isNaN(y)) { + return res + .status(400) + .json({ success: false, error: "x or y is not a number" }); + } + + const pixel = await Canvas.getPixel(x, y); + if (!pixel) { + return res.json({ success: false, error: "no_pixel" }); + } + + const otherPixels = await prisma.pixel.count({ where: { x, y } }); + + const user = await prisma.user.findFirst({ where: { sub: pixel.userId } }); + const instance = await prisma.instance.findFirst({ + where: { hostname: pixel.userId.split("@")[1] }, + }); + + res.json({ + success: true, + pixel, + otherPixels: otherPixels - 1, + user: user && { + sub: user.sub, + display_name: user.display_name, + picture_url: user.picture_url, + profile_url: user.profile_url, + isAdmin: user.isAdmin, + isModerator: user.isModerator, + }, + instance: instance && { + hostname: instance.hostname, + name: instance.name, + logo_url: instance.logo_url, + banner_url: instance.banner_url, + }, + }); +}); + export default app; diff --git a/packages/server/src/lib/Canvas.ts b/packages/server/src/lib/Canvas.ts index fcf75c0..cfd4bba 100644 --- a/packages/server/src/lib/Canvas.ts +++ b/packages/server/src/lib/Canvas.ts @@ -151,6 +151,15 @@ class Canvas { return await this.canvasToRedis(); } + async getPixel(x: number, y: number) { + return await prisma.pixel.findFirst({ + where: { + x, + y, + }, + }); + } + async setPixel(user: { sub: string }, x: number, y: number, hex: string) { const redis = await Redis.getClient();