Browse Source

Prefix for image also

Jeremie Pardou-Piquemal 3 years ago
parent
commit
cf3a8a6725

+ 44 - 0
backend/src/hooks.js

@@ -0,0 +1,44 @@
+import { throwError } from "./utils";
+
+export const ownerOrNewHooks = async (context) => {
+  let existingGame = null;
+
+  const { userId, store, resourceId, body, boxId, method } = context;
+  if (boxId === "game" && ["POST", "UPDATE", "DELETE"].includes(method)) {
+    if (!userId) {
+      throwError(
+        "Game creation/modification not allowed for unauthenticated users",
+        403
+      );
+    }
+
+    const nextContext = {
+      ...context,
+      allow: true,
+      body: { ...body, owner: userId },
+    };
+
+    if (!resourceId) {
+      // Creation
+      return nextContext;
+    }
+
+    try {
+      existingGame = await store.get("game", resourceId);
+    } catch {
+      console.log("Game not found");
+      // Creation but with resourceId
+      return nextContext;
+    }
+
+    if (existingGame.owner !== userId) {
+      // Update with bad user
+      throwError("Modification allowed only for owner", 403);
+    }
+    // Update with good user (and force user)
+    return nextContext;
+  } else {
+    // No changes
+    return context;
+  }
+};

+ 8 - 52
backend/src/index.js

@@ -1,64 +1,20 @@
 import saveGame from "./saveGame";
 import deleteGame from "./deleteGame";
+import { replaceImageUrl } from "./migrations";
+import { ownerOrNewHooks } from "./hooks";
 
 const SESSION_DURATION = 60; // Session duration in days
 
