about summary refs log tree commit diff
diff options
context:
space:
mode:
-rw-r--r--package.json2
-rw-r--r--src/lib/api/index.ts11
-rw-r--r--src/lib/constants.ts4
-rw-r--r--src/lib/media/alt-text.ts16
-rw-r--r--src/state/models/media/gallery.ts4
-rw-r--r--src/state/models/media/image.ts14
-rw-r--r--src/state/models/ui/shell.ts33
-rw-r--r--src/view/com/composer/Composer.tsx2
-rw-r--r--src/view/com/composer/photos/Gallery.tsx123
-rw-r--r--src/view/com/modals/AltImage.tsx106
-rw-r--r--src/view/com/modals/Modal.tsx8
-rw-r--r--src/view/com/modals/Modal.web.tsx3
-rw-r--r--src/view/com/notifications/FeedItem.tsx5
-rw-r--r--src/view/com/util/images/AutoSizedImage.tsx41
-rw-r--r--src/view/com/util/images/ImageHorzList.tsx22
-rw-r--r--src/view/com/util/images/ImageLayoutGrid.tsx122
-rw-r--r--src/view/com/util/post-embeds/index.tsx4
-rw-r--r--src/view/shell/index.tsx2
-rw-r--r--yarn.lock8
19 files changed, 405 insertions, 125 deletions
diff --git a/package.json b/package.json
index 4fa046b87..aef018a81 100644
--- a/package.json
+++ b/package.json
@@ -64,7 +64,7 @@
     "expo-build-properties": "~0.5.1",
     "expo-camera": "~13.2.1",
     "expo-dev-client": "~2.1.1",
-    "expo-image": "~1.0.0",
+    "expo-image": "^1.2.1",
     "expo-image-picker": "~14.1.1",
     "expo-localization": "~14.1.1",
     "expo-media-library": "~15.2.3",
diff --git a/src/lib/api/index.ts b/src/lib/api/index.ts
index 1b12f29c5..3877b3ef7 100644
--- a/src/lib/api/index.ts
+++ b/src/lib/api/index.ts
@@ -10,15 +10,15 @@ import {
 import {AtUri} from '@atproto/api'
 import {RootStoreModel} from 'state/models/root-store'
 import {isNetworkError} from 'lib/strings/errors'
-import {Image} from 'lib/media/types'
 import {LinkMeta} from '../link-meta/link-meta'
 import {isWeb} from 'platform/detection'
+import {ImageModel} from 'state/models/media/image'
 
 export interface ExternalEmbedDraft {
   uri: string
   isLoading: boolean
   meta?: LinkMeta
-  localThumb?: Image
+  localThumb?: ImageModel
 }
 
 export async function resolveName(store: RootStoreModel, didOrHandle: string) {
@@ -61,7 +61,7 @@ interface PostOpts {
     cid: string
   }
   extLink?: ExternalEmbedDraft
-  images?: string[]
+  images?: ImageModel[]
   knownHandles?: Set<string>
   onStateChange?: (state: string) => void
 }
@@ -109,10 +109,11 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
     const images: AppBskyEmbedImages.Image[] = []
     for (const image of opts.images) {
       opts.onStateChange?.(`Uploading image #${images.length + 1}...`)
-      const res = await uploadBlob(store, image, 'image/jpeg')
+      const path = image.compressed?.path ?? image.path
+      const res = await uploadBlob(store, path, 'image/jpeg')
       images.push({
         image: res.data.blob,
-        alt: '', // TODO supply alt text
+        alt: image.altText ?? '',
       })
     }
 
diff --git a/src/lib/constants.ts b/src/lib/constants.ts
index d49d8c75c..12bdc5543 100644
--- a/src/lib/constants.ts
+++ b/src/lib/constants.ts
@@ -4,6 +4,10 @@ export const FEEDBACK_FORM_URL =
 export const MAX_DISPLAY_NAME = 64
 export const MAX_DESCRIPTION = 256
 
+// Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html
+// but adding buffer room to account for languages like German
+export const MAX_ALT_TEXT = 120
+
 export const PROD_TEAM_HANDLES = [
   'jay.bsky.social',
   'pfrazee.com',
diff --git a/src/lib/media/alt-text.ts b/src/lib/media/alt-text.ts
new file mode 100644
index 000000000..9f9f907bf
--- /dev/null
+++ b/src/lib/media/alt-text.ts
@@ -0,0 +1,16 @@
+import {RootStoreModel} from 'state/index'
+
+export async function openAltTextModal(store: RootStoreModel): Promise<string> {
+  return new Promise((resolve, reject) => {
+    store.shell.openModal({
+      name: 'alt-text-image',
+      onAltTextSet: (altText?: string) => {
+        if (altText) {
+          resolve(altText)
+        } else {
+          reject(new Error('Canceled'))
+        }
+      },
+    })
+  })
+}
diff --git a/src/state/models/media/gallery.ts b/src/state/models/media/gallery.ts
index fbe6c92a0..97b1ac1d8 100644
--- a/src/state/models/media/gallery.ts
+++ b/src/state/models/media/gallery.ts
@@ -65,6 +65,10 @@ export class GalleryModel {
     })
   }
 
