From ca0240351c5cb2863b5ee800eb7789cbb6ed45e9 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Fri, 1 May 2020 17:37:01 +1000 Subject: [PATCH 1/9] Moved to storing maps as uint arrays instead of blobs --- src/helpers/useDataSource.js | 2 +- src/modals/SelectMapModal.js | 54 +++++++++++++++++++----------------- src/routes/Game.js | 4 +-- 3 files changed, 31 insertions(+), 29 deletions(-) diff --git a/src/helpers/useDataSource.js b/src/helpers/useDataSource.js index 9eed094..4af57a1 100644 --- a/src/helpers/useDataSource.js +++ b/src/helpers/useDataSource.js @@ -11,7 +11,7 @@ function useDataSource(data, defaultSources) { } let url = null; if (data.type === "file") { - url = URL.createObjectURL(data.file); + url = URL.createObjectURL(new Blob([data.file])); } else if (data.type === "default") { url = defaultSources[data.key]; } diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index 89083e2..62a75b4 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -11,6 +11,7 @@ import MapSettings from "../components/map/MapSettings"; import AuthContext from "../contexts/AuthContext"; import usePrevious from "../helpers/usePrevious"; +import blobToBuffer from "../helpers/blobToBuffer"; import { maps as defaultMaps } from "../maps"; @@ -141,31 +142,34 @@ function SelectMapModal({ let image = new Image(); setImageLoading(true); - // Copy file to avoid permissions issues - const copy = new Blob([file], { type: file.type }); - // Create and load the image temporarily to get its dimensions - const url = URL.createObjectURL(copy); - image.onload = function () { - handleMapAdd({ - file: copy, - name, - type: "file", - gridX: fileGridX, - gridY: fileGridY, - width: image.width, - height: image.height, - id: shortid.generate(), - created: Date.now(), - lastModified: Date.now(), - owner: userId, - ...defaultMapProps, - }); - setImageLoading(false); - URL.revokeObjectURL(url); - }; - image.src = url; - // Set file input to null to allow adding the same image 2 times in a row - fileInputRef.current.value = null; + blobToBuffer(file).then((buffer) => { + // Copy file to avoid permissions issues + const blob = new Blob([buffer]); + // Create and load the image temporarily to get its dimensions + const url = URL.createObjectURL(blob); + image.onload = function () { + handleMapAdd({ + file: buffer, + name, + type: "file", + gridX: fileGridX, + gridY: fileGridY, + width: image.width, + height: image.height, + id: shortid.generate(), + created: Date.now(), + lastModified: Date.now(), + owner: userId, + ...defaultMapProps, + }); + setImageLoading(false); + URL.revokeObjectURL(url); + }; + image.src = url; + + // Set file input to null to allow adding the same image 2 times in a row + fileInputRef.current.value = null; + }); } function openImageDialog() { diff --git a/src/routes/Game.js b/src/routes/Game.js index d83c7ca..8243889 100644 --- a/src/routes/Game.js +++ b/src/routes/Game.js @@ -289,9 +289,7 @@ function Game() { if (data.id === "mapResponse") { setMapLoading(false); if (data.data && data.data.type === "file") { - // Convert file back to blob after peer transfer - const file = new Blob([data.data.file]); - const newMap = { ...data.data, file }; + const newMap = { ...data.data, file: data.data.file }; // Store in db db.table("maps") .put(newMap) From 05d5c76c86af1f6a0a10057c4caba7edf13f2420 Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Sun, 3 May 2020 10:44:26 +1000 Subject: [PATCH 2/9] Moved to msgpack instead of js-binarypack --- package.json | 2 +- src/helpers/Peer.js | 63 ++++++++++++++++++++++++++---------- src/helpers/blobToBuffer.js | 8 ++--- src/modals/SelectMapModal.js | 1 + yarn.lock | 10 +++--- 5 files changed, 56 insertions(+), 28 deletions(-) diff --git a/package.json b/package.json index 353b2ff..c408468 100644 --- a/package.json +++ b/package.json @@ -3,13 +3,13 @@ "version": "1.2.0", "private": true, "dependencies": { + "@msgpack/msgpack": "^1.12.1", "@stripe/stripe-js": "^1.3.2", "@testing-library/jest-dom": "^4.2.4", "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", "dexie": "^2.0.4", "interactjs": "^1.9.7", - "js-binarypack": "^0.0.9", "normalize-wheel": "^1.0.1", "react": "^16.13.0", "react-dom": "^16.13.0", diff --git a/src/helpers/Peer.js b/src/helpers/Peer.js index 04d8893..1c01515 100644 --- a/src/helpers/Peer.js +++ b/src/helpers/Peer.js @@ -1,5 +1,5 @@ import SimplePeer from "simple-peer"; -import BinaryPack from "js-binarypack"; +import { encode, decode } from "@msgpack/msgpack"; import shortid from "shortid"; import blobToBuffer from "./blobToBuffer"; @@ -14,7 +14,7 @@ class Peer extends SimplePeer { this.currentChunks = {}; this.on("data", (packed) => { - const unpacked = BinaryPack.unpack(packed); + const unpacked = decode(packed); // If the special property __chunked is set and true // The data is a partial chunk of the a larger file // So wait until all chunks are collected and assembled @@ -31,9 +31,13 @@ class Peer extends SimplePeer { // All chunks have been loaded if (chunk.count === chunk.total) { - const merged = BinaryPack.unpack(Buffer.concat(chunk.data)); - this.emit("dataComplete", merged); - delete this.currentChunks[unpacked.id]; + // Merge chunks with a blob + // TODO: Look at a more efficient way to recombine buffer data + const merged = new Blob(chunk.data); + blobToBuffer(merged).then((buffer) => { + this.emit("dataComplete", decode(buffer)); + delete this.currentChunks[unpacked.id]; + }); } } else { this.emit("dataComplete", unpacked); @@ -41,29 +45,24 @@ class Peer extends SimplePeer { }); } - async sendPackedData(packedData) { - const buffer = await blobToBuffer(packedData); - super.send(buffer); - } - send(data) { - const packedData = BinaryPack.pack(data); + const packedData = encode(data); - if (packedData.size > MAX_BUFFER_SIZE) { + if (packedData.byteLength > MAX_BUFFER_SIZE) { const chunks = this.chunk(packedData); for (let chunk of chunks) { - this.sendPackedData(BinaryPack.pack(chunk)); + super.send(encode(chunk)); } return; } else { - this.sendPackedData(packedData); + super.send(packedData); } } // Converted from https://github.com/peers/peerjs/ - chunk(blob) { + chunk(data) { const chunks = []; - const size = blob.size; + const size = data.byteLength; const total = Math.ceil(size / MAX_BUFFER_SIZE); const id = shortid.generate(); @@ -72,7 +71,7 @@ class Peer extends SimplePeer { while (start < size) { const end = Math.min(size, start + MAX_BUFFER_SIZE); - const slice = blob.slice(start, end); + const slice = data.slice(start, end); const chunk = { __chunked: true, @@ -89,6 +88,36 @@ class Peer extends SimplePeer { return chunks; } + + // // Converted from https://github.com/peers/peerjs/ + // chunk(blob) { + // const chunks = []; + // const size = blob.size; + // const total = Math.ceil(size / MAX_BUFFER_SIZE); + // const id = shortid.generate(); + + // let index = 0; + // let start = 0; + + // while (start < size) { + // const end = Math.min(size, start + MAX_BUFFER_SIZE); + // const slice = blob.slice(start, end); + + // const chunk = { + // __chunked: true, + // data: slice, + // id, + // index, + // total, + // }; + + // chunks.push(chunk); + // start = end; + // index++; + // } + + // return chunks; + // } } export default Peer; diff --git a/src/helpers/blobToBuffer.js b/src/helpers/blobToBuffer.js index 55eb6d7..c816ced 100644 --- a/src/helpers/blobToBuffer.js +++ b/src/helpers/blobToBuffer.js @@ -1,12 +1,10 @@ async function blobToBuffer(blob) { if (blob.arrayBuffer) { const arrayBuffer = await blob.arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - return buffer; + return new Uint8Array(arrayBuffer); } else { - const arrayBuffer = await new Response(blob).arrayBuffer(); - const buffer = Buffer.from(arrayBuffer); - return buffer; + const arrayBuffer = new Response(blob).arrayBuffer(); + return new Uint8Array(arrayBuffer); } } diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index 62a75b4..3ffa96b 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -149,6 +149,7 @@ function SelectMapModal({ const url = URL.createObjectURL(blob); image.onload = function () { handleMapAdd({ + // Save as a buffer to send with msgpack file: buffer, name, type: "file", diff --git a/yarn.lock b/yarn.lock index 3dce1d6..4996633 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1398,6 +1398,11 @@ call-me-maybe "^1.0.1" glob-to-regexp "^0.3.0" +"@msgpack/msgpack@^1.12.1": + version "1.12.1" + resolved "https://registry.yarnpkg.com/@msgpack/msgpack/-/msgpack-1.12.1.tgz#aab1084bc33c955501bc0f202099e38304143e0b" + integrity sha512-nGwwmkdm3tuLdEkWMIwLBgFBfMFILILxcZIQY0dfqsdboN2iZdKfOYKUOKoa0wXw1FL1PL3yEYGPCXhwodQDTA== + "@nodelib/fs.stat@^1.1.2": version "1.1.3" resolved "https://registry.yarnpkg.com/@nodelib/fs.stat/-/fs.stat-1.1.3.tgz#2b5a3ab3f918cca48a8c754c08168e3f03eba61b" @@ -6537,11 +6542,6 @@ jest@24.9.0: import-local "^2.0.0" jest-cli "^24.9.0" -js-binarypack@^0.0.9: - version "0.0.9" - resolved "https://registry.yarnpkg.com/js-binarypack/-/js-binarypack-0.0.9.tgz#454243d3de212961cc1514a2f119dec2faf64035" - integrity sha1-RUJD094hKWHMFRSi8Rnewvr2QDU= - "js-tokens@^3.0.0 || ^4.0.0", js-tokens@^4.0.0: version "4.0.0" resolved "https://registry.yarnpkg.com/js-tokens/-/js-tokens-4.0.0.tgz#19203fb59991df98e3a287050d4647cdeaf32499" From 60059ff44797864758616ee7b19cd79ae59b586a Mon Sep 17 00:00:00 2001 From: Mitchell McCaffrey Date: Sun, 3 May 2020 18:22:09 +1000 Subject: [PATCH 3/9] Added database faker for when indexedb is disabled Database is now in a context with a status New FAQ for indexdb errors --- package.json | 1 + src/App.js | 39 +++++----- src/components/map/MapTiles.js | 124 +++++++++++++++++++------------- src/contexts/AuthContext.js | 32 +++++---- src/contexts/DatabaseContext.js | 54 ++++++++++++++ src/database.js | 11 --- src/modals/SelectMapModal.js | 34 ++++----- src/modals/SettingsModal.js | 8 +-- src/routes/FAQ.js | 14 ++++ src/routes/Game.js | 19 +++-- yarn.lock | 57 ++++++++++++--- 11 files changed, 264 insertions(+), 129 deletions(-) create mode 100644 src/contexts/DatabaseContext.js delete mode 100644 src/database.js diff --git a/package.json b/package.json index c408468..aefedb6 100644 --- a/package.json +++ b/package.json @@ -9,6 +9,7 @@ "@testing-library/react": "^9.3.2", "@testing-library/user-event": "^7.1.2", "dexie": "^2.0.4", + "fake-indexeddb": "^3.0.0", "interactjs": "^1.9.7", "normalize-wheel": "^1.0.1", "react": "^16.13.0", diff --git a/src/App.js b/src/App.js index 09fc5e3..470a417 100644 --- a/src/App.js +++ b/src/App.js @@ -9,28 +9,31 @@ import About from "./routes/About"; import FAQ from "./routes/FAQ"; import { AuthProvider } from "./contexts/AuthContext"; +import { DatabaseProvider } from "./contexts/DatabaseContext"; function App() { return ( - - - - - - - - - - - - - - - - - - + + + + + + + + + + + + + + + + + + + + ); } diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js index 5babba3..a71e761 100644 --- a/src/components/map/MapTiles.js +++ b/src/components/map/MapTiles.js @@ -1,10 +1,13 @@ -import React from "react"; -import { Flex } from "theme-ui"; +import React, { useContext } from "react"; +import { Flex, Box, Text } from "theme-ui"; import SimpleBar from "simplebar-react"; import AddIcon from "../../icons/AddIcon"; import MapTile from "./MapTile"; +import Link from "../Link"; + +import DatabaseContext from "../../contexts/DatabaseContext"; function MapTiles({ maps, @@ -16,59 +19,80 @@ function MapTiles({ onMapReset, onSubmit, }) { + const { databaseStatus } = useContext(DatabaseContext); return ( - - + + - + + + + {maps.map((map) => ( + + ))} - {maps.map((map) => ( - - ))} - - + + {databaseStatus === "disabled" && ( + + + Map saving is unavailable. See FAQ for more + information. + + + )} + ); } diff --git a/src/contexts/AuthContext.js b/src/contexts/AuthContext.js index 0d71b2c..e9aced1 100644 --- a/src/contexts/AuthContext.js +++ b/src/contexts/AuthContext.js @@ -1,13 +1,15 @@ -import React, { useState, useEffect } from "react"; +import React, { useState, useEffect, useContext } from "react"; import shortid from "shortid"; -import { getRandomMonster } from "../helpers/monsters"; +import DatabaseContext from "./DatabaseContext"; -import db from "../database"; +import { getRandomMonster } from "../helpers/monsters"; const AuthContext = React.createContext(); export function AuthProvider({ children }) { + const { database } = useContext(DatabaseContext); + const [password, setPassword] = useState( sessionStorage.getItem("auth") || "" ); @@ -20,41 +22,47 @@ export function AuthProvider({ children }) { const [userId, setUserId] = useState(); useEffect(() => { + if (!database) { + return; + } async function loadUserId() { - const storedUserId = await db.table("user").get("userId"); + const storedUserId = await database.table("user").get("userId"); if (storedUserId) { setUserId(storedUserId.value); } else { const id = shortid.generate(); setUserId(id); - db.table("user").add({ key: "userId", value: id }); + database.table("user").add({ key: "userId", value: id }); } } loadUserId(); - }, []); + }, [database]); const [nickname, setNickname] = useState(""); useEffect(() => { + if (!database) { + return; + } async function loadNickname() { - const storedNickname = await db.table("user").get("nickname"); + const storedNickname = await database.table("user").get("nickname"); if (storedNickname) { setNickname(storedNickname.value); } else { const name = getRandomMonster(); setNickname(name); - db.table("user").add({ key: "nickname", value: name }); + database.table("user").add({ key: "nickname", value: name }); } } loadNickname(); - }, []); + }, [database]); useEffect(() => { - if (nickname !== undefined) { - db.table("user").update("nickname", { value: nickname }); + if (nickname !== undefined && database !== undefined) { + database.table("user").update("nickname", { value: nickname }); } - }, [nickname]); + }, [nickname, database]); const value = { userId, diff --git a/src/contexts/DatabaseContext.js b/src/contexts/DatabaseContext.js new file mode 100644 index 0000000..7bbac74 --- /dev/null +++ b/src/contexts/DatabaseContext.js @@ -0,0 +1,54 @@ +import React, { useState, useEffect } from "react"; +import Dexie from "dexie"; + +const DatabaseContext = React.createContext(); + +export function DatabaseProvider({ children }) { + const [database, setDatabase] = useState(); + const [databaseStatus, setDatabaseStatus] = useState("loading"); + + function loadVersions(db) { + db.version(1).stores({ + maps: "id, owner", + states: "mapId", + tokens: "id, owner", + user: "key", + }); + } + + useEffect(() => { + // Create a test database and open it to see if indexedDB is enabled + let testDBRequest = window.indexedDB.open("__test"); + testDBRequest.onsuccess = function () { + testDBRequest.result.close(); + let db = new Dexie("OwlbearRodeoDB"); + loadVersions(db); + setDatabase(db); + setDatabaseStatus("loaded"); + window.indexedDB.deleteDatabase("__test"); + }; + // If indexedb disabled create an in memory database + testDBRequest.onerror = async function () { + console.warn("Database is disabled, no state will be saved"); + const indexedDB = await import("fake-indexeddb"); + const IDBKeyRange = await import("fake-indexeddb/lib/FDBKeyRange"); + let db = new Dexie("OwlbearRodeoDB", { indexedDB, IDBKeyRange }); + loadVersions(db); + setDatabase(db); + setDatabaseStatus("disabled"); + window.indexedDB.deleteDatabase("__test"); + }; + }, []); + + const value = { + database, + databaseStatus, + }; + return ( + + {children} + + ); +} + +export default DatabaseContext; diff --git a/src/database.js b/src/database.js deleted file mode 100644 index c1ad236..0000000 --- a/src/database.js +++ /dev/null @@ -1,11 +0,0 @@ -import Dexie from "dexie"; - -const db = new Dexie("OwlbearRodeoDB"); -db.version(1).stores({ - maps: "id, owner", - states: "mapId", - tokens: "id, owner", - user: "key", -}); - -export default db; diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index 3ffa96b..cb0cbcb 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -2,13 +2,12 @@ import React, { useRef, useState, useEffect, useContext } from "react"; import { Box, Button, Flex, Label, Text } from "theme-ui"; import shortid from "shortid"; -import db from "../database"; - import Modal from "../components/Modal"; import MapTiles from "../components/map/MapTiles"; import MapSettings from "../components/map/MapSettings"; import AuthContext from "../contexts/AuthContext"; +import DatabaseContext from "../contexts/DatabaseContext"; import usePrevious from "../helpers/usePrevious"; import blobToBuffer from "../helpers/blobToBuffer"; @@ -43,6 +42,7 @@ function SelectMapModal({ // The map currently being view in the map screen currentMap, }) { + const { database } = useContext(DatabaseContext); const { userId } = useContext(AuthContext); const wasOpen = usePrevious(isOpen); @@ -55,7 +55,7 @@ function SelectMapModal({ const [maps, setMaps] = useState([]); // Load maps from the database and ensure state is properly setup useEffect(() => { - if (!userId) { + if (!userId || !database) { return; } async function getDefaultMaps() { @@ -73,16 +73,16 @@ function SelectMapModal({ ...defaultMapProps, }); // Add a state for the map if there isn't one already - const state = await db.table("states").get(id); + const state = await database.table("states").get(id); if (!state) { - await db.table("states").add({ ...defaultMapState, mapId: id }); + await database.table("states").add({ ...defaultMapState, mapId: id }); } } return defaultMapsWithIds; } async function loadMaps() { - let storedMaps = await db + let storedMaps = await database .table("maps") .where({ owner: userId }) .toArray(); @@ -93,7 +93,7 @@ function SelectMapModal({ // reload map state as is may have changed while the modal was closed if (selectedMap) { - const state = await db.table("states").get(selectedMap.id); + const state = await database.table("states").get(selectedMap.id); if (state) { setSelectedMapState(state); } @@ -103,7 +103,7 @@ function SelectMapModal({ if (!wasOpen && isOpen) { loadMaps(); } - }, [userId, isOpen, wasOpen, selectedMap]); + }, [userId, database, isOpen, wasOpen, selectedMap]); const fileInputRef = useRef(); @@ -180,21 +180,21 @@ function SelectMapModal({ } async function handleMapAdd(map) { - await db.table("maps").add(map); + await database.table("maps").add(map); const state = { ...defaultMapState, mapId: map.id }; - await db.table("states").add(state); + await database.table("states").add(state); setMaps((prevMaps) => [map, ...prevMaps]); setSelectedMap(map); setSelectedMapState(state); } async function handleMapRemove(id) { - await db.table("maps").delete(id); - await db.table("states").delete(id); + await database.table("maps").delete(id); + await database.table("states").delete(id); setMaps((prevMaps) => { const filtered = prevMaps.filter((map) => map.id !== id); setSelectedMap(filtered[0]); - db.table("states").get(filtered[0].id).then(setSelectedMapState); + database.table("states").get(filtered[0].id).then(setSelectedMapState); return filtered; }); // Removed the map from the map screen if needed @@ -204,14 +204,14 @@ function SelectMapModal({ } async function handleMapSelect(map) { - const state = await db.table("states").get(map.id); + const state = await database.table("states").get(map.id); setSelectedMapState(state); setSelectedMap(map); } async function handleMapReset(id) { const state = { ...defaultMapState, mapId: id }; - await db.table("states").put(state); + await database.table("states").put(state); setSelectedMapState(state); // Reset the state of the current map if needed if (currentMap && currentMap.id === selectedMap.id) { @@ -261,7 +261,7 @@ function SelectMapModal({ async function handleMapSettingsChange(key, value) { const change = { [key]: value, lastModified: Date.now() }; - db.table("maps").update(selectedMap.id, change); + database.table("maps").update(selectedMap.id, change); const newMap = { ...selectedMap, ...change }; setMaps((prevMaps) => { const newMaps = [...prevMaps]; @@ -275,7 +275,7 @@ function SelectMapModal({ } async function handleMapStateSettingsChange(key, value) { - db.table("states").update(selectedMap.id, { [key]: value }); + database.table("states").update(selectedMap.id, { [key]: value }); setSelectedMapState((prevState) => ({ ...prevState, [key]: value })); } diff --git a/src/modals/SettingsModal.js b/src/modals/SettingsModal.js index c60cb20..43fbe55 100644 --- a/src/modals/SettingsModal.js +++ b/src/modals/SettingsModal.js @@ -4,20 +4,20 @@ import { Box, Label, Flex, Button, useColorMode, Checkbox } from "theme-ui"; import Modal from "../components/Modal"; import AuthContext from "../contexts/AuthContext"; - -import db from "../database"; +import DatabaseContext from "../contexts/DatabaseContext"; function SettingsModal({ isOpen, onRequestClose }) { + const { database } = useContext(DatabaseContext); const { userId } = useContext(AuthContext); const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false); async function handleEraseAllData() { - await db.delete(); + await database.delete(); window.location.reload(); } async function handleClearCache() { - await db.table("maps").where("owner").notEqual(userId).delete(); + await database.table("maps").where("owner").notEqual(userId).delete(); // TODO: With custom tokens look up all tokens that aren't being used in a state window.location.reload(); } diff --git a/src/routes/FAQ.js b/src/routes/FAQ.js index 03a548a..8cb6e3e 100644 --- a/src/routes/FAQ.js +++ b/src/routes/FAQ.js @@ -131,6 +131,20 @@ function FAQ() { . + + Saving + + + Database is disabled. + + + Owlbear Rodeo uses a local database to store saved data. If you are + seeing a database is disabled message this usually means you have data + storage disabled. The most common occurances of this is if you are + using Private Browsing modes or in Firefox have the Never Remember + History option enabled. The site will still function in these cases + however all data will be lost when the page closes or reloads. +