diff --git a/src/components/Post/Embed/index.tsx b/src/components/Post/Embed/index.tsx index 927f8bf98b..a125db7845 100644 --- a/src/components/Post/Embed/index.tsx +++ b/src/components/Post/Embed/index.tsx @@ -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 ( - - - {({active}) => ( - <> - {!active && !linkDisabled && ( - - )} - {linkDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} - - )} - - + + + + {({active}) => ( + <> + {!active && !linkDisabled && ( + + )} + {linkDisabled ? ( + + {contents} + + ) : ( + + {contents} + + )} + + )} + + + ) } diff --git a/src/components/images/Gallery/index-old.tsx b/src/components/images/Gallery/index-old.tsx new file mode 100644 index 0000000000..3b04a55b0e --- /dev/null +++ b/src/components/images/Gallery/index-old.tsx @@ -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[], + fetchedDims: (Dimensions | null)[], + ) => void + onPressIn?: (index: number) => void + viewContext?: PostEmbedViewContext +} + +const Context = createContext<{ + ref: React.RefObject +}>({ + ref: {current: null}, +}) + +export function GalleryBleed({children}: {children: React.ReactNode}) { + const ref = useRef(null) + + if (!isValidElement(children)) { + throw new Error('GalleryBleed children must be a single React element') + } + + const node = children as React.ReactElement + + return ( + + {cloneElement(node, { + ref: mergeRefs([ref, node?.props?.ref]), + })} + + ) +} + +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[]>([]).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 ( + + {images.map((image, index) => ( + + onPress?.(index, [containerRef], [dims]) + } + onPressIn={() => onPressIn?.(index)} + hideBadge={ + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + ))} + + ) + } + + return ( + 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 && ( + + { + 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}) => ( + + { + 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, + ]}> + { + thumbDimsRef.current[index] = { + width: e.source.width, + height: e.source.height, + } + }} + loading={index === 0 ? 'eager' : 'lazy'} + /> + + + {image.alt && !hideBadges ? ( + + + ALT + + + ) : null} + + )} + /> + + )} + + ) +} + +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 {children} +} diff --git a/src/components/images/Gallery/index.web.tsx b/src/components/images/Gallery/index-old.web.tsx similarity index 100% rename from src/components/images/Gallery/index.web.tsx rename to src/components/images/Gallery/index-old.web.tsx diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx index 39cb0a6a44..7607b4ea8a 100644 --- a/src/components/images/Gallery/index.tsx +++ b/src/components/images/Gallery/index.tsx @@ -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 +}>({ + ref: {current: null}, +}) + +export function GalleryBleed({children}: {children: React.ReactNode}) { + const ref = useRef(null) + + if (!isValidElement(children)) { + throw new Error('GalleryBleed children must be a single React element') + } + + const node = children as React.ReactElement + + return ( + + {cloneElement(node, { + ref: mergeRefs([ref, node?.props?.ref]), + })} + + ) +} + +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[]>([]).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 ( - - {images.map((image, index) => ( - - onPress?.(index, [containerRef], [dims]) - } - onPressIn={() => onPressIn?.(index)} - hideBadge={ - viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia - } - /> - ))} - - ) + /* + * 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(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 ( 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 && ( - - { - 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}) => ( - - { - 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, - ]}> - { - thumbDimsRef.current[index] = { - width: e.source.width, - height: e.source.height, - } - }} - loading={index === 0 ? 'eager' : 'lazy'} - /> - - - {image.alt && !hideBadges ? ( - - - ALT - - - ) : null} - - )} - /> - - )} + + item.thumb} + renderItem={({item}) => { + return + }} + style={[{ + height: contentHeight, + marginLeft: -insetLeft, + width, + }, a.debug]} + contentContainerStyle={{ + gap: ITEM_GAP, + paddingLeft: insetLeft, + paddingRight: insetRight, + }} + /> + ) } -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 {children} +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 ( + + { + const ar = getAspectRatio(e.source) + if (ar && ar !== aspectRatio) { + setAspectRatio(ar) + } + }} + /> + + ) } diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 42574c7408..ff639d7292 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -58,6 +58,7 @@ import {WhoCanReply} from '#/components/WhoCanReply' import {useAnalytics} from '#/analytics' import {useActorStatus} from '#/features/liveNow' import * as bsky from '#/types/bsky' +import {GalleryBleed} from '#/components/images/Gallery' export function ThreadItemAnchor({ item, @@ -308,234 +309,243 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ return ( <> - - - - - - - - - + + + + + + + + + + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + + + + + + - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - moderation.ui('displayName'), - )} + {sanitizeHandle(post.author.handle, '@')} - - - - - - - {sanitizeHandle(post.author.handle, '@')} - - - - - - - - - - - - - {richText?.text ? ( - - ) : undefined} - - {post.embed && ( - - + - )} - - - {post.repostCount !== 0 || - post.likeCount !== 0 || - post.quoteCount !== 0 || - post.bookmarkCount !== 0 ? ( - // Show this section unless we're *sure* it has no engagement. + + + + + + + + + + {richText?.text ? ( + + ) : undefined} + + {post.embed && ( + + + + )} + + + {post.repostCount !== 0 || + post.likeCount !== 0 || + post.quoteCount !== 0 || + post.bookmarkCount !== 0 ? ( + // Show this section unless we're *sure* it has no engagement. + + {post.repostCount != null && post.repostCount !== 0 ? ( + + + + + {formatPostStatCount(post.repostCount)} + {' '} + + + + + ) : null} + {post.quoteCount != null && + post.quoteCount !== 0 && + !post.viewer?.embeddingDisabled ? ( + + + + + {formatPostStatCount(post.quoteCount)} + {' '} + + + + + ) : null} + {post.likeCount != null && post.likeCount !== 0 ? ( + + + + + {formatPostStatCount(post.likeCount)} + {' '} + + + + + ) : null} + {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( + + + + {formatPostStatCount(post.bookmarkCount)} + {' '} + + + + ) : null} + + ) : null} - {post.repostCount != null && post.repostCount !== 0 ? ( - - - - - {formatPostStatCount(post.repostCount)} - {' '} - - - - - ) : null} - {post.quoteCount != null && - post.quoteCount !== 0 && - !post.viewer?.embeddingDisabled ? ( - - - - - {formatPostStatCount(post.quoteCount)} - {' '} - - - - - ) : null} - {post.likeCount != null && post.likeCount !== 0 ? ( - - - - - {formatPostStatCount(post.likeCount)} - {' '} - - - - - ) : null} - {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( - - - - {formatPostStatCount(post.bookmarkCount)} - {' '} - - - - ) : null} + + + - ) : null} - - - - + - - + ) }) diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index 87aa551330..0c9bfd14a4 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -45,6 +45,7 @@ import * as Skele from '#/components/Skeleton' import {SubtleHover} from '#/components/SubtleHover' import {Text} from '#/components/Typography' import {useActorStatus} from '#/features/liveNow' +import {GalleryBleed} from '#/components/images/Gallery' export type ThreadItemPostProps = { item: Extract @@ -131,18 +132,20 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({ !item.ui.showParentReplyLine && overrides?.topBorder !== true return ( - - {children} - + + + {children} + + ) }) diff --git a/src/screens/PostThread/components/ThreadItemTreePost.tsx b/src/screens/PostThread/components/ThreadItemTreePost.tsx index 82de0247e2..8626c5edff 100644 --- a/src/screens/PostThread/components/ThreadItemTreePost.tsx +++ b/src/screens/PostThread/components/ThreadItemTreePost.tsx @@ -44,6 +44,7 @@ import {RichText} from '#/components/RichText' import * as Skele from '#/components/Skeleton' import {SubtleHover} from '#/components/SubtleHover' import {Text} from '#/components/Typography' +import {GalleryBleed} from '#/components/images/Gallery' /** * Mimic the space in PostMeta @@ -129,33 +130,35 @@ const ThreadItemTreePostOuterWrapper = memo( const indents = Math.max(0, item.ui.indent - 1) return ( - - {Array.from(Array(indents)).map((_, n: number) => { - const isSkipped = item.ui.skippedIndentIndices.has(n) - return ( - + - ) - })} - {children} - + ], + ]}> + {Array.from(Array(indents)).map((_, n: number) => { + const isSkipped = item.ui.skippedIndentIndices.has(n) + return ( + + ) + })} + {children} + + ) }, ) diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 691010cdc6..7b51c84579 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -51,6 +51,7 @@ import {useAnalytics} from '#/analytics' import {useActorStatus} from '#/features/liveNow' import * as bsky from '#/types/bsky' import {PostFeedReason} from './PostFeedReason' +import {GalleryBleed} from '#/components/images/Gallery' interface FeedItemProps { record: AppBskyFeedPost.Record @@ -294,118 +295,120 @@ let FeedItemInner = ({ }, [reason]) return ( - { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> - - - - {isThreadChild && ( - - )} - - - - {reason && ( - - )} - - - - - - - {isThreadParent && ( - - )} - - - - {showReplyTo && - (parentAuthor || isParentBlocked || isParentNotFound) && ( - + { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + + + {isThreadChild && ( + )} - - - + + + + {reason && ( + + )} + - - - + + + + {isThreadParent && ( + + )} + + + + {showReplyTo && + (parentAuthor || isParentBlocked || isParentNotFound) && ( + + )} + + + + + + + + + ) } FeedItemInner = memo(FeedItemInner)