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',
|
LiveNowBetaDisable = 'live_now_beta:disable',
|
||||||
GroupChatsDisable = 'group_chats:disable',
|
GroupChatsDisable = 'group_chats:disable',
|
||||||
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
ComposerLanguageDetectionEnable = 'composer:language_detection:enable',
|
||||||
|
ComposerImageLayoutToggleEnable = 'composer:image_layout_toggle:enable',
|
||||||
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
|
||||||
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
NotificationsExpandedProfileCardEnable = 'notifications:expanded_profile_card:enable',
|
||||||
SearchV2Enable = 'search_v2:enable',
|
SearchV2Enable = 'search_v2:enable',
|
||||||
|
|||||||
@@ -260,6 +260,11 @@ export type Events = {
|
|||||||
'composer:threadgate:open': {
|
'composer:threadgate:open': {
|
||||||
nudged: boolean
|
nudged: boolean
|
||||||
}
|
}
|
||||||
|
'composer:imageLayout:toggle': {
|
||||||
|
layout: 'grid' | 'carousel'
|
||||||
|
imageCount: number
|
||||||
|
nudged: boolean
|
||||||
|
}
|
||||||
'composer:threadgate:save': {
|
'composer:threadgate:save': {
|
||||||
replyOptions: string
|
replyOptions: string
|
||||||
quotesEnabled: boolean
|
quotesEnabled: boolean
|
||||||
|
|||||||
@@ -19,8 +19,6 @@ import {useAnalytics} from '#/analytics'
|
|||||||
import {type EmbedType} from '#/types/bsky/post'
|
import {type EmbedType} from '#/types/bsky/post'
|
||||||
import {type CommonProps} from './types'
|
import {type CommonProps} from './types'
|
||||||
|
|
||||||
const MAX_GRID_IMAGES = 4
|
|
||||||
|
|
||||||
export function ImageEmbed({
|
export function ImageEmbed({
|
||||||
embed,
|
embed,
|
||||||
...rest
|
...rest
|
||||||
@@ -38,9 +36,16 @@ export function ImageEmbed({
|
|||||||
aspectRatio: item.aspectRatio,
|
aspectRatio: item.aspectRatio,
|
||||||
}))
|
}))
|
||||||
: embed.view.images
|
: 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 =
|
const useExpandedLayout =
|
||||||
embed.type === 'gallery'
|
embed.type === 'gallery'
|
||||||
? images.length > MAX_GRID_IMAGES
|
? true
|
||||||
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||||
|
|
||||||
const layout: 'single' | 'grid' | 'carousel' =
|
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
|
demoMode: boolean
|
||||||
activitySubscriptionsNudged?: boolean
|
activitySubscriptionsNudged?: boolean
|
||||||
threadgateNudged?: boolean
|
threadgateNudged?: boolean
|
||||||
|
imageLayoutNudged?: boolean
|
||||||
inviteFriendsFollowersPromoDismissed?: boolean
|
inviteFriendsFollowersPromoDismissed?: boolean
|
||||||
/**
|
/**
|
||||||
* Selected color theme for the Invite Friends QR card.
|
* 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 {GifAltTextDialog} from '#/view/com/composer/GifAltText'
|
||||||
import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
|
import {LabelsBtn} from '#/view/com/composer/labels/LabelsBtn'
|
||||||
import {Gallery} from '#/view/com/composer/photos/Gallery'
|
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 {OpenCameraBtn} from '#/view/com/composer/photos/OpenCameraBtn'
|
||||||
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
|
import {SelectGifBtn} from '#/view/com/composer/photos/SelectGifBtn'
|
||||||
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
||||||
@@ -164,6 +165,7 @@ import {
|
|||||||
type SelectMediaButtonProps,
|
type SelectMediaButtonProps,
|
||||||
} from './SelectMediaButton'
|
} from './SelectMediaButton'
|
||||||
import {
|
import {
|
||||||
|
canToggleImageLayout,
|
||||||
type ComposerAction,
|
type ComposerAction,
|
||||||
composerReducer,
|
composerReducer,
|
||||||
createComposerState,
|
createComposerState,
|
||||||
@@ -2026,6 +2028,7 @@ function ComposerPills({
|
|||||||
bottomBarAnimatedStyle: StyleProp<ViewStyle>
|
bottomBarAnimatedStyle: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
|
const ax = useAnalytics()
|
||||||
const media = post.embed.media
|
const media = post.embed.media
|
||||||
const hasMedia =
|
const hasMedia =
|
||||||
media?.type === 'images' ||
|
media?.type === 'images' ||
|
||||||
@@ -2079,6 +2082,23 @@ function ComposerPills({
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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>
|
</ScrollView>
|
||||||
</Animated.View>
|
</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_add_images'; images: ComposerImage[]}
|
||||||
| {type: 'embed_update_image'; image: ComposerImage}
|
| {type: 'embed_update_image'; image: ComposerImage}
|
||||||
| {type: 'embed_remove_image'; image: ComposerImage}
|
| {type: 'embed_remove_image'; image: ComposerImage}
|
||||||
|
| {type: 'embed_set_image_layout'; layout: ImageLayout}
|
||||||
| {
|
| {
|
||||||
type: 'embed_add_video'
|
type: 'embed_add_video'
|
||||||
asset: ImagePickerAsset
|
asset: ImagePickerAsset
|
||||||
@@ -172,6 +173,29 @@ export type ComposerAction =
|
|||||||
export const LEGACY_IMAGES_EMBED_MAX = 4
|
export const LEGACY_IMAGES_EMBED_MAX = 4
|
||||||
export const MAX_GALLERY_IMAGES = 10
|
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
|
* 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`.
|
* `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
|
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': {
|
case 'embed_add_video': {
|
||||||
const prevMedia = state.embed.media
|
const prevMedia = state.embed.media
|
||||||
let nextMedia = prevMedia
|
let nextMedia = prevMedia
|
||||||
|
|||||||
@@ -966,8 +966,13 @@ let PostFeed = ({
|
|||||||
const totalImages = AppBskyEmbedGallery.isView(post.embed)
|
const totalImages = AppBskyEmbedGallery.isView(post.embed)
|
||||||
? post.embed.items.filter(AppBskyEmbedGallery.isViewImage).length
|
? post.embed.items.filter(AppBskyEmbedGallery.isViewImage).length
|
||||||
: post.embed.images.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)
|
const useExpandedLayout = AppBskyEmbedGallery.isView(post.embed)
|
||||||
? totalImages > 4
|
? true
|
||||||
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
: ax.features.enabled(ax.features.PostGalleryEmbedEnable)
|
||||||
const layout =
|
const layout =
|
||||||
totalImages === 1
|
totalImages === 1
|
||||||
|
|||||||
Reference in New Issue
Block a user