[APP-539] Rework lightbox and alt-image behaviors (#573)

* Replace the long press on the lightbox with footer controls

* Remove long-press from images in the feed

* Tune the lightbox footer control ui

* Replace the AltImageRead modal with the ability to view all alt text in the lightbox footer

* Tune lightbox footer for iOS

* Add alt text to the web lightbox

* Fix lint

* a11y slight changes

---------

Co-authored-by: renahlee <renahlee@outlook.com>
zio/stable
Paul Frazee 2023-05-04 00:54:35 -05:00 committed by GitHub
parent 4ef853ef6c
commit d97e75c62f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 127 additions and 179 deletions

View File

@ -48,11 +48,6 @@ export interface AltTextImageModal {
image: ImageModel image: ImageModel
} }
export interface AltTextImageReadModal {
name: 'alt-text-image-read'
altText: string
}
export interface DeleteAccountModal { export interface DeleteAccountModal {
name: 'delete-account' name: 'delete-account'
} }
@ -106,7 +101,6 @@ export type Modal =
// Posts // Posts
| AltTextImageModal | AltTextImageModal
| AltTextImageReadModal
| CropImageModal | CropImageModal
| ServerInputModal | ServerInputModal
| RepostModal | RepostModal
@ -127,9 +121,14 @@ export class ProfileImageLightbox implements LightboxModel {
} }
} }
interface ImagesLightboxItem {
uri: string
alt?: string
}
export class ImagesLightbox implements LightboxModel { export class ImagesLightbox implements LightboxModel {
name = 'images' name = 'images'
constructor(public uris: string[], public index: number) { constructor(public images: ImagesLightboxItem[], public index: number) {
makeAutoObservable(this) makeAutoObservable(this)
} }
setIndex(index: number) { setIndex(index: number) {
@ -173,7 +172,7 @@ export class ShellUiModel {
isModalActive = false isModalActive = false
activeModals: Modal[] = [] activeModals: Modal[] = []
isLightboxActive = false isLightboxActive = false
activeLightbox: ProfileImageLightbox | ImagesLightbox | undefined activeLightbox: ProfileImageLightbox | ImagesLightbox | null = null
isComposerActive = false isComposerActive = false
composerOpts: ComposerOpts | undefined composerOpts: ComposerOpts | undefined
@ -262,7 +261,7 @@ export class ShellUiModel {
closeLightbox() { closeLightbox() {
this.isLightboxActive = false this.isLightboxActive = false
this.activeLightbox = undefined this.activeLightbox = null
} }
openComposer(opts: ComposerOpts) { openComposer(opts: ComposerOpts) {

View File

@ -1,31 +1,75 @@
import React from 'react' import React from 'react'
import {Pressable, StyleSheet, View} from 'react-native'
import {observer} from 'mobx-react-lite' import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import ImageView from './ImageViewing' import ImageView from './ImageViewing'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import * as models from 'state/models/ui/shell' import * as models from 'state/models/ui/shell'
import {saveImageModal} from 'lib/media/manip' import {saveImageModal} from 'lib/media/manip'
import {ImageSource} from './ImageViewing/@types' import {Text} from '../util/text/Text'
import {s, colors} from 'lib/styles'
import {Button} from '../util/forms/Button'
import {isIOS} from 'platform/detection'
export const Lightbox = observer(function Lightbox() { export const Lightbox = observer(function Lightbox() {
const store = useStores() const store = useStores()
if (!store.shell.isLightboxActive) { const [isAltExpanded, setAltExpanded] = React.useState(false)
return null
}
const onClose = () => { const onClose = React.useCallback(() => {
store.shell.closeLightbox() store.shell.closeLightbox()
} }, [store])
const onLongPress = (image: ImageSource) => {
if (
typeof image === 'object' &&
'uri' in image &&
typeof image.uri === 'string'
) {
saveImageModal({uri: image.uri})
}
}
if (store.shell.activeLightbox?.name === 'profile-image') { const LightboxFooter = React.useCallback(
({imageIndex}: {imageIndex: number}) => {
const lightbox = store.shell.activeLightbox
if (!lightbox) {
return null
}
let altText = ''
let uri
if (lightbox.name === 'images') {
const opts = store.shell.activeLightbox as models.ImagesLightbox
uri = opts.images[imageIndex].uri
altText = opts.images[imageIndex].alt
} else if (store.shell.activeLightbox.name === 'profile-image') {
const opts = store.shell.activeLightbox as models.ProfileImageLightbox
uri = opts.profileView.avatar
}
return (
<View style={[styles.footer]}>
{altText ? (
<Pressable
onPress={() => setAltExpanded(!isAltExpanded)}
accessibilityRole="button">
<Text
style={[s.gray3, styles.footerText]}
numberOfLines={isAltExpanded ? undefined : 3}>
{altText}
</Text>
</Pressable>
) : null}
<View style={styles.footerBtns}>
<Button
type="primary-outline"
style={styles.footerBtn}
onPress={() => saveImageModal({uri})}>
<FontAwesomeIcon icon="arrow-up-from-bracket" style={s.white} />
<Text type="xl" style={s.white}>
Share
</Text>
</Button>
</View>
</View>
)
},
[store.shell.activeLightbox, isAltExpanded, setAltExpanded],
)
if (!store.shell.activeLightbox) {
return null
} else if (store.shell.activeLightbox.name === 'profile-image') {
const opts = store.shell.activeLightbox as models.ProfileImageLightbox const opts = store.shell.activeLightbox as models.ProfileImageLightbox
return ( return (
<ImageView <ImageView
@ -33,20 +77,44 @@ export const Lightbox = observer(function Lightbox() {
imageIndex={0} imageIndex={0}
visible visible
onRequestClose={onClose} onRequestClose={onClose}
FooterComponent={LightboxFooter}
/> />
) )
} else if (store.shell.activeLightbox?.name === 'images') { } else if (store.shell.activeLightbox.name === 'images') {
const opts = store.shell.activeLightbox as models.ImagesLightbox const opts = store.shell.activeLightbox as models.ImagesLightbox
return ( return (
<ImageView <ImageView
images={opts.uris.map(uri => ({uri}))} images={opts.images.map(({uri}) => ({uri}))}
imageIndex={opts.index} imageIndex={opts.index}
visible visible
onRequestClose={onClose} onRequestClose={onClose}
onLongPress={onLongPress} FooterComponent={LightboxFooter}
/> />
) )
} else { } else {
return null return null
} }
}) })
const styles = StyleSheet.create({
footer: {
paddingTop: 16,
paddingBottom: isIOS ? 40 : 24,
paddingHorizontal: 24,
backgroundColor: '#000d',
},
footerText: {
paddingBottom: isIOS ? 20 : 16,
},
footerBtns: {
flexDirection: 'row',
justifyContent: 'center',
},
footerBtn: {
flexDirection: 'row',
alignItems: 'center',
gap: 8,
backgroundColor: 'transparent',
borderColor: colors.white,
},
})

View File

@ -10,11 +10,13 @@ import {observer} from 'mobx-react-lite'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
import {useStores} from 'state/index' import {useStores} from 'state/index'
import * as models from 'state/models/ui/shell' import * as models from 'state/models/ui/shell'
import {colors} from 'lib/styles' import {colors, s} from 'lib/styles'
import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader' import ImageDefaultHeader from './ImageViewing/components/ImageDefaultHeader'
import {Text} from '../util/text/Text'
interface Img { interface Img {
uri: string uri: string
alt?: string
} }
export const Lightbox = observer(function Lightbox() { export const Lightbox = observer(function Lightbox() {
@ -37,7 +39,7 @@ export const Lightbox = observer(function Lightbox() {
} }
} else if (activeLightbox instanceof models.ImagesLightbox) { } else if (activeLightbox instanceof models.ImagesLightbox) {
const opts = activeLightbox const opts = activeLightbox
imgs = opts.uris.map(uri => ({uri})) imgs = opts.images
} }
if (!imgs) { if (!imgs) {
@ -131,6 +133,11 @@ function LightboxInner({
)} )}
</View> </View>
</TouchableWithoutFeedback> </TouchableWithoutFeedback>
{imgs[index].alt ? (
<View style={styles.footer}>
<Text style={s.white}>{imgs[index].alt}</Text>
</View>
) : null}
<View style={styles.closeBtn}> <View style={styles.closeBtn}>
<ImageDefaultHeader onRequestClose={onClose} /> <ImageDefaultHeader onRequestClose={onClose} />
</View> </View>
@ -183,4 +190,9 @@ const styles = StyleSheet.create({
right: 30, right: 30,
top: '50%', top: '50%',
}, },
footer: {
paddingHorizontal: 32,
paddingVertical: 24,
backgroundColor: colors.black,
},
}) })

View File

@ -1,80 +0,0 @@
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}
accessibilityRole="button"
accessibilityLabel="Done"
accessibilityHint="Closes alt text modal">
<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,7 +13,6 @@ 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'
@ -76,9 +75,6 @@ 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,7 +15,6 @@ 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'
@ -89,8 +88,6 @@ function Modal({modal}: {modal: ModalIface}) {
element = <ContentLanguagesSettingsModal.Component /> element = <ContentLanguagesSettingsModal.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

@ -1,8 +1,7 @@
import {AppBskyEmbedImages} from '@atproto/api' import {AppBskyEmbedImages} from '@atproto/api'
import React, {ComponentProps, FC, useCallback} from 'react' import React, {ComponentProps, FC} from 'react'
import {Pressable, StyleSheet, Text, TouchableOpacity, View} from 'react-native' import {StyleSheet, Text, TouchableOpacity, View} from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {useStores} from 'state/index'
type EventFunction = (index: number) => void type EventFunction = (index: number) => void
@ -26,22 +25,14 @@ export const GalleryItem: FC<GalleryItemProps> = ({
onLongPress, onLongPress,
}) => { }) => {
const image = images[index] 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 ( return (
<View> <View>
<TouchableOpacity <TouchableOpacity
delayPressIn={DELAY_PRESS_IN} delayPressIn={DELAY_PRESS_IN}
onPress={() => onPress?.(index)} onPress={onPress ? () => onPress(index) : undefined}
onPressIn={() => onPressIn?.(index)} onPressIn={onPressIn ? () => onPressIn(index) : undefined}
onLongPress={() => onLongPress?.(index)} onLongPress={onLongPress ? () => onLongPress(index) : undefined}
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel="View image" accessibilityLabel="View image"
accessibilityHint=""> accessibilityHint="">
@ -54,15 +45,7 @@ export const GalleryItem: FC<GalleryItemProps> = ({
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
/> />
</TouchableOpacity> </TouchableOpacity>
{image.alt === '' ? null : ( {image.alt === '' ? null : <Text style={styles.alt}>ALT</Text>}
<Pressable
onPress={onPressAltText}
accessibilityRole="button"
accessibilityLabel="View alt text"
accessibilityHint="Opens modal with alt text">
<Text style={styles.alt}>ALT</Text>
</Pressable>
)}
</View> </View>
) )
} }
@ -78,8 +61,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 3, paddingVertical: 3,
position: 'absolute', position: 'absolute',
left: 10, left: 6,
top: -26, bottom: 6,
width: 46, width: 46,
}, },
}) })

View File

@ -1,11 +1,10 @@
import React, {useCallback} from 'react' import React from 'react'
import { import {
StyleSheet, StyleSheet,
StyleProp, StyleProp,
View, View,
ViewStyle, ViewStyle,
Image as RNImage, Image as RNImage,
Pressable,
Text, Text,
} from 'react-native' } from 'react-native'
import { import {
@ -20,7 +19,6 @@ 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'
import {usePalette} from 'lib/hooks/usePalette' import {usePalette} from 'lib/hooks/usePalette'
import {saveImageModal} from 'lib/media/manip'
import {YoutubeEmbed} from './YoutubeEmbed' 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'
@ -44,16 +42,6 @@ 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) &&
@ -103,20 +91,17 @@ export function PostEmbeds({
const {images} = embed const {images} = embed
if (images.length > 0) { if (images.length > 0) {
const uris = embed.images.map(img => img.fullsize) const items = embed.images.map(img => ({uri: img.fullsize, alt: img.alt}))
const openLightbox = (index: number) => { const openLightbox = (index: number) => {
store.shell.openLightbox(new ImagesLightbox(uris, index)) store.shell.openLightbox(new ImagesLightbox(items, index))
}
const onLongPress = (index: number) => {
saveImageModal({uri: uris[index]})
} }
const onPressIn = (index: number) => { const onPressIn = (index: number) => {
const firstImageToShow = uris[index] const firstImageToShow = items[index].uri
RNImage.prefetch(firstImageToShow) RNImage.prefetch(firstImageToShow)
uris.forEach(uri => { items.forEach(item => {
if (firstImageToShow !== uri) { if (firstImageToShow !== item.uri) {
// First image already prefeched above // First image already prefeched above
RNImage.prefetch(uri) RNImage.prefetch(item.uri)
} }
}) })
} }
@ -129,20 +114,9 @@ export function PostEmbeds({
alt={alt} alt={alt}
uri={thumb} uri={thumb}
onPress={() => openLightbox(0)} onPress={() => openLightbox(0)}
onLongPress={() => onLongPress(0)}
onPressIn={() => onPressIn(0)} onPressIn={() => onPressIn(0)}
style={styles.singleImage}> style={styles.singleImage}>
{alt === '' ? null : ( {alt === '' ? null : <Text style={styles.alt}>ALT</Text>}
<Pressable
onPress={() => {
onPressAltText(alt)
}}
accessibilityRole="button"
accessibilityLabel="View alt text"
accessibilityHint="Opens modal with alt text">
<Text style={styles.alt}>ALT</Text>
</Pressable>
)}
</AutoSizedImage> </AutoSizedImage>
</View> </View>
) )
@ -153,7 +127,6 @@ export function PostEmbeds({
<ImageLayoutGrid <ImageLayoutGrid
images={embed.images} images={embed.images}
onPress={openLightbox} onPress={openLightbox}
onLongPress={onLongPress}
onPressIn={onPressIn} onPressIn={onPressIn}
style={embed.images.length === 1 ? styles.singleImage : undefined} style={embed.images.length === 1 ? styles.singleImage : undefined}
/> />
@ -209,8 +182,8 @@ const styles = StyleSheet.create({
paddingHorizontal: 10, paddingHorizontal: 10,
paddingVertical: 3, paddingVertical: 3,
position: 'absolute', position: 'absolute',
left: 10, left: 6,
top: -26, bottom: 6,
width: 46, width: 46,
}, },
}) })