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

Configure Feed

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

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