From 63ea0624d2f00f736962b7de94021449e509966e Mon Sep 17 00:00:00 2001 From: Samuel Newman Date: Mon, 6 Jul 2026 20:08:01 +0300 Subject: [PATCH] persist, change layout --- src/storage/hooks/composer-image-layout.ts | 25 +++ src/storage/schema.ts | 10 + src/view/com/composer/Composer.tsx | 200 ++++++++++-------- src/view/com/composer/photos/Gallery.tsx | 57 +++-- .../photos/usePreferredImageLayout.ts | 26 +++ src/view/com/composer/state/composer.ts | 80 +++++-- .../com/composer/videos/SubtitleDialog.tsx | 3 +- src/view/com/composer/videos/VideoPreview.tsx | 4 +- .../com/composer/videos/VideoPreview.web.tsx | 6 +- .../videos/VideoTranscodeProgress.tsx | 2 +- 10 files changed, 267 insertions(+), 146 deletions(-) create mode 100644 src/storage/hooks/composer-image-layout.ts create mode 100644 src/view/com/composer/photos/usePreferredImageLayout.ts diff --git a/src/storage/hooks/composer-image-layout.ts b/src/storage/hooks/composer-image-layout.ts new file mode 100644 index 0000000000..af33b3d0f9 --- /dev/null +++ b/src/storage/hooks/composer-image-layout.ts @@ -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) +} diff --git a/src/storage/schema.ts b/src/storage/schema.ts index 341f6dadee..b70850635b 100644 --- a/src/storage/schema.ts +++ b/src/storage/schema.ts @@ -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' } diff --git a/src/view/com/composer/Composer.tsx b/src/view/com/composer/Composer.tsx index 312476758a..77e9e8daa5 100644 --- a/src/view/com/composer/Composer.tsx +++ b/src/view/com/composer/Composer.tsx @@ -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 = ({ /> 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 ( + + {media?.type === 'video' ? ( + + 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. */} + { + dispatch({type: 'update_labels', labels: nextLabels}) + }} + /> + {canToggleLayout ? ( + { + // Persist as the account-level default for future posts. + setComposerImageLayout(currentAccount?.did, nextLayout) + dispatch({type: 'embed_set_image_layout', layout: nextLayout}) + }} + /> + ) : null} + + ) +} + 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)} - - 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, - }, - }) - }} - /> )} + {embed.quote?.uri ? ( @@ -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 }) { 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({ - {isReply ? null : ( - { - dispatch({type: 'update_postgate', postgate: nextPostgate}) - }} - threadgateAllowUISettings={thread.threadgate} - onChangeThreadgateAllowUISettings={nextThreadgate => { - dispatch({ - type: 'update_threadgate', - threadgate: nextThreadgate, - }) - }} - style={bottomBarAnimatedStyle} - /> - )} - {hasMedia || hasLink ? ( - { - dispatch({ - type: 'update_post', - postId: post.id, - postAction: { - type: 'update_labels', - labels: nextLabels, - }, - }) - }} - /> - ) : 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} + { + dispatch({type: 'update_postgate', postgate: nextPostgate}) + }} + threadgateAllowUISettings={thread.threadgate} + onChangeThreadgateAllowUISettings={nextThreadgate => { + dispatch({ + type: 'update_threadgate', + threadgate: nextThreadgate, + }) + }} + style={bottomBarAnimatedStyle} + /> ) diff --git a/src/view/com/composer/photos/Gallery.tsx b/src/view/com/composer/photos/Gallery.tsx index 98bfac779d..922c859ea5 100644 --- a/src/view/com/composer/photos/Gallery.tsx +++ b/src/view/com/composer/photos/Gallery.tsx @@ -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 ? ( - <> - - {images.map(image => { - return ( - { - dispatch({type: 'embed_update_image', image: next}) - }} - onRemove={() => { - dispatch({type: 'embed_remove_image', image}) - }} - /> - ) - })} - - {images.some(image => !image.alt) && ( - - - Alt text describes images for blind and low-vision users, and helps - give context to everyone. - - - )} - + + {images.map(image => { + return ( + { + dispatch({type: 'embed_update_image', image: next}) + }} + onRemove={() => { + dispatch({ + type: 'embed_remove_image', + image, + preferredLayout: getPreferredLayout(), + }) + }} + /> + ) + })} + ) : null } diff --git a/src/view/com/composer/photos/usePreferredImageLayout.ts b/src/view/com/composer/photos/usePreferredImageLayout.ts new file mode 100644 index 0000000000..816b22dbb0 --- /dev/null +++ b/src/view/com/composer/photos/usePreferredImageLayout.ts @@ -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) + } +} diff --git a/src/view/com/composer/state/composer.ts b/src/view/com/composer/state/composer.ts index 3372b201ec..644831d3a0 100644 --- a/src/view/com/composer/state/composer.ts +++ b/src/view/com/composer/state/composer.ts @@ -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) { diff --git a/src/view/com/composer/videos/SubtitleDialog.tsx b/src/view/com/composer/videos/SubtitleDialog.tsx index 237f842406..6127b1947c 100644 --- a/src/view/com/composer/videos/SubtitleDialog.tsx +++ b/src/view/com/composer/videos/SubtitleDialog.tsx @@ -36,7 +36,7 @@ export function SubtitleDialogBtn(props: Props) { const {_} = useLingui() return ( - +