forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 cacheDirectory,
3 copyAsync,
4 deleteAsync,
5 makeDirectoryAsync,
6 moveAsync,
7} from 'expo-file-system/legacy'
8import {
9 type Action,
10 type ActionCrop,
11 manipulateAsync,
12 SaveFormat,
13} from 'expo-image-manipulator'
14import {nanoid} from 'nanoid/non-secure'
15
16import {POST_IMG_MAX} from '#/lib/constants'
17import {getImageDim} from '#/lib/media/manip'
18import {openCropper} from '#/lib/media/picker'
19import {type PickerImage} from '#/lib/media/picker.shared'
20import {getDataUriSize} from '#/lib/media/util'
21import {isCancelledError} from '#/lib/strings/errors'
22import {IS_NATIVE, IS_WEB} from '#/env'
23
24export type ImageTransformation = {
25 crop?: ActionCrop['crop']
26}
27
28export type ImageMeta = {
29 path: string
30 width: number
31 height: number
32 mime: string
33}
34
35export type ImageSource = ImageMeta & {
36 id: string
37}
38
39type ComposerImageBase = {
40 alt: string
41 source: ImageSource
42 /** Original localRef path from draft, if editing an existing draft. Used to reuse the same storage key. */
43 localRefPath?: string
44}
45type ComposerImageWithoutTransformation = ComposerImageBase & {
46 transformed?: undefined
47 manips?: undefined
48}
49type ComposerImageWithTransformation = ComposerImageBase & {
50 transformed: ImageMeta
51 manips?: ImageTransformation
52}
53
54export type ComposerImage =
55 | ComposerImageWithoutTransformation
56 | ComposerImageWithTransformation
57
58let _imageCacheDirectory: string
59
60function getImageCacheDirectory(): string | null {
61 if (IS_NATIVE) {
62 return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer'))
63 }
64
65 return null
66}
67
68export async function createComposerImage(
69 raw: ImageMeta,
70): Promise<ComposerImageWithoutTransformation> {
71 return {
72 alt: '',
73 source: {
74 id: nanoid(),
75 // Copy to cache to ensure file survives OS temporary file cleanup
76 path: await copyToCache(raw.path),
77 width: raw.width,
78 height: raw.height,
79 mime: raw.mime,
80 },
81 }
82}
83
84export type InitialImage = {
85 uri: string
86 width: number
87 height: number
88 altText?: string
89}
90
91export function createInitialImages(
92 uris: InitialImage[] = [],
93): ComposerImageWithoutTransformation[] {
94 return uris.map(({uri, width, height, altText = ''}) => {
95 return {
96 alt: altText,
97 source: {
98 id: nanoid(),
99 path: uri,
100 width: width,
101 height: height,
102 mime: 'image/jpeg',
103 },
104 }
105 })
106}
107
108export async function pasteImage(
109 uri: string,
110): Promise<ComposerImageWithoutTransformation> {
111 const {width, height} = await getImageDim(uri)
112 const match = /^data:(.+?);/.exec(uri)
113
114 return {
115 alt: '',
116 source: {
117 id: nanoid(),
118 path: uri,
119 width: width,
120 height: height,
121 mime: match ? match[1] : 'image/jpeg',
122 },
123 }
124}
125
126export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
127 if (!IS_NATIVE) {
128 return img
129 }
130
131 const source = img.source
132
133 // @todo: we're always passing the original image here, does image-cropper
134 // allows for setting initial crop dimensions? -mary
135 try {
136 const cropped = await openCropper({
137 imageUri: source.path,
138 })
139
140 return {
141 alt: img.alt,
142 source: source,
143 transformed: {
144 path: await moveIfNecessary(cropped.path),
145 width: cropped.width,
146 height: cropped.height,
147 mime: cropped.mime,
148 },
149 }
150 } catch (e) {
151 if (!isCancelledError(e)) {
152 return img
153 }
154
155 throw e
156 }
157}
158
159export async function manipulateImage(
160 img: ComposerImage,
161 trans: ImageTransformation,
162): Promise<ComposerImage> {
163 const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}]
164
165 const actions = rawActions.filter((a): a is Action => a !== undefined)
166
167 if (actions.length === 0) {
168 if (img.transformed === undefined) {
169 return img
170 }
171
172 return {alt: img.alt, source: img.source}
173 }
174
175 const source = img.source
176 const result = await manipulateAsync(source.path, actions, {
177 format: SaveFormat.PNG,
178 })
179
180 return {
181 alt: img.alt,
182 source: img.source,
183 transformed: {
184 path: await moveIfNecessary(result.uri),
185 width: result.width,
186 height: result.height,
187 mime: 'image/png',
188 },
189 manips: trans,
190 }
191}
192
193export function resetImageManipulation(
194 img: ComposerImage,
195): ComposerImageWithoutTransformation {
196 if (img.transformed !== undefined) {
197 return {alt: img.alt, source: img.source}
198 }
199
200 return img
201}
202
203export async function compressImage(img: ComposerImage): Promise<PickerImage> {
204 const source = img.transformed || img.source
205
206 const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
207
208 let minQualityPercentage = 0
209 let maxQualityPercentage = 101 // exclusive
210 let newDataUri
211
212 while (maxQualityPercentage - minQualityPercentage > 1) {
213 const qualityPercentage = Math.round(
214 (maxQualityPercentage + minQualityPercentage) / 2,
215 )
216
217 const res = await manipulateAsync(
218 source.path,
219 [{resize: {width: w, height: h}}],
220 {
221 compress: qualityPercentage / 100,
222 format: SaveFormat.JPEG,
223 base64: true,
224 },
225 )
226
227 const base64 = res.base64
228 const size = base64 ? getDataUriSize(base64) : 0
229 if (base64 && size <= POST_IMG_MAX.size) {
230 minQualityPercentage = qualityPercentage
231 newDataUri = {
232 path: await moveIfNecessary(res.uri),
233 width: res.width,
234 height: res.height,
235 mime: 'image/jpeg',
236 size,
237 }
238 } else {
239 maxQualityPercentage = qualityPercentage
240 }
241 }
242
243 if (newDataUri) {
244 return newDataUri
245 }
246
247 throw new Error(`Unable to compress image`)
248}
249
250async function moveIfNecessary(from: string) {
251 const cacheDir = IS_NATIVE && getImageCacheDirectory()
252
253 if (cacheDir && from.startsWith(cacheDir)) {
254 const to = joinPath(cacheDir, nanoid(36))
255
256 await makeDirectoryAsync(cacheDir, {intermediates: true})
257 await moveAsync({from, to})
258
259 return to
260 }
261
262 return from
263}
264
265/**
266 * Copy a file from a potentially temporary location to our cache directory.
267 * This ensures picker files are available for draft saving even if the original
268 * temporary files are cleaned up by the OS.
269 *
270 * On web, converts blob URLs to data URIs immediately to prevent revocation issues.
271 */
272async function copyToCache(from: string): Promise<string> {
273 // Data URIs don't need any conversion
274 if (from.startsWith('data:')) {
275 return from
276 }
277
278 if (IS_WEB) {
279 // Web: convert blob URLs to data URIs before they can be revoked
280 if (from.startsWith('blob:')) {
281 try {
282 const response = await fetch(from)
283 const blob = await response.blob()
284 return await blobToDataUri(blob)
285 } catch (e) {
286 // Blob URL was likely revoked, return as-is for downstream error handling
287 return from
288 }
289 }
290 // Other URLs on web don't need conversion
291 return from
292 }
293
294 // Native: copy to cache directory to survive OS temp file cleanup
295 const cacheDir = getImageCacheDirectory()
296 if (!cacheDir || from.startsWith(cacheDir)) {
297 return from
298 }
299
300 const to = joinPath(cacheDir, nanoid(36))
301 await makeDirectoryAsync(cacheDir, {intermediates: true})
302
303 let normalizedFrom = from
304 if (!from.startsWith('file://') && from.startsWith('/')) {
305 normalizedFrom = `file://${from}`
306 }
307
308 await copyAsync({from: normalizedFrom, to})
309 return to
310}
311
312/**
313 * Convert a Blob to a data URI
314 */
315function blobToDataUri(blob: Blob): Promise<string> {
316 return new Promise((resolve, reject) => {
317 const reader = new FileReader()
318 reader.onloadend = () => {
319 if (typeof reader.result === 'string') {
320 resolve(reader.result)
321 } else {
322 reject(new Error('Failed to convert blob to data URI'))
323 }
324 }
325 reader.onerror = () => reject(reader.error)
326 reader.readAsDataURL(blob)
327 })
328}
329
330/** Purge files that were created to accomodate image manipulation */
331export async function purgeTemporaryImageFiles() {
332 const cacheDir = IS_NATIVE && getImageCacheDirectory()
333
334 if (cacheDir) {
335 await deleteAsync(cacheDir, {idempotent: true})
336 await makeDirectoryAsync(cacheDir)
337 }
338}
339
340function joinPath(a: string, b: string) {
341 if (a.endsWith('/')) {
342 if (b.startsWith('/')) {
343 return a.slice(0, -1) + b
344 }
345 return a + b
346 } else if (b.startsWith('/')) {
347 return a + b
348 }
349 return a + '/' + b
350}
351
352function containImageRes(
353 w: number,
354 h: number,
355 {width: maxW, height: maxH}: {width: number; height: number},
356): [width: number, height: number] {
357 let scale = 1
358
359 if (w > maxW || h > maxH) {
360 scale = w > h ? maxW / w : maxH / h
361 w = Math.floor(w * scale)
362 h = Math.floor(h * scale)
363 }
364
365 return [w, h]
366}