about summary refs log tree commit diff
path: root/src/view
diff options
context:
space:
mode:
Diffstat (limited to 'src/view')
-rw-r--r--src/view/com/composer/Composer.tsx42
-rw-r--r--src/view/com/composer/ExternalEmbed.tsx28
-rw-r--r--src/view/com/composer/ExternalEmbedRemoveBtn.tsx34
-rw-r--r--src/view/com/composer/char-progress/CharProgress.tsx7
-rw-r--r--src/view/com/composer/videos/SelectVideoBtn.tsx67
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx39
-rw-r--r--src/view/com/composer/videos/VideoPreview.web.tsx27
-rw-r--r--src/view/com/composer/videos/VideoTranscodeBackdrop.tsx37
-rw-r--r--src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx7
-rw-r--r--src/view/com/composer/videos/VideoTranscodeProgress.tsx53
-rw-r--r--src/view/com/composer/videos/state.ts51
-rw-r--r--src/view/com/util/post-embeds/ExternalLinkEmbed.tsx2
12 files changed, 361 insertions, 33 deletions
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