Merge branch 'master' into typescript

This commit is contained in:
Mitchell McCaffrey
2021-07-02 15:54:54 +10:00
157 changed files with 8114 additions and 4055 deletions

View File

@@ -0,0 +1,367 @@
import React, { useState, useContext, useCallback, useEffect } from "react";
import * as Comlink from "comlink";
import { encode } from "@msgpack/msgpack";
import { useLiveQuery } from "dexie-react-hooks";
import { useDatabase } from "./DatabaseContext";
import useDebounce from "../hooks/useDebounce";
import { omit } from "../helpers/shared";
/**
* @typedef Asset
* @property {string} id
* @property {number} width
* @property {number} height
* @property {Uint8Array} file
* @property {string} mime
* @property {string} owner
*/
/**
* @callback getAsset
* @param {string} assetId
* @returns {Promise<Asset|undefined>}
*/
/**
* @callback addAssets
* @param {Asset[]} assets
*/
/**
* @callback putAsset
* @param {Asset} asset
*/
/**
* @typedef AssetsContext
* @property {getAsset} getAsset
* @property {addAssets} addAssets
* @property {putAsset} putAsset
*/
/**
* @type {React.Context<undefined|AssetsContext>}
*/
const AssetsContext = React.createContext();
// 100 MB max cache size
const maxCacheSize = 1e8;
export function AssetsProvider({ children }) {
const { worker, database, databaseStatus } = useDatabase();
useEffect(() => {
if (databaseStatus === "loaded") {
worker.cleanAssetCache(maxCacheSize);
}
}, [worker, databaseStatus]);
const getAsset = useCallback(
async (assetId) => {
return await database.table("assets").get(assetId);
},
[database]
);
const addAssets = useCallback(
async (assets) => {
await database.table("assets").bulkAdd(assets);
},
[database]
);
const putAsset = useCallback(
async (asset) => {
// Check for broadcast channel and attempt to use worker to put map to avoid UI lockup
// Safari doesn't support BC so fallback to single thread
if (window.BroadcastChannel) {
const packedAsset = encode(asset);
const success = await worker.putData(
Comlink.transfer(packedAsset, [packedAsset.buffer]),
"assets"
);
if (!success) {
await database.table("assets").put(asset);
}
} else {
await database.table("assets").put(asset);
}
},
[database, worker]
);
const value = {
getAsset,
addAssets,
putAsset,
};
return (
<AssetsContext.Provider value={value}>{children}</AssetsContext.Provider>
);
}
export function useAssets() {
const context = useContext(AssetsContext);
if (context === undefined) {
throw new Error("useAssets must be used within a AssetsProvider");
}
return context;
}
/**
* @typedef AssetURL
* @property {string} url
* @property {string} id
* @property {number} references
*/
/**
* @type React.Context<undefined|Object.<string, AssetURL>>
*/
export const AssetURLsStateContext = React.createContext();
/**
* @type React.Context<undefined|React.Dispatch<React.SetStateAction<{}>>>
*/
export const AssetURLsUpdaterContext = React.createContext();
/**
* Helper to manage sharing of custom image sources between uses of useAssetURL
*/
export function AssetURLsProvider({ children }) {
const [assetURLs, setAssetURLs] = useState({});
const { database } = useDatabase();
// Keep track of the assets that need to be loaded
const [assetKeys, setAssetKeys] = useState([]);
// Load assets after 100ms
const loadingDebouncedAssetURLs = useDebounce(assetURLs, 100);
// Update the asset keys to load when a url is added without an asset attached
useEffect(() => {
if (!loadingDebouncedAssetURLs) {
return;
}
let keysToLoad = [];
for (let url of Object.values(loadingDebouncedAssetURLs)) {
if (url.url === null) {
keysToLoad.push(url.id);
}
}
if (keysToLoad.length > 0) {
setAssetKeys(keysToLoad);
}
}, [loadingDebouncedAssetURLs]);
// Get the new assets whenever the keys change
const assets = useLiveQuery(
() => database?.table("assets").where("id").anyOf(assetKeys).toArray(),
[database, assetKeys]
);
// Update asset URLs when assets are loaded
useEffect(() => {
if (!assets || assets.length === 0) {
return;
}
// Assets are about to be loaded so clear the keys to load
setAssetKeys([]);
setAssetURLs((prevURLs) => {
let newURLs = { ...prevURLs };
for (let asset of assets) {
if (newURLs[asset.id]?.url === null) {
newURLs[asset.id] = {
...newURLs[asset.id],
url: URL.createObjectURL(
new Blob([asset.file], { type: asset.mime })
),
};
}
}
return newURLs;
});
}, [assets]);
// Clean up asset URLs every minute
const cleanUpDebouncedAssetURLs = useDebounce(assetURLs, 60 * 1000);
// Revoke url when no more references
useEffect(() => {
setAssetURLs((prevURLs) => {
let urlsToCleanup = [];
for (let url of Object.values(prevURLs)) {
if (url.references <= 0) {
URL.revokeObjectURL(url.url);
urlsToCleanup.push(url.id);
}
}
if (urlsToCleanup.length > 0) {
return omit(prevURLs, urlsToCleanup);
} else {
return prevURLs;
}
});
}, [cleanUpDebouncedAssetURLs]);
return (
<AssetURLsStateContext.Provider value={assetURLs}>
<AssetURLsUpdaterContext.Provider value={setAssetURLs}>
{children}
</AssetURLsUpdaterContext.Provider>
</AssetURLsStateContext.Provider>
);
}
/**
* Helper function to load either file or default asset into a URL
* @param {string} assetId
* @param {"file"|"default"} type
* @param {Object.<string, string>} defaultSources
* @param {string|undefined} unknownSource
* @returns {string|undefined}
*/
export function useAssetURL(assetId, type, defaultSources, unknownSource) {
const assetURLs = useContext(AssetURLsStateContext);
if (assetURLs === undefined) {
throw new Error("useAssetURL must be used within a AssetURLsProvider");
}
const setAssetURLs = useContext(AssetURLsUpdaterContext);
if (setAssetURLs === undefined) {
throw new Error("useAssetURL must be used within a AssetURLsProvider");
}
useEffect(() => {
if (!assetId || type !== "file") {
return;
}
function updateAssetURL() {
function increaseReferences(prevURLs) {
return {
...prevURLs,
[assetId]: {
...prevURLs[assetId],
references: prevURLs[assetId].references + 1,
},
};
}
function createReference(prevURLs) {
return {
...prevURLs,
[assetId]: { url: null, id: assetId, references: 1 },
};
}
setAssetURLs((prevURLs) => {
if (assetId in prevURLs) {
// Check if the asset url is already added and increase references
return increaseReferences(prevURLs);
} else {
return createReference(prevURLs);
}
});
}
updateAssetURL();
return () => {
// Decrease references
setAssetURLs((prevURLs) => {
if (assetId in prevURLs) {
return {
...prevURLs,
[assetId]: {
...prevURLs[assetId],
references: prevURLs[assetId].references - 1,
},
};
} else {
return prevURLs;
}
});
};
}, [assetId, setAssetURLs, type]);
if (!assetId) {
return unknownSource;
}
if (type === "default") {
return defaultSources[assetId];
}
if (type === "file") {
return assetURLs[assetId]?.url || unknownSource;
}
return unknownSource;
}
/**
* @typedef FileData
* @property {string} file
* @property {"file"} type
* @property {string} thumbnail
* @property {string=} quality
* @property {Object.<string, string>=} resolutions
*/
/**
* @typedef DefaultData
* @property {string} key
* @property {"default"} type
*/
/**
* Load a map or token into a URL taking into account a thumbnail and multiple resolutions
* @param {FileData|DefaultData} data
* @param {Object.<string, string>} defaultSources
* @param {string|undefined} unknownSource
* @param {boolean} thumbnail
* @returns {string|undefined}
*/
export function useDataURL(
data,
defaultSources,
unknownSource,
thumbnail = false
) {
const [assetId, setAssetId] = useState();
useEffect(() => {
if (!data) {
return;
}
function loadAssetId() {
if (data.type === "default") {
setAssetId(data.key);
} else {
if (thumbnail) {
setAssetId(data.thumbnail);
} else if (data.resolutions && data.quality !== "original") {
setAssetId(data.resolutions[data.quality]);
} else {
setAssetId(data.file);
}
}
}
loadAssetId();
}, [data, thumbnail]);
const assetURL = useAssetURL(
assetId,
data?.type,
defaultSources,
unknownSource
);
return assetURL;
}
export default AssetsContext;

