forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1/**
2 * Native file system storage for draft media.
3 * Media is stored by localRefPath key (unique identifier stored in server draft).
4 */
5import {Directory, File, Paths} from 'expo-file-system'
6
7import {logger} from './logger'
8
9const MEDIA_DIR = 'bsky-draft-media'
10
11function getMediaDirectory(): Directory {
12 return new Directory(Paths.document, MEDIA_DIR)
13}
14
15function getMediaFile(localRefPath: string): File {
16 const safeFilename = encodeURIComponent(localRefPath)
17 return new File(getMediaDirectory(), safeFilename)
18}
19
20let dirCreated = false
21
22/**
23 * Ensure the media directory exists
24 */
25function ensureDirectory(): void {
26 if (dirCreated) return
27 const dir = getMediaDirectory()
28 if (!dir.exists) {
29 dir.create()
30 }
31 dirCreated = true
32}
33
34/**
35 * Save a media file to local storage by localRefPath key
36 */
37export async function saveMediaToLocal(
38 localRefPath: string,
39 sourcePath: string,
40): Promise<void> {
41 ensureDirectory()
42
43 const destFile = getMediaFile(localRefPath)
44
45 // Ensure source path has file:// prefix for expo-file-system
46 let normalizedSource = sourcePath
47 if (!sourcePath.startsWith('file://') && sourcePath.startsWith('/')) {
48 normalizedSource = `file://${sourcePath}`
49 }
50
51 try {
52 const sourceFile = new File(normalizedSource)
53 sourceFile.copy(destFile)
54 // Update cache after successful save
55 mediaExistsCache.set(localRefPath, true)
56 } catch (error) {
57 logger.error('Failed to save media to drafts storage', {
58 error,
59 localRefPath,
60 sourcePath: normalizedSource,
61 destPath: destFile.uri,
62 })
63 throw error
64 }
65}
66
67/**
68 * Load a media file path from local storage
69 * @returns The file URI for the saved media
70 */
71export async function loadMediaFromLocal(
72 localRefPath: string,
73): Promise<string> {
74 const file = getMediaFile(localRefPath)
75
76 if (!file.exists) {
77 throw new Error(`Media file not found: ${localRefPath}`)
78 }
79
80 return file.uri
81}
82
83/**
84 * Delete a media file from local storage
85 */
86export async function deleteMediaFromLocal(
87 localRefPath: string,
88): Promise<void> {
89 const file = getMediaFile(localRefPath)
90 // Idempotent: only delete if file exists
91 if (file.exists) {
92 file.delete()
93 }
94 mediaExistsCache.delete(localRefPath)
95}
96
97/**
98 * Check if a media file exists in local storage (synchronous check using cache)
99 * Note: This uses a cached directory listing for performance
100 */
101const mediaExistsCache = new Map<string, boolean>()
102let cachePopulated = false
103
104export function mediaExists(localRefPath: string): boolean {
105 // For native, we need an async check but the API requires sync
106 // Use cached result if available, otherwise assume doesn't exist
107 if (mediaExistsCache.has(localRefPath)) {
108 return mediaExistsCache.get(localRefPath)!
109 }
110 // If cache not populated yet, trigger async population
111 if (!cachePopulated && !populateCachePromise) {
112 populateCachePromise = populateCacheInternal()
113 }
114 return false // Conservative: assume doesn't exist if not in cache
115}
116
117let populateCachePromise: Promise<void> | null = null
118
119function populateCacheInternal(): Promise<void> {
120 return new Promise(resolve => {
121 try {
122 const dir = getMediaDirectory()
123 if (dir.exists) {
124 const items = dir.list()
125 for (const item of items) {
126 // Reverse the URL encoding to get the original localRefPath
127 const localRefPath = decodeURIComponent(item.name)
128 mediaExistsCache.set(localRefPath, true)
129 }
130 }
131 cachePopulated = true
132 } catch (e) {
133 logger.warn('Failed to populate media cache', {error: e})
134 }
135 resolve()
136 })
137}
138
139/**
140 * Ensure the media cache is populated. Call this before checking mediaExists.
141 */
142export async function ensureMediaCachePopulated(): Promise<void> {
143 if (cachePopulated) return
144 if (!populateCachePromise) {
145 populateCachePromise = populateCacheInternal()
146 }
147 await populateCachePromise
148}
149
150/**
151 * Clear the media exists cache (call when media is added/deleted)
152 */
153export function clearMediaCache(): void {
154 mediaExistsCache.clear()
155 cachePopulated = false
156 populateCachePromise = null
157}
158
159/**
160 * Revoke a media URL (no-op on native - only needed for web blob URLs)
161 */
162export function revokeMediaUrl(_url: string): void {
163 // No-op on native - file URIs don't need revocation
164}
165
166/**
167 * Revoke all media URLs (no-op on native - only needed for web blob URLs)
168 */
169export function revokeAllMediaUrls(): void {
170 // No-op on native - file URIs don't need revocation
171}