diff options
Diffstat (limited to 'src')
19 files changed, 468 insertions, 33 deletions
diff --git a/src/components/icons/VideoClip.tsx b/src/components/icons/VideoClip.tsx new file mode 100644 index 000000000..c2c13c491 --- /dev/null +++ b/src/components/icons/VideoClip.tsx @@ -0,0 +1,5 @@ +import {createSinglePathSVG} from './TEMPLATE' + +export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({ + path: 'M3 4a1 1 0 0 1 1-1h16a1 1 0 0 1 1 1v16a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v2h2V5H5Zm4 0v6h6V5H9Zm8 0v2h2V5h-2Zm2 4h-2v2h2V9Zm0 4h-2v2.444h2V13Zm0 4.444h-2V19h2v-1.556ZM15 19v-6H9v6h6Zm-8 0v-2H5v2h2Zm-2-4h2v-2H5v2Zm0-4h2V9H5v2Z', +}) diff --git a/src/lib/hooks/usePermissions.ts b/src/lib/hooks/usePermissions.ts index 9f1f8fb6f..d248e1975 100644 --- a/src/lib/hooks/usePermissions.ts +++ b/src/lib/hooks/usePermissions.ts @@ -48,6 +48,35 @@ export function usePhotoLibraryPermission() { return {requestPhotoAccessIfNeeded} } +export function useVideoLibraryPermission() { + const [res, requestPermission] = MediaLibrary.usePermissions({ + granularPermissions: ['video'], + }) + const requestVideoAccessIfNeeded = async () => { + // On the, we use <input type="file"> to produce a filepicker + // This does not need any permission granting. + if (isWeb) { + return true + } + + if (res?.granted) { + return true + } else if (!res || res.status === 'undetermined' || res?.canAskAgain) { + const {canAskAgain, granted, status} = await requestPermission() + + if (!canAskAgain && status === 'undetermined') { + openPermissionAlert('video library') + } + + return granted + } else { + openPermissionAlert('video library') + return false + } + } + return {requestVideoAccessIfNeeded} +} + export function useCameraPermission() { const [res, requestPermission] = Camera.useCameraPermissions() diff --git a/src/lib/hooks/usePermissions.web.ts b/src/lib/hooks/usePermissions.web.ts index c550a7d6d..b65bbc414 100644 --- a/src/lib/hooks/usePermissions.web.ts +++ b/src/lib/hooks/usePermissions.web.ts @@ -14,3 +14,11 @@ export function useCameraPermission() { return {requestCameraAccessIfNeeded} } + +export function useVideoLibraryPermission() { + const requestVideoAccessIfNeeded = async () => { + return true + } + + return {requestVideoAccessIfNeeded} +} diff --git a/src/lib/media/video/compress.ts b/src/lib/media/video/compress.ts new file mode 100644 index 000000000..60e5e94a0 --- /dev/null +++ b/src/lib/media/video/compress.ts @@ -0,0 +1,30 @@ +import {getVideoMetaData, Video} from 'react-native-compressor' + +export type CompressedVideo = { + uri: string + size: number +} + +export async function compressVideo( + file: string, + opts?: { + getCancellationId?: (id: string) => void + onProgress?: (progress: number) => void + }, +): Promise<CompressedVideo> { + const {onProgress, getCancellationId} = opts || {} + + const compressed = await Video.compress( + file, + { + getCancellationId, + compressionMethod: 'manual', + bitrate: 3_000_000, // 3mbps + maxSize: 1920, + }, + onProgress, + ) + + const info = await getVideoMetaData(compressed) + return {uri: compressed, size: info.size} +} diff --git a/src/lib/media/video/compress.web.ts b/src/lib/media/video/compress.web.ts new file mode 100644 index 000000000..968f2b157 --- /dev/null +++ b/src/lib/media/video/compress.web.ts @@ -0,0 +1,28 @@ +import {VideoTooLargeError} from 'lib/media/video/errors' + +const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB + +export type CompressedVideo = { + uri: string + size: number +} + +// doesn't actually compress, but throws if >100MB +export async function compressVideo( + file: string, + _callbacks?: { + onProgress: (progress: number) => void + }, +): Promise<CompressedVideo> { + const blob = await fetch(file).then(res => res.blob()) + const video = URL.createObjectURL(blob) + + if (blob.size > MAX_VIDEO_SIZE) { + throw new VideoTooLargeError() + } + + return { + size: blob.size, + uri: video, + } +} diff --git a/src/lib/media/video/errors.ts b/src/lib/media/video/errors.ts new file mode 100644 index 000000000..701a7e235 --- /dev/null +++ b/src/lib/media/video/errors.ts @@ -0,0 +1,6 @@ +export class VideoTooLargeError extends Error { + constructor() { + super('Videos cannot be larger than 100MB') + this.name = 'VideoTooLargeError' + } +} diff --git a/src/lib/statsig/gates.ts b/src/lib/statsig/gates.ts index 6a4081185..378b27349 100644 --- a/src/lib/statsig/gates.ts +++ b/src/lib/statsig/gates.ts @@ -11,3 +11,4 @@ export type Gate = | 'suggested_feeds_interstitial' | 'suggested_follows_interstitial' | 'ungroup_follow_backs' + | 'videos' diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 9e2f77d4d..c8a77385e 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -1,4 +1,5 @@ import React, { + Suspense, useCallback, useEffect, useImperativeHandle, @@ -42,7 +43,7 @@ import { } from '#/lib/gif-alt-text' import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' import {LikelyType} from '#/lib/link-meta/link-meta' -import {logEvent} from '#/lib/statsig/statsig' +import {logEvent, useGate} from '#/lib/statsig/statsig' import {logger} from '#/logger' import {emitPostCreated} from '#/state/events' import {useModalControls} from '#/state/modals' @@ -96,6 +97,10 @@ import {SuggestedLanguage} from './select-language/SuggestedLanguage' import {TextInput, TextInputRef} from './text-input/TextInput' import {ThreadgateBtn} from './threadgate/ThreadgateBtn' import {useExternalLinkFetch} from './useExternalLinkFetch' +import {SelectVideoBtn} from './videos/SelectVideoBtn' +import {useVideoState} from './videos/state' +import {VideoPreview} from './videos/VideoPreview' +import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress' import hairlineWidth = StyleSheet.hairlineWidth type CancelRef = { @@ -115,6 +120,7 @@ export const ComposePost = observer(function ComposePost({ }: Props & { cancelRef?: React.RefObject<CancelRef> }) { + const gate = useGate() const {currentAccount} = useSession() const agent = useAgent() const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) @@ -156,6 +162,14 @@ export const ComposePost = observer(function ComposePost({ const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( initQuote, ) + const { + video, + onSelectVideo, + videoPending, + videoProcessingData, + clearVideo, + videoProcessingProgress, + } = useVideoState({setError}) const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) const [extGif, setExtGif] = useState<Gif>() const [labels, setLabels] = useState<string[]>([]) @@ -375,8 +389,9 @@ export const ComposePost = observer(function ComposePost({ ? _(msg`Write your reply`) : _(msg`What's up?`) - const canSelectImages = gallery.size < 4 && !extLink - const hasMedia = gallery.size > 0 || Boolean(extLink) + const canSelectImages = + gallery.size < 4 && !extLink && !video && !videoPending + const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video) const onEmojiButtonPress = useCallback(() => { openPicker?.(textInput.current?.getCursorPosition()) @@ -600,7 +615,20 @@ export const ComposePost = observer(function ComposePost({ <QuoteX onRemove={() => setQuote(undefined)} /> )} </View> - ) : undefined} + ) : null} + {videoPending && videoProcessingData ? ( + <VideoTranscodeProgress + input={videoProcessingData} + progress={videoProcessingProgress} + /> + ) : ( + video && ( + // remove suspense when we get rid of lazy + <Suspense fallback={null}> + <VideoPreview video={video} clear={clearVideo} /> + </Suspense> + ) + )} </Animated.ScrollView> <SuggestedLanguage text={richtext.text} /> @@ -619,6 +647,12 @@ export const ComposePost = observer(function ComposePost({ ]}> <View style={[a.flex_row, a.align_center, a.gap_xs]}> <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} /> + {gate('videos') && ( + <SelectVideoBtn + onSelectVideo={onSelectVideo} + disabled={!canSelectImages} + /> + )} <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> <SelectGifBtn onClose={focusTextInput} diff --git a/src/view/com/composer/ExternalEmbed.tsx b/src/view/com/composer/ExternalEmbed.tsx index b81065e99..4801ca0ab 100644 --- a/src/view/com/composer/ExternalEmbed.tsx +++ b/src/view/com/composer/ExternalEmbed.tsx @@ -1,12 +1,9 @@ import React from 'react' -import {StyleProp, TouchableOpacity, View, ViewStyle} from 'react-native' -import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' -import {msg} from '@lingui/macro' -import {useLingui} from '@lingui/react' +import {StyleProp, View, ViewStyle} from 'react-native' import {ExternalEmbedDraft} from 'lib/api/index' -import {s} from 'lib/styles' import {Gif} from 'state/queries/tenor' +import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed' import {atoms as a, useTheme} from '#/alf' import {Loader} from '#/components/Loader' @@ -22,7 +19,6 @@ export const ExternalEmbed = ({ gif?: Gif }) => { const t = useTheme() - const {_} = useLingui() const linkInfo = React.useMemo( () => @@ -70,25 +66,7 @@ export const ExternalEmbed = ({ <ExternalLinkEmbed link={linkInfo} hideAlt /> </View> ) : null} - <TouchableOpacity - style={{ - position: 'absolute', - top: 16, - right: 10, - height: 36, - width: 36, - backgroundColor: 'rgba(0, 0, 0, 0.75)', - borderRadius: 18, - alignItems: 'center', - justifyContent: 'center', - }} - onPress={onRemove} - accessibilityRole="button" - accessibilityLabel={_(msg`Remove image preview`)} - accessibilityHint={_(msg`Removes default thumbnail from ${link.uri}`)} - onAccessibilityEscape={onRemove}> - <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> - </TouchableOpacity> + <ExternalEmbedRemoveBtn onRemove={onRemove} /> </View> ) } diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx new file mode 100644 index 000000000..7742900a8 --- /dev/null +++ b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx @@ -0,0 +1,34 @@ +import React from 'react' +import {TouchableOpacity} from 'react-native' +import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {s} from 'lib/styles' + +export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) { + const {_} = useLingui() + + return ( + <TouchableOpacity + style={{ + position: 'absolute', + top: 10, + right: 10, + height: 36, + width: 36, + backgroundColor: 'rgba(0, 0, 0, 0.75)', + borderRadius: 18, + alignItems: 'center', + justifyContent: 'center', + zIndex: 1, + }} + onPress={onRemove} + accessibilityRole="button" + accessibilityLabel={_(msg`Remove image preview`)} + accessibilityHint={_(msg`Removes the image preview`)} + onAccessibilityEscape={onRemove}> + <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> + </TouchableOpacity> + ) +} diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx index a3fa78a59..a205fe096 100644 --- a/src/view/com/composer/char-progress/CharProgress.tsx +++ b/src/view/com/composer/char-progress/CharProgress.tsx @@ -1,13 +1,14 @@ import React from 'react' import {View} from 'react-native' -import {Text} from '../../util/text/Text' // @ts-ignore no type definition -prf import ProgressCircle from 'react-native-progress/Circle' // @ts-ignore no type definition -prf import ProgressPie from 'react-native-progress/Pie' -import {s} from 'lib/styles' -import {usePalette} from 'lib/hooks/usePalette' + import {MAX_GRAPHEME_LENGTH} from 'lib/constants' +import {usePalette} from 'lib/hooks/usePalette' +import {s} from 'lib/styles' +import {Text} from '../../util/text/Text' const DANGER_LENGTH = MAX_GRAPHEME_LENGTH diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx new file mode 100644 index 000000000..9c528a92e --- /dev/null +++ b/src/view/com/composer/videos/SelectVideoBtn.tsx @@ -0,0 +1,67 @@ +import React, {useCallback} from 'react' +import { + ImagePickerAsset, + launchImageLibraryAsync, + MediaTypeOptions, + UIImagePickerPreferredAssetRepresentationMode, +} from 'expo-image-picker' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' + +import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' +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' + +const VIDEO_MAX_DURATION = 90 + +type Props = { + onSelectVideo: (video: ImagePickerAsset) => void + disabled?: boolean +} + +export function SelectVideoBtn({onSelectVideo, disabled}: Props) { + const {_} = useLingui() + const t = useTheme() + const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() + + const onPressSelectVideo = useCallback(async () => { + if (isNative && !(await requestVideoAccessIfNeeded())) { + return + } + + const response = await launchImageLibraryAsync({ + exif: false, + mediaTypes: MediaTypeOptions.Videos, + videoMaxDuration: VIDEO_MAX_DURATION, + quality: 1, + legacy: true, + preferredAssetRepresentationMode: + UIImagePickerPreferredAssetRepresentationMode.Current, + }) + if (response.assets && response.assets.length > 0) { + onSelectVideo(response.assets[0]) + } + }, [onSelectVideo, requestVideoAccessIfNeeded]) + + 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 new file mode 100644 index 000000000..b04cdf1c8 --- /dev/null +++ b/src/view/com/composer/videos/VideoPreview.tsx @@ -0,0 +1,39 @@ +/* eslint-disable @typescript-eslint/no-shadow */ +import React from 'react' +import {View} from 'react-native' +import {useVideoPlayer, VideoView} from 'expo-video' + +import {CompressedVideo} from '#/lib/media/video/compress' +import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' +import {atoms as a} from '#/alf' + +export function VideoPreview({ + video, + clear, +}: { + video: CompressedVideo + clear: () => void +}) { + const player = useVideoPlayer(video.uri, player => { + player.loop = true + player.play() + }) + + return ( + <View + style={[ + a.w_full, + a.rounded_sm, + {aspectRatio: 16 / 9}, + a.overflow_hidden, + ]}> + <VideoView + player={player} + style={a.flex_1} + allowsPictureInPicture={false} + nativeControls={false} + /> + <ExternalEmbedRemoveBtn onRemove={clear} /> + </View> + ) +} diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx new file mode 100644 index 000000000..223dbd424 --- /dev/null +++ b/src/view/com/composer/videos/VideoPreview.web.tsx @@ -0,0 +1,27 @@ +import React from 'react' +import {View} from 'react-native' + +import {CompressedVideo} from '#/lib/media/video/compress' +import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' +import {atoms as a} from '#/alf' + +export function VideoPreview({ + video, + clear, +}: { + video: CompressedVideo + clear: () => void +}) { + return ( + <View + style={[ + a.w_full, + a.rounded_sm, + {aspectRatio: 16 / 9}, + a.overflow_hidden, + ]}> + <ExternalEmbedRemoveBtn onRemove={clear} /> + <video src={video.uri} style={a.flex_1} autoPlay loop muted playsInline /> + </View> + ) +} diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx new file mode 100644 index 000000000..1f4173642 --- /dev/null +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx @@ -0,0 +1,37 @@ +import React, {useEffect} from 'react' +import {clearCache, createVideoThumbnail} from 'react-native-compressor' +import Animated, {FadeIn} from 'react-native-reanimated' +import {Image} from 'expo-image' +import {useQuery} from '@tanstack/react-query' + +import {atoms as a} from '#/alf' + +export function VideoTranscodeBackdrop({uri}: {uri: string}) { + const {data: thumbnail} = useQuery({ + queryKey: ['thumbnail', uri], + queryFn: async () => { + return await createVideoThumbnail(uri) + }, + }) + + useEffect(() => { + return () => { + clearCache() + } + }, []) + + return ( + <Animated.View style={a.flex_1} entering={FadeIn}> + {thumbnail && ( + <Image + style={a.flex_1} + source={thumbnail.path} + cachePolicy="none" + accessibilityIgnoresInvertColors + blurRadius={15} + contentFit="cover" + /> + )} + </Animated.View> + ) +} diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx new file mode 100644 index 000000000..9b580fdf2 --- /dev/null +++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx @@ -0,0 +1,7 @@ +import React from 'react' + +export function VideoTranscodeBackdrop({uri}: {uri: string}) { + return ( + <video src={uri} style={{flex: 1, filter: 'blur(10px)'}} muted autoPlay /> + ) +} diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx new file mode 100644 index 000000000..79407cd3e --- /dev/null +++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx @@ -0,0 +1,53 @@ +import React from 'react' +import {View} from 'react-native' +// @ts-expect-error no type definition +import ProgressPie from 'react-native-progress/Pie' +import {ImagePickerAsset} from 'expo-image-picker' + +import {atoms as a, useTheme} from '#/alf' +import {Text} from '#/components/Typography' +import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' + +export function VideoTranscodeProgress({ + input, + progress, +}: { + input: ImagePickerAsset + progress: number +}) { + const t = useTheme() + + const aspectRatio = input.width / input.height + + return ( + <View + style={[ + a.w_full, + a.mt_md, + t.atoms.bg_contrast_50, + a.rounded_md, + a.overflow_hidden, + {aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio}, + ]}> + <VideoTranscodeBackdrop uri={input.uri} /> + <View + style={[ + a.flex_1, + a.align_center, + a.justify_center, + a.gap_lg, + a.absolute, + a.inset_0, + ]}> + <ProgressPie + size={64} + borderWidth={4} + borderColor={t.atoms.text.color} + color={t.atoms.text.color} + progress={progress} + /> + <Text>Compressing...</Text> + </View> + </View> + ) +} diff --git a/src/view/com/composer/videos/state.ts b/src/view/com/composer/videos/state.ts new file mode 100644 index 000000000..0d47dd056 --- /dev/null +++ b/src/view/com/composer/videos/state.ts @@ -0,0 +1,51 @@ +import {useState} from 'react' +import {ImagePickerAsset} from 'expo-image-picker' +import {msg} from '@lingui/macro' +import {useLingui} from '@lingui/react' +import {useMutation} from '@tanstack/react-query' + +import {compressVideo} from '#/lib/media/video/compress' +import {logger} from '#/logger' +import {VideoTooLargeError} from 'lib/media/video/errors' +import * as Toast from 'view/com/util/Toast' + +export function useVideoState({setError}: {setError: (error: string) => void}) { + const {_} = useLingui() + const [progress, setProgress] = useState(0) + + const {mutate, data, isPending, isError, reset, variables} = useMutation({ + mutationFn: async (asset: ImagePickerAsset) => { + const compressed = await compressVideo(asset.uri, { + onProgress: num => setProgress(trunc2dp(num)), + }) + + return compressed + }, + onError: (e: any) => { + // Don't log these errors in sentry, just let the user know + if (e instanceof VideoTooLargeError) { + Toast.show(_(msg`Videos cannot be larger than 100MB`)) + return + } + logger.error('Failed to compress video', {safeError: e}) + setError(_(msg`Could not compress video`)) + }, + onMutate: () => { + setProgress(0) + }, + }) + + return { + video: data, + onSelectVideo: mutate, + videoPending: isPending, + videoProcessingData: variables, + videoError: isError, + clearVideo: reset, + videoProcessingProgress: progress, + } +} + +function trunc2dp(num: number) { + return Math.trunc(num * 100) / 100 +} diff --git a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx index f5f220c62..e7fd6cb8f 100644 --- a/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx +++ b/src/view/com/util/post-embeds/ExternalLinkEmbed.tsx @@ -57,7 +57,7 @@ export const ExternalLinkEmbed = ({ } return ( - <View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}> + <View style={[a.flex_col, a.rounded_sm, a.overflow_hidden]}> <LinkWrapper link={link} onOpen={onOpen} style={style}> {imageUri && !embedPlayerParams ? ( <Image |