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