diff --git a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx index 8edf6e8cc..ca539cf93 100644 --- a/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx +++ b/src/components/Post/Embed/VideoEmbed/VideoEmbedInner/web-controls/VolumeControl.tsx @@ -78,6 +78,7 @@ export function VolumeControl({ max={100} value={sliderVolume} aria-label={_(msg`Volume`)} + data-no-carousel-drag="" style={ // Ridiculous safari hack for old version of safari. Fixed in sonoma beta -h IS_WEB_SAFARI diff --git a/src/components/ProgressGuide/List.tsx b/src/components/ProgressGuide/List.tsx index d6b048044..74d243a29 100644 --- a/src/components/ProgressGuide/List.tsx +++ b/src/components/ProgressGuide/List.tsx @@ -9,6 +9,7 @@ import {msg} from '@lingui/core/macro' import {useLingui} from '@lingui/react' import {Trans} from '@lingui/react/macro' +import {useEnableSquareAvatars} from '#/state/preferences/enable-square-avatars' import {useProfileFollowsQuery} from '#/state/queries/profile-follows' import {useSession} from '#/state/session' import { @@ -126,6 +127,7 @@ export function ProgressGuideList({style}: {style?: StyleProp}) { function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) { const t = useTheme() + const enableSquareAvatars = useEnableSquareAvatars() const [containerWidth, setContainerWidth] = useState(0) const onLayout = (e: LayoutChangeEvent) => { @@ -142,6 +144,11 @@ function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) { const avatarSize = containerWidth > 0 ? containerWidth / visiblePortions : 0 const overlap = avatarSize * overlapRatio const iconSize = avatarSize * 0.5 + const borderRadius = enableSquareAvatars + ? avatarSize > 32 + ? 8 + : 3 + : 999 const followedAvatars = follows?.slice(0, TOTAL_AVATARS) ?? [] const remainingSlots = TOTAL_AVATARS - followedAvatars.length @@ -155,10 +162,10 @@ function StackedAvatars({follows}: {follows?: bsky.profile.AnyProfileView[]}) { { overscrollX = 0 @@ -83,7 +86,41 @@ export function usePointerHandlers({ } const onMouseDown = (e: MouseEvent) => { + const target = e.target + if (!(target instanceof Element)) return + + /* + * Don't hijack form controls (volume slider, etc.) or buttons. Without + * this, preventDefault breaks range inputs and drag becomes a carousel + * swipe instead of adjusting the control. + */ + if ( + target.closest( + 'input, textarea, select, button, [role="slider"], [role="button"], [data-no-carousel-drag]', + ) + ) { + return + } + + /* + * If a nested carousel scroll surface sits between the target and this + * node, let that carousel own the gesture (inner media gallery vs outer + * repost carousel). + */ + let node: Element | null = target + while (node && node !== el) { + if ( + node instanceof HTMLElement && + node !== el && + node.dataset.carouselScroll != null + ) { + return + } + node = node.parentElement + } + e.preventDefault() // prevent native image drag + e.stopPropagation() // Cancel any in-progress tween if (stopTween) { @@ -311,6 +348,7 @@ export function usePointerHandlers({ clearOverscroll() el.style.cursor = '' el.style.userSelect = '' + delete el.dataset.carouselScroll } }, [ flatListRef, diff --git a/src/view/com/posts/PostFeedItemCarousel.tsx b/src/view/com/posts/PostFeedItemCarousel.tsx index c3a5c1165..1a90d3cb2 100644 --- a/src/view/com/posts/PostFeedItemCarousel.tsx +++ b/src/view/com/posts/PostFeedItemCarousel.tsx @@ -1,5 +1,5 @@ import {useCallback, useRef} from 'react' -import {FlatList, ScrollView, View} from 'react-native' +import {type FlatList, ScrollView, View} from 'react-native' import {Plural, useLingui} from '@lingui/react/macro' import {useEnableSquareButtons} from '#/state/preferences/enable-square-buttons' @@ -11,13 +11,14 @@ import { ChevronLeft_Stroke2_Corner0_Rounded as ChevronLeft, ChevronRight_Stroke2_Corner0_Rounded as ChevronRight, } from '#/components/icons/Chevron' +import {GalleryBleed} from '#/components/images/Gallery' import {ITEM_GAP} from '#/components/images/Gallery/const' import {tween} from '#/components/images/Gallery/tween' import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers' import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers' import {getOffsetForIndex} from '#/components/images/Gallery/utils' import {Text} from '#/components/Typography' -import {IS_ANDROID, IS_WEB} from '#/env' +import {IS_WEB} from '#/env' import {PostFeedItem} from './PostFeedItem' const CARD_WIDTH = 320 @@ -34,37 +35,42 @@ function RepostCard({ const t = useTheme() const item = slice.items[0] + /* + * GalleryBleed sizes nested image carousels to this card instead of the + * viewport. It also applies overflow: hidden for border-radius clipping. + */ return ( - - - + + + + + ) } @@ -186,14 +192,18 @@ function RepostCarouselNative({items}: {items: FeedPostSlice[]}) { function RepostCarouselWeb({items}: {items: FeedPostSlice[]}) { const {t: l} = useLingui() - const flatListRef = useRef(null) + const scrollRef = useRef(null) const itemWidthsRef = useRef>(new Map()) const itemRefsRef = useRef>(new Map()) const currentIndexRef = useRef(0) const stopTweenRef = useRef<(() => void) | null>(null) const scrollTo = useCallback((offset: number) => { - flatListRef.current?.scrollToOffset({offset, animated: false}) + const el = + scrollRef.current?.getScrollableNode() as unknown as HTMLElement | null + if (el) { + el.scrollLeft = offset + } }, []) const onSettle = useCallback((index: number) => { @@ -209,7 +219,7 @@ function RepostCarouselWeb({items}: {items: FeedPostSlice[]}) { const scrollToIndex = useCallback( (index: number) => { const el = - flatListRef.current?.getScrollableNode() as unknown as + scrollRef.current?.getScrollableNode() as unknown as | HTMLElement | null if (!el) return @@ -219,6 +229,12 @@ function RepostCarouselWeb({items}: {items: FeedPostSlice[]}) { stopTweenRef.current = null } + /* + * Update the index immediately (matching native) so rapid arrow presses + * advance past an in-flight settle instead of re-targeting the same slide. + */ + currentIndexRef.current = index + const from = el.scrollLeft const to = getOffsetForIndex(itemWidthsRef.current, index) if (from === to) { @@ -255,8 +271,13 @@ function RepostCarouselWeb({items}: {items: FeedPostSlice[]}) { scrollToIndex(next) }, [items.length, scrollToIndex]) + /* + * Use ScrollView (not FlatList) so nested media Galleries can keep their own + * horizontal FlatList. Nested VirtualizedLists break inner carousels into a + * vertical stack and stretch sibling cards to the tallest item. + */ useKeyboardHandlers({ - flatListRef, + flatListRef: scrollRef as unknown as React.RefObject, itemWidthsRef, currentIndexRef, scrollTo, @@ -265,7 +286,7 @@ function RepostCarouselWeb({items}: {items: FeedPostSlice[]}) { }) usePointerHandlers({ - flatListRef, + flatListRef: scrollRef as unknown as React.RefObject, itemWidthsRef, currentIndexRef, scrollTo, @@ -282,45 +303,44 @@ function RepostCarouselWeb({items}: {items: FeedPostSlice[]}) { /> - slice.items[0]._reactKey} - renderItem={({item: slice, index}) => { - itemWidthsRef.current.set(index, CARD_WIDTH) - - return ( - { - if (node) { - itemRefsRef.current.set(index, node) - } else { - itemRefsRef.current.delete(index) - } - }} - /> - ) - }} style={[a.w_full, web({overscrollBehaviorX: 'contain'})]} contentContainerStyle={[ a.px_md, a.pt_sm, a.pb_lg, - {gap: ITEM_GAP, alignItems: 'flex-start'}, - ]} - /> + ]}> + + {items.map((slice, index) => { + itemWidthsRef.current.set(index, CARD_WIDTH) + return ( + { + if (node) { + itemRefsRef.current.set(index, node) + } else { + itemRefsRef.current.delete(index) + } + }} + /> + ) + })} + +