diff --git a/README.md b/README.md index 720b9fc..96c3f49 100644 --- a/README.md +++ b/README.md @@ -29,9 +29,9 @@ The site will now be available on http://localhost:3000. ### Documentation - [Folders](./docs/Folders.md) +- [Room DOM](./docs/Room%20DOM.md) - [Server Rooms](./docs/Server%20Rooms.md) - [Client Room Context](./docs/Client%20Room%20Context.md) -- [Client Socket Messaging](./docs/Client%20Socket%20Messaging.md) ### Tech Stack diff --git a/docs/Client Room Context.md b/docs/Client Room Context.md index dd877e6..72ad529 100644 --- a/docs/Client Room Context.md +++ b/docs/Client Room Context.md @@ -1,15 +1,44 @@ # Client Room Context -Within the [`Room` page](/src/pages/Room/index.tsx), general room data is stored in a `RoomContext` React Context. +Code areas for setting up client connections to the server are contained in the [`@connection/` folder](./Folders.md#connection). +They are provided with basic room settings by the Room page, set up [React Contexts](https://reactjs.org/docs/context.html) that provide rudimentary connection APIs, then render the Room page as children underneath those contexts. -## Initializing Room State +Initializing the context consists of three phases, each corresponding to a React component that renders the next step when ready: -Room context state is initialized at the high-level [`RoomContainer` component](/src/pages/room/RoomContainer.tsx). +1. `DynamicScene` +2. `DynamicSceneHydrating` +3. `DynamicSceneConnected` -Each member is stored as a piece of `useState` state. -They're each wrapped with an instanceof the `GetterAndSetter` type, which is a generic TypeScript type that provides `.get()` and `.set()` methods. +### `DynamicScene` -> See [`RoomContext.ts`](/src/pages/room/RoomContext.ts) for more details on how the initial context value is created. +[`DynamicScene`](/src/connection/DynamicScene/index.tsx) receives basic settings directly passed by the page query and calls [`useRoomConnection`](/src/connection/RoomConnection.ts) to: + +1. Initialze the NAF connection by setting the `networked-scene` attribute on the root Aframe scene, providing a `globalOnConnectHook` to NAF's `onConnect` pointing to our temporarily created global listener. +2. Upon connection success in that global listener, stores NAF's Socket.IO client in our React state. + +That stored socket client is used by the rest of the connection logic to send data to and receive data from the server. + +### `DynamicSceneHydrating` + +[`DynamicSceneHydrating`](/src/connection/DynamicSceneHydrating/index.tsx) takes in that socket and calls [`useRoomDataConnection`](/src/connection/DynamicScene/DynamicSceneHydrating/useRoomDataConnection.ts) to: + +1. Send a `KaraokeEvent.RoomDataHydration` event to the server requesting a summary of room information. +2. Upon receiving the response, store the room information in React state. + +That stored room data is used by the rest of the connection and room logic to keep a local copy of the most recent snapshot of the room's state. + +### `DynamicSceneConnected` + +[`DynamicSceneConnected`](/src/connection/DynamicSceneConnected/index.tsx) receives accumulated client and room data state, and uses them to initialize contexts used by the rest room logic: + +- [`EmitContext`](/src/connection/EmitContext.ts) receives a single `emit` function clients may send events to the server through. +- [`RoomContext`](/src/connection/RoomContext.ts) receives [`GetterAndSetter`] wrappers around room data, as well as an [`emitRoomData`] utility to simultaneously update local room data and send any changed values to the server. + +It also starts a [`useRoomDataSyncing`](/src/connection/DynamicScene/DynamicSceneConnected/useRoomDataSyncing.ts) effect that continuously synchronizes local state to the server as it changes: + +- It immediately emits a `KaraokeEvent.UsernameSet` to indicate the user's selected username to the server. +- When a `KaraokeEvent.OccupantsUpdated` event is received, it updates the local occupancy map. +- When a `KaraokeEvent.RoomDataUpdated` event is received, it updates the local room data. ## Reading Room State @@ -26,9 +55,9 @@ The commonly used `roomData` member is a full object, so it's convenient to dest ```ts const { roomData } = useRoomContext(); -const { environment } = roomData.get(); +const { currentTime } = roomData.get(); -environment; // string +currentTime; // string ``` ## Writing Room State @@ -36,12 +65,17 @@ environment; // string The `.set()` method on those members takes in a new version to pass to a `setState` setter. ```ts -const { roomData } = useRoomContext(); +const { occupants } = useRoomContext(); -roomData.set({ - ...roomData.get(), - environment: "new environment", -}); +occupants.set(newOccupancyMap); ``` -> 🔜 Consider reading [Client Socket Messaging](./Client%20Socket%20Messaging.md) next to understand how pages are synchronized. +For `RoomData` specifically, use the returned `emitRoomData` function to simultaneously update local room data and send any changed values to the server. + +```ts +const { emitRoomData, roomData } = useRoomContext(); + +emitRoomData({ + songs: [...roomData.get().songs, newSong], +}); +``` diff --git a/docs/Client Socket Messaging.md b/docs/Client Socket Messaging.md deleted file mode 100644 index 19b0adf..0000000 --- a/docs/Client Socket Messaging.md +++ /dev/null @@ -1,46 +0,0 @@ -# Client Socket Messaging - -> 🔙 Read [Client Room Context](./Client%20Room%20Context.md) before this page to understand how local room data is stored. - -Client Socket.IO connections are set up in the [`Scene` component](/src/pages/room/Scene/index.tsx), which uses [`useRoomConnection`](/src/pages/room/RoomConnection/index.ts) to hook onto Networked Aframe's Socket.IO connection. - -## Socket Connection Initialization - -Networked Aframe does our initial Socket.IO / server connection for us. -It also exposes an `onConnect` prop to receive a name of a global function to call when ready. -We provide our `globalOnConnectHook` to call our temporarily created global listener that stores its socket in our own React state. - -### Room Connection - -That global listener called on connection performs two pieces of state initialization in a `createRoomConnection`: - -- It sets the local `client.id` for the room state using the SocketIO connection ID (`personId`). -- It emits an initial `KaraokeEvent.UsernameSet` event to the server to register the client person's username. - -It also sets up listeners for server events: - -- `KaraokeEvent.OccupantsUpdated`: Sets the full list of occupants in the room from when the server creates, updates, or deletes one. -- `KaraokeEvent.RoomDataUpdated`: Receives a full or partial update of room "jukebox" data from the server. - -## Emitting Events - -`useRoomConnection` returns an `emit` callback to receive SocketIO events later in the application. -Because the socket state isn't set until NAF's global callback is fired, a `pendingEvents` array is kept in the interim. -Its events are emitted ("flushed") all at once as soon as a connection is established. - -### Emit Context - -Components inside the scene can generally call to `useEmitContext` to receive that `emit` function via React Context. -This is typically done in conjunction with setting some new state locally as well: - -```ts -const emit = useEmitContext(); -const { roomData } = useRoomContext(); - -// ... - -roomData.set({ ...roomData.get(), ...newData }); -emit(KaraokeEvent.RoomDataUpdated, newData); -``` - -> 🤔 Eventually, it might be a good idea to create wrappers and/or effects to call `emit` automatically on changes. diff --git a/docs/Folders.md b/docs/Folders.md index e85d3ff..08a9738 100644 --- a/docs/Folders.md +++ b/docs/Folders.md @@ -3,6 +3,7 @@ Application code for Karaoke Nite lives entirely within a few folders inside the `src/` directory: - `components` +- `connection` - `pages` - `shared` - `server` @@ -19,6 +20,13 @@ These form a sort of fledgling component library that fills out our basic button > 💡 Sticking to our fledgling design system makes it easier to keep pieces of the UI looking consistent and reduces work recreating similar components. +## `connection` + +Room-specific logic for initialization a Socket.IO connection to the server and communicating over it. + +- 👍 **Do** keep all code around initializing server connections here, to abstract those details away from other folders. +- 👎 **Don't** update UI based on the results of those connections here; prefer doing that in page-specific React components. + ## `pages` [Next.js Pages](https://nextjs.org/docs/basic-features/pages) are stored here. diff --git a/docs/Room DOM.md b/docs/Room DOM.md new file mode 100644 index 0000000..7228561 --- /dev/null +++ b/docs/Room DOM.md @@ -0,0 +1,26 @@ +# Room DOM + +Because Aframe uses direct DOM elements as a source of truth and the Room page uses Aframe for its 3D environment, we must manage direct DOM interactions in our React code. + +> We tried [Aframe-React](https://github.com/supermedium/aframe-react) but ran into severe issues ([#5](https://github.com/karaokenite/karaokenite-react/issues/5); [#7](https://github.com/karaokenite/karaokenite-react/issues/7); [networked-aframe#226](https://github.com/networked-aframe/networked-aframe/issues/226)). + +## Static HTML + +We use a [Next.js custom `Document`](https://nextjs.org/docs/advanced-features/custom-document) to include: + +- Static Aframe library scripts: + + - When on the `/room` page, these are executed synchronously in a proper order. + - When on any other page, they are loaded as `async` as a prefetching technique. + +- Raw Aframe elements on the page: + + - Placing then in the raw HTML ensures they already exist on the page by the time our scripts need them. + - Our Aframe DOM template is stored in a [`template.html`](/src/pages/room/RoomBody/template.html) and loaded via Webpack's [`html-loader`](https://webpack.js.org/loaders/html-loader). + +## DOM Interactions + +Room DOM interactions are initialized by [`RoomEvents`](/src/pages/room/RoomEvents.ts), a `null`-returning component that sets React effect hooks to initialize DOM event listeners. +Those listeners generally use [`react-use`'s `useEvent`](https://github.com/streamich/react-use/blob/master/docs/useEvent.md) to be automatically disposed when needed. + +> See [Client Room Context](./Client%20Room%20Context.md) for how those listeners interact with room state. diff --git a/docs/Server Rooms.md b/docs/Server Rooms.md index 7def06a..b1bb989 100644 --- a/docs/Server Rooms.md +++ b/docs/Server Rooms.md @@ -57,11 +57,13 @@ We define our own events on top of the required NAF ones. Clients may send these events to the server: +- `KaraokeEvent.RoomDataHydration`: One-time event requesting room data upon joining a room. - `KaraokeEvent.RoomDataUpdated`: General update for some portion of a room's "jukebox" data, such as song index or volume. - `KaraokeEvent.UsernameSet`: Adds a `username` alias for a person by id. In response, the server may send these events back to the entire room: +- `KaraokeEvent.RoomDataHydration`: Provides a one-time full summary of room data upon joining a room. - `KaraokeEvent.OccupantsUpdated`: Provides the full list of all occupants in the room. - `KaraokeEvent.RoomDataUpdated`: Matching event to send the entire room's jukebox data. diff --git a/src/components/constants.ts b/src/components/constants.ts index 2da3910..36eae99 100644 --- a/src/components/constants.ts +++ b/src/components/constants.ts @@ -1,3 +1,5 @@ +export const environmentSelector = "[environment]"; + export const modalsElementId = "modals"; export const rootElementId = "root"; @@ -13,10 +15,3 @@ export const controlIds = { volumeHighButton: "volume-high-button", volumeLowButton: "volume-low-button", }; - -export const globalOnConnectHook = "KaraokeNiteListenToConnection"; - -/** - * How often to send currentTime updates for videos. - */ -export const videoElementSyncInterval = 1000; diff --git a/src/components/elements.ts b/src/components/elements.ts index bdf4c0d..3b242f4 100644 --- a/src/components/elements.ts +++ b/src/components/elements.ts @@ -1,13 +1,17 @@ +import { Entity } from "aframe"; import { mapValues } from "lodash"; -import { controlIds, sceneSelector, videoElementId } from "./constants"; +import * as constants from "./constants"; import { getElementById, querySelector } from "./queries"; -import { Entity } from "aframe"; -export const sceneElement = querySelector(sceneSelector); +export const environmentElement = querySelector( + constants.environmentSelector +); + +export const sceneElement = querySelector(constants.sceneSelector); export const videoElement = getElementById( - videoElementId + constants.videoElementId ); -export const controls = mapValues(controlIds, getElementById); +export const controls = mapValues(constants.controlIds, getElementById); diff --git a/src/components/queries.ts b/src/components/queries.ts index 1c6acd3..cd7de0f 100644 --- a/src/components/queries.ts +++ b/src/components/queries.ts @@ -1,3 +1,9 @@ +/* +Quick wrappers around DOM query methods with (unsafe) generic types for returns. +The document isn't available in server-side rendering but we want these elements +to be easily available in scripts that manipualte them. +*/ + export const getElementById = (id: string) => { return (globalThis.document?.getElementById(id) as unknown) as ElementType; }; diff --git a/src/connection/DynamicScene/DynamicSceneHydrating/useRoomDataConnection.ts b/src/connection/DynamicScene/DynamicSceneHydrating/useRoomDataConnection.ts index f84d2a6..464b9a7 100644 --- a/src/connection/DynamicScene/DynamicSceneHydrating/useRoomDataConnection.ts +++ b/src/connection/DynamicScene/DynamicSceneHydrating/useRoomDataConnection.ts @@ -6,20 +6,20 @@ export const useRoomDataConnection = (socket: SocketIOClient.Socket) => { const [roomData, setRoomData] = useState(); useEffect(() => { - const disconnect = () => { + const stopListening = () => { socket.off(KaraokeEvent.RoomDataHydration, receiveRoomData); }; const receiveRoomData = (data: RoomData) => { setRoomData(data); - disconnect(); + stopListening(); }; setRoomData(undefined); socket.emit(KaraokeEvent.RoomDataHydration); socket.on(KaraokeEvent.RoomDataHydration, receiveRoomData); - return disconnect; + return stopListening; }, [socket]); return roomData; diff --git a/src/connection/useRoomConnection.ts b/src/connection/useRoomConnection.ts index b17f307..d72421a 100644 --- a/src/connection/useRoomConnection.ts +++ b/src/connection/useRoomConnection.ts @@ -1,8 +1,9 @@ import { useEffect, useState } from "react"; -import { globalOnConnectHook } from "@components/constants"; import { sceneElement } from "@components/elements"; +const globalOnConnectHook = "KaraokeNiteListenToConnection"; + export const useRoomConnection = (roomName: string) => { const [socket, setSocket] = useState(); diff --git a/src/pages/room/RoomBody/RawHtml.tsx b/src/pages/room/RoomBody/RawHtml.tsx index b9dfe85..5a0ef21 100644 --- a/src/pages/room/RoomBody/RawHtml.tsx +++ b/src/pages/room/RoomBody/RawHtml.tsx @@ -1,7 +1,5 @@ import React from "react"; -// https://github.com/networked-aframe/networked-aframe/issues/226 -// Ugh. export const RawHtml = (html: string) => { return function GeneratedRawHtml() { return
; diff --git a/src/pages/room/RoomEvents/useVideoControls/useTimeSynchronization.ts b/src/pages/room/RoomEvents/useVideoControls/useTimeSynchronization.ts index 2202e12..c60d5ed 100644 --- a/src/pages/room/RoomEvents/useVideoControls/useTimeSynchronization.ts +++ b/src/pages/room/RoomEvents/useVideoControls/useTimeSynchronization.ts @@ -1,10 +1,14 @@ import { useEffect } from "react"; import { useInterval } from "react-use"; -import { videoElementSyncInterval } from "@components/constants"; import { videoElement } from "@components/elements"; import { useRoomContext } from "@connection/RoomContext"; +/** + * How often to emit currentTime updates. + */ +const videoElementSyncInterval = 1000; + export const useTimeSynchronization = () => { const { emitRoomData, roomData } = useRoomContext(); const { currentTime, playing } = roomData.get(); diff --git a/src/shared/rooms.ts b/src/shared/rooms.ts index e103589..9c85ea3 100644 --- a/src/shared/rooms.ts +++ b/src/shared/rooms.ts @@ -1,5 +1,5 @@ -import { RoomData } from "./types"; import { defaultSongIndex } from "./songs"; +import { RoomData } from "./types"; export const environments = [ "osiris",