Image alt text view modal (#551)

* Image alt text view modal

* Minor style tweaks

---------

Co-authored-by: Paul Frazee <pfrazee@gmail.com>
zio/stable
Ollie H 2023-05-01 11:59:17 -07:00 committed by GitHub
parent 0ec98c77ef
commit dbb3c5c155
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
7 changed files with 272 additions and 192 deletions

View File

@ -47,6 +47,11 @@ export interface AltTextImageModal {
onAltTextSet: (altText?: string) => void onAltTextSet: (altText?: string) => void
} }
export interface AltTextImageReadModal {
name: 'alt-text-image-read'
altText: string
}
export interface DeleteAccountModal { export interface DeleteAccountModal {
name: 'delete-account' name: 'delete-account'
} }
@ -93,8 +98,9 @@ export type Modal =
| ReportAccountModal | ReportAccountModal
| ReportPostModal | ReportPostModal
// Posting // Posts
| AltTextImageModal | AltTextImageModal
| AltTextImageReadModal
| CropImageModal | CropImageModal
| ServerInputModal | ServerInputModal
| RepostModal | RepostModal

View File

@ -0,0 +1,75 @@
import React, {useCallback} from 'react'
import {StyleSheet, View} from 'react-native'
import {usePalette} from 'lib/hooks/usePalette'
import {gradients, s} from 'lib/styles'
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 = ['70%']
interface Props {
altText: string
}
export function Component({altText}: Props) {
const pal = usePalette('default')
const store = useStores()
const onPress = useCallback(() => {
store.shell.closeModal()
}, [store])
return (
<View
testID="altTextImageModal"
style={[pal.view, styles.container, s.flex1]}>
<Text style={[styles.title, pal.text]}>Image description</Text>
<View style={[styles.text, pal.viewLight]}>
<Text style={pal.text}>{altText}</Text>
</View>
<TouchableOpacity testID="altTextImageSaveBtn" onPress={onPress}>
<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]}>
Done
</Text>
</LinearGradient>
</TouchableOpacity>
</View>
)
}
const styles = StyleSheet.create({
container: {
gap: 18,
paddingVertical: isDesktopWeb ? 0 : 18,
paddingHorizontal: isDesktopWeb ? 0 : 12,
height: '100%',
width: '100%',
},
title: {
textAlign: 'center',
fontWeight: 'bold',
fontSize: 24,
},
text: {
borderRadius: 5,
marginVertical: 18,
paddingHorizontal: 18,
paddingVertical: 16,
},
button: {
flexDirection: 'row',
alignItems: 'center',
justifyContent: 'center',
width: '100%',
borderRadius: 32,
padding: 10,
},
})

View File

@ -13,6 +13,7 @@ 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 AltImageModal from './AltImage'
import * as AltImageReadModal from './AltImageRead'
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'
@ -74,6 +75,9 @@ export const ModalsContainer = observer(function ModalsContainer() {
} else if (activeModal?.name === 'alt-text-image') { } else if (activeModal?.name === 'alt-text-image') {
snapPoints = AltImageModal.snapPoints snapPoints = AltImageModal.snapPoints
element = <AltImageModal.Component {...activeModal} /> element = <AltImageModal.Component {...activeModal} />
} else if (activeModal?.name === 'alt-text-image-read') {
snapPoints = AltImageReadModal.snapPoints
element = <AltImageReadModal.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} />

View File

@ -15,6 +15,7 @@ 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 AltTextImageModal from './AltImage'
import * as AltTextImageReadModal from './AltImageRead'
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'
@ -84,6 +85,8 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ContentFilteringSettingsModal.Component /> element = <ContentFilteringSettingsModal.Component />
} else if (modal.name === 'alt-text-image') { } else if (modal.name === 'alt-text-image') {
element = <AltTextImageModal.Component {...modal} /> element = <AltTextImageModal.Component {...modal} />
} else if (modal.name === 'alt-text-image-read') {
element = <AltTextImageReadModal.Component {...modal} />
} else { } else {
return null return null
} }

View File

