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