Bluesky app fork with some witchin' additions 馃挮
at main 527 lines 15 kB view raw
1import {useCallback, useEffect, useRef} from 'react' 2import {Keyboard} from 'react-native' 3import {File} from 'expo-file-system' 4import {type ImagePickerAsset} from 'expo-image-picker' 5import {msg, plural} from '@lingui/macro' 6import {useLingui} from '@lingui/react' 7 8import {VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE} from '#/lib/constants' 9import { 10 usePhotoLibraryPermission, 11 useVideoLibraryPermission, 12} from '#/lib/hooks/usePermissions' 13import {openUnifiedPicker} from '#/lib/media/picker' 14import {extractDataUriMime} from '#/lib/media/util' 15import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' 16import {MAX_IMAGES} from '#/view/com/composer/state/composer' 17import {atoms as a, useTheme} from '#/alf' 18import {Button} from '#/components/Button' 19import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' 20import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' 21import * as toast from '#/components/Toast' 22import {IS_NATIVE, IS_WEB} from '#/env' 23import {isAnimatedGif} from './videos/isAnimatedGif' 24 25export type SelectMediaButtonProps = { 26 disabled?: boolean 27 /** 28 * If set, this limits the types of assets that can be selected. 29 */ 30 allowedAssetTypes: AssetType | undefined 31 selectedAssetsCount: number 32 onSelectAssets: (props: { 33 type: AssetType 34 assets: ImagePickerAsset[] 35 errors: string[] 36 }) => void 37 /** 38 * If true, automatically open the media picker when the component mounts. 39 */ 40 autoOpen?: boolean 41} 42 43/** 44 * Generic asset classes, or buckets, that we support. 45 */ 46export type AssetType = 'video' | 'image' | 'gif' 47 48/** 49 * Shadows `ImagePickerAsset` from `expo-image-picker`, but with a guaranteed `mimeType` 50 */ 51type ValidatedImagePickerAsset = Omit<ImagePickerAsset, 'mimeType'> & { 52 mimeType: string 53} 54 55/** 56 * Codes for known validation states 57 */ 58enum SelectedAssetError { 59 Unsupported = 'Unsupported', 60 MixedTypes = 'MixedTypes', 61 MaxImages = 'MaxImages', 62 MaxVideos = 'MaxVideos', 63 VideoTooLong = 'VideoTooLong', 64 FileTooBig = 'FileTooBig', 65 MaxGIFs = 'MaxGIFs', 66} 67 68/** 69 * Supported video mime types. This differs slightly from 70 * `SUPPORTED_MIME_TYPES` from `#/lib/constants` because we only care about 71 * videos here. 72 */ 73const SUPPORTED_VIDEO_MIME_TYPES = [ 74 'video/mp4', 75 'video/mpeg', 76 'video/webm', 77 'video/quicktime', 78] as const 79type SupportedVideoMimeType = (typeof SUPPORTED_VIDEO_MIME_TYPES)[number] 80function isSupportedVideoMimeType( 81 mimeType: string, 82): mimeType is SupportedVideoMimeType { 83 return SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType as SupportedVideoMimeType) 84} 85 86/** 87 * Supported image mime types. 88 */ 89const SUPPORTED_IMAGE_MIME_TYPES = ( 90 [ 91 'image/gif', 92 'image/jpeg', 93 'image/png', 94 'image/svg+xml', 95 'image/webp', 96 'image/avif', 97 IS_NATIVE && 'image/heic', 98 ] as const 99).filter(Boolean) 100type SupportedImageMimeType = Exclude< 101 (typeof SUPPORTED_IMAGE_MIME_TYPES)[number], 102 boolean 103> 104function isSupportedImageMimeType( 105 mimeType: string, 106): mimeType is SupportedImageMimeType { 107 return SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType as SupportedImageMimeType) 108} 109 110/** 111 * This is a last-ditch effort type thing here, try not to rely on this. 112 */ 113const extensionToMimeType: Record< 114 string, 115 SupportedVideoMimeType | SupportedImageMimeType 116> = { 117 mp4: 'video/mp4', 118 mov: 'video/quicktime', 119 webm: 'video/webm', 120 webp: 'image/webp', 121 gif: 'image/gif', 122 jpg: 'image/jpeg', 123 jpeg: 'image/jpeg', 124 png: 'image/png', 125 svg: 'image/svg+xml', 126 heic: 'image/heic', 127} 128 129/** 130 * Attempts to bucket the given asset into one of our known types based on its 131 * `mimeType`. If `mimeType` is not available, we try to infer it through 132 * various means. 133 */ 134async function classifyImagePickerAsset(asset: ImagePickerAsset): Promise< 135 | { 136 success: true 137 type: AssetType 138 mimeType: string 139 } 140 | { 141 success: false 142 type: undefined 143 mimeType: undefined 144 } 145> { 146 /* 147 * Try to use the `mimeType` reported by `expo-image-picker` first. 148 */ 149 let mimeType = asset.mimeType 150 151 if (!mimeType) { 152 /* 153 * We can try to infer this from the data-uri. 154 */ 155 const maybeMimeType = extractDataUriMime(asset.uri) 156 157 if ( 158 maybeMimeType.startsWith('image/') || 159 maybeMimeType.startsWith('video/') 160 ) { 161 mimeType = maybeMimeType 162 } else if (maybeMimeType.startsWith('file/')) { 163 /* 164 * On the off-chance we get a `file/*` mime, try to infer from the 165 * extension. 166 */ 167 const extension = asset.uri.split('.').pop()?.toLowerCase() 168 mimeType = extensionToMimeType[extension || ''] 169 } 170 } 171 172 if (!mimeType) { 173 return { 174 success: false, 175 type: undefined, 176 mimeType: undefined, 177 } 178 } 179 180 /* 181 * Distill this down into a type "class". 182 */ 183 let type: AssetType | undefined 184 if (mimeType === 'image/gif') { 185 let bytes: ArrayBuffer | undefined 186 if (IS_WEB) { 187 bytes = await asset.file?.arrayBuffer() 188 } else { 189 const file = new File(asset.uri) 190 if (file.exists) { 191 bytes = await file.arrayBuffer() 192 } 193 } 194 if (bytes) { 195 const {isAnimated} = isAnimatedGif(bytes) 196 type = isAnimated ? 'gif' : 'image' 197 } else { 198 // If we can't read the file, assume it's animated 199 type = 'gif' 200 } 201 } else if (mimeType?.startsWith('video/')) { 202 type = 'video' 203 } else if (mimeType?.startsWith('image/')) { 204 type = 'image' 205 } 206 207 /* 208 * If we weren't able to find a valid type, we don't support this asset. 209 */ 210 if (!type) { 211 return { 212 success: false, 213 type: undefined, 214 mimeType: undefined, 215 } 216 } 217 218 return { 219 success: true, 220 type, 221 mimeType, 222 } 223} 224 225/** 226 * Takes in raw assets from `expo-image-picker` and applies validation. Returns 227 * the dominant `AssetType`, any valid assets, and any errors encountered along 228 * the way. 229 */ 230async function processImagePickerAssets( 231 assets: ImagePickerAsset[], 232 { 233 selectionCountRemaining, 234 allowedAssetTypes, 235 }: { 236 selectionCountRemaining: number 237 allowedAssetTypes: AssetType | undefined 238 }, 239) { 240 /* 241 * A deduped set of error codes, which we'll use later 242 */ 243 const errors = new Set<SelectedAssetError>() 244 245 /* 246 * We only support selecting a single type of media at a time, so this gets 247 * set to whatever the first valid asset type is, OR to whatever 248 * `allowedAssetTypes` is set to. 249 */ 250 let selectableAssetType: AssetType | undefined 251 252 /* 253 * This will hold the assets that we can actually use, after filtering 254 */ 255 let supportedAssets: ValidatedImagePickerAsset[] = [] 256 257 for (const asset of assets) { 258 const {success, type, mimeType} = await classifyImagePickerAsset(asset) 259 260 if (!success) { 261 errors.add(SelectedAssetError.Unsupported) 262 continue 263 } 264 265 /* 266 * If we have an `allowedAssetTypes` prop, constrain to that. Otherwise, 267 * set this to the first valid asset type we see, and then use that to 268 * constrain all remaining selected assets. 269 */ 270 selectableAssetType = allowedAssetTypes || selectableAssetType || type 271 272 // ignore mixed types 273 if (type !== selectableAssetType) { 274 errors.add(SelectedAssetError.MixedTypes) 275 continue 276 } 277 278 if (type === 'video') { 279 /** 280 * We don't care too much about mimeType at this point on native, 281 * since the `processVideo` step later on will convert to `.mp4`. 282 */ 283 if (IS_WEB && !isSupportedVideoMimeType(mimeType)) { 284 errors.add(SelectedAssetError.Unsupported) 285 continue 286 } 287 288 /* 289 * Filesize appears to be stable across all platforms, so we can use it 290 * to filter out large files on web. On native, we compress these anyway, 291 * so we only check on web. 292 */ 293 if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) { 294 errors.add(SelectedAssetError.FileTooBig) 295 continue 296 } 297 } 298 299 if (type === 'image') { 300 if (!isSupportedImageMimeType(mimeType)) { 301 errors.add(SelectedAssetError.Unsupported) 302 continue 303 } 304 } 305 306 if (type === 'gif') { 307 /* 308 * Filesize appears to be stable across all platforms, so we can use it 309 * to filter out large files on web. On native, we compress GIFs as 310 * videos anyway, so we only check on web. 311 */ 312 if (IS_WEB && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) { 313 errors.add(SelectedAssetError.FileTooBig) 314 continue 315 } 316 } 317 318 /* 319 * All validations passed, we have an asset! 320 */ 321 supportedAssets.push({ 322 mimeType, 323 ...asset, 324 /* 325 * In `expo-image-picker` >= v17, `uri` is now a `blob:` URL, not a 326 * data-uri. Our handling elsewhere in the app (for web) relies on the 327 * base64 data-uri, so we construct it here for web only. 328 */ 329 uri: 330 IS_WEB && asset.base64 331 ? `data:${mimeType};base64,${asset.base64}` 332 : asset.uri, 333 }) 334 } 335 336 if (supportedAssets.length > 0) { 337 if (selectableAssetType === 'image') { 338 if (supportedAssets.length > selectionCountRemaining) { 339 errors.add(SelectedAssetError.MaxImages) 340 supportedAssets = supportedAssets.slice(0, selectionCountRemaining) 341 } 342 } else if (selectableAssetType === 'video') { 343 if (supportedAssets.length > 1) { 344 errors.add(SelectedAssetError.MaxVideos) 345 supportedAssets = supportedAssets.slice(0, 1) 346 } 347 348 if (supportedAssets[0].duration) { 349 if (IS_WEB) { 350 /* 351 * Web reports duration as seconds 352 */ 353 supportedAssets[0].duration = supportedAssets[0].duration * 1000 354 } 355 356 if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) { 357 errors.add(SelectedAssetError.VideoTooLong) 358 supportedAssets = [] 359 } 360 } else { 361 errors.add(SelectedAssetError.Unsupported) 362 supportedAssets = [] 363 } 364 } else if (selectableAssetType === 'gif') { 365 if (supportedAssets.length > 1) { 366 errors.add(SelectedAssetError.MaxGIFs) 367 supportedAssets = supportedAssets.slice(0, 1) 368 } 369 } 370 } 371 372 return { 373 type: selectableAssetType!, // set above 374 assets: supportedAssets, 375 errors, 376 } 377} 378 379export function SelectMediaButton({ 380 disabled, 381 allowedAssetTypes, 382 selectedAssetsCount, 383 onSelectAssets, 384 autoOpen, 385}: SelectMediaButtonProps) { 386 const {_} = useLingui() 387 const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() 388 const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() 389 const sheetWrapper = useSheetWrapper() 390 const t = useTheme() 391 const hasAutoOpened = useRef(false) 392 393 const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount 394 395 const processSelectedAssets = useCallback( 396 async (rawAssets: ImagePickerAsset[]) => { 397 const { 398 type, 399 assets, 400 errors: errorCodes, 401 } = await processImagePickerAssets(rawAssets, { 402 selectionCountRemaining, 403 allowedAssetTypes, 404 }) 405 406 /* 407 * Convert error codes to user-friendly messages. 408 */ 409 const errors = Array.from(errorCodes).map(error => { 410 return { 411 [SelectedAssetError.Unsupported]: _( 412 msg`One or more of your selected files are not supported.`, 413 ), 414 [SelectedAssetError.MixedTypes]: _( 415 msg`Selecting multiple media types is not supported.`, 416 ), 417 [SelectedAssetError.MaxImages]: _( 418 msg({ 419 message: `You can select up to ${plural(MAX_IMAGES, { 420 other: '# images', 421 })} in total.`, 422 comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`, 423 }), 424 ), 425 [SelectedAssetError.MaxVideos]: _( 426 msg`You can only select one video at a time.`, 427 ), 428 [SelectedAssetError.VideoTooLong]: _( 429 msg`Videos must be less than 3 minutes long.`, 430 ), 431 [SelectedAssetError.MaxGIFs]: _( 432 msg`You can only select one GIF at a time.`, 433 ), 434 [SelectedAssetError.FileTooBig]: _( 435 msg`One or more of your selected files are too large. Maximum size is 100聽MB.`, 436 ), 437 }[error] 438 }) 439 440 /* 441 * Report the selected assets and any errors back to the 442 * composer. 443 */ 444 onSelectAssets({ 445 type, 446 assets, 447 errors, 448 }) 449 }, 450 [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes], 451 ) 452 453 const onPressSelectMedia = useCallback(async () => { 454 if (IS_NATIVE) { 455 const [photoAccess, videoAccess] = await Promise.all([ 456 requestPhotoAccessIfNeeded(), 457 requestVideoAccessIfNeeded(), 458 ]) 459 460 if (!photoAccess && !videoAccess) { 461 toast.show(_(msg`You need to allow access to your media library.`), { 462 type: 'error', 463 }) 464 return 465 } 466 } 467 468 if (IS_NATIVE && Keyboard.isVisible()) { 469 Keyboard.dismiss() 470 } 471 472 const {assets, canceled} = await sheetWrapper( 473 openUnifiedPicker({selectionCountRemaining}), 474 ) 475 476 if (canceled) return 477 478 await processSelectedAssets(assets) 479 }, [ 480 _, 481 requestPhotoAccessIfNeeded, 482 requestVideoAccessIfNeeded, 483 sheetWrapper, 484 processSelectedAssets, 485 selectionCountRemaining, 486 ]) 487 488 const enableSquareButtons = useEnableSquareButtons() 489 490 useEffect(() => { 491 if (autoOpen && !hasAutoOpened.current && !disabled) { 492 hasAutoOpened.current = true 493 void onPressSelectMedia() 494 } 495 }, [autoOpen, disabled, onPressSelectMedia]) 496 497 return ( 498 <Button 499 testID="openMediaBtn" 500 onPress={onPressSelectMedia} 501 label={_( 502 msg({ 503 message: `Add media to post`, 504 comment: `Accessibility label for button in composer to add images, a video, or a GIF to a post`, 505 }), 506 )} 507 accessibilityHint={_( 508 msg({ 509 message: `Opens device gallery to select up to ${plural(MAX_IMAGES, { 510 other: '# images', 511 })}, or a single video or GIF.`, 512 comment: `Accessibility hint for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`, 513 }), 514 )} 515 style={a.p_sm} 516 variant="ghost" 517 shape={enableSquareButtons ? 'square' : 'round'} 518 color="primary" 519 disabled={disabled}> 520 <ImageIcon 521 size="lg" 522 style={disabled && t.atoms.text_contrast_low} 523 accessibilityIgnoresInvertColors={true} 524 /> 525 </Button> 526 ) 527}