Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 394 lines 9.5 kB view raw
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( 208 img: ComposerImage, 209 options?: { 210 highResolution?: boolean 211 }, 212): Promise<PickerImage> { 213 const source = img.transformed || img.source 214 const highResolution = options?.highResolution ?? false 215 let attempts = 0 216 let maxDimension = highResolution ? 4000 : POST_IMG_MAX.width 217 218 let minQualityPercentage = 0 219 let maxQualityPercentage = 101 // exclusive 220 let newDataUri 221 222 while (maxQualityPercentage - minQualityPercentage > 1) { 223 if (attempts >= 4) break 224 225 const [w, h] = containImageRes(source.width, source.height, maxDimension) 226 const qualityPercentage = Math.round( 227 (maxQualityPercentage + minQualityPercentage) / 2, 228 ) 229 230 /* 231 * In the event the image doesn't compress well, we want to avoid 232 * unecessary iterations. In this case, binary search will check 51, 26, 233 * 13(rounded). We don't want to go below 25, so if we've halved to 13, 234 * reset the loop and reduce the image dimensions instead. 235 */ 236 if (qualityPercentage <= 13) { 237 minQualityPercentage = 0 238 maxQualityPercentage = 101 239 attempts++ 240 // 4000px → 3200px → 2560px → 2048px → ~1638px 241 maxDimension = Math.floor(maxDimension * 0.8) 242 continue 243 } 244 245 const res = await manipulateAsync( 246 source.path, 247 [{resize: {width: w, height: h}}], 248 { 249 compress: qualityPercentage / 100, 250 format: SaveFormat.JPEG, 251 base64: true, 252 }, 253 ) 254 255 const base64 = res.base64 256 const size = base64 ? getDataUriSize(base64) : 0 257 if (base64 && size <= POST_IMG_MAX.size) { 258 minQualityPercentage = qualityPercentage 259 newDataUri = { 260 path: await moveIfNecessary(res.uri), 261 width: res.width, 262 height: res.height, 263 mime: 'image/jpeg', 264 size, 265 } 266 } else { 267 maxQualityPercentage = qualityPercentage 268 } 269 } 270 271 if (newDataUri) { 272 return newDataUri 273 } 274 275 throw new Error(`Unable to compress image`) 276} 277 278async function moveIfNecessary(from: string) { 279 const cacheDir = IS_NATIVE && getImageCacheDirectory() 280 281 if (cacheDir && from.startsWith(cacheDir)) { 282 const to = joinPath(cacheDir, nanoid(36)) 283 284 await makeDirectoryAsync(cacheDir, {intermediates: true}) 285 await moveAsync({from, to}) 286 287 return to 288 } 289 290 return from 291} 292 293/** 294 * Copy a file from a potentially temporary location to our cache directory. 295 * This ensures picker files are available for draft saving even if the original 296 * temporary files are cleaned up by the OS. 297 * 298 * On web, converts blob URLs to data URIs immediately to prevent revocation issues. 299 */ 300async function copyToCache(from: string): Promise<string> { 301 // Data URIs don't need any conversion 302 if (from.startsWith('data:')) { 303 return from 304 } 305 306 if (IS_WEB) { 307 // Web: convert blob URLs to data URIs before they can be revoked 308 if (from.startsWith('blob:')) { 309 try { 310 const response = await fetch(from) 311 const blob = await response.blob() 312 return await blobToDataUri(blob) 313 } catch (e) { 314 // Blob URL was likely revoked, return as-is for downstream error handling 315 return from 316 } 317 } 318 // Other URLs on web don't need conversion 319 return from 320 } 321 322 // Native: copy to cache directory to survive OS temp file cleanup 323 const cacheDir = getImageCacheDirectory() 324 if (!cacheDir || from.startsWith(cacheDir)) { 325 return from 326 } 327 328 const to = joinPath(cacheDir, nanoid(36)) 329 await makeDirectoryAsync(cacheDir, {intermediates: true}) 330 331 let normalizedFrom = from 332 if (!from.startsWith('file://') && from.startsWith('/')) { 333 normalizedFrom = `file://${from}` 334 } 335 336 await copyAsync({from: normalizedFrom, to}) 337 return to 338} 339 340/** 341 * Convert a Blob to a data URI 342 */ 343function blobToDataUri(blob: Blob): Promise<string> { 344 return new Promise((resolve, reject) => { 345 const reader = new FileReader() 346 reader.onloadend = () => { 347 if (typeof reader.result === 'string') { 348 resolve(reader.result) 349 } else { 350 reject(new Error('Failed to convert blob to data URI')) 351 } 352 } 353 reader.onerror = () => reject(reader.error) 354 reader.readAsDataURL(blob) 355 }) 356} 357 358/** Purge files that were created to accomodate image manipulation */ 359export async function purgeTemporaryImageFiles() { 360 const cacheDir = IS_NATIVE && getImageCacheDirectory() 361 362 if (cacheDir) { 363 await deleteAsync(cacheDir, {idempotent: true}) 364 await makeDirectoryAsync(cacheDir) 365 } 366} 367 368function joinPath(a: string, b: string) { 369 if (a.endsWith('/')) { 370 if (b.startsWith('/')) { 371 return a.slice(0, -1) + b 372 } 373 return a + b 374 } else if (b.startsWith('/')) { 375 return a + b 376 } 377 return a + '/' + b 378} 379 380function containImageRes( 381 w: number, 382 h: number, 383 max: number, 384): [width: number, height: number] { 385 let scale = 1 386 387 if (w > max || h > max) { 388 scale = w > h ? max / w : max / h 389 w = Math.floor(w * scale) 390 h = Math.floor(h * scale) 391 } 392 393 return [w, h] 394}