Sketch out 'shell' approach
This commit is contained in:
@@ -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,43 +321,45 @@ export function QuoteEmbed({
|
|||||||
)
|
)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<GalleryBleed>
|
||||||
style={[a.mt_sm]}
|
<View
|
||||||
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
|
style={[a.mt_sm]}
|
||||||
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
|
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
|
||||||
<ContentHider
|
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
|
||||||
modui={moderation?.ui('contentList')}
|
<ContentHider
|
||||||
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
|
modui={moderation?.ui('contentList')}
|
||||||
activeStyle={[a.p_md, a.pt_sm]}
|
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
|
||||||
childContainerStyle={[a.pt_sm]}>
|
activeStyle={[a.p_md, a.pt_sm]}
|
||||||
{({active}) => (
|
childContainerStyle={[a.pt_sm]}>
|
||||||
<>
|
{({active}) => (
|
||||||
{!active && !linkDisabled && (
|
<>
|
||||||
<SubtleHover
|
{!active && !linkDisabled && (
|
||||||
native
|
<SubtleHover
|
||||||
hover={hover || pressed}
|
native
|
||||||
style={[a.rounded_md]}
|
hover={hover || pressed}
|
||||||
/>
|
style={[a.rounded_md]}
|
||||||
)}
|
/>
|
||||||
{linkDisabled ? (
|
)}
|
||||||
<View style={[!active && a.p_md]} pointerEvents="none">
|
{linkDisabled ? (
|
||||||
{contents}
|
<View style={[!active && a.p_md]} pointerEvents="none">
|
||||||
</View>
|
{contents}
|
||||||
) : (
|
</View>
|
||||||
<Link
|
) : (
|
||||||
style={[!active && a.p_md]}
|
<Link
|
||||||
hoverStyle={t.atoms.border_contrast_high}
|
style={[!active && a.p_md]}
|
||||||
href={itemHref}
|
hoverStyle={t.atoms.border_contrast_high}
|
||||||
title={itemTitle}
|
href={itemHref}
|
||||||
onBeforePress={onBeforePress}
|
title={itemTitle}
|
||||||
onPressIn={onPressIn}
|
onBeforePress={onBeforePress}
|
||||||
onPressOut={onPressOut}>
|
onPressIn={onPressIn}
|
||||||
{contents}
|
onPressOut={onPressOut}>
|
||||||
</Link>
|
{contents}
|
||||||
)}
|
</Link>
|
||||||
</>
|
)}
|
||||||
)}
|
</>
|
||||||
</ContentHider>
|
)}
|
||||||
</View>
|
</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 {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
|
||||||
const containerRefs = useRef<AnimatedRef<any>[]>([]).current
|
} else if (bps.gtPhone) {
|
||||||
const thumbDimsRef = useRef<(Dimensions | null)[]>([])
|
return 260
|
||||||
|
} else {
|
||||||
const ref0 = useAnimatedRef()
|
return 200
|
||||||
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
|
}, [bps])
|
||||||
if (index === 0 && images.length > 1) {
|
|
||||||
width = Math.min(width, containerWidth - MIN_PEEK)
|
|
||||||
}
|
|
||||||
return width
|
|
||||||
}
|
|
||||||
|
|
||||||
if (screenReaderEnabled) {
|
/*
|
||||||
return (
|
* Container overflow styles
|
||||||
<View style={[a.relative, a.gap_sm]}>
|
*/
|
||||||
{images.map((image, index) => (
|
const bleedRef = useGalleryBleedRef()
|
||||||
<AutoSizedImage
|
const [bleedDims, setBleedDims] = useState<{
|
||||||
key={image.thumb}
|
left: number
|
||||||
crop={
|
right: number
|
||||||
viewContext === PostEmbedViewContext.ThreadHighlighted
|
width: number
|
||||||
? 'none'
|
}>()
|
||||||
: viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
const measureBleed = () => {
|
||||||
? 'square'
|
bleedRef?.current?.measureInWindow((x, _y, width) => {
|
||||||
: 'constrained'
|
setBleedDims({left: x, right: x + width, width})
|
||||||
}
|
})
|
||||||
image={image}
|
|
||||||
onPress={(containerRef, dims) =>
|
|
||||||
onPress?.(index, [containerRef], [dims])
|
|
||||||
}
|
|
||||||
onPressIn={() => onPressIn?.(index)}
|
|
||||||
hideBadge={
|
|
||||||
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</View>
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
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 (
|
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
|
horizontal
|
||||||
data={images}
|
pagingEnabled={false}
|
||||||
horizontal
|
showsHorizontalScrollIndicator={false}
|
||||||
pagingEnabled={false}
|
decelerationRate={0.993}
|
||||||
showsHorizontalScrollIndicator={false}
|
directionalLockEnabled
|
||||||
decelerationRate={0.993}
|
nestedScrollEnabled
|
||||||
directionalLockEnabled
|
alwaysBounceVertical={false}
|
||||||
alwaysBounceVertical={false}
|
scrollEventThrottle={16}
|
||||||
style={{
|
data={images}
|
||||||
width: bleed ? windowWidth : containerWidth + QUOTE_PADDING * 2,
|
keyExtractor={(item) => item.thumb}
|
||||||
height: containerHeight,
|
renderItem={({item}) => {
|
||||||
marginLeft: -insetLeft,
|
return <GalleryImage image={item} contentHeight={contentHeight} />
|
||||||
}}
|
}}
|
||||||
contentContainerStyle={{
|
style={[{
|
||||||
gap: ITEM_GAP,
|
height: contentHeight,
|
||||||
paddingLeft: insetLeft,
|
marginLeft: -insetLeft,
|
||||||
paddingRight: insetRight,
|
width,
|
||||||
}}
|
}, a.debug]}
|
||||||
onScroll={e => {
|
contentContainerStyle={{
|
||||||
const offsetX = e.nativeEvent.contentOffset.x
|
gap: ITEM_GAP,
|
||||||
// Determine which item is most visible based on scroll position
|
paddingLeft: insetLeft,
|
||||||
let accumulated = insetLeft // account for left content padding
|
paddingRight: insetRight,
|
||||||
let page = 0
|
}}
|
||||||
for (let i = 0; i < images.length; i++) {
|
/>
|
||||||
const w = getItemWidth(images[i], i) + ITEM_GAP
|
</BlockDrawerGesture>
|
||||||
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>
|
</View>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function DrawerGestureBlocker({children}: {children: React.ReactNode}) {
|
function getAspectRatio({
|
||||||
const drawerGesture = useContext(DrawerGestureContext)
|
width,
|
||||||
|
height,
|
||||||
const nativeGesture = useMemo(() => {
|
}: {width?: number; height?: number} = {}) {
|
||||||
const gesture = Gesture.Native()
|
if (width && width > 0 && height && height > 0) {
|
||||||
if (drawerGesture) {
|
return width / height
|
||||||
gesture.blocksExternalGesture(drawerGesture)
|
}
|
||||||
}
|
return undefined
|
||||||
return gesture
|
}
|
||||||
}, [drawerGesture])
|
|
||||||
|
function computeDims({
|
||||||
return <GestureDetector gesture={nativeGesture}>{children}</GestureDetector>
|
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>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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,234 +309,243 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
|
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
|
||||||
<View
|
<GalleryBleed>
|
||||||
testID={`postThreadItem-by-${post.author.handle}`}
|
<View
|
||||||
style={[
|
testID={`postThreadItem-by-${post.author.handle}`}
|
||||||
{
|
style={[
|
||||||
paddingHorizontal: OUTER_SPACE,
|
{
|
||||||
},
|
paddingHorizontal: OUTER_SPACE,
|
||||||
isRoot && [a.pt_lg],
|
},
|
||||||
]}>
|
isRoot && [a.pt_lg],
|
||||||
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
|
]}>
|
||||||
<View collapsable={false}>
|
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
|
||||||
<PreviewableUserAvatar
|
<View collapsable={false}>
|
||||||
size={42}
|
<PreviewableUserAvatar
|
||||||
profile={post.author}
|
size={42}
|
||||||
moderation={moderation.ui('avatar')}
|
profile={post.author}
|
||||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
moderation={moderation.ui('avatar')}
|
||||||
live={live}
|
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||||
onBeforePress={onOpenAuthor}
|
live={live}
|
||||||
/>
|
onBeforePress={onOpenAuthor}
|
||||||
</View>
|
/>
|
||||||
<Link
|
</View>
|
||||||
to={authorHref}
|
<Link
|
||||||
style={[a.flex_1]}
|
to={authorHref}
|
||||||
label={sanitizeDisplayName(
|
style={[a.flex_1]}
|
||||||
post.author.displayName || sanitizeHandle(post.author.handle),
|
label={sanitizeDisplayName(
|
||||||
moderation.ui('displayName'),
|
post.author.displayName || sanitizeHandle(post.author.handle),
|
||||||
)}
|
moderation.ui('displayName'),
|
||||||
onPress={onOpenAuthor}>
|
)}
|
||||||
<View style={[a.flex_1, a.align_start]}>
|
onPress={onOpenAuthor}>
|
||||||
<ProfileHoverCard did={post.author.did} style={[a.w_full]}>
|
<View style={[a.flex_1, a.align_start]}>
|
||||||
<View style={[a.flex_row, a.align_center]}>
|
<ProfileHoverCard did={post.author.did} style={[a.w_full]}>
|
||||||
|
<View style={[a.flex_row, a.align_center]}>
|
||||||
|
<Text
|
||||||
|
emoji
|
||||||
|
style={[
|
||||||
|
a.flex_shrink,
|
||||||
|
a.text_lg,
|
||||||
|
a.font_semi_bold,
|
||||||
|
a.leading_snug,
|
||||||
|
]}
|
||||||
|
numberOfLines={1}>
|
||||||
|
{sanitizeDisplayName(
|
||||||
|
post.author.displayName ||
|
||||||
|
sanitizeHandle(post.author.handle),
|
||||||
|
moderation.ui('displayName'),
|
||||||
|
)}
|
||||||
|
</Text>
|
||||||
|
|
||||||
|
<View style={[a.pl_xs]}>
|
||||||
|
<ProfileBadges
|
||||||
|
profile={authorShadow}
|
||||||
|
size="md"
|
||||||
|
interactive
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
</View>
|
||||||
<Text
|
<Text
|
||||||
emoji
|
|
||||||
style={[
|
style={[
|
||||||
a.flex_shrink,
|
a.text_md,
|
||||||
a.text_lg,
|
|
||||||
a.font_semi_bold,
|
|
||||||
a.leading_snug,
|
a.leading_snug,
|
||||||
|
t.atoms.text_contrast_medium,
|
||||||
]}
|
]}
|
||||||
numberOfLines={1}>
|
numberOfLines={1}>
|
||||||
{sanitizeDisplayName(
|
{sanitizeHandle(post.author.handle, '@')}
|
||||||
post.author.displayName ||
|
|
||||||
sanitizeHandle(post.author.handle),
|
|
||||||
moderation.ui('displayName'),
|
|
||||||
)}
|
|
||||||
</Text>
|
</Text>
|
||||||
|
</ProfileHoverCard>
|
||||||
<View style={[a.pl_xs]}>
|
|
||||||
<ProfileBadges
|
|
||||||
profile={authorShadow}
|
|
||||||
size="md"
|
|
||||||
interactive
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<Text
|
|
||||||
style={[
|
|
||||||
a.text_md,
|
|
||||||
a.leading_snug,
|
|
||||||
t.atoms.text_contrast_medium,
|
|
||||||
]}
|
|
||||||
numberOfLines={1}>
|
|
||||||
{sanitizeHandle(post.author.handle, '@')}
|
|
||||||
</Text>
|
|
||||||
</ProfileHoverCard>
|
|
||||||
</View>
|
|
||||||
</Link>
|
|
||||||
<View collapsable={false} style={[a.self_center]}>
|
|
||||||
<ThreadItemAnchorFollowButton
|
|
||||||
did={post.author.did}
|
|
||||||
enabled={showFollowButton}
|
|
||||||
/>
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
<View style={[a.pb_sm]}>
|
|
||||||
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
|
|
||||||
<ContentHider
|
|
||||||
modui={moderation.ui('contentView')}
|
|
||||||
ignoreMute
|
|
||||||
childContainerStyle={[a.pt_sm]}>
|
|
||||||
<PostAlerts
|
|
||||||
modui={moderation.ui('contentView')}
|
|
||||||
size="lg"
|
|
||||||
includeMute
|
|
||||||
style={[a.pb_sm]}
|
|
||||||
additionalCauses={additionalPostAlerts}
|
|
||||||
/>
|
|
||||||
{richText?.text ? (
|
|
||||||
<RichText
|
|
||||||
enableTags
|
|
||||||
selectable
|
|
||||||
value={richText}
|
|
||||||
style={[a.flex_1, a.text_lg]}
|
|
||||||
authorHandle={post.author.handle}
|
|
||||||
shouldProxyLinks={true}
|
|
||||||
/>
|
|
||||||
) : undefined}
|
|
||||||
<TranslatedPost post={post} postTextStyle={[a.text_lg]} />
|
|
||||||
{post.embed && (
|
|
||||||
<View style={[a.py_xs]}>
|
|
||||||
<Embed
|
|
||||||
embed={post.embed}
|
|
||||||
moderation={moderation}
|
|
||||||
viewContext={PostEmbedViewContext.ThreadHighlighted}
|
|
||||||
onOpen={onOpenEmbed}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
)}
|
</Link>
|
||||||
</ContentHider>
|
<View collapsable={false} style={[a.self_center]}>
|
||||||
<ExpandedPostDetails
|
<ThreadItemAnchorFollowButton
|
||||||
post={item.value.post}
|
did={post.author.did}
|
||||||
isThreadAuthor={isThreadAuthor}
|
enabled={showFollowButton}
|
||||||
/>
|
/>
|
||||||
{post.repostCount !== 0 ||
|
</View>
|
||||||
post.likeCount !== 0 ||
|
</View>
|
||||||
post.quoteCount !== 0 ||
|
<View style={[a.pb_sm]}>
|
||||||
post.bookmarkCount !== 0 ? (
|
<LabelsOnMyPost post={post} style={[a.pb_sm]} />
|
||||||
// Show this section unless we're *sure* it has no engagement.
|
<ContentHider
|
||||||
|
modui={moderation.ui('contentView')}
|
||||||
|
ignoreMute
|
||||||
|
childContainerStyle={[a.pt_sm]}>
|
||||||
|
<PostAlerts
|
||||||
|
modui={moderation.ui('contentView')}
|
||||||
|
size="lg"
|
||||||
|
includeMute
|
||||||
|
style={[a.pb_sm]}
|
||||||
|
additionalCauses={additionalPostAlerts}
|
||||||
|
/>
|
||||||
|
{richText?.text ? (
|
||||||
|
<RichText
|
||||||
|
enableTags
|
||||||
|
selectable
|
||||||
|
value={richText}
|
||||||
|
style={[a.flex_1, a.text_lg]}
|
||||||
|
authorHandle={post.author.handle}
|
||||||
|
shouldProxyLinks={true}
|
||||||
|
/>
|
||||||
|
) : undefined}
|
||||||
|
<TranslatedPost post={post} postTextStyle={[a.text_lg]} />
|
||||||
|
{post.embed && (
|
||||||
|
<View style={[a.py_xs]}>
|
||||||
|
<Embed
|
||||||
|
embed={post.embed}
|
||||||
|
moderation={moderation}
|
||||||
|
viewContext={PostEmbedViewContext.ThreadHighlighted}
|
||||||
|
onOpen={onOpenEmbed}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
)}
|
||||||
|
</ContentHider>
|
||||||
|
<ExpandedPostDetails
|
||||||
|
post={item.value.post}
|
||||||
|
isThreadAuthor={isThreadAuthor}
|
||||||
|
/>
|
||||||
|
{post.repostCount !== 0 ||
|
||||||
|
post.likeCount !== 0 ||
|
||||||
|
post.quoteCount !== 0 ||
|
||||||
|
post.bookmarkCount !== 0 ? (
|
||||||
|
// Show this section unless we're *sure* it has no engagement.
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
a.flex_row,
|
||||||
|
a.flex_wrap,
|
||||||
|
a.align_center,
|
||||||
|
{
|
||||||
|
rowGap: a.gap_sm.gap,
|
||||||
|
columnGap: a.gap_lg.gap,
|
||||||
|
},
|
||||||
|
a.border_t,
|
||||||
|
a.border_b,
|
||||||
|
a.mt_md,
|
||||||
|
a.py_md,
|
||||||
|
t.atoms.border_contrast_low,
|
||||||
|
]}>
|
||||||
|
{post.repostCount != null && post.repostCount !== 0 ? (
|
||||||
|
<Link to={repostsHref} label={l`Reposts of this post`}>
|
||||||
|
<Text
|
||||||
|
testID="repostCount-expanded"
|
||||||
|
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)">
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||||
|
{formatPostStatCount(post.repostCount)}
|
||||||
|
</Text>{' '}
|
||||||
|
<Plural
|
||||||
|
value={post.repostCount}
|
||||||
|
one="repost"
|
||||||
|
other="reposts"
|
||||||
|
/>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{post.quoteCount != null &&
|
||||||
|
post.quoteCount !== 0 &&
|
||||||
|
!post.viewer?.embeddingDisabled ? (
|
||||||
|
<Link to={quotesHref} label={l`Quotes of this post`}>
|
||||||
|
<Text
|
||||||
|
testID="quoteCount-expanded"
|
||||||
|
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)">
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||||
|
{formatPostStatCount(post.quoteCount)}
|
||||||
|
</Text>{' '}
|
||||||
|
<Plural
|
||||||
|
value={post.quoteCount}
|
||||||
|
one="quote"
|
||||||
|
other="quotes"
|
||||||
|
/>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{post.likeCount != null && post.likeCount !== 0 ? (
|
||||||
|
<Link to={likesHref} label={l`Likes on this post`}>
|
||||||
|
<Text
|
||||||
|
testID="likeCount-expanded"
|
||||||
|
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)">
|
||||||
|
<Text
|
||||||
|
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||||
|
{formatPostStatCount(post.likeCount)}
|
||||||
|
</Text>{' '}
|
||||||
|
<Plural
|
||||||
|
value={post.likeCount}
|
||||||
|
one="like"
|
||||||
|
other="likes"
|
||||||
|
/>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
</Link>
|
||||||
|
) : null}
|
||||||
|
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
|
||||||
|
<Text
|
||||||
|
testID="bookmarkCount-expanded"
|
||||||
|
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
||||||
|
<Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)">
|
||||||
|
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
||||||
|
{formatPostStatCount(post.bookmarkCount)}
|
||||||
|
</Text>{' '}
|
||||||
|
<Plural
|
||||||
|
value={post.bookmarkCount}
|
||||||
|
one="save"
|
||||||
|
other="saves"
|
||||||
|
/>
|
||||||
|
</Trans>
|
||||||
|
</Text>
|
||||||
|
) : null}
|
||||||
|
</View>
|
||||||
|
) : null}
|
||||||
<View
|
<View
|
||||||
style={[
|
style={[
|
||||||
a.flex_row,
|
a.pt_sm,
|
||||||
a.flex_wrap,
|
a.pb_2xs,
|
||||||
a.align_center,
|
|
||||||
{
|
{
|
||||||
rowGap: a.gap_sm.gap,
|
marginLeft: -5,
|
||||||
columnGap: a.gap_lg.gap,
|
|
||||||
},
|
},
|
||||||
a.border_t,
|
|
||||||
a.border_b,
|
|
||||||
a.mt_md,
|
|
||||||
a.py_md,
|
|
||||||
t.atoms.border_contrast_low,
|
|
||||||
]}>
|
]}>
|
||||||
{post.repostCount != null && post.repostCount !== 0 ? (
|
<FeedFeedbackProvider value={feedFeedback}>
|
||||||
<Link to={repostsHref} label={l`Reposts of this post`}>
|
<PostControls
|
||||||
<Text
|
big
|
||||||
testID="repostCount-expanded"
|
post={postShadow}
|
||||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
record={record}
|
||||||
<Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
|
richText={richText}
|
||||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
onPressReply={onPressReply}
|
||||||
{formatPostStatCount(post.repostCount)}
|
logContext="PostThreadItem"
|
||||||
</Text>{' '}
|
threadgateRecord={threadgateRecord}
|
||||||
<Plural
|
feedContext={postSource?.post?.feedContext}
|
||||||
value={post.repostCount}
|
reqId={postSource?.post?.reqId}
|
||||||
one="repost"
|
viaRepost={viaRepost}
|
||||||
other="reposts"
|
/>
|
||||||
/>
|
</FeedFeedbackProvider>
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
{post.quoteCount != null &&
|
|
||||||
post.quoteCount !== 0 &&
|
|
||||||
!post.viewer?.embeddingDisabled ? (
|
|
||||||
<Link to={quotesHref} label={l`Quotes of this post`}>
|
|
||||||
<Text
|
|
||||||
testID="quoteCount-expanded"
|
|
||||||
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)">
|
|
||||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
|
||||||
{formatPostStatCount(post.quoteCount)}
|
|
||||||
</Text>{' '}
|
|
||||||
<Plural
|
|
||||||
value={post.quoteCount}
|
|
||||||
one="quote"
|
|
||||||
other="quotes"
|
|
||||||
/>
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
{post.likeCount != null && post.likeCount !== 0 ? (
|
|
||||||
<Link to={likesHref} label={l`Likes on this post`}>
|
|
||||||
<Text
|
|
||||||
testID="likeCount-expanded"
|
|
||||||
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)">
|
|
||||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
|
||||||
{formatPostStatCount(post.likeCount)}
|
|
||||||
</Text>{' '}
|
|
||||||
<Plural value={post.likeCount} one="like" other="likes" />
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
</Link>
|
|
||||||
) : null}
|
|
||||||
{post.bookmarkCount != null && post.bookmarkCount !== 0 ? (
|
|
||||||
<Text
|
|
||||||
testID="bookmarkCount-expanded"
|
|
||||||
style={[a.text_md, t.atoms.text_contrast_medium]}>
|
|
||||||
<Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)">
|
|
||||||
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
|
|
||||||
{formatPostStatCount(post.bookmarkCount)}
|
|
||||||
</Text>{' '}
|
|
||||||
<Plural
|
|
||||||
value={post.bookmarkCount}
|
|
||||||
one="save"
|
|
||||||
other="saves"
|
|
||||||
/>
|
|
||||||
</Trans>
|
|
||||||
</Text>
|
|
||||||
) : null}
|
|
||||||
</View>
|
</View>
|
||||||
) : null}
|
<DebugFieldDisplay subject={post} />
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
a.pt_sm,
|
|
||||||
a.pb_2xs,
|
|
||||||
{
|
|
||||||
marginLeft: -5,
|
|
||||||
},
|
|
||||||
]}>
|
|
||||||
<FeedFeedbackProvider value={feedFeedback}>
|
|
||||||
<PostControls
|
|
||||||
big
|
|
||||||
post={postShadow}
|
|
||||||
record={record}
|
|
||||||
richText={richText}
|
|
||||||
onPressReply={onPressReply}
|
|
||||||
logContext="PostThreadItem"
|
|
||||||
threadgateRecord={threadgateRecord}
|
|
||||||
feedContext={postSource?.post?.feedContext}
|
|
||||||
reqId={postSource?.post?.reqId}
|
|
||||||
viaRepost={viaRepost}
|
|
||||||
/>
|
|
||||||
</FeedFeedbackProvider>
|
|
||||||
</View>
|
</View>
|
||||||
<DebugFieldDisplay subject={post} />
|
|
||||||
</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,18 +132,20 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
|
|||||||
!item.ui.showParentReplyLine && overrides?.topBorder !== true
|
!item.ui.showParentReplyLine && overrides?.topBorder !== true
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<GalleryBleed>
|
||||||
style={[
|
<View
|
||||||
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
|
style={[
|
||||||
{paddingHorizontal: OUTER_SPACE},
|
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
|
||||||
// If there's no next child, add a little padding to bottom
|
{paddingHorizontal: OUTER_SPACE},
|
||||||
!item.ui.showChildReplyLine &&
|
// If there's no next child, add a little padding to bottom
|
||||||
!item.ui.precedesChildReadMore && {
|
!item.ui.showChildReplyLine &&
|
||||||
paddingBottom: OUTER_SPACE / 2,
|
!item.ui.precedesChildReadMore && {
|
||||||
},
|
paddingBottom: OUTER_SPACE / 2,
|
||||||
]}>
|
},
|
||||||
{children}
|
]}>
|
||||||
</View>
|
{children}
|
||||||
|
</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,33 +130,35 @@ const ThreadItemTreePostOuterWrapper = memo(
|
|||||||
const indents = Math.max(0, item.ui.indent - 1)
|
const indents = Math.max(0, item.ui.indent - 1)
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<View
|
<GalleryBleed>
|
||||||
style={[
|
<View
|
||||||
a.flex_row,
|
style={[
|
||||||
item.ui.indent === 1 &&
|
a.flex_row,
|
||||||
!item.ui.showParentReplyLine && [
|
item.ui.indent === 1 &&
|
||||||
a.border_t,
|
!item.ui.showParentReplyLine && [
|
||||||
t.atoms.border_contrast_low,
|
a.border_t,
|
||||||
],
|
|
||||||
]}>
|
|
||||||
{Array.from(Array(indents)).map((_, n: number) => {
|
|
||||||
const isSkipped = item.ui.skippedIndentIndices.has(n)
|
|
||||||
return (
|
|
||||||
<View
|
|
||||||
key={`${item.value.post.uri}-padding-${n}`}
|
|
||||||
style={[
|
|
||||||
t.atoms.border_contrast_low,
|
t.atoms.border_contrast_low,
|
||||||
{
|
],
|
||||||
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
|
]}>
|
||||||
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
|
{Array.from(Array(indents)).map((_, n: number) => {
|
||||||
left: 1,
|
const isSkipped = item.ui.skippedIndentIndices.has(n)
|
||||||
},
|
return (
|
||||||
]}
|
<View
|
||||||
/>
|
key={`${item.value.post.uri}-padding-${n}`}
|
||||||
)
|
style={[
|
||||||
})}
|
t.atoms.border_contrast_low,
|
||||||
{children}
|
{
|
||||||
</View>
|
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
|
||||||
|
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
|
||||||
|
left: 1,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
})}
|
||||||
|
{children}
|
||||||
|
</View>
|
||||||
|
</GalleryBleed>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
+111
-108
@@ -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,118 +295,120 @@ let FeedItemInner = ({
|
|||||||
}, [reason])
|
}, [reason])
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Link
|
<GalleryBleed>
|
||||||
testID={`feedItem-by-${post.author.handle}`}
|
<Link
|
||||||
style={outerStyles}
|
testID={`feedItem-by-${post.author.handle}`}
|
||||||
href={href}
|
style={outerStyles}
|
||||||
noFeedback
|
href={href}
|
||||||
accessible={false}
|
noFeedback
|
||||||
onBeforePress={onBeforePress}
|
accessible={false}
|
||||||
dataSet={{feedContext}}
|
onBeforePress={onBeforePress}
|
||||||
onPointerEnter={() => {
|
dataSet={{feedContext}}
|
||||||
setHover(true)
|
onPointerEnter={() => {
|
||||||
}}
|
setHover(true)
|
||||||
onPointerLeave={() => {
|
}}
|
||||||
setHover(false)
|
onPointerLeave={() => {
|
||||||
}}>
|
setHover(false)
|
||||||
<SubtleHover hover={hover} />
|
}}>
|
||||||
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
|
<SubtleHover hover={hover} />
|
||||||
<View style={{width: 42}}>
|
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
|
||||||
{isThreadChild && (
|
<View style={{width: 42}}>
|
||||||
<View
|
{isThreadChild && (
|
||||||
style={[
|
<View
|
||||||
styles.replyLine,
|
style={[
|
||||||
{
|
styles.replyLine,
|
||||||
flexGrow: 1,
|
{
|
||||||
backgroundColor: pal.colors.replyLine,
|
flexGrow: 1,
|
||||||
marginBottom: 4,
|
backgroundColor: pal.colors.replyLine,
|
||||||
},
|
marginBottom: 4,
|
||||||
]}
|
},
|
||||||
/>
|
]}
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={[a.pt_sm, a.flex_shrink]}>
|
|
||||||
{reason && (
|
|
||||||
<PostFeedReason
|
|
||||||
reason={reason}
|
|
||||||
moderation={moderation}
|
|
||||||
onOpenReposter={onOpenReposter}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<View style={styles.layout}>
|
|
||||||
<View style={styles.layoutAvi}>
|
|
||||||
<PreviewableUserAvatar
|
|
||||||
size={42}
|
|
||||||
profile={post.author}
|
|
||||||
moderation={moderation.ui('avatar')}
|
|
||||||
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
|
||||||
onBeforePress={onOpenAuthor}
|
|
||||||
live={live}
|
|
||||||
/>
|
|
||||||
{isThreadParent && (
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.replyLine,
|
|
||||||
{
|
|
||||||
flexGrow: 1,
|
|
||||||
backgroundColor: pal.colors.replyLine,
|
|
||||||
marginTop: live ? 8 : 4,
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</View>
|
|
||||||
<View
|
|
||||||
style={[
|
|
||||||
styles.layoutContent,
|
|
||||||
!richText.text && styles.layoutContentNoText,
|
|
||||||
]}>
|
|
||||||
<PostMeta
|
|
||||||
author={post.author}
|
|
||||||
moderation={moderation}
|
|
||||||
timestamp={post.indexedAt}
|
|
||||||
postHref={href}
|
|
||||||
onOpenAuthor={onOpenAuthor}
|
|
||||||
/>
|
|
||||||
{showReplyTo &&
|
|
||||||
(parentAuthor || isParentBlocked || isParentNotFound) && (
|
|
||||||
<PostRepliedTo
|
|
||||||
parentAuthor={parentAuthor}
|
|
||||||
isParentBlocked={isParentBlocked}
|
|
||||||
isParentNotFound={isParentNotFound}
|
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<LabelsOnMyPost post={post} />
|
</View>
|
||||||
<PostContent
|
|
||||||
moderation={moderation}
|
<View style={[a.pt_sm, a.flex_shrink]}>
|
||||||
richText={richText}
|
{reason && (
|
||||||
postEmbed={post.embed}
|
<PostFeedReason
|
||||||
postAuthor={post.author}
|
reason={reason}
|
||||||
onOpenEmbed={onOpenEmbed}
|
moderation={moderation}
|
||||||
post={post}
|
onOpenReposter={onOpenReposter}
|
||||||
threadgateRecord={threadgateRecord}
|
/>
|
||||||
/>
|
)}
|
||||||
<PostControls
|
</View>
|
||||||
post={post}
|
|
||||||
record={record}
|
|
||||||
richText={richText}
|
|
||||||
onPressReply={onPressReply}
|
|
||||||
logContext="FeedItem"
|
|
||||||
feedContext={feedContext}
|
|
||||||
reqId={reqId}
|
|
||||||
threadgateRecord={threadgateRecord}
|
|
||||||
onShowLess={onShowLess}
|
|
||||||
viaRepost={viaRepost}
|
|
||||||
/>
|
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
<DiscoverDebug feedContext={feedContext} />
|
<View style={styles.layout}>
|
||||||
</View>
|
<View style={styles.layoutAvi}>
|
||||||
</Link>
|
<PreviewableUserAvatar
|
||||||
|
size={42}
|
||||||
|
profile={post.author}
|
||||||
|
moderation={moderation.ui('avatar')}
|
||||||
|
type={post.author.associated?.labeler ? 'labeler' : 'user'}
|
||||||
|
onBeforePress={onOpenAuthor}
|
||||||
|
live={live}
|
||||||
|
/>
|
||||||
|
{isThreadParent && (
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.replyLine,
|
||||||
|
{
|
||||||
|
flexGrow: 1,
|
||||||
|
backgroundColor: pal.colors.replyLine,
|
||||||
|
marginTop: live ? 8 : 4,
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
|
<View
|
||||||
|
style={[
|
||||||
|
styles.layoutContent,
|
||||||
|
!richText.text && styles.layoutContentNoText,
|
||||||
|
]}>
|
||||||
|
<PostMeta
|
||||||
|
author={post.author}
|
||||||
|
moderation={moderation}
|
||||||
|
timestamp={post.indexedAt}
|
||||||
|
postHref={href}
|
||||||
|
onOpenAuthor={onOpenAuthor}
|
||||||
|
/>
|
||||||
|
{showReplyTo &&
|
||||||
|
(parentAuthor || isParentBlocked || isParentNotFound) && (
|
||||||
|
<PostRepliedTo
|
||||||
|
parentAuthor={parentAuthor}
|
||||||
|
isParentBlocked={isParentBlocked}
|
||||||
|
isParentNotFound={isParentNotFound}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<LabelsOnMyPost post={post} />
|
||||||
|
<PostContent
|
||||||
|
moderation={moderation}
|
||||||
|
richText={richText}
|
||||||
|
postEmbed={post.embed}
|
||||||
|
postAuthor={post.author}
|
||||||
|
onOpenEmbed={onOpenEmbed}
|
||||||
|
post={post}
|
||||||
|
threadgateRecord={threadgateRecord}
|
||||||
|
/>
|
||||||
|
<PostControls
|
||||||
|
post={post}
|
||||||
|
record={record}
|
||||||
|
richText={richText}
|
||||||
|
onPressReply={onPressReply}
|
||||||
|
logContext="FeedItem"
|
||||||
|
feedContext={feedContext}
|
||||||
|
reqId={reqId}
|
||||||
|
threadgateRecord={threadgateRecord}
|
||||||
|
onShowLess={onShowLess}
|
||||||
|
viaRepost={viaRepost}
|
||||||
|
/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<DiscoverDebug feedContext={feedContext} />
|
||||||
|
</View>
|
||||||
|
</Link>
|
||||||
|
</GalleryBleed>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
FeedItemInner = memo(FeedItemInner)
|
FeedItemInner = memo(FeedItemInner)
|
||||||
|
|||||||
Reference in New Issue
Block a user