[APP-1934] replace image grid layout with carousel (#10157)

Co-authored-by: RetroSunstar <57507616+RetroSunstar@users.noreply.github.com>
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Co-authored-by: Eric Bailey <git@esb.lol>
This commit is contained in:
Spence Pope
2026-04-15 18:53:01 -04:00
committed by GitHub
parent d3f5093817
commit e804546809
20 changed files with 1697 additions and 511 deletions
+1
View File
@@ -13,6 +13,7 @@ export enum Features {
ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled', ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled',
GroupChatsEnable = 'group_chats:enable', GroupChatsEnable = 'group_chats:enable',
DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', DmsNewMessageComposerEnable = 'dms:new_message_composer:enable',
PostGalleryEmbedEnable = 'post_gallery_embed:enable',
AATest = 'aa-test', AATest = 'aa-test',
} }
+15
View File
@@ -1046,4 +1046,19 @@ export type Events = {
'profile:associated:germ:click-self-info': {} 'profile:associated:germ:click-self-info': {}
'profile:associated:germ:self-disconnect': {} 'profile:associated:germ:self-disconnect': {}
'profile:associated:germ:self-reconnect': {} 'profile:associated:germ:self-reconnect': {}
// Gallery carousel events
'post:gallery:swipe': {
fromImage: number
toImage: number
totalImages: number
}
'post:gallery:openLightbox': {
fromImage: number
totalImages: number
}
'post:gallery:impression': {
totalImages: number
postUri: string
}
} }
+17
View File
@@ -12,8 +12,10 @@ import {useLightboxControls} from '#/state/lightbox'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {AutoSizedImage} from '#/components/images/AutoSizedImage' import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {Gallery} from '#/components/images/Gallery'
import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {useAnalytics} from '#/analytics'
import {type EmbedType} from '#/types/bsky/post' import {type EmbedType} from '#/types/bsky/post'
import {type CommonProps} from './types' import {type CommonProps} from './types'
@@ -23,8 +25,10 @@ export function ImageEmbed({
}: CommonProps & { }: CommonProps & {
embed: EmbedType<'images'> embed: EmbedType<'images'>
}) { }) {
const ax = useAnalytics()
const {openLightbox} = useLightboxControls() const {openLightbox} = useLightboxControls()
const {images} = embed.view const {images} = embed.view
const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable)
if (images.length > 0) { if (images.length > 0) {
const items = images.map(img => ({ const items = images.map(img => ({
@@ -95,6 +99,19 @@ export function ImageEmbed({
) )
} }
if (galleryEnabled) {
return (
<View style={[a.mt_sm, rest.style]}>
<Gallery
images={images}
onPress={onPress}
onPressIn={onPressIn}
viewContext={rest.viewContext}
/>
</View>
)
}
return ( return (
<View style={[a.mt_sm, rest.style]}> <View style={[a.mt_sm, rest.style]}>
<ImageLayoutGrid <ImageLayoutGrid
+4
View File
@@ -19,6 +19,7 @@ import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta' import {PostMeta} from '#/view/com/util/PostMeta'
import {atoms as a, useTheme} from '#/alf' import {atoms as a, useTheme} from '#/alf'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {GalleryBleed} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider' import {ContentHider} from '#/components/moderation/ContentHider'
import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostAlerts} from '#/components/moderation/PostAlerts'
import {RichText} from '#/components/RichText' import {RichText} from '#/components/RichText'
@@ -308,6 +309,7 @@ export function QuoteEmbed({
<Embed <Embed
embed={quote.embed} embed={quote.embed}
moderation={moderation} moderation={moderation}
viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia}
isWithinQuote={parentIsWithinQuote ?? true} isWithinQuote={parentIsWithinQuote ?? true}
// already within quote? override nested // already within quote? override nested
allowNestedQuotes={ allowNestedQuotes={
@@ -319,6 +321,7 @@ export function QuoteEmbed({
) )
return ( return (
<GalleryBleed>
<View <View
style={[a.mt_sm]} style={[a.mt_sm]}
onPointerEnter={linkDisabled ? undefined : onPointerEnter} onPointerEnter={linkDisabled ? undefined : onPointerEnter}
@@ -357,5 +360,6 @@ export function QuoteEmbed({
)} )}
</ContentHider> </ContentHider>
</View> </View>
</GalleryBleed>
) )
} }
+3
View File
@@ -0,0 +1,3 @@
export const ITEM_GAP = 8 // tokens.space.sm
export const MIN_ASPECT_RATIO = 2 / 3 // portrait limit
export const MAX_ASPECT_RATIO = 3 / 2 // landscape limit
+531
View File
@@ -0,0 +1,531 @@
import {
cloneElement,
createContext,
isValidElement,
useContext,
useEffect,
useMemo,
useRef,
useState,
} from 'react'
import {FlatList, Pressable, useWindowDimensions, View} from 'react-native'
import Animated, {
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 debounce from 'lodash.debounce'
import {type Dimensions} from '#/lib/media/types'
import {mergeRefs} from '#/lib/merge-refs'
import {useA11y} from '#/state/a11y'
import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge'
import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture'
import {atoms as a, useBreakpoints, useTheme, web} from '#/alf'
import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal'
import {AutoSizedImage} from '#/components/images/AutoSizedImage'
import {
ITEM_GAP,
MAX_ASPECT_RATIO,
MIN_ASPECT_RATIO,
} from '#/components/images/Gallery/const'
import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers'
import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers'
import {getAspectRatio} from '#/components/images/Gallery/utils'
import {MediaInsetBorder} from '#/components/MediaInsetBorder'
import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {Text} from '#/components/Typography'
import {useAnalytics} from '#/analytics'
import {IS_WEB} from '#/env'
export * from './const'
export * from './maybeApplyGalleryOffsetStyles'
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)
node.props.onLayout?.(e)
},
style: [node.props.style, a.overflow_hidden],
})}
</Context.Provider>
)
}
export function useGalleryBleed() {
return useContext(Context)
}
export function Gallery({
images,
onPress,
onPressIn,
viewContext,
}: GalleryProps) {
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])
const isWithinQuote =
viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia
const hideBadges = isWithinQuote
/*
* 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 [contentDims, setContentDims] = useState<{x: number; width: number}>()
const measure = () => {
if (contentRef.current && bleedRef.current) {
contentRef.current.measureLayout(
bleedRef.current,
(x, _y, w) => {
setContentDims({x, width: w})
},
() => {},
)
}
}
const width = bleedWidth || Math.min(600, window.width)
const insetLeft = contentDims?.x ?? 0
const insetRight =
bleedWidth > 0
? bleedWidth - (contentDims?.x ?? 0) - (contentDims?.width ?? 0)
: 0
/* End container overflow styles */
const flatListRef = useRef<FlatList>(null)
const itemWidthsRef = useRef<Map<number, number>>(new Map())
const itemRefsRef = useRef<Map<number, View>>(new Map())
const containerRefsRef = useRef<Map<number, AnimatedRef<any>>>(new Map())
const thumbDimsRef = useRef<Map<number, Dimensions>>(new Map())
const currentIndexRef = useRef(0)
const emitSwipeMetric = useMemo(
() =>
debounce((fromIndex: number, toIndex: number) => {
ax.metric('post:gallery:swipe', {
fromImage: fromIndex + 1, // convert to 1-based index for easier analysis
toImage: toIndex + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
}, 200),
[ax, images.length],
)
const setCurrentIndex = (index: number) => {
const prev = currentIndexRef.current
if (prev !== index) {
currentIndexRef.current = index
emitSwipeMetric(prev, index)
}
}
const scrollTo = (offset: number) => {
flatListRef.current?.scrollToOffset({offset, animated: false})
}
const onSettle = (index: number) => {
setCurrentIndex(index)
if (!IS_WEB) return
// Update tabIndex: only the active image is tab-focusable
itemRefsRef.current.forEach((node, i) => {
const el = node as unknown as HTMLElement
el.tabIndex = i === index ? 0 : -1
})
const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null
el?.focus({preventScroll: true})
}
useKeyboardHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount: images.length,
})
usePointerHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount: images.length,
})
if (screenReaderEnabled) {
return (
<View style={[a.relative, a.gap_sm]}>
{images.map((image, index) => (
<AutoSizedImage
key={image.thumb + index}
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
ref={contentRef}
style={[
a.w_full,
{
height: contentHeight,
overflow: 'visible',
},
]}
onLayout={measure}>
<BlockDrawerGesture>
<FlatList
ref={flatListRef}
role="group"
aria-roledescription={l`carousel`}
aria-label={l`Image gallery, ${images.length} images`}
horizontal
pagingEnabled={false}
showsHorizontalScrollIndicator={false}
decelerationRate={0.993}
directionalLockEnabled
nestedScrollEnabled
alwaysBounceVertical={false}
scrollEventThrottle={16}
data={images}
keyExtractor={(item, index) => item.thumb + index}
renderItem={({item, index}) => {
return (
<GalleryImage
hideBadges={hideBadges}
largeAltBadge={largeAltBadge}
image={item}
contentHeight={contentHeight}
index={index}
imageCount={images.length}
onWidthChange={(i, w) => {
itemWidthsRef.current.set(i, w)
}}
itemRef={node => {
if (node) {
itemRefsRef.current.set(index, node)
} else {
itemRefsRef.current.delete(index)
}
}}
onContainerRef={(i, ref) => {
containerRefsRef.current.set(i, ref)
}}
onThumbDims={(i, dims) => {
thumbDimsRef.current.set(i, dims)
}}
onPress={
onPress
? () => {
ax.metric('post:gallery:openLightbox', {
fromImage: index + 1, // convert to 1-based index for easier analysis
totalImages: images.length,
})
const refs: AnimatedRef<any>[] = []
const dims: (Dimensions | null)[] = []
for (let i = 0; i < images.length; i++) {
refs.push(containerRefsRef.current.get(i)!)
dims.push(thumbDimsRef.current.get(i) ?? null)
}
onPress(index, refs, dims)
}
: undefined
}
onPressIn={onPressIn ? () => onPressIn(index) : undefined}
/>
)
}}
onScroll={e => {
// web handles via onSettle in the web hooks
if (IS_WEB) return
const offsetX = e.nativeEvent.contentOffset.x
let accumulated = 0
for (let i = 0; i < images.length; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
setCurrentIndex(i)
break
}
accumulated += w
if (i === images.length - 1) {
setCurrentIndex(i)
}
}
}}
style={[
{
height: contentHeight,
marginLeft: -insetLeft,
width,
},
]}
contentContainerStyle={{
gap: ITEM_GAP,
paddingLeft: insetLeft,
paddingRight: insetRight,
}}
/>
</BlockDrawerGesture>
</View>
)
}
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.
*
* Clamp between MIN_ASPECT_RATIO (portrait) and MAX_ASPECT_RATIO
* (landscape) so items stay a reasonable size in the carousel.
*/
const raw = aspectRatio ?? 1
const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO))
const width = Math.floor(height * clamped)
return {width, height, aspectRatio: clamped, isCropped: raw !== clamped}
}
function GalleryImage({
contentHeight: height,
image,
index,
imageCount,
onWidthChange,
itemRef,
hideBadges,
largeAltBadge,
onContainerRef,
onThumbDims,
onPress,
onPressIn,
}: {
contentHeight: number
image: AppBskyEmbedImages.ViewImage
index: number
imageCount: number
onWidthChange: (index: number, width: number) => void
itemRef: (node: View | null) => void
hideBadges?: boolean
largeAltBadge?: boolean
onContainerRef: (index: number, ref: AnimatedRef<any>) => void
onThumbDims: (index: number, dims: Dimensions) => void
onPress?: () => void
onPressIn?: () => void
}) {
const t = useTheme()
const {t: l} = useLingui()
const [focused, setFocused] = useState(false)
const containerRef = useAnimatedRef()
const [aspectRatio, setAspectRatio] = useState(() =>
getAspectRatio(image.aspectRatio),
)
const {isCropped, ...dims} = computeDims({height, aspectRatio})
const hasAlt = !!image.alt
useEffect(() => {
onWidthChange(index, dims.width)
}, [index, dims.width, onWidthChange])
useEffect(() => {
onContainerRef(index, containerRef)
}, [index, containerRef, onContainerRef])
return (
<Animated.View
ref={containerRef}
collapsable={false}
aria-roledescription={l`slide`}
aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}>
<Pressable
ref={itemRef}
tabIndex={index === 0 ? 0 : -1}
onPress={onPress}
onPressIn={onPressIn}
onFocus={() => setFocused(true)}
onBlur={() => setFocused(false)}
accessibilityRole="button"
accessibilityLabel={image.alt || l`Image ${index + 1}`}
accessibilityHint={l`Opens full image`}
android_ripple={{
color: utils.alpha(t.atoms.bg.backgroundColor, 0.2),
foreground: true,
}}
style={[
a.rounded_md,
a.overflow_hidden,
t.atoms.bg_contrast_25,
web({
cursor: 'inherit',
outline: 0,
border: 0,
}),
]}>
<Image
source={{uri: image.thumb}}
contentFit="cover"
accessible={true}
accessibilityLabel={image.alt}
accessibilityHint=""
accessibilityIgnoresInvertColors
loading={index === 0 ? 'eager' : 'lazy'}
style={[dims]}
onLoad={e => {
const ar = getAspectRatio(e.source)
if (ar && ar !== aspectRatio) {
setAspectRatio(ar)
}
onThumbDims(index, {
width: e.source.width,
height: e.source.height,
})
}}
/>
{(hasAlt || isCropped) && !hideBadges ? (
<View
accessible={false}
style={[
a.absolute,
a.flex_row,
{
bottom: a.p_xs.padding,
right: a.p_xs.padding,
gap: 3,
},
largeAltBadge && {
gap: 4,
},
]}>
{isCropped && (
<View
style={[
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Fullscreen
fill={t.atoms.text_contrast_high.color}
width={largeAltBadge ? 18 : 12}
/>
</View>
)}
{hasAlt && (
<View
style={[
a.justify_center,
a.rounded_sm,
a.p_xs,
t.atoms.bg_contrast_25,
{
opacity: 0.8,
},
largeAltBadge && {
padding: 6,
},
]}>
<Text
style={[
a.font_bold,
largeAltBadge ? a.text_xs : {fontSize: 8},
]}>
<Trans>ALT</Trans>
</Text>
</View>
)}
</View>
) : null}
<MediaInsetBorder
style={
focused && {
borderWidth: 2,
}
}
/>
</Pressable>
</Animated.View>
)
}
@@ -0,0 +1,102 @@
import {
AppBskyEmbedImages,
AppBskyEmbedRecordWithMedia,
type AppBskyFeedDefs,
AppBskyFeedPost,
type ModerationCause,
type ModerationUI,
} from '@atproto/api'
import {unique} from '#/lib/moderation'
import {type AppModerationCause} from '#/components/Pills'
import {Features, features} from '#/analytics/features'
import * as bsky from '#/types/bsky'
export const POST_META_NO_CONTENT_OFFSET = {paddingTop: 10}
export const POST_EMBED_NO_CONTENT_OFFSET = {paddingTop: 6}
export function maybeApplyGalleryOffsetStyles(
placement: 'meta' | 'embed',
{
post,
modui,
additionalCauses,
}: {
post: AppBskyFeedDefs.PostView
modui: ModerationUI
additionalCauses?: ModerationCause[] | AppModerationCause[]
},
) {
// don't ever check gates like this, except this one time
if (!features.isOn(Features.PostGalleryEmbedEnable)) return
if (
!bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
) {
return
}
/*
* First check if we even have images
*/
const embed = post.record.embed
const isImageEmbed =
embed &&
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
embed,
AppBskyEmbedImages.isMain,
)
const isRecordWithMedia =
embed &&
bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>(
embed,
AppBskyEmbedRecordWithMedia.isMain,
)
let hasImages = false
if (isImageEmbed) {
// one image, not a gallery
if (embed.images.length === 1) return
hasImages = true
}
if (isRecordWithMedia) {
if (
bsky.dangerousIsType<AppBskyEmbedImages.Main>(
embed.media,
AppBskyEmbedImages.isMain,
)
) {
// one image, not a gallery
if (embed.media.images.length === 1) return
}
hasImages = true
}
if (!hasImages) return
/*
* Then check if we have any text
*/
let hasLabels = false
if (modui.alert) {
hasLabels = modui.alerts.filter(unique).length > 0
}
if (modui.inform) {
hasLabels = hasLabels || modui.informs.filter(unique).length > 0
}
if (additionalCauses?.length) {
hasLabels = true
}
/*
* If no text or labels, then we need a lil bump
*/
const shouldApplyOffset = !post.record.text && !hasLabels
return shouldApplyOffset
? placement === 'meta'
? POST_META_NO_CONTENT_OFFSET
: POST_EMBED_NO_CONTENT_OFFSET
: {}
}
+40
View File
@@ -0,0 +1,40 @@
function ease(t: number, b: number, c: number, d: number) {
return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b
}
/**
* Tween from `start` to `end` over `duration` ms using an exponential ease-out.
* Returns a function that starts the tween. That function returns a stop handle.
*
* Adapted from tinkerbell.
*/
export function tween(start: number, end: number, duration: number) {
return function run(cb: (v: number) => void, done?: () => void) {
let ts: number | undefined
let frame: number
frame = (function tick(last: number) {
return requestAnimationFrame(t => {
if (!ts) ts = t
const te = t - ts
const next = Math.round(ease(te, start, end - start, duration))
if (
(end > start
? next < end && last <= end
: next > end && last >= end) &&
te <= duration
) {
frame = tick(next)
cb(next)
} else {
cb(end)
done?.()
}
})
})(start)
return function stop() {
cancelAnimationFrame(frame)
}
}
}
@@ -0,0 +1,8 @@
export function useKeyboardHandlers(_args: {
flatListRef: any
itemWidthsRef: any
currentIndexRef: any
scrollTo: any
onSettle: any
imageCount: any
}) {}
@@ -0,0 +1,91 @@
import {useEffect} from 'react'
import {type FlatList} from 'react-native'
import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
const SETTLE_DURATION = 700
export function useKeyboardHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
}: {
flatListRef: React.RefObject<FlatList | null>
itemWidthsRef: React.RefObject<Map<number, number>>
currentIndexRef: React.RefObject<number>
scrollTo: (offset: number) => void
onSettle: (index: number) => void
imageCount: number
}) {
useEffect(() => {
if (imageCount <= 1) return
let stopTween: (() => void) | null = null
let pendingIndex: number | null = null
const onKeyDown = (e: KeyboardEvent) => {
const el =
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
if (!el || !el.contains(document.activeElement)) return
const current = pendingIndex ?? currentIndexRef.current
let targetIndex: number | undefined
if (e.key === 'ArrowRight') {
if (current < imageCount - 1) {
targetIndex = current + 1
}
} else if (e.key === 'ArrowLeft') {
if (current > 0) {
targetIndex = current - 1
}
}
if (targetIndex != null) {
e.preventDefault()
if (stopTween) {
stopTween()
stopTween = null
}
pendingIndex = targetIndex
const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
const idx = targetIndex
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
pendingIndex = null
onSettle(idx)
},
)
}
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
if (stopTween) stopTween()
}
}, [
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
])
}
@@ -0,0 +1,8 @@
export function usePointerHandlers(_args: {
flatListRef: any
itemWidthsRef: any
currentIndexRef: any
scrollTo: any
onSettle: any
imageCount: any
}) {}
@@ -0,0 +1,270 @@
import {useEffect} from 'react'
import {type FlatList} from 'react-native'
import {ITEM_GAP} from '#/components/images/Gallery/const'
import {tween} from '#/components/images/Gallery/tween'
import {getOffsetForIndex} from '#/components/images/Gallery/utils'
const DRAG_THRESHOLD = 3
const FLICK_DECAY = 0.85
const FLICK_MIN_VELOCITY = 0.1
const ADVANCE_THRESHOLD = 0.15
const FRAME_MS = 1000 / 60
const SETTLE_DURATION = 700
const OVERSCROLL_RESISTANCE = 0.4
const BOUNCE_DURATION = 700
function whichByDistance(
itemWidths: Map<number, number>,
currentIndex: number,
distance: number,
direction: -1 | 1,
imageCount: number,
): number {
let remaining = distance
let i = currentIndex
while (remaining > 0 && i >= 0 && i < imageCount) {
const w = (itemWidths.get(i) ?? 0) + ITEM_GAP
if (remaining > w) {
remaining -= w
i -= direction
} else if (remaining > w * ADVANCE_THRESHOLD) {
i -= direction
break
} else {
break
}
}
return Math.max(0, Math.min(i, imageCount - 1))
}
export function usePointerHandlers({
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
}: {
flatListRef: React.RefObject<FlatList | null>
itemWidthsRef: React.RefObject<Map<number, number>>
currentIndexRef: React.RefObject<number>
scrollTo: (offset: number) => void
onSettle: (index: number) => void
imageCount: number
}) {
useEffect(() => {
if (imageCount <= 1) return
const el =
flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null
if (!el) return
let isDragging = false
let isMouseDown = false
let startX = 0
let dragScrollLeft = 0
let delta = 0
let prevDelta = 0
let velo = 0
let t = 0
let stopTween: (() => void) | null = null
let localIndex = currentIndexRef.current
let overscrollX = 0
el.style.cursor = 'grab'
const clearOverscroll = () => {
overscrollX = 0
el.style.transform = ''
}
const onMouseDown = (e: MouseEvent) => {
e.preventDefault() // prevent native image drag
// Cancel any in-progress tween
if (stopTween) {
stopTween()
stopTween = null
}
clearOverscroll()
isMouseDown = true
isDragging = false
localIndex = currentIndexRef.current
startX = e.pageX
dragScrollLeft = el.scrollLeft
delta = 0
prevDelta = 0
velo = 0
t = e.timeStamp
}
const onMouseMove = (e: MouseEvent) => {
if (!isMouseDown) return
const x = e.pageX - startX
// Require minimum movement before starting drag
if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return
if (!isDragging) {
isDragging = true
el.style.cursor = 'grabbing'
el.style.userSelect = 'none'
// Blur focused element within the gallery
if (el.contains(document.activeElement)) {
;(document.activeElement as HTMLElement)?.blur?.()
}
}
e.preventDefault()
// Track velocity
const elapsed = e.timeStamp - t || 1
prevDelta = delta
delta = x
velo = (delta - prevDelta) / (elapsed * FRAME_MS)
t = e.timeStamp
const desiredScroll = dragScrollLeft - delta
const maxScroll = el.scrollWidth - el.clientWidth
if (desiredScroll < 0) {
// Overscroll at start — rubber band
scrollTo(0)
overscrollX = desiredScroll * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else if (desiredScroll > maxScroll) {
// Overscroll at end — rubber band
scrollTo(maxScroll)
overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE
el.style.transform = `translateX(${-overscrollX}px)`
} else {
// Normal scroll range
scrollTo(desiredScroll)
if (overscrollX !== 0) clearOverscroll()
}
// Update local index from scroll position (only in normal range)
if (overscrollX === 0) {
const offsetX = desiredScroll
let accumulated = 0
for (let i = 0; i < imageCount; i++) {
const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP
if (offsetX < accumulated + w / 2) {
localIndex = i
break
}
accumulated += w
if (i === imageCount - 1) localIndex = i
}
}
}
const onMouseUp = () => {
if (!isMouseDown) return
const wasDragging = isDragging
isMouseDown = false
isDragging = false
el.style.cursor = 'grab'
el.style.userSelect = ''
if (wasDragging) {
// Suppress the click that follows mouseup after a drag
el.addEventListener('click', e => e.stopPropagation(), {
once: true,
capture: true,
})
if (overscrollX !== 0) {
// Bounce back from overscroll
const targetIndex = overscrollX > 0 ? imageCount - 1 : 0
const fromOverscroll = overscrollX
stopTween = tween(
fromOverscroll,
0,
BOUNCE_DURATION,
)(
v => {
el.style.transform = `translateX(${-v}px)`
},
() => {
stopTween = null
clearOverscroll()
onSettle(targetIndex)
},
)
} else {
// Normal flick settle
let v = Math.abs(velo)
let restingDistance = 0
while (v > FLICK_MIN_VELOCITY) {
v *= FLICK_DECAY
restingDistance += v
}
const direction: -1 | 1 = delta < 0 ? -1 : 1
const totalDistance = Math.abs(delta) + restingDistance
const targetIndex = whichByDistance(
itemWidthsRef.current,
localIndex,
totalDistance,
direction,
imageCount,
)
const from = el.scrollLeft
const to = getOffsetForIndex(itemWidthsRef.current, targetIndex)
if (from === to) {
onSettle(targetIndex)
return
}
stopTween = tween(
from,
to,
SETTLE_DURATION,
)(
v => {
scrollTo(v)
},
() => {
stopTween = null
onSettle(targetIndex)
},
)
}
}
}
el.addEventListener('mousedown', onMouseDown)
window.addEventListener('mousemove', onMouseMove)
window.addEventListener('mouseup', onMouseUp)
return () => {
el.removeEventListener('mousedown', onMouseDown)
window.removeEventListener('mousemove', onMouseMove)
window.removeEventListener('mouseup', onMouseUp)
if (stopTween) stopTween()
clearOverscroll()
el.style.cursor = ''
el.style.userSelect = ''
}
}, [
flatListRef,
itemWidthsRef,
currentIndexRef,
scrollTo,
onSettle,
imageCount,
])
}
+22
View File
@@ -0,0 +1,22 @@
import {ITEM_GAP} from '#/components/images/Gallery/const'
export function getOffsetForIndex(
itemWidths: Map<number, number>,
index: number,
): number {
let offset = 0
for (let i = 0; i < index; i++) {
offset += (itemWidths.get(i) ?? 0) + ITEM_GAP
}
return offset
}
export function getAspectRatio({
width,
height,
}: {width?: number; height?: number} = {}) {
if (width && width > 0 && height && height > 0) {
return width / height
}
return undefined
}
+1 -1
View File
@@ -6,7 +6,7 @@ import {type AppBskyEmbedImages} from '@atproto/api'
import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types'
import {atoms as a, useBreakpoints} from '#/alf' import {atoms as a, useBreakpoints} from '#/alf'
import {PostEmbedViewContext} from '#/components/Post/Embed/types' import {PostEmbedViewContext} from '#/components/Post/Embed/types'
import {GalleryItem} from './Gallery' import {GalleryItem} from './ImageLayoutGridItem'
interface ImageLayoutGridProps { interface ImageLayoutGridProps {
images: AppBskyEmbedImages.ViewImage[] images: AppBskyEmbedImages.ViewImage[]
@@ -39,6 +39,7 @@ import {Button} from '#/components/Button'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {GalleryBleed} from '#/components/images/Gallery'
import {Link} from '#/components/Link' import {Link} from '#/components/Link'
import {ContentHider} from '#/components/moderation/ContentHider' import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
@@ -308,6 +309,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
return ( return (
<> <>
<ThreadItemAnchorParentReplyLine isRoot={isRoot} /> <ThreadItemAnchorParentReplyLine isRoot={isRoot} />
<GalleryBleed>
<View <View
testID={`postThreadItem-by-${post.author.handle}`} testID={`postThreadItem-by-${post.author.handle}`}
style={[ style={[
@@ -406,7 +408,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
) : undefined} ) : undefined}
<TranslatedPost post={post} postTextStyle={[a.text_lg]} /> <TranslatedPost post={post} postTextStyle={[a.text_lg]} />
{post.embed && ( {post.embed && (
<View style={[a.py_xs]}> <View style={[richText?.text ? a.py_xs : []]}>
<Embed <Embed
embed={post.embed} embed={post.embed}
moderation={moderation} moderation={moderation}
@@ -446,7 +448,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
testID="repostCount-expanded" testID="repostCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)"> <Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> <Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.repostCount)} {formatPostStatCount(post.repostCount)}
</Text>{' '} </Text>{' '}
<Plural <Plural
@@ -466,7 +469,8 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
testID="quoteCount-expanded" testID="quoteCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)"> <Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> <Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.quoteCount)} {formatPostStatCount(post.quoteCount)}
</Text>{' '} </Text>{' '}
<Plural <Plural
@@ -484,10 +488,15 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
testID="likeCount-expanded" testID="likeCount-expanded"
style={[a.text_md, t.atoms.text_contrast_medium]}> style={[a.text_md, t.atoms.text_contrast_medium]}>
<Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)"> <Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)">
<Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> <Text
style={[a.text_md, a.font_semi_bold, t.atoms.text]}>
{formatPostStatCount(post.likeCount)} {formatPostStatCount(post.likeCount)}
</Text>{' '} </Text>{' '}
<Plural value={post.likeCount} one="like" other="likes" /> <Plural
value={post.likeCount}
one="like"
other="likes"
/>
</Trans> </Trans>
</Text> </Text>
</Link> </Link>
@@ -536,6 +545,7 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({
<DebugFieldDisplay subject={post} /> <DebugFieldDisplay subject={post} />
</View> </View>
</View> </View>
</GalleryBleed>
</> </>
) )
}) })
@@ -32,6 +32,10 @@ import {atoms as a, useTheme} from '#/alf'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {
GalleryBleed,
maybeApplyGalleryOffsetStyles,
} from '#/components/images/Gallery'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider' import {PostHider} from '#/components/moderation/PostHider'
@@ -131,6 +135,7 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
!item.ui.showParentReplyLine && overrides?.topBorder !== true !item.ui.showParentReplyLine && overrides?.topBorder !== true
return ( return (
<GalleryBleed>
<View <View
style={[ style={[
showTopBorder && [a.border_t, t.atoms.border_contrast_low], showTopBorder && [a.border_t, t.atoms.border_contrast_low],
@@ -143,6 +148,7 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({
]}> ]}>
{children} {children}
</View> </View>
</GalleryBleed>
) )
}) })
@@ -295,7 +301,14 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
moderation={moderation} moderation={moderation}
timestamp={post.indexedAt} timestamp={post.indexedAt}
postHref={postHref} postHref={postHref}
style={[a.pb_xs]} style={[
a.pb_xs,
maybeApplyGalleryOffsetStyles('meta', {
post,
modui: moderation.ui('contentList'),
additionalCauses: additionalPostAlerts,
}),
]}
/> />
<LabelsOnMyPost post={post} style={[a.pb_xs]} /> <LabelsOnMyPost post={post} style={[a.pb_xs]} />
<PostAlerts <PostAlerts
@@ -323,7 +336,15 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({
) : undefined} ) : undefined}
<TranslatedPost hideTranslateLink post={post} /> <TranslatedPost hideTranslateLink post={post} />
{post.embed && ( {post.embed && (
<View style={[a.pb_xs]}> <View
style={[
maybeApplyGalleryOffsetStyles('embed', {
post,
modui: moderation.ui('contentList'),
additionalCauses: additionalPostAlerts,
}),
a.pb_xs,
]}>
<Embed <Embed
embed={post.embed} embed={post.embed}
moderation={moderation} moderation={moderation}
@@ -32,6 +32,7 @@ import {atoms as a, useTheme} from '#/alf'
import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay'
import {useInteractionState} from '#/components/hooks/useInteractionState' import {useInteractionState} from '#/components/hooks/useInteractionState'
import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash'
import {GalleryBleed} from '#/components/images/Gallery'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostAlerts} from '#/components/moderation/PostAlerts'
import {PostHider} from '#/components/moderation/PostHider' import {PostHider} from '#/components/moderation/PostHider'
@@ -129,6 +130,7 @@ const ThreadItemTreePostOuterWrapper = memo(
const indents = Math.max(0, item.ui.indent - 1) const indents = Math.max(0, item.ui.indent - 1)
return ( return (
<GalleryBleed>
<View <View
style={[ style={[
a.flex_row, a.flex_row,
@@ -156,6 +158,7 @@ const ThreadItemTreePostOuterWrapper = memo(
})} })}
{children} {children}
</View> </View>
</GalleryBleed>
) )
}, },
) )
+22 -1
View File
@@ -27,6 +27,10 @@ import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta' import {PostMeta} from '#/view/com/util/PostMeta'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {
GalleryBleed,
maybeApplyGalleryOffsetStyles,
} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider' import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -155,6 +159,7 @@ function PostInner({
const [hover, setHover] = useState(false) const [hover, setHover] = useState(false)
return ( return (
<GalleryBleed>
<Link <Link
href={itemHref} href={itemHref}
style={[ style={[
@@ -181,7 +186,15 @@ function PostInner({
type={post.author.associated?.labeler ? 'labeler' : 'user'} type={post.author.associated?.labeler ? 'labeler' : 'user'}
/> />
</View> </View>
<View style={styles.layoutContent}> <View
style={[
styles.layoutContent,
maybeApplyGalleryOffsetStyles('meta', {
post,
modui: moderation.ui('contentList'),
additionalCauses: [],
}),
]}>
<PostMeta <PostMeta
author={post.author} author={post.author}
moderation={moderation} moderation={moderation}
@@ -221,11 +234,18 @@ function PostInner({
) : undefined} ) : undefined}
<TranslatedPost hideTranslateLink post={post} /> <TranslatedPost hideTranslateLink post={post} />
{post.embed ? ( {post.embed ? (
<View
style={maybeApplyGalleryOffsetStyles('embed', {
post,
modui: moderation.ui('contentList'),
additionalCauses: [],
})}>
<Embed <Embed
embed={post.embed} embed={post.embed}
moderation={moderation} moderation={moderation}
viewContext={PostEmbedViewContext.Feed} viewContext={PostEmbedViewContext.Feed}
/> />
</View>
) : null} ) : null}
</ContentHider> </ContentHider>
<PostControls <PostControls
@@ -238,6 +258,7 @@ function PostInner({
</View> </View>
</View> </View>
</Link> </Link>
</GalleryBleed>
) )
} }
+52 -33
View File
@@ -34,6 +34,10 @@ import {Link} from '#/view/com/util/Link'
import {PostMeta} from '#/view/com/util/PostMeta' import {PostMeta} from '#/view/com/util/PostMeta'
import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar'
import {atoms as a} from '#/alf' import {atoms as a} from '#/alf'
import {
GalleryBleed,
maybeApplyGalleryOffsetStyles,
} from '#/components/images/Gallery'
import {ContentHider} from '#/components/moderation/ContentHider' import {ContentHider} from '#/components/moderation/ContentHider'
import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe'
import {PostAlerts} from '#/components/moderation/PostAlerts' import {PostAlerts} from '#/components/moderation/PostAlerts'
@@ -163,6 +167,7 @@ let FeedItemInner = ({
const queryClient = useQueryClient() const queryClient = useQueryClient()
const {openComposer} = useOpenComposer() const {openComposer} = useOpenComposer()
const pal = usePalette('default') const pal = usePalette('default')
const {currentAccount} = useSession()
const [hover, setHover] = useState(false) const [hover, setHover] = useState(false)
@@ -293,7 +298,32 @@ let FeedItemInner = ({
} }
}, [reason]) }, [reason])
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
? post.record?.reply?.root?.uri || post.uri
: undefined
const isControlledByViewer =
rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did
return isControlledByViewer && isPostHiddenByThreadgate
? [
{
type: 'reply-hidden',
source: {type: 'user', did: currentAccount?.did},
priority: 6,
},
]
: []
}, [post, currentAccount?.did, threadgateHiddenReplies])
return ( return (
<GalleryBleed>
<Link <Link
testID={`feedItem-by-${post.author.handle}`} testID={`feedItem-by-${post.author.handle}`}
style={outerStyles} style={outerStyles}
@@ -359,7 +389,15 @@ let FeedItemInner = ({
/> />
)} )}
</View> </View>
<View style={styles.layoutContent}> <View
style={[
styles.layoutContent,
maybeApplyGalleryOffsetStyles('meta', {
post,
modui: moderation.ui('contentList'),
additionalCauses: additionalPostAlerts,
}),
]}>
<PostMeta <PostMeta
author={post.author} author={post.author}
moderation={moderation} moderation={moderation}
@@ -383,7 +421,7 @@ let FeedItemInner = ({
postAuthor={post.author} postAuthor={post.author}
onOpenEmbed={onOpenEmbed} onOpenEmbed={onOpenEmbed}
post={post} post={post}
threadgateRecord={threadgateRecord} additionalPostAlerts={additionalPostAlerts}
/> />
<PostControls <PostControls
post={post} post={post}
@@ -402,6 +440,7 @@ let FeedItemInner = ({
<DiscoverDebug feedContext={feedContext} /> <DiscoverDebug feedContext={feedContext} />
</View> </View>
</Link> </Link>
</GalleryBleed>
) )
} }
FeedItemInner = memo(FeedItemInner) FeedItemInner = memo(FeedItemInner)
@@ -413,7 +452,7 @@ let PostContent = ({
postEmbed, postEmbed,
postAuthor, postAuthor,
onOpenEmbed, onOpenEmbed,
threadgateRecord, additionalPostAlerts,
}: { }: {
moderation: ModerationDecision moderation: ModerationDecision
richText: RichTextAPI richText: RichTextAPI
@@ -421,35 +460,11 @@ let PostContent = ({
postAuthor: AppBskyFeedDefs.PostView['author'] postAuthor: AppBskyFeedDefs.PostView['author']
onOpenEmbed: () => void onOpenEmbed: () => void
post: AppBskyFeedDefs.PostView post: AppBskyFeedDefs.PostView
threadgateRecord?: AppBskyFeedThreadgate.Record additionalPostAlerts?: AppModerationCause[]
}): React.ReactNode => { }): React.ReactNode => {
const {currentAccount} = useSession()
const [limitLines, setLimitLines] = useState( const [limitLines, setLimitLines] = useState(
() => countLines(richText.text) >= MAX_POST_LINES, () => countLines(richText.text) >= MAX_POST_LINES,
) )
const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
threadgateRecord,
})
const additionalPostAlerts: AppModerationCause[] = useMemo(() => {
const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri)
const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>(
post.record,
AppBskyFeedPost.isRecord,
)
? post.record?.reply?.root?.uri || post.uri
: undefined
const isControlledByViewer =
rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did
return isControlledByViewer && isPostHiddenByThreadgate
? [
{
type: 'reply-hidden',
source: {type: 'user', did: currentAccount?.did},
priority: 6,
},
]
: []
}, [post, currentAccount?.did, threadgateHiddenReplies])
const record = useMemo<AppBskyFeedPost.Record | undefined>( const record = useMemo<AppBskyFeedPost.Record | undefined>(
() => () =>
@@ -492,7 +507,15 @@ let PostContent = ({
) : undefined} ) : undefined}
{record && <TranslatedPost hideTranslateLink post={post} />} {record && <TranslatedPost hideTranslateLink post={post} />}
{postEmbed ? ( {postEmbed ? (
<View style={[a.pb_xs]}> <View
style={[
a.pb_xs,
maybeApplyGalleryOffsetStyles('embed', {
post,
modui: moderation.ui('contentList'),
additionalCauses: additionalPostAlerts,
}),
]}>
<Embed <Embed
embed={postEmbed} embed={postEmbed}
moderation={moderation} moderation={moderation}
@@ -524,13 +547,9 @@ const styles = StyleSheet.create({
layoutAvi: { layoutAvi: {
paddingLeft: 8, paddingLeft: 8,
paddingRight: 10, paddingRight: 10,
position: 'relative',
zIndex: 999,
}, },
layoutContent: { layoutContent: {
position: 'relative',
flex: 1, flex: 1,
zIndex: 0,
}, },
alert: { alert: {
marginTop: 6, marginTop: 6,