+  setAltText(image: ImageModel) {
+    image.setAltText()
+  }
+
   crop(image: ImageModel) {
     image.crop()
   }
diff --git a/src/state/models/media/image.ts b/src/state/models/media/image.ts
index 584bf90cc..3585bb083 100644
--- a/src/state/models/media/image.ts
+++ b/src/state/models/media/image.ts
@@ -5,6 +5,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
 import {openCropper} from 'lib/media/picker'
 import {POST_IMG_MAX} from 'lib/constants'
 import {scaleDownDimensions} from 'lib/media/util'
+import {openAltTextModal} from 'lib/media/alt-text'
 
 // TODO: EXIF embed
 // Cases to consider: ExternalEmbed
@@ -14,6 +15,7 @@ export class ImageModel implements RNImage {
   width: number
   height: number
   size: number
+  altText?: string = undefined
   cropped?: RNImage = undefined
   compressed?: RNImage = undefined
   scaledWidth: number = POST_IMG_MAX.width
@@ -41,6 +43,18 @@ export class ImageModel implements RNImage {
     this.scaledHeight = height
   }
 
+  async setAltText() {
+    try {
+      const altText = await openAltTextModal(this.rootStore)
+
+      runInAction(() => {
+        this.altText = altText
+      })
+    } catch (err) {
+      this.rootStore.log.error('Failed to set alt text', err)
+    }
+  }
+
   async crop() {
     try {
       const cropped = await openCropper(this.rootStore, {
diff --git a/src/state/models/ui/shell.ts b/src/state/models/ui/shell.ts
index 47cc0aa82..b717fe05c 100644
--- a/src/state/models/ui/shell.ts
+++ b/src/state/models/ui/shell.ts
@@ -3,7 +3,7 @@ import {RootStoreModel} from '../root-store'
 import {makeAutoObservable} from 'mobx'
 import {ProfileModel} from '../content/profile'
 import {isObj, hasProp} from 'lib/type-guards'
-import {Image} from 'lib/media/types'
+import {Image as RNImage} from 'react-native-image-crop-picker'
 
 export interface ConfirmModal {
   name: 'confirm'
@@ -38,7 +38,12 @@ export interface ReportAccountModal {
 export interface CropImageModal {
   name: 'crop-image'
   uri: string
-  onSelect: (img?: Image) => void
+  onSelect: (img?: RNImage) => void
+}
+
+export interface AltTextImageModal {
+  name: 'alt-text-image'
+  onAltTextSet: (altText?: string) => void
 }
 
 export interface DeleteAccountModal {
@@ -70,18 +75,30 @@ export interface ContentFilteringSettingsModal {
 }
 
 export type Modal =
-  | ConfirmModal
+  // Account
+  | ChangeHandleModal
+  | DeleteAccountModal
   | EditProfileModal
-  | ServerInputModal
-  | ReportPostModal
+
+  // Curation
+  | ContentFilteringSettingsModal
+
+  // Reporting
   | ReportAccountModal
+  | ReportPostModal
+
+  // Posting
+  | AltTextImageModal
   | CropImageModal
-  | DeleteAccountModal
+  | ServerInputModal
   | RepostModal
-  | ChangeHandleModal
+
+  // Bluesky access
   | WaitlistModal
   | InviteCodesModal
-  | ContentFilteringSettingsModal
+
+  // Generic
+  | ConfirmModal
 
 interface LightboxModel {}
 
diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx
index 08f977f79..275001309 100644
--- a/src/view/com/composer/Composer.tsx
+++ b/src/view/com/composer/Composer.tsx
@@ -142,7 +142,7 @@ export const ComposePost = observer(function ComposePost({
         await apilib.post(store, {
           rawText: rt.text,
           replyTo: replyTo?.uri,
-          images: gallery.paths,
+          images: gallery.images,
           quote: quote,
           extLink: extLink,
           onStateChange: setProcessingState,
diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx
index f4dfc88fa..98f0824fd 100644
--- a/src/view/com/composer/photos/Gallery.tsx
+++ b/src/view/com/composer/photos/Gallery.tsx
@@ -1,4 +1,5 @@
 import React, {useCallback} from 'react'
+import {ImageStyle, Keyboard} from 'react-native'
 import {GalleryModel} from 'state/models/media/gallery'
 import {observer} from 'mobx-react-lite'
 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -6,6 +7,8 @@ import {colors} from 'lib/styles'
 import {StyleSheet, TouchableOpacity, View} from 'react-native'
 import {ImageModel} from 'state/models/media/image'
 import {Image} from 'expo-image'
+import {Text} from 'view/com/util/text/Text'
+import {isDesktopWeb} from 'platform/detection'
 
 interface Props {
   gallery: GalleryModel
@@ -13,17 +16,28 @@ interface Props {
 
 export const Gallery = observer(function ({gallery}: Props) {
   const getImageStyle = useCallback(() => {
-    switch (gallery.size) {
-      case 1:
-        return styles.image250
-      case 2:
-        return styles.image175
-      default:
-        return styles.image85
+    let side: number
+
+    if (gallery.size === 1) {
+      side = 250
+    } else {
+      side = (isDesktopWeb ? 560 : 350) / gallery.size
+    }
+
+    return {
+      height: side,
+      width: side,
     }
   }, [gallery])
 
   const imageStyle = getImageStyle()
+  const handleAddImageAltText = useCallback(
+    (image: ImageModel) => {
+      Keyboard.dismiss()
+      gallery.setAltText(image)
+    },
+    [gallery],
+  )
   const handleRemovePhoto = useCallback(
     (image: ImageModel) => {
       gallery.remove(image)
@@ -38,14 +52,68 @@ export const Gallery = observer(function ({gallery}: Props) {
     [gallery],
   )
 
+  const isOverflow = !isDesktopWeb && gallery.size > 2
+
+  const imageControlLabelStyle = {
+    borderRadius: 5,
+    paddingHorizontal: 10,
+    position: 'absolute' as const,
+    width: 46,
+    zIndex: 1,
+    ...(isOverflow
+      ? {
+          left: 4,
+          bottom: 4,
+        }
+      : isDesktopWeb && gallery.size < 3
+      ? {
+          left: 8,
+          top: 8,
+        }
+      : {
+          left: 4,
+          top: 4,
+        }),
+  }
+
+  const imageControlsSubgroupStyle = {
+    display: 'flex' as const,
+    flexDirection: 'row' as const,
+    position: 'absolute' as const,
+    ...(isOverflow
+      ? {
+          top: 4,
+          right: 4,
+          gap: 4,
+        }
+      : isDesktopWeb && gallery.size < 3
+      ? {
+          top: 8,
+          right: 8,
+          gap: 8,
+        }
+      : {
+          top: 4,
+          right: 4,
+          gap: 4,
+        }),
+    zIndex: 1,
+  }
+
   return !gallery.isEmpty ? (
     <View testID="selectedPhotosView" style={styles.gallery}>
       {gallery.images.map(image =>
         image.compressed !== undefined ? (
-          <View
-            key={`selected-image-${image.path}`}
-            style={[styles.imageContainer, imageStyle]}>
-            <View style={styles.imageControls}>
+          <View key={`selected-image-${image.path}`} style={[imageStyle]}>
+            <TouchableOpacity
+              testID="altTextButton"
+              onPress={() => {
+                handleAddImageAltText(image)
+              }}
+              style={[styles.imageControl, imageControlLabelStyle]}>
+              <Text style={styles.imageControlTextContent}>ALT</Text>
+            </TouchableOpacity>
+            <View style={imageControlsSubgroupStyle}>
               <TouchableOpacity
                 testID="cropPhotoButton"
                 onPress={() => {
@@ -72,7 +140,7 @@ export const Gallery = observer(function ({gallery}: Props) {
 
             <Image
               testID="selectedPhotoImage"
-              style={[styles.image, imageStyle]}
+              style={[styles.image, imageStyle] as ImageStyle}
               source={{
                 uri: image.compressed.path,
               }}
@@ -88,36 +156,13 @@ const styles = StyleSheet.create({
   gallery: {
     flex: 1,
     flexDirection: 'row',
+    gap: 8,
     marginTop: 16,
   },
-  imageContainer: {
-    margin: 2,
-  },
   image: {
     resizeMode: 'cover',
     borderRadius: 8,
   },
-  image250: {
-    width: 250,
-    height: 250,
-  },
-  image175: {
-    width: 175,
-    height: 175,
-  },
-  image85: {
-    width: 85,
-    height: 85,
-  },
-  imageControls: {
-    position: 'absolute',
-    display: 'flex',
-    flexDirection: 'row',
-    gap: 4,
-    top: 8,
-    right: 8,
-    zIndex: 1,
-  },
   imageControl: {
     width: 24,
     height: 24,
@@ -127,4 +172,10 @@ const styles = StyleSheet.create({
     alignItems: 'center',
     justifyContent: 'center',
   },
+  imageControlTextContent: {
+    color: 'white',
+    fontSize: 12,
+    fontWeight: 'bold',
+    letterSpacing: 1,
+  },
 })
diff --git a/src/view/com/modals/AltImage.tsx b/src/view/com/modals/AltImage.tsx
new file mode 100644
index 000000000..987df1462
--- /dev/null
+++ b/src/view/com/modals/AltImage.tsx
@@ -0,0 +1,106 @@
+import React, {useCallback, useState} from 'react'
+import {StyleSheet, View} from 'react-native'
+import {usePalette} from 'lib/hooks/usePalette'
+import {TextInput} from './util'
+import {gradients, s} from 'lib/styles'
+import {enforceLen} from 'lib/strings/helpers'
+import {MAX_ALT_TEXT} from 'lib/constants'
+import {useTheme} from 'lib/ThemeContext'
+import {Text} from '../util/text/Text'
+import {TouchableOpacity} from 'react-native-gesture-handler'
+import LinearGradient from 'react-native-linear-gradient'
+import {useStores} from 'state/index'
+import {isDesktopWeb} from 'platform/detection'
+
+export const snapPoints = [330]
+
+interface Props {
+  onAltTextSet: (altText?: string | undefined) => void
+}
+
+export function Component({onAltTextSet}: Props) {
+  const pal = usePalette('default')
+  const store = useStores()
+  const theme = useTheme()
+  const [altText, setAltText] = useState('')
+
+  const onPressSave = useCallback(() => {
+    onAltTextSet(altText)
+    store.shell.closeModal()
+  }, [store, altText, onAltTextSet])
+
+  const onPressCancel = () => {
+    store.shell.closeModal()
+  }
+
+  return (
+    <View testID="altTextImageModal" style={[pal.view, styles.container]}>
+      <Text style={[styles.title, pal.text]}>Add alt text</Text>
+      <TextInput
+        testID="altTextImageInput"
+        style={[styles.textArea, pal.border, pal.text]}
+        keyboardAppearance={theme.colorScheme}
+        multiline
+        value={altText}
+        onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))}
+      />
+      <View style={styles.buttonControls}>
+        <TouchableOpacity testID="altTextImageSaveBtn" onPress={onPressSave}>
+          <LinearGradient
+            colors={[gradients.blueLight.start, gradients.blueLight.end]}
+            start={{x: 0, y: 0}}
+            end={{x: 1, y: 1}}
+            style={[styles.button]}>
+            <Text type="button-lg" style={[s.white, s.bold]}>
+              Save
+            </Text>
+          </LinearGradient>
+        </TouchableOpacity>
+        <TouchableOpacity
+          testID="altTextImageCancelBtn"
+          onPress={onPressCancel}>
+          <View style={[styles.button]}>
+            <Text type="button-lg" style={[pal.textLight]}>
+              Cancel
+            </Text>
+          </View>
+        </TouchableOpacity>
+      </View>
+    </View>
+  )
+}
+
+const styles = StyleSheet.create({
+  container: {
+    gap: 18,
+    bottom: 0,
+    paddingVertical: 18,
+    paddingHorizontal: isDesktopWeb ? 0 : 12,
+    width: '100%',
+  },
+  title: {
+    textAlign: 'center',
+    fontWeight: 'bold',
+    fontSize: 24,
+  },
+  textArea: {
+    borderWidth: 1,
+    borderRadius: 6,
+    paddingTop: 10,
+    paddingHorizontal: 12,
+    fontSize: 16,
+    height: 100,
+    textAlignVertical: 'top',
+  },
+  button: {
+    flexDirection: 'row',
+    alignItems: 'center',
+    justifyContent: 'center',
+    width: '100%',
+    borderRadius: 32,
+    padding: 10,
+  },
+  buttonControls: {
+    gap: 8,
+  },
+})
diff --git a/src/view/com/modals/Modal.tsx b/src/view/com/modals/Modal.tsx
index 3f10ec836..a83cdfdae 100644
--- a/src/view/com/modals/Modal.tsx
+++ b/src/view/com/modals/Modal.tsx
@@ -1,5 +1,5 @@
 import React, {useRef, useEffect} from 'react'
-import {StyleSheet, View} from 'react-native'
+import {StyleSheet} from 'react-native'
 import {observer} from 'mobx-react-lite'
 import BottomSheet from '@gorhom/bottom-sheet'
 import {useStores} from 'state/index'
@@ -11,6 +11,7 @@ import * as EditProfileModal from './EditProfile'
 import * as ServerInputModal from './ServerInput'
 import * as ReportPostModal from './ReportPost'
 import * as RepostModal from './Repost'
+import * as AltImageModal from './AltImage'
 import * as ReportAccountModal from './ReportAccount'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as ChangeHandleModal from './ChangeHandle'
@@ -68,6 +69,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
   } else if (activeModal?.name === 'repost') {
     snapPoints = RepostModal.snapPoints
     element = <RepostModal.Component {...activeModal} />
+  } else if (activeModal?.name === 'alt-text-image') {
+    snapPoints = AltImageModal.snapPoints
+    element = <AltImageModal.Component {...activeModal} />
   } else if (activeModal?.name === 'change-handle') {
     snapPoints = ChangeHandleModal.snapPoints
     element = <ChangeHandleModal.Component {...activeModal} />
@@ -81,7 +85,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
     snapPoints = ContentFilteringSettingsModal.snapPoints
     element = <ContentFilteringSettingsModal.Component />
   } else {
-    return <View />
+    return null
   }
 
   return (
diff --git a/src/view/com/modals/Modal.web.tsx b/src/view/com/modals/Modal.web.tsx
index 25fed69a4..1effee69b 100644
--- a/src/view/com/modals/Modal.web.tsx
+++ b/src/view/com/modals/Modal.web.tsx
@@ -14,6 +14,7 @@ import * as ReportAccountModal from './ReportAccount'
 import * as DeleteAccountModal from './DeleteAccount'
 import * as RepostModal from './Repost'
 import * as CropImageModal from './crop-image/CropImage.web'
+import * as AltTextImageModal from './AltImage'
 import * as ChangeHandleModal from './ChangeHandle'
 import * as WaitlistModal from './Waitlist'
 import * as InviteCodesModal from './InviteCodes'
@@ -78,6 +79,8 @@ function Modal({modal}: {modal: ModalIface}) {
     element = <InviteCodesModal.Component />
   } else if (modal.name === 'content-filtering-settings') {
     element = <ContentFilteringSettingsModal.Component />
+  } else if (modal.name === 'alt-text-image') {
+    element = <AltTextImageModal.Component {...modal} />
   } else {
     return null
   }
diff --git a/src/view/com/notifications/FeedItem.tsx b/src/view/com/notifications/FeedItem.tsx
index b05111ffc..02dea4204 100644
--- a/src/view/com/notifications/FeedItem.tsx
+++ b/src/view/com/notifications/FeedItem.tsx
@@ -369,10 +369,7 @@ function AdditionalPostText({
     <>
       {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
       {images && images?.length > 0 && (
-        <ImageHorzList
-          uris={images?.map(img => img.thumb)}
-          style={styles.additionalPostImages}
-        />
+        <ImageHorzList images={images} style={styles.additionalPostImages} />
       )}
     </>
   )
diff --git a/src/view/com/util/images/AutoSizedImage.tsx b/src/view/com/util/images/AutoSizedImage.tsx
index 17e3e809b..8c31f5614 100644
--- a/src/view/com/util/images/AutoSizedImage.tsx
+++ b/src/view/com/util/images/AutoSizedImage.tsx
@@ -9,29 +9,33 @@ import {
 import {Image} from 'expo-image'
 import {clamp} from 'lib/numbers'
 import {useStores} from 'state/index'
-import {Dim} from 'lib/media/manip'
+import {Dimensions} from 'lib/media/types'
 
 export const DELAY_PRESS_IN = 500
 const MIN_ASPECT_RATIO = 0.33 // 1/3
 const MAX_ASPECT_RATIO = 5 // 5/1
 
+interface Props {
+  alt?: string
+  uri: string
+  onPress?: () => void
+  onLongPress?: () => void
+  onPressIn?: () => void
+  style?: StyleProp<ViewStyle>
+  children?: React.ReactNode
+}
+
 export function AutoSizedImage({
+  alt,
   uri,
   onPress,
   onLongPress,
   onPressIn,
   style,
   children = null,
-}: {
-  uri: string
-  onPress?: () => void
-  onLongPress?: () => void
-  onPressIn?: () => void
-  style?: StyleProp<ViewStyle>
-  children?: React.ReactNode
-}) {
+}: Props) {
   const store = useStores()
-  const [dim, setDim] = React.useState<Dim | undefined>(
+  const [dim, setDim] = React.useState<Dimensions | undefined>(
     store.imageSizes.get(uri),
   )
   const [aspectRatio, setAspectRatio] = React.useState<number>(
@@ -59,20 +63,31 @@ export function AutoSizedImage({
         onPressIn={onPressIn}
         delayPressIn={DELAY_PRESS_IN}
         style={[styles.container, style]}>
-        <Image style={[styles.image, {aspectRatio}]} source={uri} />
+        <Image
+          style={[styles.image, {aspectRatio}]}
+          source={uri}
+          accessible={true} // Must set for `accessibilityLabel` to work
+          accessibilityLabel={alt}
+        />
         {children}
       </TouchableOpacity>
     )
   }
+
   return (
     <View style={[styles.container, style]}>
-      <Image style={[styles.image, {aspectRatio}]} source={{uri}} />
+      <Image
+        style={[styles.image, {aspectRatio}]}
+        source={{uri}}
+        accessible={true} // Must set for `accessibilityLabel` to work
+        accessibilityLabel={alt}
+      />
       {children}
     </View>
   )
 }
 
-function calc(dim: Dim) {
+function calc(dim: Dimensions) {
   if (dim.width === 0 || dim.height === 0) {
     return 1
   }
diff --git a/src/view/com/util/images/ImageHorzList.tsx b/src/view/com/util/images/ImageHorzList.tsx
index 40f1948d6..5c232e0b4 100644
--- a/src/view/com/util/images/ImageHorzList.tsx
+++ b/src/view/com/util/images/ImageHorzList.tsx
@@ -7,21 +7,25 @@ import {
   ViewStyle,
 } from 'react-native'
 import {Image} from 'expo-image'
+import {AppBskyEmbedImages} from '@atproto/api'
 
-export function ImageHorzList({
-  uris,
-  onPress,
-  style,
-}: {
-  uris: string[]
+interface Props {
+  images: AppBskyEmbedImages.ViewImage[]
   onPress?: (index: number) => void
   style?: StyleProp<ViewStyle>
-}) {
+}
+
+export function ImageHorzList({images, onPress, style}: Props) {
   return (
     <View style={[styles.flexRow, style]}>
-      {uris.map((uri, i) => (
+      {images.map(({thumb, alt}, i) => (
         <TouchableWithoutFeedback key={i} onPress={() => onPress?.(i)}>
-          <Image source={{uri}} style={styles.image} />
+          <Image
+            source={{uri: thumb}}
+            style={styles.image}
+            accessible={true}
+            accessibilityLabel={alt}
+          />
         </TouchableWithoutFeedback>
       ))}
     </View>
diff --git a/src/view/com/util/images/ImageLayoutGrid.tsx b/src/view/com/util/images/ImageLayoutGrid.tsx
index f4fe59522..51bb04fe9 100644
--- a/src/view/com/util/images/ImageLayoutGrid.tsx
+++ b/src/view/com/util/images/ImageLayoutGrid.tsx
@@ -9,26 +9,25 @@ import {
 } from 'react-native'
 import {Image, ImageStyle} from 'expo-image'
 import {Dimensions} from 'lib/media/types'
+import {AppBskyEmbedImages} from '@atproto/api'
 
 export const DELAY_PRESS_IN = 500
 
-export type ImageLayoutGridType = number
+interface ImageLayoutGridProps {
+  images: AppBskyEmbedImages.ViewImage[]
+  onPress?: (index: number) => void
+  onLongPress?: (index: number) => void
+  onPressIn?: (index: number) => void
+  style?: StyleProp<ViewStyle>
+}
 
 export function ImageLayoutGrid({
-  type,
-  uris,
+  images,
   onPress,
   onLongPress,
   onPressIn,
   style,
-}: {
-  type: ImageLayoutGridType
-  uris: string[]
-  onPress?: (index: number) => void
-  onLongPress?: (index: number) => void
-  onPressIn?: (index: number) => void
-  style?: StyleProp<ViewStyle>
-}) {
+}: ImageLayoutGridProps) {
   const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
 
   const onLayout = (evt: LayoutChangeEvent) => {
@@ -42,8 +41,7 @@ export function ImageLayoutGrid({
     <View style={style} onLayout={onLayout}>
       {containerInfo ? (
         <ImageLayoutGridInner
-          type={type}
-          uris={uris}
+          images={images}
           onPress={onPress}
           onPressIn={onPressIn}
           onLongPress={onLongPress}
@@ -54,41 +52,42 @@ export function ImageLayoutGrid({
   )
 }
 
+interface ImageLayoutGridInnerProps {
+  images: AppBskyEmbedImages.ViewImage[]
+  onPress?: (index: number) => void
+  onLongPress?: (index: number) => void
+  onPressIn?: (index: number) => void
+  containerInfo: Dimensions
+}
+
 function ImageLayoutGridInner({
-  type,
-  uris,
+  images,
   onPress,
   onLongPress,
   onPressIn,
   containerInfo,
-}: {
-  type: ImageLayoutGridType
-  uris: string[]
-  onPress?: (index: number) => void
-  onLongPress?: (index: number) => void
-  onPressIn?: (index: number) => void
-  containerInfo: Dimensions
-}) {
+}: ImageLayoutGridInnerProps) {
+  const count = images.length
   const size1 = useMemo<ImageStyle>(() => {
-    if (type === 3) {
+    if (count === 3) {
       const size = (containerInfo.width - 10) / 3
       return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
     } else {
       const size = (containerInfo.width - 5) / 2
       return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
     }
-  }, [type, containerInfo])
+  }, [count, containerInfo])
   const size2 = React.useMemo<ImageStyle>(() => {
-    if (type === 3) {
+    if (count === 3) {
       const size = ((containerInfo.width - 10) / 3) * 2 + 5
       return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
     } else {
       const size = (containerInfo.width - 5) / 2
       return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
     }
-  }, [type, containerInfo])
+  }, [count, containerInfo])
 
-  if (type === 2) {
+  if (count === 2) {
     return (
       <View style={styles.flexRow}>
         <TouchableOpacity
@@ -96,7 +95,12 @@ function ImageLayoutGridInner({
           onPress={() => onPress?.(0)}
           onPressIn={() => onPressIn?.(0)}
           onLongPress={() => onLongPress?.(0)}>
-          <Image source={{uri: uris[0]}} style={size1} />
+          <Image
+            source={{uri: images[0].thumb}}
+            style={size1}
+            accessible={true}
+            accessibilityLabel={images[0].alt}
+          />
         </TouchableOpacity>
         <View style={styles.wSpace} />
         <TouchableOpacity
@@ -104,12 +108,17 @@ function ImageLayoutGridInner({
           onPress={() => onPress?.(1)}
           onPressIn={() => onPressIn?.(1)}
           onLongPress={() => onLongPress?.(1)}>
-          <Image source={{uri: uris[1]}} style={size1} />
+          <Image
+            source={{uri: images[1].thumb}}
+            style={size1}
+            accessible={true}
+            accessibilityLabel={images[1].alt}
+          />
         </TouchableOpacity>
       </View>
     )
   }
-  if (type === 3) {
+  if (count === 3) {
     return (
       <View style={styles.flexRow}>
         <TouchableOpacity
@@ -117,7 +126,12 @@ function ImageLayoutGridInner({
           onPress={() => onPress?.(0)}
           onPressIn={() => onPressIn?.(0)}
           onLongPress={() => onLongPress?.(0)}>
-          <Image source={{uri: uris[0]}} style={size2} />
+          <Image
+            source={{uri: images[0].thumb}}
+            style={size2}
+            accessible={true}
+            accessibilityLabel={images[0].alt}
+          />
         </TouchableOpacity>
         <View style={styles.wSpace} />
         <View>
@@ -126,7 +140,12 @@ function ImageLayoutGridInner({
             onPress={() => onPress?.(1)}
             onPressIn={() => onPressIn?.(1)}
             onLongPress={() => onLongPress?.(1)}>
-            <Image source={{uri: uris[1]}} style={size1} />
+            <Image
+              source={{uri: images[1].thumb}}
+              style={size1}
+              accessible={true}
+              accessibilityLabel={images[1].alt}
+            />
           </TouchableOpacity>
           <View style={styles.hSpace} />
           <TouchableOpacity
@@ -134,13 +153,18 @@ function ImageLayoutGridInner({
             onPress={() => onPress?.(2)}
             onPressIn={() => onPressIn?.(2)}
             onLongPress={() => onLongPress?.(2)}>
-            <Image source={{uri: uris[2]}} style={size1} />
+            <Image
+              source={{uri: images[2].thumb}}
+              style={size1}
+              accessible={true}
+              accessibilityLabel={images[2].alt}
+            />
           </TouchableOpacity>
         </View>
       </View>
     )
   }
-  if (type === 4) {
+  if (count === 4) {
     return (
       <View style={styles.flexRow}>
         <View>
@@ -149,7 +173,12 @@ function ImageLayoutGridInner({
             onPress={() => onPress?.(0)}
             onPressIn={() => onPressIn?.(0)}
             onLongPress={() => onLongPress?.(0)}>
-            <Image source={{uri: uris[0]}} style={size1} />
+            <Image
+              source={{uri: images[0].thumb}}
+              style={size1}
+              accessible={true}
+              accessibilityLabel={images[0].alt}
+            />
           </TouchableOpacity>
           <View style={styles.hSpace} />
           <TouchableOpacity
@@ -157,7 +186,12 @@ function ImageLayoutGridInner({
             onPress={() => onPress?.(2)}
             onPressIn={() => onPressIn?.(2)}
             onLongPress={() => onLongPress?.(2)}>
-            <Image source={{uri: uris[2]}} style={size1} />
+            <Image
+              source={{uri: images[2].thumb}}
+              style={size1}
+              accessible={true}
+              accessibilityLabel={images[2].alt}
+            />
           </TouchableOpacity>
         </View>
         <View style={styles.wSpace} />
@@ -167,7 +201,12 @@ function ImageLayoutGridInner({
             onPress={() => onPress?.(1)}
             onPressIn={() => onPressIn?.(1)}
             onLongPress={() => onLongPress?.(1)}>
-            <Image source={{uri: uris[1]}} style={size1} />
+            <Image
+              source={{uri: images[1].thumb}}
+              style={size1}
+              accessible={true}
+              accessibilityLabel={images[1].alt}
+            />
           </TouchableOpacity>
           <View style={styles.hSpace} />
           <TouchableOpacity
@@ -175,7 +214,12 @@ function ImageLayoutGridInner({
             onPress={() => onPress?.(3)}
             onPressIn={() => onPressIn?.(3)}
             onLongPress={() => onLongPress?.(3)}>
-            <Image source={{uri: uris[3]}} style={size1} />
+            <Image
+              source={{uri: images[3].thumb}}
+              style={size1}
+              accessible={true}
+              accessibilityLabel={images[3].alt}
+            />
           </TouchableOpacity>
         </View>
       </View>
diff --git a/src/view/com/util/post-embeds/index.tsx b/src/view/com/util/post-embeds/index.tsx
index c15986b76..f37fba342 100644
--- a/src/view/com/util/post-embeds/index.tsx
+++ b/src/view/com/util/post-embeds/index.tsx
@@ -112,6 +112,7 @@ export function PostEmbeds({
           return (
             <View style={[styles.imagesContainer, style]}>
               <AutoSizedImage
+                alt={embed.images[0].alt}
                 uri={embed.images[0].thumb}
                 onPress={() => openLightbox(0)}
                 onLongPress={() => onLongPress(0)}
@@ -124,8 +125,7 @@ export function PostEmbeds({
           return (
             <View style={[styles.imagesContainer, style]}>
               <ImageLayoutGrid
-                type={embed.images.length}
-                uris={embed.images.map(img => img.thumb)}
+                images={embed.images}
                 onPress={openLightbox}
                 onLongPress={onLongPress}
                 onPressIn={onPressIn}
diff --git a/src/view/shell/index.tsx b/src/view/shell/index.tsx
index eab050fd0..e0abec777 100644
--- a/src/view/shell/index.tsx
+++ b/src/view/shell/index.tsx
@@ -54,7 +54,6 @@ const ShellInner = observer(() => {
           </Drawer>
         </ErrorBoundary>
       </View>
-      <ModalsContainer />
       <Lightbox />
       <Composer
         active={store.shell.isComposerActive}
@@ -64,6 +63,7 @@ const ShellInner = observer(() => {
         onPost={store.shell.composerOpts?.onPost}
         quote={store.shell.composerOpts?.quote}
       />
+      <ModalsContainer />
     </>
   )
 })
diff --git a/yarn.lock b/yarn.lock
index f1cb70cf8..635de6c18 100644
--- a/yarn.lock
+++ b/yarn.lock
@@ -8364,10 +8364,10 @@ expo-image-picker@~14.1.1:
   dependencies:
     expo-image-loader "~4.1.0"
 
-expo-image@~1.0.0:
-  version "1.0.0"
-  resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.0.0.tgz#a3670d20815d99e2527307a33761c9b0088823b1"
-  integrity sha512-A1amVExKhBa/eRXuceauYtPkf9izeje5AbxEWL09tgK91rf3GSIZXM5PSDGlIM0s7dpCV+Iet2jhwcFUfWaZrw==
+expo-image@^1.2.1:
+  version "1.2.1"
+  resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.2.1.tgz#3f377cb3142de2107903f4e4f88a7f44785dee18"
+  integrity sha512-pYZFN0ctuIBA+sqUiw70rHQQ04WDyEcF549ObArdj0MNgSUCBJMFmu/jrWDmxOpEMF40lfLVIZKigJT7Bw+GYA==
 
 expo-json-utils@~0.5.0:
   version "0.5.1"