diff --git a/app.config.js b/app.config.js
index 4a449122..1467f762 100644
--- a/app.config.js
+++ b/app.config.js
@@ -211,6 +211,8 @@ module.exports = function (config) {
sounds: PLATFORM === 'ios' ? ['assets/dm.aiff'] : ['assets/dm.mp3'],
},
],
+ 'expo-video',
+ 'react-native-compressor',
'./plugins/starterPackAppClipExtension/withStarterPackAppClip.js',
'./plugins/withAndroidManifestPlugin.js',
'./plugins/withAndroidManifestFCMIconPlugin.js',
diff --git a/assets/icons/videoClip_stroke2_corner0_rounded.svg b/assets/icons/videoClip_stroke2_corner0_rounded.svg
new file mode 100644
index 00000000..fd4c08d4
--- /dev/null
+++ b/assets/icons/videoClip_stroke2_corner0_rounded.svg
@@ -0,0 +1 @@
+
diff --git a/package.json b/package.json
index 5f9f0345..0ea23a27 100644
--- a/package.json
+++ b/package.json
@@ -136,6 +136,7 @@
"expo-system-ui": "~3.0.4",
"expo-task-manager": "~11.8.1",
"expo-updates": "~0.25.14",
+ "expo-video": "^1.1.10",
"expo-web-browser": "~13.0.3",
"fast-text-encoding": "^1.0.6",
"history": "^5.3.0",
@@ -166,6 +167,7 @@
"react-dom": "^18.2.0",
"react-keyed-flatten-children": "^3.0.0",
"react-native": "0.74.1",
+ "react-native-compressor": "^1.8.24",
"react-native-date-picker": "^4.4.2",
"react-native-drawer-layout": "^4.0.0-alpha.3",
"react-native-fs": "^2.20.0",
diff --git a/src/components/icons/VideoClip.tsx b/src/components/icons/VideoClip.tsx
new file mode 100644
index 00000000..c2c13c49
--- /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 9f1f8fb6..d248e197 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 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 c550a7d6..b65bbc41 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 00000000..60e5e94a
--- /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 {
+ 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 00000000..968f2b15
--- /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 {
+ 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 00000000..701a7e23
--- /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 6a408118..378b2734 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 9e2f77d4..c8a77385 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
}) {
+ 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(
initQuote,
)
+ const {
+ video,
+ onSelectVideo,
+ videoPending,
+ videoProcessingData,
+ clearVideo,
+ videoProcessingProgress,
+ } = useVideoState({setError})
const {extLink, setExtLink} = useExternalLinkFetch({setQuote})
const [extGif, setExtGif] = useState()
const [labels, setLabels] = useState([])
@@ -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({
setQuote(undefined)} />
)}
- ) : undefined}
+ ) : null}
+ {videoPending && videoProcessingData ? (
+
+ ) : (
+ video && (
+ // remove suspense when we get rid of lazy
+
+
+
+ )
+ )}
@@ -619,6 +647,12 @@ export const ComposePost = observer(function ComposePost({
]}>
+ {gate('videos') && (
+
+ )}
{
const t = useTheme()
- const {_} = useLingui()
const linkInfo = React.useMemo(
() =>
@@ -70,25 +66,7 @@ export const ExternalEmbed = ({
) : null}
-
-
-
+
)
}
diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
new file mode 100644
index 00000000..7742900a
--- /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 (
+
+
+
+ )
+}
diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx
index a3fa78a5..a205fe09 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 00000000..9c528a92
--- /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 (
+ <>
+
+ >
+ )
+}
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
new file mode 100644
index 00000000..b04cdf1c
--- /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 (
+
+
+
+
+ )
+}
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 00000000..223dbd42
--- /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 (
+
+
+
+
+ )
+}
diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
new file mode 100644
index 00000000..1f417364
--- /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 (
+
+ {thumbnail && (
+
+ )}
+
+ )
+}
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 00000000..9b580fdf
--- /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 (
+
+ )
+}
diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
new file mode 100644
index 00000000..79407cd3
--- /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 (
+
+
+
+
+ Compressing...
+
+
+ )
+}
diff --git a/src/view/com/composer/videos/state.ts b/src/view/com/composer/videos/state.ts
new file mode 100644
index 00000000..0d47dd05
--- /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 f5f220c6..e7fd6cb8 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 (
-
+
{imageUri && !embedPlayerParams ? (