From 9f5d5a539bfa1f1a2bc56e0cf4d7d59419be7bce Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 5 Jul 2026 10:11:00 +0000 Subject: [PATCH] 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 Claude-Session: https://claude.ai/code/session_01SvDXhHEBCjAfokEFK8pHt8 --- src/analytics/features/types.ts | 1 + src/analytics/metrics/types.ts | 5 + src/components/Post/Embed/ImageEmbed.tsx | 11 +- src/components/icons/Carousel.tsx | 5 + src/components/icons/GridSquare.tsx | 5 + src/storage/hooks/image-layout-nudged.ts | 9 ++ src/storage/schema.ts | 1 + src/view/com/composer/Composer.tsx | 20 ++++ .../com/composer/photos/ImageLayoutBtn.tsx | 101 ++++++++++++++++++ src/view/com/composer/state/composer.ts | 49 +++++++++ src/view/com/posts/PostFeed.tsx | 7 +- 11 files changed, 210 insertions(+), 4 deletions(-) create mode 100644 src/components/icons/Carousel.tsx create mode 100644 src/components/icons/GridSquare.tsx create mode 100644 src/storage/hooks/image-layout-nudged.ts create mode 100644 src/view/com/composer/photos/ImageLayoutBtn.tsx diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index 3d8d20a856..c1871e2927 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -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', diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index 3cb707733f..93ed118969 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -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 diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index ecc0c09d13..bc3e15cfbf 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -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' = diff --git a/src/components/icons/Carousel.tsx b/src/components/icons/Carousel.tsx new file mode 100644 index 0000000000..10db8858cf --- /dev/null +++ b/src/components/icons/Carousel.tsx @@ -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', +}) diff --git a/src/components/icons/GridSquare.tsx b/src/components/icons/GridSquare.tsx new file mode 100644 index 0000000000..a7e6cb6656 --- /dev/null +++ b/src/components/icons/GridSquare.tsx @@ -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', +}) diff --git a/src/storage/hooks/image-layout-nudged.ts b/src/storage/hooks/image-layout-nudged.ts new file mode 100644 index 0000000000..8d3ce76214 --- /dev/null +++ b/src/storage/hooks/image-layout-nudged.ts @@ -0,0 +1,9 @@ +import {device, useStorage} from '#/storage' + +export function useImageLayoutNudged() { + const [imageLayoutNudged = false, setImageLayoutNudged] = useStorage(device, [ + 'imageLayoutNudged', + ]) + + return [imageLayoutNudged, setImageLayoutNudged] as const +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index add2e9a0a8..341f6dadee 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -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. diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index a2cf83c75e..20d8e95f5d 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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 }) { 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) ? ( + { + dispatch({ + type: 'update_post', + postId: post.id, + postAction: { + type: 'embed_set_image_layout', + layout: nextLayout, + }, + }) + }} + /> + ) : null} ) diff --git a/src/view/com/composer/photos/ImageLayoutBtn.tsx b/src/view/com/composer/photos/ImageLayoutBtn.tsx new file mode 100644 index 0000000000..2c53998dc8 --- /dev/null +++ b/src/view/com/composer/photos/ImageLayoutBtn.tsx @@ -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 ( + + + + + + + + You can now choose to display your images in the new carousel + format. + + + + + ) +} diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index fc8b341270..3372b201ec 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -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 diff --git a/src/view/com/posts/PostFeed.tsx b/src/view/com/posts/PostFeed.tsx index e502ca028c..e43004b1c2 100644 --- a/src/view/com/posts/PostFeed.tsx +++ b/src/view/com/posts/PostFeed.tsx @@ -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