Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at b47f5f023e5213cedf0654fd9bf5546634de8599 297 lines 6.6 kB view raw
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}