this repo has no description
0
fork

Configure Feed

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

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