Bluesky app fork with some witchin' additions 馃挮
at main 192 lines 4.6 kB view raw
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}