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