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:
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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' =
|
||||
|
||||
@@ -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',
|
||||
})
|
||||
@@ -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',
|
||||
})
|
||||
@@ -0,0 +1,9 @@
|
||||
import {device, useStorage} from '#/storage'
|
||||
|
||||
export function useImageLayoutNudged() {
|
||||
const [imageLayoutNudged = false, setImageLayoutNudged] = useStorage(device, [
|
||||
'imageLayoutNudged',
|
||||
])
|
||||
|
||||
return [imageLayoutNudged, setImageLayoutNudged] as const
|
||||
}
|
||||
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user