Compare commits
29 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| f5252780e1 | |||
| 13ce9da116 | |||
| 11929c7379 | |||
| 23bafcfb29 | |||
| ff492efd5f | |||
| 3ac5f0ea8e | |||
| f8ffdad354 | |||
| ea51ffd69b | |||
| cf18b87135 | |||
| 6e390f805c | |||
| e15b03bea2 | |||
| 7dd260fa98 | |||
| 2acc497ae3 | |||
| b1e58fa438 | |||
| e162e1d2c3 | |||
| 27ead59a77 | |||
| 228e0c3aa0 | |||
| d45b9df5d7 | |||
| af3be9aa76 | |||
| 319ba37a11 | |||
| 00f255e2fa | |||
| 2c8639aa16 | |||
| dcc2d46544 | |||
| a0cb3006ef | |||
| 01cd962c09 | |||
| e1b8d25dba | |||
| eb516cddab | |||
| ed6aa121bb | |||
| 7c11795f68 |
@@ -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",
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
},
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
},
|
||||
|
||||
@@ -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
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user