diff --git a/src/lib/modules/filesystem/category.ts b/src/lib/modules/filesystem/category.ts index 47a4565..778f75c 100644 --- a/src/lib/modules/filesystem/category.ts +++ b/src/lib/modules/filesystem/category.ts @@ -116,6 +116,6 @@ const storeToIndexedDB = (info: CategoryInfo) => { return { ...info, exists: true as const }; }; -export const getCategoryInfo = async (id: CategoryId, masterKey: CryptoKey) => { - return await cache.get(id, masterKey); +export const getCategoryInfo = (id: CategoryId, masterKey: CryptoKey) => { + return cache.get(id, masterKey); }; diff --git a/src/lib/modules/filesystem/directory.ts b/src/lib/modules/filesystem/directory.ts index 3f6cab1..4144a68 100644 --- a/src/lib/modules/filesystem/directory.ts +++ b/src/lib/modules/filesystem/directory.ts @@ -97,6 +97,6 @@ const storeToIndexedDB = (info: DirectoryInfo) => { return { ...info, exists: true as const }; }; -export const getDirectoryInfo = async (id: DirectoryId, masterKey: CryptoKey) => { - return await cache.get(id, masterKey); +export const getDirectoryInfo = (id: DirectoryId, masterKey: CryptoKey) => { + return cache.get(id, masterKey); }; diff --git a/src/lib/modules/filesystem/file.ts b/src/lib/modules/filesystem/file.ts index 66ad359..9f8827d 100644 --- a/src/lib/modules/filesystem/file.ts +++ b/src/lib/modules/filesystem/file.ts @@ -168,10 +168,10 @@ const bulkStoreToIndexedDB = (infos: FileInfo[]) => { return infos.map((info) => [info.id, { ...info, exists: true }] as const); }; -export const getFileInfo = async (id: number, masterKey: CryptoKey) => { - return await cache.get(id, masterKey); +export const getFileInfo = (id: number, masterKey: CryptoKey) => { + return cache.get(id, masterKey); }; -export const bulkGetFileInfo = async (ids: number[], masterKey: CryptoKey) => { - return await cache.bulkGet(new Set(ids), masterKey); +export const bulkGetFileInfo = (ids: number[], masterKey: CryptoKey) => { + return cache.bulkGet(new Set(ids), masterKey); }; diff --git a/src/lib/modules/filesystem/internal.svelte.ts b/src/lib/modules/filesystem/internal.svelte.ts index 6e8d7f2..7a8c446 100644 --- a/src/lib/modules/filesystem/internal.svelte.ts +++ b/src/lib/modules/filesystem/internal.svelte.ts @@ -29,8 +29,6 @@ export class FilesystemCache { this.map.set(key, newState); } - state.promise = newPromise; - (state.value ? Promise.resolve(state.value) : this.options.fetchFromIndexedDB(key).then((loadedInfo) => { @@ -54,7 +52,8 @@ export class FilesystemCache { state.promise = undefined; }); - return newPromise; + state.promise = newPromise; + return state.value ?? newPromise; }); } @@ -108,12 +107,17 @@ export class FilesystemCache { }); }); - return Promise.all( + const bottleneckPromises = Array.from( keys .keys() .filter((key) => this.map.get(key)!.value === undefined) .map((key) => this.map.get(key)!.promise!), - ).then(() => new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const))); + ); + const makeResult = () => + new Map(keys.keys().map((key) => [key, this.map.get(key)!.value!] as const)); + return bottleneckPromises.length > 0 + ? Promise.all(bottleneckPromises).then(makeResult) + : makeResult(); }); } } diff --git a/src/lib/utils/HybridPromise.ts b/src/lib/utils/HybridPromise.ts new file mode 100644 index 0000000..10c6be9 --- /dev/null +++ b/src/lib/utils/HybridPromise.ts @@ -0,0 +1,93 @@ +type MaybePromise = T | Promise | HybridPromise; + +type HybridPromiseState = + | { mode: "sync"; status: "fulfilled"; value: T } + | { mode: "sync"; status: "rejected"; reason: unknown } + | { mode: "async"; promise: Promise }; + +export class HybridPromise implements PromiseLike { + private isConsumed = false; + + private constructor(private readonly state: HybridPromiseState) { + if (state.mode === "sync" && state.status === "rejected") { + queueMicrotask(() => { + if (!this.isConsumed) { + throw state.reason; + } + }); + } + } + + isSync(): boolean { + return this.state.mode === "sync"; + } + + toPromise(): Promise { + this.isConsumed = true; + + if (this.state.mode === "async") return this.state.promise; + return this.state.status === "fulfilled" + ? Promise.resolve(this.state.value) + : Promise.reject(this.state.reason); + } + + static resolve(value: MaybePromise): HybridPromise { + if (value instanceof HybridPromise) return value; + return new HybridPromise( + value instanceof Promise + ? { mode: "async", promise: value } + : { mode: "sync", status: "fulfilled", value }, + ); + } + + static reject(reason?: unknown): HybridPromise { + return new HybridPromise({ mode: "sync", status: "rejected", reason }); + } + + then( + onfulfilled?: ((value: T) => MaybePromise) | null | undefined, + onrejected?: ((reason: unknown) => MaybePromise) | null | undefined, + ): HybridPromise { + this.isConsumed = true; + + if (this.state.mode === "async") { + return new HybridPromise({ + mode: "async", + promise: this.state.promise.then(onfulfilled, onrejected) as any, + }); + } + + try { + if (this.state.status === "fulfilled") { + if (!onfulfilled) return HybridPromise.resolve(this.state.value as any); + return HybridPromise.resolve(onfulfilled(this.state.value)); + } else { + if (!onrejected) return HybridPromise.reject(this.state.reason); + return HybridPromise.resolve(onrejected(this.state.reason)); + } + } catch (e) { + return HybridPromise.reject(e); + } + } + + catch( + onrejected?: ((reason: unknown) => MaybePromise) | null | undefined, + ): HybridPromise { + return this.then(null, onrejected); + } + + finally(onfinally?: (() => void) | null | undefined): HybridPromise { + this.isConsumed = true; + + if (this.state.mode === "async") { + return new HybridPromise({ mode: "async", promise: this.state.promise.finally(onfinally) }); + } + + try { + onfinally?.(); + return new HybridPromise(this.state); + } catch (e) { + return HybridPromise.reject(e); + } + } +} diff --git a/src/lib/utils/index.ts b/src/lib/utils/index.ts index 1db9577..5d5b9d4 100644 --- a/src/lib/utils/index.ts +++ b/src/lib/utils/index.ts @@ -1,3 +1,4 @@ export * from "./format"; export * from "./gotoStateful"; +export * from "./HybridPromise"; export * from "./sort"; diff --git a/src/routes/(fullscreen)/file/[id]/+page.svelte b/src/routes/(fullscreen)/file/[id]/+page.svelte index 585c0d0..0b344bc 100644 --- a/src/routes/(fullscreen)/file/[id]/+page.svelte +++ b/src/routes/(fullscreen)/file/[id]/+page.svelte @@ -9,6 +9,7 @@ import { captureVideoThumbnail } from "$lib/modules/thumbnail"; import { getFileDownloadState } from "$lib/modules/file"; import { masterKeyStore } from "$lib/stores"; + import { HybridPromise } from "$lib/utils"; import AddToCategoryBottomSheet from "./AddToCategoryBottomSheet.svelte"; import DownloadStatus from "./DownloadStatus.svelte"; import { @@ -26,8 +27,7 @@ let { data } = $props(); - let infoPromise: Promise | undefined = $state(); - let info: FileInfo | null = $state(null); + let info: MaybeFileInfo | undefined = $state(); let downloadState = $derived(getFileDownloadState(data.id)); let isMenuOpen = $state(false); @@ -65,22 +65,20 @@ const addToCategory = async (categoryId: number) => { await requestFileAdditionToCategory(data.id, categoryId); isAddToCategoryBottomSheetOpen = false; - infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME }; const removeFromCategory = async (categoryId: number) => { await requestFileRemovalFromCategory(data.id, categoryId); - infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + void getFileInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME }; $effect(() => { - infoPromise = getFileInfo(data.id, $masterKeyStore?.get(1)?.key!).then((fileInfo) => { - if (fileInfo.exists) { - info = fileInfo; + HybridPromise.resolve(getFileInfo(data.id, $masterKeyStore?.get(1)?.key!)).then((result) => { + if (data.id === result.id) { + info = result; } - return fileInfo; }); - info = null; isDownloadRequested = false; viewerType = undefined; }); @@ -111,8 +109,8 @@ }); $effect(() => { - if (info && downloadState?.status === "decrypted") { - untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentType)); + if (info?.exists && downloadState?.status === "decrypted") { + untrack(() => !isDownloadRequested && updateViewer(downloadState.result!, info!.contentIv!)); } }); @@ -123,87 +121,85 @@ 파일 -{#if info} - - - -
e.stopPropagation()}> - - -
-
- -
- {#if downloadState} - - {/if} - {#if viewerType} -
- {#snippet viewerLoading(message: string)} -

{message}

- {/snippet} + + + +
e.stopPropagation()}> + + +
+
+ +
+ {#if downloadState} + + {/if} + {#if info && viewerType} +
+ {#snippet viewerLoading(message: string)} +

{message}

+ {/snippet} - {#if viewerType === "image"} - {#if fileBlobUrl} - {info.name} - {:else} - {@render viewerLoading("이미지를 불러오고 있어요.")} - {/if} - {:else if viewerType === "video"} - {#if fileBlobUrl} -
- - updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)} - class="w-full" - > - 이 장면을 썸네일로 설정하기 - -
- {:else} - {@render viewerLoading("비디오를 불러오고 있어요.")} - {/if} + {#if viewerType === "image"} + {#if fileBlobUrl} + {info.name} + {:else} + {@render viewerLoading("이미지를 불러오고 있어요.")} {/if} -
- {/if} -
-

카테고리

-
- goto(`/category/${id}`)} - onCategoryMenuClick={({ id }) => removeFromCategory(id)} - /> - (isAddToCategoryBottomSheetOpen = true)} - class="h-12 w-full" - iconClass="text-gray-600" - textClass="text-gray-700" - > - 카테고리에 추가하기 - -
+ {:else if viewerType === "video"} + {#if fileBlobUrl} +
+ + updateThumbnail(info?.dataKey?.key!, info?.dataKey?.version!)} + class="w-full" + > + 이 장면을 썸네일로 설정하기 + +
+ {:else} + {@render viewerLoading("비디오를 불러오고 있어요.")} + {/if} + {/if} +
+ {/if} +
+

카테고리

+
+ goto(`/category/${id}`)} + onCategoryMenuClick={({ id }) => removeFromCategory(id)} + /> + (isAddToCategoryBottomSheetOpen = true)} + class="h-12 w-full" + iconClass="text-gray-600" + textClass="text-gray-700" + > + 카테고리에 추가하기 +
- +
+
- -{/if} + diff --git a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte index 5d89512..17b6662 100644 --- a/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte +++ b/src/routes/(fullscreen)/file/[id]/AddToCategoryBottomSheet.svelte @@ -4,6 +4,7 @@ import { CategoryCreateModal } from "$lib/components/organisms"; import { getCategoryInfo, type MaybeCategoryInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; + import { HybridPromise } from "$lib/utils"; import { requestCategoryCreation } from "./service"; interface Props { @@ -13,48 +14,50 @@ let { onAddToCategoryClick, isOpen = $bindable() }: Props = $props(); - let categoryInfoPromise: Promise | undefined = $state(); + let categoryInfo: MaybeCategoryInfo | undefined = $state(); let isCategoryCreateModalOpen = $state(false); $effect(() => { if (isOpen) { - categoryInfoPromise = getCategoryInfo("root", $masterKeyStore?.get(1)?.key!); + HybridPromise.resolve(getCategoryInfo("root", $masterKeyStore?.get(1)?.key!)).then( + (result) => (categoryInfo = result), + ); } }); -{#await categoryInfoPromise then categoryInfo} - {#if categoryInfo?.exists} - - - - (categoryInfoPromise = getCategoryInfo(id, $masterKeyStore?.get(1)?.key!))} - onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} - subCategoryCreatePosition="top" - /> - {#if categoryInfo.id !== "root"} - - - - {/if} - - +{#if categoryInfo?.exists} + + + + HybridPromise.resolve(getCategoryInfo(id, $masterKeyStore?.get(1)?.key!)).then( + (result) => (categoryInfo = result), + )} + onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} + subCategoryCreatePosition="top" + /> + {#if categoryInfo.id !== "root"} + + + + {/if} + + +{/if} - { - if (await requestCategoryCreation(name, categoryInfo.id, $masterKeyStore?.get(1)!)) { - categoryInfoPromise = getCategoryInfo(categoryInfo.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} - /> - {/if} -{/await} + { + if (await requestCategoryCreation(name, categoryInfo!.id, $masterKeyStore?.get(1)!)) { + void getCategoryInfo(categoryInfo!.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> diff --git a/src/routes/(main)/category/[[id]]/+page.svelte b/src/routes/(main)/category/[[id]]/+page.svelte index f57b402..3c07474 100644 --- a/src/routes/(main)/category/[[id]]/+page.svelte +++ b/src/routes/(main)/category/[[id]]/+page.svelte @@ -4,6 +4,7 @@ import { Category, CategoryCreateModal } from "$lib/components/organisms"; import { getCategoryInfo, type MaybeCategoryInfo } from "$lib/modules/filesystem"; import { masterKeyStore } from "$lib/stores"; + import { HybridPromise } from "$lib/utils"; import CategoryDeleteModal from "./CategoryDeleteModal.svelte"; import CategoryMenuBottomSheet from "./CategoryMenuBottomSheet.svelte"; import CategoryRenameModal from "./CategoryRenameModal.svelte"; @@ -18,7 +19,7 @@ let { data } = $props(); let context = createContext(); - let infoPromise: Promise | undefined = $state(); + let info: MaybeCategoryInfo | undefined = $state(); let isCategoryCreateModalOpen = $state(false); let isCategoryMenuBottomSheetOpen = $state(false); @@ -26,7 +27,13 @@ let isCategoryDeleteModalOpen = $state(false); $effect(() => { - infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); + HybridPromise.resolve(getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!)).then( + (result) => { + if (data.id === result.id) { + info = result; + } + }, + ); }); @@ -34,70 +41,68 @@ 카테고리 -{#await infoPromise then info} - {#if info?.exists} - {#if info.id !== "root"} - - {/if} -
- goto(`/file/${id}?from=category`)} - onFileRemoveClick={async ({ id }) => { - await requestFileRemovalFromCategory(id, data.id as number); - infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - }} - onSubCategoryClick={({ id }) => goto(`/category/${id}`)} - onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} - onSubCategoryMenuClick={(subCategory) => { - context.selectedCategory = subCategory; - isCategoryMenuBottomSheetOpen = true; - }} - /> -
- - { - if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} - /> - - { - isCategoryMenuBottomSheetOpen = false; - isCategoryRenameModalOpen = true; - }} - onDeleteClick={() => { - isCategoryMenuBottomSheetOpen = false; - isCategoryDeleteModalOpen = true; - }} - /> - { - if (await requestCategoryRename(context.selectedCategory!, newName)) { - infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} - /> - { - if (await requestCategoryDeletion(context.selectedCategory!)) { - infoPromise = getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} - /> +{#if info?.exists} + {#if info.id !== "root"} + {/if} -{/await} +
+ goto(`/file/${id}?from=category`)} + onFileRemoveClick={async ({ id }) => { + await requestFileRemovalFromCategory(id, data.id as number); + void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + }} + onSubCategoryClick={({ id }) => goto(`/category/${id}`)} + onSubCategoryCreateClick={() => (isCategoryCreateModalOpen = true)} + onSubCategoryMenuClick={(subCategory) => { + context.selectedCategory = subCategory; + isCategoryMenuBottomSheetOpen = true; + }} + /> +
+{/if} + + { + if (await requestCategoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { + void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> + + { + isCategoryMenuBottomSheetOpen = false; + isCategoryRenameModalOpen = true; + }} + onDeleteClick={() => { + isCategoryMenuBottomSheetOpen = false; + isCategoryDeleteModalOpen = true; + }} +/> + { + if (await requestCategoryRename(context.selectedCategory!, newName)) { + void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> + { + if (await requestCategoryDeletion(context.selectedCategory!)) { + void getCategoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> diff --git a/src/routes/(main)/directory/[[id]]/+page.svelte b/src/routes/(main)/directory/[[id]]/+page.svelte index 8edd04b..1919d7b 100644 --- a/src/routes/(main)/directory/[[id]]/+page.svelte +++ b/src/routes/(main)/directory/[[id]]/+page.svelte @@ -6,6 +6,7 @@ import { TopBar } from "$lib/components/molecules"; import { getDirectoryInfo, type MaybeDirectoryInfo } from "$lib/modules/filesystem"; import { masterKeyStore, hmacSecretStore } from "$lib/stores"; + import { HybridPromise } from "$lib/utils"; import DirectoryCreateModal from "./DirectoryCreateModal.svelte"; import DirectoryEntries from "./DirectoryEntries"; import DownloadStatusCard from "./DownloadStatusCard.svelte"; @@ -29,7 +30,7 @@ let { data } = $props(); let context = createContext(); - let infoPromise: Promise | undefined = $state(); + let info: MaybeDirectoryInfo | undefined = $state(); let fileInput: HTMLInputElement | undefined = $state(); let duplicatedFile: File | undefined = $state(); let resolveForDuplicateFileModal: ((res: boolean) => void) | undefined = $state(); @@ -60,7 +61,7 @@ .then((res) => { if (!res) return; // TODO: FIXME - infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); }) .catch((e: Error) => { // TODO: FIXME @@ -78,7 +79,13 @@ }); $effect(() => { - infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); + HybridPromise.resolve(getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!)).then( + (result) => { + if (data.id === result.id) { + info = result; + } + }, + ); }); @@ -88,17 +95,17 @@ -{#await infoPromise then info} - {#if info?.exists} -
- {#if showTopBar} - - {/if} -
-
- goto("/file/uploads")} /> - goto("/file/downloads")} /> -
+{#if info?.exists} +
+ {#if showTopBar} + + {/if} +
+
+ goto("/file/uploads")} /> + goto("/file/downloads")} /> +
+ {#key info.id} goto(`/${type}/${id}`)} @@ -109,85 +116,85 @@ showParentEntry={isFromFilePage && info.parentId !== undefined} onParentClick={() => goto( - info.parentId === "root" + info!.parentId === "root" ? "/directory?from=file" - : `/directory/${info.parentId}?from=file`, + : `/directory/${info!.parentId}?from=file`, )} /> -
+ {/key}
+
+{/if} - { - isEntryCreateBottomSheetOpen = true; - }} - class="bottom-24 right-4" - /> - { - isEntryCreateBottomSheetOpen = false; - isDirectoryCreateModalOpen = true; - }} - onFileUploadClick={() => { - isEntryCreateBottomSheetOpen = false; - fileInput?.click(); - }} - /> - { - if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { - infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} - /> - { - resolveForDuplicateFileModal?.(false); - isDuplicateFileModalOpen = false; - }} - onUploadClick={() => { - resolveForDuplicateFileModal?.(true); - isDuplicateFileModalOpen = false; - }} - /> + { + isEntryCreateBottomSheetOpen = true; + }} + class="bottom-24 right-4" +/> + { + isEntryCreateBottomSheetOpen = false; + isDirectoryCreateModalOpen = true; + }} + onFileUploadClick={() => { + isEntryCreateBottomSheetOpen = false; + fileInput?.click(); + }} +/> + { + if (await requestDirectoryCreation(name, data.id, $masterKeyStore?.get(1)!)) { + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> + { + resolveForDuplicateFileModal?.(false); + isDuplicateFileModalOpen = false; + }} + onUploadClick={() => { + resolveForDuplicateFileModal?.(true); + isDuplicateFileModalOpen = false; + }} +/> - { - isEntryMenuBottomSheetOpen = false; - isEntryRenameModalOpen = true; - }} - onDeleteClick={() => { - isEntryMenuBottomSheetOpen = false; - isEntryDeleteModalOpen = true; - }} - /> - { - if (await requestEntryRename(context.selectedEntry!, newName)) { - infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} - /> - { - if (await requestEntryDeletion(context.selectedEntry!)) { - infoPromise = getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME - return true; - } - return false; - }} - /> - {/if} -{/await} + { + isEntryMenuBottomSheetOpen = false; + isEntryRenameModalOpen = true; + }} + onDeleteClick={() => { + isEntryMenuBottomSheetOpen = false; + isEntryDeleteModalOpen = true; + }} +/> + { + if (await requestEntryRename(context.selectedEntry!, newName)) { + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/> + { + if (await requestEntryDeletion(context.selectedEntry!)) { + void getDirectoryInfo(data.id, $masterKeyStore?.get(1)?.key!); // TODO: FIXME + return true; + } + return false; + }} +/>