grungnet/src/contexts/AssetsContext.tsx
2021-07-24 18:04:58 +10:00

355 lines
8.8 KiB
TypeScript

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";
import { Asset } from "../types/Asset";
export type GetAssetEventHanlder = (
assetId: string
) => Promise<Asset | undefined>;
export type AddAssetsEventHandler = (assets: Asset[]) => Promise<void>;
export type PutAssetEventsHandler = (asset: Asset) => Promise<void>;
type AssetsContextValue = {
getAsset: GetAssetEventHanlder;
addAssets: AddAssetsEventHandler;
putAsset: PutAssetEventsHandler;
};
const AssetsContext =
React.createContext<AssetsContextValue | undefined>(undefined);
// 100 MB max cache size
const maxCacheSize = 1e8;
export function AssetsProvider({ children }: { children: React.ReactNode }) {
const { worker, database, databaseStatus } = useDatabase();
useEffect(() => {
if (databaseStatus === "loaded") {
worker.cleanAssetCache(maxCacheSize);
}
}, [worker, databaseStatus]);
const getAsset = useCallback<GetAssetEventHanlder>(
async (assetId) => {
if (database) {
return await database.table("assets").get(assetId);
}
},
[database]
);
const addAssets = useCallback<AddAssetsEventHandler>(
async (assets) => {
if (database) {
await database.table("assets").bulkAdd(assets);
}
},
[database]
);
const putAsset = useCallback<PutAssetEventsHandler>(
async (asset) => {
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);
}
}
},
[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 AssetURL = {
url: string | null;
id: string;
references: number;
};
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
*/
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
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(() => {
if (!loadingDebouncedAssetURLs) {
return;
}
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
const assets = useLiveQuery<Asset[]>(
() => database?.table("assets").bulkGet(assetKeys) || [],
[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 (asset && 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.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
*/
export function useAssetURL(
assetId: string | null,
type: "file" | "default" | null,
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;
}
const updateAssetURL = () => {
const increaseReferences = (prevURLs: AssetURLs): AssetURLs => {
return {
...prevURLs,
[assetId]: {
...prevURLs[assetId],
references: prevURLs[assetId].references + 1,
},
};
};
const createReference = (prevURLs: AssetURLs): AssetURLs => {
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;
}
type FileData = {
file: string;
type: "file";
thumbnail?: string;
quality?: string;
resolutions?: Record<string, string>;
};
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(
data: FileData | DefaultData | null,
defaultSources: Record<string, string>,
unknownSource: string | undefined = undefined,
thumbnail = false
) {
const [assetId, setAssetId] = useState<string | null>(null);
useEffect(() => {
function loadAssetId() {
if (!data) {
return;
}
if (data.type === "default") {
setAssetId(data.key);
} else {
if (thumbnail && data.thumbnail) {
setAssetId(data.thumbnail);
} else if (
data.resolutions &&
data.quality &&
data.quality !== "original"
) {
setAssetId(data.resolutions[data.quality]);
} else {
setAssetId(data.file);
}
}
}
loadAssetId();
}, [data, thumbnail]);
const assetURL = useAssetURL(
assetId || null,
data?.type || null,
defaultSources,
unknownSource
);
return assetURL;
}
export default AssetsContext;