Add alt text support and rework image layout (#503)

* Add alt text support and rework image layout

* Add additional BottomSheet implementation to account for nested Composer modal

* Use mobile gallery layout on mobile web

* Missing key

* Fix lint

* Move altimage modal into the standard modal system

* Fix overflow wrapping of images

* Fixes to the alt-image modal

* Remove unnecessary switch

* Restore old imagelayoutgrid code

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Ollie Hsieh 2023-04-21 14:20:06 -07:00 committed by GitHub
parent 0f5735b616
commit f0706dbe9f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
19 changed files with 412 additions and 132 deletions

View File

@ -64,7 +64,7 @@
"expo-build-properties": "~0.5.1", "expo-build-properties": "~0.5.1",
"expo-camera": "~13.2.1", "expo-camera": "~13.2.1",
"expo-dev-client": "~2.1.1", "expo-dev-client": "~2.1.1",
"expo-image": "~1.0.0", "expo-image": "^1.2.1",
"expo-image-picker": "~14.1.1", "expo-image-picker": "~14.1.1",
"expo-localization": "~14.1.1", "expo-localization": "~14.1.1",
"expo-media-library": "~15.2.3", "expo-media-library": "~15.2.3",

View File

@ -10,15 +10,15 @@ import {
import {AtUri} from '@atproto/api' import {AtUri} from '@atproto/api'
import {RootStoreModel} from 'state/models/root-store' import {RootStoreModel} from 'state/models/root-store'
import {isNetworkError} from 'lib/strings/errors' import {isNetworkError} from 'lib/strings/errors'
import {Image} from 'lib/media/types'
import {LinkMeta} from '../link-meta/link-meta' import {LinkMeta} from '../link-meta/link-meta'
import {isWeb} from 'platform/detection' import {isWeb} from 'platform/detection'
import {ImageModel} from 'state/models/media/image'
export interface ExternalEmbedDraft { export interface ExternalEmbedDraft {
uri: string uri: string
isLoading: boolean isLoading: boolean
meta?: LinkMeta meta?: LinkMeta
localThumb?: Image localThumb?: ImageModel
} }
export async function resolveName(store: RootStoreModel, didOrHandle: string) { export async function resolveName(store: RootStoreModel, didOrHandle: string) {
@ -61,7 +61,7 @@ interface PostOpts {
cid: string cid: string
} }
extLink?: ExternalEmbedDraft extLink?: ExternalEmbedDraft
images?: string[] images?: ImageModel[]
knownHandles?: Set<string> knownHandles?: Set<string>
onStateChange?: (state: string) => void onStateChange?: (state: string) => void
} }
@ -109,10 +109,11 @@ export async function post(store: RootStoreModel, opts: PostOpts) {
const images: AppBskyEmbedImages.Image[] = [] const images: AppBskyEmbedImages.Image[] = []
for (const image of opts.images) { for (const image of opts.images) {
opts.onStateChange?.(`Uploading image #${images.length + 1}...`) 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({ images.push({
image: res.data.blob, image: res.data.blob,
alt: '', // TODO supply alt text alt: image.altText ?? '',
}) })
} }

View File

@ -4,6 +4,10 @@ export const FEEDBACK_FORM_URL =
export const MAX_DISPLAY_NAME = 64 export const MAX_DISPLAY_NAME = 64
export const MAX_DESCRIPTION = 256 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 = [ export const PROD_TEAM_HANDLES = [
'jay.bsky.social', 'jay.bsky.social',
'pfrazee.com', 'pfrazee.com',

View File

@ -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'))
}
},
})
})
}

View File

@ -65,6 +65,10 @@ export class GalleryModel {
}) })
} }
setAltText(image: ImageModel) {
image.setAltText()
}
crop(image: ImageModel) { crop(image: ImageModel) {
image.crop() image.crop()
} }

View File

