Bluesky app fork with some witchin' additions 💫

Video compression in composer (#4638)

Co-authored-by: Samuel Newman <10959775+mozzius@users.noreply.github.com>
Co-authored-by: Hailey <me@haileyok.com>

authored by samuel.fm

Samuel Newman
Hailey
and committed by
GitHub
8f06ba70 56b68874

+483 -33
+2
app.config.js
··· 211 211 sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'], 212 212 }, 213 213 ], 214 + 'expo-video', 215 + 'react-native-compressor', 214 216 './plugins/starterPackAppClipExtension/withStarterPackAppClip.js', 215 217 './plugins/withAndroidManifestPlugin.js', 216 218 './plugins/withAndroidManifestFCMIconPlugin.js',
+1
assets/icons/videoClip_stroke2_corner0_rounded.svg
··· 1 + <svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24"><path fill="#000" fill-rule="evenodd" d="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" clip-rule="evenodd"/></svg>
+2
package.json
··· 136 136 "expo-system-ui": "~3.0.4", 137 137 "expo-task-manager": "~11.8.1", 138 138 "expo-updates": "~0.25.14", 139 + "expo-video": "^1.1.10", 139 140 "expo-web-browser": "~13.0.3", 140 141 "fast-text-encoding": "^1.0.6", 141 142 "history": "^5.3.0", ··· 166 167 "react-dom": "^18.2.0", 167 168 "react-keyed-flatten-children": "^3.0.0", 168 169 "react-native": "0.74.1", 170 + "react-native-compressor": "^1.8.24", 169 171 "react-native-date-picker": "^4.4.2", 170 172 "react-native-drawer-layout": "^4.0.0-alpha.3", 171 173 "react-native-fs": "^2.20.0",
+5
src/components/icons/VideoClip.tsx
··· 1 + import {createSinglePathSVG} from './TEMPLATE' 2 + 3 + export const VideoClip_Stroke2_Corner0_Rounded = createSinglePathSVG({ 4 + 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', 5 + })
+29
src/lib/hooks/usePermissions.ts
··· 48 48 return {requestPhotoAccessIfNeeded} 49 49 } 50 50 51 + export function useVideoLibraryPermission() { 52 + const [res, requestPermission] = MediaLibrary.usePermissions({ 53 + granularPermissions: ['video'], 54 + }) 55 + const requestVideoAccessIfNeeded = async () => { 56 + // On the, we use <input type="file"> to produce a filepicker 57 + // This does not need any permission granting. 58 + if (isWeb) { 59 + return true 60 + } 61 + 62 + if (res?.granted) { 63 + return true 64 + } else if (!res || res.status === 'undetermined' || res?.canAskAgain) { 65 + const {canAskAgain, granted, status} = await requestPermission() 66 + 67 + if (!canAskAgain && status === 'undetermined') { 68 + openPermissionAlert('video library') 69 + } 70 + 71 + return granted 72 + } else { 73 + openPermissionAlert('video library') 74 + return false 75 + } 76 + } 77 + return {requestVideoAccessIfNeeded} 78 + } 79 + 51 80 export function useCameraPermission() { 52 81 const [res, requestPermission] = Camera.useCameraPermissions() 53 82
+8
src/lib/hooks/usePermissions.web.ts
··· 14 14 15 15 return {requestCameraAccessIfNeeded} 16 16 } 17 + 18 + export function useVideoLibraryPermission() { 19 + const requestVideoAccessIfNeeded = async () => { 20 + return true 21 + } 22 + 23 + return {requestVideoAccessIfNeeded} 24 + }
+30
src/lib/media/video/compress.ts
··· 1 + import {getVideoMetaData, Video} from 'react-native-compressor' 2 + 3 + export type CompressedVideo = { 4 + uri: string 5 + size: number 6 + } 7 + 8 + export async function compressVideo( 9 + file: string, 10 + opts?: { 11 + getCancellationId?: (id: string) => void 12 + onProgress?: (progress: number) => void 13 + }, 14 + ): Promise<CompressedVideo> { 15 + const {onProgress, getCancellationId} = opts || {} 16 + 17 + const compressed = await Video.compress( 18 + file, 19 + { 20 + getCancellationId, 21 + compressionMethod: 'manual', 22 + bitrate: 3_000_000, // 3mbps 23 + maxSize: 1920, 24 + }, 25 + onProgress, 26 + ) 27 + 28 + const info = await getVideoMetaData(compressed) 29 + return {uri: compressed, size: info.size} 30 + }
+28
src/lib/media/video/compress.web.ts
··· 1 + import {VideoTooLargeError} from 'lib/media/video/errors' 2 + 3 + const MAX_VIDEO_SIZE = 1024 * 1024 * 100 // 100MB 4 + 5 + export type CompressedVideo = { 6 + uri: string 7 + size: number 8 + } 9 + 10 + // doesn't actually compress, but throws if >100MB 11 + export async function compressVideo( 12 + file: string, 13 + _callbacks?: { 14 + onProgress: (progress: number) => void 15 + }, 16 + ): Promise<CompressedVideo> { 17 + const blob = await fetch(file).then(res => res.blob()) 18 + const video = URL.createObjectURL(blob) 19 + 20 + if (blob.size > MAX_VIDEO_SIZE) { 21 + throw new VideoTooLargeError() 22 + } 23 + 24 + return { 25 + size: blob.size, 26 + uri: video, 27 + } 28 + }
+6
src/lib/media/video/errors.ts
··· 1 + export class VideoTooLargeError extends Error { 2 + constructor() { 3 + super('Videos cannot be larger than 100MB') 4 + this.name = 'VideoTooLargeError' 5 + } 6 + }
+1
src/lib/statsig/gates.ts
··· 11 11 | 'suggested_feeds_interstitial' 12 12 | 'suggested_follows_interstitial' 13 13 | 'ungroup_follow_backs' 14 + | 'videos'
+38 -4
src/view/com/composer/Composer.tsx
··· 1 1 import React, { 2 + Suspense, 2 3 useCallback, 3 4 useEffect, 4 5 useImperativeHandle, ··· 42 43 } from '#/lib/gif-alt-text' 43 44 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED' 44 45 import {LikelyType} from '#/lib/link-meta/link-meta' 45 - import {logEvent} from '#/lib/statsig/statsig' 46 + import {logEvent, useGate} from '#/lib/statsig/statsig' 46 47 import {logger} from '#/logger' 47 48 import {emitPostCreated} from '#/state/events' 48 49 import {useModalControls} from '#/state/modals' ··· 96 97 import {TextInput, TextInputRef} from './text-input/TextInput' 97 98 import {ThreadgateBtn} from './threadgate/ThreadgateBtn' 98 99 import {useExternalLinkFetch} from './useExternalLinkFetch' 100 + import {SelectVideoBtn} from './videos/SelectVideoBtn' 101 + import {useVideoState} from './videos/state' 102 + import {VideoPreview} from './videos/VideoPreview' 103 + import {VideoTranscodeProgress} from './videos/VideoTranscodeProgress' 99 104 import hairlineWidth = StyleSheet.hairlineWidth 100 105 101 106 type CancelRef = { ··· 115 120 }: Props & { 116 121 cancelRef?: React.RefObject<CancelRef> 117 122 }) { 123 + const gate = useGate() 118 124 const {currentAccount} = useSession() 119 125 const agent = useAgent() 120 126 const {data: currentProfile} = useProfileQuery({did: currentAccount!.did}) ··· 156 162 const [quote, setQuote] = useState<ComposerOpts['quote'] | undefined>( 157 163 initQuote, 158 164 ) 165 + const { 166 + video, 167 + onSelectVideo, 168 + videoPending, 169 + videoProcessingData, 170 + clearVideo, 171 + videoProcessingProgress, 172 + } = useVideoState({setError}) 159 173 const {extLink, setExtLink} = useExternalLinkFetch({setQuote}) 160 174 const [extGif, setExtGif] = useState<Gif>() 161 175 const [labels, setLabels] = useState<string[]>([]) ··· 375 389 ? _(msg`Write your reply`) 376 390 : _(msg`What's up?`) 377 391 378 - const canSelectImages = gallery.size < 4 && !extLink 379 - const hasMedia = gallery.size > 0 || Boolean(extLink) 392 + const canSelectImages = 393 + gallery.size < 4 && !extLink && !video && !videoPending 394 + const hasMedia = gallery.size > 0 || Boolean(extLink) || Boolean(video) 380 395 381 396 const onEmojiButtonPress = useCallback(() => { 382 397 openPicker?.(textInput.current?.getCursorPosition()) ··· 600 615 <QuoteX onRemove={() => setQuote(undefined)} /> 601 616 )} 602 617 </View> 603 - ) : undefined} 618 + ) : null} 619 + {videoPending && videoProcessingData ? ( 620 + <VideoTranscodeProgress 621 + input={videoProcessingData} 622 + progress={videoProcessingProgress} 623 + /> 624 + ) : ( 625 + video && ( 626 + // remove suspense when we get rid of lazy 627 + <Suspense fallback={null}> 628 + <VideoPreview video={video} clear={clearVideo} /> 629 + </Suspense> 630 + ) 631 + )} 604 632 </Animated.ScrollView> 605 633 <SuggestedLanguage text={richtext.text} /> 606 634 ··· 619 647 ]}> 620 648 <View style={[a.flex_row, a.align_center, a.gap_xs]}> 621 649 <SelectPhotoBtn gallery={gallery} disabled={!canSelectImages} /> 650 + {gate('videos') && ( 651 + <SelectVideoBtn 652 + onSelectVideo={onSelectVideo} 653 + disabled={!canSelectImages} 654 + /> 655 + )} 622 656 <OpenCameraBtn gallery={gallery} disabled={!canSelectImages} /> 623 657 <SelectGifBtn 624 658 onClose={focusTextInput}
+3 -25
src/view/com/composer/ExternalEmbed.tsx
··· 1 1 import React from 'react' 2 - import {StyleProp, TouchableOpacity, View, ViewStyle} from 'react-native' 3 - import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 - import {msg} from '@lingui/macro' 5 - import {useLingui} from '@lingui/react' 2 + import {StyleProp, View, ViewStyle} from 'react-native' 6 3 7 4 import {ExternalEmbedDraft} from 'lib/api/index' 8 - import {s} from 'lib/styles' 9 5 import {Gif} from 'state/queries/tenor' 6 + import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' 10 7 import {ExternalLinkEmbed} from 'view/com/util/post-embeds/ExternalLinkEmbed' 11 8 import {atoms as a, useTheme} from '#/alf' 12 9 import {Loader} from '#/components/Loader' ··· 22 19 gif?: Gif 23 20 }) => { 24 21 const t = useTheme() 25 - const {_} = useLingui() 26 22 27 23 const linkInfo = React.useMemo( 28 24 () => ··· 70 66 <ExternalLinkEmbed link={linkInfo} hideAlt /> 71 67 </View> 72 68 ) : null} 73 - <TouchableOpacity 74 - style={{ 75 - position: 'absolute', 76 - top: 16, 77 - right: 10, 78 - height: 36, 79 - width: 36, 80 - backgroundColor: 'rgba(0, 0, 0, 0.75)', 81 - borderRadius: 18, 82 - alignItems: 'center', 83 - justifyContent: 'center', 84 - }} 85 - onPress={onRemove} 86 - accessibilityRole="button" 87 - accessibilityLabel={_(msg`Remove image preview`)} 88 - accessibilityHint={_(msg`Removes default thumbnail from ${link.uri}`)} 89 - onAccessibilityEscape={onRemove}> 90 - <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> 91 - </TouchableOpacity> 69 + <ExternalEmbedRemoveBtn onRemove={onRemove} /> 92 70 </View> 93 71 ) 94 72 }
+34
src/view/com/composer/ExternalEmbedRemoveBtn.tsx
··· 1 + import React from 'react' 2 + import {TouchableOpacity} from 'react-native' 3 + import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' 4 + import {msg} from '@lingui/macro' 5 + import {useLingui} from '@lingui/react' 6 + 7 + import {s} from 'lib/styles' 8 + 9 + export function ExternalEmbedRemoveBtn({onRemove}: {onRemove: () => void}) { 10 + const {_} = useLingui() 11 + 12 + return ( 13 + <TouchableOpacity 14 + style={{ 15 + position: 'absolute', 16 + top: 10, 17 + right: 10, 18 + height: 36, 19 + width: 36, 20 + backgroundColor: 'rgba(0, 0, 0, 0.75)', 21 + borderRadius: 18, 22 + alignItems: 'center', 23 + justifyContent: 'center', 24 + zIndex: 1, 25 + }} 26 + onPress={onRemove} 27 + accessibilityRole="button" 28 + accessibilityLabel={_(msg`Remove image preview`)} 29 + accessibilityHint={_(msg`Removes the image preview`)} 30 + onAccessibilityEscape={onRemove}> 31 + <FontAwesomeIcon size={18} icon="xmark" style={s.white} /> 32 + </TouchableOpacity> 33 + ) 34 + }
+4 -3
src/view/com/composer/char-progress/CharProgress.tsx
··· 1 1 import React from 'react' 2 2 import {View} from 'react-native' 3 - import {Text} from '../../util/text/Text' 4 3 // @ts-ignore no type definition -prf 5 4 import ProgressCircle from 'react-native-progress/Circle' 6 5 // @ts-ignore no type definition -prf 7 6 import ProgressPie from 'react-native-progress/Pie' 7 + 8 + import {MAX_GRAPHEME_LENGTH} from 'lib/constants' 9 + import {usePalette} from 'lib/hooks/usePalette' 8 10 import {s} from 'lib/styles' 9 - import {usePalette} from 'lib/hooks/usePalette' 10 - import {MAX_GRAPHEME_LENGTH} from 'lib/constants' 11 + import {Text} from '../../util/text/Text' 11 12 12 13 const DANGER_LENGTH = MAX_GRAPHEME_LENGTH 13 14
+67
src/view/com/composer/videos/SelectVideoBtn.tsx
··· 1 + import React, {useCallback} from 'react' 2 + import { 3 + ImagePickerAsset, 4 + launchImageLibraryAsync, 5 + MediaTypeOptions, 6 + UIImagePickerPreferredAssetRepresentationMode, 7 + } from 'expo-image-picker' 8 + import {msg} from '@lingui/macro' 9 + import {useLingui} from '@lingui/react' 10 + 11 + import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions' 12 + import {isNative} from '#/platform/detection' 13 + import {atoms as a, useTheme} from '#/alf' 14 + import {Button} from '#/components/Button' 15 + import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip' 16 + 17 + const VIDEO_MAX_DURATION = 90 18 + 19 + type Props = { 20 + onSelectVideo: (video: ImagePickerAsset) => void 21 + disabled?: boolean 22 + } 23 + 24 + export function SelectVideoBtn({onSelectVideo, disabled}: Props) { 25 + const {_} = useLingui() 26 + const t = useTheme() 27 + const {requestVideoAccessIfNeeded} = useVideoLibraryPermission() 28 + 29 + const onPressSelectVideo = useCallback(async () => { 30 + if (isNative && !(await requestVideoAccessIfNeeded())) { 31 + return 32 + } 33 + 34 + const response = await launchImageLibraryAsync({ 35 + exif: false, 36 + mediaTypes: MediaTypeOptions.Videos, 37 + videoMaxDuration: VIDEO_MAX_DURATION, 38 + quality: 1, 39 + legacy: true, 40 + preferredAssetRepresentationMode: 41 + UIImagePickerPreferredAssetRepresentationMode.Current, 42 + }) 43 + if (response.assets && response.assets.length > 0) { 44 + onSelectVideo(response.assets[0]) 45 + } 46 + }, [onSelectVideo, requestVideoAccessIfNeeded]) 47 + 48 + return ( 49 + <> 50 + <Button 51 + testID="openGifBtn" 52 + onPress={onPressSelectVideo} 53 + label={_(msg`Select video`)} 54 + accessibilityHint={_(msg`Opens video picker`)} 55 + style={a.p_sm} 56 + variant="ghost" 57 + shape="round" 58 + color="primary" 59 + disabled={disabled}> 60 + <VideoClipIcon 61 + size="lg" 62 + style={disabled && t.atoms.text_contrast_low} 63 + /> 64 + </Button> 65 + </> 66 + ) 67 + }
+39
src/view/com/composer/videos/VideoPreview.tsx
··· 1 + /* eslint-disable @typescript-eslint/no-shadow */ 2 + import React from 'react' 3 + import {View} from 'react-native' 4 + import {useVideoPlayer, VideoView} from 'expo-video' 5 + 6 + import {CompressedVideo} from '#/lib/media/video/compress' 7 + import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' 8 + import {atoms as a} from '#/alf' 9 + 10 + export function VideoPreview({ 11 + video, 12 + clear, 13 + }: { 14 + video: CompressedVideo 15 + clear: () => void 16 + }) { 17 + const player = useVideoPlayer(video.uri, player => { 18 + player.loop = true 19 + player.play() 20 + }) 21 + 22 + return ( 23 + <View 24 + style={[ 25 + a.w_full, 26 + a.rounded_sm, 27 + {aspectRatio: 16 / 9}, 28 + a.overflow_hidden, 29 + ]}> 30 + <VideoView 31 + player={player} 32 + style={a.flex_1} 33 + allowsPictureInPicture={false} 34 + nativeControls={false} 35 + /> 36 + <ExternalEmbedRemoveBtn onRemove={clear} /> 37 + </View> 38 + ) 39 + }
+27
src/view/com/composer/videos/VideoPreview.web.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + 4 + import {CompressedVideo} from '#/lib/media/video/compress' 5 + import {ExternalEmbedRemoveBtn} from 'view/com/composer/ExternalEmbedRemoveBtn' 6 + import {atoms as a} from '#/alf' 7 + 8 + export function VideoPreview({ 9 + video, 10 + clear, 11 + }: { 12 + video: CompressedVideo 13 + clear: () => void 14 + }) { 15 + return ( 16 + <View 17 + style={[ 18 + a.w_full, 19 + a.rounded_sm, 20 + {aspectRatio: 16 / 9}, 21 + a.overflow_hidden, 22 + ]}> 23 + <ExternalEmbedRemoveBtn onRemove={clear} /> 24 + <video src={video.uri} style={a.flex_1} autoPlay loop muted playsInline /> 25 + </View> 26 + ) 27 + }
+37
src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
··· 1 + import React, {useEffect} from 'react' 2 + import {clearCache, createVideoThumbnail} from 'react-native-compressor' 3 + import Animated, {FadeIn} from 'react-native-reanimated' 4 + import {Image} from 'expo-image' 5 + import {useQuery} from '@tanstack/react-query' 6 + 7 + import {atoms as a} from '#/alf' 8 + 9 + export function VideoTranscodeBackdrop({uri}: {uri: string}) { 10 + const {data: thumbnail} = useQuery({ 11 + queryKey: ['thumbnail', uri], 12 + queryFn: async () => { 13 + return await createVideoThumbnail(uri) 14 + }, 15 + }) 16 + 17 + useEffect(() => { 18 + return () => { 19 + clearCache() 20 + } 21 + }, []) 22 + 23 + return ( 24 + <Animated.View style={a.flex_1} entering={FadeIn}> 25 + {thumbnail && ( 26 + <Image 27 + style={a.flex_1} 28 + source={thumbnail.path} 29 + cachePolicy="none" 30 + accessibilityIgnoresInvertColors 31 + blurRadius={15} 32 + contentFit="cover" 33 + /> 34 + )} 35 + </Animated.View> 36 + ) 37 + }
+7
src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx
··· 1 + import React from 'react' 2 + 3 + export function VideoTranscodeBackdrop({uri}: {uri: string}) { 4 + return ( 5 + <video src={uri} style={{flex: 1, filter: 'blur(10px)'}} muted autoPlay /> 6 + ) 7 + }
+53
src/view/com/composer/videos/VideoTranscodeProgress.tsx
··· 1 + import React from 'react' 2 + import {View} from 'react-native' 3 + // @ts-expect-error no type definition 4 + import ProgressPie from 'react-native-progress/Pie' 5 + import {ImagePickerAsset} from 'expo-image-picker' 6 + 7 + import {atoms as a, useTheme} from '#/alf' 8 + import {Text} from '#/components/Typography' 9 + import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop' 10 + 11 + export function VideoTranscodeProgress({ 12 + input, 13 + progress, 14 + }: { 15 + input: ImagePickerAsset 16 + progress: number 17 + }) { 18 + const t = useTheme() 19 + 20 + const aspectRatio = input.width / input.height 21 + 22 + return ( 23 + <View 24 + style={[ 25 + a.w_full, 26 + a.mt_md, 27 + t.atoms.bg_contrast_50, 28 + a.rounded_md, 29 + a.overflow_hidden, 30 + {aspectRatio: isNaN(aspectRatio) ? 16 / 9 : aspectRatio}, 31 + ]}> 32 + <VideoTranscodeBackdrop uri={input.uri} /> 33 + <View 34 + style={[ 35 + a.flex_1, 36 + a.align_center, 37 + a.justify_center, 38 + a.gap_lg, 39 + a.absolute, 40 + a.inset_0, 41 + ]}> 42 + <ProgressPie 43 + size={64} 44 + borderWidth={4} 45 + borderColor={t.atoms.text.color} 46 + color={t.atoms.text.color} 47 + progress={progress} 48 + /> 49 + <Text>Compressing...</Text> 50 + </View> 51 + </View> 52 + ) 53 + }
+51
src/view/com/composer/videos/state.ts
··· 1 + import {useState} from 'react' 2 + import {ImagePickerAsset} from 'expo-image-picker' 3 + import {msg} from '@lingui/macro' 4 + import {useLingui} from '@lingui/react' 5 + import {useMutation} from '@tanstack/react-query' 6 + 7 + import {compressVideo} from '#/lib/media/video/compress' 8 + import {logger} from '#/logger' 9 + import {VideoTooLargeError} from 'lib/media/video/errors' 10 + import * as Toast from 'view/com/util/Toast' 11 + 12 + export function useVideoState({setError}: {setError: (error: string) => void}) { 13 + const {_} = useLingui() 14 + const [progress, setProgress] = useState(0) 15 + 16 + const {mutate, data, isPending, isError, reset, variables} = useMutation({ 17 + mutationFn: async (asset: ImagePickerAsset) => { 18 + const compressed = await compressVideo(asset.uri, { 19 + onProgress: num => setProgress(trunc2dp(num)), 20 + }) 21 + 22 + return compressed 23 + }, 24 + onError: (e: any) => { 25 + // Don't log these errors in sentry, just let the user know 26 + if (e instanceof VideoTooLargeError) { 27 + Toast.show(_(msg`Videos cannot be larger than 100MB`)) 28 + return 29 + } 30 + logger.error('Failed to compress video', {safeError: e}) 31 + setError(_(msg`Could not compress video`)) 32 + }, 33 + onMutate: () => { 34 + setProgress(0) 35 + }, 36 + }) 37 + 38 + return { 39 + video: data, 40 + onSelectVideo: mutate, 41 + videoPending: isPending, 42 + videoProcessingData: variables, 43 + videoError: isError, 44 + clearVideo: reset, 45 + videoProcessingProgress: progress, 46 + } 47 + } 48 + 49 + function trunc2dp(num: number) { 50 + return Math.trunc(num * 100) / 100 51 + }
+1 -1
src/view/com/util/post-embeds/ExternalLinkEmbed.tsx
··· 57 57 } 58 58 59 59 return ( 60 - <View style={[a.flex_col, a.rounded_sm, a.overflow_hidden, a.mt_sm]}> 60 + <View style={[a.flex_col, a.rounded_sm, a.overflow_hidden]}> 61 61 <LinkWrapper link={link} onOpen={onOpen} style={style}> 62 62 {imageUri && !embedPlayerParams ? ( 63 63 <Image
+10
yarn.lock
··· 12302 12302 ignore "^5.3.1" 12303 12303 resolve-from "^5.0.0" 12304 12304 12305 + expo-video@^1.1.10: 12306 + version "1.1.10" 12307 + resolved "https://registry.yarnpkg.com/expo-video/-/expo-video-1.1.10.tgz#b47c0d40c21f401236639424bd25d70c09316b7b" 12308 + integrity sha512-k9ecpgtwAK8Ut8enm8Jv398XkB/uVOyLLqk80M/d8pH9EN5CVrBQ7iEzWlR3quvVUFM7Uf5wRukJ4hk3mZ8NCg== 12309 + 12305 12310 expo-web-browser@~13.0.3: 12306 12311 version "13.0.3" 12307 12312 resolved "https://registry.yarnpkg.com/expo-web-browser/-/expo-web-browser-13.0.3.tgz#dceb05dbc187b498ca937b02adf385b0232a4e92" ··· 18846 18851 integrity sha512-tSH6gvOyQjt3qtjG+kU9sTypclL1672yjpVufcE3aHNM0FhvjBUQZqsb/awIux4zEuVC3k/DP4p0GdTT/QUt/Q== 18847 18852 dependencies: 18848 18853 react-is "^18.2.0" 18854 + 18855 + react-native-compressor@^1.8.24: 18856 + version "1.8.24" 18857 + resolved "https://registry.yarnpkg.com/react-native-compressor/-/react-native-compressor-1.8.24.tgz#3cc481ad6dfe2787ec4385275dd24791f04d9e71" 18858 + integrity sha512-PdwOBdnyBnpOag1FRX9ks4cb0GiMLKFU9HSaFTHdb/uw6fVIrnCHpELASeliOxlabWb5rOyVPbc58QpGIfZQIQ== 18849 18859 18850 18860 react-native-date-picker@^4.4.2: 18851 18861 version "4.4.2"