forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
1import {
2 cacheDirectory,
3 deleteAsync,
4 makeDirectoryAsync,
5 moveAsync,
6} from 'expo-file-system/legacy'
7import {
8 type Action,
9 type ActionCrop,
10 manipulateAsync,
11 SaveFormat,
12} from 'expo-image-manipulator'
13import {nanoid} from 'nanoid/non-secure'
14
15import {POST_IMG_MAX} from '#/lib/constants'
16import {getImageDim} from '#/lib/media/manip'
17import {openCropper} from '#/lib/media/picker'
18import {type PickerImage} from '#/lib/media/picker.shared'
19import {getDataUriSize} from '#/lib/media/util'
20import {isCancelledError} from '#/lib/strings/errors'
21import {isNative} from '#/platform/detection'
22
23export type ImageTransformation = {
24 crop?: ActionCrop['crop']
25}
26
27export type ImageMeta = {
28 path: string
29 width: number
30 height: number
31 mime: string
32}
33
34export type ImageSource = ImageMeta & {
35 id: string
36}
37
38type ComposerImageBase = {
39 alt: string
40 source: ImageSource
41}
42type ComposerImageWithoutTransformation = ComposerImageBase & {
43 transformed?: undefined
44 manips?: undefined
45}
46type ComposerImageWithTransformation = ComposerImageBase & {
47 transformed: ImageMeta
48 manips?: ImageTransformation
49}
50
51export type ComposerImage =
52 | ComposerImageWithoutTransformation
53 | ComposerImageWithTransformation
54
55let _imageCacheDirectory: string
56
57function getImageCacheDirectory(): string | null {
58 if (isNative) {
59 return (_imageCacheDirectory ??= joinPath(cacheDirectory!, 'bsky-composer'))
60 }
61
62 return null
63}
64
65export async function createComposerImage(
66 raw: ImageMeta,
67): Promise<ComposerImageWithoutTransformation> {
68 return {
69 alt: '',
70 source: {
71 id: nanoid(),
72 path: await moveIfNecessary(raw.path),
73 width: raw.width,
74 height: raw.height,
75 mime: raw.mime,
76 },
77 }
78}
79
80export type InitialImage = {
81 uri: string
82 width: number
83 height: number
84 altText?: string
85}
86
87export function createInitialImages(
88 uris: InitialImage[] = [],
89): ComposerImageWithoutTransformation[] {
90 return uris.map(({uri, width, height, altText = ''}) => {
91 return {
92 alt: altText,
93 source: {
94 id: nanoid(),
95 path: uri,
96 width: width,
97 height: height,
98 mime: 'image/jpeg',
99 },
100 }
101 })
102}
103
104export async function pasteImage(
105 uri: string,
106): Promise<ComposerImageWithoutTransformation> {
107 const {width, height} = await getImageDim(uri)
108 const match = /^data:(.+?);/.exec(uri)
109
110 return {
111 alt: '',
112 source: {
113 id: nanoid(),
114 path: uri,
115 width: width,
116 height: height,
117 mime: match ? match[1] : 'image/jpeg',
118 },
119 }
120}
121
122export async function cropImage(img: ComposerImage): Promise<ComposerImage> {
123 if (!isNative) {
124 return img
125 }
126
127 const source = img.source
128
129 // @todo: we're always passing the original image here, does image-cropper
130 // allows for setting initial crop dimensions? -mary
131 try {
132 const cropped = await openCropper({
133 imageUri: source.path,
134 })
135
136 return {
137 alt: img.alt,
138 source: source,
139 transformed: {
140 path: await moveIfNecessary(cropped.path),
141 width: cropped.width,
142 height: cropped.height,
143 mime: cropped.mime,
144 },
145 }
146 } catch (e) {
147 if (!isCancelledError(e)) {
148 return img
149 }
150
151 throw e
152 }
153}
154
155export async function manipulateImage(
156 img: ComposerImage,
157 trans: ImageTransformation,
158): Promise<ComposerImage> {
159 const rawActions: (Action | undefined)[] = [trans.crop && {crop: trans.crop}]
160
161 const actions = rawActions.filter((a): a is Action => a !== undefined)
162
163 if (actions.length === 0) {
164 if (img.transformed === undefined) {
165 return img
166 }
167
168 return {alt: img.alt, source: img.source}
169 }
170
171 const source = img.source
172 const result = await manipulateAsync(source.path, actions, {
173 format: SaveFormat.PNG,
174 })
175
176 return {
177 alt: img.alt,
178 source: img.source,
179 transformed: {
180 path: await moveIfNecessary(result.uri),
181 width: result.width,
182 height: result.height,
183 mime: 'image/png',
184 },
185 manips: trans,
186 }
187}
188
189export function resetImageManipulation(
190 img: ComposerImage,
191): ComposerImageWithoutTransformation {
192 if (img.transformed !== undefined) {
193 return {alt: img.alt, source: img.source}
194 }
195
196 return img
197}
198
199export async function compressImage(img: ComposerImage): Promise<PickerImage> {
200 const source = img.transformed || img.source
201
202 const [w, h] = containImageRes(source.width, source.height, POST_IMG_MAX)
203
204 let minQualityPercentage = 0
205 let maxQualityPercentage = 101 // exclusive
206 let newDataUri
207
208 while (maxQualityPercentage - minQualityPercentage > 1) {
209 const qualityPercentage = Math.round(
210 (maxQualityPercentage + minQualityPercentage) / 2,
211 )
212
213 const res = await manipulateAsync(
214 source.path,
215 [{resize: {width: w, height: h}}],
216 {
217 compress: qualityPercentage / 100,
218 format: SaveFormat.JPEG,
219 base64: true,
220 },
221 )
222
223 const base64 = res.base64
224 const size = base64 ? getDataUriSize(base64) : 0
225 if (base64 && size <= POST_IMG_MAX.size) {
226 minQualityPercentage = qualityPercentage
227 newDataUri = {
228 path: await moveIfNecessary(res.uri),
229 width: res.width,
230 height: res.height,
231 mime: 'image/jpeg',
232 size,
233 }
234 } else {
235 maxQualityPercentage = qualityPercentage
236 }
237 }
238
239 if (newDataUri) {
240 return newDataUri
241 }
242
243 throw new Error(`Unable to compress image`)
244}
245
246async function moveIfNecessary(from: string) {
247 const cacheDir = isNative && getImageCacheDirectory()
248
249 if (cacheDir && from.startsWith(cacheDir)) {
250 const to = joinPath(cacheDir, nanoid(36))
251
252 await makeDirectoryAsync(cacheDir, {intermediates: true})
253 await moveAsync({from, to})
254
255 return to
256 }
257
258 return from
259}
260
261/** Purge files that were created to accomodate image manipulation */
262export async function purgeTemporaryImageFiles() {
263 const cacheDir = isNative && getImageCacheDirectory()
264
265 if (cacheDir) {
266 await deleteAsync(cacheDir, {idempotent: true})
267 await makeDirectoryAsync(cacheDir)
268 }
269}
270
271function joinPath(a: string, b: string) {
272 if (a.endsWith('/')) {
273 if (b.startsWith('/')) {
274 return a.slice(0, -1) + b
275 }
276 return a + b
277 } else if (b.startsWith('/')) {
278 return a + b
279 }
280 return a + '/' + b
281}
282
283function containImageRes(
284 w: number,
285 h: number,
286 {width: maxW, height: maxH}: {width: number; height: number},
287): [width: number, height: number] {
288 let scale = 1
289
290 if (w > maxW || h > maxH) {
291 scale = w > h ? maxW / w : maxH / h
292 w = Math.floor(w * scale)
293 h = Math.floor(h * scale)
294 }
295
296 return [w, h]
297}