[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:
@@ -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',
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
: {}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
])
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
Reference in New Issue
Block a user