update scrolling and bleeding edges

This commit is contained in:
vineyardbovines
2026-04-01 14:59:45 -04:00
parent e1b8d25dba
commit 01cd962c09
3 changed files with 142 additions and 132 deletions
+1
View File
@@ -308,6 +308,7 @@ export function QuoteEmbed({
<Embed <Embed
embed={quote.embed} embed={quote.embed}
moderation={moderation} moderation={moderation}
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
isWithinQuote={parentIsWithinQuote ?? true} isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested // already within quote? override nested
allowNestedQuotes={ allowNestedQuotes={
+70 -42
View File
@@ -1,5 +1,5 @@
import {useContext, useMemo, useRef, useState} from 'react' import {useContext, useMemo, useRef, useState} from 'react'
import {FlatList, Pressable, View} from 'react-native' import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
import {DrawerGestureContext} from 'react-native-drawer-layout' import {DrawerGestureContext} from 'react-native-drawer-layout'
import {Gesture, GestureDetector} from 'react-native-gesture-handler' import {Gesture, GestureDetector} from 'react-native-gesture-handler'
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated' import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
@@ -20,8 +20,7 @@ import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
const CONTAINER_ASPECT_RATIO = 4 / 3 const CONTAINER_ASPECT_RATIO = 4 / 3
const PEEK_WIDTH = 40 const ITEM_GAP = 8 // tokens.space.sm
const ITEM_GAP = 6
interface GalleryProps { interface GalleryProps {
images: AppBskyEmbedImages.ViewImage[] images: AppBskyEmbedImages.ViewImage[]
@@ -45,8 +44,9 @@ export function Gallery({
const ax = useAnalytics() const ax = useAnalytics()
const {screenReaderEnabled} = useA11y() const {screenReaderEnabled} = useA11y()
const largeAltBadge = useLargeAltBadgeEnabled() const largeAltBadge = useLargeAltBadgeEnabled()
const [currentPage, setCurrentPage] = useState(0)
const currentPageRef = useRef(0) const currentPageRef = useRef(0)
const {width: windowWidth} = useWindowDimensions()
const [leftOffset, setLeftOffset] = useState(0)
const [containerWidth, setContainerWidth] = useState(0) const [containerWidth, setContainerWidth] = useState(0)
const containerRefs = useRef<AnimatedRef<any>[]>([]).current const containerRefs = useRef<AnimatedRef<any>[]>([]).current
@@ -61,11 +61,34 @@ export function Gallery({
containerRefs[i] = refs[i] containerRefs[i] = refs[i]
} }
const hideBadges = const isWithinQuote =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const hideBadges = isWithinQuote
const itemWidth = containerWidth > 0 ? containerWidth - PEEK_WIDTH : 0 const containerHeight =
const snapInterval = itemWidth + ITEM_GAP containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0
// Bleed: full-width carousel that extends to screen edges
// In quotes: small bleed to the quote card border (p_md = 12px)
const QUOTE_PADDING = 12
const bleed = !isWithinQuote
const insetLeft = bleed
? leftOffset || windowWidth - containerWidth
: QUOTE_PADDING
const insetRight = bleed
? windowWidth - insetLeft - containerWidth
: QUOTE_PADDING
const getItemWidth = (image: AppBskyEmbedImages.ViewImage) => {
const ar = image.aspectRatio
if (ar && ar.width > 0 && ar.height > 0) {
const ratio = ar.width / ar.height
// Width derived from image's own aspect ratio at the fixed container height
const w = containerHeight * ratio
// Clamp: at least 40% of content width, at most the full content width
return Math.max(containerWidth * 0.4, Math.min(w, containerWidth))
}
return containerWidth
}
if (screenReaderEnabled) { if (screenReaderEnabled) {
return ( return (
@@ -125,8 +148,22 @@ export function Gallery({
return ( return (
<View <View
style={[a.rounded_md, a.overflow_hidden]} style={
onLayout={e => setContainerWidth(e.nativeEvent.layout.width)}> containerWidth > 0
? {height: containerHeight, overflow: 'visible'}
: {aspectRatio: CONTAINER_ASPECT_RATIO}
}
onLayout={e => {
const w = e.nativeEvent.layout.width
if (w > 0) {
setContainerWidth(w)
}
e.target.measureInWindow((x: number) => {
if (x > 0) {
setLeftOffset(x)
}
})
}}>
{containerWidth > 0 && ( {containerWidth > 0 && (
<DrawerGestureBlocker> <DrawerGestureBlocker>
<FlatList <FlatList
@@ -134,14 +171,31 @@ export function Gallery({
horizontal horizontal
pagingEnabled={false} pagingEnabled={false}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
snapToOffsets={images.map((_, i) => i * snapInterval)}
decelerationRate="normal" decelerationRate="normal"
disableIntervalMomentum style={{
contentContainerStyle={{gap: ITEM_GAP}} width: bleed ? windowWidth : containerWidth + QUOTE_PADDING * 2,
height: containerHeight,
marginLeft: -insetLeft,
}}
contentContainerStyle={{
gap: ITEM_GAP,
paddingLeft: insetLeft,
paddingRight: insetRight,
}}
onScroll={e => { onScroll={e => {
const offsetX = e.nativeEvent.contentOffset.x const offsetX = e.nativeEvent.contentOffset.x
if (snapInterval > 0) { // Determine which item is most visible based on scroll position
const page = Math.round(offsetX / snapInterval) let accumulated = insetLeft // account for left content padding
let page = 0
for (let i = 0; i < images.length; i++) {
const w = getItemWidth(images[i]) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
page = i
break
}
accumulated += w
page = i
}
if (page !== currentPageRef.current) { if (page !== currentPageRef.current) {
ax.metric('post:gallery:swipe', { ax.metric('post:gallery:swipe', {
fromIndex: currentPageRef.current, fromIndex: currentPageRef.current,
@@ -149,8 +203,6 @@ export function Gallery({
totalImages: images.length, totalImages: images.length,
}) })
currentPageRef.current = page currentPageRef.current = page
setCurrentPage(page)
}
} }
}} }}
scrollEventThrottle={16} scrollEventThrottle={16}
@@ -161,8 +213,8 @@ export function Gallery({
collapsable={false} collapsable={false}
style={[ style={[
{ {
width: itemWidth, width: getItemWidth(image),
aspectRatio: CONTAINER_ASPECT_RATIO, height: containerHeight,
}, },
]}> ]}>
<Pressable <Pressable
@@ -250,30 +302,6 @@ export function Gallery({
/> />
</DrawerGestureBlocker> </DrawerGestureBlocker>
)} )}
{images.length > 1 && (
<View
accessible={false}
style={[
a.absolute,
a.rounded_full,
t.atoms.bg_contrast_975,
{
bottom: a.p_xs.padding,
left: a.p_xs.padding,
paddingHorizontal: 8,
paddingVertical: 4,
opacity: 0.75,
},
]}>
<Text
style={[
a.font_bold,
{fontSize: 11, color: t.atoms.text_inverted.color},
]}>
{currentPage + 1}/{images.length}
</Text>
</View>
)}
</View> </View>
) )
} }
+64 -83
View File
@@ -1,14 +1,13 @@
import {memo, useMemo, useState} from 'react' import {memo, useState} from 'react'
import { import {
findNodeHandle, findNodeHandle,
type ImageStyle,
Keyboard, Keyboard,
type LayoutChangeEvent, type LayoutChangeEvent,
Platform, Platform,
ScrollView,
StyleSheet, StyleSheet,
TouchableOpacity, TouchableOpacity,
View, View,
type ViewStyle,
} from 'react-native' } from 'react-native'
import {Image} from 'expo-image' import {Image} from 'expo-image'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome' import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -16,7 +15,6 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react' import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro' import {Trans} from '@lingui/react/macro'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type Dimensions} from '#/lib/media/types' import {type Dimensions} from '#/lib/media/types'
import {colors} from '#/lib/styles' import {colors} from '#/lib/styles'
import {type ComposerImage, cropImage} from '#/state/gallery' import {type ComposerImage, cropImage} from '#/state/gallery'
@@ -32,6 +30,7 @@ import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog' import {ImageAltTextDialog} from './ImageAltTextDialog'
const IMAGE_GAP = 8 const IMAGE_GAP = 8
const CONTAINER_HEIGHT = 200
interface GalleryProps { interface GalleryProps {
images: ComposerImage[] images: ComposerImage[]
@@ -63,53 +62,33 @@ interface GalleryInnerProps extends GalleryProps {
containerInfo: Dimensions containerInfo: Dimensions
} }
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { const getItemWidth = (image: ComposerImage, height: number) => {
const {isMobile} = useWebMediaQueries() const source = image.transformed ?? image.source
if (source.width > 0 && source.height > 0) {
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => { const ratio = source.width / source.height
const side = const w = height * ratio
images.length === 1 // Clamp: at least 60% of height, at most 1.5x height
? 250 return Math.max(height * 0.6, Math.min(w, height * 1.5))
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
images.length
const isOverflow = isMobile && images.length > 2
return {
altTextControlStyle: isOverflow
? {left: 4, bottom: 4}
: !isMobile && images.length < 3
? {left: 8, top: 8}
: {left: 4, top: 4},
imageControlsStyle: {
display: 'flex' as const,
flexDirection: 'row' as const,
position: 'absolute' as const,
...(isOverflow
? {top: 4, right: 4, gap: 4}
: !isMobile && images.length < 3
? {top: 8, right: 8, gap: 8}
: {top: 4, right: 4, gap: 4}),
zIndex: 1,
},
imageStyle: {
height: side,
width: side,
},
} }
}, [images.length, containerInfo, isMobile]) return height
}
const GalleryInner = ({images, dispatch}: GalleryInnerProps) => {
return images.length !== 0 ? ( return images.length !== 0 ? (
<> <>
<View testID="selectedPhotosView" style={styles.gallery}> <ScrollView
testID="selectedPhotosView"
horizontal
showsHorizontalScrollIndicator={false}
contentContainerStyle={{gap: IMAGE_GAP, paddingTop: 16}}
style={{height: CONTAINER_HEIGHT + 16}}>
{images.map(image => { {images.map(image => {
return ( return (
<GalleryItem <GalleryItem
key={image.source.id} key={image.source.id}
image={image} image={image}
altTextControlStyle={altTextControlStyle} itemWidth={getItemWidth(image, CONTAINER_HEIGHT)}
imageControlsStyle={imageControlsStyle} itemHeight={CONTAINER_HEIGHT}
imageStyle={imageStyle}
onChange={next => { onChange={next => {
dispatch({type: 'embed_update_image', image: next}) dispatch({type: 'embed_update_image', image: next})
}} }}
@@ -119,7 +98,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
/> />
) )
})} })}
</View> </ScrollView>
<Admonition type="tip" style={[a.mt_sm]}> <Admonition type="tip" style={[a.mt_sm]}>
<Trans> <Trans>
Alt text describes images for blind and low-vision users, and helps Alt text describes images for blind and low-vision users, and helps
@@ -132,18 +111,16 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
type GalleryItemProps = { type GalleryItemProps = {
image: ComposerImage image: ComposerImage
altTextControlStyle?: ViewStyle itemWidth: number
imageControlsStyle?: ViewStyle itemHeight: number
imageStyle?: ImageStyle
onChange: (next: ComposerImage) => void onChange: (next: ComposerImage) => void
onRemove: () => void onRemove: () => void
} }
const GalleryItem = ({ const GalleryItem = ({
image, image,
altTextControlStyle, itemWidth,
imageControlsStyle, itemHeight,
imageStyle,
onChange, onChange,
onRemove, onRemove,
}: GalleryItemProps): React.ReactNode => { }: GalleryItemProps): React.ReactNode => {
@@ -156,7 +133,6 @@ const GalleryItem = ({
const [altBtnViewTag, setAltBtnViewTag] = useState<number>() const [altBtnViewTag, setAltBtnViewTag] = useState<number>()
const altBtnRef = (node: View | null) => { const altBtnRef = (node: View | null) => {
// for iOS 26 fluid transition
if (IS_IOS && node) { if (IS_IOS && node) {
const tag = findNodeHandle(node) const tag = findNodeHandle(node)
if (tag != null) setAltBtnViewTag(tag) if (tag != null) setAltBtnViewTag(tag)
@@ -185,33 +161,8 @@ const GalleryItem = ({
return ( return (
<View <View
ref={altBtnRef} ref={altBtnRef}
style={imageStyle as ViewStyle} style={{width: itemWidth, height: itemHeight}}
// Fixes ALT and icons appearing with half opacity when the post is inactive
renderToHardwareTextureAndroid> renderToHardwareTextureAndroid>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={onAltTextEdit}
style={[styles.altTextControl, altTextControlStyle]}>
{image.alt.length !== 0 ? (
<FontAwesomeIcon
icon="check"
size={10}
style={{color: t.palette.white}}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={10}
style={{color: t.palette.white}}
/>
)}
<Text style={styles.altTextControlLabel} accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<View style={imageControlsStyle}> <View style={imageControlsStyle}>
<TouchableOpacity <TouchableOpacity
testID="editPhotoButton" testID="editPhotoButton"
@@ -236,6 +187,32 @@ const GalleryItem = ({
/> />
</TouchableOpacity> </TouchableOpacity>
</View> </View>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={onAltTextEdit}
style={[styles.altTextControl, {left: 4, bottom: 4}]}>
{image.alt.length !== 0 ? (
<FontAwesomeIcon
icon="check"
size={10}
style={{color: t.palette.white}}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={10}
style={{color: t.palette.white}}
/>
)}
<Text style={styles.altTextControlLabel} accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity <TouchableOpacity
accessibilityRole="button" accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)} accessibilityLabel={_(msg`Add alt text`)}
@@ -246,7 +223,7 @@ const GalleryItem = ({
<Image <Image
testID="selectedPhotoImage" testID="selectedPhotoImage"
style={[styles.image, imageStyle]} style={[styles.image, {width: itemWidth, height: itemHeight}]}
source={{ source={{
uri: (image.transformed ?? image.source).path, uri: (image.transformed ?? image.source).path,
}} }}
@@ -275,13 +252,17 @@ const GalleryItem = ({
) )
} }
const imageControlsStyle = {
display: 'flex' as const,
flexDirection: 'row' as const,
position: 'absolute' as const,
top: 4,
right: 4,
gap: 4,
zIndex: 1,
}
const styles = StyleSheet.create({ const styles = StyleSheet.create({
gallery: {
flex: 1,
flexDirection: 'row',
gap: IMAGE_GAP,
marginTop: 16,
},
image: { image: {
borderRadius: tokens.borderRadius.md, borderRadius: tokens.borderRadius.md,
}, },