diff --git a/src/state/queries/nuxs/definitions.ts b/src/state/queries/nuxs/definitions.ts index 25f975db26..d616654638 100644 --- a/src/state/queries/nuxs/definitions.ts +++ b/src/state/queries/nuxs/definitions.ts @@ -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 | undefined> = { @@ -93,4 +98,5 @@ export const NuxSchemas: Record | undefined> = { [Nux.LiveNowBetaDialog]: undefined, [Nux.LiveNowBetaNudge]: undefined, [Nux.DraftsAnnouncement]: undefined, + [Nux.ComposerCarouselAnnouncement]: undefined, } diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 8cef41ef88..070d18a4b6 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -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(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) ? ( + + + Alt text describes images for blind and low-vision users, and helps give + context to everyone. + + + ) : null + + if (isCarousel) { + return ( + <> + + {images.map(image => ( + { + dispatch({type: 'embed_update_image', image: next}) + }} + onRemove={() => { + dispatch({type: 'embed_remove_image', image}) + }} + /> + ))} + + + {altTextReminder} + + ) + } + + return ( <> {images.map(image => { @@ -125,16 +215,9 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => { ) })} - {images.some(image => !image.alt) && ( - - - Alt text describes images for blind and low-vision users, and helps - give context to everyone. - - - )} + {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 ( + + + + Posts with more than 4 photos are shown as a swipeable carousel. + + + + + ) +} + const styles = StyleSheet.create({ gallery: { flex: 1, diff --git a/src/view/com/composer/photos/galleryLayout.test.ts b/src/view/com/composer/photos/galleryLayout.test.ts new file mode 100644 index 0000000000..cb5dc48f39 --- /dev/null +++ b/src/view/com/composer/photos/galleryLayout.test.ts @@ -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) + }) +}) diff --git a/src/view/com/composer/photos/galleryLayout.ts b/src/view/com/composer/photos/galleryLayout.ts new file mode 100644 index 0000000000..1e2be6db56 --- /dev/null +++ b/src/view/com/composer/photos/galleryLayout.ts @@ -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) +}