persist, change layout
This commit is contained in:
@@ -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)
|
||||
}
|
||||
@@ -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'
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user