An ATproto social media client -- with an independent Appview.
7
fork

Configure Feed

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

at main 296 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 {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}