diff --git a/.env b/.env new file mode 100644 index 0000000..f2f811d --- /dev/null +++ b/.env @@ -0,0 +1,2 @@ +REACT_APP_BROKER_URL=http://localhost:9000 +REACT_APP_ICE_SERVERS_URL=http://localhost:9000/iceservers \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..3277f06 --- /dev/null +++ b/.env.production @@ -0,0 +1,2 @@ +REACT_APP_BROKER_URL=https://agent.owlbear.rodeo +REACT_APP_ICE_SERVERS_URL=https://agent.owlbear.rodeo/iceservers \ No newline at end of file diff --git a/package.json b/package.json index 353b2ff..0808450 100644 --- a/package.json +++ b/package.json @@ -3,18 +3,20 @@ "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", + "fake-indexeddb": "^3.0.0", "interactjs": "^1.9.7", - "js-binarypack": "^0.0.9", "normalize-wheel": "^1.0.1", "react": "^16.13.0", "react-dom": "^16.13.0", "react-modal": "^3.11.2", "react-router-dom": "^5.1.2", + "react-router-hash-link": "^1.2.2", "react-scripts": "3.4.0", "shortid": "^2.2.15", "simple-peer": "^9.6.2", 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/Link.js b/src/components/Link.js index f5264cc..5f29f97 100644 --- a/src/components/Link.js +++ b/src/components/Link.js @@ -1,6 +1,6 @@ import React from "react"; import { Link as ThemeLink } from "theme-ui"; -import { Link as RouterLink } from "react-router-dom"; +import { HashLink as RouterLink } from "react-router-hash-link"; function Link({ to, ...rest }) { return ( diff --git a/src/components/map/MapTiles.js b/src/components/map/MapTiles.js index 5babba3..4ebc275 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/components/party/StartStreamButton.js b/src/components/party/StartStreamButton.js index b55020f..749368c 100644 --- a/src/components/party/StartStreamButton.js +++ b/src/components/party/StartStreamButton.js @@ -22,7 +22,7 @@ function StartStreamButton({ onStreamStart, onStreamEnd, stream }) { Browser not supported for audio sharing.

- See FAQ for more information. + See FAQ for more information. ); @@ -35,7 +35,7 @@ function StartStreamButton({ onStreamStart, onStreamEnd, stream }) { Ensure "Share audio" is selected when sharing.

- See FAQ for more information. + 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..7492682 --- /dev/null +++ b/src/contexts/DatabaseContext.js @@ -0,0 +1,44 @@ +import React, { useState, useEffect } from "react"; + +import { getDatabase } from "../database"; + +const DatabaseContext = React.createContext(); + +export function DatabaseProvider({ children }) { + const [database, setDatabase] = useState(); + const [databaseStatus, setDatabaseStatus] = useState("loading"); + + 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 = getDatabase(); + 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 = getDatabase({ indexedDB, IDBKeyRange }); + 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 index c1ad236..71851cb 100644 --- a/src/database.js +++ b/src/database.js @@ -1,11 +1,36 @@ import Dexie from "dexie"; -const db = new Dexie("OwlbearRodeoDB"); -db.version(1).stores({ - maps: "id, owner", - states: "mapId", - tokens: "id, owner", - user: "key", -}); +import blobToBuffer from "./helpers/blobToBuffer"; -export default db; +function loadVersions(db) { + // v1.2.0 + db.version(1).stores({ + maps: "id, owner", + states: "mapId", + tokens: "id, owner", + user: "key", + }); + // v1.2.1 - Move from blob files to array buffers + db.version(2) + .stores({}) + .upgrade(async (tx) => { + const maps = await Dexie.waitFor(tx.table("maps").toArray()); + let mapBuffers = {}; + for (let map of maps) { + mapBuffers[map.id] = await Dexie.waitFor(blobToBuffer(map.file)); + } + return tx + .table("maps") + .toCollection() + .modify((map) => { + map.file = mapBuffers[map.id]; + }); + }); +} + +// Get the dexie database used in DatabaseContext +export function getDatabase(options) { + let db = new Dexie("OwlbearRodeoDB", options); + loadVersions(db); + return db; +} diff --git a/src/helpers/Peer.js b/src/helpers/Peer.js index 04d8893..680506b 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, 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/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/helpers/useSession.js b/src/helpers/useSession.js index 164a829..1de6308 100644 --- a/src/helpers/useSession.js +++ b/src/helpers/useSession.js @@ -6,7 +6,7 @@ import Peer from "../helpers/Peer"; import AuthContext from "../contexts/AuthContext"; -const socket = io("https://agent.owlbear.rodeo"); +const socket = io(process.env.REACT_APP_BROKER_URL); function useSession( partyId, @@ -18,9 +18,16 @@ function useSession( onPeerError ) { const { password, setAuthenticationStatus } = useContext(AuthContext); + const [iceServers, setIceServers] = useState([]); useEffect(() => { - socket.emit("join party", partyId, password); + async function joinParty() { + const response = await fetch(process.env.REACT_APP_ICE_SERVERS_URL); + const data = await response.json(); + setIceServers(data.iceServers); + socket.emit("join party", partyId, password); + } + joinParty(); }, [partyId, password]); const [peers, setPeers] = useState({}); @@ -127,7 +134,11 @@ function useSession( // Setup event listeners for the socket useEffect(() => { function addPeer(id, initiator, sync) { - const connection = new Peer({ initiator, trickle: false }); + const connection = new Peer({ + initiator, + trickle: false, + config: { iceServers }, + }); setPeers((prevPeers) => ({ ...prevPeers, @@ -178,7 +189,7 @@ function useSession( socket.removeListener("signal", handleSignal); socket.removeListener("auth error", handleAuthError); }; - }, [peers, setAuthenticationStatus]); + }, [peers, setAuthenticationStatus, iceServers]); return { peers, socket }; } diff --git a/src/modals/SelectMapModal.js b/src/modals/SelectMapModal.js index 89083e2..cb0cbcb 100644 --- a/src/modals/SelectMapModal.js +++ b/src/modals/SelectMapModal.js @@ -2,15 +2,15 @@ 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"; import { maps as defaultMaps } from "../maps"; @@ -42,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); @@ -54,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() { @@ -72,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(); @@ -92,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); } @@ -102,7 +103,7 @@ function SelectMapModal({ if (!wasOpen && isOpen) { loadMaps(); } - }, [userId, isOpen, wasOpen, selectedMap]); + }, [userId, database, isOpen, wasOpen, selectedMap]); const fileInputRef = useRef(); @@ -141,31 +142,35 @@ 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({ + // Save as a buffer to send with msgpack + 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() { @@ -175,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 @@ -199,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) { @@ -256,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]; @@ -270,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..8a3afef 100644 --- a/src/routes/FAQ.js +++ b/src/routes/FAQ.js @@ -26,7 +26,7 @@ function FAQ() { Frequently Asked Questions - + Using Radio (experimental) @@ -61,7 +61,13 @@ function FAQ() { {" "} on the Mozilla Developer Network. - + Connection @@ -131,6 +137,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. +