Add experimental composer toggle for image grid/carousel layout

Behind the new `composer:image_layout_toggle:enable` feature gate, show
a pill button in the composer toolbar (next to the labels button) when a
post has 2-4 images. The button switches the draft between the legacy
`app.bsky.embed.images` shape (grid) and the newer
`app.bsky.embed.gallery` shape (carousel, previously only used for 5+
images). Gallery embeds now always render with the carousel layout so
the choice is reflected for viewers.

The button is wrapped in a one-time tooltip nudge, following the same
pattern as the threadgate button, and emits a
`composer:imageLayout:toggle` metric.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SvDXhHEBCjAfokEFK8pHt8
This commit is contained in:
Claude
2026-07-05 10:11:00 +00:00
parent 79a16094f1
commit 9f5d5a539b
11 changed files with 210 additions and 4 deletions
+1
View File
@@ -11,6 +11,7 @@ export enum Features {
LiveNowBetaDisable = 'live_now_beta:disable',
GroupChatsDisable = 'group_chats:disable',
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
ComposerImageLayoutToggleEnable = 'composer:image_layout_toggle:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
SearchV2Enable = 'search_v2:enable',
+5
View File
@@ -260,6 +260,11 @@ export type Events = {
'composer:threadgate:open': {
nudged: boolean
}
'composer:imageLayout:toggle': {
layout: 'grid' | 'carousel'
imageCount: number
nudged: boolean
}
'composer:threadgate:save': {
replyOptions: string
quotesEnabled: boolean
+8 -3
View File
@@ -19,8 +19,6 @@ import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
const MAX_GRID_IMAGES = 4
export function ImageEmbed({
embed,
...rest
@@ -38,9 +36,16 @@ export function ImageEmbed({
aspectRatio: item.aspectRatio,
}))
: embed.view.images
/*
* The gallery embed type implies the carousel layout: >4 images always
* publishes as a gallery, and the composer's layout toggle experiment
* publishes 2-4 images as a gallery when the user opts into the carousel.
* Legacy `images` embeds keep the grid unless the viewer-side gate flips
* them to the carousel.
*/
const useExpandedLayout =
embed.type === 'gallery'
? images.length > MAX_GRID_IMAGES
? true
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
const layout: 'single' | 'grid' | 'carousel' =
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const Carousel_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M8 5a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v14a1 1 0 0 1-1 1H9a1 1 0 0 1-1-1V5Zm2 1v12h4V6h-4ZM3 6a1 1 0 0 1 2 0v12a1 1 0 1 1-2 0V6Zm16 0a1 1 0 1 1 2 0v12a1 1 0 1 1-2 0V6Z',
})
+5
View File
@@ -0,0 +1,5 @@
import {createSinglePathSVG} from './TEMPLATE'
export const GridSquare2x2_Stroke2_Corner0_Rounded = createSinglePathSVG({
path: 'M3 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1V4Zm2 1v4h4V5H5ZM13 4a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1V4Zm2 1v4h4V5h-4ZM3 14a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1H4a1 1 0 0 1-1-1v-6Zm2 1v4h4v-4H5Zm8-1a1 1 0 0 1 1-1h6a1 1 0 0 1 1 1v6a1 1 0 0 1-1 1h-6a1 1 0 0 1-1-1v-6Zm2 1v4h4v-4h-4Z',
})
+9
View File
@@ -0,0 +1,9 @@
import {device, useStorage} from '#/storage'
export function useImageLayoutNudged() {
const [imageLayoutNudged = false, setImageLayoutNudged] = useStorage(device, [
'imageLayoutNudged',
])
return [imageLayoutNudged, setImageLayoutNudged] as const
}
+1
View File
@@ -62,6 +62,7 @@ export type Device = {
demoMode: boolean
activitySubscriptionsNudged?: boolean
threadgateNudged?: boolean
imageLayoutNudged?: boolean
inviteFriendsFollowersPromoDismissed?: boolean
/**
* Selected color theme for the Invite Friends QR card.
+20
View File
@@ -111,6 +111,7 @@ import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn
import {GifAltTextDialog} from '#/view/com/composer/GifAltText'
import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
import {Gallery} from '#/view/com/composer/photos/Gallery'
import {ImageLayoutBtn} from '#/view/com/composer/photos/ImageLayoutBtn'
import {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
@@ -164,6 +165,7 @@ import {
type SelectMediaButtonProps,
} from './SelectMediaButton'
import {
canToggleImageLayout,
type ComposerAction,
composerReducer,
createComposerState,
@@ -2026,6 +2028,7 @@ function ComposerPills({
bottomBarAnimatedStyle: StyleProp<ViewStyle>
}) {
const t = useTheme()
const ax = useAnalytics()
const media = post.embed.media
const hasMedia =
media?.type === 'images' ||
@@ -2079,6 +2082,23 @@ function ComposerPills({
}}
/>
) : null}
{canToggleImageLayout(media) &&
ax.features.enabled(ax.features.ComposerImageLayoutToggleEnable) ? (
<ImageLayoutBtn
layout={media.type === 'gallery' ? 'carousel' : 'grid'}
imageCount={media.images.length}
onChange={nextLayout => {
dispatch({
type: 'update_post',
postId: post.id,
postAction: {
type: 'embed_set_image_layout',
layout: nextLayout,
},
})
}}
/>
) : null}
</ScrollView>
</Animated.View>
)
@@ -0,0 +1,101 @@
import {useEffect, useState} from 'react'
import {Trans, useLingui} from '@lingui/react/macro'
import {Button, ButtonIcon, ButtonText} from '#/components/Button'
import {Carousel_Stroke2_Corner0_Rounded as CarouselIcon} from '#/components/icons/Carousel'
import {GridSquare2x2_Stroke2_Corner0_Rounded as GridIcon} from '#/components/icons/GridSquare'
import * as Tooltip from '#/components/Tooltip'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {useImageLayoutNudged} from '#/storage/hooks/image-layout-nudged'
import {type ImageLayout} from '../state/composer'
/**
* Experiment (gated by `ComposerImageLayoutToggleEnable`): lets the user pick
* how 2-4 images are displayed in the final post. `carousel` publishes the
* images as the newer `app.bsky.embed.gallery` embed (the format used for 5+
* images), `grid` keeps the legacy `app.bsky.embed.images` embed.
*/
export function ImageLayoutBtn({
layout,
imageCount,
onChange,
}: {
layout: ImageLayout
imageCount: number
onChange: (layout: ImageLayout) => void
}) {
const {t: l} = useLingui()
const ax = useAnalytics()
const [imageLayoutNudged, setImageLayoutNudged] = useImageLayoutNudged()
const [showTooltip, setShowTooltip] = useState(false)
const [tooltipWasShown] = useState(!imageLayoutNudged)
useEffect(() => {
if (!imageLayoutNudged) {
const timeout = setTimeout(() => {
setShowTooltip(true)
}, 1000)
return () => clearTimeout(timeout)
}
}, [imageLayoutNudged])
const onDismissTooltip = (visible: boolean) => {
if (visible) return
setImageLayoutNudged(true)
setShowTooltip(false)
}
const nextLayout: ImageLayout = layout === 'grid' ? 'carousel' : 'grid'
const onPress = () => {
ax.metric('composer:imageLayout:toggle', {
layout: nextLayout,
imageCount,
nudged: tooltipWasShown,
})
setShowTooltip(false)
setImageLayoutNudged(true)
onChange(nextLayout)
}
return (
<Tooltip.Outer
visible={showTooltip}
onVisibleChange={onDismissTooltip}
position="top">
<Tooltip.Target>
<Button
color={showTooltip ? 'primary_subtle' : 'secondary'}
size="small"
testID="imageLayoutBtn"
onPress={onPress}
label={
nextLayout === 'carousel'
? l`Switch images to carousel layout`
: l`Switch images to grid layout`
}
accessibilityHint={l`Switches how the images in your post are displayed`}>
<ButtonIcon icon={layout === 'grid' ? GridIcon : CarouselIcon} />
<ButtonText numberOfLines={1} maxFontSizeMultiplier={2}>
{layout === 'grid' ? (
<Trans context="Image layout in a post">Grid</Trans>
) : (
<Trans context="Image layout in a post">Carousel</Trans>
)}
</ButtonText>
</Button>
</Tooltip.Target>
<Tooltip.TextBubble>
<Text>
<Trans>
You can now choose to display your images in the new carousel
format.
</Trans>
</Text>
</Tooltip.TextBubble>
</Tooltip.Outer>
)
}
+49
View File
@@ -85,6 +85,7 @@ export type PostAction =
| {type: 'embed_add_images'; images: ComposerImage[]}
| {type: 'embed_update_image'; image: ComposerImage}
| {type: 'embed_remove_image'; image: ComposerImage}
| {type: 'embed_set_image_layout'; layout: ImageLayout}
| {
type: 'embed_add_video'
asset: ImagePickerAsset
@@ -172,6 +173,29 @@ export type ComposerAction =
export const LEGACY_IMAGES_EMBED_MAX = 4
export const MAX_GALLERY_IMAGES = 10
/**
* How a set of images is displayed in the final post. Maps to the embed
* variant: `grid` is the legacy `app.bsky.embed.images` shape, `carousel`
* is the newer `app.bsky.embed.gallery` shape.
*/
export type ImageLayout = 'grid' | 'carousel'
/**
* Whether the user can pick between the grid and carousel layouts for the
* given media. Only image media with 2 to 4 images qualifies: a single
* image renders the same either way, and >4 images always requires the
* gallery embed.
*/
export function canToggleImageLayout(
media: EmbedDraft['media'],
): media is ImagesMedia | GalleryMedia {
return (
(media?.type === 'images' || media?.type === 'gallery') &&
media.images.length >= 2 &&
media.images.length <= LEGACY_IMAGES_EMBED_MAX
)
}
/**
* Picks the embed variant for a set of images. <=4 lands in the legacy
* `app.bsky.embed.images` shape; >4 promotes to `app.bsky.embed.gallery`.
@@ -454,6 +478,31 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
}
return state
}
case 'embed_set_image_layout': {
const prevMedia = state.embed.media
/*
* Only 2-4 images can move between the two shapes: adding or removing
* images re-picks the variant via imagesToMediaVariant, so an explicit
* layout choice only holds while the count stays in that range.
*/
if (!canToggleImageLayout(prevMedia)) {
return state
}
const nextType = action.layout === 'carousel' ? 'gallery' : 'images'
if (prevMedia.type === nextType) {
return state
}
return {
...state,
embed: {
...state.embed,
media: {
type: nextType,
images: prevMedia.images,
},
},
}
}
case 'embed_add_video': {
const prevMedia = state.embed.media
let nextMedia = prevMedia
+6 -1
View File
@@ -966,8 +966,13 @@ let PostFeed = ({
const totalImages = AppBskyEmbedGallery.isView(post.embed)
? post.embed.items.filter(AppBskyEmbedGallery.isViewImage).length
: post.embed.images.length
/*
* Keep in sync with the layout decision in ImageEmbed: gallery
* embeds always render as the carousel, legacy `images` embeds
* depend on the viewer-side gate.
*/
const useExpandedLayout = AppBskyEmbedGallery.isView(post.embed)
? totalImages > 4
? true
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
const layout =
totalImages === 1