Sketch out 'shell' approach

This commit is contained in:
Eric Bailey
2026-04-13 13:05:27 -05:00
parent 23bafcfb29
commit 11929c7379
8 changed files with 935 additions and 640 deletions
+3
View File
@@ -42,6 +42,7 @@ import {
QuoteEmbedViewContext, QuoteEmbedViewContext,
} from './types' } from './types'
import {VideoEmbed} from './VideoEmbed' import {VideoEmbed} from './VideoEmbed'
import {GalleryBleed} from '#/components/images/Gallery'
export {PostEmbedViewContext, QuoteEmbedViewContext} from './types' export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
@@ -320,6 +321,7 @@ export function QuoteEmbed({
) )
return ( return (
<GalleryBleed>
<View <View
style={[a.mt_sm]} style={[a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter} onPointerEnter={linkDisabled ? undefined : onPointerEnter}
@@ -358,5 +360,6 @@ export function QuoteEmbed({
)} )}
</ContentHider> </ContentHider>
</View> </View>
</GalleryBleed>
) )
} }
+332
View File
@@ -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>
}
+160 -219
View File
@@ -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 {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
import {DrawerGestureContext} from 'react-native-drawer-layout' import {DrawerGestureContext} from 'react-native-drawer-layout'
import {Gesture, GestureDetector} from 'react-native-gesture-handler' 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 {utils} from '@bsky.app/alf'
import {Trans, useLingui} from '@lingui/react/macro' import {Trans, useLingui} from '@lingui/react/macro'
import {mergeRefs} from '#/lib/merge-refs'
import {type Dimensions} from '#/lib/media/types' import {type Dimensions} from '#/lib/media/types'
import {useA11y} from '#/state/a11y' import {useA11y} from '#/state/a11y'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 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 {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {MediaInsetBorder} from '#/components/MediaInsetBorder' import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
const CONTAINER_ASPECT_RATIO = 3 / 2 const CONTAINER_ASPECT_RATIO = 3 / 2
const ITEM_GAP = 8 // tokens.space.sm const ITEM_GAP = 8 // tokens.space.sm
@@ -33,6 +43,36 @@ interface GalleryProps {
viewContext?: PostEmbedViewContext 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({ export function Gallery({
images, images,
onPress, onPress,
@@ -44,256 +84,157 @@ export function Gallery({
const ax = useAnalytics() const ax = useAnalytics()
const {screenReaderEnabled} = useA11y() const {screenReaderEnabled} = useA11y()
const largeAltBadge = useLargeAltBadgeEnabled() const largeAltBadge = useLargeAltBadgeEnabled()
const currentPageRef = useRef(0) const bps = useBreakpoints()
const {width: windowWidth} = useWindowDimensions() const window = useWindowDimensions()
const [leftOffset, setLeftOffset] = useState(0) const contentHeight = useMemo(() => {
const [containerWidth, setContainerWidth] = useState(0) if (bps.gtMobile) {
return 300
} else if (bps.gtPhone) {
return 260
} else {
return 200
}
}, [bps])
const containerRefs = useRef<AnimatedRef<any>[]>([]).current /*
const thumbDimsRef = useRef<(Dimensions | null)[]>([]) * Container overflow styles
*/
const ref0 = useAnimatedRef() const bleedRef = useGalleryBleedRef()
const ref1 = useAnimatedRef() const [bleedDims, setBleedDims] = useState<{
const ref2 = useAnimatedRef() left: number
const ref3 = useAnimatedRef() right: number
const refs = [ref0, ref1, ref2, ref3] width: number
for (let i = 0; i < images.length; i++) { }>()
containerRefs[i] = refs[i] const measureBleed = () => {
bleedRef?.current?.measureInWindow((x, _y, width) => {
setBleedDims({left: x, right: x + width, width})
})
} }
const contentRef = useRef<View>(null)
const isWithinQuote = const [contentDims, setContentDims] = useState<{
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia left: number
const hideBadges = isWithinQuote right: number
width: number
const containerHeight = }>()
containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0 const measureContent = () => {
// Bleed: full-width carousel that extends to screen edges contentRef?.current?.measureInWindow((x, _y, width) => {
// In quotes: small bleed to the quote card border (p_md = 12px) setContentDims({left: x, right: x + width, width})
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>
)
} }
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 ( return (
<View <View
style={ ref={contentRef}
containerWidth > 0 style={[
? {height: containerHeight, overflow: 'visible'} a.w_full,
: {aspectRatio: CONTAINER_ASPECT_RATIO} {
} height: contentHeight,
onLayout={e => { overflow: 'visible',
const w = e.nativeEvent.layout.width },
if (w > 0) { ]}
setContainerWidth(w) onLayout={() => {
} measureBleed()
e.target.measureInWindow((x: number) => { measureContent()
if (x > 0) {
setLeftOffset(x)
}
})
}}> }}>
{containerWidth > 0 && ( <BlockDrawerGesture>
<DrawerGestureBlocker>
<FlatList <FlatList
data={images}
horizontal horizontal
pagingEnabled={false} pagingEnabled={false}
showsHorizontalScrollIndicator={false} showsHorizontalScrollIndicator={false}
decelerationRate={0.993} decelerationRate={0.993}
directionalLockEnabled directionalLockEnabled
nestedScrollEnabled
alwaysBounceVertical={false} alwaysBounceVertical={false}
style={{ scrollEventThrottle={16}
width: bleed ? windowWidth : containerWidth + QUOTE_PADDING * 2, data={images}
height: containerHeight, keyExtractor={(item) => item.thumb}
marginLeft: -insetLeft, renderItem={({item}) => {
return <GalleryImage image={item} contentHeight={contentHeight} />
}} }}
style={[{
height: contentHeight,
marginLeft: -insetLeft,
width,
}, a.debug]}
contentContainerStyle={{ contentContainerStyle={{
gap: ITEM_GAP, gap: ITEM_GAP,
paddingLeft: insetLeft, paddingLeft: insetLeft,
paddingRight: insetRight, paddingRight: insetRight,
}} }}
onScroll={e => { />
const offsetX = e.nativeEvent.contentOffset.x </BlockDrawerGesture>
// Determine which item is most visible based on scroll position </View>
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(),
) )
}
function getAspectRatio({
width,
height,
}: {width?: number; height?: number} = {}) {
if (width && width > 0 && height && height > 0) {
return width / height
} }
: undefined return undefined
} }
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
android_ripple={{ function computeDims({
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), height,
foreground: true, aspectRatio,
}} }: {
accessibilityRole="button" height: number
accessibilityLabel={ aspectRatio?: number
image.alt || l`Image ${index + 1} of ${images.length}` }) {
} /*
accessibilityHint={l`Opens full image`} * Old images, or images from other clients can sometimes not have
style={[ * aspectRatio populated. In these cases, default to square and we'll
a.flex_1, * resize once the image loads.
a.rounded_md, */
a.overflow_hidden, const width = Math.floor(height * (aspectRatio ?? 1))
t.atoms.bg_contrast_25, 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 <Image
source={{uri: image.thumb}} source={{uri: image.thumb}}
style={[a.flex_1]}
contentFit="cover" contentFit="cover"
accessible={true} accessible={true}
accessibilityLabel={image.alt} accessibilityLabel={image.alt}
accessibilityHint="" accessibilityHint=""
accessibilityIgnoresInvertColors accessibilityIgnoresInvertColors
loading="eager"
// loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]}
onLoad={e => { onLoad={e => {
thumbDimsRef.current[index] = { const ar = getAspectRatio(e.source)
width: e.source.width, if (ar && ar !== aspectRatio) {
height: e.source.height, setAspectRatio(ar)
} }
}} }}
loading={index === 0 ? 'eager' : 'lazy'}
/> />
<MediaInsetBorder />
</Pressable> </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>
}
@@ -58,6 +58,7 @@ import {WhoCanReply} from '#/components/WhoCanReply'
import {useAnalytics} from '#/analytics' import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow' import {useActorStatus} from '#/features/liveNow'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
import {GalleryBleed} from '#/components/images/Gallery'
export function ThreadItemAnchor({ export function ThreadItemAnchor({
item, item,
@@ -308,6 +309,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
return ( return (
<> <>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} /> <ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<GalleryBleed>
<View <View
testID={`postThreadItem-by-${post.author.handle}`} testID={`postThreadItem-by-${post.author.handle}`}
style={[ style={[
@@ -446,7 +448,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
testID="repostCount-expanded" testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)"> <Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> <Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.repostCount)} {formatPostStatCount(post.repostCount)}
</Text>{' '} </Text>{' '}
<Plural <Plural
@@ -466,7 +469,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
testID="quoteCount-expanded" testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)"> <Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> <Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.quoteCount)} {formatPostStatCount(post.quoteCount)}
</Text>{' '} </Text>{' '}
<Plural <Plural
@@ -484,10 +488,15 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
testID="likeCount-expanded" testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)"> <Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> <Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.likeCount)} {formatPostStatCount(post.likeCount)}
</Text>{' '} </Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" /> <Plural
value={post.likeCount}
one="like"
other="likes"
/>
</Trans> </Trans>
</Text> </Text>
</Link> </Link>
@@ -536,6 +545,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<DebugFieldDisplay subject={post} /> <DebugFieldDisplay subject={post} />
</View> </View>
</View> </View>
</GalleryBleed>
</> </>
) )
}) })
@@ -45,6 +45,7 @@ import * as Skele from '#/components/Skeleton'
import {SubtleHover} from '#/components/SubtleHover' import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {useActorStatus} from '#/features/liveNow' import {useActorStatus} from '#/features/liveNow'
import {GalleryBleed} from '#/components/images/Gallery'
export type ThreadItemPostProps = { export type ThreadItemPostProps = {
item: Extract<ThreadItem, {type: 'threadPost'}> item: Extract<ThreadItem, {type: 'threadPost'}>
@@ -131,6 +132,7 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
!item.ui.showParentReplyLine && overrides?.topBorder !== true !item.ui.showParentReplyLine && overrides?.topBorder !== true
return ( return (
<GalleryBleed>
<View <View
style={[ style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low], showTopBorder && [a.border_t, t.atoms.border_contrast_low],
@@ -143,6 +145,7 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
]}> ]}>
{children} {children}
</View> </View>
</GalleryBleed>
) )
}) })
@@ -44,6 +44,7 @@ import {RichText} from '#/components/RichText'
import * as Skele from '#/components/Skeleton' import * as Skele from '#/components/Skeleton'
import {SubtleHover} from '#/components/SubtleHover' import {SubtleHover} from '#/components/SubtleHover'
import {Text} from '#/components/Typography' import {Text} from '#/components/Typography'
import {GalleryBleed} from '#/components/images/Gallery'
/** /**
* Mimic the space in PostMeta * Mimic the space in PostMeta
@@ -129,6 +130,7 @@ const ThreadItemTreePostOuterWrapper = memo(
const indents = Math.max(0, item.ui.indent - 1) const indents = Math.max(0, item.ui.indent - 1)
return ( return (
<GalleryBleed>
<View <View
style={[ style={[
a.flex_row, a.flex_row,
@@ -156,6 +158,7 @@ const ThreadItemTreePostOuterWrapper = memo(
})} })}
{children} {children}
</View> </View>
</GalleryBleed>
) )
}, },
) )
+3
View File
@@ -51,6 +51,7 @@ import {useAnalytics} from '#/analytics'
import {useActorStatus} from '#/features/liveNow' import {useActorStatus} from '#/features/liveNow'
import * as bsky from '#/types/bsky' import * as bsky from '#/types/bsky'
import {PostFeedReason} from './PostFeedReason' import {PostFeedReason} from './PostFeedReason'
import {GalleryBleed} from '#/components/images/Gallery'
interface FeedItemProps { interface FeedItemProps {
record: AppBskyFeedPost.Record record: AppBskyFeedPost.Record
@@ -294,6 +295,7 @@ let FeedItemInner = ({
}, [reason]) }, [reason])
return ( return (
<GalleryBleed>
<Link <Link
testID={`feedItem-by-${post.author.handle}`} testID={`feedItem-by-${post.author.handle}`}
style={outerStyles} style={outerStyles}
@@ -406,6 +408,7 @@ let FeedItemInner = ({
<DiscoverDebug feedContext={feedContext} /> <DiscoverDebug feedContext={feedContext} />
</View> </View>
</Link> </Link>
</GalleryBleed>
) )
} }
FeedItemInner = memo(FeedItemInner) FeedItemInner = memo(FeedItemInner)