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.tsx111
-rw-r--r--src/view/com/composer/SelectMediaButton.tsx524
-rw-r--r--src/view/com/composer/photos/ImageAltTextDialog.tsx5
-rw-r--r--src/view/com/composer/photos/SelectPhotoBtn.tsx60
-rw-r--r--src/view/com/composer/videos/SelectVideoBtn.tsx88
-rw-r--r--src/view/com/composer/videos/VideoPreview.tsx31
-rw-r--r--src/view/com/lightbox/Lightbox.web.tsx1
-rw-r--r--src/view/com/post-thread/PostThreadFollowBtn.tsx4
-rw-r--r--src/view/com/profile/FollowButton.tsx10
-rw-r--r--src/view/screens/Log.tsx116
-rw-r--r--src/view/shell/index.tsx18
-rw-r--r--src/view/shell/index.web.tsx27
12 files changed, 676 insertions, 319 deletions
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 296545353..d0dbdfaba 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -77,7 +77,11 @@ import {logger} from '#/logger'
 import {isAndroid, isIOS, isNative, isWeb} from '#/platform/detection'
 import {useDialogStateControlContext} from '#/state/dialogs'
 import {emitPostCreated} from '#/state/events'
-import {type ComposerImage, pasteImage} from '#/state/gallery'
+import {
+  type ComposerImage,
+  createComposerImage,
+  pasteImage,
+} from '#/state/gallery'
 import {useModalControls} from '#/state/modals'
 import {useRequireAltTextEnabled} from '#/state/preferences'
 import {
@@ -103,7 +107,6 @@ import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
 import {Gallery} from '#/view/com/composer/photos/Gallery'
 import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
 import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
-import {SelectPhotoBtn} from '#/view/com/composer/photos/SelectPhotoBtn'
 import {SelectLangBtn} from '#/view/com/composer/select-language/SelectLangBtn'
 import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
 // TODO: Prevent naming components that coincide with RN primitives
@@ -113,12 +116,10 @@ import {
   type TextInputRef,
 } from '#/view/com/composer/text-input/TextInput'
 import {ThreadgateBtn} from '#/view/com/composer/threadgate/ThreadgateBtn'
-import {SelectVideoBtn} from '#/view/com/composer/videos/SelectVideoBtn'
 import {SubtitleDialogBtn} from '#/view/com/composer/videos/SubtitleDialog'
 import {VideoPreview} from '#/view/com/composer/videos/VideoPreview'
 import {VideoTranscodeProgress} from '#/view/com/composer/videos/VideoTranscodeProgress'
 import {Text} from '#/view/com/util/text/Text'
-import * as Toast from '#/view/com/util/Toast'
 import {UserAvatar} from '#/view/com/util/UserAvatar'
 import {atoms as a, native, useTheme, web} from '#/alf'
 import {Button, ButtonIcon, ButtonText} from '#/components/Button'
@@ -127,9 +128,15 @@ import {EmojiArc_Stroke2_Corner0_Rounded as EmojiSmile} from '#/components/icons
 import {TimesLarge_Stroke2_Corner0_Rounded as X} from '#/components/icons/Times'
 import {LazyQuoteEmbed} from '#/components/Post/Embed/LazyQuoteEmbed'
 import * as Prompt from '#/components/Prompt'
+import * as toast from '#/components/Toast'
 import {Text as NewText} from '#/components/Typography'
 import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
 import {
+  type AssetType,
+  SelectMediaButton,
+  type SelectMediaButtonProps,
+} from './SelectMediaButton'
+import {
   type ComposerAction,
   composerReducer,
   createComposerState,
@@ -514,12 +521,13 @@ export const ComposePost = ({
       onPostSuccess?.(postSuccessData)
     }
     onClose()
-    Toast.show(
+    toast.show(
       thread.posts.length > 1
         ? _(msg`Your posts have been published`)
         : replyTo
           ? _(msg`Your reply has been published`)
           : _(msg`Your post has been published`),
+      {type: 'success'},
     )
   }, [
     _,
@@ -811,11 +819,16 @@ let ComposerPost = React.memo(function ComposerPost({
 
   const onPhotoPasted = useCallback(
     async (uri: string) => {
-      if (uri.startsWith('data:video/') || uri.startsWith('data:image/gif')) {
+      if (
+        uri.startsWith('data:video/') ||
+        (isWeb && 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')
+          toast.show(_(msg`Unsupported video type: ${mimeType}`), {
+            type: 'error',
+          })
           return
         }
         const name = `pasted.${mimeToExt(mimeType)}`
@@ -1251,7 +1264,6 @@ function ComposerFooter({
   dispatch,
   showAddButton,
   onEmojiButtonPress,
-  onError,
   onSelectVideo,
   onAddPost,
 }: {
@@ -1266,11 +1278,32 @@ function ComposerFooter({
   const t = useTheme()
   const {_} = useLingui()
   const {isMobile} = useWebMediaQueries()
+  /*
+   * Once we've allowed a certain type of asset to be selected, we don't allow
+   * other types of media to be selected.
+   */
+  const [selectedAssetsType, setSelectedAssetsType] = useState<
+    AssetType | undefined
+  >(undefined)
 
   const media = post.embed.media
   const images = media?.type === 'images' ? media.images : []
   const video = media?.type === 'video' ? media.video : null
   const isMaxImages = images.length >= MAX_IMAGES
+  const isMaxVideos = !!video
+
+  let selectedAssetsCount = 0
+  let isMediaSelectionDisabled = false
+
+  if (media?.type === 'images') {
+    isMediaSelectionDisabled = isMaxImages
+    selectedAssetsCount = images.length
+  } else if (media?.type === 'video') {
+    isMediaSelectionDisabled = isMaxVideos
+    selectedAssetsCount = 1
+  } else {
+    isMediaSelectionDisabled = !!media
+  }
 
   const onImageAdd = useCallback(
     (next: ComposerImage[]) => {
@@ -1289,6 +1322,54 @@ function ComposerFooter({
     [dispatch],
   )
 
+  /*
+   * Reset if the user clears any selected media
+   */
+  if (selectedAssetsType !== undefined && !media) {
+    setSelectedAssetsType(undefined)
+  }
+
+  const onSelectAssets = useCallback<SelectMediaButtonProps['onSelectAssets']>(
+    async ({type, assets, errors}) => {
+      setSelectedAssetsType(type)
+
+      if (assets.length) {
+        if (type === 'image') {
+          const images: ComposerImage[] = []
+
+          await Promise.all(
+            assets.map(async image => {
+              const composerImage = await createComposerImage({
+                path: image.uri,
+                width: image.width,
+                height: image.height,
+                mime: image.mimeType!,
+              })
+              images.push(composerImage)
+            }),
+          ).catch(e => {
+            logger.error(`createComposerImage failed`, {
+              safeMessage: e.message,
+            })
+          })
+
+          onImageAdd(images)
+        } else if (type === 'video') {
+          onSelectVideo(post.id, assets[0])
+        } else if (type === 'gif') {
+          onSelectVideo(post.id, assets[0])
+        }
+      }
+
+      errors.map(error => {
+        toast.show(error, {
+          type: 'warning',
+        })
+      })
+    },
+    [post.id, onSelectVideo, onImageAdd],
+  )
+
   return (
     <View
       style={[
@@ -1307,15 +1388,11 @@ function ComposerFooter({
             <VideoUploadToolbar state={video} />
           ) : (
             <ToolbarWrapper style={[a.flex_row, a.align_center, a.gap_xs]}>
-              <SelectPhotoBtn
-                size={images.length}
-                disabled={media?.type === 'images' ? isMaxImages : !!media}
-                onAdd={onImageAdd}
-              />
-              <SelectVideoBtn
-                onSelectVideo={asset => onSelectVideo(post.id, asset)}
-                disabled={!!media}
-                setError={onError}
+              <SelectMediaButton
+                disabled={isMediaSelectionDisabled}
+                allowedAssetTypes={selectedAssetsType}
+                selectedAssetsCount={selectedAssetsCount}
+                onSelectAssets={onSelectAssets}
               />
               <OpenCameraBtn
                 disabled={media?.type === 'images' ? isMaxImages : !!media}
diff --git a/src/view/com/composer/SelectMediaButton.tsx b/src/view/com/composer/SelectMediaButton.tsx
new file mode 100644
index 000000000..026d0ac19
--- /dev/null
+++ b/src/view/com/composer/SelectMediaButton.tsx
@@ -0,0 +1,524 @@
+import {useCallback} from 'react'
+import {Keyboard} from 'react-native'
+import {
+  type ImagePickerAsset,
+  launchImageLibraryAsync,
+  UIImagePickerPreferredAssetRepresentationMode,
+} from 'expo-image-picker'
+import {msg, plural} from '@lingui/macro'
+import {useLingui} from '@lingui/react'
+
+import {VIDEO_MAX_DURATION_MS, VIDEO_MAX_SIZE} from '#/lib/constants'
+import {
+  usePhotoLibraryPermission,
+  useVideoLibraryPermission,
+} from '#/lib/hooks/usePermissions'
+import {extractDataUriMime} from '#/lib/media/util'
+import {isIOS, isNative, isWeb} from '#/platform/detection'
+import {MAX_IMAGES} from '#/view/com/composer/state/composer'
+import {atoms as a, useTheme} from '#/alf'
+import {Button} from '#/components/Button'
+import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
+import {Image_Stroke2_Corner0_Rounded as ImageIcon} from '#/components/icons/Image'
+import * as toast from '#/components/Toast'
+
+export type SelectMediaButtonProps = {
+  disabled?: boolean
+  /**
+   * If set, this limits the types of assets that can be selected.
+   */
+  allowedAssetTypes: AssetType | undefined
+  selectedAssetsCount: number
+  onSelectAssets: (props: {
+    type: AssetType
+    assets: ImagePickerAsset[]
+    errors: string[]
+  }) => void
+}
+
+/**
+ * Generic asset classes, or buckets, that we support.
+ */
+export type AssetType = 'video' | 'image' | 'gif'
+
+/**
+ * Shadows `ImagePickerAsset` from `expo-image-picker`, but with a guaranteed `mimeType`
+ */
+type ValidatedImagePickerAsset = Omit<ImagePickerAsset, 'mimeType'> & {
+  mimeType: string
+}
+
+/**
+ * Codes for known validation states
+ */
+enum SelectedAssetError {
+  Unsupported = 'Unsupported',
+  MixedTypes = 'MixedTypes',
+  MaxImages = 'MaxImages',
+  MaxVideos = 'MaxVideos',
+  VideoTooLong = 'VideoTooLong',
+  FileTooBig = 'FileTooBig',
+  MaxGIFs = 'MaxGIFs',
+}
+
+/**
+ * Supported video mime types. This differs slightly from
+ * `SUPPORTED_MIME_TYPES` from `#/lib/constants` because we only care about
+ * videos here.
+ */
+const SUPPORTED_VIDEO_MIME_TYPES = [
+  'video/mp4',
+  'video/mpeg',
+  'video/webm',
+  'video/quicktime',
+] as const
+type SupportedVideoMimeType = (typeof SUPPORTED_VIDEO_MIME_TYPES)[number]
+function isSupportedVideoMimeType(
+  mimeType: string,
+): mimeType is SupportedVideoMimeType {
+  return SUPPORTED_VIDEO_MIME_TYPES.includes(mimeType as SupportedVideoMimeType)
+}
+
+/**
+ * Supported image mime types.
+ */
+const SUPPORTED_IMAGE_MIME_TYPES = (
+  [
+    'image/gif',
+    'image/jpeg',
+    'image/png',
+    'image/svg+xml',
+    'image/webp',
+    'image/avif',
+    isNative && 'image/heic',
+  ] as const
+).filter(Boolean)
+type SupportedImageMimeType = Exclude<
+  (typeof SUPPORTED_IMAGE_MIME_TYPES)[number],
+  boolean
+>
+function isSupportedImageMimeType(
+  mimeType: string,
+): mimeType is SupportedImageMimeType {
+  return SUPPORTED_IMAGE_MIME_TYPES.includes(mimeType as SupportedImageMimeType)
+}
+
+/**
+ * This is a last-ditch effort type thing here, try not to rely on this.
+ */
+const extensionToMimeType: Record<
+  string,
+  SupportedVideoMimeType | SupportedImageMimeType
+> = {
+  mp4: 'video/mp4',
+  mov: 'video/quicktime',
+  webm: 'video/webm',
+  webp: 'image/webp',
+  gif: 'image/gif',
+  jpg: 'image/jpeg',
+  jpeg: 'image/jpeg',
+  png: 'image/png',
+  svg: 'image/svg+xml',
+  heic: 'image/heic',
+}
+
+/**
+ * Attempts to bucket the given asset into one of our known types based on its
+ * `mimeType`. If `mimeType` is not available, we try to infer it through
+ * various means.
+ */
+function classifyImagePickerAsset(asset: ImagePickerAsset):
+  | {
+      success: true
+      type: AssetType
+      mimeType: string
+    }
+  | {
+      success: false
+      type: undefined
+      mimeType: undefined
+    } {
+  /*
+   * Try to use the `mimeType` reported by `expo-image-picker` first.
+   */
+  let mimeType = asset.mimeType
+
+  if (!mimeType) {
+    /*
+     * We can try to infer this from the data-uri.
+     */
+    const maybeMimeType = extractDataUriMime(asset.uri)
+
+    if (
+      maybeMimeType.startsWith('image/') ||
+      maybeMimeType.startsWith('video/')
+    ) {
+      mimeType = maybeMimeType
+    } else if (maybeMimeType.startsWith('file/')) {
+      /*
+       * On the off-chance we get a `file/*` mime, try to infer from the
+       * extension.
+       */
+      const extension = asset.uri.split('.').pop()?.toLowerCase()
+      mimeType = extensionToMimeType[extension || '']
+    }
+  }
+
+  if (!mimeType) {
+    return {
+      success: false,
+      type: undefined,
+      mimeType: undefined,
+    }
+  }
+
+  /*
+   * Distill this down into a type "class".
+   */
+  let type: AssetType | undefined
+  if (mimeType === 'image/gif') {
+    type = 'gif'
+  } else if (mimeType?.startsWith('video/')) {
+    type = 'video'
+  } else if (mimeType?.startsWith('image/')) {
+    type = 'image'
+  }
+
+  /*
+   * If we weren't able to find a valid type, we don't support this asset.
+   */
+  if (!type) {
+    return {
+      success: false,
+      type: undefined,
+      mimeType: undefined,
+    }
+  }
+
+  return {
+    success: true,
+    type,
+    mimeType,
+  }
+}
+
+/**
+ * Takes in raw assets from `expo-image-picker` and applies validation. Returns
+ * the dominant `AssetType`, any valid assets, and any errors encountered along
+ * the way.
+ */
+async function processImagePickerAssets(
+  assets: ImagePickerAsset[],
+  {
+    selectionCountRemaining,
+    allowedAssetTypes,
+  }: {
+    selectionCountRemaining: number
+    allowedAssetTypes: AssetType | undefined
+  },
+) {
+  /*
+   * A deduped set of error codes, which we'll use later
+   */
+  const errors = new Set<SelectedAssetError>()
+
+  /*
+   * We only support selecting a single type of media at a time, so this gets
+   * set to whatever the first valid asset type is, OR to whatever
+   * `allowedAssetTypes` is set to.
+   */
+  let selectableAssetType: AssetType | undefined
+
+  /*
+   * This will hold the assets that we can actually use, after filtering
+   */
+  let supportedAssets: ValidatedImagePickerAsset[] = []
+
+  for (const asset of assets) {
+    const {success, type, mimeType} = classifyImagePickerAsset(asset)
+
+    if (!success) {
+      errors.add(SelectedAssetError.Unsupported)
+      continue
+    }
+
+    /*
+     * If we have an `allowedAssetTypes` prop, constrain to that. Otherwise,
+     * set this to the first valid asset type we see, and then use that to
+     * constrain all remaining selected assets.
+     */
+    selectableAssetType = allowedAssetTypes || selectableAssetType || type
+
+    // ignore mixed types
+    if (type !== selectableAssetType) {
+      errors.add(SelectedAssetError.MixedTypes)
+      continue
+    }
+
+    if (type === 'video') {
+      /**
+       * We don't care too much about mimeType at this point on native,
+       * since the `processVideo` step later on will convert to `.mp4`.
+       */
+      if (isWeb && !isSupportedVideoMimeType(mimeType)) {
+        errors.add(SelectedAssetError.Unsupported)
+        continue
+      }
+
+      /*
+       * Filesize appears to be stable across all platforms, so we can use it
+       * to filter out large files on web. On native, we compress these anyway,
+       * so we only check on web.
+       */
+      if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
+        errors.add(SelectedAssetError.FileTooBig)
+        continue
+      }
+    }
+
+    if (type === 'image') {
+      if (!isSupportedImageMimeType(mimeType)) {
+        errors.add(SelectedAssetError.Unsupported)
+        continue
+      }
+    }
+
+    if (type === 'gif') {
+      /*
+       * Filesize appears to be stable across all platforms, so we can use it
+       * to filter out large files on web. On native, we compress GIFs as
+       * videos anyway, so we only check on web.
+       */
+      if (isWeb && asset.fileSize && asset.fileSize > VIDEO_MAX_SIZE) {
+        errors.add(SelectedAssetError.FileTooBig)
+        continue
+      }
+    }
+
+    /*
+     * All validations passed, we have an asset!
+     */
+    supportedAssets.push({
+      mimeType,
+      ...asset,
+      /*
+       * In `expo-image-picker` >= v17, `uri` is now a `blob:` URL, not a
+       * data-uri. Our handling elsewhere in the app (for web) relies on the
+       * base64 data-uri, so we construct it here for web only.
+       */
+      uri:
+        isWeb && asset.base64
+          ? `data:${mimeType};base64,${asset.base64}`
+          : asset.uri,
+    })
+  }
+
+  if (supportedAssets.length > 0) {
+    if (selectableAssetType === 'image') {
+      if (supportedAssets.length > selectionCountRemaining) {
+        errors.add(SelectedAssetError.MaxImages)
+        supportedAssets = supportedAssets.slice(0, selectionCountRemaining)
+      }
+    } else if (selectableAssetType === 'video') {
+      if (supportedAssets.length > 1) {
+        errors.add(SelectedAssetError.MaxVideos)
+        supportedAssets = supportedAssets.slice(0, 1)
+      }
+
+      if (supportedAssets[0].duration) {
+        if (isWeb) {
+          /*
+           * Web reports duration as seconds
+           */
+          supportedAssets[0].duration = supportedAssets[0].duration * 1000
+        }
+
+        if (supportedAssets[0].duration > VIDEO_MAX_DURATION_MS) {
+          errors.add(SelectedAssetError.VideoTooLong)
+          supportedAssets = []
+        }
+      } else {
+        errors.add(SelectedAssetError.Unsupported)
+        supportedAssets = []
+      }
+    } else if (selectableAssetType === 'gif') {
+      if (supportedAssets.length > 1) {
+        errors.add(SelectedAssetError.MaxGIFs)
+        supportedAssets = supportedAssets.slice(0, 1)
+      }
+    }
+  }
+
+  return {
+    type: selectableAssetType!, // set above
+    assets: supportedAssets,
+    errors,
+  }
+}
+
+export function SelectMediaButton({
+  disabled,
+  allowedAssetTypes,
+  selectedAssetsCount,
+  onSelectAssets,
+}: SelectMediaButtonProps) {
+  const {_} = useLingui()
+  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
+  const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
+  const sheetWrapper = useSheetWrapper()
+  const t = useTheme()
+
+  const selectionCountRemaining = MAX_IMAGES - selectedAssetsCount
+
+  const processSelectedAssets = useCallback(
+    async (rawAssets: ImagePickerAsset[]) => {
+      const {
+        type,
+        assets,
+        errors: errorCodes,
+      } = await processImagePickerAssets(rawAssets, {
+        selectionCountRemaining,
+        allowedAssetTypes,
+      })
+
+      /*
+       * Convert error codes to user-friendly messages.
+       */
+      const errors = Array.from(errorCodes).map(error => {
+        return {
+          [SelectedAssetError.Unsupported]: _(
+            msg`One or more of your selected files are not supported.`,
+          ),
+          [SelectedAssetError.MixedTypes]: _(
+            msg`Selecting multiple media types is not supported.`,
+          ),
+          [SelectedAssetError.MaxImages]: _(
+            msg({
+              message: `You can select up to ${plural(MAX_IMAGES, {
+                other: '# images',
+              })} in total.`,
+              comment: `Error message for maximum number of images that can be selected to add to a post, currently 4 but may change.`,
+            }),
+          ),
+          [SelectedAssetError.MaxVideos]: _(
+            msg`You can only select one video at a time.`,
+          ),
+          [SelectedAssetError.VideoTooLong]: _(
+            msg`Videos must be less than 3 minutes long.`,
+          ),
+          [SelectedAssetError.MaxGIFs]: _(
+            msg`You can only select one GIF at a time.`,
+          ),
+          [SelectedAssetError.FileTooBig]: _(
+            msg`One or more of your selected files is too large. Maximum size is 100 MB.`,
+          ),
+        }[error]
+      })
+
+      /*
+       * Report the selected assets and any errors back to the
+       * composer.
+       */
+      onSelectAssets({
+        type,
+        assets,
+        errors,
+      })
+    },
+    [_, onSelectAssets, selectionCountRemaining, allowedAssetTypes],
+  )
+
+  const onPressSelectMedia = useCallback(async () => {
+    if (isNative) {
+      const [photoAccess, videoAccess] = await Promise.all([
+        requestPhotoAccessIfNeeded(),
+        requestVideoAccessIfNeeded(),
+      ])
+
+      if (!photoAccess && !videoAccess) {
+        toast.show(_(msg`You need to allow access to your media library.`), {
+          type: 'error',
+        })
+        return
+      }
+    }
+
+    if (isNative && Keyboard.isVisible()) {
+      Keyboard.dismiss()
+    }
+
+    const {assets, canceled} = await sheetWrapper(
+      launchImageLibraryAsync({
+        exif: false,
+        mediaTypes: ['images', 'videos'],
+        quality: 1,
+        allowsMultipleSelection: true,
+        legacy: true,
+        base64: isWeb,
+        selectionLimit: isIOS ? selectionCountRemaining : undefined,
+        preferredAssetRepresentationMode:
+          UIImagePickerPreferredAssetRepresentationMode.Current,
+        videoMaxDuration: VIDEO_MAX_DURATION_MS / 1000,
+      }),
+    )
+
+    if (canceled) return
+
+    await processSelectedAssets(assets)
+  }, [
+    _,
+    requestPhotoAccessIfNeeded,
+    requestVideoAccessIfNeeded,
+    sheetWrapper,
+    processSelectedAssets,
+    selectionCountRemaining,
+  ])
+
+  return (
+    <Button
+      testID="openMediaBtn"
+      onPress={onPressSelectMedia}
+      label={_(
+        msg({
+          message: `Add media to post`,
+          comment: `Accessibility label for button in composer to add photos or a video to a post`,
+        }),
+      )}
+      accessibilityHint={
+        isNative
+          ? _(
+              msg({
+                message: `Opens device gallery to select up to ${plural(
+                  MAX_IMAGES,
+                  {
+                    other: '# images',
+                  },
+                )}, or a single video.`,
+                comment: `Accessibility hint on native for button in composer to add images or a video to a post. Maximum number of images that can be selected is currently 4 but may change.`,
+              }),
+            )
+          : _(
+              msg({
+                message: `Opens device gallery to select up to ${plural(
+                  MAX_IMAGES,
+                  {
+                    other: '# images',
+                  },
+                )}, or a single video or GIF.`,
+                comment: `Accessibility hint on web for button in composer to add images, a video, or a GIF to a post. Maximum number of images that can be selected is currently 4 but may change.`,
+              }),
+            )
+      }
+      style={a.p_sm}
+      variant="ghost"
+      shape="round"
+      color="primary"
+      disabled={disabled}>
+      <ImageIcon
+        size="lg"
+        style={disabled && t.atoms.text_contrast_low}
+        accessibilityIgnoresInvertColors={true}
+      />
+    </Button>
+  )
+}
diff --git a/src/view/com/composer/photos/ImageAltTextDialog.tsx b/src/view/com/composer/photos/ImageAltTextDialog.tsx
index 724149937..b356cde9b 100644
--- a/src/view/com/composer/photos/ImageAltTextDialog.tsx
+++ b/src/view/com/composer/photos/ImageAltTextDialog.tsx
@@ -96,13 +96,12 @@ const ImageAltTextInner = ({
         <View style={[t.atoms.bg_contrast_50, a.rounded_sm, a.overflow_hidden]}>
           <Image
             style={imageStyle}
-            source={{
-              uri: (image.transformed ?? image.source).path,
-            }}
+            source={{uri: (image.transformed ?? image.source).path}}
             contentFit="contain"
             accessible={true}
             accessibilityIgnoresInvertColors
             enableLiveTextInteraction
+            autoplay={false}
           />
         </View>
       </View>
diff --git a/src/view/com/composer/photos/SelectPhotoBtn.tsx b/src/view/com/composer/photos/SelectPhotoBtn.tsx
deleted file mode 100644
index f4c6aa328..000000000
--- a/src/view/com/composer/photos/SelectPhotoBtn.tsx
+++ /dev/null
@@ -1,60 +0,0 @@
-/* eslint-disable react-native-a11y/has-valid-accessibility-ignores-invert-colors */
-import {useCallback} from 'react'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {usePhotoLibraryPermission} from '#/lib/hooks/usePermissions'
-import {openPicker} from '#/lib/media/picker'
-import {isNative} from '#/platform/detection'
-import {ComposerImage, createComposerImage} from '#/state/gallery'
-import {atoms as a, useTheme} from '#/alf'
-import {Button} from '#/components/Button'
-import {useSheetWrapper} from '#/components/Dialog/sheet-wrapper'
-import {Image_Stroke2_Corner0_Rounded as Image} from '#/components/icons/Image'
-
-type Props = {
-  size: number
-  disabled?: boolean
-  onAdd: (next: ComposerImage[]) => void
-}
-
-export function SelectPhotoBtn({size, disabled, onAdd}: Props) {
-  const {_} = useLingui()
-  const {requestPhotoAccessIfNeeded} = usePhotoLibraryPermission()
-  const t = useTheme()
-  const sheetWrapper = useSheetWrapper()
-
-  const onPressSelectPhotos = useCallback(async () => {
-    if (isNative && !(await requestPhotoAccessIfNeeded())) {
-      return
-    }
-
-    const images = await sheetWrapper(
-      openPicker({
-        selectionLimit: 4 - size,
-        allowsMultipleSelection: true,
-      }),
-    )
-
-    const results = await Promise.all(
-      images.map(img => createComposerImage(img)),
-    )
-
-    onAdd(results)
-  }, [requestPhotoAccessIfNeeded, size, onAdd, sheetWrapper])
-
-  return (
-    <Button
-      testID="openGalleryBtn"
-      onPress={onPressSelectPhotos}
-      label={_(msg`Gallery`)}
-      accessibilityHint={_(msg`Opens device photo gallery`)}
-      style={a.p_sm}
-      variant="ghost"
-      shape="round"
-      color="primary"
-      disabled={disabled}>
-      <Image size="lg" style={disabled && t.atoms.text_contrast_low} />
-    </Button>
-  )
-}
diff --git a/src/view/com/composer/videos/SelectVideoBtn.tsx b/src/view/com/composer/videos/SelectVideoBtn.tsx
deleted file mode 100644
index 96715955f..000000000
--- a/src/view/com/composer/videos/SelectVideoBtn.tsx
+++ /dev/null
@@ -1,88 +0,0 @@
-import {useCallback} from 'react'
-import {type ImagePickerAsset} from 'expo-image-picker'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-
-import {
-  SUPPORTED_MIME_TYPES,
-  type SupportedMimeTypes,
-  VIDEO_MAX_DURATION_MS,
-} from '#/lib/constants'
-import {useVideoLibraryPermission} from '#/lib/hooks/usePermissions'
-import {isWeb} from '#/platform/detection'
-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'
-import {pickVideo} from './pickVideo'
-
-type Props = {
-  onSelectVideo: (video: ImagePickerAsset) => void
-  disabled?: boolean
-  setError: (error: string) => void
-}
-
-export function SelectVideoBtn({onSelectVideo, disabled, setError}: Props) {
-  const {_} = useLingui()
-  const t = useTheme()
-  const {requestVideoAccessIfNeeded} = useVideoLibraryPermission()
-
-  const onPressSelectVideo = useCallback(async () => {
-    if (isNative && !(await requestVideoAccessIfNeeded())) {
-      return
-    }
-
-    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_MS) {
-            throw Error(_(msg`Videos must be less than 3 minutes long`))
-          }
-          // compression step on native converts to mp4, so no need to check there
-          if (
-            !SUPPORTED_MIME_TYPES.includes(asset.mimeType as SupportedMimeTypes)
-          ) {
-            throw Error(_(msg`Unsupported video type: ${asset.mimeType}`))
-          }
-        } else {
-          if (typeof asset.duration !== 'number') {
-            throw Error('Asset is not a video')
-          }
-          if (asset.duration > VIDEO_MAX_DURATION_MS) {
-            throw Error(_(msg`Videos must be less than 3 minutes long`))
-          }
-        }
-        onSelectVideo(asset)
-      } catch (err) {
-        if (err instanceof Error) {
-          setError(err.message)
-        } else {
-          setError(_(msg`An error occurred while selecting the video`))
-        }
-      }
-    }
-  }, [requestVideoAccessIfNeeded, setError, _, onSelectVideo])
-
-  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
index 255174bea..84cb1dba7 100644
--- a/src/view/com/composer/videos/VideoPreview.tsx
+++ b/src/view/com/composer/videos/VideoPreview.tsx
@@ -1,9 +1,10 @@
 import React from 'react'
 import {View} from 'react-native'
-import {ImagePickerAsset} from 'expo-image-picker'
+import {Image} from 'expo-image'
+import {type ImagePickerAsset} from 'expo-image-picker'
 import {BlueskyVideoView} from '@haileyok/bluesky-video'
 
-import {CompressedVideo} from '#/lib/media/video/types'
+import {type CompressedVideo} from '#/lib/media/video/types'
 import {clamp} from '#/lib/numbers'
 import {useAutoplayDisabled} from '#/state/preferences'
 import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
@@ -48,13 +49,25 @@ export function VideoPreview({
         <VideoTranscodeBackdrop uri={asset.uri} />
       </View>
       {isActivePost && (
-        <BlueskyVideoView
-          url={video.uri}
-          autoplay={!autoplayDisabled}
-          beginMuted={true}
-          forceTakeover={true}
-          ref={playerRef}
-        />
+        <>
+          {video.mimeType === 'image/gif' ? (
+            <Image
+              style={[a.flex_1]}
+              autoplay={!autoplayDisabled}
+              source={{uri: video.uri}}
+              accessibilityIgnoresInvertColors
+              cachePolicy="none"
+            />
+          ) : (
+            <BlueskyVideoView
+              url={video.uri}
+              autoplay={!autoplayDisabled}
+              beginMuted={true}
+              forceTakeover={true}
+              ref={playerRef}
+            />
+          )}
+        </>
       )}
       <ExternalEmbedRemoveBtn onRemove={clear} />
       {autoplayDisabled && (
diff --git a/src/view/com/lightbox/Lightbox.web.tsx b/src/view/com/lightbox/Lightbox.web.tsx
index 97811da7f..ab50fbcf0 100644
--- a/src/view/com/lightbox/Lightbox.web.tsx
+++ b/src/view/com/lightbox/Lightbox.web.tsx
@@ -76,6 +76,7 @@ function LightboxInner({
   const onKeyDown = useCallback(
     (e: KeyboardEvent) => {
       if (e.key === 'Escape') {
+        e.preventDefault()
         onClose()
       } else if (e.key === 'ArrowLeft') {
         onPressLeft()
diff --git a/src/view/com/post-thread/PostThreadFollowBtn.tsx b/src/view/com/post-thread/PostThreadFollowBtn.tsx
index 145e919f9..fc9296cad 100644
--- a/src/view/com/post-thread/PostThreadFollowBtn.tsx
+++ b/src/view/com/post-thread/PostThreadFollowBtn.tsx
@@ -1,5 +1,5 @@
 import React from 'react'
-import {AppBskyActorDefs} from '@atproto/api'
+import {type AppBskyActorDefs} from '@atproto/api'
 import {msg, Trans} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
@@ -126,7 +126,7 @@ function PostThreadFollowBtnLoaded({
       <ButtonText>
         {!isFollowing ? (
           isFollowedBy ? (
-            <Trans>Follow Back</Trans>
+            <Trans>Follow back</Trans>
           ) : (
             <Trans>Follow</Trans>
           )
diff --git a/src/view/com/profile/FollowButton.tsx b/src/view/com/profile/FollowButton.tsx
index 656ed914a..ff9c1cd7b 100644
--- a/src/view/com/profile/FollowButton.tsx
+++ b/src/view/com/profile/FollowButton.tsx
@@ -1,11 +1,11 @@
-import {StyleProp, TextStyle, View} from 'react-native'
+import {type StyleProp, type TextStyle, View} from 'react-native'
 import {msg} from '@lingui/macro'
 import {useLingui} from '@lingui/react'
 
-import {Shadow} from '#/state/cache/types'
+import {type Shadow} from '#/state/cache/types'
 import {useProfileFollowMutationQueue} from '#/state/queries/profile'
-import * as bsky from '#/types/bsky'
-import {Button, ButtonType} from '../util/forms/Button'
+import type * as bsky from '#/types/bsky'
+import {Button, type ButtonType} from '../util/forms/Button'
 import * as Toast from '../util/Toast'
 
 export function FollowButton({
@@ -78,7 +78,7 @@ export function FollowButton({
         type={unfollowedType}
         labelStyle={labelStyle}
         onPress={onPressFollow}
-        label={_(msg({message: 'Follow Back', context: 'action'}))}
+        label={_(msg({message: 'Follow back', context: 'action'}))}
       />
     )
   }
diff --git a/src/view/screens/Log.tsx b/src/view/screens/Log.tsx
deleted file mode 100644
index 026319baf..000000000
--- a/src/view/screens/Log.tsx
+++ /dev/null
@@ -1,116 +0,0 @@
-import React from 'react'
-import {StyleSheet, TouchableOpacity, View} from 'react-native'
-import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
-import {msg} from '@lingui/macro'
-import {useLingui} from '@lingui/react'
-import {useFocusEffect} from '@react-navigation/native'
-
-import {usePalette} from '#/lib/hooks/usePalette'
-import {useGetTimeAgo} from '#/lib/hooks/useTimeAgo'
-import {CommonNavigatorParams, NativeStackScreenProps} from '#/lib/routes/types'
-import {s} from '#/lib/styles'
-import {getEntries} from '#/logger/logDump'
-import {useTickEveryMinute} from '#/state/shell'
-import {useSetMinimalShellMode} from '#/state/shell'
-import {Text} from '#/view/com/util/text/Text'
-import {ViewHeader} from '#/view/com/util/ViewHeader'
-import {ScrollView} from '#/view/com/util/Views'
-import * as Layout from '#/components/Layout'
-
-export function LogScreen({}: NativeStackScreenProps<
-  CommonNavigatorParams,
-  'Log'
->) {
-  const pal = usePalette('default')
-  const {_} = useLingui()
-  const setMinimalShellMode = useSetMinimalShellMode()
-  const [expanded, setExpanded] = React.useState<string[]>([])
-  const timeAgo = useGetTimeAgo()
-  const tick = useTickEveryMinute()
-
-  useFocusEffect(
-    React.useCallback(() => {
-      setMinimalShellMode(false)
-    }, [setMinimalShellMode]),
-  )
-
-  const toggler = (id: string) => () => {
-    if (expanded.includes(id)) {
-      setExpanded(expanded.filter(v => v !== id))
-    } else {
-      setExpanded([...expanded, id])
-    }
-  }
-
-  return (
-    <Layout.Screen>
-      <ViewHeader title="Log" />
-      <ScrollView style={s.flex1}>
-        {getEntries()
-          .slice(0)
-          .map(entry => {
-            return (
-              <View key={`entry-${entry.id}`}>
-                <TouchableOpacity
-                  style={[styles.entry, pal.border, pal.view]}
-                  onPress={toggler(entry.id)}
-                  accessibilityLabel={_(msg`View debug entry`)}
-                  accessibilityHint={_(
-                    msg`Opens additional details for a debug entry`,
-                  )}>
-                  {entry.level === 'debug' ? (
-                    <FontAwesomeIcon icon="info" />
-                  ) : (
-                    <FontAwesomeIcon icon="exclamation" style={s.red3} />
-                  )}
-                  <Text type="sm" style={[styles.summary, pal.text]}>
-                    {String(entry.message)}
-                  </Text>
-                  {entry.metadata && Object.keys(entry.metadata).length ? (
-                    <FontAwesomeIcon
-                      icon={
-                        expanded.includes(entry.id) ? 'angle-up' : 'angle-down'
-                      }
-                      style={s.mr5}
-                    />
-                  ) : undefined}
-                  <Text type="sm" style={[styles.ts, pal.textLight]}>
-                    {timeAgo(entry.timestamp, tick)}
-                  </Text>
-                </TouchableOpacity>
-                {expanded.includes(entry.id) ? (
-                  <View style={[pal.view, s.pl10, s.pr10, s.pb10]}>
-                    <View style={[pal.btn, styles.details]}>
-                      <Text type="mono" style={pal.text}>
-                        {JSON.stringify(entry.metadata, null, 2)}
-                      </Text>
-                    </View>
-                  </View>
-                ) : undefined}
-              </View>
-            )
-          })}
-        <View style={s.footerSpacer} />
-      </ScrollView>
-    </Layout.Screen>
-  )
-}
-
-const styles = StyleSheet.create({
-  entry: {
-    flexDirection: 'row',
-    borderTopWidth: 1,
-    paddingVertical: 10,
-    paddingHorizontal: 6,
-  },
-  summary: {
-    flex: 1,
-  },
-  ts: {
-    width: 40,
-  },
-  details: {
-    paddingVertical: 10,
-    paddingHorizontal: 6,
-  },
-})
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index 04fccc44c..8b4c65b8f 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -13,6 +13,7 @@ import {useNotificationsRegistration} from '#/lib/notifications/notifications'
 import {isStateAtTabRoot} from '#/lib/routes/helpers'
 import {isAndroid, isIOS} from '#/platform/detection'
 import {useDialogFullyExpandedCountContext} from '#/state/dialogs'
+import {useGeolocation} from '#/state/geolocation'
 import {useSession} from '#/state/session'
 import {
   useIsDrawerOpen,
@@ -26,6 +27,7 @@ import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {atoms as a, select, useTheme} from '#/alf'
 import {setSystemUITheme} from '#/alf/util/systemUI'
 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
+import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay'
 import {EmailDialog} from '#/components/dialogs/EmailDialog'
 import {InAppBrowserConsentDialog} from '#/components/dialogs/InAppBrowserConsent'
 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
@@ -180,9 +182,11 @@ function ShellInner() {
   )
 }
 
-export const Shell: React.FC = function ShellImpl() {
-  const fullyExpandedCount = useDialogFullyExpandedCountContext()
+export function Shell() {
   const t = useTheme()
+  const {geolocation} = useGeolocation()
+  const fullyExpandedCount = useDialogFullyExpandedCountContext()
+
   useIntentHandler()
 
   useEffect(() => {
@@ -200,9 +204,13 @@ export const Shell: React.FC = function ShellImpl() {
           navigationBar: t.name !== 'light' ? 'light' : 'dark',
         }}
       />
-      <RoutesContainer>
-        <ShellInner />
-      </RoutesContainer>
+      {geolocation?.isAgeBlockedGeo ? (
+        <BlockedGeoOverlay />
+      ) : (
+        <RoutesContainer>
+          <ShellInner />
+        </RoutesContainer>
+      )}
     </View>
   )
 }
diff --git a/src/view/shell/index.web.tsx b/src/view/shell/index.web.tsx
index 3c2bc58ab..f942ab49e 100644
--- a/src/view/shell/index.web.tsx
+++ b/src/view/shell/index.web.tsx
@@ -5,11 +5,10 @@ import {useLingui} from '@lingui/react'
 import {useNavigation} from '@react-navigation/native'
 import {RemoveScrollBar} from 'react-remove-scroll-bar'
 
-import {useColorSchemeStyle} from '#/lib/hooks/useColorSchemeStyle'
 import {useIntentHandler} from '#/lib/hooks/useIntentHandler'
 import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
 import {type NavigationProp} from '#/lib/routes/types'
-import {colors} from '#/lib/styles'
+import {useGeolocation} from '#/state/geolocation'
 import {useIsDrawerOpen, useSetDrawerOpen} from '#/state/shell'
 import {useComposerKeyboardShortcut} from '#/state/shell/composer/useComposerKeyboardShortcut'
 import {useCloseAllActiveElements} from '#/state/util'
@@ -18,6 +17,7 @@ import {ModalsContainer} from '#/view/com/modals/Modal'
 import {ErrorBoundary} from '#/view/com/util/ErrorBoundary'
 import {atoms as a, select, useTheme} from '#/alf'
 import {AgeAssuranceRedirectDialog} from '#/components/ageAssurance/AgeAssuranceRedirectDialog'
+import {BlockedGeoOverlay} from '#/components/BlockedGeoOverlay'
 import {EmailDialog} from '#/components/dialogs/EmailDialog'
 import {LinkWarningDialog} from '#/components/dialogs/LinkWarning'
 import {MutedWordsDialog} from '#/components/dialogs/MutedWords'
@@ -130,24 +130,23 @@ function ShellInner() {
   )
 }
 
-export const Shell: React.FC = function ShellImpl() {
-  const pageBg = useColorSchemeStyle(styles.bgLight, styles.bgDark)
+export function Shell() {
+  const t = useTheme()
+  const {geolocation} = useGeolocation()
   return (
-    <View style={[a.util_screen_outer, pageBg]}>
-      <RoutesContainer>
-        <ShellInner />
-      </RoutesContainer>
+    <View style={[a.util_screen_outer, t.atoms.bg]}>
+      {geolocation?.isAgeBlockedGeo ? (
+        <BlockedGeoOverlay />
+      ) : (
+        <RoutesContainer>
+          <ShellInner />
+        </RoutesContainer>
+      )}
     </View>
   )
 }
 
 const styles = StyleSheet.create({
-  bgLight: {
-    backgroundColor: colors.white,
-  },
-  bgDark: {
-    backgroundColor: colors.black, // TODO
-  },
   drawerMask: {
     ...a.fixed,
     width: '100%',