172 lines
4.4 KiB
TypeScript
172 lines
4.4 KiB
TypeScript
/**
|
|
* Native file system storage for draft media.
|
|
* Media is stored by localRefPath key (unique identifier stored in server draft).
|
|
*/
|
|
import {Directory, File, Paths} from 'expo-file-system'
|
|
|
|
import {logger} from './logger'
|
|
|
|
const MEDIA_DIR = 'bsky-draft-media'
|
|
|
|
function getMediaDirectory(): Directory {
|
|
return new Directory(Paths.document, MEDIA_DIR)
|
|
}
|
|
|
|
function getMediaFile(localRefPath: string): File {
|
|
const safeFilename = encodeURIComponent(localRefPath)
|
|
return new File(getMediaDirectory(), safeFilename)
|
|
}
|
|
|
|
let dirCreated = false
|
|
|
|
/**
|
|
* Ensure the media directory exists
|
|
*/
|
|
function ensureDirectory(): void {
|
|
if (dirCreated) return
|
|
const dir = getMediaDirectory()
|
|
if (!dir.exists) {
|
|
dir.create()
|
|
}
|
|
dirCreated = true
|
|
}
|
|
|
|
/**
|
|
* Save a media file to local storage by localRefPath key
|
|
*/
|
|
export async function saveMediaToLocal(
|
|
localRefPath: string,
|
|
sourcePath: string,
|
|
): Promise<void> {
|
|
ensureDirectory()
|
|
|
|
const destFile = getMediaFile(localRefPath)
|
|
|
|
// Ensure source path has file:// prefix for expo-file-system
|
|
let normalizedSource = sourcePath
|
|
if (!sourcePath.startsWith('file://') && sourcePath.startsWith('/')) {
|
|
normalizedSource = `file://${sourcePath}`
|
|
}
|
|
|
|
try {
|
|
const sourceFile = new File(normalizedSource)
|
|
sourceFile.copy(destFile)
|
|
// Update cache after successful save
|
|
mediaExistsCache.set(localRefPath, true)
|
|
} catch (error) {
|
|
logger.error('Failed to save media to drafts storage', {
|
|
error,
|
|
localRefPath,
|
|
sourcePath: normalizedSource,
|
|
destPath: destFile.uri,
|
|
})
|
|
throw error
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load a media file path from local storage
|
|
* @returns The file URI for the saved media
|
|
*/
|
|
export async function loadMediaFromLocal(
|
|
localRefPath: string,
|
|
): Promise<string> {
|
|
const file = getMediaFile(localRefPath)
|
|
|
|
if (!file.exists) {
|
|
throw new Error(`Media file not found: ${localRefPath}`)
|
|
}
|
|
|
|
return file.uri
|
|
}
|
|
|
|
/**
|
|
* Delete a media file from local storage
|
|
*/
|
|
export async function deleteMediaFromLocal(
|
|
localRefPath: string,
|
|
): Promise<void> {
|
|
const file = getMediaFile(localRefPath)
|
|
// Idempotent: only delete if file exists
|
|
if (file.exists) {
|
|
file.delete()
|
|
}
|
|
mediaExistsCache.delete(localRefPath)
|
|
}
|
|
|
|
/**
|
|
* Check if a media file exists in local storage (synchronous check using cache)
|
|
* Note: This uses a cached directory listing for performance
|
|
*/
|
|
const mediaExistsCache = new Map<string, boolean>()
|
|
let cachePopulated = false
|
|
|
|
export function mediaExists(localRefPath: string): boolean {
|
|
// For native, we need an async check but the API requires sync
|
|
// Use cached result if available, otherwise assume doesn't exist
|
|
if (mediaExistsCache.has(localRefPath)) {
|
|
return mediaExistsCache.get(localRefPath)!
|
|
}
|
|
// If cache not populated yet, trigger async population
|
|
if (!cachePopulated && !populateCachePromise) {
|
|
populateCachePromise = populateCacheInternal()
|
|
}
|
|
return false // Conservative: assume doesn't exist if not in cache
|
|
}
|
|
|
|
let populateCachePromise: Promise<void> | null = null
|
|
|
|
function populateCacheInternal(): Promise<void> {
|
|
return new Promise(resolve => {
|
|
try {
|
|
const dir = getMediaDirectory()
|
|
if (dir.exists) {
|
|
const items = dir.list()
|
|
for (const item of items) {
|
|
// Reverse the URL encoding to get the original localRefPath
|
|
const localRefPath = decodeURIComponent(item.name)
|
|
mediaExistsCache.set(localRefPath, true)
|
|
}
|
|
}
|
|
cachePopulated = true
|
|
} catch (e) {
|
|
logger.warn('Failed to populate media cache', {error: e})
|
|
}
|
|
resolve()
|
|
})
|
|
}
|
|
|
|
/**
|
|
* Ensure the media cache is populated. Call this before checking mediaExists.
|
|
*/
|
|
export async function ensureMediaCachePopulated(): Promise<void> {
|
|
if (cachePopulated) return
|
|
if (!populateCachePromise) {
|
|
populateCachePromise = populateCacheInternal()
|
|
}
|
|
await populateCachePromise
|
|
}
|
|
|
|
/**
|
|
* Clear the media exists cache (call when media is added/deleted)
|
|
*/
|
|
export function clearMediaCache(): void {
|
|
mediaExistsCache.clear()
|
|
cachePopulated = false
|
|
populateCachePromise = null
|
|
}
|
|
|
|
/**
|
|
* Revoke a media URL (no-op on native - only needed for web blob URLs)
|
|
*/
|
|
export function revokeMediaUrl(_url: string): void {
|
|
// No-op on native - file URIs don't need revocation
|
|
}
|
|
|
|
/**
|
|
* Revoke all media URLs (no-op on native - only needed for web blob URLs)
|
|
*/
|
|
export function revokeAllMediaUrls(): void {
|
|
// No-op on native - file URIs don't need revocation
|
|
}
|