@ -0,0 +1,76 @@
import {AppBskyEmbedImages} from '@atproto/api'
import React, {ComponentProps, FC, useCallback} from 'react'
import {Pressable, StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import {Image} from 'expo-image'
import {useStores} from 'state/index'
type EventFunction = (index: number) => void
interface GalleryItemProps {
images: AppBskyEmbedImages.ViewImage[]
index: number
onPress?: EventFunction
onLongPress?: EventFunction
onPressIn?: EventFunction
imageStyle: ComponentProps<typeof Image>['style']
}
const DELAY_PRESS_IN = 500
export const GalleryItem: FC<GalleryItemProps> = ({
images,
index,
imageStyle,
onPress,
onPressIn,
onLongPress,
}) => {
const image = images[index]
const store = useStores()
const onPressAltText = useCallback(() => {
store.shell.openModal({
name: 'alt-text-image-read',
altText: image.alt,
})
}, [image.alt, store.shell])
return (
<View>
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(index)}
onPressIn={() => onPressIn?.(index)}
onLongPress={() => onLongPress?.(index)}>
<Image
source={{uri: image.thumb}}
style={imageStyle}
accessible={true}
accessibilityLabel={image.alt}
/>
</TouchableOpacity>
{image.alt === '' ? null : (
<Pressable onPress={onPressAltText}>
<Text style={styles.alt}>ALT</Text>
</Pressable>
)}
</View>
)
}
const styles = StyleSheet.create({
alt: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
color: 'white',
fontSize: 12,
fontWeight: 'bold',
letterSpacing: 1,
paddingHorizontal: 10,
paddingVertical: 3,
position: 'absolute',
left: 10,
top: -26,
width: 46,
},
})

View File