-const replaceImageUrl = async ({ store }) => {
-  console.log("Migrate images...");
-
-  (await store.list("game")).forEach((game) => {
-    if (!Array.isArray(game.board.migrations)) {
-      game.board.migrations = [];
-    }
-
-    const migrations = new Set(game.board.migrations);
-
-    const from = "public.jeremiez.net/airboardgame/";
-    const to = "public.jeremiez.net/ricochet/";
-
-    if (migrations.has("migrate_image_url")) {
-      return;
-    }
-    game.items = game.items.map((item) => {
-      if (item.type === "image") {
-        const newItem = { ...item };
-        if (newItem.content && newItem.content.includes(from)) {
-          newItem.content = newItem.content.replace(from, to);
-        }
-        if (
-          newItem.overlay &&
-          newItem.overlay.content &&
-          newItem.overlay.content.includes(from)
-        ) {
-          newItem.overlay = newItem.overlay.replace(from, to);
-        }
-        if (newItem.backContent && newItem.backContent.includes(from)) {
-          newItem.backContent = newItem.backContent.replace(from, to);
-        }
-        return newItem;
-      } else {
-        return item;
-      }
-    });
-
-    if (game.board.imageUrl && game.board.imageUrl.includes(from)) {
-      game.board.imageUrl = game.board.imageUrl.replace(from, to);
-    }
-
-    migrations.add("migrate_image_url");
-    game.board.migrations = Array.from(migrations);
-
-    store.update("game", game._id, game);
-  });
-};
-
-export const main = async ({ store, functions, schedules }) => {
+export const main = async ({ store, functions, schedules, hooks }) => {
   // Add remote functions
-  functions.saveGame = saveGame;
+  /*functions.saveGame = saveGame;
   functions.deleteGame = deleteGame;
   functions.test = ({ store }) => {
     console.log("Test function call is a success", store);
-  };
+  };*/
+
+  hooks.before = [ownerOrNewHooks];
+  hooks.beforeFile = [ownerOrNewHooks];
 
   // Declare stores
   await store.createOrUpdateBox("game", { security: "readOnly" });

+ 48 - 0
backend/src/migrations.js

@@ -0,0 +1,48 @@
+export const replaceImageUrl = async ({ store }) => {
+  console.log("Migrate images...");
+
+  (await store.list("game")).forEach((game) => {
+    if (!Array.isArray(game.board.migrations)) {
+      game.board.migrations = [];
+    }
+
+    const migrations = new Set(game.board.migrations);
+
+    const from = "public.jeremiez.net/airboardgame/";
+    const to = "public.jeremiez.net/ricochet/";
+
+    if (migrations.has("migrate_image_url")) {
+      return;
+    }
+    game.items = game.items.map((item) => {
+      if (item.type === "image") {
+        const newItem = { ...item };
+        if (newItem.content && newItem.content.includes(from)) {
+          newItem.content = newItem.content.replace(from, to);
+        }
+        if (
+          newItem.overlay &&
+          newItem.overlay.content &&
+          newItem.overlay.content.includes(from)
+        ) {
+          newItem.overlay = newItem.overlay.replace(from, to);
+        }
+        if (newItem.backContent && newItem.backContent.includes(from)) {
+          newItem.backContent = newItem.backContent.replace(from, to);
+        }
+        return newItem;
+      } else {
+        return item;
+      }
+    });
+
+    if (game.board.imageUrl && game.board.imageUrl.includes(from)) {
+      game.board.imageUrl = game.board.imageUrl.replace(from, to);
+    }
+
+    migrations.add("migrate_image_url");
+    game.board.migrations = Array.from(migrations);
+
+    store.update("game", game._id, game);
+  });
+};

+ 0 - 2
backend/src/saveGame.js

@@ -1,5 +1,3 @@
-console.log("Save game");
-
 const throwError = (message, code = 400) => {
   const errorObject = new Error(message);
   errorObject.statusCode = code;

+ 5 - 0
backend/src/utils.js

@@ -0,0 +1,5 @@
+export const throwError = (message, code = 400) => {
+  const errorObject = new Error(message);
+  errorObject.statusCode = code;
+  throw errorObject;
+};

+ 3 - 2
src/App.jsx

@@ -17,6 +17,7 @@ import "./react-confirm-alert.css";
 
 import Home from "./views/Home";
 import GameView from "./views/GameView";
+import SessionView from "./views/SessionView";
 import AuthView from "./views/AuthView";
 
 import Waiter from "./ui/Waiter";
@@ -38,10 +39,10 @@ const App = () => {
               }}
             </Route>
             <Route path="/game/:gameId/session/:room/">
-              <GameView />
+              <SessionView />
             </Route>
             <Route path="/game/:gameId?">
-              <GameView edit />
+              <GameView />
             </Route>
             <Route exact path="/login/:userHash/:token">
               <AuthView />

+ 9 - 1
src/components/BoardConfig.jsx

@@ -8,6 +8,7 @@ import Label from "../ui/formUtils/Label";
 import useBoardConfig from "./useBoardConfig";
 import ImageField from "../ui/formUtils/ImageField";
 import Hint from "../ui/formUtils/Hint";
+import { useGame } from "../hooks/useGame";
 
 import { Range } from "rc-slider";
 import { nanoid } from "nanoid";
@@ -23,6 +24,7 @@ const BoardConfigForm = styled.div`
 const BoardConfig = () => {
   const { t } = useTranslation();
   const [boardConfig, setBoardConfig] = useBoardConfig();
+  const { addFile } = useGame();
 
   const [defaultPlayerCount] = React.useState([]);
 
@@ -162,7 +164,13 @@ const BoardConfig = () => {
             {t("Image")}
             <Field name="imageUrl" initialValue={boardConfig.imageUrl}>
               {({ input: { value, onChange } }) => {
-                return <ImageField value={value} onChange={onChange} />;
+                return (
+                  <ImageField
+                    value={value}
+                    onChange={onChange}
+                    uploadFile={addFile}
+                  />
+                );
               }}
             </Field>
           </Label>

+ 7 - 2
src/components/ImageDropNPaste.jsx

@@ -10,11 +10,15 @@ import { nanoid } from "nanoid";
 import { useRecoilCallback } from "recoil";
 import { useTranslation } from "react-i18next";
 
+import { useGame } from "../hooks/useGame";
+
 const ImageDropNPaste = ({ namespace, children }) => {
   const { t } = useTranslation();
   const [uploading, setUploading] = React.useState(false);
   const { pushItem } = useItems();
 
+  const { addFile } = useGame();
+
   const addImageItem = useRecoilCallback(
     ({ snapshot }) => async (location) => {
       const { centerX, centerY } = await snapshot.getPromise(PanZoomRotateAtom);
@@ -34,13 +38,14 @@ const ImageDropNPaste = ({ namespace, children }) => {
       setUploading(true);
       await Promise.all(
         acceptedFiles.map(async (file) => {
-          const location = await uploadImage(namespace, file);
+          const location = await addFile(file);
+          //const location = await uploadImage(namespace, file);
           await addImageItem(location);
         })
       );
       setUploading(false);
     },
-    [addImageItem, namespace]
+    [addImageItem, addFile]
   );
 
   const { getRootProps } = useDropzone({ onDrop });

+ 4 - 1
src/components/boardComponents/Image.jsx

@@ -1,6 +1,7 @@
 import React, { memo } from "react";
 import { useUsers } from "../users";
 import styled from "styled-components";
+import { media2Url } from "../../utils/media";
 
 import eye from "../../images/eye.svg";
 
@@ -84,6 +85,8 @@ const Image = ({
 }) => {
   const { currentUser, users } = useUsers();
 
+  const imageContent = media2Url(content);
+
   const size = {};
 
   if (width) {
@@ -119,7 +122,7 @@ const Image = ({
       </UnflippedFor>
       <FrontImage
         visible={!flippedForMe}
-        src={content}
+        src={imageContent}
         alt=""
         draggable={false}
         {...size}

+ 25 - 3
src/components/boardComponents/forms/ImageFormFields.jsx

@@ -6,7 +6,11 @@ import Label from "../../../ui/formUtils/Label";
 
 import ImageField from "../../../ui/formUtils/ImageField";
 
+import { useGame } from "../../../hooks/useGame";
+
 const ImageForm = ({ initialValues }) => {
+  const { addFile } = useGame();
+
   const { t } = useTranslation();
   return (
     <>
@@ -58,7 +62,13 @@ const ImageForm = ({ initialValues }) => {
         {t("Front image")}
         <Field name="content" initialValue={initialValues.content}>
           {({ input: { value, onChange } }) => {
-            return <ImageField value={value} onChange={onChange} />;
+            return (
+              <ImageField
+                value={value}
+                onChange={onChange}
+                uploadFile={addFile}
+              />
+            );
           }}
         </Field>
       </Label>
@@ -67,7 +77,13 @@ const ImageForm = ({ initialValues }) => {
         {t("Back image")}
         <Field name="backContent" initialValue={initialValues.backContent}>
           {({ input: { value, onChange } }) => {
-            return <ImageField value={value} onChange={onChange} />;
+            return (
+              <ImageField
+                value={value}
+                onChange={onChange}
+                uploadFile={addFile}
+              />
+            );
           }}
         </Field>
       </Label>
@@ -80,7 +96,13 @@ const ImageForm = ({ initialValues }) => {
           }
         >
           {({ input: { value, onChange } }) => {
-            return <ImageField value={value} onChange={onChange} />;
+            return (
+              <ImageField
+                value={value}
+                onChange={onChange}
+                uploadFile={addFile}
+              />
+            );
           }}
         </Field>
       </Label>

+ 15 - 1
src/hooks/useGame.jsx

@@ -13,6 +13,9 @@ import {
 } from "../components/Board";
 import useBoardConfig from "../components/useBoardConfig";
 
+import { uploadResourceImage } from "../utils/api";
+import { API_BASE } from "../utils/settings";
+
 export const GameContext = React.createContext({});
 
 export const GameProvider = ({ gameId, game, children }) => {
@@ -61,6 +64,17 @@ export const GameProvider = ({ gameId, game, children }) => {
     []
   );
 
+  const addFile = React.useCallback(
+    async (file) => {
+      const filePath = await uploadResourceImage("game", gameId, file);
+      return {
+        type: "local",
+        content: filePath,
+      };
+    },
+    [gameId]
+  );
+
   React.useEffect(() => {
     if (game) {
       setGame(game);
@@ -69,7 +83,7 @@ export const GameProvider = ({ gameId, game, children }) => {
 
   return (
     <GameContext.Provider
-      value={{ setGame, getGame: getCurrentGame, gameId, gameLoaded }}
+      value={{ setGame, getGame: getCurrentGame, gameId, gameLoaded, addFile }}
     >
       {gameLoaded && children}
       <SubscribeGameEvents getGame={getCurrentGame} setGame={setGame} />

+ 87 - 24
src/ui/formUtils/ImageField.jsx

@@ -2,33 +2,50 @@ import React from "react";
 import { useTranslation } from "react-i18next";
 import styled from "styled-components";
 import { useDropzone } from "react-dropzone";
-import { uploadImage } from "../../utils/api";
-import { useGame } from "../../hooks/useGame";
+import { media2Url } from "../../utils/media";
 
 const Thumbnail = styled.img`
   height: 50px;
+  display: block;
+  width: 100%;
 `;
 
 const RemoveButton = styled.span`
   position: absolute;
   top: 1px;
   right: 1px;
+  cursor: pointer;
 `;
 
-const ImageField = ({ value, onChange }) => {
+const ImageField = ({ value, onChange, uploadFile = () => {} }) => {
   const { t } = useTranslation();
   const [uploading, setUploading] = React.useState(false);
-  const { gameId } = useGame();
+
+  let type, content;
+
+  // Manage compat with
+  if (typeof value === "object") {
+    type = value.type;
+    content = value.content;
+  } else {
+    if (value) {
+      type = "external";
+      content = value;
+    } else {
+      type = "empty";
+      content = null;
+    }
+  }
 
   const onDrop = React.useCallback(
     async (acceptedFiles) => {
       const file = acceptedFiles[0];
       setUploading(true);
-      const location = await uploadImage(gameId, file);
+      const location = await uploadFile(file);
       onChange(location);
       setUploading(false);
     },
-    [gameId, onChange]
+    [onChange, uploadFile]
   );
 
   const { getRootProps, getInputProps } = useDropzone({ onDrop });
@@ -37,35 +54,81 @@ const ImageField = ({ value, onChange }) => {
     (e) => {
       e.stopPropagation();
       e.preventDefault();
-      onChange(null);
+      onChange({ type: "empty", content: null });
       return false;
     },
     [onChange]
   );
 
   const handleInputChange = (e) => {
-    onChange(e.target.value);
+    onChange({ type, content: e.target.value });
+  };
+
+  const handleTypeChange = (e) => {
+    console.log(e.target.value);
+    onChange({ type: e.target.value, content });
   };
 
+  const url = media2Url(value);
+  console.log("tive", value, url, type);
+
   return (
     <div style={{ position: "relative" }}>
-      {!value && !uploading && <p>{t("No image")}</p>}
+      {(type === "empty" || !content) && !uploading && <p>{t("No image")}</p>}
       {uploading && <p>{t("Sending file...")}</p>}
-      {value && <Thumbnail src={value} />}
-      {value && <RemoveButton onClick={handleRemove}>X</RemoveButton>}
-      <input value={value} onChange={handleInputChange} />
-      <div
-        {...getRootProps()}
-        style={{
-          border: "3px dashed white",
-          margin: "0.5em",
-          padding: "0.5em",
-          textAlign: "center",
-        }}
-      >
-        <input {...getInputProps()} />
-        <p>{t("Click or drag'n'drop file here")}</p>
-      </div>
+      {type !== "empty" && content && <Thumbnail src={url} />}
+      {type !== "empty" && (
+        <RemoveButton onClick={handleRemove}>X</RemoveButton>
+      )}
+      <label>
+        <input
+          type="radio"
+          value="empty"
+          onChange={handleTypeChange}
+          checked={type === "empty"}
+        />
+        No image
+      </label>
+      <label>
+        <input
+          type="radio"
+          value="local"
+          onChange={handleTypeChange}
+          checked={type === "local"}
+        />
+        Library
+      </label>
+      <label>
+        <input
+          type="radio"
+          value="external"
+          checked={type === "external"}
+          onChange={handleTypeChange}
+        />
+        External
+      </label>
+
+      {type === "external" && (
+        <input
+          value={content}
+          placeholder={t("Enter an image url...")}
+          onChange={handleInputChange}
+        />
+      )}
+      {type === "local" && (
+        <div
+          {...getRootProps()}
+          style={{
+            border: "3px dashed white",
+            margin: "0.5em",
+            padding: "0.5em",
+            textAlign: "center",
+          }}
+        >
+          <input {...getInputProps()} />
+          <p>{t("Click or drag'n'drop file here")}</p>
+        </div>
+      )}
     </div>
   );
 };

+ 43 - 11
src/utils/api.js

@@ -1,21 +1,20 @@
-import { API_ENDPOINT, IS_PRODUCTION, SITEID } from "./settings";
-//import { nanoid } from "nanoid";
+import { API_ENDPOINT, IS_PRODUCTION } from "./settings";
 
 import testGame from "../games/testGame";
 import perfGame from "../games/perfGame";
 import unpublishedGame from "../games/unpublishedGame";
 import { nanoid } from "nanoid";
 
-const uploadURI = `${API_ENDPOINT}/file`;
-const gameURI = `${API_ENDPOINT}/${SITEID}/store/game`;
-const sessionURI = `${API_ENDPOINT}/${SITEID}/store/session`;
-const execURI = `${API_ENDPOINT}/${SITEID}/execute`;
-const authURI = `${API_ENDPOINT}/${SITEID}/auth`;
+const oldUploadURI = `${API_ENDPOINT}/file`;
+const gameURI = `${API_ENDPOINT}/store/game`;
+const sessionURI = `${API_ENDPOINT}/store/session`;
+//const execURI = `${API_ENDPOINT}/execute`;
+const authURI = `${API_ENDPOINT}/auth`;
 
 export const uploadImage = async (namespace, file) => {
   const payload = new FormData();
   payload.append("file", file);
-  const result = await fetch(`${uploadURI}/${namespace}/`, {
+  const result = await fetch(`${oldUploadURI}/${namespace}/`, {
     method: "POST",
     body: payload, // this sets the `Content-Type` header to `multipart/form-data`
   });
@@ -23,7 +22,40 @@ export const uploadImage = async (namespace, file) => {
   return await result.text();
 };
 
-// TODO Add delete Image
+export const uploadResourceImage = async (boxId, resourceId, file) => {
+  const uploadGameURI = `${API_ENDPOINT}/store/${boxId}/${resourceId}/file`;
+
+  const payload = new FormData();
+  payload.append("file", file);
+
+  const result = await fetch(`${uploadGameURI}/`, {
+    method: "POST",
+    body: payload, // this sets the `Content-Type` header to `multipart/form-data`
+    credentials: "include",
+  });
+
+  return await result.text();
+};
+
+export const listResourceImage = async (boxId, resourceId) => {
+  const uploadGameURI = `${API_ENDPOINT}/store/${boxId}/${resourceId}/file`;
+
+  const result = await fetch(`${uploadGameURI}/`, {
+    method: "GET",
+    credentials: "include",
+  });
+
+  return await result.json();
+};
+
+export const deleteResourceImage = async (filePath) => {
+  const result = await fetch(`${API_ENDPOINT}/${filePath}`, {
+    method: "DELETE",
+    credentials: "include",
+  });
+
+  return await result.json();
+};
 
 export const getBestTranslationFromConfig = (
   {
@@ -143,7 +175,7 @@ export const createGame = async (data) => {
 };
 
 export const updateGame = async (id, data) => {
-  const result = await fetch(`${execURI}/saveGame/${id}`, {
+  const result = await fetch(`${gameURI}/${id}`, {
     method: "POST",
     headers: {
       Accept: "application/json",
@@ -165,7 +197,7 @@ export const updateGame = async (id, data) => {
 };
 
 export const deleteGame = async (id) => {
-  const result = await fetch(`${execURI}/deleteGame/${id}`, {
+  const result = await fetch(`${gameURI}/${id}`, {
     method: "POST",
     credentials: "include",
   });

+ 19 - 0
src/utils/media.js

@@ -0,0 +1,19 @@
+import { API_BASE } from "./settings";
+
+export const media2Url = (value) => {
+  if (typeof content === "object") {
+    switch (value.type) {
+      case "local":
+        return `${API_BASE}/${value.content}`;
+      case "external":
+        return value.content;
+      case "dataUrl":
+        return value.content;
+      case "empty":
+        return null;
+      default:
+      // do nothing
+    }
+  }
+  return value;
+};

+ 6 - 0
src/utils/settings.js

@@ -3,6 +3,12 @@ export const USE_PROXY =
 
 export const SITEID = import.meta.env.VITE_RICOCHET_SITEID;
 
+export const API_BASE = USE_PROXY
+  ? ""
+  : `${import.meta.env.VITE_API_ENDPOINT}` ||
+    `${window.location.origin}` ||
+    "http://localhost:3001";
+
 export const API_ENDPOINT = USE_PROXY
   ? `/${SITEID}`
   : `${import.meta.env.VITE_API_ENDPOINT}/${SITEID}` ||

+ 1 - 1
src/views/BoardView.jsx

@@ -119,7 +119,7 @@ export const BoardView = ({ namespace, edit: editMode = false, session }) => {
         </BoardContainer>
       )}
       <ActionBar>
-        <MessageButton />
+        {!editMode && <MessageButton />}
         <div className="spacer" />
         <Touch
           onClick={() => setMoveFirst(false)}

+ 10 - 16
src/views/GameView.jsx

@@ -12,7 +12,7 @@ import { SOCKET_URL, SOCKET_OPTIONS } from "../utils/settings";
 import BoardView from "../views/BoardView";
 import Waiter from "../ui/Waiter";
 
-import { getGame, getSession } from "../utils/api";
+import { getGame } from "../utils/api";
 
 import GameProvider from "../hooks/useGame";
 import { useTranslation } from "react-i18next";
@@ -23,7 +23,7 @@ const newGameData = {
   board: { size: 2000, scale: 1, name: "New game" },
 };
 
-export const GameView = ({ edit, session }) => {
+export const GameView = ({ session }) => {
   const { c2c, joined, isMaster } = useC2C();
   const { gameId } = useParams();
   const [realGameId, setRealGameId] = React.useState();
@@ -48,13 +48,7 @@ export const GameView = ({ edit, session }) => {
           setRealGameId(nanoid());
         } else {
           // Load game from server
-          try {
-            // First from session if exists
-            gameData = await getSession(session);
-          } catch {
-            // Then from initial game
-            gameData = await getGame(gameId);
-          }
+          gameData = await getGame(gameId);
 
           setRealGameId(gameId);
         }
@@ -68,10 +62,10 @@ export const GameView = ({ edit, session }) => {
         if (!isMounted) return;
 
         setGame(gameData);
-        const { messages = [] } = gameData;
-        setMessages(messages.map((m) => parseMessage(m)));
+
         // Send loadGame event for other user
-        c2c.publish("loadGame", gameData);
+        //c2c.publish("loadGame", gameData);
+
         setGameLoaded(true);
       } catch (e) {
         console.log(e);
@@ -86,7 +80,7 @@ export const GameView = ({ edit, session }) => {
     return () => {
       isMounted = false;
     };
-  }, [c2c, gameId, gameLoaded, isMaster, joined, session, t]);
+  }, [c2c, gameId, gameLoaded, isMaster, joined, setMessages, t]);
 
   // Load game from master if any
   React.useEffect(() => {
@@ -115,17 +109,17 @@ export const GameView = ({ edit, session }) => {
 
   return (
     <GameProvider game={game} gameId={realGameId}>
-      <BoardView namespace={realGameId} edit={edit} session={session} />
+      <BoardView namespace={realGameId} edit={true} session={session} />
     </GameProvider>
   );
 };
 
-const ConnectedGameView = ({ edit = false }) => {
+const ConnectedGameView = () => {
   const { room = nanoid() } = useParams();
   return (
     <Provider url={SOCKET_URL} options={SOCKET_OPTIONS}>
       <C2CProvider room={room}>
-        <GameView edit={edit} session={room} />
+        <GameView session={room} />
       </C2CProvider>
     </Provider>
   );

+ 129 - 0
src/views/SessionView.jsx

@@ -0,0 +1,129 @@
+import React from "react";
+import { useSetRecoilState } from "recoil";
+import { useParams } from "react-router-dom";
+import { nanoid } from "nanoid";
+import { Provider } from "@scripters/use-socket.io";
+
+import { C2CProvider, useC2C } from "../hooks/useC2C";
+import { MessagesAtom, parseMessage } from "../hooks/useMessage";
+
+import { SOCKET_URL, SOCKET_OPTIONS } from "../utils/settings";
+
+import BoardView from "../views/BoardView";
+import Waiter from "../ui/Waiter";
+
+import { getGame, getSession } from "../utils/api";
+
+import GameProvider from "../hooks/useGame";
+import { useTranslation } from "react-i18next";
+
+const newGameData = {
+  items: [],
+  availableItems: [],
+  board: { size: 2000, scale: 1, name: "New game" },
+};
+
+export const GameView = ({ session }) => {
+  const { c2c, joined, isMaster } = useC2C();
+  const { gameId } = useParams();
+  const [realGameId, setRealGameId] = React.useState();
+  const [gameLoaded, setGameLoaded] = React.useState(false);
+  const [game, setGame] = React.useState(null);
+  const gameLoadingRef = React.useRef(false);
+  const setMessages = useSetRecoilState(MessagesAtom);
+
+  const { t } = useTranslation();
+
+  React.useEffect(() => {
+    let isMounted = true;
+
+    const loadGameInitialData = async () => {
+      try {
+        let gameData;
+
+        // Load game from server
+        try {
+          // First from session if exists
+          gameData = await getSession(session);
+        } catch {
+          // Then from initial game
+          gameData = await getGame(gameId);
+        }
+
+        setRealGameId(gameId);
+
+        // Add id if necessary
+        gameData.items = gameData.items.map((item) => ({
+          ...item,
+          id: nanoid(),
+        }));
+
+        if (!isMounted) return;
+
+        setGame(gameData);
+        const { messages = [] } = gameData;
+        setMessages(messages.map((m) => parseMessage(m)));
+
+        // Send loadGame event for other user
+        c2c.publish("loadGame", gameData);
+
+        setGameLoaded(true);
+      } catch (e) {
+        console.log(e);
+      }
+    };
+
+    if (joined && isMaster && !gameLoaded && !gameLoadingRef.current) {
+      gameLoadingRef.current = true;
+      loadGameInitialData();
+    }
+
+    return () => {
+      isMounted = false;
+    };
+  }, [c2c, gameId, gameLoaded, isMaster, joined, session, setMessages, t]);
+
+  // Load game from master if any
+  React.useEffect(() => {
+    if (joined && !isMaster && !gameLoaded && !gameLoadingRef.current) {
+      gameLoadingRef.current = true;
+      const onReceiveGame = (receivedGame) => {
+        setGame(receivedGame);
+        setGameLoaded(true);
+      };
+      c2c.call("getGame").then(onReceiveGame, () => {
+        setTimeout(
+          c2c
+            .call("getGame")
+            .then(onReceiveGame, (error) =>
+              console.log("Failed to call getGame with error", error)
+            ),
+          1000
+        );
+      });
+    }
+  }, [c2c, isMaster, joined, gameLoaded]);
+
+  if (!gameLoaded) {
+    return <Waiter message={t("Game loading...")} />;
+  }
+
+  return (
+    <GameProvider game={game} gameId={realGameId}>
+      <BoardView namespace={realGameId} session={session} />
+    </GameProvider>
+  );
+};
+
+const ConnectedGameView = () => {
+  const { room = nanoid() } = useParams();
+  return (
+    <Provider url={SOCKET_URL} options={SOCKET_OPTIONS}>
+      <C2CProvider room={room}>
+        <GameView session={room} />
+      </C2CProvider>
+    </Provider>
+  );
+};
+
+export default ConnectedGameView;