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

Configure Feed

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

at 6bfe758d2a9ea376552fb45e5e589bccd0cf4df5 431 lines 12 kB view raw
1import {Image as RNImage} from 'react-native' 2import uuid from 'react-native-uuid' 3import { 4 cacheDirectory, 5 copyAsync, 6 createDownloadResumable, 7 deleteAsync, 8 EncodingType, 9 getInfoAsync, 10 makeDirectoryAsync, 11 StorageAccessFramework, 12 writeAsStringAsync, 13} from 'expo-file-system/legacy' 14import {manipulateAsync, SaveFormat} from 'expo-image-manipulator' 15import * as MediaLibrary from 'expo-media-library' 16import * as Sharing from 'expo-sharing' 17import {Buffer} from 'buffer' 18 19import {POST_IMG_MAX} from '#/lib/constants' 20import {logger} from '#/logger' 21import {IS_ANDROID, IS_IOS} from '#/env' 22import {type PickerImage} from './picker.shared' 23import {type Dimensions} from './types' 24 25export async function compressIfNeeded( 26 img: PickerImage, 27 maxSize: number = POST_IMG_MAX.size, 28): Promise<PickerImage> { 29 if (img.size < maxSize) { 30 return img 31 } 32 const resizedImage = await doResize(normalizePath(img.path), { 33 width: img.width, 34 height: img.height, 35 mode: 'stretch', 36 maxSize, 37 }) 38 const finalImageMovedPath = await moveToPermanentPath( 39 resizedImage.path, 40 '.jpg', 41 ) 42 const finalImg = { 43 ...resizedImage, 44 path: finalImageMovedPath, 45 } 46 return finalImg 47} 48 49export interface DownloadAndResizeOpts { 50 uri: string 51 width: number 52 height: number 53 mode: 'contain' | 'cover' | 'stretch' 54 maxSize: number 55 timeout: number 56} 57 58export async function downloadAndResize(opts: DownloadAndResizeOpts) { 59 let appendExt = 'jpeg' 60 try { 61 const urip = new URL(opts.uri) 62 const ext = urip.pathname.split('.').pop() 63 if (ext === 'png') { 64 appendExt = 'png' 65 } 66 } catch (e: any) { 67 console.error('Invalid URI', opts.uri, e) 68 return 69 } 70 71 const path = createPath(appendExt) 72 73 try { 74 await downloadImage(opts.uri, path, opts.timeout) 75 return await doResize(path, opts) 76 } finally { 77 safeDeleteAsync(path) 78 } 79} 80 81export async function shareImageModal({uri}: {uri: string}) { 82 if (!(await Sharing.isAvailableAsync())) { 83 // TODO might need to give an error to the user in this case -prf 84 return 85 } 86 87 // we're currently relying on the fact our CDN only serves jpegs 88 // -prf 89 const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) 90 const imagePath = await moveToPermanentPath(imageUri, '.jpg') 91 safeDeleteAsync(imageUri) 92 await Sharing.shareAsync(imagePath, { 93 mimeType: 'image/jpeg', 94 UTI: 'image/jpeg', 95 }) 96} 97 98const ALBUM_NAME = 'Bluesky' 99 100export async function saveImageToMediaLibrary({uri}: {uri: string}) { 101 // download the file to cache 102 // NOTE 103 // assuming JPEG 104 // we're currently relying on the fact our CDN only serves jpegs 105 // -prf 106 const imageUri = await downloadImage(uri, createPath('jpg'), 15e3) 107 const imagePath = await moveToPermanentPath(imageUri, '.jpg') 108 109 // save 110 try { 111 if (IS_ANDROID) { 112 // android triggers an annoying permission prompt if you try and move an image 113 // between albums. therefore, we need to either create the album with the image 114 // as the starting image, or put it directly into the album 115 const album = await MediaLibrary.getAlbumAsync(ALBUM_NAME) 116 if (album) { 117 // try and migrate if needed 118 try { 119 if (await MediaLibrary.albumNeedsMigrationAsync(album)) { 120 await MediaLibrary.migrateAlbumIfNeededAsync(album) 121 } 122 } catch (err) { 123 logger.info('Attempted and failed to migrate album', { 124 safeMessage: err, 125 }) 126 } 127 128 try { 129 // if album exists, put the image straight in there 130 await MediaLibrary.createAssetAsync(imagePath, album) 131 } catch (err) { 132 logger.info('Failed to create asset', {safeMessage: err}) 133 // however, it's possible that we don't have write permission to the album 134 // try making a new one! 135 try { 136 await MediaLibrary.createAlbumAsync( 137 ALBUM_NAME, 138 undefined, 139 undefined, 140 imagePath, 141 ) 142 } catch (err2) { 143 logger.info('Failed to create asset in a fresh album', { 144 safeMessage: err2, 145 }) 146 // ... and if all else fails, just put it in DCIM 147 await MediaLibrary.createAssetAsync(imagePath) 148 } 149 } 150 } else { 151 // otherwise, create album with asset (albums must always have at least one asset) 152 await MediaLibrary.createAlbumAsync( 153 ALBUM_NAME, 154 undefined, 155 undefined, 156 imagePath, 157 ) 158 } 159 } else { 160 await MediaLibrary.saveToLibraryAsync(imagePath) 161 } 162 } catch (err) { 163 logger.error(err instanceof Error ? err : String(err), { 164 message: 'Failed to save image to media library', 165 }) 166 throw err 167 } finally { 168 safeDeleteAsync(imagePath) 169 } 170} 171 172export function getImageDim(path: string): Promise<Dimensions> { 173 return new Promise((resolve, reject) => { 174 RNImage.getSize( 175 path, 176 (width, height) => { 177 resolve({width, height}) 178 }, 179 reject, 180 ) 181 }) 182} 183 184// internal methods 185// = 186 187interface DoResizeOpts { 188 width: number 189 height: number 190 mode: 'contain' | 'cover' | 'stretch' 191 maxSize: number 192} 193 194async function doResize( 195 localUri: string, 196 opts: DoResizeOpts, 197): Promise<PickerImage> { 198 // We need to get the dimensions of the image before we resize it. Previously, the library we used allowed us to enter 199 // a "max size", and it would do the "best possible size" calculation for us. 200 // Now instead, we have to supply the final dimensions to the manipulation function instead. 201 // Performing an "empty" manipulation lets us get the dimensions of the original image. React Native's Image.getSize() 202 // does not work for local files... 203 const imageRes = await manipulateAsync(localUri, [], {}) 204 const newDimensions = getResizedDimensions({ 205 width: imageRes.width, 206 height: imageRes.height, 207 }) 208 209 let minQualityPercentage = 0 210 let maxQualityPercentage = 101 // exclusive 211 let newDataUri 212 const intermediateUris = [] 213 214 while (maxQualityPercentage - minQualityPercentage > 1) { 215 const qualityPercentage = Math.round( 216 (maxQualityPercentage + minQualityPercentage) / 2, 217 ) 218 const resizeRes = await manipulateAsync( 219 localUri, 220 [{resize: newDimensions}], 221 { 222 format: SaveFormat.JPEG, 223 compress: qualityPercentage / 100, 224 }, 225 ) 226 227 intermediateUris.push(resizeRes.uri) 228 229 const fileInfo = await getInfoAsync(resizeRes.uri) 230 if (!fileInfo.exists) { 231 throw new Error( 232 'The image manipulation library failed to create a new image.', 233 ) 234 } 235 236 if (fileInfo.size < opts.maxSize) { 237 minQualityPercentage = qualityPercentage 238 newDataUri = { 239 path: normalizePath(resizeRes.uri), 240 mime: 'image/jpeg', 241 size: fileInfo.size, 242 width: resizeRes.width, 243 height: resizeRes.height, 244 } 245 } else { 246 maxQualityPercentage = qualityPercentage 247 } 248 } 249 250 for (const intermediateUri of intermediateUris) { 251 if (newDataUri?.path !== normalizePath(intermediateUri)) { 252 safeDeleteAsync(intermediateUri) 253 } 254 } 255 256 if (newDataUri) { 257 safeDeleteAsync(imageRes.uri) 258 return newDataUri 259 } 260 261 throw new Error( 262 `This image is too big! We couldn't compress it down to ${opts.maxSize} bytes`, 263 ) 264} 265 266async function moveToPermanentPath(path: string, ext: string): Promise<string> { 267 /* 268 Since this package stores images in a temp directory, we need to move the file to a permanent location. 269 Relevant: IOS bug when trying to open a second time: 270 https://github.com/ivpusic/react-native-image-crop-picker/issues/1199 271 */ 272 const filename = uuid.v4() 273 274 // cacheDirectory will not ever be null on native, but it could be on web. This function only ever gets called on 275 // native so we assert as a string. 276 const destinationPath = joinPath(cacheDirectory as string, filename + ext) 277 await copyAsync({ 278 from: normalizePath(path), 279 to: normalizePath(destinationPath), 280 }) 281 safeDeleteAsync(path) 282 return normalizePath(destinationPath) 283} 284 285export async function safeDeleteAsync(path: string) { 286 // Normalize is necessary for Android, otherwise it doesn't delete. 287 const normalizedPath = normalizePath(path) 288 try { 289 await deleteAsync(normalizedPath, {idempotent: true}) 290 } catch (e) { 291 console.error('Failed to delete file', e) 292 } 293} 294 295function joinPath(a: string, b: string) { 296 if (a.endsWith('/')) { 297 if (b.startsWith('/')) { 298 return a.slice(0, -1) + b 299 } 300 return a + b 301 } else if (b.startsWith('/')) { 302 return a + b 303 } 304 return a + '/' + b 305} 306 307function normalizePath(str: string, allPlatforms = false): string { 308 if (IS_ANDROID || allPlatforms) { 309 if (!str.startsWith('file://')) { 310 return `file://${str}` 311 } 312 } 313 return str 314} 315 316export async function saveBytesToDisk( 317 filename: string, 318 bytes: Uint8Array, 319 type: string, 320) { 321 const encoded = Buffer.from(bytes).toString('base64') 322 return await saveToDevice(filename, encoded, type) 323} 324 325export async function saveToDevice( 326 filename: string, 327 encoded: string, 328 type: string, 329) { 330 try { 331 if (IS_IOS) { 332 await withTempFile(filename, encoded, async tmpFileUrl => { 333 await Sharing.shareAsync(tmpFileUrl, {UTI: type}) 334 }) 335 return true 336 } else { 337 const permissions = 338 await StorageAccessFramework.requestDirectoryPermissionsAsync() 339 340 if (!permissions.granted) { 341 return false 342 } 343 344 const fileUrl = await StorageAccessFramework.createFileAsync( 345 permissions.directoryUri, 346 filename, 347 type, 348 ) 349 350 await writeAsStringAsync(fileUrl, encoded, { 351 encoding: EncodingType.Base64, 352 }) 353 return true 354 } 355 } catch (e) { 356 logger.error('Error occurred while saving file', {message: e}) 357 return false 358 } 359} 360 361async function withTempFile<T>( 362 filename: string, 363 encoded: string, 364 cb: (url: string) => T | Promise<T>, 365): Promise<T> { 366 // cacheDirectory will not ever be null so we assert as a string. 367 // Using a directory so that the file name is not a random string 368 const tmpDirUri = joinPath(cacheDirectory as string, String(uuid.v4())) 369 await makeDirectoryAsync(tmpDirUri, {intermediates: true}) 370 371 try { 372 const tmpFileUrl = joinPath(tmpDirUri, filename) 373 await writeAsStringAsync(tmpFileUrl, encoded, { 374 encoding: EncodingType.Base64, 375 }) 376 377 return await cb(tmpFileUrl) 378 } finally { 379 safeDeleteAsync(tmpDirUri) 380 } 381} 382 383export function getResizedDimensions(originalDims: { 384 width: number 385 height: number 386}) { 387 if ( 388 originalDims.width <= POST_IMG_MAX.width && 389 originalDims.height <= POST_IMG_MAX.height 390 ) { 391 return originalDims 392 } 393 394 const ratio = Math.min( 395 POST_IMG_MAX.width / originalDims.width, 396 POST_IMG_MAX.height / originalDims.height, 397 ) 398 399 return { 400 width: Math.round(originalDims.width * ratio), 401 height: Math.round(originalDims.height * ratio), 402 } 403} 404 405function createPath(ext: string) { 406 // cacheDirectory will never be null on native, so the null check here is not necessary except for typescript. 407 // we use a web-only function for downloadAndResize on web 408 return `${cacheDirectory ?? ''}/${uuid.v4()}.${ext}` 409} 410 411async function downloadImage(uri: string, path: string, timeout: number) { 412 const dlResumable = createDownloadResumable(uri, path, {cache: true}) 413 let timedOut = false 414 const to1 = setTimeout(() => { 415 timedOut = true 416 dlResumable.cancelAsync() 417 }, timeout) 418 419 const dlRes = await dlResumable.downloadAsync() 420 clearTimeout(to1) 421 422 if (!dlRes?.uri) { 423 if (timedOut) { 424 throw new Error('Failed to download image - timed out') 425 } else { 426 throw new Error('Failed to download image - dlRes is undefined') 427 } 428 } 429 430 return normalizePath(dlRes.uri) 431}