Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

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>

authored by

Ollie Hsieh
Paul Frazee
and committed by
GitHub
f0706dbe 0f5735b6

+405 -125
+1 -1
package.json
··· 64 64 "expo-build-properties": "~0.5.1", 65 65 "expo-camera": "~13.2.1", 66 66 "expo-dev-client": "~2.1.1", 67 - "expo-image": "~1.0.0", 67 + "expo-image": "^1.2.1", 68 68 "expo-image-picker": "~14.1.1", 69 69 "expo-localization": "~14.1.1", 70 70 "expo-media-library": "~15.2.3",
+6 -5
src/lib/api/index.ts
··· 10 10 import {AtUri} from '@atproto/api' 11 11 import {RootStoreModel} from 'state/models/root-store' 12 12 import {isNetworkError} from 'lib/strings/errors' 13 - import {Image} from 'lib/media/types' 14 13 import {LinkMeta} from '../link-meta/link-meta' 15 14 import {isWeb} from 'platform/detection' 15 + import {ImageModel} from 'state/models/media/image' 16 16 17 17 export interface ExternalEmbedDraft { 18 18 uri: string 19 19 isLoading: boolean 20 20 meta?: LinkMeta 21 - localThumb?: Image 21 + localThumb?: ImageModel 22 22 } 23 23 24 24 export async function resolveName(store: RootStoreModel, didOrHandle: string) { ··· 61 61 cid: string 62 62 } 63 63 extLink?: ExternalEmbedDraft 64 - images?: string[] 64 + images?: ImageModel[] 65 65 knownHandles?: Set<string> 66 66 onStateChange?: (state: string) => void 67 67 } ··· 109 109 const images: AppBskyEmbedImages.Image[] = [] 110 110 for (const image of opts.images) { 111 111 opts.onStateChange?.(`Uploading image #${images.length + 1}...`) 112 - const res = await uploadBlob(store, image, 'image/jpeg') 112 + const path = image.compressed?.path ?? image.path 113 + const res = await uploadBlob(store, path, 'image/jpeg') 113 114 images.push({ 114 115 image: res.data.blob, 115 - alt: '', // TODO supply alt text 116 + alt: image.altText ?? '', 116 117 }) 117 118 } 118 119
+4
src/lib/constants.ts
··· 4 4 export const MAX_DISPLAY_NAME = 64 5 5 export const MAX_DESCRIPTION = 256 6 6 7 + // Recommended is 100 per: https://www.w3.org/WAI/GL/WCAG20/tests/test3.html 8 + // but adding buffer room to account for languages like German 9 + export const MAX_ALT_TEXT = 120 10 + 7 11 export const PROD_TEAM_HANDLES = [ 8 12 'jay.bsky.social', 9 13 'pfrazee.com',
+16
src/lib/media/alt-text.ts
··· 1 + import {RootStoreModel} from 'state/index' 2 + 3 + export async function openAltTextModal(store: RootStoreModel): Promise<string> { 4 + return new Promise((resolve, reject) => { 5 + store.shell.openModal({ 6 + name: 'alt-text-image', 7 + onAltTextSet: (altText?: string) => { 8 + if (altText) { 9 + resolve(altText) 10 + } else { 11 + reject(new Error('Canceled')) 12 + } 13 + }, 14 + }) 15 + }) 16 + }
+4
src/state/models/media/gallery.ts
··· 65 65 }) 66 66 } 67 67 68 + setAltText(image: ImageModel) { 69 + image.setAltText() 70 + } 71 + 68 72 crop(image: ImageModel) { 69 73 image.crop() 70 74 }
+14
src/state/models/media/image.ts
··· 5 5 import {openCropper} from 'lib/media/picker' 6 6 import {POST_IMG_MAX} from 'lib/constants' 7 7 import {scaleDownDimensions} from 'lib/media/util' 8 + import {openAltTextModal} from 'lib/media/alt-text' 8 9 9 10 // TODO: EXIF embed 10 11 // Cases to consider: ExternalEmbed ··· 14 15 width: number 15 16 height: number 16 17 size: number 18 + altText?: string = undefined 17 19 cropped?: RNImage = undefined 18 20 compressed?: RNImage = undefined 19 21 scaledWidth: number = POST_IMG_MAX.width ··· 39 41 40 42 this.scaledWidth = width 41 43 this.scaledHeight = height 44 + } 45 + 46 + async setAltText() { 47 + try { 48 + const altText = await openAltTextModal(this.rootStore) 49 + 50 + runInAction(() => { 51 + this.altText = altText 52 + }) 53 + } catch (err) { 54 + this.rootStore.log.error('Failed to set alt text', err) 55 + } 42 56 } 43 57 44 58 async crop() {
+25 -8
src/state/models/ui/shell.ts
··· 3 3 import {makeAutoObservable} from 'mobx' 4 4 import {ProfileModel} from '../content/profile' 5 5 import {isObj, hasProp} from 'lib/type-guards' 6 - import {Image} from 'lib/media/types' 6 + import {Image as RNImage} from 'react-native-image-crop-picker' 7 7 8 8 export interface ConfirmModal { 9 9 name: 'confirm' ··· 38 38 export interface CropImageModal { 39 39 name: 'crop-image' 40 40 uri: string 41 - onSelect: (img?: Image) => void 41 + onSelect: (img?: RNImage) => void 42 + } 43 + 44 + export interface AltTextImageModal { 45 + name: 'alt-text-image' 46 + onAltTextSet: (altText?: string) => void 42 47 } 43 48 44 49 export interface DeleteAccountModal { ··· 70 75 } 71 76 72 77 export type Modal = 73 - | ConfirmModal 78 + // Account 79 + | ChangeHandleModal 80 + | DeleteAccountModal 74 81 | EditProfileModal 75 - | ServerInputModal 76 - | ReportPostModal 82 + 83 + // Curation 84 + | ContentFilteringSettingsModal 85 + 86 + // Reporting 77 87 | ReportAccountModal 88 + | ReportPostModal 89 + 90 + // Posting 91 + | AltTextImageModal 78 92 | CropImageModal 79 - | DeleteAccountModal 93 + | ServerInputModal 80 94 | RepostModal 81 - | ChangeHandleModal 95 + 96 + // Bluesky access 82 97 | WaitlistModal 83 98 | InviteCodesModal 84 - | ContentFilteringSettingsModal 99 + 100 + // Generic 101 + | ConfirmModal 85 102 86 103 interface LightboxModel {} 87 104
+1 -1
src/view/com/composer/Composer.tsx
··· 142 142 await apilib.post(store, { 143 143 rawText: rt.text, 144 144 replyTo: replyTo?.uri, 145 - images: gallery.paths, 145 + images: gallery.images, 146 146 quote: quote, 147 147 extLink: extLink, 148 148 onStateChange: setProcessingState,
+87 -36
src/view/com/composer/photos/Gallery.tsx
··· 1 1 import React, {useCallback} from 'react' 2 + import {ImageStyle, Keyboard} from 'react-native' 2 3 import {GalleryModel} from 'state/models/media/gallery' 3 4 import {observer} from 'mobx-react-lite' 4 5 import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' ··· 6 7 import {StyleSheet, TouchableOpacity, View} from 'react-native' 7 8 import {ImageModel} from 'state/models/media/image' 8 9 import {Image} from 'expo-image' 10 + import {Text} from 'view/com/util/text/Text' 11 + import {isDesktopWeb} from 'platform/detection' 9 12 10 13 interface Props { 11 14 gallery: GalleryModel ··· 13 16 14 17 export const Gallery = observer(function ({gallery}: Props) { 15 18 const getImageStyle = useCallback(() => { 16 - switch (gallery.size) { 17 - case 1: 18 - return styles.image250 19 - case 2: 20 - return styles.image175 21 - default: 22 - return styles.image85 19 + let side: number 20 + 21 + if (gallery.size === 1) { 22 + side = 250 23 + } else { 24 + side = (isDesktopWeb ? 560 : 350) / gallery.size 25 + } 26 + 27 + return { 28 + height: side, 29 + width: side, 23 30 } 24 31 }, [gallery]) 25 32 26 33 const imageStyle = getImageStyle() 34 + const handleAddImageAltText = useCallback( 35 + (image: ImageModel) => { 36 + Keyboard.dismiss() 37 + gallery.setAltText(image) 38 + }, 39 + [gallery], 40 + ) 27 41 const handleRemovePhoto = useCallback( 28 42 (image: ImageModel) => { 29 43 gallery.remove(image) ··· 38 52 [gallery], 39 53 ) 40 54 55 + const isOverflow = !isDesktopWeb && gallery.size > 2 56 + 57 + const imageControlLabelStyle = { 58 + borderRadius: 5, 59 + paddingHorizontal: 10, 60 + position: 'absolute' as const, 61 + width: 46, 62 + zIndex: 1, 63 + ...(isOverflow 64 + ? { 65 + left: 4, 66 + bottom: 4, 67 + } 68 + : isDesktopWeb && gallery.size < 3 69 + ? { 70 + left: 8, 71 + top: 8, 72 + } 73 + : { 74 + left: 4, 75 + top: 4, 76 + }), 77 + } 78 + 79 + const imageControlsSubgroupStyle = { 80 + display: 'flex' as const, 81 + flexDirection: 'row' as const, 82 + position: 'absolute' as const, 83 + ...(isOverflow 84 + ? { 85 + top: 4, 86 + right: 4, 87 + gap: 4, 88 + } 89 + : isDesktopWeb && gallery.size < 3 90 + ? { 91 + top: 8, 92 + right: 8, 93 + gap: 8, 94 + } 95 + : { 96 + top: 4, 97 + right: 4, 98 + gap: 4, 99 + }), 100 + zIndex: 1, 101 + } 102 + 41 103 return !gallery.isEmpty ? ( 42 104 <View testID="selectedPhotosView" style={styles.gallery}> 43 105 {gallery.images.map(image => 44 106 image.compressed !== undefined ? ( 45 - <View 46 - key={`selected-image-${image.path}`} 47 - style={[styles.imageContainer, imageStyle]}> 48 - <View style={styles.imageControls}> 107 + <View key={`selected-image-${image.path}`} style={[imageStyle]}> 108 + <TouchableOpacity 109 + testID="altTextButton" 110 + onPress={() => { 111 + handleAddImageAltText(image) 112 + }} 113 + style={[styles.imageControl, imageControlLabelStyle]}> 114 + <Text style={styles.imageControlTextContent}>ALT</Text> 115 + </TouchableOpacity> 116 + <View style={imageControlsSubgroupStyle}> 49 117 <TouchableOpacity 50 118 testID="cropPhotoButton" 51 119 onPress={() => { ··· 72 140 73 141 <Image 74 142 testID="selectedPhotoImage" 75 - style={[styles.image, imageStyle]} 143 + style={[styles.image, imageStyle] as ImageStyle} 76 144 source={{ 77 145 uri: image.compressed.path, 78 146 }} ··· 88 156 gallery: { 89 157 flex: 1, 90 158 flexDirection: 'row', 159 + gap: 8, 91 160 marginTop: 16, 92 161 }, 93 - imageContainer: { 94 - margin: 2, 95 - }, 96 162 image: { 97 163 resizeMode: 'cover', 98 164 borderRadius: 8, 99 165 }, 100 - image250: { 101 - width: 250, 102 - height: 250, 103 - }, 104 - image175: { 105 - width: 175, 106 - height: 175, 107 - }, 108 - image85: { 109 - width: 85, 110 - height: 85, 111 - }, 112 - imageControls: { 113 - position: 'absolute', 114 - display: 'flex', 115 - flexDirection: 'row', 116 - gap: 4, 117 - top: 8, 118 - right: 8, 119 - zIndex: 1, 120 - }, 121 166 imageControl: { 122 167 width: 24, 123 168 height: 24, ··· 126 171 borderWidth: 0.5, 127 172 alignItems: 'center', 128 173 justifyContent: 'center', 174 + }, 175 + imageControlTextContent: { 176 + color: 'white', 177 + fontSize: 12, 178 + fontWeight: 'bold', 179 + letterSpacing: 1, 129 180 }, 130 181 })
+106
src/view/com/modals/AltImage.tsx
··· 1 + import React, {useCallback, useState} from 'react' 2 + import {StyleSheet, View} from 'react-native' 3 + import {usePalette} from 'lib/hooks/usePalette' 4 + import {TextInput} from './util' 5 + import {gradients, s} from 'lib/styles' 6 + import {enforceLen} from 'lib/strings/helpers' 7 + import {MAX_ALT_TEXT} from 'lib/constants' 8 + import {useTheme} from 'lib/ThemeContext' 9 + import {Text} from '../util/text/Text' 10 + import {TouchableOpacity} from 'react-native-gesture-handler' 11 + import LinearGradient from 'react-native-linear-gradient' 12 + import {useStores} from 'state/index' 13 + import {isDesktopWeb} from 'platform/detection' 14 + 15 + export const snapPoints = [330] 16 + 17 + interface Props { 18 + onAltTextSet: (altText?: string | undefined) => void 19 + } 20 + 21 + export function Component({onAltTextSet}: Props) { 22 + const pal = usePalette('default') 23 + const store = useStores() 24 + const theme = useTheme() 25 + const [altText, setAltText] = useState('') 26 + 27 + const onPressSave = useCallback(() => { 28 + onAltTextSet(altText) 29 + store.shell.closeModal() 30 + }, [store, altText, onAltTextSet]) 31 + 32 + const onPressCancel = () => { 33 + store.shell.closeModal() 34 + } 35 + 36 + return ( 37 + <View testID="altTextImageModal" style={[pal.view, styles.container]}> 38 + <Text style={[styles.title, pal.text]}>Add alt text</Text> 39 + <TextInput 40 + testID="altTextImageInput" 41 + style={[styles.textArea, pal.border, pal.text]} 42 + keyboardAppearance={theme.colorScheme} 43 + multiline 44 + value={altText} 45 + onChangeText={text => setAltText(enforceLen(text, MAX_ALT_TEXT))} 46 + /> 47 + <View style={styles.buttonControls}> 48 + <TouchableOpacity testID="altTextImageSaveBtn" onPress={onPressSave}> 49 + <LinearGradient 50 + colors={[gradients.blueLight.start, gradients.blueLight.end]} 51 + start={{x: 0, y: 0}} 52 + end={{x: 1, y: 1}} 53 + style={[styles.button]}> 54 + <Text type="button-lg" style={[s.white, s.bold]}> 55 + Save 56 + </Text> 57 + </LinearGradient> 58 + </TouchableOpacity> 59 + <TouchableOpacity 60 + testID="altTextImageCancelBtn" 61 + onPress={onPressCancel}> 62 + <View style={[styles.button]}> 63 + <Text type="button-lg" style={[pal.textLight]}> 64 + Cancel 65 + </Text> 66 + </View> 67 + </TouchableOpacity> 68 + </View> 69 + </View> 70 + ) 71 + } 72 + 73 + const styles = StyleSheet.create({ 74 + container: { 75 + gap: 18, 76 + bottom: 0, 77 + paddingVertical: 18, 78 + paddingHorizontal: isDesktopWeb ? 0 : 12, 79 + width: '100%', 80 + }, 81 + title: { 82 + textAlign: 'center', 83 + fontWeight: 'bold', 84 + fontSize: 24, 85 + }, 86 + textArea: { 87 + borderWidth: 1, 88 + borderRadius: 6, 89 + paddingTop: 10, 90 + paddingHorizontal: 12, 91 + fontSize: 16, 92 + height: 100, 93 + textAlignVertical: 'top', 94 + }, 95 + button: { 96 + flexDirection: 'row', 97 + alignItems: 'center', 98 + justifyContent: 'center', 99 + width: '100%', 100 + borderRadius: 32, 101 + padding: 10, 102 + }, 103 + buttonControls: { 104 + gap: 8, 105 + }, 106 + })
+6 -2
src/view/com/modals/Modal.tsx
··· 1 1 import React, {useRef, useEffect} from 'react' 2 - import {StyleSheet, View} from 'react-native' 2 + import {StyleSheet} from 'react-native' 3 3 import {observer} from 'mobx-react-lite' 4 4 import BottomSheet from '@gorhom/bottom-sheet' 5 5 import {useStores} from 'state/index' ··· 11 11 import * as ServerInputModal from './ServerInput' 12 12 import * as ReportPostModal from './ReportPost' 13 13 import * as RepostModal from './Repost' 14 + import * as AltImageModal from './AltImage' 14 15 import * as ReportAccountModal from './ReportAccount' 15 16 import * as DeleteAccountModal from './DeleteAccount' 16 17 import * as ChangeHandleModal from './ChangeHandle' ··· 68 69 } else if (activeModal?.name === 'repost') { 69 70 snapPoints = RepostModal.snapPoints 70 71 element = <RepostModal.Component {...activeModal} /> 72 + } else if (activeModal?.name === 'alt-text-image') { 73 + snapPoints = AltImageModal.snapPoints 74 + element = <AltImageModal.Component {...activeModal} /> 71 75 } else if (activeModal?.name === 'change-handle') { 72 76 snapPoints = ChangeHandleModal.snapPoints 73 77 element = <ChangeHandleModal.Component {...activeModal} /> ··· 81 85 snapPoints = ContentFilteringSettingsModal.snapPoints 82 86 element = <ContentFilteringSettingsModal.Component /> 83 87 } else { 84 - return <View /> 88 + return null 85 89 } 86 90 87 91 return (
+3
src/view/com/modals/Modal.web.tsx
··· 14 14 import * as DeleteAccountModal from './DeleteAccount' 15 15 import * as RepostModal from './Repost' 16 16 import * as CropImageModal from './crop-image/CropImage.web' 17 + import * as AltTextImageModal from './AltImage' 17 18 import * as ChangeHandleModal from './ChangeHandle' 18 19 import * as WaitlistModal from './Waitlist' 19 20 import * as InviteCodesModal from './InviteCodes' ··· 78 79 element = <InviteCodesModal.Component /> 79 80 } else if (modal.name === 'content-filtering-settings') { 80 81 element = <ContentFilteringSettingsModal.Component /> 82 + } else if (modal.name === 'alt-text-image') { 83 + element = <AltTextImageModal.Component {...modal} /> 81 84 } else { 82 85 return null 83 86 }
+1 -4
src/view/com/notifications/FeedItem.tsx
··· 369 369 <> 370 370 {text?.length > 0 && <Text style={pal.textLight}>{text}</Text>} 371 371 {images && images?.length > 0 && ( 372 - <ImageHorzList 373 - uris={images?.map(img => img.thumb)} 374 - style={styles.additionalPostImages} 375 - /> 372 + <ImageHorzList images={images} style={styles.additionalPostImages} /> 376 373 )} 377 374 </> 378 375 )
+28 -13
src/view/com/util/images/AutoSizedImage.tsx
··· 9 9 import {Image} from 'expo-image' 10 10 import {clamp} from 'lib/numbers' 11 11 import {useStores} from 'state/index' 12 - import {Dim} from 'lib/media/manip' 12 + import {Dimensions} from 'lib/media/types' 13 13 14 14 export const DELAY_PRESS_IN = 500 15 15 const MIN_ASPECT_RATIO = 0.33 // 1/3 16 16 const MAX_ASPECT_RATIO = 5 // 5/1 17 17 18 + interface Props { 19 + alt?: string 20 + uri: string 21 + onPress?: () => void 22 + onLongPress?: () => void 23 + onPressIn?: () => void 24 + style?: StyleProp<ViewStyle> 25 + children?: React.ReactNode 26 + } 27 + 18 28 export function AutoSizedImage({ 29 + alt, 19 30 uri, 20 31 onPress, 21 32 onLongPress, 22 33 onPressIn, 23 34 style, 24 35 children = null, 25 - }: { 26 - uri: string 27 - onPress?: () => void 28 - onLongPress?: () => void 29 - onPressIn?: () => void 30 - style?: StyleProp<ViewStyle> 31 - children?: React.ReactNode 32 - }) { 36 + }: Props) { 33 37 const store = useStores() 34 - const [dim, setDim] = React.useState<Dim | undefined>( 38 + const [dim, setDim] = React.useState<Dimensions | undefined>( 35 39 store.imageSizes.get(uri), 36 40 ) 37 41 const [aspectRatio, setAspectRatio] = React.useState<number>( ··· 59 63 onPressIn={onPressIn} 60 64 delayPressIn={DELAY_PRESS_IN} 61 65 style={[styles.container, style]}> 62 - <Image style={[styles.image, {aspectRatio}]} source={uri} /> 66 + <Image 67 + style={[styles.image, {aspectRatio}]} 68 + source={uri} 69 + accessible={true} // Must set for `accessibilityLabel` to work 70 + accessibilityLabel={alt} 71 + /> 63 72 {children} 64 73 </TouchableOpacity> 65 74 ) 66 75 } 76 + 67 77 return ( 68 78 <View style={[styles.container, style]}> 69 - <Image style={[styles.image, {aspectRatio}]} source={{uri}} /> 79 + <Image 80 + style={[styles.image, {aspectRatio}]} 81 + source={{uri}} 82 + accessible={true} // Must set for `accessibilityLabel` to work 83 + accessibilityLabel={alt} 84 + /> 70 85 {children} 71 86 </View> 72 87 ) 73 88 } 74 89 75 - function calc(dim: Dim) { 90 + function calc(dim: Dimensions) { 76 91 if (dim.width === 0 || dim.height === 0) { 77 92 return 1 78 93 }
+13 -9
src/view/com/util/images/ImageHorzList.tsx
··· 7 7 ViewStyle, 8 8 } from 'react-native' 9 9 import {Image} from 'expo-image' 10 + import {AppBskyEmbedImages} from '@atproto/api' 10 11 11 - export function ImageHorzList({ 12 - uris, 13 - onPress, 14 - style, 15 - }: { 16 - uris: string[] 12 + interface Props { 13 + images: AppBskyEmbedImages.ViewImage[] 17 14 onPress?: (index: number) => void 18 15 style?: StyleProp<ViewStyle> 19 - }) { 16 + } 17 + 18 + export function ImageHorzList({images, onPress, style}: Props) { 20 19 return ( 21 20 <View style={[styles.flexRow, style]}> 22 - {uris.map((uri, i) => ( 21 + {images.map(({thumb, alt}, i) => ( 23 22 <TouchableWithoutFeedback key={i} onPress={() => onPress?.(i)}> 24 - <Image source={{uri}} style={styles.image} /> 23 + <Image 24 + source={{uri: thumb}} 25 + style={styles.image} 26 + accessible={true} 27 + accessibilityLabel={alt} 28 + /> 25 29 </TouchableWithoutFeedback> 26 30 ))} 27 31 </View>
+83 -39
src/view/com/util/images/ImageLayoutGrid.tsx
··· 9 9 } from 'react-native' 10 10 import {Image, ImageStyle} from 'expo-image' 11 11 import {Dimensions} from 'lib/media/types' 12 + import {AppBskyEmbedImages} from '@atproto/api' 12 13 13 14 export const DELAY_PRESS_IN = 500 14 15 15 - export type ImageLayoutGridType = number 16 + interface ImageLayoutGridProps { 17 + images: AppBskyEmbedImages.ViewImage[] 18 + onPress?: (index: number) => void 19 + onLongPress?: (index: number) => void 20 + onPressIn?: (index: number) => void 21 + style?: StyleProp<ViewStyle> 22 + } 16 23 17 24 export function ImageLayoutGrid({ 18 - type, 19 - uris, 25 + images, 20 26 onPress, 21 27 onLongPress, 22 28 onPressIn, 23 29 style, 24 - }: { 25 - type: ImageLayoutGridType 26 - uris: string[] 27 - onPress?: (index: number) => void 28 - onLongPress?: (index: number) => void 29 - onPressIn?: (index: number) => void 30 - style?: StyleProp<ViewStyle> 31 - }) { 30 + }: ImageLayoutGridProps) { 32 31 const [containerInfo, setContainerInfo] = useState<Dimensions | undefined>() 33 32 34 33 const onLayout = (evt: LayoutChangeEvent) => { ··· 42 41 <View style={style} onLayout={onLayout}> 43 42 {containerInfo ? ( 44 43 <ImageLayoutGridInner 45 - type={type} 46 - uris={uris} 44 + images={images} 47 45 onPress={onPress} 48 46 onPressIn={onPressIn} 49 47 onLongPress={onLongPress} ··· 54 52 ) 55 53 } 56 54 55 + interface ImageLayoutGridInnerProps { 56 + images: AppBskyEmbedImages.ViewImage[] 57 + onPress?: (index: number) => void 58 + onLongPress?: (index: number) => void 59 + onPressIn?: (index: number) => void 60 + containerInfo: Dimensions 61 + } 62 + 57 63 function ImageLayoutGridInner({ 58 - type, 59 - uris, 64 + images, 60 65 onPress, 61 66 onLongPress, 62 67 onPressIn, 63 68 containerInfo, 64 - }: { 65 - type: ImageLayoutGridType 66 - uris: string[] 67 - onPress?: (index: number) => void 68 - onLongPress?: (index: number) => void 69 - onPressIn?: (index: number) => void 70 - containerInfo: Dimensions 71 - }) { 69 + }: ImageLayoutGridInnerProps) { 70 + const count = images.length 72 71 const size1 = useMemo<ImageStyle>(() => { 73 - if (type === 3) { 72 + if (count === 3) { 74 73 const size = (containerInfo.width - 10) / 3 75 74 return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} 76 75 } else { 77 76 const size = (containerInfo.width - 5) / 2 78 77 return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} 79 78 } 80 - }, [type, containerInfo]) 79 + }, [count, containerInfo]) 81 80 const size2 = React.useMemo<ImageStyle>(() => { 82 - if (type === 3) { 81 + if (count === 3) { 83 82 const size = ((containerInfo.width - 10) / 3) * 2 + 5 84 83 return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} 85 84 } else { 86 85 const size = (containerInfo.width - 5) / 2 87 86 return {width: size, height: size, resizeMode: 'cover', borderRadius: 4} 88 87 } 89 - }, [type, containerInfo]) 88 + }, [count, containerInfo]) 90 89 91 - if (type === 2) { 90 + if (count === 2) { 92 91 return ( 93 92 <View style={styles.flexRow}> 94 93 <TouchableOpacity ··· 96 95 onPress={() => onPress?.(0)} 97 96 onPressIn={() => onPressIn?.(0)} 98 97 onLongPress={() => onLongPress?.(0)}> 99 - <Image source={{uri: uris[0]}} style={size1} /> 98 + <Image 99 + source={{uri: images[0].thumb}} 100 + style={size1} 101 + accessible={true} 102 + accessibilityLabel={images[0].alt} 103 + /> 100 104 </TouchableOpacity> 101 105 <View style={styles.wSpace} /> 102 106 <TouchableOpacity ··· 104 108 onPress={() => onPress?.(1)} 105 109 onPressIn={() => onPressIn?.(1)} 106 110 onLongPress={() => onLongPress?.(1)}> 107 - <Image source={{uri: uris[1]}} style={size1} /> 111 + <Image 112 + source={{uri: images[1].thumb}} 113 + style={size1} 114 + accessible={true} 115 + accessibilityLabel={images[1].alt} 116 + /> 108 117 </TouchableOpacity> 109 118 </View> 110 119 ) 111 120 } 112 - if (type === 3) { 121 + if (count === 3) { 113 122 return ( 114 123 <View style={styles.flexRow}> 115 124 <TouchableOpacity ··· 117 126 onPress={() => onPress?.(0)} 118 127 onPressIn={() => onPressIn?.(0)} 119 128 onLongPress={() => onLongPress?.(0)}> 120 - <Image source={{uri: uris[0]}} style={size2} /> 129 + <Image 130 + source={{uri: images[0].thumb}} 131 + style={size2} 132 + accessible={true} 133 + accessibilityLabel={images[0].alt} 134 + /> 121 135 </TouchableOpacity> 122 136 <View style={styles.wSpace} /> 123 137 <View> ··· 126 140 onPress={() => onPress?.(1)} 127 141 onPressIn={() => onPressIn?.(1)} 128 142 onLongPress={() => onLongPress?.(1)}> 129 - <Image source={{uri: uris[1]}} style={size1} /> 143 + <Image 144 + source={{uri: images[1].thumb}} 145 + style={size1} 146 + accessible={true} 147 + accessibilityLabel={images[1].alt} 148 + /> 130 149 </TouchableOpacity> 131 150 <View style={styles.hSpace} /> 132 151 <TouchableOpacity ··· 134 153 onPress={() => onPress?.(2)} 135 154 onPressIn={() => onPressIn?.(2)} 136 155 onLongPress={() => onLongPress?.(2)}> 137 - <Image source={{uri: uris[2]}} style={size1} /> 156 + <Image 157 + source={{uri: images[2].thumb}} 158 + style={size1} 159 + accessible={true} 160 + accessibilityLabel={images[2].alt} 161 + /> 138 162 </TouchableOpacity> 139 163 </View> 140 164 </View> 141 165 ) 142 166 } 143 - if (type === 4) { 167 + if (count === 4) { 144 168 return ( 145 169 <View style={styles.flexRow}> 146 170 <View> ··· 149 173 onPress={() => onPress?.(0)} 150 174 onPressIn={() => onPressIn?.(0)} 151 175 onLongPress={() => onLongPress?.(0)}> 152 - <Image source={{uri: uris[0]}} style={size1} /> 176 + <Image 177 + source={{uri: images[0].thumb}} 178 + style={size1} 179 + accessible={true} 180 + accessibilityLabel={images[0].alt} 181 + /> 153 182 </TouchableOpacity> 154 183 <View style={styles.hSpace} /> 155 184 <TouchableOpacity ··· 157 186 onPress={() => onPress?.(2)} 158 187 onPressIn={() => onPressIn?.(2)} 159 188 onLongPress={() => onLongPress?.(2)}> 160 - <Image source={{uri: uris[2]}} style={size1} /> 189 + <Image 190 + source={{uri: images[2].thumb}} 191 + style={size1} 192 + accessible={true} 193 + accessibilityLabel={images[2].alt} 194 + /> 161 195 </TouchableOpacity> 162 196 </View> 163 197 <View style={styles.wSpace} /> ··· 167 201 onPress={() => onPress?.(1)} 168 202 onPressIn={() => onPressIn?.(1)} 169 203 onLongPress={() => onLongPress?.(1)}> 170 - <Image source={{uri: uris[1]}} style={size1} /> 204 + <Image 205 + source={{uri: images[1].thumb}} 206 + style={size1} 207 + accessible={true} 208 + accessibilityLabel={images[1].alt} 209 + /> 171 210 </TouchableOpacity> 172 211 <View style={styles.hSpace} /> 173 212 <TouchableOpacity ··· 175 214 onPress={() => onPress?.(3)} 176 215 onPressIn={() => onPressIn?.(3)} 177 216 onLongPress={() => onLongPress?.(3)}> 178 - <Image source={{uri: uris[3]}} style={size1} /> 217 + <Image 218 + source={{uri: images[3].thumb}} 219 + style={size1} 220 + accessible={true} 221 + accessibilityLabel={images[3].alt} 222 + /> 179 223 </TouchableOpacity> 180 224 </View> 181 225 </View>
+2 -2
src/view/com/util/post-embeds/index.tsx
··· 112 112 return ( 113 113 <View style={[styles.imagesContainer, style]}> 114 114 <AutoSizedImage 115 + alt={embed.images[0].alt} 115 116 uri={embed.images[0].thumb} 116 117 onPress={() => openLightbox(0)} 117 118 onLongPress={() => onLongPress(0)} ··· 124 125 return ( 125 126 <View style={[styles.imagesContainer, style]}> 126 127 <ImageLayoutGrid 127 - type={embed.images.length} 128 - uris={embed.images.map(img => img.thumb)} 128 + images={embed.images} 129 129 onPress={openLightbox} 130 130 onLongPress={onLongPress} 131 131 onPressIn={onPressIn}
+1 -1
src/view/shell/index.tsx
··· 54 54 </Drawer> 55 55 </ErrorBoundary> 56 56 </View> 57 - <ModalsContainer /> 58 57 <Lightbox /> 59 58 <Composer 60 59 active={store.shell.isComposerActive} ··· 64 63 onPost={store.shell.composerOpts?.onPost} 65 64 quote={store.shell.composerOpts?.quote} 66 65 /> 66 + <ModalsContainer /> 67 67 </> 68 68 ) 69 69 })
+4 -4
yarn.lock
··· 8364 8364 dependencies: 8365 8365 expo-image-loader "~4.1.0" 8366 8366 8367 - expo-image@~1.0.0: 8368 - version "1.0.0" 8369 - resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.0.0.tgz#a3670d20815d99e2527307a33761c9b0088823b1" 8370 - integrity sha512-A1amVExKhBa/eRXuceauYtPkf9izeje5AbxEWL09tgK91rf3GSIZXM5PSDGlIM0s7dpCV+Iet2jhwcFUfWaZrw== 8367 + expo-image@^1.2.1: 8368 + version "1.2.1" 8369 + resolved "https://registry.yarnpkg.com/expo-image/-/expo-image-1.2.1.tgz#3f377cb3142de2107903f4e4f88a7f44785dee18" 8370 + integrity sha512-pYZFN0ctuIBA+sqUiw70rHQQ04WDyEcF549ObArdj0MNgSUCBJMFmu/jrWDmxOpEMF40lfLVIZKigJT7Bw+GYA== 8371 8371 8372 8372 expo-json-utils@~0.5.0: 8373 8373 version "0.5.1"