this repo has no description
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}