import { useState, useEffect, useRef } from "react"; import { useToasts } from "react-toast-notifications"; import { useMapData } from "../contexts/MapDataContext"; import { useMapLoading } from "../contexts/MapLoadingContext"; import { useUserId } from "../contexts/UserIdContext"; import { useDatabase } from "../contexts/DatabaseContext"; import { useParty } from "../contexts/PartyContext"; import { useAssets } from "../contexts/AssetsContext"; import useDebounce from "../hooks/useDebounce"; import useNetworkedState from "../hooks/useNetworkedState"; import useMapActions from "../hooks/useMapActions"; import Session, { PeerDataEvent, PeerDataProgressEvent } from "./Session"; import Action from "../actions/Action"; import Map from "../components/map/Map"; import TokenBar from "../components/token/TokenBar"; import GlobalImageDrop from "../components/image/GlobalImageDrop"; import { Map as MapType } from "../types/Map"; import { MapState } from "../types/MapState"; import { AssetManifest, AssetManifestAsset, AssetManifestAssets, } from "../types/Asset"; import { TokenState } from "../types/TokenState"; import { DrawingState } from "../types/Drawing"; import { FogState } from "../types/Fog"; import { Note } from "../types/Note"; import { AddStatesAction, EditStatesAction, RemoveStatesAction, } from "../actions"; /** * @typedef {object} NetworkedMapProps * @property {Session} session */ /** * @param {NetworkedMapProps} props */ function NetworkedMapAndTokens({ session }: { session: Session }) { const { addToast } = useToasts(); const userId = useUserId(); const partyState = useParty(); const { assetLoadStart, assetProgressUpdate, isLoading } = useMapLoading(); const { updateMapState } = useMapData(); const { getAsset, putAsset } = useAssets(); const [currentMap, setCurrentMap] = useState(null); const [currentMapState, setCurrentMapState] = useNetworkedState( null, session, "map_state", 500, true, "mapId" ); const [assetManifest, setAssetManifest] = useNetworkedState( null, session, "manifest", 500, true, "mapId" ); async function loadAssetManifestFromMap(map: MapType, mapState: MapState) { const assets: AssetManifestAssets = {}; const { owner } = map; let processedTokens = new Set(); for (let tokenState of Object.values(mapState.tokens)) { if (tokenState.type === "file" && !processedTokens.has(tokenState.file)) { processedTokens.add(tokenState.file); assets[tokenState.file] = { id: tokenState.file, owner: tokenState.owner, }; } } if (map.type === "file") { assets[map.thumbnail] = { id: map.thumbnail, owner }; if (map.quality !== "original") { const qualityId = map.resolutions[map.quality]; if (qualityId) { assets[qualityId] = { id: qualityId, owner }; } } else { assets[map.file] = { id: map.file, owner }; } } setAssetManifest({ mapId: map.id, assets }, true, true); } function addAssetsIfNeeded(assets: AssetManifestAsset[]) { setAssetManifest((prevManifest) => { if (prevManifest?.assets) { let newAssets = { ...prevManifest.assets }; for (let asset of assets) { const id = asset.id; const exists = id in newAssets; if (!exists) { newAssets[id] = asset; } } return { ...prevManifest, assets: newAssets }; } return prevManifest; }); } // Keep track of assets we are already requesting to prevent from loading them multiple times const requestingAssetsRef = useRef(new Set()); useEffect(() => { if (!assetManifest || !userId) { return; } async function requestAssetsIfNeeded() { if (!assetManifest) { return; } for (let asset of Object.values(assetManifest.assets)) { if ( asset.owner === userId || requestingAssetsRef.current.has(asset.id) ) { continue; } const owner = Object.values(partyState).find( (player) => player.userId === asset.owner ); // Ensure requests are added before any async operation to prevent them from sending twice requestingAssetsRef.current.add(asset.id); const cachedAsset = await getAsset(asset.id); if (!owner) { // Add no owner toast if we don't have asset in out cache if (!cachedAsset) { // TODO: Stop toast from appearing multiple times addToast("Unable to find owner for asset"); } requestingAssetsRef.current.delete(asset.id); continue; } if (cachedAsset) { requestingAssetsRef.current.delete(asset.id); } else if (owner.sessionId) { assetLoadStart(asset.id); session.sendTo(owner.sessionId, "assetRequest", asset); } } } requestAssetsIfNeeded(); }, [ assetManifest, partyState, session, userId, addToast, getAsset, assetLoadStart, ]); /** * Map state */ const { database } = useDatabase(); // Sync the map state to the database after 500ms of inactivity const debouncedMapState = useDebounce(currentMapState, 500); useEffect(() => { if ( debouncedMapState && debouncedMapState.mapId && currentMap && currentMap?.owner === userId && database ) { updateMapState(debouncedMapState.mapId, debouncedMapState); } }, [currentMap, debouncedMapState, userId, database, updateMapState]); async function handleMapChange( newMap: MapType | null, newMapState: MapState | null ) { // Clear map before sending new one setCurrentMap(null); session.socket?.emit("map", null); setCurrentMapState(newMapState, true, true); setCurrentMap(newMap); session.socket?.emit("map", newMap); if (!newMap || !newMapState) { setAssetManifest(null, true, true); return; } await loadAssetManifestFromMap(newMap, newMapState); } const [mapActions, addActions, updateActionIndex, resetActions] = useMapActions(setCurrentMapState); function handleMapReset(newMapState: MapState) { setCurrentMapState(newMapState, true, true); resetActions(); } function handleMapDraw(action: Action) { addActions([{ type: "drawings", action }]); } function handleFogDraw(action: Action) { addActions([{ type: "fogs", action }]); } function handleUndo() { updateActionIndex(-1); } function handleRedo() { updateActionIndex(1); } // If map changes clear map actions const previousMapIdRef = useRef(); useEffect(() => { if (currentMap && currentMap?.id !== previousMapIdRef.current) { resetActions(); previousMapIdRef.current = currentMap?.id; } }, [currentMap, resetActions]); function handleNoteCreate(notes: Note[]) { const action = new AddStatesAction(notes); addActions([{ type: "notes", action }]); } function handleNoteChange(changes: Record>) { let edits: Partial[] = []; for (let id in changes) { edits.push({ ...changes[id], id }); } const action = new EditStatesAction(edits); addActions([{ type: "notes", action }]); } function handleNoteRemove(noteIds: string[]) { const action = new RemoveStatesAction(noteIds); addActions([{ type: "notes", action }]); } /** * Token state */ async function handleMapTokensStateCreate(tokenStates: TokenState[]) { if (!currentMap || !currentMapState) { return; } let assets: AssetManifestAsset[] = []; for (let tokenState of tokenStates) { if (tokenState.type === "file") { assets.push({ id: tokenState.file, owner: tokenState.owner }); } } if (assets.length > 0) { addAssetsIfNeeded(assets); } const action = new AddStatesAction(tokenStates); addActions([{ type: "tokens", action }]); } function handleMapTokenStateChange( changes: Record> ) { let edits: Partial[] = []; for (let id in changes) { edits.push({ ...changes[id], id }); } const action = new EditStatesAction(edits); addActions([{ type: "tokens", action }]); } function handleMapTokenStateRemove(tokenStateIds: string[]) { const action = new RemoveStatesAction(tokenStateIds); addActions([{ type: "tokens", action }]); } function handleSelectionItemsChange( tokenChanges: Record>, noteChanges: Record> ) { let tokenEdits: Partial[] = []; for (let id in tokenChanges) { tokenEdits.push({ ...tokenChanges[id], id }); } const tokenAction = new EditStatesAction(tokenEdits); let noteEdits: Partial[] = []; for (let id in noteChanges) { noteEdits.push({ ...noteChanges[id], id }); } const noteAction = new EditStatesAction(noteEdits); addActions([ { type: "tokens", action: tokenAction }, { type: "notes", action: noteAction }, ]); } function handleSelectionItemsRemove( tokenStateIds: string[], noteIds: string[] ) { const tokenAction = new RemoveStatesAction(tokenStateIds); const noteAction = new RemoveStatesAction(noteIds); addActions([ { type: "tokens", action: tokenAction }, { type: "notes", action: noteAction }, ]); } function handleSelectionItemsCreate( tokenStates: TokenState[], notes: Note[] ) { const tokenAction = new AddStatesAction(tokenStates); const noteAction = new AddStatesAction(notes); addActions([ { type: "tokens", action: tokenAction }, { type: "notes", action: noteAction }, ]); } useEffect(() => { async function handlePeerData({ id, data, reply }: PeerDataEvent) { if (id === "assetRequest") { const asset = await getAsset(data.id); if (asset) { reply("assetResponseSuccess", asset, data.id); } else { reply("assetResponseFail", data.id, data.id); } } if (id === "assetResponseSuccess") { const asset = data; await putAsset(asset); requestingAssetsRef.current.delete(asset.id); } if (id === "assetResponseFail") { const assetId = data; requestingAssetsRef.current.delete(assetId); } } function handlePeerDataProgress({ id, total, count, }: PeerDataProgressEvent) { assetProgressUpdate({ id, total, count }); } async function handleSocketMap(map?: MapType) { if (map) { setCurrentMap(map); } else { setCurrentMap(null); } } session.on("peerData", handlePeerData); session.on("peerDataProgress", handlePeerDataProgress); session.socket?.on("map", handleSocketMap); return () => { session.off("peerData", handlePeerData); session.off("peerDataProgress", handlePeerDataProgress); session.socket?.off("map", handleSocketMap); }; }); const canChangeMap = !isLoading; return ( ); } export default NetworkedMapAndTokens;