persist, change layout

This commit is contained in:
Samuel Newman
2026-07-06 20:08:01 +03:00
parent c3d88ab07a
commit 63ea0624d2
10 changed files with 267 additions and 146 deletions
@@ -0,0 +1,25 @@
import {type ImageLayout} from '#/view/com/composer/state/composer'
import {account} from '#/storage'
/**
* Read the user's preferred layout for how 2 to 4 images are displayed in a
* new post. Stored per-account and defaults to `carousel`. See the
* `composerImageLayout` field on the `Account` schema for details.
*
* This is an imperative read (rather than a reactive hook) so callers can pull
* the current value at event time - dispatch, reducer init, remove handler -
* instead of relying on a render-captured value that can lag the store.
*/
export function getComposerImageLayout(did: string | undefined): ImageLayout {
return account.get([did ?? 'pwi', 'composerImageLayout']) ?? 'carousel'
}
/**
* Persist the user's preferred image layout for the given account.
*/
export function setComposerImageLayout(
did: string | undefined,
layout: ImageLayout,
): void {
account.set([did ?? 'pwi', 'composerImageLayout'], layout)
}
+10
View File
@@ -92,4 +92,14 @@ export type Account = {
* Recently selected GIFs in the GIF picker. Most recent first, capped at 20.
*/
recentGifs?: Gif[]
/**
* Preferred layout for how 2 to 4 images are displayed in a new post:
* `grid` publishes the legacy `app.bsky.embed.images` embed, `carousel`
* publishes the newer `app.bsky.embed.gallery` embed. Defaults to
* `carousel` when unset, and is only consulted while the composer image
* layout toggle experiment is enabled. Typed as an inline union rather than
* importing `ImageLayout` from composer state to avoid a circular import.
*/
composerImageLayout?: 'grid' | 'carousel'
}
+113 -87
View File
@@ -75,6 +75,7 @@ import {
import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
import {mimeToExt} from '#/lib/media/video/util'
import {type SelfLabel} from '#/lib/moderation'
import {useCallOnce} from '#/lib/once'
import {type NavigationProp} from '#/lib/routes/types'
import {cleanError} from '#/lib/strings/errors'
@@ -114,6 +115,7 @@ 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 {useGetPreferredImageLayout} from '#/view/com/composer/photos/usePreferredImageLayout'
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
// TODO: Prevent naming components that coincide with RN primitives
// due to linting false positives
@@ -145,6 +147,7 @@ import {
IS_WEB_SAFARI,
} from '#/env'
import {type Gif} from '#/features/gifPicker/types'
import {setComposerImageLayout} from '#/storage/hooks/composer-image-layout'
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
import {
draftToComposerPosts,
@@ -215,6 +218,7 @@ function useAddImagesWithCap(
dispatchPostAction: (action: PostAction) => void,
) {
const {t: l} = useLingui()
const getPreferredLayout = useGetPreferredImageLayout()
return useCallback(
(next: ComposerImage[]) => {
const result = applyGalleryCap(currentCount, next)
@@ -244,9 +248,10 @@ function useAddImagesWithCap(
dispatchPostAction({
type: 'embed_add_images',
images: result.accepted,
preferredLayout: getPreferredLayout(),
})
},
[currentCount, dispatchPostAction, l],
[currentCount, dispatchPostAction, l, getPreferredLayout],
)
}
@@ -352,10 +357,12 @@ export const ComposePost = ({
setLanguageNudgeAt(prev => (now - prev > 10_000 ? now : prev))
}
const getPreferredImageLayout = useGetPreferredImageLayout()
const [composerState, composerDispatch] = useReducer(
composerReducer,
{
initImageUris,
initImageLayout: getPreferredImageLayout(),
initQuoteUri: initQuote?.uri,
initText,
initMention,
@@ -1361,7 +1368,6 @@ export const ComposePost = ({
/>
<ComposerPills
isReply={!!replyTo}
post={activePost}
thread={composerState.thread}
dispatch={composerDispatch}
bottomBarAnimatedStyle={bottomBarAnimatedStyle}
@@ -1746,6 +1752,7 @@ let ComposerPost = memo(function ComposerPost({
<ComposerEmbeds
canRemoveQuote={canRemoveQuote}
embed={post.embed}
labels={post.labels}
dispatch={dispatchPost}
clearVideo={() => onClearVideo(post.id)}
isActivePost={isActive}
@@ -1902,14 +1909,98 @@ function AltTextReminder({error}: {error: string}) {
)
}
/**
* Row of media-related buttons (video alt text/captions, content warnings,
* image layout) shown directly beneath the media preview. Lives per-post so
* each threaded post's buttons target its own media via the post-scoped
* `dispatch`.
*/
function ComposerEmbedButtons({
embed,
labels,
dispatch,
}: {
embed: EmbedDraft
labels: SelfLabel[]
dispatch: (action: PostAction) => void
}) {
const ax = useAnalytics()
const {currentAccount} = useSession()
const media = embed.media
const hasMedia =
media?.type === 'images' ||
media?.type === 'gallery' ||
media?.type === 'gif' ||
media?.type === 'video'
const hasLink = !!embed.link
const canToggleLayout =
canToggleImageLayout(media) &&
ax.features.enabled(ax.features.ComposerImageLayoutToggleEnable)
if (!hasMedia && !hasLink) {
return null
}
return (
<View style={[a.flex_row, a.flex_wrap, a.gap_sm, a.mt_sm]}>
{media?.type === 'video' ? (
<SubtitleDialogBtn
defaultAltText={media.video.altText}
saveAltText={altText =>
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_alt_text',
altText,
signal: media.video.abortController.signal,
},
})
}
captions={media.video.captions}
setCaptions={updater => {
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_captions',
updater,
signal: media.video.abortController.signal,
},
})
}}
/>
) : null}
{/* Content warnings apply to any media or link embed. */}
<LabelsBtn
labels={labels}
onChange={nextLabels => {
dispatch({type: 'update_labels', labels: nextLabels})
}}
/>
{canToggleLayout ? (
<ImageLayoutBtn
layout={media.type === 'gallery' ? 'carousel' : 'grid'}
imageCount={media.images.length}
onChange={nextLayout => {
// Persist as the account-level default for future posts.
setComposerImageLayout(currentAccount?.did, nextLayout)
dispatch({type: 'embed_set_image_layout', layout: nextLayout})
}}
/>
) : null}
</View>
)
}
function ComposerEmbeds({
embed,
labels,
dispatch,
clearVideo,
canRemoveQuote,
isActivePost,
}: {
embed: EmbedDraft
labels: SelfLabel[]
dispatch: (action: PostAction) => void
clearVideo: () => void
canRemoveQuote: boolean
@@ -1969,33 +2060,10 @@ function ComposerEmbeds({
clear={clearVideo}
/>
) : null)}
<SubtitleDialogBtn
defaultAltText={video.altText}
saveAltText={altText =>
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_alt_text',
altText,
signal: video.abortController.signal,
},
})
}
captions={video.captions}
setCaptions={updater => {
dispatch({
type: 'embed_update_video',
videoAction: {
type: 'update_captions',
updater,
signal: video.abortController.signal,
},
})
}}
/>
</Animated.View>
)}
</LayoutAnimationConfig>
<ComposerEmbedButtons embed={embed} labels={labels} dispatch={dispatch} />
{embed.quote?.uri ? (
<View
style={[a.pb_sm, video ? [a.pt_md] : [a.pt_xl], IS_WEB && [a.pb_md]]}>
@@ -2017,28 +2085,20 @@ function ComposerEmbeds({
function ComposerPills({
isReply,
thread,
post,
dispatch,
bottomBarAnimatedStyle,
}: {
isReply: boolean
thread: ThreadDraft
post: PostDraft
dispatch: (action: ComposerAction) => void
bottomBarAnimatedStyle: StyleProp<ViewStyle>
}) {
const t = useTheme()
const ax = useAnalytics()
const media = post.embed.media
const hasMedia =
media?.type === 'images' ||
media?.type === 'gallery' ||
media?.type === 'gif' ||
media?.type === 'video'
const hasLink = !!post.embed.link
const {gtMobile} = useBreakpoints()
// Don't render anything if no pills are going to be displayed
if (isReply && !hasMedia && !hasLink) {
// Replies can't set a threadgate, and the labels/layout buttons now live
// beneath each post's media, so there are no pills to show for a reply.
if (isReply) {
return null
}
@@ -2046,59 +2106,25 @@ function ComposerPills({
<Animated.View
style={[a.flex_row, a.py_sm, t.atoms.bg, bottomBarAnimatedStyle]}>
<ScrollView
contentContainerStyle={[a.gap_sm, a.px_sm]}
contentContainerStyle={[a.gap_sm, gtMobile ? a.px_lg : a.px_sm]}
horizontal={true}
bounces={false}
keyboardShouldPersistTaps="always"
showsHorizontalScrollIndicator={false}>
{isReply ? null : (
<ThreadgateBtn
postgate={thread.postgate}
onChangePostgate={nextPostgate => {
dispatch({type: 'update_postgate', postgate: nextPostgate})
}}
threadgateAllowUISettings={thread.threadgate}
onChangeThreadgateAllowUISettings={nextThreadgate => {
dispatch({
type: 'update_threadgate',
threadgate: nextThreadgate,
})
}}
style={bottomBarAnimatedStyle}
/>
)}
{hasMedia || hasLink ? (
<LabelsBtn
labels={post.labels}
onChange={nextLabels => {
dispatch({
type: 'update_post',
postId: post.id,
postAction: {
type: 'update_labels',
labels: nextLabels,
},
})
}}
/>
) : 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}
<ThreadgateBtn
postgate={thread.postgate}
onChangePostgate={nextPostgate => {
dispatch({type: 'update_postgate', postgate: nextPostgate})
}}
threadgateAllowUISettings={thread.threadgate}
onChangeThreadgateAllowUISettings={nextThreadgate => {
dispatch({
type: 'update_threadgate',
threadgate: nextThreadgate,
})
}}
style={bottomBarAnimatedStyle}
/>
</ScrollView>
</Animated.View>
)
+26 -31
View File
@@ -19,8 +19,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type Dimensions} from '#/lib/media/types'
import {colors} from '#/lib/styles'
import {type ComposerImage, cropImage} from '#/state/gallery'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import {tokens, useTheme} from '#/alf'
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'
@@ -33,6 +32,7 @@ import {IS_IOS, IS_NATIVE} from '#/env'
import {type PostAction} from '../state/composer'
import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog'
import {useGetPreferredImageLayout} from './usePreferredImageLayout'
const IMAGE_GAP = 8
@@ -68,6 +68,7 @@ interface GalleryInnerProps extends GalleryProps {
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries()
const getPreferredLayout = useGetPreferredImageLayout()
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
// Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high;
@@ -105,35 +106,29 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
}, [images.length, containerInfo, isMobile])
return images.length !== 0 ? (
<>
<View testID="selectedPhotosView" style={styles.gallery}>
{images.map(image => {
return (
<GalleryItem
key={image.source.id}
image={image}
altTextControlStyle={altTextControlStyle}
imageControlsStyle={imageControlsStyle}
imageStyle={imageStyle}
onChange={next => {
dispatch({type: 'embed_update_image', image: next})
}}
onRemove={() => {
dispatch({type: 'embed_remove_image', image})
}}
/>
)
})}
</View>
{images.some(image => !image.alt) && (
<Admonition type="info" style={[a.mt_sm]}>
<Trans>
Alt text describes images for blind and low-vision users, and helps
give context to everyone.
</Trans>
</Admonition>
)}
</>
<View testID="selectedPhotosView" style={styles.gallery}>
{images.map(image => {
return (
<GalleryItem
key={image.source.id}
image={image}
altTextControlStyle={altTextControlStyle}
imageControlsStyle={imageControlsStyle}
imageStyle={imageStyle}
onChange={next => {
dispatch({type: 'embed_update_image', image: next})
}}
onRemove={() => {
dispatch({
type: 'embed_remove_image',
image,
preferredLayout: getPreferredLayout(),
})
}}
/>
)
})}
</View>
) : null
}
@@ -0,0 +1,26 @@
import {useSession} from '#/state/session'
import {useAnalytics} from '#/analytics'
import {getComposerImageLayout} from '#/storage/hooks/composer-image-layout'
import {type ImageLayout} from '../state/composer'
/**
* Returns a getter for the image layout to apply to freshly created or
* re-picked 2-4 image sets. When the layout toggle experiment is off, users
* keep today's behavior (the legacy `images` embed), so we force `grid`. When
* it's on, we honor the account-level preference, which defaults to `carousel`.
*
* The returned function reads the stored preference imperatively when CALLED,
* so callers get the current value at event time (dispatch, reducer init,
* remove handler) rather than a render-captured value that can lag the store.
*/
export function useGetPreferredImageLayout(): () => ImageLayout {
const ax = useAnalytics()
const {currentAccount} = useSession()
const gateEnabled = ax.features.enabled(
ax.features.ComposerImageLayoutToggleEnable,
)
return () => {
if (!gateEnabled) return 'grid'
return getComposerImageLayout(currentAccount?.did)
}
}
+59 -21
View File
@@ -82,9 +82,17 @@ export type PostDraft = {
export type PostAction =
| {type: 'update_richtext'; richtext: RichText}
| {type: 'update_labels'; labels: SelfLabel[]}
| {type: 'embed_add_images'; images: ComposerImage[]}
| {
type: 'embed_add_images'
images: ComposerImage[]
preferredLayout?: ImageLayout
}
| {type: 'embed_update_image'; image: ComposerImage}
| {type: 'embed_remove_image'; image: ComposerImage}
| {
type: 'embed_remove_image'
image: ComposerImage
preferredLayout?: ImageLayout
}
| {type: 'embed_set_image_layout'; layout: ImageLayout}
| {
type: 'embed_add_video'
@@ -197,19 +205,26 @@ export function canToggleImageLayout(
}
/**
* 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`.
* Anything beyond the gallery cap is dropped by the hard slice; callers
* should already have enforced the cap upstream (picker, paste, etc),
* and the reducer logs a warning when the cap is exceeded so the UI
* layer can surface a toast.
* Picks the embed variant for a set of images. A single image always uses the
* legacy `app.bsky.embed.images` shape (it renders identically either way) and
* >4 images always promote to `app.bsky.embed.gallery`. For 2-4 images the
* `preferredLayout` decides: `carousel` uses the gallery shape, `grid` (the
* default) keeps the legacy images shape. Anything beyond the gallery cap is
* dropped by the hard slice; callers should already have enforced the cap
* upstream (picker, paste, etc), and the reducer logs a warning when the cap
* is exceeded so the UI layer can surface a toast.
*/
function imagesToMediaVariant(
images: ComposerImage[],
preferredLayout: ImageLayout = 'grid',
): ImagesMedia | GalleryMedia {
return images.length <= LEGACY_IMAGES_EMBED_MAX
? {type: 'images', images: images.slice(0, LEGACY_IMAGES_EMBED_MAX)}
: {type: 'gallery', images: images.slice(0, MAX_GALLERY_IMAGES)}
if (images.length > LEGACY_IMAGES_EMBED_MAX) {
return {type: 'gallery', images: images.slice(0, MAX_GALLERY_IMAGES)}
}
if (images.length >= 2 && preferredLayout === 'carousel') {
return {type: 'gallery', images}
}
return {type: 'images', images: images.slice(0, LEGACY_IMAGES_EMBED_MAX)}
}
export function composerReducer(
@@ -409,12 +424,19 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
})
}
if (!prevMedia) {
nextMedia = imagesToMediaVariant(action.images)
nextMedia = imagesToMediaVariant(action.images, action.preferredLayout)
} else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') {
nextMedia = imagesToMediaVariant([
...prevMedia.images,
...action.images,
])
/*
* Re-pick using the caller's current effective preference. Because an
* explicit toggle also persists to that preference, this keeps the
* shape consistent as the set grows: a carousel preference stays
* gallery, a grid preference stays legacy images. The count guards in
* imagesToMediaVariant still force gallery past the legacy cap.
*/
nextMedia = imagesToMediaVariant(
[...prevMedia.images, ...action.images],
action.preferredLayout,
)
}
return {
...state,
@@ -462,10 +484,17 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
nextLabels = []
}
} else {
// Re-pick the variant so a gallery that shrinks to <=4 demotes
// back to the legacy `app.bsky.embed.images` shape - keeps old
// clients rendering it when possible.
nextMedia = imagesToMediaVariant(remainingImages)
/*
* Re-pick using the caller's current effective preference so a
* gallery that shrinks to <=4 demotes back to legacy `images` for
* grid users (keeping old clients rendering it), while a carousel
* preference keeps it a gallery. imagesToMediaVariant still forces
* gallery above the legacy cap and images for a lone remaining image.
*/
nextMedia = imagesToMediaVariant(
remainingImages,
action.preferredLayout,
)
}
return {
...state,
@@ -670,12 +699,18 @@ export function createComposerState({
initText,
initMention,
initImageUris,
initImageLayout = 'grid',
initQuoteUri,
initInteractionSettings,
}: {
initText: string | undefined
initMention: string | undefined
initImageUris: ComposerOpts['imageUris']
/**
* Preferred layout for a fresh 2-4 image set supplied via `initImageUris`
* (e.g. share intents). Defaults to `grid` to preserve legacy behavior.
*/
initImageLayout?: ImageLayout
initQuoteUri: string | undefined
initInteractionSettings:
| AppBskyActorDefs.PostInteractionSettingsPref
@@ -683,7 +718,10 @@ export function createComposerState({
}): ComposerState {
let media: ImagesMedia | GalleryMedia | undefined
if (initImageUris?.length) {
media = imagesToMediaVariant(createInitialImages(initImageUris))
media = imagesToMediaVariant(
createInitialImages(initImageUris),
initImageLayout,
)
}
let quote: Link | undefined
if (initQuoteUri) {
@@ -36,7 +36,7 @@ export function SubtitleDialogBtn(props: Props) {
const {_} = useLingui()
return (
<View style={[a.flex_row, a.my_xs]}>
<View style={[a.flex_row]}>
<Button
label={IS_WEB ? _(msg`Captions & alt text`) : _(msg`Alt text`)}
accessibilityHint={
@@ -46,7 +46,6 @@ export function SubtitleDialogBtn(props: Props) {
}
size="small"
color="secondary"
variant="ghost"
onPress={() => {
if (Keyboard.isVisible()) Keyboard.dismiss()
control.open()
@@ -9,6 +9,7 @@ import {useAutoplayDisabled} from '#/state/preferences'
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a} from '#/alf'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
@@ -44,7 +45,7 @@ export function VideoPreview({
<View style={[a.pt_xs]}>
<ConstrainedImage
aspectRatio={constrained || 1}
minMobileAspectRatio={14 / 9}>
minMobileAspectRatio={6 / 5}>
<View style={[a.flex_1, {backgroundColor: 'black'}]}>
<View style={[a.absolute, a.inset_0]}>
<VideoTranscodeBackdrop uri={asset.uri} />
@@ -78,6 +79,7 @@ export function VideoPreview({
<PlayButtonIcon />
</View>
)}
<MediaInsetBorder />
</View>
</ConstrainedImage>
</View>
@@ -8,6 +8,7 @@ import {useAutoplayDisabled} from '#/state/preferences'
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
import {atoms as a} from '#/alf'
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import * as Toast from '#/components/Toast'
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
@@ -42,9 +43,7 @@ export function VideoPreview({
return (
<View style={[a.pt_xs]}>
<ConstrainedImage
aspectRatio={constrained || 1}
minMobileAspectRatio={14 / 9}>
<ConstrainedImage aspectRatio={constrained || 1}>
<View style={[a.flex_1, {backgroundColor: 'black'}]}>
{video.mimeType === 'image/gif' ? (
<img
@@ -83,6 +82,7 @@ export function VideoPreview({
</>
)}
<ExternalEmbedRemoveBtn onRemove={clear} />
<MediaInsetBorder />
</View>
</ConstrainedImage>
</View>
@@ -37,7 +37,7 @@ export function VideoTranscodeProgress({
<View style={[a.pt_xs]}>
<ConstrainedImage
aspectRatio={constrained || 1}
minMobileAspectRatio={14 / 9}>
minMobileAspectRatio={6 / 5}>
<View
style={[
a.flex_1,