forked from
jollywhoppers.com/witchsky.app
Bluesky app fork with some witchin' additions 馃挮
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}