@ -5,6 +5,7 @@ import {makeAutoObservable, runInAction} from 'mobx'
import {openCropper} from 'lib/media/picker' import {openCropper} from 'lib/media/picker'
import {POST_IMG_MAX} from 'lib/constants' import {POST_IMG_MAX} from 'lib/constants'
import {scaleDownDimensions} from 'lib/media/util' import {scaleDownDimensions} from 'lib/media/util'
import {openAltTextModal} from 'lib/media/alt-text'
// TODO: EXIF embed // TODO: EXIF embed
// Cases to consider: ExternalEmbed // Cases to consider: ExternalEmbed
@ -14,6 +15,7 @@ export class ImageModel implements RNImage {
width: number width: number
height: number height: number
size: number size: number
altText?: string = undefined
cropped?: RNImage = undefined cropped?: RNImage = undefined
compressed?: RNImage = undefined compressed?: RNImage = undefined
scaledWidth: number = POST_IMG_MAX.width scaledWidth: number = POST_IMG_MAX.width
@ -41,6 +43,18 @@ export class ImageModel implements RNImage {
this.scaledHeight = height 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() { async crop() {
try { try {
const cropped = await openCropper(this.rootStore, { const cropped = await openCropper(this.rootStore, {

View File

@ -3,7 +3,7 @@ import {RootStoreModel} from '../root-store'
import {makeAutoObservable} from 'mobx' import {makeAutoObservable} from 'mobx'
import {ProfileModel} from '../content/profile' import {ProfileModel} from '../content/profile'
import {isObj, hasProp} from 'lib/type-guards' 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 { export interface ConfirmModal {
name: 'confirm' name: 'confirm'
@ -38,7 +38,12 @@ export interface ReportAccountModal {
export interface CropImageModal { export interface CropImageModal {
name: 'crop-image' name: 'crop-image'
uri: string uri: string
onSelect: (img?: Image) => void onSelect: (img?: RNImage) => void
}
export interface AltTextImageModal {
name: 'alt-text-image'
onAltTextSet: (altText?: string) => void
} }
export interface DeleteAccountModal { export interface DeleteAccountModal {
@ -70,18 +75,30 @@ export interface ContentFilteringSettingsModal {
} }
export type Modal = export type Modal =
| ConfirmModal // Account
| EditProfileModal
| ServerInputModal
| ReportPostModal
| ReportAccountModal
| CropImageModal
| DeleteAccountModal
| RepostModal
| ChangeHandleModal | ChangeHandleModal
| DeleteAccountModal
| EditProfileModal
// Curation
| ContentFilteringSettingsModal
// Reporting
| ReportAccountModal
| ReportPostModal
// Posting
| AltTextImageModal
| CropImageModal
| ServerInputModal
| RepostModal
// Bluesky access
| WaitlistModal | WaitlistModal
| InviteCodesModal | InviteCodesModal
| ContentFilteringSettingsModal
// Generic
| ConfirmModal
interface LightboxModel {} interface LightboxModel {}

View File

@ -142,7 +142,7 @@ export const ComposePost = observer(function ComposePost({
await apilib.post(store, { await apilib.post(store, {
rawText: rt.text, rawText: rt.text,
replyTo: replyTo?.uri, replyTo: replyTo?.uri,
images: gallery.paths, images: gallery.images,
quote: quote, quote: quote,
extLink: extLink, extLink: extLink,
onStateChange: setProcessingState, onStateChange: setProcessingState,

View File

@ -1,4 +1,5 @@
import React, {useCallback} from 'react' import React, {useCallback} from 'react'
import {ImageStyle, Keyboard} from 'react-native'
import {GalleryModel} from 'state/models/media/gallery' import {GalleryModel} from 'state/models/media/gallery'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@ -6,6 +7,8 @@ import {colors} from 'lib/styles'
import {StyleSheet, TouchableOpacity, View} from 'react-native' import {StyleSheet, TouchableOpacity, View} from 'react-native'
import {ImageModel} from 'state/models/media/image' import {ImageModel} from 'state/models/media/image'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {Text} from 'view/com/util/text/Text'
import {isDesktopWeb} from 'platform/detection'
interface Props { interface Props {
gallery: GalleryModel gallery: GalleryModel
@ -13,17 +16,28 @@ interface Props {
export const Gallery = observer(function ({gallery}: Props) { export const Gallery = observer(function ({gallery}: Props) {
const getImageStyle = useCallback(() => { const getImageStyle = useCallback(() => {
switch (gallery.size) { let side: number
case 1:
return styles.image250 if (gallery.size === 1) {
case 2: side = 250
return styles.image175 } else {
default: side = (isDesktopWeb ? 560 : 350) / gallery.size
return styles.image85 }
return {
height: side,
width: side,
} }
}, [gallery]) }, [gallery])
const imageStyle = getImageStyle() const imageStyle = getImageStyle()
const handleAddImageAltText = useCallback(
(image: ImageModel) => {
Keyboard.dismiss()
gallery.setAltText(image)
},
[gallery],
)
const handleRemovePhoto = useCallback( const handleRemovePhoto = useCallback(
(image: ImageModel) => { (image: ImageModel) => {
gallery.remove(image) gallery.remove(image)
@ -38,14 +52,68 @@ export const Gallery = observer(function ({gallery}: Props) {
[gallery], [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 ? ( return !gallery.isEmpty ? (
<View testID="selectedPhotosView" style={styles.gallery}> <View testID="selectedPhotosView" style={styles.gallery}>
{gallery.images.map(image => {gallery.images.map(image =>
image.compressed !== undefined ? ( image.compressed !== undefined ? (
<View <View key={`selected-image-${image.path}`} style={[imageStyle]}>
key={`selected-image-${image.path}`} <TouchableOpacity
style={[styles.imageContainer, imageStyle]}> testID="altTextButton"
<View style={styles.imageControls}> onPress={() => {
handleAddImageAltText(image)
}}
style={[styles.imageControl, imageControlLabelStyle]}>
<Text style={styles.imageControlTextContent}>ALT</Text>
</TouchableOpacity>
<View style={imageControlsSubgroupStyle}>
<TouchableOpacity <TouchableOpacity
testID="cropPhotoButton" testID="cropPhotoButton"
onPress={() => { onPress={() => {
@ -72,7 +140,7 @@ export const Gallery = observer(function ({gallery}: Props) {
<Image <Image
testID="selectedPhotoImage" testID="selectedPhotoImage"
style={[styles.image, imageStyle]} style={[styles.image, imageStyle] as ImageStyle}
source={{ source={{
uri: image.compressed.path, uri: image.compressed.path,
}} }}
@ -88,36 +156,13 @@ const styles = StyleSheet.create({
gallery: { gallery: {
flex: 1, flex: 1,
flexDirection: 'row', flexDirection: 'row',
gap: 8,
marginTop: 16, marginTop: 16,
}, },
imageContainer: {
margin: 2,
},
image: { image: {
resizeMode: 'cover', resizeMode: 'cover',
borderRadius: 8, 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: { imageControl: {
width: 24, width: 24,
height: 24, height: 24,
@ -127,4 +172,10 @@ const styles = StyleSheet.create({
alignItems: 'center', alignItems: 'center',
justifyContent: 'center', justifyContent: 'center',
}, },
imageControlTextContent: {
color: 'white',
fontSize: 12,
fontWeight: 'bold',
letterSpacing: 1,
},
}) })

View File

@ -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,
},
})

View File

@ -1,5 +1,5 @@
import React, {useRef, useEffect} from 'react' import React, {useRef, useEffect} from 'react'
import {StyleSheet, View} from 'react-native' import {StyleSheet} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import BottomSheet from '@gorhom/bottom-sheet' import BottomSheet from '@gorhom/bottom-sheet'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -11,6 +11,7 @@ import * as EditProfileModal from './EditProfile'
import * as ServerInputModal from './ServerInput' import * as ServerInputModal from './ServerInput'
import * as ReportPostModal from './ReportPost' import * as ReportPostModal from './ReportPost'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as AltImageModal from './AltImage'
import * as ReportAccountModal from './ReportAccount' import * as ReportAccountModal from './ReportAccount'
import * as DeleteAccountModal from './DeleteAccount' import * as DeleteAccountModal from './DeleteAccount'
import * as ChangeHandleModal from './ChangeHandle' import * as ChangeHandleModal from './ChangeHandle'
@ -68,6 +69,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'repost') { } else if (activeModal?.name === 'repost') {
snapPoints = RepostModal.snapPoints snapPoints = RepostModal.snapPoints
element = <RepostModal.Component {...activeModal} /> element = <RepostModal.Component {...activeModal} />
} else if (activeModal?.name === 'alt-text-image') {
snapPoints = AltImageModal.snapPoints
element = <AltImageModal.Component {...activeModal} />
} else if (activeModal?.name === 'change-handle') { } else if (activeModal?.name === 'change-handle') {
snapPoints = ChangeHandleModal.snapPoints snapPoints = ChangeHandleModal.snapPoints
element = <ChangeHandleModal.Component {...activeModal} /> element = <ChangeHandleModal.Component {...activeModal} />
@ -81,7 +85,7 @@ export const ModalsContainer = observer(function ModalsContainer() {
snapPoints = ContentFilteringSettingsModal.snapPoints snapPoints = ContentFilteringSettingsModal.snapPoints
element = <ContentFilteringSettingsModal.Component /> element = <ContentFilteringSettingsModal.Component />
} else { } else {
return <View /> return null
} }
return ( return (

View File

@ -14,6 +14,7 @@ import * as ReportAccountModal from './ReportAccount'
import * as DeleteAccountModal from './DeleteAccount' import * as DeleteAccountModal from './DeleteAccount'
import * as RepostModal from './Repost' import * as RepostModal from './Repost'
import * as CropImageModal from './crop-image/CropImage.web' import * as CropImageModal from './crop-image/CropImage.web'
import * as AltTextImageModal from './AltImage'
import * as ChangeHandleModal from './ChangeHandle' import * as ChangeHandleModal from './ChangeHandle'
import * as WaitlistModal from './Waitlist' import * as WaitlistModal from './Waitlist'
import * as InviteCodesModal from './InviteCodes' import * as InviteCodesModal from './InviteCodes'
@ -78,6 +79,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <InviteCodesModal.Component /> element = <InviteCodesModal.Component />
} else if (modal.name === 'content-filtering-settings') { } else if (modal.name === 'content-filtering-settings') {
element = <ContentFilteringSettingsModal.Component /> element = <ContentFilteringSettingsModal.Component />
} else if (modal.name === 'alt-text-image') {
element = <AltTextImageModal.Component {...modal} />
} else { } else {
return null return null
} }

View File

@ -369,10 +369,7 @@ function AdditionalPostText({
<> <>
{text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>}
{images && images?.length > 0 && ( {images && images?.length > 0 && (
<ImageHorzList <ImageHorzList images={images} style={styles.additionalPostImages} />
uris={images?.map(img => img.thumb)}
style={styles.additionalPostImages}
/>
)} )}
</> </>
) )

View File

@ -9,29 +9,33 @@ import {
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {clamp} from 'lib/numbers' import {clamp} from 'lib/numbers'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import {Dim} from 'lib/media/manip' import {Dimensions} from 'lib/media/types'
export const DELAY_PRESS_IN = 500 export const DELAY_PRESS_IN = 500
const MIN_ASPECT_RATIO = 0.33 // 1/3 const MIN_ASPECT_RATIO = 0.33 // 1/3
const MAX_ASPECT_RATIO = 5 // 5/1 const MAX_ASPECT_RATIO = 5 // 5/1
export function AutoSizedImage({ interface Props {
uri, alt?: string
onPress,
onLongPress,
onPressIn,
style,
children = null,
}: {
uri: string uri: string
onPress?: () => void onPress?: () => void
onLongPress?: () => void onLongPress?: () => void
onPressIn?: () => void onPressIn?: () => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
children?: React.ReactNode children?: React.ReactNode
}) { }
export function AutoSizedImage({
alt,
uri,
onPress,
onLongPress,
onPressIn,
style,
children = null,
}: Props) {
const store = useStores() const store = useStores()
const [dim, setDim] = React.useState<Dim | undefined>( const [dim, setDim] = React.useState<Dimensions | undefined>(
store.imageSizes.get(uri), store.imageSizes.get(uri),
) )
const [aspectRatio, setAspectRatio] = React.useState<number>( const [aspectRatio, setAspectRatio] = React.useState<number>(
@ -59,20 +63,31 @@ export function AutoSizedImage({
onPressIn={onPressIn} onPressIn={onPressIn}
delayPressIn={DELAY_PRESS_IN} delayPressIn={DELAY_PRESS_IN}
style={[styles.container, style]}> 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} {children}
</TouchableOpacity> </TouchableOpacity>
) )
} }
return ( return (
<View style={[styles.container, style]}> <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} {children}
</View> </View>
) )
} }
function calc(dim: Dim) { function calc(dim: Dimensions) {
if (dim.width === 0 || dim.height === 0) { if (dim.width === 0 || dim.height === 0) {
return 1 return 1
} }

View File

@ -7,21 +7,25 @@ import {
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {AppBskyEmbedImages} from '@atproto/api'
export function ImageHorzList({ interface Props {
uris, images: AppBskyEmbedImages.ViewImage[]
onPress,
style,
}: {
uris: string[]
onPress?: (index: number) => void onPress?: (index: number) => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }
export function ImageHorzList({images, onPress, style}: Props) {
return ( return (
<View style={[styles.flexRow, style]}> <View style={[styles.flexRow, style]}>
{uris.map((uri, i) => ( {images.map(({thumb, alt}, i) => (
<TouchableWithoutFeedback key={i} onPress={() => onPress?.(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> </TouchableWithoutFeedback>
))} ))}
</View> </View>

View File

@ -9,26 +9,25 @@ import {
} from 'react-native' } from 'react-native'
import {Image, ImageStyle} from 'expo-image' import {Image, ImageStyle} from 'expo-image'
import {Dimensions} from 'lib/media/types' import {Dimensions} from 'lib/media/types'
import {AppBskyEmbedImages} from '@atproto/api'
export const DELAY_PRESS_IN = 500 export const DELAY_PRESS_IN = 500
export type ImageLayoutGridType = number interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
export function ImageLayoutGrid({
type,
uris,
onPress,
onLongPress,
onPressIn,
style,
}: {
type: ImageLayoutGridType
uris: string[]
onPress?: (index: number) => void onPress?: (index: number) => void
onLongPress?: (index: number) => void onLongPress?: (index: number) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
}) { }
export function ImageLayoutGrid({
images,
onPress,
onLongPress,
onPressIn,
style,
}: ImageLayoutGridProps) {
const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>() const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>()
const onLayout = (evt: LayoutChangeEvent) => { const onLayout = (evt: LayoutChangeEvent) => {
@ -42,8 +41,7 @@ export function ImageLayoutGrid({
<View style={style} onLayout={onLayout}> <View style={style} onLayout={onLayout}>
{containerInfo ? ( {containerInfo ? (
<ImageLayoutGridInner <ImageLayoutGridInner
type={type} images={images}
uris={uris}
onPress={onPress} onPress={onPress}
onPressIn={onPressIn} onPressIn={onPressIn}
onLongPress={onLongPress} onLongPress={onLongPress}
@ -54,41 +52,42 @@ export function ImageLayoutGrid({
) )
} }
function ImageLayoutGridInner({ interface ImageLayoutGridInnerProps {
type, images: AppBskyEmbedImages.ViewImage[]
uris,
onPress,
onLongPress,
onPressIn,
containerInfo,
}: {
type: ImageLayoutGridType
uris: string[]
onPress?: (index: number) => void onPress?: (index: number) => void
onLongPress?: (index: number) => void onLongPress?: (index: number) => void
onPressIn?: (index: number) => void onPressIn?: (index: number) => void
containerInfo: Dimensions containerInfo: Dimensions
}) { }
function ImageLayoutGridInner({
images,
onPress,
onLongPress,
onPressIn,
containerInfo,
}: ImageLayoutGridInnerProps) {
const count = images.length
const size1 = useMemo<ImageStyle>(() => { const size1 = useMemo<ImageStyle>(() => {
if (type === 3) { if (count === 3) {
const size = (containerInfo.width - 10) / 3 const size = (containerInfo.width - 10) / 3
return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
} else { } else {
const size = (containerInfo.width - 5) / 2 const size = (containerInfo.width - 5) / 2
return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
} }
}, [type, containerInfo]) }, [count, containerInfo])
const size2 = React.useMemo<ImageStyle>(() => { const size2 = React.useMemo<ImageStyle>(() => {
if (type === 3) { if (count === 3) {
const size = ((containerInfo.width - 10) / 3) * 2 + 5 const size = ((containerInfo.width - 10) / 3) * 2 + 5
return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
} else { } else {
const size = (containerInfo.width - 5) / 2 const size = (containerInfo.width - 5) / 2
return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} return {width: size, height: size, resizeMode: 'cover', borderRadius: 4}
} }
}, [type, containerInfo]) }, [count, containerInfo])
if (type === 2) { if (count === 2) {
return ( return (
<View style={styles.flexRow}> <View style={styles.flexRow}>
<TouchableOpacity <TouchableOpacity
@ -96,7 +95,12 @@ function ImageLayoutGridInner({
onPress={() => onPress?.(0)} onPress={() => onPress?.(0)}
onPressIn={() => onPressIn?.(0)} onPressIn={() => onPressIn?.(0)}
onLongPress={() => onLongPress?.(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> </TouchableOpacity>
<View style={styles.wSpace} /> <View style={styles.wSpace} />
<TouchableOpacity <TouchableOpacity
@ -104,12 +108,17 @@ function ImageLayoutGridInner({
onPress={() => onPress?.(1)} onPress={() => onPress?.(1)}
onPressIn={() => onPressIn?.(1)} onPressIn={() => onPressIn?.(1)}
onLongPress={() => onLongPress?.(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> </TouchableOpacity>
</View> </View>
) )
} }
if (type === 3) { if (count === 3) {
return ( return (
<View style={styles.flexRow}> <View style={styles.flexRow}>
<TouchableOpacity <TouchableOpacity
@ -117,7 +126,12 @@ function ImageLayoutGridInner({
onPress={() => onPress?.(0)} onPress={() => onPress?.(0)}
onPressIn={() => onPressIn?.(0)} onPressIn={() => onPressIn?.(0)}
onLongPress={() => onLongPress?.(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> </TouchableOpacity>
<View style={styles.wSpace} /> <View style={styles.wSpace} />
<View> <View>
@ -126,7 +140,12 @@ function ImageLayoutGridInner({
onPress={() => onPress?.(1)} onPress={() => onPress?.(1)}
onPressIn={() => onPressIn?.(1)} onPressIn={() => onPressIn?.(1)}
onLongPress={() => onLongPress?.(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> </TouchableOpacity>
<View style={styles.hSpace} /> <View style={styles.hSpace} />
<TouchableOpacity <TouchableOpacity
@ -134,13 +153,18 @@ function ImageLayoutGridInner({
onPress={() => onPress?.(2)} onPress={() => onPress?.(2)}
onPressIn={() => onPressIn?.(2)} onPressIn={() => onPressIn?.(2)}
onLongPress={() => onLongPress?.(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> </TouchableOpacity>
</View> </View>
</View> </View>
) )
} }
if (type === 4) { if (count === 4) {
return ( return (
<View style={styles.flexRow}> <View style={styles.flexRow}>
<View> <View>
@ -149,7 +173,12 @@ function ImageLayoutGridInner({
onPress={() => onPress?.(0)} onPress={() => onPress?.(0)}
onPressIn={() => onPressIn?.(0)} onPressIn={() => onPressIn?.(0)}
onLongPress={() => onLongPress?.(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> </TouchableOpacity>
<View style={styles.hSpace} /> <View style={styles.hSpace} />
<TouchableOpacity <TouchableOpacity
@ -157,7 +186,12 @@ function ImageLayoutGridInner({
onPress={() => onPress?.(2)} onPress={() => onPress?.(2)}
onPressIn={() => onPressIn?.(2)} onPressIn={() => onPressIn?.(2)}
onLongPress={() => onLongPress?.(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> </TouchableOpacity>
</View> </View>
<View style={styles.wSpace} /> <View style={styles.wSpace} />
@ -167,7 +201,12 @@ function ImageLayoutGridInner({
onPress={() => onPress?.(1)} onPress={() => onPress?.(1)}
onPressIn={() => onPressIn?.(1)} onPressIn={() => onPressIn?.(1)}
onLongPress={() => onLongPress?.(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> </TouchableOpacity>
<View style={styles.hSpace} /> <View style={styles.hSpace} />
<TouchableOpacity <TouchableOpacity
@ -175,7 +214,12 @@ function ImageLayoutGridInner({
onPress={() => onPress?.(3)} onPress={() => onPress?.(3)}
onPressIn={() => onPressIn?.(3)} onPressIn={() => onPressIn?.(3)}
onLongPress={() => onLongPress?.(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> </TouchableOpacity>
</View> </View>
</View> </View>

View File

@ -112,6 +112,7 @@ export function PostEmbeds({
return ( return (
<View style={[styles.imagesContainer, style]}> <View style={[styles.imagesContainer, style]}>
<AutoSizedImage <AutoSizedImage
alt={embed.images[0].alt}
uri={embed.images[0].thumb} uri={embed.images[0].thumb}
onPress={() => openLightbox(0)} onPress={() => openLightbox(0)}
onLongPress={() => onLongPress(0)} onLongPress={() => onLongPress(0)}
@ -124,8 +125,7 @@ export function PostEmbeds({
return ( return (
<View style={[styles.imagesContainer, style]}> <View style={[styles.imagesContainer, style]}>
<ImageLayoutGrid <ImageLayoutGrid
type={embed.images.length} images={embed.images}
uris={embed.images.map(img => img.thumb)}
onPress={openLightbox} onPress={openLightbox}
onLongPress={onLongPress} onLongPress={onLongPress}
onPressIn={onPressIn} onPressIn={onPressIn}

View File

@ -54,7 +54,6 @@ const ShellInner = observer(() => {
</Drawer> </Drawer>
</ErrorBoundary> </ErrorBoundary>
</View> </View>
<ModalsContainer />
<Lightbox /> <Lightbox />
<Composer <Composer
active={store.shell.isComposerActive} active={store.shell.isComposerActive}
@ -64,6 +63,7 @@ const ShellInner = observer(() => {
onPost={store.shell.composerOpts?.onPost} onPost={store.shell.composerOpts?.onPost}
quote={store.shell.composerOpts?.quote} quote={store.shell.composerOpts?.quote}
/> />
<ModalsContainer />
</> </>
) )
}) })

View File

@ -8364,10 +8364,10 @@ expo-image-picker@~14.1.1:
dependencies: dependencies:
expo-image-loader "~4.1.0" expo-image-loader "~4.1.0"
expo-image@~1.0.0: expo-image@^1.2.1:
version "1.0.0" version "1.2.1"
resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.0.0.tgz#a3670d20815d99e2527307a33761c9b0088823b1" resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.2.1.tgz#3f377cb3142de2107903f4e4f88a7f44785dee18"
integrity sha512-A1amVExKhBa/eRXuceauYtPkf9izeje5AbxEWL09tgK91rf3GSIZXM5PSDGlIM0s7dpCV+Iet2jhwcFUfWaZrw== integrity sha512-pYZFN0ctuIBA+sqUiw70rHQQ04WDyEcF549ObArdj0MNgSUCBJMFmu/jrWDmxOpEMF40lfLVIZKigJT7Bw+GYA==
expo-json-utils@~0.5.0: expo-json-utils@~0.5.0:
version "0.5.1" version "0.5.1"