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 {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}