@ -3,15 +3,13 @@ import {
LayoutChangeEvent, LayoutChangeEvent,
StyleProp, StyleProp,
StyleSheet, StyleSheet,
TouchableOpacity,
View, View,
ViewStyle, ViewStyle,
} from 'react-native' } from 'react-native'
import {Image, ImageStyle} from 'expo-image' import {ImageStyle} from 'expo-image'
import {Dimensions} from 'lib/media/types' import {Dimensions} from 'lib/media/types'
import {AppBskyEmbedImages} from '@atproto/api' import {AppBskyEmbedImages} from '@atproto/api'
import {GalleryItem} from './Gallery'
export const DELAY_PRESS_IN = 500
interface ImageLayoutGridProps { interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[] images: AppBskyEmbedImages.ViewImage[]
@ -21,32 +19,21 @@ interface ImageLayoutGridProps {
style?: StyleProp<ViewStyle> style?: StyleProp<ViewStyle>
} }
export function ImageLayoutGrid({ export function ImageLayoutGrid({style, ...props}: ImageLayoutGridProps) {
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) => {
const {width, height} = evt.nativeEvent.layout
setContainerInfo({ setContainerInfo({
width: evt.nativeEvent.layout.width, width,
height: evt.nativeEvent.layout.height, height,
}) })
} }
return ( return (
<View style={style} onLayout={onLayout}> <View style={style} onLayout={onLayout}>
{containerInfo ? ( {containerInfo ? (
<ImageLayoutGridInner <ImageLayoutGridInner {...props} containerInfo={containerInfo} />
images={images}
onPress={onPress}
onPressIn={onPressIn}
onLongPress={onLongPress}
containerInfo={containerInfo}
/>
) : undefined} ) : undefined}
</View> </View>
) )
@ -61,13 +48,10 @@ interface ImageLayoutGridInnerProps {
} }
function ImageLayoutGridInner({ function ImageLayoutGridInner({
images,
onPress,
onLongPress,
onPressIn,
containerInfo, containerInfo,
...props
}: ImageLayoutGridInnerProps) { }: ImageLayoutGridInnerProps) {
const count = images.length const count = props.images.length
const size1 = useMemo<ImageStyle>(() => { const size1 = useMemo<ImageStyle>(() => {
if (count === 3) { if (count === 3) {
const size = (containerInfo.width - 10) / 3 const size = (containerInfo.width - 10) / 3
@ -87,149 +71,43 @@ function ImageLayoutGridInner({
} }
}, [count, containerInfo]) }, [count, containerInfo])
if (count === 2) { switch (count) {
case 2:
return ( return (
<View style={styles.flexRow}> <View style={styles.flexRow}>
<TouchableOpacity <GalleryItem index={0} {...props} imageStyle={size1} />
delayPressIn={DELAY_PRESS_IN} <GalleryItem index={1} {...props} imageStyle={size1} />
onPress={() => onPress?.(0)}
onPressIn={() => onPressIn?.(0)}
onLongPress={() => onLongPress?.(0)}>
<Image
source={{uri: images[0].thumb}}
style={size1}
accessible={true}
accessibilityLabel={images[0].alt}
/>
</TouchableOpacity>
<View style={styles.wSpace} />
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(1)}
onPressIn={() => onPressIn?.(1)}
onLongPress={() => onLongPress?.(1)}>
<Image
source={{uri: images[1].thumb}}
style={size1}
accessible={true}
accessibilityLabel={images[1].alt}
/>
</TouchableOpacity>
</View> </View>
) )
} case 3:
if (count === 3) {
return ( return (
<View style={styles.flexRow}> <View style={styles.flexRow}>
<TouchableOpacity <GalleryItem index={0} {...props} imageStyle={size2} />
delayPressIn={DELAY_PRESS_IN} <View style={styles.flexColumn}>
onPress={() => onPress?.(0)} <GalleryItem index={1} {...props} imageStyle={size1} />
onPressIn={() => onPressIn?.(0)} <GalleryItem index={2} {...props} imageStyle={size1} />
onLongPress={() => onLongPress?.(0)}>
<Image
source={{uri: images[0].thumb}}
style={size2}
accessible={true}
accessibilityLabel={images[0].alt}
/>
</TouchableOpacity>
<View style={styles.wSpace} />
<View>
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(1)}
onPressIn={() => onPressIn?.(1)}
onLongPress={() => onLongPress?.(1)}>
<Image
source={{uri: images[1].thumb}}
style={size1}
accessible={true}
accessibilityLabel={images[1].alt}
/>
</TouchableOpacity>
<View style={styles.hSpace} />
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(2)}
onPressIn={() => onPressIn?.(2)}
onLongPress={() => onLongPress?.(2)}>
<Image
source={{uri: images[2].thumb}}
style={size1}
accessible={true}
accessibilityLabel={images[2].alt}
/>
</TouchableOpacity>
</View> </View>
</View> </View>
) )
} case 4:
if (count === 4) {
return ( return (
<View style={styles.flexRow}> <View style={styles.flexRow}>
<View> <View style={styles.flexColumn}>
<TouchableOpacity <GalleryItem index={0} {...props} imageStyle={size1} />
delayPressIn={DELAY_PRESS_IN} <GalleryItem index={2} {...props} imageStyle={size1} />
onPress={() => onPress?.(0)}
onPressIn={() => onPressIn?.(0)}
onLongPress={() => onLongPress?.(0)}>
<Image
source={{uri: images[0].thumb}}
style={size1}
accessible={true}
accessibilityLabel={images[0].alt}
/>
</TouchableOpacity>
<View style={styles.hSpace} />
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(2)}
onPressIn={() => onPressIn?.(2)}
onLongPress={() => onLongPress?.(2)}>
<Image
source={{uri: images[2].thumb}}
style={size1}
accessible={true}
accessibilityLabel={images[2].alt}
/>
</TouchableOpacity>
</View> </View>
<View style={styles.wSpace} /> <View style={styles.flexColumn}>
<View> <GalleryItem index={1} {...props} imageStyle={size1} />
<TouchableOpacity <GalleryItem index={3} {...props} imageStyle={size1} />
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(1)}
onPressIn={() => onPressIn?.(1)}
onLongPress={() => onLongPress?.(1)}>
<Image
source={{uri: images[1].thumb}}
style={size1}
accessible={true}
accessibilityLabel={images[1].alt}
/>
</TouchableOpacity>
<View style={styles.hSpace} />
<TouchableOpacity
delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(3)}
onPressIn={() => onPressIn?.(3)}
onLongPress={() => onLongPress?.(3)}>
<Image
source={{uri: images[3].thumb}}
style={size1}
accessible={true}
accessibilityLabel={images[3].alt}
/>
</TouchableOpacity>
</View> </View>
</View> </View>
) )
default:
return null
} }
return <View />
} }
const styles = StyleSheet.create({ const styles = StyleSheet.create({
flexRow: {flexDirection: 'row'}, flexRow: {flexDirection: 'row', gap: 5},
wSpace: {width: 5}, flexColumn: {flexDirection: 'column', gap: 5},
hSpace: {height: 5},
}) })

View File

