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
parent
0f5735b616
commit
f0706dbe9f
|
@ -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",
|
||||
|
|
|
@ -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 ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
|
|
|
@ -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',
|
||||
|
|
|
@ -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'))
|
||||
}
|
||||
},
|
||||
})
|
||||
})
|
||||
}
|
|
@ -65,6 +65,10 @@ export class GalleryModel {
|
|||
})
|
||||
}
|
||||
|
||||
setAltText(image: ImageModel) {
|
||||
image.setAltText()
|
||||
}
|
||||
|
||||
crop(image: ImageModel) {
|
||||
image.crop()
|
||||
}
|
||||
|
|
|
@ -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, {
|
||||
|
|
|
@ -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
|
||||
| EditProfileModal
|
||||
| ServerInputModal
|
||||
| ReportPostModal
|
||||
| ReportAccountModal
|
||||
| CropImageModal
|
||||
| DeleteAccountModal
|
||||
| RepostModal
|
||||
// Account
|
||||
| ChangeHandleModal
|
||||
| DeleteAccountModal
|
||||
| EditProfileModal
|
||||
|
||||
// Curation
|
||||
| ContentFilteringSettingsModal
|
||||
|
||||
// Reporting
|
||||
| ReportAccountModal
|
||||
| ReportPostModal
|
||||
|
||||
// Posting
|
||||
| AltTextImageModal
|
||||
| CropImageModal
|
||||
| ServerInputModal
|
||||
| RepostModal
|
||||
|
||||
// Bluesky access
|
||||
| WaitlistModal
|
||||
| InviteCodesModal
|
||||
| ContentFilteringSettingsModal
|
||||
|
||||
// Generic
|
||||
| ConfirmModal
|
||||
|
||||
interface LightboxModel {}
|
||||
|
||||
|
|
|
@ -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,
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
})
|
|
@ -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 (
|
||||
|
|
|
@ -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
|
||||
}
|
||||
|
|
|
@ -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} />
|
||||
)}
|
||||
</>
|
||||
)
|
||||
|
|
|
@ -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
|
||||
|
||||
export function AutoSizedImage({
|
||||
uri,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
style,
|
||||
children = null,
|
||||
}: {
|
||||
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,
|
||||
}: 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
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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
|
||||
|
||||
export function ImageLayoutGrid({
|
||||
type,
|
||||
uris,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
style,
|
||||
}: {
|
||||
type: ImageLayoutGridType
|
||||
uris: string[]
|
||||
interface ImageLayoutGridProps {
|
||||
images: AppBskyEmbedImages.ViewImage[]
|
||||
onPress?: (index: number) => void
|
||||
onLongPress?: (index: number) => void
|
||||
onPressIn?: (index: number) => void
|
||||
style?: StyleProp<ViewStyle>
|
||||
}) {
|
||||
}
|
||||
|
||||
export function ImageLayoutGrid({
|
||||
images,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
style,
|
||||
}: 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({
|
|||
)
|
||||
}
|
||||
|
||||
function ImageLayoutGridInner({
|
||||
type,
|
||||
uris,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
containerInfo,
|
||||
}: {
|
||||
type: ImageLayoutGridType
|
||||
uris: string[]
|
||||
interface ImageLayoutGridInnerProps {
|
||||
images: AppBskyEmbedImages.ViewImage[]
|
||||
onPress?: (index: number) => void
|
||||
onLongPress?: (index: number) => void
|
||||
onPressIn?: (index: number) => void
|
||||
containerInfo: Dimensions
|
||||
}) {
|
||||
}
|
||||
|
||||
function ImageLayoutGridInner({
|
||||
images,
|
||||
onPress,
|
||||
onLongPress,
|
||||
onPressIn,
|
||||
containerInfo,
|
||||
}: 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>
|
||||
|
|
|
@ -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}
|
||||
|
|
|
@ -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 />
|
||||
</>
|
||||
)
|
||||
})
|
||||
|
|
|
@ -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"
|
||||
|
|
Loading…
Reference in New Issue