diff options
Diffstat (limited to 'src/view')
-rw-r--r-- | src/view/com/composer/Composer.tsx | 111 | ||||
-rw-r--r-- | src/view/com/composer/SelectMediaButton.tsx | 524 | ||||
-rw-r--r-- | src/view/com/composer/photos/ImageAltTextDialog.tsx | 5 | ||||
-rw-r--r-- | src/view/com/composer/photos/SelectPhotoBtn.tsx | 60 | ||||
-rw-r--r-- | src/view/com/composer/videos/SelectVideoBtn.tsx | 88 | ||||
-rw-r--r-- | src/view/com/composer/videos/VideoPreview.tsx | 31 | ||||
-rw-r--r-- | src/view/com/lightbox/Lightbox.web.tsx | 1 | ||||
-rw-r--r-- | src/view/com/post-thread/PostThreadFollowBtn.tsx | 4 | ||||
-rw-r--r-- | src/view/com/profile/FollowButton.tsx | 10 | ||||
-rw-r--r-- | src/view/screens/Log.tsx | 116 | ||||
-rw-r--r-- | src/view/shell/index.tsx | 18 | ||||
-rw-r--r-- | src/view/shell/index.web.tsx | 27 |
12 files changed, 676 insertions, 319 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 296545353..d0dbdfaba 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -77,7 +77,11 @@ import {logger} from '#/logger' import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection' import {useDialogStateControlContext} from '#/state/dialogs' import {emitPostCreated} from '#/state/events' -import {type ComposerImage, pasteImage} from '#/state/gallery' +import { + type ComposerImage, + createComposerImage, + pasteImage, +} from '#/state/gallery' import {useModalControls} from '#/state/modals' import {useRequireAltTextEnabled} from '#/state/preferences' import { @@ -103,7 +107,6 @@ import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn' import {Gallery} from '#/view/com/composer/photos/Gallery' import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn' import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn' -import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn' import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn' import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage' // TODO: Prevent naming components that coincide with RN primitives @@ -113,12 +116,10 @@ import { type TextInputRef, } from '#/view/com/composer/text-input/TextInput' import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn' -import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn' import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog' import {VideoPreview} from '#/view/com/composer/videos/VideoPreview' import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress' import {Text} from '#/view/com/util/text/Text' -import * as Toast from '#/view/com/util/Toast' import {UserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a, native, useTheme, web} from '#/alf' import {Button, ButtonIcon, ButtonText} from '#/components/Button' @@ -127,9 +128,15 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times' import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed' import * as Prompt from '#/components/Prompt' +import * as toast from '#/components/Toast' import {Text as NewText} from '#/components/Typography' import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet' import { + type AssetType, + SelectMediaButton, + type SelectMediaButtonProps, +} from './SelectMediaButton' +import { type ComposerAction, composerReducer, createComposerState, @@ -514,12 +521,13 @@ export const ComposePost = ({ onPostSuccess?.(postSuccessData) } onClose() - Toast.show( + toast.show( thread.posts.length > 1 ? _(msg`Your posts have been published`) : replyTo ? _(msg`Your reply has been published`) : _(msg`Your post has been published`), + {type: 'success'}, ) }, [ _, @@ -811,11 +819,16 @@ let ComposerPost = React.memo(function ComposerPost({ const onPhotoPasted = useCallback( async (uri: string) => { - if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) { + if ( + uri.startsWith('data:video/') || + (isWeb && uri.startsWith('data:image/gif')) + ) { if (isNative) return // web only const [mimeType] = uri.slice('data:'.length).split(';') if (!SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)) { - Toast.show(_(msg`Unsupported video type`), 'xmark') + toast.show(_(msg`Unsupported video type: ${mimeType}`), { + type: 'error', + }) return } const name = `pasted.${mimeToExt(mimeType)}` @@ -1251,7 +1264,6 @@ function ComposerFooter({ dispatch, showAddButton, onEmojiButtonPress, - onError, onSelectVideo, onAddPost, }: { @@ -1266,11 +1278,32 @@ function ComposerFooter({ const t = useTheme() const {_} = useLingui() const {isMobile} = useWebMediaQueries() + /* + * Once we've allowed a certain type of asset to be selected, we don't allow + * other types of media to be selected. + */ + const [selectedAssetsType, setSelectedAssetsType] = useState< + AssetType | undefined + >(undefined) const media = post.embed.media const images = media?.type === 'images' ? media.images : [] const video = media?.type === 'video' ? media.video : null const isMaxImages = images.length >= MAX_IMAGES + const isMaxVideos = !!video + + let selectedAssetsCount = 0 + let isMediaSelectionDisabled = false + + if (media?.type === 'images') { + isMediaSelectionDisabled = isMaxImages + selectedAssetsCount = images.length + } else if (media?.type === 'video') { + isMediaSelectionDisabled = isMaxVideos + selectedAssetsCount = 1 + } else { + isMediaSelectionDisabled = !!media + } const onImageAdd = useCallback( (next: ComposerImage[]) => { @@ -1289,6 +1322,54 @@ function ComposerFooter({ [dispatch], ) + /* + * Reset if the user clears any selected media + */ + if (selectedAssetsType !== undefined && !media) { + setSelectedAssetsType(undefined) + } + + const onSelectAssets = useCallback<SelectMediaButtonProps['onSelectAssets']>( + async ({type, assets, errors}) => { + setSelectedAssetsType(type) + + if (assets.length) { + if (type === 'image') { + const images: ComposerImage[] = [] + + await Promise.all( + assets.map(async image => { + const composerImage = await createComposerImage({ + path: image.uri, + width: image.width, + height: image.height, + mime: image.mimeType!, + }) + images.push(composerImage) + }), + ).catch(e => { + logger.error(`createComposerImage failed`, { + safeMessage: e.message, + }) + }) + + onImageAdd(images) + } else if (type === 'video') { + onSelectVideo(post.id, assets[0]) + } else if (type === 'gif') { + onSelectVideo(post.id, assets[0]) + } + } + + errors.map(error => { + toast.show(error, { + type: 'warning', + }) + }) + }, + [post.id, onSelectVideo, onImageAdd], + ) + return ( <View style={[ @@ -1307,15 +1388,11 @@ function ComposerFooter({ <VideoUploadToolbar state={video} /> ) : ( <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}> - <SelectPhotoBtn - size={images.length} - disabled={media?.type === 'images' ? isMaxImages : !!media} - onAdd={onImageAdd} - /> - <SelectVideoBtn - onSelectVideo={asset => onSelectVideo(post.id, asset)} - disabled={!!media} - setError={onError} + <SelectMediaButton + disabled={isMediaSelectionDisabled} + allowedAssetTypes={selectedAssetsType} + selectedAssetsCount={selectedAssetsCount} + onSelectAssets={onSelectAssets} /> <OpenCameraBtn disabled={media?.type === 'images' ? isMaxImages : !!media} diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx new file mode 100644 index 000000000..026d0ac19 --- /dev/null +++ b/src/view/com/composer/SelectMediaButton.tsx @@ -0,0 +1,524 @@ +import {useCallback} from 'react' +import {Keyboard} from 'react-native' +import { + type ImagePickerAsset, + launchImageLibraryAsync, + UIImagePickerPreferredAssetRepresentationMode, +} from 'expo-image-picker' +import {msg, plural} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE} from '#/lib/constants' +import { + usePhotoLibraryPermission, + useVideoLibraryPermission, +} from '#/lib/hooks/usePermissions' +import {extractDataUriMime} from '#/lib/media/util' +import {isIOS, isNative, isWeb} from '#/platform/detection' +import {MAX_IMAGES} from '#/view/com/composer/state/composer' +import {atoms as a, useTheme} from '#/alf' +import {Button} from '#/components/Button' +import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' +import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image' +import * as toast from '#/components/Toast' + +export type SelectMediaButtonProps = { + disabled?: boolean + /** + * If set, this limits the types of assets that can be selected. + */ + allowedAssetTypes: AssetType | undefined + selectedAssetsCount: number + onSelectAssets: (props: { + type: AssetType + assets: ImagePickerAsset[] + errors: string[] + }) => void +} + +/** + * Generic asset classes, or buckets, that we support. + */ +export type AssetType = 'video' | 'image' | 'gif' + +/** + * Shadows `ImagePickerAsset` from `expo-image-picker`, but with a guaranteed `mimeType` + */ +type ValidatedImagePickerAsset = Omit<ImagePickerAsset, 'mimeType'> & { + mimeType: string +} + +/** + * Codes for known validation states + */ +enum SelectedAssetError { + Unsupported = 'Unsupported', + MixedTypes = 'MixedTypes', + MaxImages = 'MaxImages', + MaxVideos = 'MaxVideos', + VideoTooLong = 'VideoTooLong', + FileTooBig = 'FileTooBig', + MaxGIFs = 'MaxGIFs', +} + +/** + * Supported video mime types. This differs slightly from + * `SUPPORTED_MIME_TYPES` from `#/lib/constants` because we only care about + * videos here. + */ +const SUPPORTED_VIDEO_MIME_TYPES = [ + 'video/mp4', + 'video/mpeg', + 'video/webm', + 'video/quicktime', +] as const +type SupportedVideoMimeType = (typeof SUPPORTED_VIDEO_MIME_TYPES)[number] +function isSupportedVideoMimeType( + mimeType: string, +): mimeType is SupportedVideoMimeType { + return SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType as SupportedVideoMimeType) +} + +/** + * Supported image mime types. + */ +const SUPPORTED_IMAGE_MIME_TYPES = ( + [ + 'image/gif', + 'image/jpeg', + 'image/png', + 'image/svg+xml', + 'image/webp', + 'image/avif', + isNative && 'image/heic', + ] as const +).filter(Boolean) +type SupportedImageMimeType = Exclude< + (typeof SUPPORTED_IMAGE_MIME_TYPES)[number], + boolean +> +function isSupportedImageMimeType( + mimeType: string, +): mimeType is SupportedImageMimeType { + return SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType as SupportedImageMimeType) +} + +/** + * This is a last-ditch effort type thing here, try not to rely on this. + */ +const extensionToMimeType: Record< + string, + SupportedVideoMimeType | SupportedImageMimeType +> = { + mp4: 'video/mp4', + mov: 'video/quicktime', + webm: 'video/webm', + webp: 'image/webp', + gif: 'image/gif', + jpg: 'image/jpeg', + jpeg: 'image/jpeg', + png: 'image/png', + svg: 'image/svg+xml', + heic: 'image/heic', +} + +/** + * Attempts to bucket the given asset into one of our known types based on its + * `mimeType`. If `mimeType` is not available, we try to infer it through + * various means. + */ +function classifyImagePickerAsset(asset: ImagePickerAsset): + | { + success: true + type: AssetType + mimeType: string + } + | { + success: false + type: undefined + mimeType: undefined + } { + /* + * Try to use the `mimeType` reported by `expo-image-picker` first. + */ + let mimeType = asset.mimeType + + if (!mimeType) { + /* + * We can try to infer this from the data-uri. + */ + const maybeMimeType = extractDataUriMime(asset.uri) + + if ( + maybeMimeType.startsWith('image/') || + maybeMimeType.startsWith('video/') + ) { + mimeType = maybeMimeType + } else if (maybeMimeType.startsWith('file/')) { + /* + * On the off-chance we get a `file/*` mime, try to infer from the + * extension. + */ + const extension = asset.uri.split('.').pop()?.toLowerCase() + mimeType = extensionToMimeType[extension || ''] + } + } + + if (!mimeType) { + return { + success: false, + type: undefined, + mimeType: undefined, + } + } + + /* + * Distill this down into a type "class". + */ + let type: AssetType | undefined + if (mimeType === 'image/gif') { + type = 'gif' + } else if (mimeType?.startsWith('video/')) { + type = 'video' + } else if (mimeType?.startsWith('image/')) { + type = 'image' + } + + /* + * If we weren't able to find a valid type, we don't support this asset. + */ + if (!type) { + return { + success: false, + type: undefined, + mimeType: undefined, + } + } + + return { + success: true, + type, + mimeType, + } +} + +/** + * Takes in raw assets from `expo-image-picker` and applies validation. Returns + * the dominant `AssetType`, any valid assets, and any errors encountered along + * the way. + */ +async function processImagePickerAssets( + assets: ImagePickerAsset[], + { + selectionCountRemaining, + allowedAssetTypes, + }: { + selectionCountRemaining: number + allowedAssetTypes: AssetType | undefined + }, +) { + /* + * A deduped set of error codes, which we'll use later + */ + const errors = new Set<SelectedAssetError>() + + /* + * We only support selecting a single type of media at a time, so this gets + * set to whatever the first valid asset type is, OR to whatever + * `allowedAssetTypes` is set to. + */ + let selectableAssetType: AssetType | undefined + + /* + * This will hold the assets that we can actually use, after filtering + */ + let supportedAssets: ValidatedImagePickerAsset[] = [] + + for (const asset of assets) { + const {success, type, mimeType} = classifyImagePickerAsset(asset) + + if (!success) { + errors.add(SelectedAssetError.Unsupported) + continue + } + + /* + * If we have an `allowedAssetTypes` prop, constrain to that. Otherwise, + * set this to the first valid asset type we see, and then use that to + * constrain all remaining selected assets. + */ + selectableAssetType = allowedAssetTypes || selectableAssetType || type + + // ignore mixed types + if (type !== selectableAssetType) { + errors.add(SelectedAssetError.MixedTypes) + continue + } + + if (type === 'video') { + /** + * We don't care too much about mimeType at this point on native, + * since the `processVideo` step later on will convert to `.mp4`. + */ + if (isWeb && !isSupportedVideoMimeType(mimeType)) { + errors.add(SelectedAssetError.Unsupported) + continue + } + + /* + * Filesize appears to be stable across all platforms, so we can use it + * to filter out large files on web. On native, we compress these anyway, + * so we only check on web. + */ + if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) { + errors.add(SelectedAssetError.FileTooBig) + continue + } + } + + if (type === 'image') { + if (!isSupportedImageMimeType(mimeType)) { + errors.add(SelectedAssetError.Unsupported) + continue + } + } + + if (type === 'gif') { + /* + * Filesize appears to be stable across all platforms, so we can use it + * to filter out large files on web. On native, we compress GIFs as + * videos anyway, so we only check on web. + */ + if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) { + errors.add(SelectedAssetError.FileTooBig) + continue + } + } + + /* + * All validations passed, we have an asset! + */ + supportedAssets.push({ + mimeType, + ...asset, + /* + * In `expo-image-picker` >= v17, `uri` is now a `blob:` URL, not a + * data-uri. Our handling elsewhere in the app (for web) relies on the + * base64 data-uri, so we construct it here for web only. + */ + uri: + isWeb && asset.base64 + ? `data:${mimeType};base64,${asset.base64}` + : asset.uri, + }) + } + + if (supportedAssets.length > 0) { + if (selectableAssetType === 'image') { + if (supportedAssets.length > selectionCountRemaining) { + errors.add(SelectedAssetError.MaxImages) + supportedAssets = supportedAssets.slice(0, selectionCountRemaining) + } + } else if (selectableAssetType === 'video') { + if (supportedAssets.length > 1) { + errors.add(SelectedAssetError.MaxVideos) + supportedAssets = supportedAssets.slice(0, 1) + } + + if (supportedAssets[0].duration) { + if (isWeb) { + /* + * Web reports duration as seconds + */ + supportedAssets[0].duration = supportedAssets[0].duration * 1000 + } + + if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) { + errors.add(SelectedAssetError.VideoTooLong) + supportedAssets = [] + } + } else { + errors.add(SelectedAssetError.Unsupported) + supportedAssets = [] + } + } else if (selectableAssetType === 'gif') { + if (supportedAssets.length > 1) { + errors.add(SelectedAssetError.MaxGIFs) + supportedAssets = supportedAssets.slice(0, 1) + } + } + } + + return { + type: selectableAssetType!, // set above + assets: supportedAssets, + errors, + } +} + +export function SelectMediaButton({ + disabled, + allowedAssetTypes, + selectedAssetsCount, + onSelectAssets, +}: SelectMediaButtonProps) { + const {_} = useLingui() + const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() + const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() + const sheetWrapper = useSheetWrapper() + const t = useTheme() + + const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount + + const processSelectedAssets = useCallback( + async (rawAssets: ImagePickerAsset[]) => { + const { + type, + assets, + errors: errorCodes, + } = await processImagePickerAssets(rawAssets, { + selectionCountRemaining, + allowedAssetTypes, + }) + + /* + * Convert error codes to user-friendly messages. + */ + const errors = Array.from(errorCodes).map(error => { + return { + [SelectedAssetError.Unsupported]: _( + msg`One or more of your selected files are not supported.`, + ), + [SelectedAssetError.MixedTypes]: _( + msg`Selecting multiple media types is not supported.`, + ), + [SelectedAssetError.MaxImages]: _( + msg({ + message: `You can select up to ${plural(MAX_IMAGES, { + other: '# images', + })} in total.`, + comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`, + }), + ), + [SelectedAssetError.MaxVideos]: _( + msg`You can only select one video at a time.`, + ), + [SelectedAssetError.VideoTooLong]: _( + msg`Videos must be less than 3 minutes long.`, + ), + [SelectedAssetError.MaxGIFs]: _( + msg`You can only select one GIF at a time.`, + ), + [SelectedAssetError.FileTooBig]: _( + msg`One or more of your selected files is too large. Maximum size is 100 MB.`, + ), + }[error] + }) + + /* + * Report the selected assets and any errors back to the + * composer. + */ + onSelectAssets({ + type, + assets, + errors, + }) + }, + [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes], + ) + + const onPressSelectMedia = useCallback(async () => { + if (isNative) { + const [photoAccess, videoAccess] = await Promise.all([ + requestPhotoAccessIfNeeded(), + requestVideoAccessIfNeeded(), + ]) + + if (!photoAccess && !videoAccess) { + toast.show(_(msg`You need to allow access to your media library.`), { + type: 'error', + }) + return + } + } + + if (isNative && Keyboard.isVisible()) { + Keyboard.dismiss() + } + + const {assets, canceled} = await sheetWrapper( + launchImageLibraryAsync({ + exif: false, + mediaTypes: ['images', 'videos'], + quality: 1, + allowsMultipleSelection: true, + legacy: true, + base64: isWeb, + selectionLimit: isIOS ? selectionCountRemaining : undefined, + preferredAssetRepresentationMode: + UIImagePickerPreferredAssetRepresentationMode.Current, + videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000, + }), + ) + + if (canceled) return + + await processSelectedAssets(assets) + }, [ + _, + requestPhotoAccessIfNeeded, + requestVideoAccessIfNeeded, + sheetWrapper, + processSelectedAssets, + selectionCountRemaining, + ]) + + return ( + <Button + testID="openMediaBtn" + onPress={onPressSelectMedia} + label={_( + msg({ + message: `Add media to post`, + comment: `Accessibility label for button in composer to add photos or a video to a post`, + }), + )} + accessibilityHint={ + isNative + ? _( + msg({ + message: `Opens device gallery to select up to ${plural( + MAX_IMAGES, + { + other: '# images', + }, + )}, or a single video.`, + comment: `Accessibility hint on native for button in composer to add images or a video to a post. Maximum number of images that can be selected is currently 4 but may change.`, + }), + ) + : _( + msg({ + message: `Opens device gallery to select up to ${plural( + MAX_IMAGES, + { + other: '# images', + }, + )}, or a single video or GIF.`, + comment: `Accessibility hint on web 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.`, + }), + ) + } + style={a.p_sm} + variant="ghost" + shape="round" + color="primary" + disabled={disabled}> + <ImageIcon + size="lg" + style={disabled && t.atoms.text_contrast_low} + accessibilityIgnoresInvertColors={true} + /> + </Button> + ) +} diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx index 724149937..b356cde9b 100644 --- a/src/view/com/composer/photos/ImageAltTextDialog.tsx +++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx @@ -96,13 +96,12 @@ const ImageAltTextInner = ({ <View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}> <Image style={imageStyle} - source={{ - uri: (image.transformed ?? image.source).path, - }} + source={{uri: (image.transformed ?? image.source).path}} contentFit="contain" accessible={true} accessibilityIgnoresInvertColors enableLiveTextInteraction + autoplay={false} /> </View> </View> diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx deleted file mode 100644 index f4c6aa328..000000000 --- a/src/view/com/composer/photos/SelectPhotoBtn.tsx +++ /dev/null @@ -1,60 +0,0 @@ -/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */ -import {useCallback} from 'react' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions' -import {openPicker} from '#/lib/media/picker' -import {isNative} from '#/platform/detection' -import {ComposerImage, createComposerImage} from '#/state/gallery' -import {atoms as a, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper' -import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image' - -type Props = { - size: number - disabled?: boolean - onAdd: (next: ComposerImage[]) => void -} - -export function SelectPhotoBtn({size, disabled, onAdd}: Props) { - const {_} = useLingui() - const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission() - const t = useTheme() - const sheetWrapper = useSheetWrapper() - - const onPressSelectPhotos = useCallback(async () => { - if (isNative && !(await requestPhotoAccessIfNeeded())) { - return - } - - const images = await sheetWrapper( - openPicker({ - selectionLimit: 4 - size, - allowsMultipleSelection: true, - }), - ) - - const results = await Promise.all( - images.map(img => createComposerImage(img)), - ) - - onAdd(results) - }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper]) - - return ( - <Button - testID="openGalleryBtn" - onPress={onPressSelectPhotos} - label={_(msg`Gallery`)} - accessibilityHint={_(msg`Opens device photo gallery`)} - style={a.p_sm} - variant="ghost" - shape="round" - color="primary" - disabled={disabled}> - <Image size="lg" style={disabled && t.atoms.text_contrast_low} /> - </Button> - ) -} diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx deleted file mode 100644 index 96715955f..000000000 --- a/src/view/com/composer/videos/SelectVideoBtn.tsx +++ /dev/null @@ -1,88 +0,0 @@ -import {useCallback} from 'react' -import {type ImagePickerAsset} from 'expo-image-picker' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' - -import { - SUPPORTED_MIME_TYPES, - type SupportedMimeTypes, - VIDEO_MAX_DURATION_MS, -} from '#/lib/constants' -import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' -import {isWeb} from '#/platform/detection' -import {isNative} from '#/platform/detection' -import {atoms as a, useTheme} from '#/alf' -import {Button} from '#/components/Button' -import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' -import {pickVideo} from './pickVideo' - -type Props = { - onSelectVideo: (video: ImagePickerAsset) => void - disabled?: boolean - setError: (error: string) => void -} - -export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) { - const {_} = useLingui() - const t = useTheme() - const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() - - const onPressSelectVideo = useCallback(async () => { - if (isNative && !(await requestVideoAccessIfNeeded())) { - return - } - - const response = await pickVideo() - if (response.assets && response.assets.length > 0) { - const asset = response.assets[0] - try { - if (isWeb) { - // asset.duration is null for gifs (see the TODO in pickVideo.web.ts) - if (asset.duration && asset.duration > VIDEO_MAX_DURATION_MS) { - throw Error(_(msg`Videos must be less than 3 minutes long`)) - } - // compression step on native converts to mp4, so no need to check there - if ( - !SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes) - ) { - throw Error(_(msg`Unsupported video type: ${asset.mimeType}`)) - } - } else { - if (typeof asset.duration !== 'number') { - throw Error('Asset is not a video') - } - if (asset.duration > VIDEO_MAX_DURATION_MS) { - throw Error(_(msg`Videos must be less than 3 minutes long`)) - } - } - onSelectVideo(asset) - } catch (err) { - if (err instanceof Error) { - setError(err.message) - } else { - setError(_(msg`An error occurred while selecting the video`)) - } - } - } - }, [requestVideoAccessIfNeeded, setError, _, onSelectVideo]) - - return ( - <> - <Button - testID="openGifBtn" - onPress={onPressSelectVideo} - label={_(msg`Select video`)} - accessibilityHint={_(msg`Opens video picker`)} - style={a.p_sm} - variant="ghost" - shape="round" - color="primary" - disabled={disabled}> - <VideoClipIcon - size="lg" - style={disabled && t.atoms.text_contrast_low} - /> - </Button> - </> - ) -} diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx index 255174bea..84cb1dba7 100644 --- a/src/view/com/composer/videos/VideoPreview.tsx +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -1,9 +1,10 @@ import React from 'react' import {View} from 'react-native' -import {ImagePickerAsset} from 'expo-image-picker' +import {Image} from 'expo-image' +import {type ImagePickerAsset} from 'expo-image-picker' import {BlueskyVideoView} from '@haileyok/bluesky-video' -import {CompressedVideo} from '#/lib/media/video/types' +import {type CompressedVideo} from '#/lib/media/video/types' import {clamp} from '#/lib/numbers' import {useAutoplayDisabled} from '#/state/preferences' import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn' @@ -48,13 +49,25 @@ export function VideoPreview({ <VideoTranscodeBackdrop uri={asset.uri} /> </View> {isActivePost && ( - <BlueskyVideoView - url={video.uri} - autoplay={!autoplayDisabled} - beginMuted={true} - forceTakeover={true} - ref={playerRef} - /> + <> + {video.mimeType === 'image/gif' ? ( + <Image + style={[a.flex_1]} + autoplay={!autoplayDisabled} + source={{uri: video.uri}} + accessibilityIgnoresInvertColors + cachePolicy="none" + /> + ) : ( + <BlueskyVideoView + url={video.uri} + autoplay={!autoplayDisabled} + beginMuted={true} + forceTakeover={true} + ref={playerRef} + /> + )} + </> )} <ExternalEmbedRemoveBtn onRemove={clear} /> {autoplayDisabled && ( diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx index 97811da7f..ab50fbcf0 100644 --- a/src/view/com/lightbox/Lightbox.web.tsx +++ b/src/view/com/lightbox/Lightbox.web.tsx @@ -76,6 +76,7 @@ function LightboxInner({ const onKeyDown = useCallback( (e: KeyboardEvent) => { if (e.key === 'Escape') { + e.preventDefault() onClose() } else if (e.key === 'ArrowLeft') { onPressLeft() diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx index 145e919f9..fc9296cad 100644 --- a/src/view/com/post-thread/PostThreadFollowBtn.tsx +++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx @@ -1,5 +1,5 @@ import React from 'react' -import {AppBskyActorDefs} from '@atproto/api' +import {type AppBskyActorDefs} from '@atproto/api' import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' @@ -126,7 +126,7 @@ function PostThreadFollowBtnLoaded({ <ButtonText> {!isFollowing ? ( isFollowedBy ? ( - <Trans>Follow Back</Trans> + <Trans>Follow back</Trans> ) : ( <Trans>Follow</Trans> ) diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx index 656ed914a..ff9c1cd7b 100644 --- a/src/view/com/profile/FollowButton.tsx +++ b/src/view/com/profile/FollowButton.tsx @@ -1,11 +1,11 @@ -import {StyleProp, TextStyle, View} from 'react-native' +import {type StyleProp, type TextStyle, View} from 'react-native' import {msg} from '@lingui/macro' import {useLingui} from '@lingui/react' -import {Shadow} from '#/state/cache/types' +import {type Shadow} from '#/state/cache/types' import {useProfileFollowMutationQueue} from '#/state/queries/profile' -import * as bsky from '#/types/bsky' -import {Button, ButtonType} from '../util/forms/Button' +import type * as bsky from '#/types/bsky' +import {Button, type ButtonType} from '../util/forms/Button' import * as Toast from '../util/Toast' export function FollowButton({ @@ -78,7 +78,7 @@ export function FollowButton({ type={unfollowedType} labelStyle={labelStyle} onPress={onPressFollow} - label={_(msg({message: 'Follow Back', context: 'action'}))} + label={_(msg({message: 'Follow back', context: 'action'}))} /> ) } diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx deleted file mode 100644 index 026319baf..000000000 --- a/src/view/screens/Log.tsx +++ /dev/null @@ -1,116 +0,0 @@ -import React from 'react' -import {StyleSheet, TouchableOpacity, View} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' -import {useFocusEffect} from '@react-navigation/native' - -import {usePalette} from '#/lib/hooks/usePalette' -import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo' -import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types' -import {s} from '#/lib/styles' -import {getEntries} from '#/logger/logDump' -import {useTickEveryMinute} from '#/state/shell' -import {useSetMinimalShellMode} from '#/state/shell' -import {Text} from '#/view/com/util/text/Text' -import {ViewHeader} from '#/view/com/util/ViewHeader' -import {ScrollView} from '#/view/com/util/Views' -import * as Layout from '#/components/Layout' - -export function LogScreen({}: NativeStackScreenProps< - CommonNavigatorParams, - 'Log' ->) { - const pal = usePalette('default') - const {_} = useLingui() - const setMinimalShellMode = useSetMinimalShellMode() - const [expanded, setExpanded] = React.useState<string[]>([]) - const timeAgo = useGetTimeAgo() - const tick = useTickEveryMinute() - - useFocusEffect( - React.useCallback(() => { - setMinimalShellMode(false) - }, [setMinimalShellMode]), - ) - - const toggler = (id: string) => () => { - if (expanded.includes(id)) { - setExpanded(expanded.filter(v => v !== id)) - } else { - setExpanded([...expanded, id]) - } - } - - return ( - <Layout.Screen> - <ViewHeader title="Log" /> - <ScrollView style={s.flex1}> - {getEntries() - .slice(0) - .map(entry => { - return ( - <View key={`entry-${entry.id}`}> - <TouchableOpacity - style={[styles.entry, pal.border, pal.view]} - onPress={toggler(entry.id)} - accessibilityLabel={_(msg`View debug entry`)} - accessibilityHint={_( - msg`Opens additional details for a debug entry`, - )}> - {entry.level === 'debug' ? ( - <FontAwesomeIcon icon="info" /> - ) : ( - <FontAwesomeIcon icon="exclamation" style={s.red3} /> - )} - <Text type="sm" style={[styles.summary, pal.text]}> - {String(entry.message)} - </Text> - {entry.metadata && Object.keys(entry.metadata).length ? ( - <FontAwesomeIcon - icon={ - expanded.includes(entry.id) ? 'angle-up' : 'angle-down' - } - style={s.mr5} - /> - ) : undefined} - <Text type="sm" style={[styles.ts, pal.textLight]}> - {timeAgo(entry.timestamp, tick)} - </Text> - </TouchableOpacity> - {expanded.includes(entry.id) ? ( - <View style={[pal.view, s.pl10, s.pr10, s.pb10]}> - <View style={[pal.btn, styles.details]}> - <Text type="mono" style={pal.text}> - {JSON.stringify(entry.metadata, null, 2)} - </Text> - </View> - </View> - ) : undefined} - </View> - ) - })} - <View style={s.footerSpacer} /> - </ScrollView> - </Layout.Screen> - ) -} - -const styles = StyleSheet.create({ - entry: { - flexDirection: 'row', - borderTopWidth: 1, - paddingVertical: 10, - paddingHorizontal: 6, - }, - summary: { - flex: 1, - }, - ts: { - width: 40, - }, - details: { - paddingVertical: 10, - paddingHorizontal: 6, - }, -}) diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx index 04fccc44c..8b4c65b8f 100644 --- a/src/view/shell/index.tsx +++ b/src/view/shell/index.tsx @@ -13,6 +13,7 @@ import {useNotificationsRegistration} from '#/lib/notifications/notifications' import {isStateAtTabRoot} from '#/lib/routes/helpers' import {isAndroid, isIOS} from '#/platform/detection' import {useDialogFullyExpandedCountContext} from '#/state/dialogs' +import {useGeolocation} from '#/state/geolocation' import {useSession} from '#/state/session' import { useIsDrawerOpen, @@ -26,6 +27,7 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' import {setSystemUITheme} from '#/alf/util/systemUI' import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' +import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' import {EmailDialog} from '#/components/dialogs/EmailDialog' import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' @@ -180,9 +182,11 @@ function ShellInner() { ) } -export const Shell: React.FC = function ShellImpl() { - const fullyExpandedCount = useDialogFullyExpandedCountContext() +export function Shell() { const t = useTheme() + const {geolocation} = useGeolocation() + const fullyExpandedCount = useDialogFullyExpandedCountContext() + useIntentHandler() useEffect(() => { @@ -200,9 +204,13 @@ export const Shell: React.FC = function ShellImpl() { navigationBar: t.name !== 'light' ? 'light' : 'dark', }} /> - <RoutesContainer> - <ShellInner /> - </RoutesContainer> + {geolocation?.isAgeBlockedGeo ? ( + <BlockedGeoOverlay /> + ) : ( + <RoutesContainer> + <ShellInner /> + </RoutesContainer> + )} </View> ) } diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx index 3c2bc58ab..f942ab49e 100644 --- a/src/view/shell/index.web.tsx +++ b/src/view/shell/index.web.tsx @@ -5,11 +5,10 @@ import {useLingui} from '@lingui/react' import {useNavigation} from '@react-navigation/native' import {RemoveScrollBar} from 'react-remove-scroll-bar' -import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle' import {useIntentHandler} from '#/lib/hooks/useIntentHandler' import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' import {type NavigationProp} from '#/lib/routes/types' -import {colors} from '#/lib/styles' +import {useGeolocation} from '#/state/geolocation' import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell' import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut' import {useCloseAllActiveElements} from '#/state/util' @@ -18,6 +17,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal' import {ErrorBoundary} from '#/view/com/util/ErrorBoundary' import {atoms as a, select, useTheme} from '#/alf' import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog' +import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay' import {EmailDialog} from '#/components/dialogs/EmailDialog' import {LinkWarningDialog} from '#/components/dialogs/LinkWarning' import {MutedWordsDialog} from '#/components/dialogs/MutedWords' @@ -130,24 +130,23 @@ function ShellInner() { ) } -export const Shell: React.FC = function ShellImpl() { - const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark) +export function Shell() { + const t = useTheme() + const {geolocation} = useGeolocation() return ( - <View style={[a.util_screen_outer, pageBg]}> - <RoutesContainer> - <ShellInner /> - </RoutesContainer> + <View style={[a.util_screen_outer, t.atoms.bg]}> + {geolocation?.isAgeBlockedGeo ? ( + <BlockedGeoOverlay /> + ) : ( + <RoutesContainer> + <ShellInner /> + </RoutesContainer> + )} </View> ) } const styles = StyleSheet.create({ - bgLight: { - backgroundColor: colors.white, - }, - bgDark: { - backgroundColor: colors.black, // TODO - }, drawerMask: { ...a.fixed, width: '100%', |