@ -1,10 +1,12 @@
import React from 'react' import React, {useCallback} from 'react'
import { import {
StyleSheet, StyleSheet,
StyleProp, StyleProp,
View, View,
ViewStyle, ViewStyle,
Image as RNImage, Image as RNImage,
Pressable,
Text,
} from 'react-native' } from 'react-native'
import { import {
AppBskyEmbedImages, AppBskyEmbedImages,
@ -14,7 +16,6 @@ import {
AppBskyFeedPost, AppBskyFeedPost,
} from '@atproto/api' } from '@atproto/api'
import {Link} from '../Link' import {Link} from '../Link'
import {AutoSizedImage} from '../images/AutoSizedImage'
import {ImageLayoutGrid} from '../images/ImageLayoutGrid' import {ImageLayoutGrid} from '../images/ImageLayoutGrid'
import {ImagesLightbox} from 'state/models/ui/shell' import {ImagesLightbox} from 'state/models/ui/shell'
import {useStores} from 'state/index' import {useStores} from 'state/index'
@ -24,6 +25,7 @@ import {YoutubeEmbed} from './YoutubeEmbed'
import {ExternalLinkEmbed} from './ExternalLinkEmbed' import {ExternalLinkEmbed} from './ExternalLinkEmbed'
import {getYoutubeVideoId} from 'lib/strings/url-helpers' import {getYoutubeVideoId} from 'lib/strings/url-helpers'
import QuoteEmbed from './QuoteEmbed' import QuoteEmbed from './QuoteEmbed'
import {AutoSizedImage} from '../images/AutoSizedImage'
type Embed = type Embed =
| AppBskyEmbedRecord.View | AppBskyEmbedRecord.View
@ -42,6 +44,16 @@ export function PostEmbeds({
const pal = usePalette('default') const pal = usePalette('default')
const store = useStores() const store = useStores()
const onPressAltText = useCallback(
(alt: string) => {
store.shell.openModal({
name: 'alt-text-image-read',
altText: alt,
})
},
[store.shell],
)
if ( if (
AppBskyEmbedRecordWithMedia.isView(embed) && AppBskyEmbedRecordWithMedia.isView(embed) &&
AppBskyEmbedRecord.isViewRecord(embed.record.record) && AppBskyEmbedRecord.isViewRecord(embed.record.record) &&
@ -88,7 +100,9 @@ export function PostEmbeds({
} }
if (AppBskyEmbedImages.isView(embed)) { if (AppBskyEmbedImages.isView(embed)) {
if (embed.images.length > 0) { const {images} = embed
if (images.length > 0) {
const uris = embed.images.map(img => img.fullsize) const uris = embed.images.map(img => img.fullsize)
const openLightbox = (index: number) => { const openLightbox = (index: number) => {
store.shell.openLightbox(new ImagesLightbox(uris, index)) store.shell.openLightbox(new ImagesLightbox(uris, index))
@ -107,21 +121,30 @@ export function PostEmbeds({
}) })
} }
switch (embed.images.length) { if (images.length === 1) {
case 1: const {alt, thumb} = images[0]
return ( return (
<View style={[styles.imagesContainer, style]}> <View style={[styles.imagesContainer, style]}>
<AutoSizedImage <AutoSizedImage
alt={embed.images[0].alt} alt={alt}
uri={embed.images[0].thumb} uri={thumb}
onPress={() => openLightbox(0)} onPress={() => openLightbox(0)}
onLongPress={() => onLongPress(0)} onLongPress={() => onLongPress(0)}
onPressIn={() => onPressIn(0)} onPressIn={() => onPressIn(0)}
style={styles.singleImage} style={styles.singleImage}>
/> {alt === '' ? null : (
<Pressable
onPress={() => {
onPressAltText(alt)
}}>
<Text style={styles.alt}>ALT</Text>
</Pressable>
)}
</AutoSizedImage>
</View> </View>
) )
default: }
return ( return (
<View style={[styles.imagesContainer, style]}> <View style={[styles.imagesContainer, style]}>
<ImageLayoutGrid <ImageLayoutGrid
@ -129,10 +152,11 @@ export function PostEmbeds({
onPress={openLightbox} onPress={openLightbox}
onLongPress={onLongPress} onLongPress={onLongPress}
onPressIn={onPressIn} onPressIn={onPressIn}
style={embed.images.length === 1 ? styles.singleImage : undefined}
/> />
</View> </View>
) )
} // }
} }
} }
@ -172,4 +196,18 @@ const styles = StyleSheet.create({
borderRadius: 8, borderRadius: 8,
marginTop: 4, marginTop: 4,
}, },
alt: {
backgroundColor: 'rgba(0, 0, 0, 0.75)',
borderRadius: 6,
color: 'white',
fontSize: 12,
fontWeight: 'bold',
letterSpacing: 1,
paddingHorizontal: 10,
paddingVertical: 3,
position: 'absolute',
left: 10,
top: -26,
width: 46,
},
}) })