about summary refs log tree commit diff
path: root/src/view/com/composer
diff options
context:
space:
mode:
Diffstat (limited to 'src/view/com/composer')
-rw-r--r--src/view/com/composer/Composer.tsx106
-rw-r--r--src/view/com/composer/ExternalEmbedRemoveBtn.tsx1
-rw-r--r--src/view/com/composer/GifAltText.tsx2
-rw-r--r--src/view/com/composer/char-progress/CharProgress.tsx1
-rw-r--r--src/view/com/composer/labels/LabelsBtn.tsx39
-rw-r--r--src/view/com/composer/photos/OpenCameraBtn.tsx2
-rw-r--r--src/view/com/composer/photos/SelectGifBtn.tsx2
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx2
-rw-r--r--src/view/com/composer/select-language/SelectLangBtn.tsx2
-rw-r--r--src/view/com/composer/select-language/SuggestedLanguage.tsx2
-rw-r--r--src/view/com/composer/state/video.ts13
-rw-r--r--src/view/com/composer/text-input/TextInput.tsx2
-rw-r--r--src/view/com/composer/text-input/TextInput.web.tsx33
-rw-r--r--src/view/com/composer/text-input/hooks/useGrapheme.tsx2
-rw-r--r--src/view/com/composer/text-input/mobile/Autocomplete.tsx57
-rw-r--r--src/view/com/composer/text-input/web/Autocomplete.tsx15
-rw-r--r--src/view/com/composer/threadgate/ThreadgateBtn.tsx1
-rw-r--r--src/view/com/composer/videos/SelectVideoBtn.tsx44
-rw-r--r--src/view/com/composer/videos/SubtitleDialog.tsx2
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx1
-rw-r--r--src/view/com/composer/videos/VideoPreview.web.tsx86
-rw-r--r--src/view/com/composer/videos/VideoTranscodeBackdrop.tsx18
-rw-r--r--src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx4
-rw-r--r--src/view/com/composer/videos/VideoTranscodeProgress.tsx1
-rw-r--r--src/view/com/composer/videos/pickVideo.ts21
-rw-r--r--src/view/com/composer/videos/pickVideo.web.ts94
26 files changed, 323 insertions, 230 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 1899966dc..e4b09cf0f 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -56,12 +56,18 @@ import {useQueryClient} from '@tanstack/react-query'
 import * as apilib from '#/lib/api/index'
 import {EmbeddingDisabledError} from '#/lib/api/resolve'
 import {until} from '#/lib/async/until'
-import {MAX_GRAPHEME_LENGTH} from '#/lib/constants'
+import {
+  MAX_GRAPHEME_LENGTH,
+  SUPPORTED_MIME_TYPES,
+  SupportedMimeTypes,
+} from '#/lib/constants'
 import {useAnimatedScrollHandler} from '#/lib/hooks/useAnimatedScrollHandler_FIXED'
+import {useEmail} from '#/lib/hooks/useEmail'
 import {useIsKeyboardVisible} from '#/lib/hooks/useIsKeyboardVisible'
 import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
 import {usePalette} from '#/lib/hooks/usePalette'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
+import {mimeToExt} from '#/lib/media/video/util'
 import {logEvent} from '#/lib/statsig/statsig'
 import {cleanError} from '#/lib/strings/errors'
 import {colors, s} from '#/lib/styles'
@@ -110,6 +116,8 @@ import * as Toast from '#/view/com/util/Toast'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, native, useTheme} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
+import {useDialogControl} from '#/components/Dialog'
+import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {CircleInfo_Stroke2_Corner0_Rounded as CircleInfo} from '#/components/icons/CircleInfo'
 import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons/Emoji'
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
@@ -127,6 +135,8 @@ import {
   ThreadDraft,
 } from './state/composer'
 import {NO_VIDEO, NoVideoState, processVideo, VideoState} from './state/video'
