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