forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1/**
2 * Web IndexedDB storage for draft media.
3 * Media is stored by localRefPath key (unique identifier stored in server draft).
4 */
5import {createStore, del, get, keys, set} from 'idb-keyval'
6
7import {logger} from './logger'
8
9const DB_NAME = 'bsky-draft-media'
10const STORE_NAME = 'media'
11
12type MediaRecord = {
13 blob: Blob
14 createdAt: string
15}
16
17const store = createStore(DB_NAME, STORE_NAME)
18
19/**
20 * Convert a path/URL to a Blob
21 */
22async function toBlob(sourcePath: string): Promise<Blob> {
23 // Handle data URIs directly
24 if (sourcePath.startsWith('data:')) {
25 const response = await fetch(sourcePath)
26 return response.blob()
27 }
28
29 // Handle blob URLs
30 if (sourcePath.startsWith('blob:')) {
31 try {
32 const response = await fetch(sourcePath)
33 return response.blob()
34 } catch (e) {
35 logger.error('Failed to fetch blob URL - it may have been revoked', {
36 error: e,
37 sourcePath,
38 })
39 throw e
40 }
41 }
42
43 // Handle regular URLs
44 const response = await fetch(sourcePath)
45 if (!response.ok) {
46 throw new Error(`Failed to fetch media: ${response.status}`)
47 }
48 return response.blob()
49}
50
51/**
52 * Save a media file to IndexedDB by localRefPath key
53 */
54export async function saveMediaToLocal(
55 localRefPath: string,
56 sourcePath: string,
57): Promise<void> {
58 let blob: Blob
59 try {
60 blob = await toBlob(sourcePath)
61 } catch (error) {
62 logger.error('Failed to convert source to blob', {
63 error,
64 localRefPath,
65 sourcePath,
66 })
67 throw error
68 }
69
70 try {
71 await set(
72 localRefPath,
73 {
74 blob,
75 createdAt: new Date().toISOString(),
76 },
77 store,
78 )
79 // Update cache
80 mediaExistsCache.set(localRefPath, true)
81 } catch (error) {
82 logger.error('Failed to save media to IndexedDB', {error, localRefPath})
83 throw error
84 }
85}
86
87/**
88 * Track blob URLs created by loadMediaFromLocal for cleanup
89 */
90const createdBlobUrls = new Set<string>()
91
92/**
93 * Load a media file from IndexedDB
94 * @returns A blob URL for the saved media
95 */
96export async function loadMediaFromLocal(
97 localRefPath: string,
98): Promise<string> {
99 const record = await get<MediaRecord>(localRefPath, store)
100
101 if (!record) {
102 throw new Error(`Media file not found: ${localRefPath}`)
103 }
104
105 const url = URL.createObjectURL(record.blob)
106 logger.debug('Created blob URL', {url})
107 createdBlobUrls.add(url)
108 return url
109}
110
111/**
112 * Delete a media file from IndexedDB
113 */
114export async function deleteMediaFromLocal(
115 localRefPath: string,
116): Promise<void> {
117 await del(localRefPath, store)
118 mediaExistsCache.delete(localRefPath)
119}
120
121/**
122 * Check if a media file exists in IndexedDB (synchronous check using cache)
123 */
124const mediaExistsCache = new Map<string, boolean>()
125let cachePopulated = false
126let populateCachePromise: Promise<void> | null = null
127
128export function mediaExists(localRefPath: string): boolean {
129 if (mediaExistsCache.has(localRefPath)) {
130 return mediaExistsCache.get(localRefPath)!
131 }
132 // If cache not populated yet, trigger async population
133 if (!cachePopulated && !populateCachePromise) {
134 populateCachePromise = populateCacheInternal()
135 }
136 return false // Conservative: assume doesn't exist if not in cache
137}
138
139async function populateCacheInternal(): Promise<void> {
140 try {
141 const allKeys = await keys(store)
142 for (const key of allKeys) {
143 mediaExistsCache.set(key as string, true)
144 }
145 cachePopulated = true
146 } catch (e) {
147 logger.warn('Failed to populate media cache', {error: e})
148 }
149}
150
151/**
152 * Ensure the media cache is populated. Call this before checking mediaExists.
153 */
154export async function ensureMediaCachePopulated(): Promise<void> {
155 if (cachePopulated) return
156 if (!populateCachePromise) {
157 populateCachePromise = populateCacheInternal()
158 }
159 await populateCachePromise
160}
161
162/**
163 * Clear the media exists cache (call when media is added/deleted)
164 */
165export function clearMediaCache(): void {
166 mediaExistsCache.clear()
167 cachePopulated = false
168 populateCachePromise = null
169}
170
171/**
172 * Revoke a blob URL when done with it (to prevent memory leaks)
173 */
174export function revokeMediaUrl(url: string): void {
175 if (url.startsWith('blob:')) {
176 logger.debug('Revoking blob URL', {url})
177 URL.revokeObjectURL(url)
178 createdBlobUrls.delete(url)
179 }
180}
181
182/**
183 * Revoke all blob URLs created by loadMediaFromLocal.
184 * Call this when closing the drafts list dialog to prevent memory leaks.
185 */
186export function revokeAllMediaUrls(): void {
187 logger.debug(`Revoking ${createdBlobUrls.size} blob URLs`)
188 for (const url of createdBlobUrls) {
189 URL.revokeObjectURL(url)
190 }
191 createdBlobUrls.clear()
192}