grungnet/src/contexts/AssetsContext.tsx

352 lines
8.7 KiB
TypeScript
Raw Normal View History

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";
2021-07-09 02:22:35 -04:00
import { Asset } from "../types/Asset";
2021-07-16 00:55:33 -04:00
export type GetAssetEventHanlder = (
assetId: string
) => Promise<Asset | undefined>;
export type AddAssetsEventHandler = (assets: Asset[]) => Promise<void>;
export type PutAssetEventsHandler = (asset: Asset) => Promise<void>;
2021-07-09 02:22:35 -04:00
type AssetsContext = {
2021-07-16 00:55:33 -04:00
getAsset: GetAssetEventHanlder;
addAssets: AddAssetsEventHandler;
putAsset: PutAssetEventsHandler;
2021-07-09 02:22:35 -04:00
};
2021-04-22 21:48:24 -04:00
2021-07-09 02:22:35 -04:00
const AssetsContext = React.createContext<AssetsContext | undefined>(undefined);
// 100 MB max cache size
const maxCacheSize = 1e8;
2021-07-09 02:22:35 -04:00
export function AssetsProvider({ children }: { children: React.ReactNode }) {
const { worker, database, databaseStatus } = useDatabase();
useEffect(() => {
if (databaseStatus === "loaded") {
worker.cleanAssetCache(maxCacheSize);
}
}, [worker, databaseStatus]);
2021-07-16 00:55:33 -04:00
const getAsset = useCallback<GetAssetEventHanlder>(
async (assetId) => {
2021-07-09 02:22:35 -04:00
if (database) {
return await database.table("assets").get(assetId);
}
},
[database]
);
2021-07-16 00:55:33 -04:00
const addAssets = useCallback<AddAssetsEventHandler>(
2021-04-22 21:48:24 -04:00
async (assets) => {
2021-07-09 02:22:35 -04:00
if (database) {
await database.table("assets").bulkAdd(assets);
}
2021-04-22 21:48:24 -04:00
},
[database]
);
2021-07-16 00:55:33 -04:00
const putAsset = useCallback<PutAssetEventsHandler>(
async (asset) => {
2021-07-09 02:22:35 -04:00
if (database) {
// 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);
}
}
},
2021-04-29 02:38:33 -04:00
[database, worker]
);
2021-04-22 21:48:24 -04:00
const value = {
getAsset,
addAssets,
putAsset,
2021-04-22 21:48:24 -04:00
};
return (
2021-04-22 21:48:24 -04:00
<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
*/
2021-07-09 02:22:35 -04:00
type AssetURL = {
url: string | null;
id: string;
references: number;
};
2021-07-09 02:22:35 -04:00
type AssetURLs = Record<string, AssetURL>;
export const AssetURLsStateContext =
React.createContext<AssetURLs | undefined>(undefined);
export const AssetURLsUpdaterContext =
React.createContext<
React.Dispatch<React.SetStateAction<AssetURLs>> | undefined
>(undefined);
/**
* Helper to manage sharing of custom image sources between uses of useAssetURL
*/
2021-07-09 02:22:35 -04:00
export function AssetURLsProvider({ children }: { children: React.ReactNode }) {
const [assetURLs, setAssetURLs] = useState<AssetURLs>({});
const { database } = useDatabase();
// Keep track of the assets that need to be loaded
2021-07-09 02:22:35 -04:00
const [assetKeys, setAssetKeys] = useState<string[]>([]);
// 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(() => {
2021-07-09 02:22:35 -04:00
let keysToLoad: string[] = [];
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
2021-07-09 02:22:35 -04:00
const assets = useLiveQuery<Asset[]>(
() =>
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) {
2021-06-27 01:33:56 -04:00
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) {
2021-07-09 02:22:35 -04:00
url.url && 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
*/
2021-07-09 02:22:35 -04:00
export function useAssetURL(
assetId: string,
type: "file" | "default",
defaultSources: Record<string, string>,
unknownSource?: string
) {
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() {
2021-07-09 02:22:35 -04:00
function increaseReferences(prevURLs: AssetURLs): AssetURLs {
return {
...prevURLs,
[assetId]: {
...prevURLs[assetId],
references: prevURLs[assetId].references + 1,
},
};
}
2021-07-09 02:22:35 -04:00
function createReference(prevURLs: AssetURLs): AssetURLs {
return {
...prevURLs,
[assetId]: { url: null, id: assetId, references: 1 },
};
}
2021-07-09 02:22:35 -04:00
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") {
2021-04-22 21:48:24 -04:00
return assetURLs[assetId]?.url || unknownSource;
}
return unknownSource;
}
2021-07-09 02:22:35 -04:00
type FileData = {
file: string;
type: "file";
2021-07-16 07:39:45 -04:00
thumbnail?: string;
2021-07-09 02:22:35 -04:00
quality?: string;
resolutions?: Record<string, string>;
};
2021-07-09 02:22:35 -04:00
type DefaultData = {
key: string;
type: "default";
};
/**
* Load a map or token into a URL taking into account a thumbnail and multiple resolutions
*/
export function useDataURL(
2021-07-09 02:22:35 -04:00
data: FileData | DefaultData,
defaultSources: Record<string, string>,
2021-07-16 02:58:14 -04:00
unknownSource: string | undefined = undefined,
thumbnail = false
) {
2021-07-09 02:22:35 -04:00
const [assetId, setAssetId] = useState<string>();
useEffect(() => {
if (!data) {
return;
}
function loadAssetId() {
if (data.type === "default") {
setAssetId(data.key);
} else {
if (thumbnail) {
setAssetId(data.thumbnail);
2021-07-09 02:22:35 -04:00
} else if (
data.resolutions &&
data.quality &&
data.quality !== "original"
) {
setAssetId(data.resolutions[data.quality]);
} else {
setAssetId(data.file);
}
}
}
loadAssetId();
}, [data, thumbnail]);
const assetURL = useAssetURL(
2021-07-09 02:22:35 -04:00
assetId || "",
data?.type,
defaultSources,
unknownSource
);
return assetURL;
}
export default AssetsContext;