diff --git a/src/cache.ts b/src/cache.ts new file mode 100644 index 0000000..e2001a6 --- /dev/null +++ b/src/cache.ts @@ -0,0 +1,32 @@ +import { useRef } from "react" +import type { RequestError } from "./request" +import { Result } from "./result" + +// KeyHash is supposed to be something where `===` compares for structural equality (so in JS: number, string). +export type KeyHash = string | number +export function useCache( + hash: (key: Key) => KeyHash, + f: (k: Key) => Promise>, +) { + // @PERSONAL_NOTE + // Could also cache `Promise` in case two requests are made really fast one after another (not really the case in this app so whatever) + const cacheRef = useRef>(new Map()) + + async function getCached(key: Key): Promise> { + const keyHash = hash(key) + const maybeValue = cacheRef.current.get(keyHash) + if (maybeValue === undefined) { + // console.log("CACHE-MISS") + const result = await f(key) + if (result.tag === "ok") { + cacheRef.current.set(keyHash, result.value) + } + return result + } else { + // console.log("CACHE-HIT") + return Result.ok(maybeValue) + } + } + + return getCached +} diff --git a/src/ui/Picturarium.tsx b/src/ui/Picturarium.tsx index 8b685c5..9115fe8 100644 --- a/src/ui/Picturarium.tsx +++ b/src/ui/Picturarium.tsx @@ -1,4 +1,4 @@ -import { useReducer, useRef, useEffect, createContext, useContext, useState } from "react" +import { useReducer, useEffect, createContext, useContext, useState } from "react" import { Box, Button, @@ -14,6 +14,7 @@ import { Result } from "../result" import { RequestError } from "../request" import { Remote } from "../remote" import { getCaption } from "../api/caption" +import { useCache } from "../cache" // @PERSONAL_NOTE // NOTE: There are multiple school of thoughts on how to structure a component. @@ -188,39 +189,41 @@ type Msg = | { tag: "previousButtonClicked" } | { tag: "nextButtonClicked" } | { tag: "imageClicked"; imageId: ImageId } - // TODO: Move - // | { tag: "captionReceived"; forImage: ImageId; result: Result } | { tag: "modalCloseButtonClicked" } -function useApp(): [State, Dispatch] { +// === Caching Context for AI captions === +type GetImageCaptionCached = (imageId: ImageId) => Promise> + +const ImageCaptionCacheContext = createContext(null) + +function useGetImageCaptionCached(): GetImageCaptionCached { + const getImageCaptionCached = useContext(ImageCaptionCacheContext) + + if (getImageCaptionCached === null) { + throw new Error( + "useGetImageCaptionCached must be used inside ImageCaptionCacheContext.Provider", + ) + } + + return getImageCaptionCached +} + +function useApp(): [State, Dispatch, GetImageCaptionCached] { const [state, dispatch] = useReducer(update, State.init()) // === Caching API calls === - // @PERSONAL_NOTE - // Could also cache `Promise` in case two requests are made really fast one after another (not really the case in this app so whatever) - const cacheRef = useRef>(new Map()) - - async function getImageIdsCached(page: Page): Promise> { - const pageKey = Page.key(page) - const maybeImages = cacheRef.current.get(pageKey) - if (maybeImages === undefined) { - // console.log("CACHE-MISS") - const result = await getImageIds(page) - if (result.tag === "ok") { - cacheRef.current.set(pageKey, result.value) - } - return result - } else { - // console.log("CACHE-HIT") - return Result.ok(maybeImages) - } - } + const getImageIdsCached = useCache((page) => Page.key(page), getImageIds) + const getImageCaptionCached: GetImageCaptionCached = useCache( + (imageId) => imageId, + getImageCaption, + ) // === effects: initialization & reloading === // == Page Refresh == useEffect( () => { + // This call triggers eslint warning, but it's fine. We don't want to declare `getImageIdsCached` as a dependency. void getImageIdsCached(state.page).then((result) => { dispatch({ tag: "imagesReceived", forPage: state.page, result }) }) @@ -231,17 +234,6 @@ function useApp(): [State, Dispatch] { ) // == AI Captions == - // TODO: MOVE - // useEffect(() => { - // if (state.selectedImage !== undefined) { - // const imageId = state.selectedImage.imageId - // void getImageCaption(imageId).then((result) => { - // dispatch({ tag: "captionReceived", forImage: imageId, result }) - // }) - // // TODO: Need to fetch the image (with some url of smaller dimensions) - // } - // }, [state.selectedImage?.imageId]) - function update(state: State, msg: Msg): State { switch (msg.tag) { case "imagesReceived": @@ -266,18 +258,6 @@ function useApp(): [State, Dispatch] { case "imageClicked": { return { ...state, selectedImage: msg.imageId } } - // TODO: move - // case "captionReceived": { - // if (state.selectedImage !== undefined && state.selectedImage.imageId === msg.forImage) { - // const modalState: OpenImageModalState = { - // ...state.selectedImage, - // caption: msg.result, - // } - // return { ...state, selectedImage: modalState } - // } else { - // return state - // } - // } case "modalCloseButtonClicked": { return { ...state, selectedImage: undefined } } @@ -286,7 +266,7 @@ function useApp(): [State, Dispatch] { } } - return [state, dispatch] + return [state, dispatch, getImageCaptionCached] } type Dispatch = (msg: Msg) => void @@ -436,7 +416,6 @@ function Image({ } function ImageModal({ selectedImage }: { selectedImage: ImageId | undefined }) { - // TODO: Display captions const dispatch = useDispatch() return ( ({ caption: Remote.loading() }) + const getImageCaptionCached = useGetImageCaptionCached() - useEffect(() => { - let isAlive = true - // TODO: Do I need to set `Remote.loading()` here? I don't like that. + useEffect( + () => { + let isAlive = true + // TODO: Do I need to set `Remote.loading()` here? I don't like that. - void getImageCaption(imageId).then((caption) => { - if (isAlive) { - setState({ caption }) + void getImageCaptionCached(imageId).then((caption) => { + if (isAlive) { + setState({ caption }) + } + }) + + return () => { + isAlive = false } - }) - - return () => { - isAlive = false - } - }, [imageId]) + }, + // This call triggers eslint warning, but it's fine. We don't want to declare `getImageCaptionCached` as a dependency. + [imageId], + ) return ( @@ -518,66 +502,67 @@ function CaptionView({ caption }: { caption: Remote }) { } export default function Picturarium() { - const [state, dispatch] = useApp() + const [state, dispatch, getImageCaptionCached] = useApp() return ( - - - Picturarium - - - + + - - - {state.page.index} - - - - + Picturarium - - + + + + + + {state.page.index} + + + + + + + + ) } - // === Error view === function ErrorView({ title, @@ -610,4 +595,3 @@ function ErrorView({ ) } -