Compare commits

...

29 Commits

Author SHA1 Message Date
vineyardbovines f5252780e1 use bleed on web 2026-04-14 10:09:24 -04:00
vineyardbovines 13ce9da116 measureLayout instead of measureInWindow to get size relative to gallery bleed ancestor 2026-04-14 09:27:27 -04:00
Eric Bailey 11929c7379 Sketch out 'shell' approach 2026-04-13 13:23:36 -05:00
Eric Bailey 23bafcfb29 Use AutoSizedImage for screen reader view 2026-04-08 10:42:09 -05:00
vineyardbovines ff492efd5f fix: split deduplicated yarn.lock entries to match CI (Node 20)
Local Node 24 causes yarn v1 to combine npm-alias entries with their
regular counterparts. CI runs Node 20 which keeps them separate.
Split @babel/traverse, string-width, strip-ansi, and wrap-ansi
entries to match CI's expected output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-08 09:48:07 -04:00
vineyardbovines 3ac5f0ea8e Merge remote-tracking branch 'origin/main' into app-1934 2026-04-08 09:40:13 -04:00
vineyardbovines f8ffdad354 quote post right-hand padding 2026-04-07 11:07:17 -04:00
vineyardbovines ea51ffd69b fix right-hand spacing for portrait last images 2026-04-07 10:49:52 -04:00
vineyardbovines cf18b87135 Merge branch 'main' into app-1934 2026-04-07 08:55:56 -04:00
vineyardbovines 6e390f805c Merge branch 'main' into app-1934 2026-04-06 09:50:33 -04:00
RetroSunstar e15b03bea2 Fix hotkeys causing crashes on native 2026-04-06 09:36:52 -04:00
vineyardbovines 7dd260fa98 padding for non-text carousel 2026-04-06 09:23:02 -04:00
vineyardbovines 2acc497ae3 center user/avatar if no text in carousel post 2026-04-06 09:14:04 -04:00
vineyardbovines b1e58fa438 Merge branch 'main' into app-1934 2026-04-06 08:44:48 -04:00
vineyardbovines e162e1d2c3 use embla for web 2026-04-03 14:36:42 -04:00
vineyardbovines 27ead59a77 Merge branch 'main' into app-1934 2026-04-02 11:03:08 -04:00
vineyardbovines 228e0c3aa0 lingui fix 2026-04-02 10:50:29 -04:00
vineyardbovines d45b9df5d7 fix web bleed and 3/2 ratio 2026-04-02 09:55:57 -04:00
vineyardbovines af3be9aa76 web smooth scrolling 2026-04-02 09:10:51 -04:00
vineyardbovines 319ba37a11 web deceleration 2026-04-01 16:16:13 -04:00
vineyardbovines 00f255e2fa click n drag part 2 2026-04-01 15:55:50 -04:00
vineyardbovines 2c8639aa16 deceleration and snapping 2026-04-01 15:29:57 -04:00
vineyardbovines dcc2d46544 web click n drag 2026-04-01 15:23:59 -04:00
vineyardbovines a0cb3006ef web updates 2026-04-01 15:12:44 -04:00
vineyardbovines 01cd962c09 update scrolling and bleeding edges 2026-04-01 14:59:45 -04:00
vineyardbovines e1b8d25dba smooth scrolling 2026-04-01 13:25:32 -04:00
vineyardbovines eb516cddab fix typings 2026-04-01 08:58:46 -04:00
vineyardbovines ed6aa121bb fix reversion 2026-03-31 15:21:31 -04:00
vineyardbovines 7c11795f68 native carousel 2026-03-31 15:12:29 -04:00
17 changed files with 1875 additions and 487 deletions
+1
View File
@@ -142,6 +142,7 @@
"bcp-47-match": "^2.0.3",
"date-fns": "^2.30.0",
"email-validator": "^2.0.4",
"embla-carousel-react": "^8.6.0",
"emoji-mart": "^5.6.0",
"emoji-regex": "^10.4.0",
"eventemitter3": "^5.0.1",
+15
View File
@@ -1043,4 +1043,19 @@ export type Events = {
'profile:associated:germ:click-self-info': {}
'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {}
// Gallery carousel events
'post:gallery:swipe': {
fromIndex: number
toIndex: number
totalImages: number
}
'post:gallery:openLightbox': {
imageIndex: number
totalImages: number
}
'post:gallery:impression': {
totalImages: number
postUri: string
}
}
+2 -2
View File
@@ -12,7 +12,7 @@ import {useLightboxControls} from '#/state/lightbox'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {Gallery} from '#/components/images/Gallery'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types'
@@ -97,7 +97,7 @@ export function ImageEmbed({
return (
<View style={[a.mt_sm, rest.style]}>
<ImageLayoutGrid
<Gallery
images={images}
onPress={onPress}
onPressIn={onPressIn}
+42 -38
View File
@@ -42,6 +42,7 @@ import {
QuoteEmbedViewContext,
} from './types'
import {VideoEmbed} from './VideoEmbed'
import {GalleryBleed} from '#/components/images/Gallery'
export {PostEmbedViewContext, QuoteEmbedViewContext} from './types'
@@ -308,6 +309,7 @@ export function QuoteEmbed({
<Embed
embed={quote.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested
allowNestedQuotes={
@@ -319,43 +321,45 @@ export function QuoteEmbed({
)
return (
<View
style={[a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
activeStyle={[a.p_md, a.pt_sm]}
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && !linkDisabled && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
{linkDisabled ? (
<View style={[!active && a.p_md]} pointerEvents="none">
{contents}
</View>
) : (
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
{contents}
</Link>
)}
</>
)}
</ContentHider>
</View>
<GalleryBleed>
<View
style={[a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter}
onPointerLeave={linkDisabled ? undefined : onPointerLeave}>
<ContentHider
modui={moderation?.ui('contentList')}
style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]}
activeStyle={[a.p_md, a.pt_sm]}
childContainerStyle={[a.pt_sm]}>
{({active}) => (
<>
{!active && !linkDisabled && (
<SubtleHover
native
hover={hover || pressed}
style={[a.rounded_md]}
/>
)}
{linkDisabled ? (
<View style={[!active && a.p_md]} pointerEvents="none">
{contents}
</View>
) : (
<Link
style={[!active && a.p_md]}
hoverStyle={t.atoms.border_contrast_high}
href={itemHref}
title={itemTitle}
onBeforePress={onBeforePress}
onPressIn={onPressIn}
onPressOut={onPressOut}>
{contents}
</Link>
)}
</>
)}
</ContentHider>
</View>
</GalleryBleed>
)
}
+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>
}
@@ -0,0 +1,378 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import useEmblaCarousel from 'embla-carousel-react'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
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
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
}
export function Gallery({
images,
onPress,
onPressIn,
viewContext,
}: GalleryProps) {
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
const largeAltBadge = useLargeAltBadgeEnabled()
const currentPageRef = useRef(0)
const containerRef = useRef<View>(null)
const [containerWidth, setContainerWidth] = useState(0)
const [insetLeft, setInsetLeft] = useState(0)
const [insetRight, setInsetRight] = useState(0)
const insetLeftRef = useRef(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 QUOTE_PADDING = 12
const containerHeight =
containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0
const getItemWidth = (image: AppBskyEmbedImages.ViewImage, index: number) => {
const ar = image.aspectRatio
let width = containerHeight
if (ar && ar.width > 0 && ar.height > 0) {
const ratio = ar.width / ar.height
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
}
// Embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
align: () => insetLeftRef.current,
containScroll: 'trimSnaps',
dragFree: true,
})
// Track page changes for analytics
useEffect(() => {
if (!emblaApi) return
const onSelect = () => {
const page = emblaApi.selectedScrollSnap()
if (page !== currentPageRef.current) {
ax.metric('post:gallery:swipe', {
fromIndex: currentPageRef.current,
toIndex: page,
totalImages: images.length,
})
currentPageRef.current = page
}
}
const onDragStart = () => setIsDragging(true)
const onDragEnd = () => setIsDragging(false)
emblaApi.on('select', onSelect)
emblaApi.on('pointerDown', onDragStart)
emblaApi.on('pointerUp', onDragEnd)
emblaApi.on('settle', onDragEnd)
return () => {
emblaApi.off('select', onSelect)
emblaApi.off('pointerDown', onDragStart)
emblaApi.off('pointerUp', onDragEnd)
emblaApi.off('settle', onDragEnd)
}
}, [emblaApi, ax, images.length])
// Re-initialize Embla when bleed measurements change
useEffect(() => {
if (emblaApi) emblaApi.reInit()
}, [emblaApi, insetLeft, insetRight])
// Suppress click after drag
const pointerDown = useRef(false)
const dragged = useRef(false)
const [isDragging, setIsDragging] = useState(false)
useEffect(() => {
if (!emblaApi) return
const root = emblaApi.rootNode()
const onPointerDown = () => {
pointerDown.current = true
dragged.current = false
}
const onPointerMove = () => {
if (pointerDown.current) dragged.current = true
}
const onPointerUp = () => {
pointerDown.current = false
}
const onClick = (e: MouseEvent) => {
if (dragged.current) {
e.stopPropagation()
e.preventDefault()
}
}
root.addEventListener('pointerdown', onPointerDown)
root.addEventListener('pointermove', onPointerMove)
root.addEventListener('pointerup', onPointerUp)
root.addEventListener('click', onClick, true)
return () => {
root.removeEventListener('pointerdown', onPointerDown)
root.removeEventListener('pointermove', onPointerMove)
root.removeEventListener('pointerup', onPointerUp)
root.removeEventListener('click', onClick, true)
}
}, [emblaApi])
// Bleed measurement
const measureBleed = useCallback(() => {
if (isWithinQuote) return
requestAnimationFrame(() => {
const el = containerRef.current as unknown as HTMLElement
if (!el) return
const galleryRect = el.getBoundingClientRect()
let parent: HTMLElement | null = el.parentElement
while (parent) {
const ps = window.getComputedStyle(parent)
const pl = parseFloat(ps.paddingLeft)
const pr = parseFloat(ps.paddingRight)
if (pl >= 8 && pr >= 8) {
const parentRect = parent.getBoundingClientRect()
const il = galleryRect.left - parentRect.left
insetLeftRef.current = il
setInsetLeft(il)
setInsetRight(parentRect.right - galleryRect.right)
break
}
parent = parent.parentElement
}
})
}, [isWithinQuote])
const isBleed = !isWithinQuote && (insetLeft > 0 || insetRight > 0)
return (
<View
style={
containerWidth > 0
? isWithinQuote
? {
height: containerHeight,
overflow: 'hidden' as const,
width: containerWidth + QUOTE_PADDING * 2,
marginLeft: -QUOTE_PADDING,
}
: {height: containerHeight, overflow: 'visible' as const}
: {aspectRatio: CONTAINER_ASPECT_RATIO}
}
ref={containerRef}
onLayout={e => {
const w = e.nativeEvent.layout.width
if (w > 0 && containerWidth === 0) setContainerWidth(w)
measureBleed()
}}
role="group"
aria-roledescription="carousel"
aria-label={l`Image gallery, ${images.length} images`}>
{containerWidth > 0 && (
<div
style={{
overflow: 'hidden',
width: isBleed ? containerWidth + insetLeft + insetRight : '100%',
marginLeft: isBleed ? -insetLeft : 0,
height: containerHeight,
}}>
<div
ref={emblaRef}
style={{
overflow: 'visible',
width: isBleed ? containerWidth + insetLeft + insetRight : '100%',
height: containerHeight,
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none',
}}>
<div
style={{
display: 'flex',
gap: ITEM_GAP,
paddingLeft: isWithinQuote ? QUOTE_PADDING : insetLeft,
paddingRight: isWithinQuote ? QUOTE_PADDING : 0,
height: containerHeight,
}}>
{images.map((image, index) => (
<Slide
key={index}
width={getItemWidth(image, index)}
height={containerHeight}>
<View
ref={containerRefs[index]}
collapsable={false}
style={[
{
width: getItemWidth(image, index),
height: containerHeight,
},
]}
aria-roledescription="slide"
aria-label={
image.alt || l`Image ${index + 1} of ${images.length}`
}>
<Pressable
onPress={
onPress
? () => {
if (dragged.current) return
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}
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>
</Slide>
))}
{(isBleed ? insetRight > 0 : isWithinQuote) && (
<div
aria-hidden
style={{
flex: `0 0 ${Math.max(0, (isBleed ? insetRight : QUOTE_PADDING) - ITEM_GAP)}px`,
minWidth: 0,
}}
/>
)}
</div>
</div>
</div>
)}
</View>
)
}
function Slide({
width,
height,
children,
}: {
width: number
height: number
children: React.ReactNode
}) {
const [pressed, setPressed] = useState(false)
return (
<div
onPointerDown={() => setPressed(true)}
onPointerUp={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
onPointerMove={() => setPressed(false)}
style={{
flex: `0 0 ${width}px`,
minWidth: 0,
height,
transition: 'transform 0.15s ease-out',
transform: pressed ? 'scale(0.975)' : undefined,
}}>
{children}
</div>
)
}
+234
View File
@@ -0,0 +1,234 @@
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, 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
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<{
bleedRef: React.RefObject<View | null>
bleedWidth: number
}>({
bleedRef: {current: null},
bleedWidth: 0,
})
export function GalleryBleed({children}: {children: React.ReactNode}) {
const ref = useRef<View>(null)
const [bleedWidth, setBleedWidth] = useState(0)
if (!isValidElement(children)) {
throw new Error('GalleryBleed children must be a single React element')
}
const node = children as React.ReactElement<any>
return (
<Context.Provider value={{bleedRef: ref, bleedWidth}}>
{cloneElement(node, {
ref: mergeRefs([ref, node?.props?.ref]),
onLayout: (e: {nativeEvent: {layout: {width: number}}}) => {
setBleedWidth(e.nativeEvent.layout.width)
},
})}
</Context.Provider>
)
}
export function useGalleryBleed() {
return useContext(Context)
}
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 bps = useBreakpoints()
const window = useWindowDimensions()
const contentHeight = useMemo(() => {
if (bps.gtMobile) {
return 300
} else if (bps.gtPhone) {
return 260
} else {
return 200
}
}, [bps])
/*
* Container overflow styles
*
* Uses measureLayout to get the Gallery's offset relative to the GalleryBleed
* ancestor. This is a layout-relative measurement that doesn't depend on
* scroll position, so it works correctly for off-screen FlatList items.
*/
const {bleedRef, bleedWidth} = useGalleryBleed()
const contentRef = useRef<View>(null)
const [insets, setInsets] = useState<{left: number; right: number}>()
const measure = () => {
if (contentRef.current && bleedRef.current && bleedWidth > 0) {
contentRef.current.measureLayout(
bleedRef.current,
(x, _y, w) => {
setInsets({
left: x,
right: Math.max(0, bleedWidth - x - w),
})
},
() => {},
)
}
}
const insetLeft = insets?.left ?? 0
const insetRight = insets?.right ?? 0
const width = bleedWidth || Math.min(600, window.width)
/* End container overflow styles */
return (
<View
ref={contentRef}
style={[
a.w_full,
{
height: contentHeight,
overflow: 'visible',
},
]}
onLayout={measure}>
<BlockDrawerGesture>
<FlatList
horizontal
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
decelerationRate={0.993}
directionalLockEnabled
nestedScrollEnabled
alwaysBounceVertical={false}
scrollEventThrottle={16}
data={images}
keyExtractor={(item) => item.thumb}
renderItem={({item}) => {
return <GalleryImage image={item} contentHeight={contentHeight} />
}}
style={[{
height: contentHeight,
marginLeft: -insetLeft,
width,
}]}
contentContainerStyle={{
gap: ITEM_GAP,
paddingLeft: insetLeft,
paddingRight: insetRight,
}}
/>
</BlockDrawerGesture>
</View>
)
}
function getAspectRatio({
width,
height,
}: {width?: number; height?: number} = {}) {
if (width && width > 0 && height && height > 0) {
return width / height
}
return undefined
}
function computeDims({
height,
aspectRatio,
}: {
height: number
aspectRatio?: number
}) {
/*
* Old images, or images from other clients can sometimes not have
* aspectRatio populated. In these cases, default to square and we'll
* resize once the image loads.
*/
const width = Math.floor(height * (aspectRatio ?? 1))
return {width, height, aspectRatio}
}
function GalleryImage({
contentHeight: height,
image,
}: {
contentHeight: number
image: AppBskyEmbedImages.ViewImage
}) {
const t = useTheme()
const [aspectRatio, setAspectRatio] = useState(() =>
getAspectRatio(image.aspectRatio),
)
const dims = computeDims({height, aspectRatio})
return (
<Pressable style={[a.rounded_md, a.overflow_hidden, t.atoms.bg_contrast_25]}>
<Image
source={{uri: image.thumb}}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
loading="eager"
// loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]}
onLoad={e => {
const ar = getAspectRatio(e.source)
if (ar && ar !== aspectRatio) {
setAspectRatio(ar)
}
}}
/>
</Pressable>
)
}
+391
View File
@@ -0,0 +1,391 @@
import {useCallback, useEffect, useRef, useState} from 'react'
import {Pressable, View} from 'react-native'
import {type AnimatedRef, useAnimatedRef} from 'react-native-reanimated'
import {Image} from 'expo-image'
import {type AppBskyEmbedImages} from '@atproto/api'
import {Trans, useLingui} from '@lingui/react/macro'
import useEmblaCarousel from 'embla-carousel-react'
import {type Dimensions} from '#/lib/media/types'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {atoms as a, useTheme} from '#/alf'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
/**
* No-op on web. The web Gallery uses its own DOM-based bleed measurement
* (getBoundingClientRect + DOM tree walk), so the native ref/context approach
* isn't needed. These exports exist to match the native module's API.
*/
export function GalleryBleed({children}: {children: React.ReactNode}) {
return <>{children}</>
}
export function useGalleryBleed() {
return {bleedRef: {current: null}, bleedWidth: 0}
}
const CONTAINER_ASPECT_RATIO = 3 / 2
const ITEM_GAP = 8
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
}
export function Gallery({
images,
onPress,
onPressIn,
viewContext,
}: GalleryProps) {
const t = useTheme()
const {t: l} = useLingui()
const ax = useAnalytics()
const largeAltBadge = useLargeAltBadgeEnabled()
const currentPageRef = useRef(0)
const containerRef = useRef<View>(null)
const [containerWidth, setContainerWidth] = useState(0)
const [insetLeft, setInsetLeft] = useState(0)
const [insetRight, setInsetRight] = useState(0)
const insetLeftRef = useRef(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 QUOTE_PADDING = 12
const containerHeight =
containerWidth > 0 ? containerWidth / CONTAINER_ASPECT_RATIO : 0
const getItemWidth = (image: AppBskyEmbedImages.ViewImage, index: number) => {
const ar = image.aspectRatio
let width = containerHeight
if (ar && ar.width > 0 && ar.height > 0) {
const ratio = ar.width / ar.height
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
}
// Embla carousel
const [emblaRef, emblaApi] = useEmblaCarousel({
align: () => insetLeftRef.current,
containScroll: 'trimSnaps',
dragFree: true,
})
// Track page changes for analytics
useEffect(() => {
if (!emblaApi) return
const onSelect = () => {
const page = emblaApi.selectedScrollSnap()
if (page !== currentPageRef.current) {
ax.metric('post:gallery:swipe', {
fromIndex: currentPageRef.current,
toIndex: page,
totalImages: images.length,
})
currentPageRef.current = page
}
}
const onDragStart = () => setIsDragging(true)
const onDragEnd = () => setIsDragging(false)
emblaApi.on('select', onSelect)
emblaApi.on('pointerDown', onDragStart)
emblaApi.on('pointerUp', onDragEnd)
emblaApi.on('settle', onDragEnd)
return () => {
emblaApi.off('select', onSelect)
emblaApi.off('pointerDown', onDragStart)
emblaApi.off('pointerUp', onDragEnd)
emblaApi.off('settle', onDragEnd)
}
}, [emblaApi, ax, images.length])
// Re-initialize Embla when bleed measurements change
useEffect(() => {
if (emblaApi) emblaApi.reInit()
}, [emblaApi, insetLeft, insetRight])
// Suppress click after drag
const pointerDown = useRef(false)
const dragged = useRef(false)
const [isDragging, setIsDragging] = useState(false)
useEffect(() => {
if (!emblaApi) return
const root = emblaApi.rootNode()
const onPointerDown = () => {
pointerDown.current = true
dragged.current = false
}
const onPointerMove = () => {
if (pointerDown.current) dragged.current = true
}
const onPointerUp = () => {
pointerDown.current = false
}
const onClick = (e: MouseEvent) => {
if (dragged.current) {
e.stopPropagation()
e.preventDefault()
}
}
root.addEventListener('pointerdown', onPointerDown)
root.addEventListener('pointermove', onPointerMove)
root.addEventListener('pointerup', onPointerUp)
root.addEventListener('click', onClick, true)
return () => {
root.removeEventListener('pointerdown', onPointerDown)
root.removeEventListener('pointermove', onPointerMove)
root.removeEventListener('pointerup', onPointerUp)
root.removeEventListener('click', onClick, true)
}
}, [emblaApi])
// Bleed measurement
const measureBleed = useCallback(() => {
if (isWithinQuote) return
requestAnimationFrame(() => {
const el = containerRef.current as unknown as HTMLElement
if (!el) return
const galleryRect = el.getBoundingClientRect()
let parent: HTMLElement | null = el.parentElement
while (parent) {
const ps = window.getComputedStyle(parent)
const pl = parseFloat(ps.paddingLeft)
const pr = parseFloat(ps.paddingRight)
if (pl >= 8 && pr >= 8) {
const parentRect = parent.getBoundingClientRect()
const il = galleryRect.left - parentRect.left
insetLeftRef.current = il
setInsetLeft(il)
setInsetRight(parentRect.right - galleryRect.right)
break
}
parent = parent.parentElement
}
})
}, [isWithinQuote])
const isBleed = !isWithinQuote && (insetLeft > 0 || insetRight > 0)
return (
<View
style={
containerWidth > 0
? isWithinQuote
? {
height: containerHeight,
overflow: 'hidden' as const,
width: containerWidth + QUOTE_PADDING * 2,
marginLeft: -QUOTE_PADDING,
}
: {height: containerHeight, overflow: 'visible' as const}
: {aspectRatio: CONTAINER_ASPECT_RATIO}
}
ref={containerRef}
onLayout={e => {
const w = e.nativeEvent.layout.width
if (w > 0 && containerWidth === 0) setContainerWidth(w)
measureBleed()
}}
role="group"
aria-roledescription="carousel"
aria-label={l`Image gallery, ${images.length} images`}>
{containerWidth > 0 && (
<div
style={{
overflow: 'hidden',
width: isBleed ? containerWidth + insetLeft + insetRight : '100%',
marginLeft: isBleed ? -insetLeft : 0,
height: containerHeight,
}}>
<div
ref={emblaRef}
style={{
overflow: 'visible',
width: isBleed ? containerWidth + insetLeft + insetRight : '100%',
height: containerHeight,
cursor: isDragging ? 'grabbing' : 'grab',
userSelect: 'none',
}}>
<div
style={{
display: 'flex',
gap: ITEM_GAP,
paddingLeft: isWithinQuote ? QUOTE_PADDING : insetLeft,
paddingRight: isWithinQuote ? QUOTE_PADDING : 0,
height: containerHeight,
}}>
{images.map((image, index) => (
<Slide
key={index}
width={getItemWidth(image, index)}
height={containerHeight}>
<View
ref={containerRefs[index]}
collapsable={false}
style={[
{
width: getItemWidth(image, index),
height: containerHeight,
},
]}
aria-roledescription="slide"
aria-label={
image.alt || l`Image ${index + 1} of ${images.length}`
}>
<Pressable
onPress={
onPress
? () => {
if (dragged.current) return
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}
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>
</Slide>
))}
{(isBleed ? insetRight > 0 : isWithinQuote) && (
<div
aria-hidden
style={{
flex: `0 0 ${Math.max(0, (isBleed ? insetRight : QUOTE_PADDING) - ITEM_GAP)}px`,
minWidth: 0,
}}
/>
)}
</div>
</div>
</div>
)}
</View>
)
}
function Slide({
width,
height,
children,
}: {
width: number
height: number
children: React.ReactNode
}) {
const [pressed, setPressed] = useState(false)
return (
<div
onPointerDown={() => setPressed(true)}
onPointerUp={() => setPressed(false)}
onPointerLeave={() => setPressed(false)}
onPointerMove={() => setPressed(false)}
style={{
flex: `0 0 ${width}px`,
minWidth: 0,
height,
transition: 'transform 0.15s ease-out',
transform: pressed ? 'scale(0.975)' : undefined,
}}>
{children}
</div>
)
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {type AppBskyEmbedImages} from '@atproto/api'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a, useBreakpoints} from '#/alf'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {GalleryItem} from './Gallery'
import {GalleryItem} from './Gallery/GalleryItem'
interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[]
@@ -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 (
<>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<View
testID={`postThreadItem-by-${post.author.handle}`}
style={[
{
paddingHorizontal: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<View collapsable={false}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
</View>
<Link
to={authorHref}
style={[a.flex_1]}
label={sanitizeDisplayName(
post.author.displayName || sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
onPress={onOpenAuthor}>
<View style={[a.flex_1, a.align_start]}>
<ProfileHoverCard did={post.author.did} style={[a.w_full]}>
<View style={[a.flex_row, a.align_center]}>
<GalleryBleed>
<View
testID={`postThreadItem-by-${post.author.handle}`}
style={[
{
paddingHorizontal: OUTER_SPACE,
},
isRoot && [a.pt_lg],
]}>
<View style={[a.flex_row, a.gap_md, a.pb_md]}>
<View collapsable={false}>
<PreviewableUserAvatar
size={42}
profile={post.author}
moderation={moderation.ui('avatar')}
type={post.author.associated?.labeler ? 'labeler' : 'user'}
live={live}
onBeforePress={onOpenAuthor}
/>
</View>
<Link
to={authorHref}
style={[a.flex_1]}
label={sanitizeDisplayName(
post.author.displayName || sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
onPress={onOpenAuthor}>
<View style={[a.flex_1, a.align_start]}>
<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
emoji
style={[
a.flex_shrink,
a.text_lg,
a.font_semi_bold,
a.text_md,
a.leading_snug,
t.atoms.text_contrast_medium,
]}
numberOfLines={1}>
{sanitizeDisplayName(
post.author.displayName ||
sanitizeHandle(post.author.handle),
moderation.ui('displayName'),
)}
{sanitizeHandle(post.author.handle, '@')}
</Text>
<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}
/>
</ProfileHoverCard>
</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.
</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>
)}
</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
style={[
a.flex_row,
a.flex_wrap,
a.align_center,
a.pt_sm,
a.pb_2xs,
{
rowGap: a.gap_sm.gap,
columnGap: a.gap_lg.gap,
marginLeft: -5,
},
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}
<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>
) : null}
<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>
<DebugFieldDisplay subject={post} />
</View>
<DebugFieldDisplay subject={post} />
</View>
</View>
</GalleryBleed>
</>
)
})
@@ -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<ThreadItem, {type: 'threadPost'}>
@@ -131,18 +132,20 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
!item.ui.showParentReplyLine && overrides?.topBorder !== true
return (
<View
style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
{paddingHorizontal: OUTER_SPACE},
// If there's no next child, add a little padding to bottom
!item.ui.showChildReplyLine &&
!item.ui.precedesChildReadMore && {
paddingBottom: OUTER_SPACE / 2,
},
]}>
{children}
</View>
<GalleryBleed>
<View
style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low],
{paddingHorizontal: OUTER_SPACE},
// If there's no next child, add a little padding to bottom
!item.ui.showChildReplyLine &&
!item.ui.precedesChildReadMore && {
paddingBottom: OUTER_SPACE / 2,
},
]}>
{children}
</View>
</GalleryBleed>
)
})
@@ -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 (
<View
style={[
a.flex_row,
item.ui.indent === 1 &&
!item.ui.showParentReplyLine && [
a.border_t,
t.atoms.border_contrast_low,
],
]}>
{Array.from(Array(indents)).map((_, n: number) => {
const isSkipped = item.ui.skippedIndentIndices.has(n)
return (
<View
key={`${item.value.post.uri}-padding-${n}`}
style={[
<GalleryBleed>
<View
style={[
a.flex_row,
item.ui.indent === 1 &&
!item.ui.showParentReplyLine && [
a.border_t,
t.atoms.border_contrast_low,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})}
{children}
</View>
],
]}>
{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,
{
borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH,
width: TREE_INDENT + TREE_AVI_WIDTH / 2,
left: 1,
},
]}
/>
)
})}
{children}
</View>
</GalleryBleed>
)
},
)
+64 -84
View File
@@ -1,14 +1,12 @@
import {memo, useMemo, useState} from 'react'
import {memo, useState} from 'react'
import {
findNodeHandle,
type ImageStyle,
Keyboard,
type LayoutChangeEvent,
Platform,
StyleSheet,
TouchableOpacity,
View,
type ViewStyle,
} from 'react-native'
import {Image} from 'expo-image'
import {FontAwesomeIcon} from '@fortawesome/react-native-fontawesome'
@@ -16,10 +14,10 @@ import {msg} from '@lingui/core/macro'
import {useLingui} from '@lingui/react'
import {Trans} from '@lingui/react/macro'
import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
import {type Dimensions} from '#/lib/media/types'
import {colors} from '#/lib/styles'
import {type ComposerImage, cropImage} from '#/state/gallery'
import {DraggableScrollView} from '#/view/com/pager/DraggableScrollView'
import {atoms as a, tokens, useTheme} from '#/alf'
import {Admonition} from '#/components/Admonition'
import * as Dialog from '#/components/Dialog'
@@ -32,6 +30,7 @@ import {EditImageDialog} from './EditImageDialog'
import {ImageAltTextDialog} from './ImageAltTextDialog'
const IMAGE_GAP = 8
const CONTAINER_HEIGHT = 200
interface GalleryProps {
images: ComposerImage[]
@@ -63,53 +62,32 @@ interface GalleryInnerProps extends GalleryProps {
containerInfo: Dimensions
}
const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
const {isMobile} = useWebMediaQueries()
const {altTextControlStyle, imageControlsStyle, imageStyle} = useMemo(() => {
const side =
images.length === 1
? 250
: (containerInfo.width - IMAGE_GAP * (images.length - 1)) /
images.length
const isOverflow = isMobile && images.length > 2
return {
altTextControlStyle: isOverflow
? {left: 4, bottom: 4}
: !isMobile && images.length < 3
? {left: 8, top: 8}
: {left: 4, top: 4},
imageControlsStyle: {
display: 'flex' as const,
flexDirection: 'row' as const,
position: 'absolute' as const,
...(isOverflow
? {top: 4, right: 4, gap: 4}
: !isMobile && images.length < 3
? {top: 8, right: 8, gap: 8}
: {top: 4, right: 4, gap: 4}),
zIndex: 1,
},
imageStyle: {
height: side,
width: side,
},
}
}, [images.length, containerInfo, isMobile])
const getItemWidth = (image: ComposerImage, height: number) => {
const source = image.transformed ?? image.source
if (source.width > 0 && source.height > 0) {
const ratio = source.width / source.height
const w = height * ratio
// Clamp: at least 60% of height, at most 1.5x height
return Math.max(height * 0.6, Math.min(w, height * 1.5))
}
return height
}
const GalleryInner = ({images, dispatch}: GalleryInnerProps) => {
return images.length !== 0 ? (
<>
<View testID="selectedPhotosView" style={styles.gallery}>
<DraggableScrollView
testID="selectedPhotosView"
showsHorizontalScrollIndicator={false}
contentContainerStyle={{gap: IMAGE_GAP, paddingTop: 16}}
style={{height: CONTAINER_HEIGHT + 16}}>
{images.map(image => {
return (
<GalleryItem
key={image.source.id}
image={image}
altTextControlStyle={altTextControlStyle}
imageControlsStyle={imageControlsStyle}
imageStyle={imageStyle}
itemWidth={getItemWidth(image, CONTAINER_HEIGHT)}
itemHeight={CONTAINER_HEIGHT}
onChange={next => {
dispatch({type: 'embed_update_image', image: next})
}}
@@ -119,7 +97,7 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
/>
)
})}
</View>
</DraggableScrollView>
{images.some(image => !image.alt) && (
<Admonition type="info" style={[a.mt_sm]}>
<Trans>
@@ -134,18 +112,16 @@ const GalleryInner = ({images, containerInfo, dispatch}: GalleryInnerProps) => {
type GalleryItemProps = {
image: ComposerImage
altTextControlStyle?: ViewStyle
imageControlsStyle?: ViewStyle
imageStyle?: ImageStyle
itemWidth: number
itemHeight: number
onChange: (next: ComposerImage) => void
onRemove: () => void
}
const GalleryItem = ({
image,
altTextControlStyle,
imageControlsStyle,
imageStyle,
itemWidth,
itemHeight,
onChange,
onRemove,
}: GalleryItemProps): React.ReactNode => {
@@ -158,7 +134,6 @@ const GalleryItem = ({
const [altBtnViewTag, setAltBtnViewTag] = useState<number>()
const altBtnRef = (node: View | null) => {
// for iOS 26 fluid transition
if (IS_IOS && node) {
const tag = findNodeHandle(node)
if (tag != null) setAltBtnViewTag(tag)
@@ -187,33 +162,8 @@ const GalleryItem = ({
return (
<View
ref={altBtnRef}
style={imageStyle as ViewStyle}
// Fixes ALT and icons appearing with half opacity when the post is inactive
style={{width: itemWidth, height: itemHeight}}
renderToHardwareTextureAndroid>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={onAltTextEdit}
style={[styles.altTextControl, altTextControlStyle]}>
{image.alt.length !== 0 ? (
<FontAwesomeIcon
icon="check"
size={10}
style={{color: t.palette.white}}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={10}
style={{color: t.palette.white}}
/>
)}
<Text style={styles.altTextControlLabel} accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<View style={imageControlsStyle}>
<TouchableOpacity
testID="editPhotoButton"
@@ -238,6 +188,32 @@ const GalleryItem = ({
/>
</TouchableOpacity>
</View>
<TouchableOpacity
testID="altTextButton"
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
accessibilityHint=""
onPress={onAltTextEdit}
style={[styles.altTextControl, {left: 4, bottom: 4}]}>
{image.alt.length !== 0 ? (
<FontAwesomeIcon
icon="check"
size={10}
style={{color: t.palette.white}}
/>
) : (
<FontAwesomeIcon
icon="plus"
size={10}
style={{color: t.palette.white}}
/>
)}
<Text style={styles.altTextControlLabel} accessible={false}>
<Trans>ALT</Trans>
</Text>
</TouchableOpacity>
<TouchableOpacity
accessibilityRole="button"
accessibilityLabel={_(msg`Add alt text`)}
@@ -248,7 +224,7 @@ const GalleryItem = ({
<Image
testID="selectedPhotoImage"
style={[styles.image, imageStyle]}
style={[styles.image, {width: itemWidth, height: itemHeight}]}
source={{
uri: (image.transformed ?? image.source).path,
}}
@@ -277,13 +253,17 @@ const GalleryItem = ({
)
}
const imageControlsStyle = {
display: 'flex' as const,
flexDirection: 'row' as const,
position: 'absolute' as const,
top: 4,
right: 4,
gap: 4,
zIndex: 1,
}
const styles = StyleSheet.create({
gallery: {
flex: 1,
flexDirection: 'row',
gap: IMAGE_GAP,
marginTop: 16,
},
image: {
borderRadius: tokens.borderRadius.md,
},
+15 -6
View File
@@ -181,7 +181,11 @@ function PostInner({
type={post.author.associated?.labeler ? 'labeler' : 'user'}
/>
</View>
<View style={styles.layoutContent}>
<View
style={[
styles.layoutContent,
!richText.text && styles.layoutContentNoText,
]}>
<PostMeta
author={post.author}
moderation={moderation}
@@ -221,11 +225,13 @@ function PostInner({
) : undefined}
<TranslatedPost hideTranslateLink post={post} />
{post.embed ? (
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
<View style={!richText.text ? {marginTop: 6} : undefined}>
<Embed
embed={post.embed}
moderation={moderation}
viewContext={PostEmbedViewContext.Feed}
/>
</View>
) : null}
</ContentHider>
<PostControls
@@ -260,6 +266,9 @@ const styles = StyleSheet.create({
layoutContent: {
flex: 1,
},
layoutContentNoText: {
paddingTop: 10,
},
alert: {
marginBottom: 6,
},
+115 -105
View File
@@ -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,114 +295,120 @@ let FeedItemInner = ({
}, [reason])
return (
<Link
testID={`feedItem-by-${post.author.handle}`}
style={outerStyles}
href={href}
noFeedback
accessible={false}
onBeforePress={onBeforePress}
dataSet={{feedContext}}
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
<SubtleHover hover={hover} />
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
<View style={{width: 42}}>
{isThreadChild && (
<View
style={[
styles.replyLine,
{
flexGrow: 1,
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}>
<PostMeta
author={post.author}
moderation={moderation}
timestamp={post.indexedAt}
postHref={href}
onOpenAuthor={onOpenAuthor}
/>
{showReplyTo &&
(parentAuthor || isParentBlocked || isParentNotFound) && (
<PostRepliedTo
parentAuthor={parentAuthor}
isParentBlocked={isParentBlocked}
isParentNotFound={isParentNotFound}
<GalleryBleed>
<Link
testID={`feedItem-by-${post.author.handle}`}
style={outerStyles}
href={href}
noFeedback
accessible={false}
onBeforePress={onBeforePress}
dataSet={{feedContext}}
onPointerEnter={() => {
setHover(true)
}}
onPointerLeave={() => {
setHover(false)
}}>
<SubtleHover hover={hover} />
<View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}>
<View style={{width: 42}}>
{isThreadChild && (
<View
style={[
styles.replyLine,
{
flexGrow: 1,
backgroundColor: pal.colors.replyLine,
marginBottom: 4,
},
]}
/>
)}
<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>
<View style={[a.pt_sm, a.flex_shrink]}>
{reason && (
<PostFeedReason
reason={reason}
moderation={moderation}
onOpenReposter={onOpenReposter}
/>
)}
</View>
</View>
<DiscoverDebug feedContext={feedContext} />
</View>
</Link>
<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} />
<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)
@@ -492,7 +499,7 @@ let PostContent = ({
) : undefined}
{record && <TranslatedPost hideTranslateLink post={post} />}
{postEmbed ? (
<View style={[a.pb_xs]}>
<View style={[a.pb_xs, !richText.text && {marginTop: 6}]}>
<Embed
embed={postEmbed}
moderation={moderation}
@@ -532,6 +539,9 @@ const styles = StyleSheet.create({
flex: 1,
zIndex: 0,
},
layoutContentNoText: {
paddingTop: 10,
},
alert: {
marginTop: 6,
marginBottom: 6,
+18
View File
@@ -8078,6 +8078,24 @@ email-validator@^2.0.4:
resolved "https://registry.yarnpkg.com/email-validator/-/email-validator-2.0.4.tgz#b8dfaa5d0dae28f1b03c95881d904d4e40bfe7ed"
integrity sha512-gYCwo7kh5S3IDyZPLZf6hSS0MnZT8QmJFqYvbqlDZSbwdZlY6QZWxJ4i/6UhITOJ4XzyI647Bm2MXKCLqnJ4nQ==
embla-carousel-react@^8.6.0:
version "8.6.0"
resolved "https://registry.yarnpkg.com/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz#b737042a32761c38d6614593653b3ac619477bd1"
integrity sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==
dependencies:
embla-carousel "8.6.0"
embla-carousel-reactive-utils "8.6.0"
embla-carousel-reactive-utils@8.6.0:
version "8.6.0"
resolved "https://registry.yarnpkg.com/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz#607f1d8ab9921c906a555c206251b2c6db687223"
integrity sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==
embla-carousel@8.6.0:
version "8.6.0"
resolved "https://registry.yarnpkg.com/embla-carousel/-/embla-carousel-8.6.0.tgz#abcedff2bff36992ea8ac27cd30080ca5b6a3f58"
integrity sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==
emittery@^0.13.1:
version "0.13.1"
resolved "https://registry.yarnpkg.com/emittery/-/emittery-0.13.1.tgz#c04b8c3457490e0847ae51fced3af52d338e3dad"