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.
|
* Recently selected GIFs in the GIF picker. Most recent first, capped at 20.
|
||||||
*/
|
*/
|
||||||
recentGifs?: Gif[]
|
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 {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback'
|
||||||
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
|
import {createVideoTelemetry} from '#/lib/media/video/telemetry'
|
||||||
import {mimeToExt} from '#/lib/media/video/util'
|
import {mimeToExt} from '#/lib/media/video/util'
|
||||||
|
import {type SelfLabel} from '#/lib/moderation'
|
||||||
import {useCallOnce} from '#/lib/once'
|
import {useCallOnce} from '#/lib/once'
|
||||||
import {type NavigationProp} from '#/lib/routes/types'
|
import {type NavigationProp} from '#/lib/routes/types'
|
||||||
import {cleanError} from '#/lib/strings/errors'
|
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 {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 {useGetPreferredImageLayout} from '#/view/com/composer/photos/usePreferredImageLayout'
|
||||||
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
import {SuggestedLanguage} from '#/view/com/composer/select-language/SuggestedLanguage'
|
||||||
// TODO: Prevent naming components that coincide with RN primitives
|
// TODO: Prevent naming components that coincide with RN primitives
|
||||||
// due to linting false positives
|
// due to linting false positives
|
||||||
@@ -145,6 +147,7 @@ import {
|
|||||||
IS_WEB_SAFARI,
|
IS_WEB_SAFARI,
|
||||||
} from '#/env'
|
} from '#/env'
|
||||||
import {type Gif} from '#/features/gifPicker/types'
|
import {type Gif} from '#/features/gifPicker/types'
|
||||||
|
import {setComposerImageLayout} from '#/storage/hooks/composer-image-layout'
|
||||||
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
import {BottomSheetPortalProvider} from '../../../../modules/bottom-sheet'
|
||||||
import {
|
import {
|
||||||
draftToComposerPosts,
|
draftToComposerPosts,
|
||||||
@@ -215,6 +218,7 @@ function useAddImagesWithCap(
|
|||||||
dispatchPostAction: (action: PostAction) => void,
|
dispatchPostAction: (action: PostAction) => void,
|
||||||
) {
|
) {
|
||||||
const {t: l} = useLingui()
|
const {t: l} = useLingui()
|
||||||
|
const getPreferredLayout = useGetPreferredImageLayout()
|
||||||
return useCallback(
|
return useCallback(
|
||||||
(next: ComposerImage[]) => {
|
(next: ComposerImage[]) => {
|
||||||
const result = applyGalleryCap(currentCount, next)
|
const result = applyGalleryCap(currentCount, next)
|
||||||
@@ -244,9 +248,10 @@ function useAddImagesWithCap(
|
|||||||
dispatchPostAction({
|
dispatchPostAction({
|
||||||
type: 'embed_add_images',
|
type: 'embed_add_images',
|
||||||
images: result.accepted,
|
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))
|
setLanguageNudgeAt(prev => (now - prev > 10_000 ? now : prev))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getPreferredImageLayout = useGetPreferredImageLayout()
|
||||||
const [composerState, composerDispatch] = useReducer(
|
const [composerState, composerDispatch] = useReducer(
|
||||||
composerReducer,
|
composerReducer,
|
||||||
{
|
{
|
||||||
initImageUris,
|
initImageUris,
|
||||||
|
initImageLayout: getPreferredImageLayout(),
|
||||||
initQuoteUri: initQuote?.uri,
|
initQuoteUri: initQuote?.uri,
|
||||||
initText,
|
initText,
|
||||||
initMention,
|
initMention,
|
||||||
@@ -1361,7 +1368,6 @@ export const ComposePost = ({
|
|||||||
/>
|
/>
|
||||||
<ComposerPills
|
<ComposerPills
|
||||||
isReply={!!replyTo}
|
isReply={!!replyTo}
|
||||||
post={activePost}
|
|
||||||
thread={composerState.thread}
|
thread={composerState.thread}
|
||||||
dispatch={composerDispatch}
|
dispatch={composerDispatch}
|
||||||
bottomBarAnimatedStyle={bottomBarAnimatedStyle}
|
bottomBarAnimatedStyle={bottomBarAnimatedStyle}
|
||||||
@@ -1746,6 +1752,7 @@ let ComposerPost = memo(function ComposerPost({
|
|||||||
<ComposerEmbeds
|
<ComposerEmbeds
|
||||||
canRemoveQuote={canRemoveQuote}
|
canRemoveQuote={canRemoveQuote}
|
||||||
embed={post.embed}
|
embed={post.embed}
|
||||||
|
labels={post.labels}
|
||||||
dispatch={dispatchPost}
|
dispatch={dispatchPost}
|
||||||
clearVideo={() => onClearVideo(post.id)}
|
clearVideo={() => onClearVideo(post.id)}
|
||||||
isActivePost={isActive}
|
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({
|
function ComposerEmbeds({
|
||||||
embed,
|
embed,
|
||||||
|
labels,
|
||||||
dispatch,
|
dispatch,
|
||||||
clearVideo,
|
clearVideo,
|
||||||
canRemoveQuote,
|
canRemoveQuote,
|
||||||
isActivePost,
|
isActivePost,
|
||||||
}: {
|
}: {
|
||||||
embed: EmbedDraft
|
embed: EmbedDraft
|
||||||
|
labels: SelfLabel[]
|
||||||
dispatch: (action: PostAction) => void
|
dispatch: (action: PostAction) => void
|
||||||
clearVideo: () => void
|
clearVideo: () => void
|
||||||
canRemoveQuote: boolean
|
canRemoveQuote: boolean
|
||||||
@@ -1969,33 +2060,10 @@ function ComposerEmbeds({
|
|||||||
clear={clearVideo}
|
clear={clearVideo}
|
||||||
/>
|
/>
|
||||||
) : null)}
|
) : 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>
|
</Animated.View>
|
||||||
)}
|
)}
|
||||||
</LayoutAnimationConfig>
|
</LayoutAnimationConfig>
|
||||||
|
<ComposerEmbedButtons embed={embed} labels={labels} dispatch={dispatch} />
|
||||||
{embed.quote?.uri ? (
|
{embed.quote?.uri ? (
|
||||||
<View
|
<View
|
||||||
style={[a.pb_sm, video ? [a.pt_md] : [a.pt_xl], IS_WEB && [a.pb_md]]}>
|
style={[a.pb_sm, video ? [a.pt_md] : [a.pt_xl], IS_WEB && [a.pb_md]]}>
|
||||||
@@ -2017,28 +2085,20 @@ function ComposerEmbeds({
|
|||||||
function ComposerPills({
|
function ComposerPills({
|
||||||
isReply,
|
isReply,
|
||||||
thread,
|
thread,
|
||||||
post,
|
|
||||||
dispatch,
|
dispatch,
|
||||||
bottomBarAnimatedStyle,
|
bottomBarAnimatedStyle,
|
||||||
}: {
|
}: {
|
||||||
isReply: boolean
|
isReply: boolean
|
||||||
thread: ThreadDraft
|
thread: ThreadDraft
|
||||||
post: PostDraft
|
|
||||||
dispatch: (action: ComposerAction) => void
|
dispatch: (action: ComposerAction) => void
|
||||||
bottomBarAnimatedStyle: StyleProp<ViewStyle>
|
bottomBarAnimatedStyle: StyleProp<ViewStyle>
|
||||||
}) {
|
}) {
|
||||||
const t = useTheme()
|
const t = useTheme()
|
||||||
const ax = useAnalytics()
|
const {gtMobile} = useBreakpoints()
|
||||||
const media = post.embed.media
|
|
||||||
const hasMedia =
|
|
||||||
media?.type === 'images' ||
|
|
||||||
media?.type === 'gallery' ||
|
|
||||||
media?.type === 'gif' ||
|
|
||||||
media?.type === 'video'
|
|
||||||
const hasLink = !!post.embed.link
|
|
||||||
|
|
||||||
// Don't render anything if no pills are going to be displayed
|
// Replies can't set a threadgate, and the labels/layout buttons now live
|
||||||
if (isReply && !hasMedia && !hasLink) {
|
// beneath each post's media, so there are no pills to show for a reply.
|
||||||
|
if (isReply) {
|
||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2046,59 +2106,25 @@ function ComposerPills({
|
|||||||
<Animated.View
|
<Animated.View
|
||||||
style={[a.flex_row, a.py_sm, t.atoms.bg, bottomBarAnimatedStyle]}>
|
style={[a.flex_row, a.py_sm, t.atoms.bg, bottomBarAnimatedStyle]}>
|
||||||
<ScrollView
|
<ScrollView
|
||||||
contentContainerStyle={[a.gap_sm, a.px_sm]}
|
contentContainerStyle={[a.gap_sm, gtMobile ? a.px_lg : a.px_sm]}
|
||||||
horizontal={true}
|
horizontal={true}
|
||||||
bounces={false}
|
bounces={false}
|
||||||
keyboardShouldPersistTaps="always"
|
keyboardShouldPersistTaps="always"
|
||||||
showsHorizontalScrollIndicator={false}>
|
showsHorizontalScrollIndicator={false}>
|
||||||
{isReply ? null : (
|
<ThreadgateBtn
|
||||||
<ThreadgateBtn
|
postgate={thread.postgate}
|
||||||
postgate={thread.postgate}
|
onChangePostgate={nextPostgate => {
|
||||||
onChangePostgate={nextPostgate => {
|
dispatch({type: 'update_postgate', postgate: nextPostgate})
|
||||||
dispatch({type: 'update_postgate', postgate: nextPostgate})
|
}}
|
||||||
}}
|
threadgateAllowUISettings={thread.threadgate}
|
||||||
threadgateAllowUISettings={thread.threadgate}
|
onChangeThreadgateAllowUISettings={nextThreadgate => {
|
||||||
onChangeThreadgateAllowUISettings={nextThreadgate => {
|
dispatch({
|
||||||
dispatch({
|
type: 'update_threadgate',
|
||||||
type: 'update_threadgate',
|
threadgate: nextThreadgate,
|
||||||
threadgate: nextThreadgate,
|
})
|
||||||
})
|
}}
|
||||||
}}
|
style={bottomBarAnimatedStyle}
|
||||||
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}
|
|
||||||
</ScrollView>
|
</ScrollView>
|
||||||
</Animated.View>
|
</Animated.View>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -19,8 +19,7 @@ import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
|
|||||||
import {type Dimensions} from '#/lib/media/types'
|
import {type Dimensions} from '#/lib/media/types'
|
||||||
import {colors} from '#/lib/styles'
|
import {colors} from '#/lib/styles'
|
||||||
import {type ComposerImage, cropImage} from '#/state/gallery'
|
import {type ComposerImage, cropImage} from '#/state/gallery'
|
||||||
import {atoms as a, tokens, useTheme} from '#/alf'
|
import {tokens, useTheme} from '#/alf'
|
||||||
import {Admonition} from '#/components/Admonition'
|
|
||||||
import * as Dialog from '#/components/Dialog'
|
import * as Dialog from '#/components/Dialog'
|
||||||
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
import {Check_Stroke2_Corner0_Rounded as CheckIcon} from '#/components/icons/Check'
|
||||||
import {Pencil_Stroke2_Corner0_Rounded as PencilIcon} from '#/components/icons/Pencil'
|
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 {type PostAction} from '../state/composer'
|
||||||
import {EditImageDialog} from './EditImageDialog'
|
import {EditImageDialog} from './EditImageDialog'
|
||||||
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
import {ImageAltTextDialog} from './ImageAltTextDialog'
|
||||||
|
import {useGetPreferredImageLayout} from './usePreferredImageLayout'
|
||||||
|
|
||||||
const IMAGE_GAP = 8
|
const IMAGE_GAP = 8
|
||||||
|
|
||||||
@@ -68,6 +68,7 @@ interface GalleryInnerProps extends GalleryProps {
|
|||||||
|
|
||||||
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
|
||||||
const {isMobile} = useWebMediaQueries()
|
const {isMobile} = useWebMediaQueries()
|
||||||
|
const getPreferredLayout = useGetPreferredImageLayout()
|
||||||
|
|
||||||
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
|
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
|
||||||
// Cap columns at 4 so tiles stay tappable when MAX_GALLERY_IMAGES is high;
|
// 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])
|
}, [images.length, containerInfo, isMobile])
|
||||||
|
|
||||||
return images.length !== 0 ? (
|
return images.length !== 0 ? (
|
||||||
<>
|
<View testID="selectedPhotosView" style={styles.gallery}>
|
||||||
<View testID="selectedPhotosView" style={styles.gallery}>
|
{images.map(image => {
|
||||||
{images.map(image => {
|
return (
|
||||||
return (
|
<GalleryItem
|
||||||
<GalleryItem
|
key={image.source.id}
|
||||||
key={image.source.id}
|
image={image}
|
||||||
image={image}
|
altTextControlStyle={altTextControlStyle}
|
||||||
altTextControlStyle={altTextControlStyle}
|
imageControlsStyle={imageControlsStyle}
|
||||||
imageControlsStyle={imageControlsStyle}
|
imageStyle={imageStyle}
|
||||||
imageStyle={imageStyle}
|
onChange={next => {
|
||||||
onChange={next => {
|
dispatch({type: 'embed_update_image', image: next})
|
||||||
dispatch({type: 'embed_update_image', image: next})
|
}}
|
||||||
}}
|
onRemove={() => {
|
||||||
onRemove={() => {
|
dispatch({
|
||||||
dispatch({type: 'embed_remove_image', image})
|
type: 'embed_remove_image',
|
||||||
}}
|
image,
|
||||||
/>
|
preferredLayout: getPreferredLayout(),
|
||||||
)
|
})
|
||||||
})}
|
}}
|
||||||
</View>
|
/>
|
||||||
{images.some(image => !image.alt) && (
|
)
|
||||||
<Admonition type="info" style={[a.mt_sm]}>
|
})}
|
||||||
<Trans>
|
</View>
|
||||||
Alt text describes images for blind and low-vision users, and helps
|
|
||||||
give context to everyone.
|
|
||||||
</Trans>
|
|
||||||
</Admonition>
|
|
||||||
)}
|
|
||||||
</>
|
|
||||||
) : null
|
) : 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 =
|
export type PostAction =
|
||||||
| {type: 'update_richtext'; richtext: RichText}
|
| {type: 'update_richtext'; richtext: RichText}
|
||||||
| {type: 'update_labels'; labels: SelfLabel[]}
|
| {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_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_set_image_layout'; layout: ImageLayout}
|
||||||
| {
|
| {
|
||||||
type: 'embed_add_video'
|
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
|
* Picks the embed variant for a set of images. A single image always uses the
|
||||||
* `app.bsky.embed.images` shape; >4 promotes to `app.bsky.embed.gallery`.
|
* legacy `app.bsky.embed.images` shape (it renders identically either way) and
|
||||||
* Anything beyond the gallery cap is dropped by the hard slice; callers
|
* >4 images always promote to `app.bsky.embed.gallery`. For 2-4 images the
|
||||||
* should already have enforced the cap upstream (picker, paste, etc),
|
* `preferredLayout` decides: `carousel` uses the gallery shape, `grid` (the
|
||||||
* and the reducer logs a warning when the cap is exceeded so the UI
|
* default) keeps the legacy images shape. Anything beyond the gallery cap is
|
||||||
* layer can surface a toast.
|
* 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(
|
function imagesToMediaVariant(
|
||||||
images: ComposerImage[],
|
images: ComposerImage[],
|
||||||
|
preferredLayout: ImageLayout = 'grid',
|
||||||
): ImagesMedia | GalleryMedia {
|
): ImagesMedia | GalleryMedia {
|
||||||
return images.length <= LEGACY_IMAGES_EMBED_MAX
|
if (images.length > LEGACY_IMAGES_EMBED_MAX) {
|
||||||
? {type: 'images', images: images.slice(0, LEGACY_IMAGES_EMBED_MAX)}
|
return {type: 'gallery', images: images.slice(0, MAX_GALLERY_IMAGES)}
|
||||||
: {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(
|
export function composerReducer(
|
||||||
@@ -409,12 +424,19 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (!prevMedia) {
|
if (!prevMedia) {
|
||||||
nextMedia = imagesToMediaVariant(action.images)
|
nextMedia = imagesToMediaVariant(action.images, action.preferredLayout)
|
||||||
} else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') {
|
} else if (prevMedia.type === 'images' || prevMedia.type === 'gallery') {
|
||||||
nextMedia = imagesToMediaVariant([
|
/*
|
||||||
...prevMedia.images,
|
* Re-pick using the caller's current effective preference. Because an
|
||||||
...action.images,
|
* 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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -462,10 +484,17 @@ function postReducer(state: PostDraft, action: PostAction): PostDraft {
|
|||||||
nextLabels = []
|
nextLabels = []
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
// Re-pick the variant so a gallery that shrinks to <=4 demotes
|
/*
|
||||||
// back to the legacy `app.bsky.embed.images` shape - keeps old
|
* Re-pick using the caller's current effective preference so a
|
||||||
// clients rendering it when possible.
|
* gallery that shrinks to <=4 demotes back to legacy `images` for
|
||||||
nextMedia = imagesToMediaVariant(remainingImages)
|
* 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 {
|
return {
|
||||||
...state,
|
...state,
|
||||||
@@ -670,12 +699,18 @@ export function createComposerState({
|
|||||||
initText,
|
initText,
|
||||||
initMention,
|
initMention,
|
||||||
initImageUris,
|
initImageUris,
|
||||||
|
initImageLayout = 'grid',
|
||||||
initQuoteUri,
|
initQuoteUri,
|
||||||
initInteractionSettings,
|
initInteractionSettings,
|
||||||
}: {
|
}: {
|
||||||
initText: string | undefined
|
initText: string | undefined
|
||||||
initMention: string | undefined
|
initMention: string | undefined
|
||||||
initImageUris: ComposerOpts['imageUris']
|
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
|
initQuoteUri: string | undefined
|
||||||
initInteractionSettings:
|
initInteractionSettings:
|
||||||
| AppBskyActorDefs.PostInteractionSettingsPref
|
| AppBskyActorDefs.PostInteractionSettingsPref
|
||||||
@@ -683,7 +718,10 @@ export function createComposerState({
|
|||||||
}): ComposerState {
|
}): ComposerState {
|
||||||
let media: ImagesMedia | GalleryMedia | undefined
|
let media: ImagesMedia | GalleryMedia | undefined
|
||||||
if (initImageUris?.length) {
|
if (initImageUris?.length) {
|
||||||
media = imagesToMediaVariant(createInitialImages(initImageUris))
|
media = imagesToMediaVariant(
|
||||||
|
createInitialImages(initImageUris),
|
||||||
|
initImageLayout,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
let quote: Link | undefined
|
let quote: Link | undefined
|
||||||
if (initQuoteUri) {
|
if (initQuoteUri) {
|
||||||
|
|||||||
@@ -36,7 +36,7 @@ export function SubtitleDialogBtn(props: Props) {
|
|||||||
const {_} = useLingui()
|
const {_} = useLingui()
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.flex_row, a.my_xs]}>
|
<View style={[a.flex_row]}>
|
||||||
<Button
|
<Button
|
||||||
label={IS_WEB ? _(msg`Captions & alt text`) : _(msg`Alt text`)}
|
label={IS_WEB ? _(msg`Captions & alt text`) : _(msg`Alt text`)}
|
||||||
accessibilityHint={
|
accessibilityHint={
|
||||||
@@ -46,7 +46,6 @@ export function SubtitleDialogBtn(props: Props) {
|
|||||||
}
|
}
|
||||||
size="small"
|
size="small"
|
||||||
color="secondary"
|
color="secondary"
|
||||||
variant="ghost"
|
|
||||||
onPress={() => {
|
onPress={() => {
|
||||||
if (Keyboard.isVisible()) Keyboard.dismiss()
|
if (Keyboard.isVisible()) Keyboard.dismiss()
|
||||||
control.open()
|
control.open()
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import {useAutoplayDisabled} from '#/state/preferences'
|
|||||||
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||||
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
import {VideoTranscodeBackdrop} from './VideoTranscodeBackdrop'
|
||||||
|
|
||||||
@@ -44,7 +45,7 @@ export function VideoPreview({
|
|||||||
<View style={[a.pt_xs]}>
|
<View style={[a.pt_xs]}>
|
||||||
<ConstrainedImage
|
<ConstrainedImage
|
||||||
aspectRatio={constrained || 1}
|
aspectRatio={constrained || 1}
|
||||||
minMobileAspectRatio={14 / 9}>
|
minMobileAspectRatio={6 / 5}>
|
||||||
<View style={[a.flex_1, {backgroundColor: 'black'}]}>
|
<View style={[a.flex_1, {backgroundColor: 'black'}]}>
|
||||||
<View style={[a.absolute, a.inset_0]}>
|
<View style={[a.absolute, a.inset_0]}>
|
||||||
<VideoTranscodeBackdrop uri={asset.uri} />
|
<VideoTranscodeBackdrop uri={asset.uri} />
|
||||||
@@ -78,6 +79,7 @@ export function VideoPreview({
|
|||||||
<PlayButtonIcon />
|
<PlayButtonIcon />
|
||||||
</View>
|
</View>
|
||||||
)}
|
)}
|
||||||
|
<MediaInsetBorder />
|
||||||
</View>
|
</View>
|
||||||
</ConstrainedImage>
|
</ConstrainedImage>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {useAutoplayDisabled} from '#/state/preferences'
|
|||||||
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
import {ExternalEmbedRemoveBtn} from '#/view/com/composer/ExternalEmbedRemoveBtn'
|
||||||
import {atoms as a} from '#/alf'
|
import {atoms as a} from '#/alf'
|
||||||
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
import {ConstrainedImage} from '#/components/images/AutoSizedImage'
|
||||||
|
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||||
import * as Toast from '#/components/Toast'
|
import * as Toast from '#/components/Toast'
|
||||||
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
import {PlayButtonIcon} from '#/components/video/PlayButtonIcon'
|
||||||
|
|
||||||
@@ -42,9 +43,7 @@ export function VideoPreview({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<View style={[a.pt_xs]}>
|
<View style={[a.pt_xs]}>
|
||||||
<ConstrainedImage
|
<ConstrainedImage aspectRatio={constrained || 1}>
|
||||||
aspectRatio={constrained || 1}
|
|
||||||
minMobileAspectRatio={14 / 9}>
|
|
||||||
<View style={[a.flex_1, {backgroundColor: 'black'}]}>
|
<View style={[a.flex_1, {backgroundColor: 'black'}]}>
|
||||||
{video.mimeType === 'image/gif' ? (
|
{video.mimeType === 'image/gif' ? (
|
||||||
<img
|
<img
|
||||||
@@ -83,6 +82,7 @@ export function VideoPreview({
|
|||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<ExternalEmbedRemoveBtn onRemove={clear} />
|
<ExternalEmbedRemoveBtn onRemove={clear} />
|
||||||
|
<MediaInsetBorder />
|
||||||
</View>
|
</View>
|
||||||
</ConstrainedImage>
|
</ConstrainedImage>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -37,7 +37,7 @@ export function VideoTranscodeProgress({
|
|||||||
<View style={[a.pt_xs]}>
|
<View style={[a.pt_xs]}>
|
||||||
<ConstrainedImage
|
<ConstrainedImage
|
||||||
aspectRatio={constrained || 1}
|
aspectRatio={constrained || 1}
|
||||||
minMobileAspectRatio={14 / 9}>
|
minMobileAspectRatio={6 / 5}>
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_1,
|
a.flex_1,
|
||||||
|
|||||||
Reference in New Issue
Block a user