View File

@@ -1,11 +1,8 @@
import React, { useState, useEffect, useContext, SetStateAction } from "react";
import shortid from "shortid";
import { useDatabase } from "./DatabaseContext";
import React, { useState, useEffect, useContext } from "react";
import FakeStorage from "../helpers/FakeStorage";
type AuthContext = { userId: string; password: string; setPassword: React.Dispatch<any>; }
type AuthContext = { password: string; setPassword: React.Dispatch<any> };
// TODO: check what default value we want here
const AuthContext = React.createContext<AuthContext | undefined>(undefined);
@@ -20,37 +17,16 @@ try {
storage = new FakeStorage();
}
export function AuthProvider({ children }: { children: any }) {
const { database, databaseStatus } = useDatabase();
const [password, setPassword] = useState<string>(storage.getItem("auth") || "");
export function AuthProvider({ children }: { children: React.ReactNode }) {
const [password, setPassword] = useState<string>(
storage.getItem("auth") || ""
);
useEffect(() => {
storage.setItem("auth", password);
}, [password]);
// TODO: check pattern here -> undefined or empty default values
const [userId, setUserId]: [ userId: string, setUserId: React.Dispatch<SetStateAction<string>> ] = useState("");
useEffect(() => {
if (!database || databaseStatus === "loading") {
return;
}
async function loadUserId() {
const storedUserId = await database?.table("user").get("userId");
if (storedUserId) {
setUserId(storedUserId.value);
} else {
const id = shortid.generate();
setUserId(id);
database?.table("user").add({ key: "userId", value: id });
}
}
loadUserId();
}, [database, databaseStatus]);
const value = {
userId,
password,
setPassword,
};

View File

@@ -1,5 +1,6 @@
import React, { useState, useEffect, useContext, SetStateAction } from "react";
import Comlink, { Remote } from "comlink";
import React, { useState, useEffect, useContext } from "react";
import Dexie from "dexie";
import * as Comlink from "comlink";
import ErrorBanner from "../components/banner/ErrorBanner";
@@ -7,30 +8,48 @@ import { getDatabase } from "../database";
//@ts-ignore
import DatabaseWorker from "worker-loader!../workers/DatabaseWorker"; // eslint-disable-line import/no-webpack-loader-syntax
import Dexie from "dexie";
type DatabaseContext = { database: Dexie | undefined; databaseStatus: any; databaseError: Error | undefined; worker: Remote<any>; }
type DatabaseContext = {
database: Dexie | undefined;
databaseStatus: any;
databaseError: Error | undefined;
worker: Comlink.Remote<any>;
};
// TODO: check what default we want here
const DatabaseContext = React.createContext< DatabaseContext | undefined>(undefined);
const DatabaseContext =
React.createContext<DatabaseContext | undefined>(undefined);
const worker = Comlink.wrap(new DatabaseWorker());
export function DatabaseProvider({ children }: { children: any}) {
const [database, setDatabase]: [ database: Dexie | undefined, setDatabase: React.Dispatch<SetStateAction<Dexie | undefined>>] = useState();
const [databaseStatus, setDatabaseStatus]: [ datebaseStatus: any, setDatabaseStatus: React.Dispatch<SetStateAction<string>>] = useState("loading");
const [databaseError, setDatabaseError]: [ databaseError: Error | undefined, setDatabaseError: React.Dispatch<SetStateAction<Error | undefined>>] = useState();
export function DatabaseProvider({ children }: { children: React.ReactNode }) {
const [database, setDatabase] = useState<Dexie>();
const [databaseStatus, setDatabaseStatus] =
useState<"loading" | "disabled" | "upgrading" | "loaded">("loading");
const [databaseError, setDatabaseError] = useState<Error>();
useEffect(() => {
// Create a test database and open it to see if indexedDB is enabled
let testDBRequest = window.indexedDB.open("__test");
testDBRequest.onsuccess = async function () {
testDBRequest.result.close();
let db = getDatabase({ autoOpen: false });
let db = getDatabase(
{ autoOpen: false },
undefined,
undefined,
true,
() => {
setDatabaseStatus("upgrading");
}
);
setDatabase(db);
db.on("ready", () => {
setDatabaseStatus("loaded");
});
db.on("versionchange", () => {
// When another tab loads a new version of the database refresh the page
window.location.reload();
});
await db.open();
window.indexedDB.deleteDatabase("__test");
};
@@ -48,20 +67,35 @@ export function DatabaseProvider({ children }: { children: any}) {
window.indexedDB.deleteDatabase("__test");
};
function handleDatabaseError(event: any) {
event.preventDefault();
if (event.reason?.message.startsWith("QuotaExceededError")) {
setDatabaseError({
name: event.reason.name,
message: "Storage Quota Exceeded Please Clear Space and Try Again.",
});
} else {
setDatabaseError({
name: event.reason.name,
message: "Something went wrong, please refresh your browser.",
});
function handleDatabaseError(event: PromiseRejectionEvent) {
if (event) {
event.preventDefault();
if (event.reason instanceof Dexie.DexieError) {
if (event.reason?.inner?.name === "QuotaExceededError") {
setDatabaseError({
name: event.reason?.name,
message:
"Storage Quota Exceeded Please Clear Space and Try Again.",
});
} else if (event.reason?.inner?.name === "DatabaseClosedError") {
setDatabaseError({
name: event.reason?.name,
message: "Database closed, please refresh your browser.",
});
} else {
setDatabaseError({
name: event.reason?.name,
message: "Something went wrong, please refresh your browser.",
});
}
} else {
setDatabaseError({
name: event.reason?.name,
message: "Something went wrong, please refresh your browser.",
});
}
console.error(event.reason);
}
console.error(event.reason);
}
window.addEventListener("unhandledrejection", handleDatabaseError);

View File

@@ -0,0 +1,75 @@
// eslint-disable-next-line no-unused-vars
import React, { useRef, ReactNode } from "react";
import {
DndContext,
useDndContext,
useDndMonitor,
// eslint-disable-next-line no-unused-vars
DragEndEvent,
} from "@dnd-kit/core";
/**
* Wrap a dnd-kit DndContext with a position monitor to get the
* active drag element on drag end
* TODO: use look into fixing this upstream
* Related: https://github.com/clauderic/dnd-kit/issues/238
*/
/**
* @typedef DragEndOverlayEvent
* @property {DOMRect} overlayNodeClientRect
*
* @typedef {DragEndEvent & DragEndOverlayEvent} DragEndWithOverlayProps
*/
/**
* @callback DragEndWithOverlayEvent
* @param {DragEndWithOverlayProps} props
*/
/**
* @typedef CustomDragProps
* @property {DragEndWithOverlayEvent=} onDragEnd
* @property {ReactNode} children
*/
/**
* @param {CustomDragProps} props
*/
function DragPositionMonitor({ children, onDragEnd }) {
const { overlayNode } = useDndContext();
const overlayNodeClientRectRef = useRef();
function handleDragMove() {
if (overlayNode?.nodeRef?.current) {
overlayNodeClientRectRef.current = overlayNode.nodeRef.current.getBoundingClientRect();
}
}
function handleDragEnd(props) {
onDragEnd &&
onDragEnd({
...props,
overlayNodeClientRect: overlayNodeClientRectRef.current,
});
}
useDndMonitor({ onDragEnd: handleDragEnd, onDragMove: handleDragMove });
return children;
}
/**
* TODO: Import Props interface from dnd-kit with conversion to Typescript
* @param {CustomDragProps} props
*/
function DragContext({ children, onDragEnd, ...props }) {
return (
<DndContext {...props}>
<DragPositionMonitor onDragEnd={onDragEnd}>
{children}
</DragPositionMonitor>
</DndContext>
);
}
export default DragContext;

View File

@@ -0,0 +1,240 @@
import React, { useState, useContext, useEffect } from "react";
import cloneDeep from "lodash.clonedeep";
import Fuse from "fuse.js";
import { useKeyboard, useBlur } from "./KeyboardContext";
import { getGroupItems, groupsFromIds } from "../helpers/group";
import shortcuts from "../shortcuts";
const GroupContext = React.createContext();
export function GroupProvider({
groups,
itemNames,
onGroupsChange,
onGroupsSelect,
disabled,
children,
}) {
const [selectedGroupIds, setSelectedGroupIds] = useState([]);
// Either single, multiple or range
const [selectMode, setSelectMode] = useState("single");
/**
* Group Open
*/
const [openGroupId, setOpenGroupId] = useState();
const [openGroupItems, setOpenGroupItems] = useState([]);
useEffect(() => {
if (openGroupId) {
const openGroups = groupsFromIds([openGroupId], groups);
if (openGroups.length === 1) {
const openGroup = openGroups[0];
setOpenGroupItems(getGroupItems(openGroup));
} else {
// Close group if we can't find it
// This can happen if it was deleted or all it's items were deleted
setOpenGroupItems([]);
setOpenGroupId();
}
} else {
setOpenGroupItems([]);
}
}, [openGroupId, groups]);
function handleGroupOpen(groupId) {
setSelectedGroupIds([]);
setOpenGroupId(groupId);
}
function handleGroupClose() {
setSelectedGroupIds([]);
setOpenGroupId();
}
/**
* Search
*/
const [filter, setFilter] = useState();
const [filteredGroupItems, setFilteredGroupItems] = useState([]);
const [fuse, setFuse] = useState();
// Update search index when items change
useEffect(() => {
let items = [];
for (let group of groups) {
const itemsToAdd = getGroupItems(group);
const namedItems = itemsToAdd.map((item) => ({
...item,
name: itemNames[item.id],
}));
items.push(...namedItems);
}
setFuse(new Fuse(items, { keys: ["name"] }));
}, [groups, itemNames]);
// Perform search when search changes
useEffect(() => {
if (filter) {
const query = fuse.search(filter);
setFilteredGroupItems(query.map((result) => result.item));
setOpenGroupId();
} else {
setFilteredGroupItems([]);
}
}, [filter, fuse]);
/**
* Handlers
*/
const activeGroups = openGroupId
? openGroupItems
: filter
? filteredGroupItems
: groups;
/**
* @param {string|undefined} groupId The group to apply changes to, leave undefined to replace the full group object
*/
function handleGroupsChange(newGroups, groupId) {
if (groupId) {
// If a group is specidifed then update that group with the new items
const groupIndex = groups.findIndex((group) => group.id === groupId);
let updatedGroups = cloneDeep(groups);
const group = updatedGroups[groupIndex];
updatedGroups[groupIndex] = { ...group, items: newGroups };
onGroupsChange(updatedGroups);
} else {
onGroupsChange(newGroups);
}
}
function handleGroupSelect(groupId) {
let groupIds = [];
if (groupId) {
switch (selectMode) {
case "single":
groupIds = [groupId];
break;
case "multiple":
if (selectedGroupIds.includes(groupId)) {
groupIds = selectedGroupIds.filter((id) => id !== groupId);
} else {
groupIds = [...selectedGroupIds, groupId];
}
break;
case "range":
if (selectedGroupIds.length > 0) {
const currentIndex = activeGroups.findIndex(
(g) => g.id === groupId
);
const lastIndex = activeGroups.findIndex(
(g) => g.id === selectedGroupIds[selectedGroupIds.length - 1]
);
let idsToAdd = [];
let idsToRemove = [];
const direction = currentIndex > lastIndex ? 1 : -1;
for (
let i = lastIndex + direction;
direction < 0 ? i >= currentIndex : i <= currentIndex;
i += direction
) {
const id = activeGroups[i].id;
if (selectedGroupIds.includes(id)) {
idsToRemove.push(id);
} else {
idsToAdd.push(id);
}
}
groupIds = [...selectedGroupIds, ...idsToAdd].filter(
(id) => !idsToRemove.includes(id)
);
} else {
groupIds = [groupId];
}
break;
default:
groupIds = [];
}
}
setSelectedGroupIds(groupIds);
onGroupsSelect(groupIds);
}
/**
* Shortcuts
*/
function handleKeyDown(event) {
if (disabled) {
return;
}
if (shortcuts.selectRange(event)) {
setSelectMode("range");
}
if (shortcuts.selectMultiple(event)) {
setSelectMode("multiple");
}
}
function handleKeyUp(event) {
if (disabled) {
return;
}
if (shortcuts.selectRange(event) && selectMode === "range") {
setSelectMode("single");
}
if (shortcuts.selectMultiple(event) && selectMode === "multiple") {
setSelectMode("single");
}
}
useKeyboard(handleKeyDown, handleKeyUp);
// Set select mode to single when cmd+tabing
function handleBlur() {
setSelectMode("single");
}
useBlur(handleBlur);
const value = {
groups,
activeGroups,
openGroupId,
openGroupItems,
filter,
filteredGroupItems,
selectedGroupIds,
selectMode,
onSelectModeChange: setSelectMode,
onGroupOpen: handleGroupOpen,
onGroupClose: handleGroupClose,
onGroupsChange: handleGroupsChange,
onGroupSelect: handleGroupSelect,
onFilterChange: setFilter,
};
return (
<GroupContext.Provider value={value}>{children}</GroupContext.Provider>
);
}
GroupProvider.defaultProps = {
groups: [],
itemNames: {},
onGroupsChange: () => {},
onGroupsSelect: () => {},
disabled: false,
};
export function useGroup() {
const context = useContext(GroupContext);
if (context === undefined) {
throw new Error("useGroup must be used within a GroupProvider");
}
return context;
}
export default GroupContext;

View File

@@ -3,189 +3,140 @@ import React, {
useState,
useContext,
useCallback,
useRef,
ReactChild,
useMemo,
} from "react";
import * as Comlink from "comlink";
import { decode, encode } from "@msgpack/msgpack";
import { useLiveQuery } from "dexie-react-hooks";
import { useAuth } from "./AuthContext";
import { useDatabase } from "./DatabaseContext";
import { maps as defaultMaps } from "../maps";
import { Map, MapState, Note, TokenState } from "../components/map/Map";
import { Fog } from "../helpers/drawing";
import { Map, MapState, Note } from "../components/map/Map";
import { removeGroupsItems } from "../helpers/group";
// TODO: fix differences in types between default maps and imported maps
type MapDataContext = {
maps: Array<Map>,
ownedMaps: Array<Map>
mapStates: MapState[],
addMap: (map: Map) => void,
removeMap: (id: string) => void,
removeMaps: (ids: string[]) => void,
resetMap: (id: string) => void,
updateMap: (id: string, update: Partial<Map>) => void,
updateMaps: (ids: string[], update: Partial<Map>) => void,
updateMapState: (id: string, update: Partial<MapState>) => void,
putMap: (map: Map) => void,
getMap: (id: string) => Map | undefined,
getMapFromDB: (id: string) => Promise<Map>,
mapsLoading: boolean,
getMapStateFromDB: (id: string) => Promise<MapState>,
}
maps: Array<Map>;
mapStates: MapState[];
addMap: (map: Map) => void;
removeMaps: (ids: string[]) => void;
resetMap: (id: string) => void;
updateMap: (id: string, update: Partial<Map>) => void;
updateMapState: (id: string, update: Partial<MapState>) => void;
getMapState: (id: string) => Promise<MapState>;
getMap: (id: string) => Promise<Map | undefined>;
mapsLoading: boolean;
updateMapGroups: (groups: any) => void;
mapsById: Record<string, Map>;
mapGroups: any[];
};
const MapDataContext = React.createContext<MapDataContext | undefined>(undefined);
const MapDataContext =
React.createContext<MapDataContext | undefined>(undefined);
// Maximum number of maps to keep in the cache
const cachedMapMax = 15;
const defaultMapState: MapState = {
mapId: "",
tokens: {} as Record<string, TokenState>,
drawShapes: {} as any,
fogShapes: {} as Fog[],
const defaultMapState = {
tokens: {},
drawShapes: {},
fogShapes: {},
// Flags to determine what other people can edit
editFlags: ["drawing", "tokens", "notes", "fog"],
notes: {} as Note[],
};
export function MapDataProvider({ children }: { children: ReactChild }) {
const { database, databaseStatus, worker } = useDatabase();
const { userId } = useAuth();
export function MapDataProvider({ children }: { children: React.ReactNode }) {
const { database } = useDatabase();
const [maps, setMaps] = useState<Array<Map>>([]);
const [mapStates, setMapStates] = useState<MapState[]>([]);
const [mapsLoading, setMapsLoading] = useState<boolean>(true);
const mapsQuery = useLiveQuery<Map[]>(
() => database?.table("maps").toArray() || [],
[database]
);
const mapStatesQuery = useLiveQuery<MapState[]>(
() => database?.table("states").toArray() || [],
[database]
);
// Load maps from the database and ensure state is properly seup
const maps = useMemo(() => mapsQuery || [], [mapsQuery]);
const mapStates = useMemo(() => mapStatesQuery || [], [mapStatesQuery]);
const mapsLoading = useMemo(
() => !mapsQuery || !mapStatesQuery,
[mapsQuery, mapStatesQuery]
);
const mapGroupQuery = useLiveQuery(
() => database?.table("groups").get("maps"),
[database]
);
const [mapGroups, setMapGroups] = useState([]);
useEffect(() => {
if (!userId || !database || databaseStatus === "loading") {
return;
async function updateMapGroups() {
const group = await database?.table("groups").get("maps");
setMapGroups(group.items);
}
async function getDefaultMaps(): Promise<Map[]> {
const defaultMapsWithIds: Array<Map> = [];
for (let i = 0; i < defaultMaps.length; i++) {
const defaultMap = defaultMaps[i];
const mapId = `__default-${defaultMap.name}`;
defaultMapsWithIds.push({
...defaultMap,
lastUsed: Date.now() + i,
id: mapId,
owner: userId,
// Emulate the time increasing to avoid sort errors
created: Date.now() + i,
lastModified: Date.now() + i,
showGrid: false,
snapToGrid: true,
group: "default",
});
// Add a state for the map if there isn't one already
const state = await database?.table("states").get(mapId);
if (!state) {
await database?.table("states").add({ ...defaultMapState, mapId: mapId });
}
}
return defaultMapsWithIds;
if (database && mapGroupQuery) {
updateMapGroups();
}
}, [mapGroupQuery, database]);
// Loads maps without the file data to save memory
async function loadMaps() {
let storedMaps: Map[] = [];
// Try to load maps with worker, fallback to database if failed
const packedMaps = await worker.loadData("maps");
// let packedMaps;
if (packedMaps) {
storedMaps = decode(packedMaps) as Map[];
} else {
console.warn("Unable to load maps with worker, loading may be slow");
await database?.table("maps").each((map) => {
const { file, resolutions, ...rest } = map;
storedMaps.push(rest);
});
}
const sortedMaps = storedMaps.sort((a, b) => b.created - a.created);
const defaultMapsWithIds = await getDefaultMaps();
const allMaps: Array<Map> = [...sortedMaps, ...defaultMapsWithIds];
setMaps(allMaps);
const storedStates = await database?.table("states").toArray() as MapState[];
setMapStates(storedStates);
setMapsLoading(false);
}
loadMaps();
}, [userId, database, databaseStatus, worker]);
const mapsRef = useRef(maps);
useEffect(() => {
mapsRef.current = maps;
}, [maps]);
const getMap = useCallback((mapId) => {
return mapsRef.current.find((map) => map.id === mapId);
}, []);
const getMapFromDB = useCallback(
async (mapId) => {
let map = await database?.table("maps").get(mapId) as Map;
const getMap = useCallback(
async (mapId: string) => {
let map = (await database?.table("maps").get(mapId)) as Map;
return map;
},
[database]
);
const getMapStateFromDB = useCallback(
const getMapState = useCallback(
async (mapId) => {
let mapState = await database?.table("states").get(mapId) as MapState;
let mapState = (await database?.table("states").get(mapId)) as MapState;
return mapState;
},
[database]
);
/**
* Keep up to cachedMapMax amount of maps that you don't own
* Sorted by when they we're last used
*/
const updateCache = useCallback(async () => {
const cachedMaps = await database?.table("maps").where("owner").notEqual(userId).sortBy("lastUsed") as Map[];
if (cachedMaps.length > cachedMapMax) {
const cacheDeleteCount = cachedMaps.length - cachedMapMax;
const idsToDelete = cachedMaps
.slice(0, cacheDeleteCount)
.map((map: Map) => map.id);
database?.table("maps").where("id").anyOf(idsToDelete).delete();
}
}, [database, userId]);
/**
* Adds a map to the database, also adds an assosiated state for that map
* @param {Map} map map to add
* Adds a map to the database, also adds an assosiated state and group for that map
* @param {Object} map map to add
*/
const addMap = useCallback(
async (map) => {
// Just update map database as react state will be updated with an Observable
const state = { ...defaultMapState, mapId: map.id };
await database?.table("maps").add(map);
await database?.table("states").add(state);
if (map.owner !== userId) {
await updateCache();
if (database) {
// Just update map database as react state will be updated with an Observable
const state = { ...defaultMapState, mapId: map.id };
await database.table("maps").add(map);
await database.table("states").add(state);
const group = await database.table("groups").get("maps");
await database.table("groups").update("maps", {
items: [{ id: map.id, type: "item" }, ...group.items],
});
}
},
[database, updateCache, userId]
);
const removeMap = useCallback(
async (id) => {
await database?.table("maps").delete(id);
await database?.table("states").delete(id);
},
[database]
);
const removeMaps = useCallback(
async (ids) => {
await database?.table("maps").bulkDelete(ids);
await database?.table("states").bulkDelete(ids);
if (database) {
const maps = await database.table("maps").bulkGet(ids);
// Remove assets linked with maps
let assetIds = [];
for (let map of maps) {
if (map.type === "file") {
assetIds.push(map.file);
assetIds.push(map.thumbnail);
for (let res of Object.values(map.resolutions)) {
assetIds.push(res);
}
}
}
const group = await database.table("groups").get("maps");
let items = removeGroupsItems(group.items, ids);
await database.table("groups").update("maps", { items });
await database.table("maps").bulkDelete(ids);
await database.table("states").bulkDelete(ids);
await database.table("assets").bulkDelete(assetIds);
}
},
[database]
);
@@ -201,23 +152,7 @@ export function MapDataProvider({ children }: { children: ReactChild }) {
const updateMap = useCallback(
async (id, update) => {
// fake-indexeddb throws an error when updating maps in production.
// Catch that error and use put when it fails
try {
await database?.table("maps").update(id, update);
} catch (error) {
const map = (await getMapFromDB(id)) || {};
await database?.table("maps").put({ ...map, id, ...update });
}
},
[database, getMapFromDB]
);
const updateMaps = useCallback(
async (ids, update) => {
await Promise.all(
ids.map((id: string) => database?.table("maps").update(id, update))
);
await database?.table("maps").update(id, update);
},
[database]
);
@@ -229,112 +164,39 @@ export function MapDataProvider({ children }: { children: ReactChild }) {
[database]
);
/**
* Adds a map to the database if none exists or replaces a map if it already exists
* Note: this does not add a map state to do that use AddMap
* @param {Object} map the map to put
*/
const putMap = useCallback(
async (map) => {
// Attempt to use worker to put map to avoid UI lockup
const packedMap = encode(map);
const success = await worker.putData(
Comlink.transfer(packedMap, [packedMap.buffer]),
"maps",
false
);
if (!success) {
await database?.table("maps").put(map);
}
if (map.owner !== userId) {
await updateCache();
}
const updateMapGroups = useCallback(
async (groups) => {
// Update group state immediately to avoid animation delay
setMapGroups(groups);
await database?.table("groups").update("maps", { items: groups });
},
[database, updateCache, userId, worker]
[database]
);
// Create DB observable to sync creating and deleting
const [mapsById, setMapsById] = useState<Record<string, Map>>({});
useEffect(() => {
if (!database || databaseStatus === "loading") {
return;
}
function handleMapChanges(changes: any) {
for (let change of changes) {
if (change.table === "maps") {
if (change.type === 1) {
// Created
const map: Map = change.obj;
const state: MapState = { ...defaultMapState, mapId: map.id };
setMaps((prevMaps) => [map, ...prevMaps]);
setMapStates((prevStates) => [state, ...prevStates]);
} else if (change.type === 2) {
const map = change.obj;
setMaps((prevMaps) => {
const newMaps = [...prevMaps];
const i = newMaps.findIndex((m) => m.id === map.id);
if (i > -1) {
newMaps[i] = map;
}
return newMaps;
});
} else if (change.type === 3) {
// Deleted
const id = change.key;
setMaps((prevMaps) => {
const filtered = prevMaps.filter((map) => map.id !== id);
return filtered;
});
setMapStates((prevMapsStates) => {
const filtered = prevMapsStates.filter(
(state) => state.mapId !== id
);
return filtered;
});
}
}
if (change.table === "states") {
if (change.type === 2) {
// Update map state
const state = change.obj;
setMapStates((prevMapStates) => {
const newStates = [...prevMapStates];
const i = newStates.findIndex((s) => s.mapId === state.mapId);
if (i > -1) {
newStates[i] = state;
}
return newStates;
});
}
}
}
}
database.on("changes", handleMapChanges);
return () => {
database.on("changes").unsubscribe(handleMapChanges);
};
}, [database, databaseStatus]);
const ownedMaps = maps.filter((map) => map.owner === userId);
setMapsById(
maps.reduce((obj: Record<string, Map>, map) => {
obj[map.id] = map;
return obj;
}, {})
);
}, [maps]);
const value = {
maps,
ownedMaps,
mapStates,
mapGroups,
addMap,
removeMap,
removeMaps,
resetMap,
updateMap,
updateMaps,
updateMapState,
putMap,
getMap,
getMapFromDB,
mapsLoading,
getMapStateFromDB,
getMapState,
updateMapGroups,
mapsById,
};
return (
<MapDataContext.Provider value={value}>{children}</MapDataContext.Provider>

View File

@@ -1,46 +1,65 @@
import React, { useState, useRef, useContext } from "react";
import { omit, isEmpty } from "../helpers/shared";
import React, { useState, useRef, useContext, useCallback } from "react";
const MapLoadingContext = React.createContext<any | undefined>(undefined);
type MapLoadingProgress = {
count: number;
total: number;
};
export function MapLoadingProvider({ children }: { children: any}) {
const [loadingAssetCount, setLoadingAssetCount] = useState(0);
type MapLoadingProgressUpdate = MapLoadingProgress & {
id: string;
};
function assetLoadStart() {
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets + 1);
}
type MapLoadingContext = {
isLoading: boolean;
assetLoadStart: (id: string) => void;
assetProgressUpdate: (update: MapLoadingProgressUpdate) => void;
loadingProgressRef: React.MutableRefObject<number | null>;
};
function assetLoadFinish() {
setLoadingAssetCount((prevLoadingAssets) => prevLoadingAssets - 1);
}
const MapLoadingContext =
React.createContext<MapLoadingContext | undefined>(undefined);
const assetProgressRef = useRef<any>({});
export function MapLoadingProvider({
children,
}: {
children: React.ReactNode;
}) {
const [isLoading, setIsLoading] = useState(false);
// Mapping from asset id to the count and total number of pieces loaded
const assetProgressRef = useRef<Record<string, MapLoadingProgress>>({});
// Loading progress of all assets between 0 and 1
const loadingProgressRef = useRef<number | null>(null);
function assetProgressUpdate({ id, count, total }: { id: string, count: number, total: number }) {
if (count === total) {
assetProgressRef.current = omit(assetProgressRef.current, [id]);
} else {
assetProgressRef.current = {
...assetProgressRef.current,
[id]: { count, total },
};
}
if (!isEmpty(assetProgressRef.current)) {
let total = 0;
let count = 0;
for (let progress of Object.values(assetProgressRef.current) as any) {
total += progress.total;
count += progress.count;
}
loadingProgressRef.current = count / total;
}
}
const isLoading = loadingAssetCount > 0;
const assetLoadStart = useCallback((id) => {
setIsLoading(true);
// Add asset at a 0% progress
assetProgressRef.current = {
...assetProgressRef.current,
[id]: { count: 0, total: 1 },
};
}, []);
const assetProgressUpdate = useCallback(({ id, count, total }) => {
assetProgressRef.current = {
...assetProgressRef.current,
[id]: { count, total },
};
// Update loading progress
let complete = 0;
const progresses = Object.values(assetProgressRef.current);
for (let progress of progresses) {
complete += progress.count / progress.total;
}
loadingProgressRef.current = complete / progresses.length;
// All loading is complete
if (loadingProgressRef.current === 1) {
setIsLoading(false);
assetProgressRef.current = {};
}
}, []);
const value = {
assetLoadStart,
assetLoadFinish,
isLoading,
assetProgressUpdate,
loadingProgressRef,

View File

@@ -1,7 +1,7 @@
import React, { useEffect, useContext } from "react";
import { useDatabase } from "./DatabaseContext";
import { useAuth } from "./AuthContext";
import { useUserId } from "./UserIdContext";
import { getRandomMonster } from "../helpers/monsters";
@@ -12,8 +12,14 @@ import { PlayerInfo } from "../components/party/PartyState";
export const PlayerStateContext = React.createContext<any>(undefined);
export const PlayerUpdaterContext = React.createContext<any>(() => {});
export function PlayerProvider({ session, children }: { session: Session, children: any}) {
const { userId } = useAuth();
export function PlayerProvider({
session,
children,
}: {
session: Session;
children: React.ReactNode;
}) {
const userId = useUserId();
const { database, databaseStatus } = useDatabase();
const [playerState, setPlayerState] = useNetworkedState(
@@ -55,7 +61,7 @@ export function PlayerProvider({ session, children }: { session: Session, childr
if (
playerState.nickname &&
database !== undefined &&
databaseStatus !== "loading"
(databaseStatus === "loaded" || databaseStatus === "disabled")
) {
database
.table("user")

View File

@@ -0,0 +1,259 @@
import React, { useState, useContext, useEffect } from "react";
import {
MouseSensor,
TouchSensor,
KeyboardSensor,
useSensor,
useSensors,
closestCenter,
} from "@dnd-kit/core";
import DragContext from "./DragContext";
import { useGroup } from "./GroupContext";
import { moveGroupsInto, moveGroups, ungroup } from "../helpers/group";
import usePreventSelect from "../hooks/usePreventSelect";
const TileDragIdContext = React.createContext();
const TileOverGroupIdContext = React.createContext();
const TileDragCursorContext = React.createContext();
export const BASE_SORTABLE_ID = "__base__";
export const GROUP_SORTABLE_ID = "__group__";
export const GROUP_ID_PREFIX = "__group__";
export const UNGROUP_ID = "__ungroup__";
export const ADD_TO_MAP_ID = "__add__";
// Custom rectIntersect that takes a point
function rectIntersection(rects, point) {
for (let rect of rects) {
const [id, bounds] = rect;
if (
id &&
bounds &&
point.x > bounds.offsetLeft &&
point.x < bounds.offsetLeft + bounds.width &&
point.y > bounds.offsetTop &&
point.y < bounds.offsetTop + bounds.height
) {
return id;
}
}
return null;
}
export function TileDragProvider({
onDragAdd,
onDragStart,
onDragEnd,
onDragCancel,
children,
}) {
const {
groups,
activeGroups,
openGroupId,
selectedGroupIds,
onGroupsChange,
onGroupSelect,
filter,
} = useGroup();
const mouseSensor = useSensor(MouseSensor, {
activationConstraint: { distance: 3 },
});
const touchSensor = useSensor(TouchSensor, {
activationConstraint: { delay: 250, tolerance: 5 },
});
const keyboardSensor = useSensor(KeyboardSensor);
const sensors = useSensors(mouseSensor, touchSensor, keyboardSensor);
const [dragId, setDragId] = useState(null);
const [overId, setOverId] = useState(null);
const [dragCursor, setDragCursor] = useState("pointer");
const [preventSelect, resumeSelect] = usePreventSelect();
const [overGroupId, setOverGroupId] = useState(null);
useEffect(() => {
setOverGroupId(
(overId && overId.startsWith(GROUP_ID_PREFIX) && overId.slice(9)) || null
);
}, [overId]);
function handleDragStart(event) {
const { active, over } = event;
setDragId(active.id);
setOverId(over?.id || null);
if (!selectedGroupIds.includes(active.id)) {
onGroupSelect(active.id);
}
setDragCursor("grabbing");
onDragStart && onDragStart(event);
preventSelect();
}
function handleDragOver(event) {
const { over } = event;
setOverId(over?.id || null);
if (over) {
if (
over.id.startsWith(UNGROUP_ID) ||
over.id.startsWith(GROUP_ID_PREFIX)
) {
setDragCursor("alias");
} else if (over.id.startsWith(ADD_TO_MAP_ID)) {
setDragCursor(onDragAdd ? "copy" : "no-drop");
} else {
setDragCursor("grabbing");
}
}
}
function handleDragEnd(event) {
const { active, over, overlayNodeClientRect } = event;
setDragId(null);
setOverId(null);
setDragCursor("pointer");
if (active && over && active.id !== over.id) {
let selectedIndices = selectedGroupIds.map((groupId) =>
activeGroups.findIndex((group) => group.id === groupId)
);
// Maintain current group sorting
selectedIndices = selectedIndices.sort((a, b) => a - b);
if (over.id.startsWith(GROUP_ID_PREFIX)) {
onGroupSelect();
// Handle tile group
const overId = over.id.slice(9);
if (overId !== active.id) {
const overGroupIndex = activeGroups.findIndex(
(group) => group.id === overId
);
onGroupsChange(
moveGroupsInto(activeGroups, overGroupIndex, selectedIndices),
openGroupId
);
}
} else if (over.id === UNGROUP_ID) {
onGroupSelect();
// Handle tile ungroup
const newGroups = ungroup(groups, openGroupId, selectedIndices);
onGroupsChange(newGroups);
} else if (over.id === ADD_TO_MAP_ID) {
onDragAdd &&
overlayNodeClientRect &&
onDragAdd(selectedGroupIds, overlayNodeClientRect);
} else if (!filter) {
// Hanlde tile move only if we have no filter
const overGroupIndex = activeGroups.findIndex(
(group) => group.id === over.id
);
onGroupsChange(
moveGroups(activeGroups, overGroupIndex, selectedIndices),
openGroupId
);
}
}
resumeSelect();
onDragEnd && onDragEnd(event);
}
function handleDragCancel(event) {
setDragId(null);
setOverId(null);
setDragCursor("pointer");
resumeSelect();
onDragCancel && onDragCancel(event);
}
function customCollisionDetection(rects, rect) {
const rectCenter = {
x: rect.left + rect.width / 2,
y: rect.top + rect.height / 2,
};
// Find whether out rect center is outside our add to map rect
const addRect = rects.find(([id]) => id === ADD_TO_MAP_ID);
if (addRect) {
const intersectingAddRect = rectIntersection([addRect], rectCenter);
if (!intersectingAddRect) {
return ADD_TO_MAP_ID;
}
}
// Find whether out rect center is outside our ungroup rect
if (openGroupId) {
const ungroupRect = rects.find(([id]) => id === UNGROUP_ID);
if (ungroupRect) {
const intersectingGroupRect = rectIntersection(
[ungroupRect],
rectCenter
);
if (!intersectingGroupRect) {
return UNGROUP_ID;
}
}
}
const otherRects = rects.filter(
([id]) => id !== ADD_TO_MAP_ID && id !== UNGROUP_ID
);
return closestCenter(otherRects, rect);
}
return (
<DragContext
onDragStart={handleDragStart}
onDragEnd={handleDragEnd}
onDragOver={handleDragOver}
onDragCancel={handleDragCancel}
sensors={sensors}
collisionDetection={customCollisionDetection}
>
<TileDragIdContext.Provider value={dragId}>
<TileOverGroupIdContext.Provider value={overGroupId}>
<TileDragCursorContext.Provider value={dragCursor}>
{children}
</TileDragCursorContext.Provider>
</TileOverGroupIdContext.Provider>
</TileDragIdContext.Provider>
</DragContext>
);
}
export function useTileDragId() {
const context = useContext(TileDragIdContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}
export function useTileOverGroupId() {
const context = useContext(TileOverGroupIdContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}
export function useTileDragCursor() {
const context = useContext(TileDragCursorContext);
if (context === undefined) {
throw new Error("useTileDrag must be used within a TileDragProvider");
}
return context;
}

View File

@@ -3,96 +3,59 @@ import React, {
useState,
useContext,
useCallback,
useRef,
useMemo,
} from "react";
import { decode } from "@msgpack/msgpack";
import { useLiveQuery } from "dexie-react-hooks";
import { useAuth } from "./AuthContext";
import { useDatabase } from "./DatabaseContext";
import { DefaultToken, FileToken, Token, tokens as defaultTokens } from "../tokens";
import { Token } from "../tokens";
import { removeGroupsItems } from "../helpers/group";
type TokenDataContext = {
tokens: Token[];
ownedTokens: Token[];
addToken: (token: Token) => Promise<void>;
removeToken: (id: string) => Promise<void>;
tokenGroups: any[];
removeTokens: (ids: string[]) => Promise<void>;
updateToken: (id: string, update: Partial<Token>) => Promise<void>;
updateTokens: (ids: string[], update: Partial<Token>) => Promise<void>;
putToken: (token: Token) => Promise<void>;
getToken: (tokenId: string) => Token | undefined
tokensById: { [key: string]: Token; };
getToken: (tokenId: string) => Promise<Token | undefined>;
tokensById: Record<string, Token>;
tokensLoading: boolean;
getTokenFromDB: (tokenId: string) => Promise<Token>;
loadTokens: (tokenIds: string[]) => Promise<void>;
}
updateTokenGroups: (groups: any[]) => void;
updateTokensHidden: (ids: string[], hideInSidebar: boolean) => void;
};
const TokenDataContext = React.createContext<TokenDataContext | undefined>(undefined);
const TokenDataContext =
React.createContext<TokenDataContext | undefined>(undefined);
const cachedTokenMax = 100;
export function TokenDataProvider({ children }: { children: React.ReactNode }) {
const { database } = useDatabase();
export function TokenDataProvider({ children }: { children: any }) {
const { database, databaseStatus, worker } = useDatabase();
const { userId } = useAuth();
const tokensQuery = useLiveQuery<Token[]>(
() => database?.table("tokens").toArray() || [],
[database]
);
/**
* Contains all tokens without any file data,
* to ensure file data is present call loadTokens
*/
const [tokens, setTokens] = useState<Token[]>([]);
const [tokensLoading, setTokensLoading] = useState(true);
const tokens = useMemo(() => tokensQuery || [], [tokensQuery]);
const tokensLoading = useMemo(() => !tokensQuery, [tokensQuery]);
const tokenGroupQuery = useLiveQuery(
() => database?.table("groups").get("tokens"),
[database]
);
const [tokenGroups, setTokenGroups] = useState([]);
useEffect(() => {
if (!userId || !database || databaseStatus === "loading") {
return;
async function updateTokenGroups() {
const group = await database?.table("groups").get("tokens");
setTokenGroups(group.items);
}
function getDefaultTokens() {
const defaultTokensWithIds: Required<DefaultToken[]> = [];
for (let defaultToken of defaultTokens) {
defaultTokensWithIds.push({
...defaultToken,
id: `__default-${defaultToken.name}`,
owner: userId,
});
}
return defaultTokensWithIds;
if (database && tokenGroupQuery) {
updateTokenGroups();
}
}, [tokenGroupQuery, database]);
// Loads tokens without the file data to save memory
async function loadTokens() {
let storedTokens: any = [];
// Try to load tokens with worker, fallback to database if failed
const packedTokens: ArrayLike<number> | BufferSource = await worker.loadData("tokens");
if (packedTokens) {
storedTokens = decode(packedTokens);
} else {
console.warn("Unable to load tokens with worker, loading may be slow");
await database?.table("tokens").each((token: FileToken) => {
const { file, ...rest } = token;
storedTokens.push(rest);
});
}
const sortedTokens = storedTokens.sort((a: any, b: any) => b.created - a.created);
const defaultTokensWithIds = getDefaultTokens();
const allTokens = [...sortedTokens, ...defaultTokensWithIds];
setTokens(allTokens);
setTokensLoading(false);
}
loadTokens();
}, [userId, database, databaseStatus, worker]);
const tokensRef = useRef(tokens);
useEffect(() => {
tokensRef.current = tokens;
}, [tokens]);
const getToken = useCallback((tokenId) => {
return tokensRef.current.find((token) => token.id === tokenId);
}, []);
const getTokenFromDB = useCallback(
const getToken = useCallback(
async (tokenId) => {
let token = await database?.table("tokens").get(tokenId);
return token;
@@ -100,165 +63,89 @@ export function TokenDataProvider({ children }: { children: any }) {
[database]
);
/**
* Keep up to cachedTokenMax amount of tokens that you don't own
* Sorted by when they we're last used
*/
const updateCache = useCallback(async () => {
const cachedTokens: Token[] | undefined = await database?.table("tokens").where("owner").notEqual(userId).sortBy("lastUsed");
// TODO: handle undefined cachedTokens
if (!cachedTokens) {
return;
}
if (cachedTokens?.length > cachedTokenMax) {
const cacheDeleteCount = cachedTokens.length - cachedTokenMax
const idsToDelete = cachedTokens
.slice(0, cacheDeleteCount)
.map((token) => token.id);
database?.table("tokens").where("id").anyOf(idsToDelete).delete();
}
}, [database, userId]);
// Add token and add it to the token group
const addToken = useCallback(
async (token) => {
await database?.table("tokens").add(token);
if (token.owner !== userId) {
await updateCache();
if (database) {
await database.table("tokens").add(token);
const group = await database.table("groups").get("tokens");
await database.table("groups").update("tokens", {
items: [{ id: token.id, type: "item" }, ...group.items],
});
}
},
[database, updateCache, userId]
);
const removeToken = useCallback(
async (id: string) => {
await database?.table("tokens").delete(id);
},
[database]
);
const removeTokens = useCallback(
async (ids: string[]) => {
await database?.table("tokens").bulkDelete(ids);
async (ids) => {
if (database) {
const tokens = await database.table("tokens").bulkGet(ids);
let assetIds = [];
for (let token of tokens) {
if (token.type === "file") {
assetIds.push(token.file);
assetIds.push(token.thumbnail);
}
}
const group = await database.table("groups").get("tokens");
let items = removeGroupsItems(group.items, ids);
await database.table("groups").update("tokens", { items });
await database.table("tokens").bulkDelete(ids);
await database.table("assets").bulkDelete(assetIds);
}
},
[database]
);
const updateToken = useCallback(
async (id: string, update: any) => {
const change = { lastModified: Date.now(), ...update };
await database?.table("tokens").update(id, change);
async (id, update) => {
await database?.table("tokens").update(id, update);
},
[database]
);
const updateTokens = useCallback(
async (ids, update) => {
const change = { lastModified: Date.now(), ...update };
const updateTokensHidden = useCallback(
async (ids: string[], hideInSidebar: boolean) => {
await Promise.all(
ids.map((id: string) => database?.table("tokens").update(id, change))
ids.map((id) => database?.table("tokens").update(id, { hideInSidebar }))
);
},
[database]
);
const putToken = useCallback(
async (token) => {
await database?.table("tokens").put(token);
if (token.owner !== userId) {
await updateCache();
}
},
[database, updateCache, userId]
);
const loadTokens = useCallback(
async (tokenIds: string[]) => {
const loadedTokens: FileToken[] | undefined = await database?.table("tokens").bulkGet(tokenIds);
const loadedTokensById = loadedTokens?.reduce((obj: { [key: string]: FileToken }, token: FileToken) => {
obj[token.id] = token;
return obj;
}, {});
if (!loadedTokensById) {
// TODO: whatever
return;
}
setTokens((prevTokens: Token[]) => {
return prevTokens.map((prevToken) => {
if (prevToken.id in loadedTokensById) {
return loadedTokensById[prevToken.id];
} else {
return prevToken;
}
});
});
const updateTokenGroups = useCallback(
async (groups) => {
// Update group state immediately to avoid animation delay
setTokenGroups(groups);
await database?.table("groups").update("tokens", { items: groups });
},
[database]
);
// Create DB observable to sync creating and deleting
const [tokensById, setTokensById] = useState({});
useEffect(() => {
if (!database || databaseStatus === "loading") {
return;
}
function handleTokenChanges(changes: any) {
for (let change of changes) {
if (change.table === "tokens") {
if (change.type === 1) {
// Created
const token = change.obj;
setTokens((prevTokens) => [token, ...prevTokens]);
} else if (change.type === 2) {
// Updated
const token = change.obj;
setTokens((prevTokens) => {
const newTokens = [...prevTokens];
const i = newTokens.findIndex((t) => t.id === token.id);
if (i > -1) {
newTokens[i] = token;
}
return newTokens;
});
} else if (change.type === 3) {
// Deleted
const id = change.key;
setTokens((prevTokens) => {
const filtered = prevTokens.filter((token) => token.id !== id);
return filtered;
});
}
}
}
}
database.on("changes", handleTokenChanges);
return () => {
database.on("changes").unsubscribe(handleTokenChanges);
};
}, [database, databaseStatus]);
const ownedTokens = tokens.filter((token) => token.owner === userId);
const tokensById: { [key: string]: Token; } = tokens.reduce((obj: { [key: string]: Token }, token) => {
obj[token.id] = token;
return obj;
}, {});
setTokensById(
tokens.reduce((obj: Record<string, Token>, token: Token) => {
obj[token.id] = token;
return obj;
}, {})
);
}, [tokens]);
const value: TokenDataContext = {
tokens,
ownedTokens,
addToken,
removeToken,
tokenGroups,
removeTokens,
updateToken,
updateTokens,
putToken,
getToken,
tokensById,
tokensLoading,
getTokenFromDB,
loadTokens,
getToken,
updateTokenGroups,
updateTokensHidden,
};
return (

View File

@@ -0,0 +1,36 @@
import React, { useEffect, useState, useContext } from "react";
import { useDatabase } from "./DatabaseContext";
/**
* @type {React.Context<string|undefined>}
*/
const UserIdContext = React.createContext();
export function UserIdProvider({ children }) {
const { database, databaseStatus } = useDatabase();
const [userId, setUserId] = useState();
useEffect(() => {
if (!database || databaseStatus === "loading") {
return;
}
async function loadUserId() {
const storedUserId = await database.table("user").get("userId");
if (storedUserId) {
setUserId(storedUserId.value);
}
}
loadUserId();
}, [database, databaseStatus]);
return (
<UserIdContext.Provider value={userId}>{children}</UserIdContext.Provider>
);
}
export function useUserId() {
return useContext(UserIdContext);
}
export default UserIdContext;