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