Sketch out 'shell' approach
This commit is contained in:
@@ -42,6 +42,7 @@ import {
|
||||
QuoteEmbedViewContext,
|
||||
} from './types'
|
||||
import {VideoEmbed} from './VideoEmbed'
|
||||
import {GalleryBleed} from '#/components/images/Gallery'
|
||||
|
||||
export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
|
||||
|
||||
@@ -320,43 +321,45 @@ export function QuoteEmbed({
|
||||
)
|
||||
|
||||
return (
|
||||
<View
|
||||
style={[a.mt_sm]}
|
||||
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
|
||||
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
|
||||
<ContentHider
|
||||
modui={moderation?.ui('contentList')}
|
||||
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
|
||||
activeStyle={[a.p_md, a.pt_sm]}
|
||||
childContainerStyle={[a.pt_sm]}>
|
||||
{({active}) => (
|
||||
<>
|
||||
{!active && !linkDisabled && (
|
||||
<SubtleHover
|
||||
native
|
||||
hover={hover || pressed}
|
||||
style={[a.rounded_md]}
|
||||
/>
|
||||
)}
|
||||
{linkDisabled ? (
|
||||
<View style={[!active && a.p_md]} pointerEvents="none">
|
||||
{contents}
|
||||
</View>
|
||||
) : (
|
||||
<Link
|
||||
style={[!active && a.p_md]}
|
||||
hoverStyle={t.atoms.border_contrast_high}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
onBeforePress={onBeforePress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}>
|
||||
{contents}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContentHider>
|
||||
</View>
|
||||
<GalleryBleed>
|
||||
<View
|
||||
style={[a.mt_sm]}
|
||||
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
|
||||
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
|
||||
<ContentHider
|
||||
modui={moderation?.ui('contentList')}
|
||||
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
|
||||
activeStyle={[a.p_md, a.pt_sm]}
|
||||
childContainerStyle={[a.pt_sm]}>
|
||||
{({active}) => (
|
||||
<>
|
||||
{!active && !linkDisabled && (
|
||||
<SubtleHover
|
||||
native
|
||||
hover={hover || pressed}
|
||||
style={[a.rounded_md]}
|
||||
/>
|
||||
)}
|
||||
{linkDisabled ? (
|
||||
<View style={[!active && a.p_md]} pointerEvents="none">
|
||||
{contents}
|
||||
</View>
|
||||
) : (
|
||||
<Link
|
||||
style={[!active && a.p_md]}
|
||||
hoverStyle={t.atoms.border_contrast_high}
|
||||
href={itemHref}
|
||||
title={itemTitle}
|
||||
onBeforePress={onBeforePress}
|
||||
onPressIn={onPressIn}
|
||||
onPressOut={onPressOut}>
|
||||
{contents}
|
||||
</Link>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ContentHider>
|
||||
</View>
|
||||
</GalleryBleed>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,332 @@
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
isValidElement,
|
||||
} from 'react'
|
||||
import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
|
||||
import {DrawerGestureContext} from 'react-native-drawer-layout'
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
import {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 {mergeRefs} from '#/lib/merge-refs'
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
|
||||
const CONTAINER_ASPECT_RATIO = 3 / 2
|
||||
const ITEM_GAP = 8 // tokens.space.sm
|
||||
const MIN_PEEK = 40
|
||||
|
||||
interface GalleryProps {
|
||||
images: AppBskyEmbedImages.ViewImage[]
|
||||
onPress?: (
|
||||
index: number,
|
||||
containerRefs: AnimatedRef<any>[],
|
||||
fetchedDims: (Dimensions | null)[],
|
||||
) => void
|
||||
onPressIn?: (index: number) => void
|
||||
viewContext?: PostEmbedViewContext
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
ref: React.RefObject<View | null>
|
||||
}>({
|
||||
ref: {current: null},
|
||||
})
|
||||
|
||||
export function GalleryBleed({children}: {children: React.ReactNode}) {
|
||||
const ref = useRef<View>(null)
|
||||
|
||||
if (!isValidElement(children)) {
|
||||
throw new Error('GalleryBleed children must be a single React element')
|
||||
}
|
||||
|
||||
const node = children as React.ReactElement<any>
|
||||
|
||||
return (
|
||||
<Context.Provider value={{ref}}>
|
||||
{cloneElement(node, {
|
||||
ref: mergeRefs([ref, node?.props?.ref]),
|
||||
})}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function Gallery({
|
||||
images,
|
||||
onPress,
|
||||
onPressIn,
|
||||
viewContext,
|
||||
}: GalleryProps) {
|
||||
const t = useTheme()
|
||||
const {t: l} = useLingui()
|
||||
const ax = useAnalytics()
|
||||
const {screenReaderEnabled} = useA11y()
|
||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||
const currentPageRef = useRef(0)
|
||||
const {width: windowWidth} = useWindowDimensions()
|
||||
const [leftOffset, setLeftOffset] = useState(0)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
|
||||
const containerRefs = useRef<AnimatedRef<any>[]>([]).current
|
||||
const thumbDimsRef = useRef<(Dimensions | null)[]>([])
|
||||
|
||||
const ref0 = useAnimatedRef()
|
||||
const ref1 = useAnimatedRef()
|
||||
const ref2 = useAnimatedRef()
|
||||
const ref3 = useAnimatedRef()
|
||||
const refs = [ref0, ref1, ref2, ref3]
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
containerRefs[i] = refs[i]
|
||||
}
|
||||
|
||||
const isWithinQuote =
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
const hideBadges = isWithinQuote
|
||||
|
||||
const containerHeight =
|
||||
containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0
|
||||
// Bleed: full-width carousel that extends to screen edges
|
||||
// In quotes: small bleed to the quote card border (p_md = 12px)
|
||||
const QUOTE_PADDING = 12
|
||||
const bleed = !isWithinQuote
|
||||
const insetLeft = bleed
|
||||
? leftOffset || windowWidth - containerWidth
|
||||
: QUOTE_PADDING
|
||||
const insetRight = bleed
|
||||
? windowWidth - insetLeft - containerWidth
|
||||
: QUOTE_PADDING
|
||||
|
||||
const getItemWidth = (image: AppBskyEmbedImages.ViewImage, index: number) => {
|
||||
const ar = image.aspectRatio
|
||||
let width = containerHeight // default to square-ish
|
||||
if (ar && ar.width > 0 && ar.height > 0) {
|
||||
const ratio = ar.width / ar.height
|
||||
// Width derived from image's own aspect ratio at the fixed container height
|
||||
// Clamp aspect ratio between 2:3 (portrait) and 3:2 (landscape)
|
||||
const clamped = Math.max(2 / 3, Math.min(ratio, 3 / 2))
|
||||
width = containerHeight * clamped
|
||||
}
|
||||
// Ensure the first image leaves room for a peek of the next
|
||||
if (index === 0 && images.length > 1) {
|
||||
width = Math.min(width, containerWidth - MIN_PEEK)
|
||||
}
|
||||
return width
|
||||
}
|
||||
|
||||
if (screenReaderEnabled) {
|
||||
return (
|
||||
<View style={[a.relative, a.gap_sm]}>
|
||||
{images.map((image, index) => (
|
||||
<AutoSizedImage
|
||||
key={image.thumb}
|
||||
crop={
|
||||
viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onPress={(containerRef, dims) =>
|
||||
onPress?.(index, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn?.(index)}
|
||||
hideBadge={
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
containerWidth > 0
|
||||
? {height: containerHeight, overflow: 'visible'}
|
||||
: {aspectRatio: CONTAINER_ASPECT_RATIO}
|
||||
}
|
||||
onLayout={e => {
|
||||
const w = e.nativeEvent.layout.width
|
||||
if (w > 0) {
|
||||
setContainerWidth(w)
|
||||
}
|
||||
e.target.measureInWindow((x: number) => {
|
||||
if (x > 0) {
|
||||
setLeftOffset(x)
|
||||
}
|
||||
})
|
||||
}}>
|
||||
{containerWidth > 0 && (
|
||||
<DrawerGestureBlocker>
|
||||
<FlatList
|
||||
data={images}
|
||||
horizontal
|
||||
pagingEnabled={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate={0.993}
|
||||
directionalLockEnabled
|
||||
alwaysBounceVertical={false}
|
||||
style={{
|
||||
width: bleed ? windowWidth : containerWidth + QUOTE_PADDING * 2,
|
||||
height: containerHeight,
|
||||
marginLeft: -insetLeft,
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
gap: ITEM_GAP,
|
||||
paddingLeft: insetLeft,
|
||||
paddingRight: insetRight,
|
||||
}}
|
||||
onScroll={e => {
|
||||
const offsetX = e.nativeEvent.contentOffset.x
|
||||
// Determine which item is most visible based on scroll position
|
||||
let accumulated = insetLeft // account for left content padding
|
||||
let page = 0
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const w = getItemWidth(images[i], i) + ITEM_GAP
|
||||
if (offsetX < accumulated + w / 2) {
|
||||
page = i
|
||||
break
|
||||
}
|
||||
accumulated += w
|
||||
page = i
|
||||
}
|
||||
if (page !== currentPageRef.current) {
|
||||
ax.metric('post:gallery:swipe', {
|
||||
fromIndex: currentPageRef.current,
|
||||
toIndex: page,
|
||||
totalImages: images.length,
|
||||
})
|
||||
currentPageRef.current = page
|
||||
}
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
keyExtractor={(_, index) => String(index)}
|
||||
renderItem={({item: image, index}) => (
|
||||
<View
|
||||
ref={containerRefs[index]}
|
||||
collapsable={false}
|
||||
style={[
|
||||
{
|
||||
width: getItemWidth(image, index),
|
||||
height: containerHeight,
|
||||
},
|
||||
]}>
|
||||
<Pressable
|
||||
onPress={
|
||||
onPress
|
||||
? () => {
|
||||
ax.metric('post:gallery:openLightbox', {
|
||||
imageIndex: index,
|
||||
totalImages: images.length,
|
||||
})
|
||||
onPress(
|
||||
index,
|
||||
containerRefs.slice(0, images.length),
|
||||
thumbDimsRef.current.slice(),
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||
android_ripple={{
|
||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||
foreground: true,
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
image.alt || l`Image ${index + 1} of ${images.length}`
|
||||
}
|
||||
accessibilityHint={l`Opens full image`}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Image
|
||||
source={{uri: image.thumb}}
|
||||
style={[a.flex_1]}
|
||||
contentFit="cover"
|
||||
accessible={true}
|
||||
accessibilityLabel={image.alt}
|
||||
accessibilityHint=""
|
||||
accessibilityIgnoresInvertColors
|
||||
onLoad={e => {
|
||||
thumbDimsRef.current[index] = {
|
||||
width: e.source.width,
|
||||
height: e.source.height,
|
||||
}
|
||||
}}
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
</Pressable>
|
||||
{image.alt && !hideBadges ? (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: 3,
|
||||
padding: 3,
|
||||
bottom: a.p_xs.padding,
|
||||
right: a.p_xs.padding,
|
||||
opacity: 0.8,
|
||||
},
|
||||
largeAltBadge && {
|
||||
gap: 4,
|
||||
padding: 5,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
largeAltBadge ? a.text_xs : {fontSize: 8},
|
||||
]}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</DrawerGestureBlocker>
|
||||
)}
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerGestureBlocker({children}: {children: React.ReactNode}) {
|
||||
const drawerGesture = useContext(DrawerGestureContext)
|
||||
|
||||
const nativeGesture = useMemo(() => {
|
||||
const gesture = Gesture.Native()
|
||||
if (drawerGesture) {
|
||||
gesture.blocksExternalGesture(drawerGesture)
|
||||
}
|
||||
return gesture
|
||||
}, [drawerGesture])
|
||||
|
||||
return <GestureDetector gesture={nativeGesture}>{children}</GestureDetector>
|
||||
}
|
||||
@@ -1,4 +1,12 @@
|
||||
import {useContext, useMemo, useRef, useState} from 'react'
|
||||
import {
|
||||
cloneElement,
|
||||
createContext,
|
||||
useContext,
|
||||
useMemo,
|
||||
useRef,
|
||||
useState,
|
||||
isValidElement,
|
||||
} from 'react'
|
||||
import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
|
||||
import {DrawerGestureContext} from 'react-native-drawer-layout'
|
||||
import {Gesture, GestureDetector} from 'react-native-gesture-handler'
|
||||
@@ -8,15 +16,17 @@ import {type AppBskyEmbedImages} from '@atproto/api'
|
||||
import {utils} from '@bsky.app/alf'
|
||||
import {Trans, useLingui} from '@lingui/react/macro'
|
||||
|
||||
import {mergeRefs} from '#/lib/merge-refs'
|
||||
import {type Dimensions} from '#/lib/media/types'
|
||||
import {useA11y} from '#/state/a11y'
|
||||
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
|
||||
import {atoms as a, useTheme} from '#/alf'
|
||||
import {atoms as a, useTheme, useBreakpoints} from '#/alf'
|
||||
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
|
||||
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
|
||||
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
|
||||
import {Text} from '#/components/Typography'
|
||||
import {useAnalytics} from '#/analytics'
|
||||
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
|
||||
|
||||
const CONTAINER_ASPECT_RATIO = 3 / 2
|
||||
const ITEM_GAP = 8 // tokens.space.sm
|
||||
@@ -33,6 +43,36 @@ interface GalleryProps {
|
||||
viewContext?: PostEmbedViewContext
|
||||
}
|
||||
|
||||
const Context = createContext<{
|
||||
ref: React.RefObject<View | null>
|
||||
}>({
|
||||
ref: {current: null},
|
||||
})
|
||||
|
||||
export function GalleryBleed({children}: {children: React.ReactNode}) {
|
||||
const ref = useRef<View>(null)
|
||||
|
||||
if (!isValidElement(children)) {
|
||||
throw new Error('GalleryBleed children must be a single React element')
|
||||
}
|
||||
|
||||
const node = children as React.ReactElement<any>
|
||||
|
||||
return (
|
||||
<Context.Provider value={{ref}}>
|
||||
{cloneElement(node, {
|
||||
ref: mergeRefs([ref, node?.props?.ref]),
|
||||
})}
|
||||
</Context.Provider>
|
||||
)
|
||||
}
|
||||
|
||||
export function useGalleryBleedRef() {
|
||||
const {ref} = useContext(Context)
|
||||
// TODO throw?
|
||||
return ref
|
||||
}
|
||||
|
||||
export function Gallery({
|
||||
images,
|
||||
onPress,
|
||||
@@ -44,256 +84,157 @@ export function Gallery({
|
||||
const ax = useAnalytics()
|
||||
const {screenReaderEnabled} = useA11y()
|
||||
const largeAltBadge = useLargeAltBadgeEnabled()
|
||||
const currentPageRef = useRef(0)
|
||||
const {width: windowWidth} = useWindowDimensions()
|
||||
const [leftOffset, setLeftOffset] = useState(0)
|
||||
const [containerWidth, setContainerWidth] = useState(0)
|
||||
|
||||
const containerRefs = useRef<AnimatedRef<any>[]>([]).current
|
||||
const thumbDimsRef = useRef<(Dimensions | null)[]>([])
|
||||
|
||||
const ref0 = useAnimatedRef()
|
||||
const ref1 = useAnimatedRef()
|
||||
const ref2 = useAnimatedRef()
|
||||
const ref3 = useAnimatedRef()
|
||||
const refs = [ref0, ref1, ref2, ref3]
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
containerRefs[i] = refs[i]
|
||||
}
|
||||
|
||||
const isWithinQuote =
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
const hideBadges = isWithinQuote
|
||||
|
||||
const containerHeight =
|
||||
containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0
|
||||
// Bleed: full-width carousel that extends to screen edges
|
||||
// In quotes: small bleed to the quote card border (p_md = 12px)
|
||||
const QUOTE_PADDING = 12
|
||||
const bleed = !isWithinQuote
|
||||
const insetLeft = bleed
|
||||
? leftOffset || windowWidth - containerWidth
|
||||
: QUOTE_PADDING
|
||||
const insetRight = bleed
|
||||
? windowWidth - insetLeft - containerWidth
|
||||
: QUOTE_PADDING
|
||||
|
||||
const getItemWidth = (image: AppBskyEmbedImages.ViewImage, index: number) => {
|
||||
const ar = image.aspectRatio
|
||||
let width = containerHeight // default to square-ish
|
||||
if (ar && ar.width > 0 && ar.height > 0) {
|
||||
const ratio = ar.width / ar.height
|
||||
// Width derived from image's own aspect ratio at the fixed container height
|
||||
// Clamp aspect ratio between 2:3 (portrait) and 3:2 (landscape)
|
||||
const clamped = Math.max(2 / 3, Math.min(ratio, 3 / 2))
|
||||
width = containerHeight * clamped
|
||||
const bps = useBreakpoints()
|
||||
const window = useWindowDimensions()
|
||||
const contentHeight = useMemo(() => {
|
||||
if (bps.gtMobile) {
|
||||
return 300
|
||||
} else if (bps.gtPhone) {
|
||||
return 260
|
||||
} else {
|
||||
return 200
|
||||
}
|
||||
// Ensure the first image leaves room for a peek of the next
|
||||
if (index === 0 && images.length > 1) {
|
||||
width = Math.min(width, containerWidth - MIN_PEEK)
|
||||
}
|
||||
return width
|
||||
}
|
||||
}, [bps])
|
||||
|
||||
if (screenReaderEnabled) {
|
||||
return (
|
||||
<View style={[a.relative, a.gap_sm]}>
|
||||
{images.map((image, index) => (
|
||||
<AutoSizedImage
|
||||
key={image.thumb}
|
||||
crop={
|
||||
viewContext === PostEmbedViewContext.ThreadHighlighted
|
||||
? 'none'
|
||||
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
? 'square'
|
||||
: 'constrained'
|
||||
}
|
||||
image={image}
|
||||
onPress={(containerRef, dims) =>
|
||||
onPress?.(index, [containerRef], [dims])
|
||||
}
|
||||
onPressIn={() => onPressIn?.(index)}
|
||||
hideBadge={
|
||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</View>
|
||||
)
|
||||
/*
|
||||
* Container overflow styles
|
||||
*/
|
||||
const bleedRef = useGalleryBleedRef()
|
||||
const [bleedDims, setBleedDims] = useState<{
|
||||
left: number
|
||||
right: number
|
||||
width: number
|
||||
}>()
|
||||
const measureBleed = () => {
|
||||
bleedRef?.current?.measureInWindow((x, _y, width) => {
|
||||
setBleedDims({left: x, right: x + width, width})
|
||||
})
|
||||
}
|
||||
const contentRef = useRef<View>(null)
|
||||
const [contentDims, setContentDims] = useState<{
|
||||
left: number
|
||||
right: number
|
||||
width: number
|
||||
}>()
|
||||
const measureContent = () => {
|
||||
contentRef?.current?.measureInWindow((x, _y, width) => {
|
||||
setContentDims({left: x, right: x + width, width})
|
||||
})
|
||||
}
|
||||
const insetLeft =
|
||||
bleedDims && contentDims
|
||||
? Math.max(0, contentDims.left - bleedDims.left)
|
||||
: 999
|
||||
const insetRight =
|
||||
bleedDims && contentDims
|
||||
? Math.max(0, bleedDims.right - contentDims.right)
|
||||
: 999
|
||||
const width = bleedDims ? bleedDims.width : Math.min(600, window.width)
|
||||
/* End container overflow styles */
|
||||
|
||||
return (
|
||||
<View
|
||||
style={
|
||||
containerWidth > 0
|
||||
? {height: containerHeight, overflow: 'visible'}
|
||||
: {aspectRatio: CONTAINER_ASPECT_RATIO}
|
||||
}
|
||||
onLayout={e => {
|
||||
const w = e.nativeEvent.layout.width
|
||||
if (w > 0) {
|
||||
setContainerWidth(w)
|
||||
}
|
||||
e.target.measureInWindow((x: number) => {
|
||||
if (x > 0) {
|
||||
setLeftOffset(x)
|
||||
}
|
||||
})
|
||||
ref={contentRef}
|
||||
style={[
|
||||
a.w_full,
|
||||
{
|
||||
height: contentHeight,
|
||||
overflow: 'visible',
|
||||
},
|
||||
]}
|
||||
onLayout={() => {
|
||||
measureBleed()
|
||||
measureContent()
|
||||
}}>
|
||||
{containerWidth > 0 && (
|
||||
<DrawerGestureBlocker>
|
||||
<FlatList
|
||||
data={images}
|
||||
horizontal
|
||||
pagingEnabled={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate={0.993}
|
||||
directionalLockEnabled
|
||||
alwaysBounceVertical={false}
|
||||
style={{
|
||||
width: bleed ? windowWidth : containerWidth + QUOTE_PADDING * 2,
|
||||
height: containerHeight,
|
||||
marginLeft: -insetLeft,
|
||||
}}
|
||||
contentContainerStyle={{
|
||||
gap: ITEM_GAP,
|
||||
paddingLeft: insetLeft,
|
||||
paddingRight: insetRight,
|
||||
}}
|
||||
onScroll={e => {
|
||||
const offsetX = e.nativeEvent.contentOffset.x
|
||||
// Determine which item is most visible based on scroll position
|
||||
let accumulated = insetLeft // account for left content padding
|
||||
let page = 0
|
||||
for (let i = 0; i < images.length; i++) {
|
||||
const w = getItemWidth(images[i], i) + ITEM_GAP
|
||||
if (offsetX < accumulated + w / 2) {
|
||||
page = i
|
||||
break
|
||||
}
|
||||
accumulated += w
|
||||
page = i
|
||||
}
|
||||
if (page !== currentPageRef.current) {
|
||||
ax.metric('post:gallery:swipe', {
|
||||
fromIndex: currentPageRef.current,
|
||||
toIndex: page,
|
||||
totalImages: images.length,
|
||||
})
|
||||
currentPageRef.current = page
|
||||
}
|
||||
}}
|
||||
scrollEventThrottle={16}
|
||||
keyExtractor={(_, index) => String(index)}
|
||||
renderItem={({item: image, index}) => (
|
||||
<View
|
||||
ref={containerRefs[index]}
|
||||
collapsable={false}
|
||||
style={[
|
||||
{
|
||||
width: getItemWidth(image, index),
|
||||
height: containerHeight,
|
||||
},
|
||||
]}>
|
||||
<Pressable
|
||||
onPress={
|
||||
onPress
|
||||
? () => {
|
||||
ax.metric('post:gallery:openLightbox', {
|
||||
imageIndex: index,
|
||||
totalImages: images.length,
|
||||
})
|
||||
onPress(
|
||||
index,
|
||||
containerRefs.slice(0, images.length),
|
||||
thumbDimsRef.current.slice(),
|
||||
)
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
|
||||
android_ripple={{
|
||||
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
|
||||
foreground: true,
|
||||
}}
|
||||
accessibilityRole="button"
|
||||
accessibilityLabel={
|
||||
image.alt || l`Image ${index + 1} of ${images.length}`
|
||||
}
|
||||
accessibilityHint={l`Opens full image`}
|
||||
style={[
|
||||
a.flex_1,
|
||||
a.rounded_md,
|
||||
a.overflow_hidden,
|
||||
t.atoms.bg_contrast_25,
|
||||
]}>
|
||||
<Image
|
||||
source={{uri: image.thumb}}
|
||||
style={[a.flex_1]}
|
||||
contentFit="cover"
|
||||
accessible={true}
|
||||
accessibilityLabel={image.alt}
|
||||
accessibilityHint=""
|
||||
accessibilityIgnoresInvertColors
|
||||
onLoad={e => {
|
||||
thumbDimsRef.current[index] = {
|
||||
width: e.source.width,
|
||||
height: e.source.height,
|
||||
}
|
||||
}}
|
||||
loading={index === 0 ? 'eager' : 'lazy'}
|
||||
/>
|
||||
<MediaInsetBorder />
|
||||
</Pressable>
|
||||
{image.alt && !hideBadges ? (
|
||||
<View
|
||||
accessible={false}
|
||||
style={[
|
||||
a.absolute,
|
||||
a.flex_row,
|
||||
a.align_center,
|
||||
a.rounded_xs,
|
||||
t.atoms.bg_contrast_25,
|
||||
{
|
||||
gap: 3,
|
||||
padding: 3,
|
||||
bottom: a.p_xs.padding,
|
||||
right: a.p_xs.padding,
|
||||
opacity: 0.8,
|
||||
},
|
||||
largeAltBadge && {
|
||||
gap: 4,
|
||||
padding: 5,
|
||||
},
|
||||
]}>
|
||||
<Text
|
||||
style={[
|
||||
a.font_bold,
|
||||
largeAltBadge ? a.text_xs : {fontSize: 8},
|
||||
]}>
|
||||
<Trans>ALT</Trans>
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
)}
|
||||
/>
|
||||
</DrawerGestureBlocker>
|
||||
)}
|
||||
<BlockDrawerGesture>
|
||||
<FlatList
|
||||
horizontal
|
||||
pagingEnabled={false}
|
||||
showsHorizontalScrollIndicator={false}
|
||||
decelerationRate={0.993}
|
||||
directionalLockEnabled
|
||||
nestedScrollEnabled
|
||||
alwaysBounceVertical={false}
|
||||
scrollEventThrottle={16}
|
||||
data={images}
|
||||
keyExtractor={(item) => item.thumb}
|
||||
renderItem={({item}) => {
|
||||
return <GalleryImage image={item} contentHeight={contentHeight} />
|
||||
}}
|
||||
style={[{
|
||||
height: contentHeight,
|
||||
marginLeft: -insetLeft,
|
||||
width,
|
||||
}, a.debug]}
|
||||
contentContainerStyle={{
|
||||
gap: ITEM_GAP,
|
||||
paddingLeft: insetLeft,
|
||||
paddingRight: insetRight,
|
||||
}}
|
||||
/>
|
||||
</BlockDrawerGesture>
|
||||
</View>
|
||||
)
|
||||
}
|
||||
|
||||
function DrawerGestureBlocker({children}: {children: React.ReactNode}) {
|
||||
const drawerGesture = useContext(DrawerGestureContext)
|
||||
|
||||
const nativeGesture = useMemo(() => {
|
||||
const gesture = Gesture.Native()
|
||||
if (drawerGesture) {
|
||||
gesture.blocksExternalGesture(drawerGesture)
|
||||
}
|
||||
return gesture
|
||||
}, [drawerGesture])
|
||||
|
||||
return <GestureDetector gesture={nativeGesture}>{children}</GestureDetector>
|
||||
function getAspectRatio({
|
||||
width,
|
||||
height,
|
||||
}: {width?: number; height?: number} = {}) {
|
||||
if (width && width > 0 && height && height > 0) {
|
||||
return width / height
|
||||
}
|
||||
return undefined
|
||||
}
|
||||
|
||||
function computeDims({
|
||||
height,
|
||||
aspectRatio,
|
||||
}: {
|
||||
height: number
|
||||
aspectRatio?: number
|
||||
}) {
|
||||
/*
|
||||
* Old images, or images from other clients can sometimes not have
|
||||
* aspectRatio populated. In these cases, default to square and we'll
|
||||
* resize once the image loads.
|
||||
*/
|
||||
const width = Math.floor(height * (aspectRatio ?? 1))
|
||||
return {width, height, aspectRatio}
|
||||
}
|
||||
|
||||
function GalleryImage({
|
||||
contentHeight: height,
|
||||
image,
|
||||
}: {
|
||||
contentHeight: number
|
||||
image: AppBskyEmbedImages.ViewImage
|
||||
}) {
|
||||
const t = useTheme()
|
||||
const [aspectRatio, setAspectRatio] = useState(() =>
|
||||
getAspectRatio(image.aspectRatio),
|
||||
)
|
||||
const dims = computeDims({height, aspectRatio})
|
||||
|
||||
return (
|
||||
<Pressable style={[a.rounded_md, a.overflow_hidden, t.atoms.bg_contrast_25]}>
|
||||
<Image
|
||||
source={{uri: image.thumb}}
|
||||
contentFit="cover"
|
||||
accessible={true}
|
||||
accessibilityLabel={image.alt}
|
||||
accessibilityHint=""
|
||||
accessibilityIgnoresInvertColors
|
||||
loading="eager"
|
||||
// loading={index === 0 ? 'eager' : 'lazy'}
|
||||
style={[dims]}
|
||||
onLoad={e => {
|
||||
const ar = getAspectRatio(e.source)
|
||||
if (ar && ar !== aspectRatio) {
|
||||
setAspectRatio(ar)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Pressable>
|
||||
)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user