diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts
index b86c62e5ba..70cb8fc38d 100644
--- a/src/analytics/features/types.ts
+++ b/src/analytics/features/types.ts
@@ -13,6 +13,7 @@ export enum Features {
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
GroupChatsEnable = 'group_chats:enable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
+ PostGalleryEmbedEnable = 'post_gallery_embed:enable',
AATest = 'aa-test',
}
diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts
index ccb256e219..1d74e1c5b8 100644
--- a/src/analytics/metrics/types.ts
+++ b/src/analytics/metrics/types.ts
@@ -1046,4 +1046,19 @@ export type Events = {
'profile:associated:germ:click-self-info': {}
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
+
+ // Gallery carousel events
+ 'post:gallery:swipe': {
+ fromImage: number
+ toImage: number
+ totalImages: number
+ }
+ 'post:gallery:openLightbox': {
+ fromImage: number
+ totalImages: number
+ }
+ 'post:gallery:impression': {
+ totalImages: number
+ postUri: string
+ }
}
diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx
index a3f0d46377..fba3256d56 100644
--- a/src/components/Post/Embed/ImageEmbed.tsx
+++ b/src/components/Post/Embed/ImageEmbed.tsx
@@ -12,8 +12,10 @@ import {useLightboxControls} from '#/state/lightbox'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
+import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
+import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
@@ -23,8 +25,10 @@ export function ImageEmbed({
}: CommonProps & {
embed: EmbedType<'images'>
}) {
+ const ax = useAnalytics()
const {openLightbox} = useLightboxControls()
const {images} = embed.view
+ const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
if (images.length > 0) {
const items = images.map(img => ({
@@ -95,6 +99,19 @@ export function ImageEmbed({
)
}
+ if (galleryEnabled) {
+ return (
+
+
+
+ )
+ }
+
return (
-
- {({active}) => (
- <>
- {!active && !linkDisabled && (
-
- )}
- {linkDisabled ? (
-
- {contents}
-
- ) : (
-
- {contents}
-
- )}
- >
- )}
-
-
+
+
+
+ {({active}) => (
+ <>
+ {!active && !linkDisabled && (
+
+ )}
+ {linkDisabled ? (
+
+ {contents}
+
+ ) : (
+
+ {contents}
+
+ )}
+ >
+ )}
+
+
+
)
}
diff --git a/src/components/images/Gallery/const.ts b/src/components/images/Gallery/const.ts
new file mode 100644
index 0000000000..443b65ecb8
--- /dev/null
+++ b/src/components/images/Gallery/const.ts
@@ -0,0 +1,3 @@
+export const ITEM_GAP = 8 // tokens.space.sm
+export const MIN_ASPECT_RATIO = 2 / 3 // portrait limit
+export const MAX_ASPECT_RATIO = 3 / 2 // landscape limit
diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx
new file mode 100644
index 0000000000..7c3a84e9d0
--- /dev/null
+++ b/src/components/images/Gallery/index.tsx
@@ -0,0 +1,531 @@
+import {
+ cloneElement,
+ createContext,
+ isValidElement,
+ useContext,
+ useEffect,
+ useMemo,
+ useRef,
+ useState,
+} from 'react'
+import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
+import Animated, {
+ type AnimatedRef,
+ useAnimatedRef,
+} from 'react-native-reanimated'
+import {Image} from 'expo-image'
+import {type AppBskyEmbedImages} from '@atproto/api'
+import {utils} from '@bsky.app/alf'
+import {Trans, useLingui} from '@lingui/react/macro'
+import debounce from 'lodash.debounce'
+
+import {type Dimensions} from '#/lib/media/types'
+import {mergeRefs} from '#/lib/merge-refs'
+import {useA11y} from '#/state/a11y'
+import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
+import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
+import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
+import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
+import {AutoSizedImage} from '#/components/images/AutoSizedImage'
+import {
+ ITEM_GAP,
+ MAX_ASPECT_RATIO,
+ MIN_ASPECT_RATIO,
+} from '#/components/images/Gallery/const'
+import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers'
+import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
+import {getAspectRatio} from '#/components/images/Gallery/utils'
+import {MediaInsetBorder} from '#/components/MediaInsetBorder'
+import {PostEmbedViewContext} from '#/components/Post/Embed/types'
+import {Text} from '#/components/Typography'
+import {useAnalytics} from '#/analytics'
+import {IS_WEB} from '#/env'
+
+export * from './const'
+export * from './maybeApplyGalleryOffsetStyles'
+
+interface GalleryProps {
+ images: AppBskyEmbedImages.ViewImage[]
+ onPress?: (
+ index: number,
+ containerRefs: AnimatedRef[],
+ fetchedDims: (Dimensions | null)[],
+ ) => void
+ onPressIn?: (index: number) => void
+ viewContext?: PostEmbedViewContext
+}
+
+const Context = createContext<{
+ bleedRef: React.RefObject
+ bleedWidth: number
+}>({
+ bleedRef: {current: null},
+ bleedWidth: 0,
+})
+
+export function GalleryBleed({children}: {children: React.ReactNode}) {
+ const ref = useRef(null)
+ const [bleedWidth, setBleedWidth] = useState(0)
+
+ if (!isValidElement(children)) {
+ throw new Error('GalleryBleed children must be a single React element')
+ }
+
+ const node = children as React.ReactElement
+
+ return (
+
+ {cloneElement(node, {
+ ref: mergeRefs([ref, node?.props?.ref]),
+ onLayout: (e: {nativeEvent: {layout: {width: number}}}) => {
+ setBleedWidth(e.nativeEvent.layout.width)
+ node.props.onLayout?.(e)
+ },
+ style: [node.props.style, a.overflow_hidden],
+ })}
+
+ )
+}
+
+export function useGalleryBleed() {
+ return useContext(Context)
+}
+
+export function Gallery({
+ images,
+ onPress,
+ onPressIn,
+ viewContext,
+}: GalleryProps) {
+ const {t: l} = useLingui()
+ const ax = useAnalytics()
+ const {screenReaderEnabled} = useA11y()
+ const largeAltBadge = useLargeAltBadgeEnabled()
+ const bps = useBreakpoints()
+ const window = useWindowDimensions()
+ const contentHeight = useMemo(() => {
+ if (bps.gtMobile) {
+ return 300
+ } else if (bps.gtPhone) {
+ return 260
+ } else {
+ return 200
+ }
+ }, [bps])
+ const isWithinQuote =
+ viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
+ const hideBadges = isWithinQuote
+
+ /*
+ * Container overflow styles
+ *
+ * Uses measureLayout to get the Gallery's offset relative to the GalleryBleed
+ * ancestor. This is a layout-relative measurement that doesn't depend on
+ * scroll position, so it works correctly for off-screen FlatList items.
+ */
+ const {bleedRef, bleedWidth} = useGalleryBleed()
+ const contentRef = useRef(null)
+ const [contentDims, setContentDims] = useState<{x: number; width: number}>()
+ const measure = () => {
+ if (contentRef.current && bleedRef.current) {
+ contentRef.current.measureLayout(
+ bleedRef.current,
+ (x, _y, w) => {
+ setContentDims({x, width: w})
+ },
+ () => {},
+ )
+ }
+ }
+ const width = bleedWidth || Math.min(600, window.width)
+ const insetLeft = contentDims?.x ?? 0
+ const insetRight =
+ bleedWidth > 0
+ ? bleedWidth - (contentDims?.x ?? 0) - (contentDims?.width ?? 0)
+ : 0
+ /* End container overflow styles */
+
+ const flatListRef = useRef(null)
+ const itemWidthsRef = useRef