+import {getVideoMetadata} from './videos/pickVideo'
+import {clearThumbnailCache} from './videos/VideoTranscodeBackdrop'
 
 type CancelRef = {
   onPressCancel: () => void
@@ -246,7 +256,8 @@ export const ComposePost = ({
 
   const onClose = useCallback(() => {
     closeComposer()
-  }, [closeComposer])
+    clearThumbnailCache(queryClient)
+  }, [closeComposer, queryClient])
 
   const insets = useSafeAreaInsets()
   const viewStyles = useMemo(
@@ -297,6 +308,15 @@ export const ComposePost = ({
     }
   }, [onPressCancel, closeAllDialogs, closeAllModals])
 
+  const {needsEmailVerification} = useEmail()
+  const emailVerificationControl = useDialogControl()
+
+  useEffect(() => {
+    if (needsEmailVerification) {
+      emailVerificationControl.open()
+    }
+  }, [needsEmailVerification, emailVerificationControl])
+
   const missingAltError = useMemo(() => {
     if (!requireAltTextEnabled) {
       return
@@ -570,6 +590,15 @@ export const ComposePost = ({
   const isWebFooterSticky = !isNative && thread.posts.length > 1
   return (
     <BottomSheetPortalProvider>
+      <VerifyEmailDialog
+        control={emailVerificationControl}
+        onCloseWithoutVerifying={() => {
+          onClose()
+        }}
+        reasonText={_(
+          msg`Before creating a post, you must first verify your email.`,
+        )}
+      />
       <KeyboardAvoidingView
         testID="composePostView"
         behavior={isIOS ? 'padding' : 'height'}
@@ -723,14 +752,24 @@ let ComposerPost = React.memo(function ComposerPost({
 
   const onPhotoPasted = useCallback(
     async (uri: string) => {
-      if (uri.startsWith('data:video/')) {
-        onSelectVideo(post.id, {uri, type: 'video', height: 0, width: 0})
+      if (uri.startsWith('data:video/') || 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')
+          return
+        }
+        const name = `pasted.${mimeToExt(mimeType)}`
+        const file = await fetch(uri)
+          .then(res => res.blob())
+          .then(blob => new File([blob], name, {type: mimeType}))
+        onSelectVideo(post.id, await getVideoMetadata(file))
       } else {
         const res = await pasteImage(uri)
         onImageAdd([res])
       }
     },
-    [post.id, onSelectVideo, onImageAdd],
+    [post.id, onSelectVideo, onImageAdd, _],
   )
 
   return (
@@ -986,17 +1025,6 @@ function ComposerEmbeds({
                   asset={video.asset}
                   video={video.video}
                   isActivePost={isActivePost}
-                  setDimensions={(width: number, height: number) => {
-                    dispatch({
-                      type: 'embed_update_video',
-                      videoAction: {
-                        type: 'update_dimensions',
-                        width,
-                        height,
-                        signal: video.abortController.signal,
-                      },
-                    })
-                  }}
                   clear={clearVideo}
                 />
               ) : null)}
@@ -1244,12 +1272,12 @@ function useScrollTracker({
   const contentHeight = useSharedValue(0)
 
   const hasScrolledToTop = useDerivedValue(() =>
-    withTiming(contentOffset.value === 0 ? 1 : 0),
+    withTiming(contentOffset.get() === 0 ? 1 : 0),
   )
 
   const hasScrolledToBottom = useDerivedValue(() =>
     withTiming(
-      contentHeight.value - contentOffset.value - 5 <= scrollViewHeight.value
+      contentHeight.get() - contentOffset.get() - 5 <= scrollViewHeight.get()
         ? 1
         : 0,
     ),
@@ -1267,11 +1295,11 @@ function useScrollTracker({
     }) => {
       'worklet'
       if (typeof newContentHeight === 'number')
-        contentHeight.value = Math.floor(newContentHeight)
+        contentHeight.set(Math.floor(newContentHeight))
       if (typeof newContentOffset === 'number')
-        contentOffset.value = Math.floor(newContentOffset)
+        contentOffset.set(Math.floor(newContentOffset))
       if (typeof newScrollViewHeight === 'number')
-        scrollViewHeight.value = Math.floor(newScrollViewHeight)
+        scrollViewHeight.set(Math.floor(newScrollViewHeight))
     },
     [contentHeight, contentOffset, scrollViewHeight],
   )
@@ -1287,21 +1315,22 @@ function useScrollTracker({
     },
   })
 
-  const onScrollViewContentSizeChange = useCallback(
-    (_width: number, height: number) => {
-      if (stickyBottom && height > contentHeight.value) {
+  const onScrollViewContentSizeChangeUIThread = useCallback(
+    (newContentHeight: number) => {
+      'worklet'
+      const oldContentHeight = contentHeight.get()
+      let shouldScrollToBottom = false
+      if (stickyBottom && newContentHeight > oldContentHeight) {
         const isFairlyCloseToBottom =
-          contentHeight.value - contentOffset.value - 100 <=
-          scrollViewHeight.value
+          oldContentHeight - contentOffset.get() - 100 <= scrollViewHeight.get()
         if (isFairlyCloseToBottom) {
-          runOnUI(() => {
-            scrollTo(scrollViewRef, 0, contentHeight.value, true)
-          })()
+          shouldScrollToBottom = true
         }
       }
-      showHideBottomBorder({
-        newContentHeight: height,
-      })
+      showHideBottomBorder({newContentHeight})
+      if (shouldScrollToBottom) {
+        scrollTo(scrollViewRef, 0, newContentHeight, true)
+      }
     },
     [
       showHideBottomBorder,
@@ -1313,6 +1342,13 @@ function useScrollTracker({
     ],
   )
 
+  const onScrollViewContentSizeChange = useCallback(
+    (_width: number, height: number) => {
+      runOnUI(onScrollViewContentSizeChangeUIThread)(height)
+    },
+    [onScrollViewContentSizeChangeUIThread],
+  )
+
   const onScrollViewLayout = useCallback(
     (evt: LayoutChangeEvent) => {
       showHideBottomBorder({
@@ -1326,7 +1362,7 @@ function useScrollTracker({
     return {
       borderBottomWidth: StyleSheet.hairlineWidth,
       borderColor: interpolateColor(
-        hasScrolledToTop.value,
+        hasScrolledToTop.get(),
         [0, 1],
         [t.atoms.border_contrast_medium.borderColor, 'transparent'],
       ),
@@ -1336,7 +1372,7 @@ function useScrollTracker({
     return {
       borderTopWidth: StyleSheet.hairlineWidth,
       borderColor: interpolateColor(
-        hasScrolledToBottom.value,
+        hasScrolledToBottom.get(),
         [0, 1],
         [t.atoms.border_contrast_medium.borderColor, 'transparent'],
       ),
@@ -1581,7 +1617,7 @@ function VideoUploadToolbar({state}: {state: VideoState}) {
 
   const animatedStyle = useAnimatedStyle(() => {
     return {
-      transform: [{rotateZ: `${rotate.value}deg`}],
+      transform: [{rotateZ: `${rotate.get()}deg`}],
     }
   })
 
diff --git a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
index 3ef9dad47..92102f847 100644
--- a/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
+++ b/src/view/com/composer/ExternalEmbedRemoveBtn.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/composer/GifAltText.tsx b/src/view/com/composer/GifAltText.tsx
index ef5f8a3a5..bd99b9f28 100644
--- a/src/view/com/composer/GifAltText.tsx
+++ b/src/view/com/composer/GifAltText.tsx
@@ -1,4 +1,4 @@
-import React, {useState} from 'react'
+import {useState} from 'react'
 import {TouchableOpacity, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/composer/char-progress/CharProgress.tsx b/src/view/com/composer/char-progress/CharProgress.tsx
index c7d9628d6..f2734e4ec 100644
--- a/src/view/com/composer/char-progress/CharProgress.tsx
+++ b/src/view/com/composer/char-progress/CharProgress.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {StyleProp, TextStyle, View, ViewStyle} from 'react-native'
 // @ts-ignore no type definition -prf
 import ProgressCircle from 'react-native-progress/Circle'
diff --git a/src/view/com/composer/labels/LabelsBtn.tsx b/src/view/com/composer/labels/LabelsBtn.tsx
index 75eaa33d7..0718a1928 100644
--- a/src/view/com/composer/labels/LabelsBtn.tsx
+++ b/src/view/com/composer/labels/LabelsBtn.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {Keyboard, View} from 'react-native'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
@@ -158,22 +157,26 @@ function DialogInner({
                   <Toggle.Item name="porn" label={_(msg`Porn`)}>
                     <Toggle.Checkbox />
                     <Toggle.LabelText>
-                      <Trans>Porn</Trans>
+                      <Trans>Adult</Trans>
                     </Toggle.LabelText>
                   </Toggle.Item>
                 </View>
               </Toggle.Group>
-              <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
-                {labels.includes('sexual') ? (
-                  <Trans>Pictures meant for adults.</Trans>
-                ) : labels.includes('nudity') ? (
-                  <Trans>Artistic or non-erotic nudity.</Trans>
-                ) : labels.includes('porn') ? (
-                  <Trans>Sexual activity or erotic nudity.</Trans>
-                ) : (
-                  <Trans>Does not contain adult content.</Trans>
-                )}
-              </Text>
+              {labels.includes('sexual') ||
+              labels.includes('nudity') ||
+              labels.includes('porn') ? (
+                <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
+                  {labels.includes('sexual') ? (
+                    <Trans>Pictures meant for adults.</Trans>
+                  ) : labels.includes('nudity') ? (
+                    <Trans>Artistic or non-erotic nudity.</Trans>
+                  ) : labels.includes('porn') ? (
+                    <Trans>Sexual activity or erotic nudity.</Trans>
+                  ) : (
+                    ''
+                  )}
+                </Text>
+              ) : null}
             </View>
           </View>
           <View>
@@ -203,16 +206,14 @@ function DialogInner({
                   </Toggle.LabelText>
                 </Toggle.Item>
               </Toggle.Group>
-              <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
-                {labels.includes('graphic-media') ? (
+              {labels.includes('graphic-media') ? (
+                <Text style={[a.mt_sm, t.atoms.text_contrast_medium]}>
                   <Trans>
                     Media that may be disturbing or inappropriate for some
                     audiences.
                   </Trans>
-                ) : (
-                  <Trans>Does not contain graphic or disturbing content.</Trans>
-                )}
-              </Text>
+                </Text>
+              ) : null}
             </View>
           </View>
         </View>
diff --git a/src/view/com/composer/photos/OpenCameraBtn.tsx b/src/view/com/composer/photos/OpenCameraBtn.tsx
index 79d59a92d..fb3ab5c8f 100644
--- a/src/view/com/composer/photos/OpenCameraBtn.tsx
+++ b/src/view/com/composer/photos/OpenCameraBtn.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback} from 'react'
+import {useCallback} from 'react'
 import * as MediaLibrary from 'expo-media-library'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/composer/photos/SelectGifBtn.tsx b/src/view/com/composer/photos/SelectGifBtn.tsx
index 74f9acdc6..3fb0e00d2 100644
--- a/src/view/com/composer/photos/SelectGifBtn.tsx
+++ b/src/view/com/composer/photos/SelectGifBtn.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useRef} from 'react'
+import {useCallback, useRef} from 'react'
 import {Keyboard} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
index 37bfbafe6..f4c6aa328 100644
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ b/src/view/com/composer/photos/SelectPhotoBtn.tsx
@@ -1,5 +1,5 @@
 /* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
-import React, {useCallback} from 'react'
+import {useCallback} from 'react'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
diff --git a/src/view/com/composer/select-language/SelectLangBtn.tsx b/src/view/com/composer/select-language/SelectLangBtn.tsx
index 94dbc35c6..cd3cb608d 100644
--- a/src/view/com/composer/select-language/SelectLangBtn.tsx
+++ b/src/view/com/composer/select-language/SelectLangBtn.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useMemo} from 'react'
+import {useCallback, useMemo} from 'react'
 import {Keyboard, StyleSheet} from 'react-native'
 import {
   FontAwesomeIcon,
diff --git a/src/view/com/composer/select-language/SuggestedLanguage.tsx b/src/view/com/composer/select-language/SuggestedLanguage.tsx
index e915f4c66..6d55aeb53 100644
--- a/src/view/com/composer/select-language/SuggestedLanguage.tsx
+++ b/src/view/com/composer/select-language/SuggestedLanguage.tsx
@@ -1,4 +1,4 @@
-import React, {useEffect, useState} from 'react'
+import {useEffect, useState} from 'react'
 import {StyleSheet, View} from 'react-native'
 import {
   FontAwesomeIcon,
diff --git a/src/view/com/composer/state/video.ts b/src/view/com/composer/state/video.ts
index 8814a7e61..7ce4a0cf8 100644
--- a/src/view/com/composer/state/video.ts
+++ b/src/view/com/composer/state/video.ts
@@ -37,12 +37,6 @@ export type VideoAction =
     }
   | {type: 'update_progress'; progress: number; signal: AbortSignal}
   | {
-      type: 'update_dimensions'
-      width: number
-      height: number
-      signal: AbortSignal
-    }
-  | {
       type: 'update_alt_text'
       altText: string
       signal: AbortSignal
@@ -185,13 +179,6 @@ export function videoReducer(
         progress: action.progress,
       }
     }
-  } else if (action.type === 'update_dimensions') {
-    if (state.asset) {
-      return {
-        ...state,
-        asset: {...state.asset, width: action.width, height: action.height},
-      }
-    }
   } else if (action.type === 'update_alt_text') {
     return {
       ...state,
diff --git a/src/view/com/composer/text-input/TextInput.tsx b/src/view/com/composer/text-input/TextInput.tsx
index 10cf1a931..96cecb37c 100644
--- a/src/view/com/composer/text-input/TextInput.tsx
+++ b/src/view/com/composer/text-input/TextInput.tsx
@@ -31,7 +31,7 @@ import {
   suggestLinkCardUri,
 } from '#/view/com/composer/text-input/text-input-util'
 import {atoms as a, useAlf} from '#/alf'
-import {normalizeTextStyles} from '#/components/Typography'
+import {normalizeTextStyles} from '#/alf/typography'
 import {Autocomplete} from './mobile/Autocomplete'
 
 export interface TextInputRef {
diff --git a/src/view/com/composer/text-input/TextInput.web.tsx b/src/view/com/composer/text-input/TextInput.web.tsx
index fa742d258..8ec4fefa8 100644
--- a/src/view/com/composer/text-input/TextInput.web.tsx
+++ b/src/view/com/composer/text-input/TextInput.web.tsx
@@ -11,6 +11,7 @@ import {Paragraph} from '@tiptap/extension-paragraph'
 import {Placeholder} from '@tiptap/extension-placeholder'
 import {Text as TiptapText} from '@tiptap/extension-text'
 import {generateJSON} from '@tiptap/html'
+import {Fragment, Node, Slice} from '@tiptap/pm/model'
 import {EditorContent, JSONContent, useEditor} from '@tiptap/react'
 
 import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
@@ -23,8 +24,8 @@ import {
 } from '#/view/com/composer/text-input/text-input-util'
 import {textInputWebEmitter} from '#/view/com/composer/text-input/textInputWebEmitter'
 import {atoms as a, useAlf} from '#/alf'
+import {normalizeTextStyles} from '#/alf/typography'
 import {Portal} from '#/components/Portal'
-import {normalizeTextStyles} from '#/components/Typography'
 import {Text} from '../../util/text/Text'
 import {createSuggestion} from './web/Autocomplete'
 import {Emoji} from './web/EmojiPicker.web'
@@ -166,6 +167,11 @@ export const TextInput = React.forwardRef(function TextInputImpl(
   const editor = useEditor(
     {
       extensions,
+      coreExtensionOptions: {
+        clipboardTextSerializer: {
+          blockSeparator: '\n',
+        },
+      },
       onFocus() {
         onFocus?.()
       },
@@ -173,6 +179,20 @@ export const TextInput = React.forwardRef(function TextInputImpl(
         attributes: {
           class: modeClass,
         },
+        clipboardTextParser: (text, context) => {
+          const blocks = text.split(/(?:\r\n?|\n)/)
+          const nodes: Node[] = blocks.map(line => {
+            return Node.fromJSON(
+              context.doc.type.schema,
+              line.length > 0
+                ? {type: 'paragraph', content: [{type: 'text', text: line}]}
+                : {type: 'paragraph', content: []},
+            )
+          })
+
+          const fragment = Fragment.fromArray(nodes)
+          return Slice.maxOpen(fragment)
+        },
         handlePaste: (view, event) => {
           const clipboardData = event.clipboardData
           let preventDefault = false
@@ -205,6 +225,7 @@ export const TextInput = React.forwardRef(function TextInputImpl(
       autofocus: 'end',
       editable: true,
       injectCSS: true,
+      shouldRerenderOnTransaction: false,
       onCreate({editor: editorProp}) {
         // HACK
         // the 'enter' animation sometimes causes autofocus to fail
@@ -297,15 +318,9 @@ export const TextInput = React.forwardRef(function TextInputImpl(
     style.lineHeight = style.lineHeight
       ? ((style.lineHeight + 'px') as unknown as number)
       : undefined
+    style.minHeight = webForceMinHeight ? 140 : undefined
     return style
-  }, [t, fonts])
-
-  React.useLayoutEffect(() => {
-    let node = editor?.view.dom
-    if (node) {
-      node.style.minHeight = webForceMinHeight ? '140px' : ''
-    }
-  }, [editor, webForceMinHeight])
+  }, [t, fonts, webForceMinHeight])
 
   return (
     <>
diff --git a/src/view/com/composer/text-input/hooks/useGrapheme.tsx b/src/view/com/composer/text-input/hooks/useGrapheme.tsx
index 01b5b9698..aa375ff47 100644
--- a/src/view/com/composer/text-input/hooks/useGrapheme.tsx
+++ b/src/view/com/composer/text-input/hooks/useGrapheme.tsx
@@ -13,7 +13,7 @@ export const useGrapheme = () => {
 
         if (graphemes.length > length) {
           remainingCharacters = 0
-          name = `${graphemes.slice(0, length).join('')}...`
+          name = `${graphemes.slice(0, length).join('')}…`
         } else {
           remainingCharacters = length - graphemes.length
           name = graphemes.join('')
diff --git a/src/view/com/composer/text-input/mobile/Autocomplete.tsx b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
index 3d2bcfa61..0fda6843b 100644
--- a/src/view/com/composer/text-input/mobile/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/mobile/Autocomplete.tsx
@@ -1,7 +1,5 @@
-import React, {useRef} from 'react'
 import {View} from 'react-native'
 import Animated, {FadeInDown, FadeOut} from 'react-native-reanimated'
-import {AppBskyActorDefs} from '@atproto/api'
 import {Trans} from '@lingui/macro'
 
 import {PressableScale} from '#/lib/custom-animations/PressableScale'
@@ -11,7 +9,6 @@ import {useActorAutocompleteQuery} from '#/state/queries/actor-autocomplete'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, useTheme} from '#/alf'
 import {Text} from '#/components/Typography'
-import {useGrapheme} from '../hooks/useGrapheme'
 
 export function Autocomplete({
   prefix,
@@ -22,15 +19,11 @@ export function Autocomplete({
 }) {
   const t = useTheme()
 
-  const {getGraphemeString} = useGrapheme()
   const isActive = !!prefix
-  const {data: suggestions, isFetching} = useActorAutocompleteQuery(prefix)
-  const suggestionsRef = useRef<
-    AppBskyActorDefs.ProfileViewBasic[] | undefined
-  >(undefined)
-  if (suggestions) {
-    suggestionsRef.current = suggestions
-  }
+  const {data: suggestions, isFetching} = useActorAutocompleteQuery(
+    prefix,
+    true,
+  )
 
   if (!isActive) return null
 
@@ -46,26 +39,8 @@ export function Autocomplete({
         t.atoms.border_contrast_high,
         {marginLeft: -62},
       ]}>
-      {suggestionsRef.current?.length ? (
-        suggestionsRef.current.slice(0, 5).map((item, index, arr) => {
-          // Eventually use an average length
-          const MAX_CHARS = 40
-          const MAX_HANDLE_CHARS = 20
-
-          // Using this approach because styling is not respecting
-          // bounding box wrapping (before converting to ellipsis)
-          const {name: displayHandle, remainingCharacters} = getGraphemeString(
-            item.handle,
-            MAX_HANDLE_CHARS,
-          )
-
-          const {name: displayName} = getGraphemeString(
-            item.displayName || item.handle,
-            MAX_CHARS -
-              MAX_HANDLE_CHARS +
-              (remainingCharacters > 0 ? remainingCharacters : 0),
-          )
-
+      {suggestions?.length ? (
+        suggestions.slice(0, 5).map((item, index, arr) => {
           return (
             <View
               style={[
@@ -93,15 +68,23 @@ export function Autocomplete({
                     type={item.associated?.labeler ? 'labeler' : 'user'}
                   />
                   <Text
-                    style={[a.text_md, a.font_bold]}
-                    emoji={true}
+                    style={[a.flex_1, a.text_md, a.font_bold]}
+                    emoji
+                    numberOfLines={1}>
+                    {sanitizeDisplayName(
+                      item.displayName || sanitizeHandle(item.handle),
+                    )}
+                  </Text>
+                  <Text
+                    style={[
+                      t.atoms.text_contrast_medium,
+                      a.text_right,
+                      {maxWidth: '50%'},
+                    ]}
                     numberOfLines={1}>
-                    {sanitizeDisplayName(displayName)}
+                    {sanitizeHandle(item.handle, '@')}
                   </Text>
                 </View>
-                <Text style={[t.atoms.text_contrast_medium]} numberOfLines={1}>
-                  {sanitizeHandle(displayHandle, '@')}
-                </Text>
               </PressableScale>
             </View>
           )
diff --git a/src/view/com/composer/text-input/web/Autocomplete.tsx b/src/view/com/composer/text-input/web/Autocomplete.tsx
index a43e67c04..f40c2ee8d 100644
--- a/src/view/com/composer/text-input/web/Autocomplete.tsx
+++ b/src/view/com/composer/text-input/web/Autocomplete.tsx
@@ -1,9 +1,4 @@
-import React, {
-  forwardRef,
-  useEffect,
-  useImperativeHandle,
-  useState,
-} from 'react'
+import {forwardRef, useEffect, useImperativeHandle, useState} from 'react'
 import {Pressable, StyleSheet, View} from 'react-native'
 import {Trans} from '@lingui/macro'
 import {ReactRenderer} from '@tiptap/react'
@@ -15,6 +10,8 @@ import {
 import tippy, {Instance as TippyInstance} from 'tippy.js'
 
 import {usePalette} from '#/lib/hooks/usePalette'
+import {sanitizeDisplayName} from '#/lib/strings/display-names'
+import {sanitizeHandle} from '#/lib/strings/handles'
 import {ActorAutocompleteFn} from '#/state/queries/actor-autocomplete'
 import {Text} from '#/view/com/util/text/Text'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
@@ -153,7 +150,9 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
           {items.length > 0 ? (
             items.map((item, index) => {
               const {name: displayName} = getGraphemeString(
-                item.displayName ?? item.handle,
+                sanitizeDisplayName(
+                  item.displayName || sanitizeHandle(item.handle),
+                ),
                 30, // Heuristic value; can be modified
               )
               const isSelected = selectedIndex === index
@@ -186,7 +185,7 @@ const MentionList = forwardRef<MentionListRef, SuggestionProps>(
                     </Text>
                   </View>
                   <Text type="xs" style={pal.textLight} numberOfLines={1}>
-                    @{item.handle}
+                    {sanitizeHandle(item.handle, '@')}
                   </Text>
                 </Pressable>
               )
diff --git a/src/view/com/composer/threadgate/ThreadgateBtn.tsx b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
index 78bf8c06f..4130cc7e4 100644
--- a/src/view/com/composer/threadgate/ThreadgateBtn.tsx
+++ b/src/view/com/composer/threadgate/ThreadgateBtn.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {Keyboard, StyleProp, ViewStyle} from 'react-native'
 import {AnimatedStyle} from 'react-native-reanimated'
 import {AppBskyFeedPostgate} from '@atproto/api'
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
index 2ba003a6d..1b052ccdd 100644
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ b/src/view/com/composer/videos/SelectVideoBtn.tsx
@@ -1,11 +1,6 @@
-import React, {useCallback} from 'react'
+import {useCallback} from 'react'
 import {Keyboard} from 'react-native'
-import {
-  ImagePickerAsset,
-  launchImageLibraryAsync,
-  MediaTypeOptions,
-  UIImagePickerPreferredAssetRepresentationMode,
-} from 'expo-image-picker'
+import {ImagePickerAsset} from 'expo-image-picker'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
@@ -22,6 +17,7 @@ import {useDialogControl} from '#/components/Dialog'
 import {VerifyEmailDialog} from '#/components/dialogs/VerifyEmailDialog'
 import {VideoClip_Stroke2_Corner0_Rounded as VideoClipIcon} from '#/components/icons/VideoClip'
 import * as Prompt from '#/components/Prompt'
+import {pickVideo} from './pickVideo'
 
 const VIDEO_MAX_DURATION = 60 * 1000 // 60s in milliseconds
 
@@ -52,24 +48,22 @@ export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
       Keyboard.dismiss()
       control.open()
     } else {
-      const response = await launchImageLibraryAsync({
-        exif: false,
-        mediaTypes: MediaTypeOptions.Videos,
-        quality: 1,
-        legacy: true,
-        preferredAssetRepresentationMode:
-          UIImagePickerPreferredAssetRepresentationMode.Current,
-      })
+      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) {
+              throw Error(_(msg`Videos must be less than 60 seconds long`))
+            }
             // compression step on native converts to mp4, so no need to check there
-            const mimeType = getMimeType(asset)
             if (
-              !SUPPORTED_MIME_TYPES.includes(mimeType as SupportedMimeTypes)
+              !SUPPORTED_MIME_TYPES.includes(
+                asset.mimeType as SupportedMimeTypes,
+              )
             ) {
-              throw Error(_(msg`Unsupported video type: ${mimeType}`))
+              throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
             }
           } else {
             if (typeof asset.duration !== 'number') {
@@ -142,17 +136,3 @@ function VerifyEmailPrompt({control}: {control: Prompt.PromptControlProps}) {
     </>
   )
 }
-
-function getMimeType(asset: ImagePickerAsset) {
-  if (isWeb) {
-    const [mimeType] = asset.uri.slice('data:'.length).split(';base64,')
-    if (!mimeType) {
-      throw new Error('Could not determine mime type')
-    }
-    return mimeType
-  }
-  if (!asset.mimeType) {
-    throw new Error('Could not determine mime type')
-  }
-  return asset.mimeType
-}
diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx
index 27c3de02b..e907dc41c 100644
--- a/src/view/com/composer/videos/SubtitleDialog.tsx
+++ b/src/view/com/composer/videos/SubtitleDialog.tsx
@@ -1,4 +1,4 @@
-import React, {useCallback, useState} from 'react'
+import {useCallback, useState} from 'react'
 import {Keyboard, StyleProp, View, ViewStyle} from 'react-native'
 import RNPickerSelect from 'react-native-picker-select'
 import {msg, Trans} from '@lingui/macro'
diff --git a/src/view/com/composer/videos/VideoPreview.tsx b/src/view/com/composer/videos/VideoPreview.tsx
index fff7545a5..255174bea 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -20,7 +20,6 @@ export function VideoPreview({
   asset: ImagePickerAsset
   video: CompressedVideo
   isActivePost: boolean
-  setDimensions: (width: number, height: number) => void
   clear: () => void
 }) {
   const t = useTheme()
diff --git a/src/view/com/composer/videos/VideoPreview.web.tsx b/src/view/com/composer/videos/VideoPreview.web.tsx
index ccb3391c2..f20f8b383 100644
--- a/src/view/com/composer/videos/VideoPreview.web.tsx
+++ b/src/view/com/composer/videos/VideoPreview.web.tsx
@@ -1,4 +1,3 @@
-import React, {useEffect, useRef} from 'react'
 import {View} from 'react-native'
 import {ImagePickerAsset} from 'expo-image-picker'
 import {msg} from '@lingui/macro'
@@ -12,58 +11,22 @@ import * as Toast from '#/view/com/util/Toast'
 import {atoms as a} from '#/alf'
 import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
 
-const MAX_DURATION = 60
-
 export function VideoPreview({
   asset,
   video,
-  setDimensions,
+
   clear,
 }: {
   asset: ImagePickerAsset
   video: CompressedVideo
-  setDimensions: (width: number, height: number) => void
+
   clear: () => void
 }) {
-  const ref = useRef<HTMLVideoElement>(null)
   const {_} = useLingui()
+  // TODO: figure out how to pause a GIF for reduced motion
+  // it's not possible using an img tag -sfn
   const autoplayDisabled = useAutoplayDisabled()
 
-  useEffect(() => {
-    if (!ref.current) return
-
-    const abortController = new AbortController()
-    const {signal} = abortController
-    ref.current.addEventListener(
-      'loadedmetadata',
-      function () {
-        setDimensions(this.videoWidth, this.videoHeight)
-        if (!isNaN(this.duration)) {
-          if (this.duration > MAX_DURATION) {
-            Toast.show(
-              _(msg`Videos must be less than 60 seconds long`),
-              'xmark',
-            )
-            clear()
-          }
-        }
-      },
-      {signal},
-    )
-    ref.current.addEventListener(
-      'error',
-      () => {
-        Toast.show(_(msg`Could not process your video`), 'xmark')
-        clear()
-      },
-      {signal},
-    )
-
-    return () => {
-      abortController.abort()
-    }
-  }, [setDimensions, _, clear])
-
   let aspectRatio = asset.width / asset.height
 
   if (isNaN(aspectRatio)) {
@@ -83,19 +46,34 @@ export function VideoPreview({
         a.relative,
       ]}>
       <ExternalEmbedRemoveBtn onRemove={clear} />
-      <video
-        ref={ref}
-        src={video.uri}
-        style={{width: '100%', height: '100%', objectFit: 'cover'}}
-        autoPlay={!autoplayDisabled}
-        loop
-        muted
-        playsInline
-      />
-      {autoplayDisabled && (
-        <View style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
-          <PlayButtonIcon />
-        </View>
+      {video.mimeType === 'image/gif' ? (
+        <img
+          src={video.uri}
+          style={{width: '100%', height: '100%', objectFit: 'cover'}}
+          alt="GIF"
+        />
+      ) : (
+        <>
+          <video
+            src={video.uri}
+            style={{width: '100%', height: '100%', objectFit: 'cover'}}
+            autoPlay={!autoplayDisabled}
+            loop
+            muted
+            playsInline
+            onError={err => {
+              console.error('Error loading video', err)
+              Toast.show(_(msg`Could not process your video`), 'xmark')
+              clear()
+            }}
+          />
+          {autoplayDisabled && (
+            <View
+              style={[a.absolute, a.inset_0, a.justify_center, a.align_center]}>
+              <PlayButtonIcon />
+            </View>
+          )}
+        </>
       )}
     </View>
   )
diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
index ef38e62af..caf0b38e2 100644
--- a/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.tsx
@@ -1,25 +1,25 @@
-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 {QueryClient, useQuery} from '@tanstack/react-query'
 
 import {atoms as a} from '#/alf'
 
+export const RQKEY = 'video-thumbnail'
+
+export function clearThumbnailCache(queryClient: QueryClient) {
+  clearCache()
+  queryClient.resetQueries({queryKey: [RQKEY]})
+}
+
 export function VideoTranscodeBackdrop({uri}: {uri: string}) {
   const {data: thumbnail} = useQuery({
-    queryKey: ['thumbnail', uri],
+    queryKey: [RQKEY, uri],
     queryFn: async () => {
       return await createVideoThumbnail(uri)
     },
   })
 
-  useEffect(() => {
-    return () => {
-      clearCache()
-    }
-  }, [])
-
   return (
     thumbnail && (
       <Animated.View style={a.flex_1} entering={FadeIn}>
diff --git a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx
index d4090d853..a04200f53 100644
--- a/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeBackdrop.web.tsx
@@ -1,3 +1,7 @@
+export function clearThumbnailCache() {
+  // no-op
+}
+
 export function VideoTranscodeBackdrop() {
   return null
 }
diff --git a/src/view/com/composer/videos/VideoTranscodeProgress.tsx b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
index f6f0f7ccf..f408be720 100644
--- a/src/view/com/composer/videos/VideoTranscodeProgress.tsx
+++ b/src/view/com/composer/videos/VideoTranscodeProgress.tsx
@@ -1,4 +1,3 @@
-import React from 'react'
 import {View} from 'react-native'
 // @ts-expect-error no type definition
 import ProgressPie from 'react-native-progress/Pie'
diff --git a/src/view/com/composer/videos/pickVideo.ts b/src/view/com/composer/videos/pickVideo.ts
new file mode 100644
index 000000000..0edf7d0de
--- /dev/null
+++ b/src/view/com/composer/videos/pickVideo.ts
@@ -0,0 +1,21 @@
+import {
+  ImagePickerAsset,
+  launchImageLibraryAsync,
+  MediaTypeOptions,
+  UIImagePickerPreferredAssetRepresentationMode,
+} from 'expo-image-picker'
+
+export async function pickVideo() {
+  return await launchImageLibraryAsync({
+    exif: false,
+    mediaTypes: MediaTypeOptions.Videos,
+    quality: 1,
+    legacy: true,
+    preferredAssetRepresentationMode:
+      UIImagePickerPreferredAssetRepresentationMode.Current,
+  })
+}
+
+export const getVideoMetadata = (_file: File): Promise<ImagePickerAsset> => {
+  throw new Error('getVideoMetadata is web only')
+}
diff --git a/src/view/com/composer/videos/pickVideo.web.ts b/src/view/com/composer/videos/pickVideo.web.ts
new file mode 100644
index 000000000..56a38fa56
--- /dev/null
+++ b/src/view/com/composer/videos/pickVideo.web.ts
@@ -0,0 +1,94 @@
+import {ImagePickerAsset, ImagePickerResult} from 'expo-image-picker'
+
+import {SUPPORTED_MIME_TYPES} from '#/lib/constants'
+
+// mostly copied from expo-image-picker and adapted to support gifs
+// also adds support for reading video metadata
+
+export async function pickVideo(): Promise<ImagePickerResult> {
+  const input = document.createElement('input')
+  input.style.display = 'none'
+  input.setAttribute('type', 'file')
+  // TODO: do we need video/* here? -sfn
+  input.setAttribute('accept', SUPPORTED_MIME_TYPES.join(','))
+  input.setAttribute('id', String(Math.random()))
+
+  document.body.appendChild(input)
+
+  return new Promise(resolve => {
+    input.addEventListener('change', async () => {
+      if (input.files) {
+        const file = input.files[0]
+        resolve({
+          canceled: false,
+          assets: [await getVideoMetadata(file)],
+        })
+      } else {
+        resolve({canceled: true, assets: null})
+      }
+      document.body.removeChild(input)
+    })
+
+    const event = new MouseEvent('click')
+    input.dispatchEvent(event)
+  })
+}
+
+// TODO: we're converting to a dataUrl here, and then converting back to an
+// ArrayBuffer in the compressVideo function. This is a bit wasteful, but it
+// lets us use the ImagePickerAsset type, which the rest of the code expects.
+// We should unwind this and just pass the ArrayBuffer/objectUrl through the system
+// instead of a string -sfn
+export const getVideoMetadata = (file: File): Promise<ImagePickerAsset> => {
+  return new Promise((resolve, reject) => {
+    const reader = new FileReader()
+    reader.onload = () => {
+      const uri = reader.result as string
+
+      if (file.type === 'image/gif') {
+        const img = new Image()
+        img.onload = () => {
+          resolve({
+            uri,
+            mimeType: 'image/gif',
+            width: img.width,
+            height: img.height,
+            // todo: calculate gif duration. seems possible if you read the bytes
+            // https://codepen.io/Ryman/pen/nZpYwY
+            // for now let's just let the server reject it, since that seems uncommon -sfn
+            duration: null,
+          })
+        }
+        img.onerror = (_ev, _source, _lineno, _colno, error) => {
+          console.log('Failed to grab GIF metadata', error)
+          reject(new Error('Failed to grab GIF metadata'))
+        }
+        img.src = uri
+      } else {
+        const video = document.createElement('video')
+        const blobUrl = URL.createObjectURL(file)
+
+        video.preload = 'metadata'
+        video.src = blobUrl
+
+        video.onloadedmetadata = () => {
+          URL.revokeObjectURL(blobUrl)
+          resolve({
+            uri,
+            mimeType: file.type,
+            width: video.videoWidth,
+            height: video.videoHeight,
+            // convert seconds to ms
+            duration: video.duration * 1000,
+          })
+        }
+        video.onerror = (_ev, _source, _lineno, _colno, error) => {
+          URL.revokeObjectURL(blobUrl)
+          console.log('Failed to grab video metadata', error)
+          reject(new Error('Failed to grab video metadata'))
+        }
+      }
+    }
+    reader.readAsDataURL(file)
+  })
+}