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