Composer: preview 5+ photos as a swipeable carousel

Render the composer photo preview as the existing grid for 1-4 images and
a horizontal carousel for more than 4, matching the viewing-side display rule.

- >4 selected images render a horizontal ScrollView carousel (aspect-ratio
  tiles, next image peeking from the right), reusing GalleryItem so per-image
  edit / remove / + ALT controls keep working. Auto-scrolls to a newly-added
  tile when crossing into / within carousel mode.
- Screen-reader users get the wrapped grid layout instead of the carousel
  (mirrors the viewing-side gallery), keeping per-image controls navigable.
- One-time dismissible admonition (Nux.ComposerCarouselAnnouncement) when the
  preview enters carousel mode.
- Add a clamped tile-width helper (galleryLayout.ts) with unit tests, reusing
  the viewing-side MIN/MAX_ASPECT_RATIO bounds.

APP-2288
This commit is contained in:
vineyardbovines
2026-06-03 20:40:10 -04:00
parent 77c08164b7
commit 9c4b86ab4a
4 changed files with 177 additions and 12 deletions
+6
View File
@@ -15,6 +15,7 @@ export enum Nux {
LiveNowBetaDialog = 'LiveNowBetaDialog',
LiveNowBetaNudge = 'LiveNowBetaNudge',
DraftsAnnouncement = 'DraftsAnnouncement',
ComposerCarouselAnnouncement = 'ComposerCarouselAnnouncement',
/*
* Blocking announcements. New IDs are required for each new announcement.
@@ -77,6 +78,10 @@ export type AppNux = BaseNux<
id: Nux.DraftsAnnouncement
data: undefined
}
| {
id: Nux.ComposerCarouselAnnouncement
data: undefined
}
>
export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
@@ -93,4 +98,5 @@ export const NuxSchemas: Record<Nux, zod.ZodObject<any> | undefined> = {
[Nux.LiveNowBetaDialog]: undefined,
[Nux.LiveNowBetaNudge]: undefined,
[Nux.DraftsAnnouncement]: undefined,
[Nux.ComposerCarouselAnnouncement]: undefined,
}
+131 -12
View File
@@ -1,10 +1,11 @@
import {memo, useMemo, useState} from 'react'
import {memo, useEffect, useMemo, useRef, useState} from 'react'
import {
findNodeHandle,
type ImageStyle,
Keyboard,
type LayoutChangeEvent,
Platform,
ScrollView,
StyleSheet,
TouchableOpacity,
View,
@@ -18,9 +19,12 @@ import {Trans} from '@lingui/react/macro'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type Dimensions} from '#/lib/media/types'
import {colors} from '#/lib/styles'
import {useA11y} from '#/state/a11y'
import {type ComposerImage, cropImage} from '#/state/gallery'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Nux, useNux, useSaveNux} from '#/state/queries/nuxs'
import {atoms as a, tokens, useTheme, web} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {Button, ButtonIcon} from '#/components/Button'
import * as Dialog from '#/components/Dialog'
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
import {Pencil_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
@@ -32,10 +36,27 @@ import {useAnalytics} from '#/analytics'
import {IS_IOS, IS_NATIVE} from '#/env'
import {type PostAction} from '../state/composer'
import {EditImageDialog} from './EditImageDialog'
import {getCarouselTileWidth} from './galleryLayout'
import {ImageAltTextDialog} from './ImageAltTextDialog'
const IMAGE_GAP = 8
// Posts with more than this many images preview as a horizontal carousel
// instead of the grid (matches the viewing-side display rule).
const GALLERY_CAROUSEL_THRESHOLD = 4
// Fixed height for carousel tiles; widths derive from each image aspect ratio.
const CAROUSEL_TILE_HEIGHT = 171
const CAROUSEL_CONTROLS_STYLE = {
display: 'flex' as const,
flexDirection: 'row' as const,
position: 'absolute' as const,
top: 4,
right: 4,
gap: 4,
zIndex: 1,
}
const CAROUSEL_ALT_STYLE = {left: 4, bottom: 4}
interface GalleryProps {
images: ComposerImage[]
dispatch: (action: PostAction) => void
@@ -67,7 +88,21 @@ interface GalleryInnerProps extends GalleryProps {
}
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
const {_} = useLingui()
const {isMobile} = useWebMediaQueries()
const {screenReaderEnabled} = useA11y()
const isCarousel =
images.length > GALLERY_CAROUSEL_THRESHOLD && !screenReaderEnabled
const scrollRef = useRef<ScrollView>(null)
const prevCountRef = useRef(images.length)
useEffect(() => {
// When a new image is added in carousel mode, reveal it.
if (isCarousel && images.length > prevCountRef.current) {
scrollRef.current?.scrollToEnd({animated: true})
}
prevCountRef.current = images.length
}, [images.length, isCarousel])
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
// Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high;
@@ -104,7 +139,62 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
}
}, [images.length, containerInfo, isMobile])
return images.length !== 0 ? (
if (images.length === 0) {
return null
}
const altTextReminder = images.some(image => !image.alt) ? (
<Admonition type="info" style={[a.mt_sm]}>
<Trans>
Alt text describes images for blind and low-vision users, and helps give
context to everyone.
</Trans>
</Admonition>
) : null
if (isCarousel) {
return (
<>
<ScrollView
ref={scrollRef}
horizontal
showsHorizontalScrollIndicator={false}
testID="selectedPhotosView"
accessibilityLabel={_(msg`Selected photos, ${images.length} images`)}
accessibilityHint=""
role="group"
aria-roledescription={_(msg`carousel`)}
style={[{marginTop: 16}, web({overscrollBehaviorX: 'contain'})]}
contentContainerStyle={{gap: IMAGE_GAP, paddingRight: IMAGE_GAP}}>
{images.map(image => (
<GalleryItem
key={image.source.id}
image={image}
altTextControlStyle={CAROUSEL_ALT_STYLE}
imageControlsStyle={CAROUSEL_CONTROLS_STYLE}
imageStyle={{
height: CAROUSEL_TILE_HEIGHT,
width: getCarouselTileWidth(
image.transformed ?? image.source,
CAROUSEL_TILE_HEIGHT,
),
}}
onChange={next => {
dispatch({type: 'embed_update_image', image: next})
}}
onRemove={() => {
dispatch({type: 'embed_remove_image', image})
}}
/>
))}
</ScrollView>
<CarouselAdmonition />
{altTextReminder}
</>
)
}
return (
<>
<View testID="selectedPhotosView" style={styles.gallery}>
{images.map(image => {
@@ -125,16 +215,9 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
)
})}
</View>
{images.some(image => !image.alt) && (
<Admonition type="info" style={[a.mt_sm]}>
<Trans>
Alt text describes images for blind and low-vision users, and helps
give context to everyone.
</Trans>
</Admonition>
)}
{altTextReminder}
</>
) : null
)
}
type GalleryItemProps = {
@@ -270,6 +353,42 @@ const GalleryItem = ({
)
}
function CarouselAdmonition() {
const {_} = useLingui()
const {nux} = useNux(Nux.ComposerCarouselAnnouncement)
const {mutate: save, variables} = useSaveNux()
// Optimistically hide while the completion is saving.
if (variables) return null
if (nux && nux.completed) return null
return (
<View style={[a.mt_sm]}>
<Admonition type="info">
<Trans>
Posts with more than 4 photos are shown as a swipeable carousel.
</Trans>
</Admonition>
<Button
label={_(msg`Dismiss`)}
size="tiny"
variant="solid"
color="secondary_inverted"
shape="round"
onPress={() =>
save({
id: Nux.ComposerCarouselAnnouncement,
completed: true,
data: undefined,
})
}
style={[a.absolute, {top: 8, right: 8}]}>
<ButtonIcon icon={TimesIcon} />
</Button>
</View>
)
}
const styles = StyleSheet.create({
gallery: {
flex: 1,
@@ -0,0 +1,22 @@
import {getCarouselTileWidth} from './galleryLayout'
describe('getCarouselTileWidth', () => {
it('clamps wide landscape images to the max aspect ratio (3/2)', () => {
// 200/100 = 2.0 -> clamped to 1.5 -> 100 * 1.5 = 150
expect(getCarouselTileWidth({width: 200, height: 100}, 100)).toBe(150)
})
it('clamps tall portrait images to the min aspect ratio (2/3)', () => {
// 100/200 = 0.5 -> clamped to 0.6667 -> round(100 * 0.6667) = 67
expect(getCarouselTileWidth({width: 100, height: 200}, 100)).toBe(67)
})
it('keeps in-range aspect ratios unchanged', () => {
// 400/300 = 1.333 (in [0.667, 1.5]) -> 120 * 4/3 = 160
expect(getCarouselTileWidth({width: 400, height: 300}, 120)).toBe(160)
})
it('falls back to square when dimensions are missing or zero', () => {
expect(getCarouselTileWidth({width: 0, height: 0}, 100)).toBe(100)
})
})
@@ -0,0 +1,18 @@
import {
MAX_ASPECT_RATIO,
MIN_ASPECT_RATIO,
} from '#/components/images/Gallery/const'
/**
* Width of a carousel tile at a fixed height, derived from the image aspect
* ratio and clamped to the same range the viewing-side carousel uses so tiles
* stay a reasonable size. Falls back to square when dimensions are missing.
*/
export function getCarouselTileWidth(
dims: {width: number; height: number},
tileHeight: number,
): number {
const raw = dims.width > 0 && dims.height > 0 ? dims.width / dims.height : 1
const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO))
return Math.round(tileHeight * clamped)
}