diff --git a/src/analytics/features/types.ts b/src/analytics/features/types.ts index b86c62e5ba..70cb8fc38d 100644 --- a/src/analytics/features/types.ts +++ b/src/analytics/features/types.ts @@ -13,6 +13,7 @@ export enum Features { ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled', GroupChatsEnable = 'group_chats:enable', DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', + PostGalleryEmbedEnable = 'post_gallery_embed:enable', AATest = 'aa-test', } diff --git a/src/analytics/metrics/types.ts b/src/analytics/metrics/types.ts index ccb256e219..1d74e1c5b8 100644 --- a/src/analytics/metrics/types.ts +++ b/src/analytics/metrics/types.ts @@ -1046,4 +1046,19 @@ export type Events = { 'profile:associated:germ:click-self-info': {} 'profile:associated:germ:self-disconnect': {} 'profile:associated:germ:self-reconnect': {} + + // Gallery carousel events + 'post:gallery:swipe': { + fromImage: number + toImage: number + totalImages: number + } + 'post:gallery:openLightbox': { + fromImage: number + totalImages: number + } + 'post:gallery:impression': { + totalImages: number + postUri: string + } } diff --git a/src/components/Post/Embed/ImageEmbed.tsx b/src/components/Post/Embed/ImageEmbed.tsx index a3f0d46377..fba3256d56 100644 --- a/src/components/Post/Embed/ImageEmbed.tsx +++ b/src/components/Post/Embed/ImageEmbed.tsx @@ -12,8 +12,10 @@ import {useLightboxControls} from '#/state/lightbox' import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' import {atoms as a} from '#/alf' import {AutoSizedImage} from '#/components/images/AutoSizedImage' +import {Gallery} from '#/components/images/Gallery' import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' import {PostEmbedViewContext} from '#/components/Post/Embed/types' +import {useAnalytics} from '#/analytics' import {type EmbedType} from '#/types/bsky/post' import {type CommonProps} from './types' @@ -23,8 +25,10 @@ export function ImageEmbed({ }: CommonProps & { embed: EmbedType<'images'> }) { + const ax = useAnalytics() const {openLightbox} = useLightboxControls() const {images} = embed.view + const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) if (images.length > 0) { const items = images.map(img => ({ @@ -95,6 +99,19 @@ export function ImageEmbed({ ) } + if (galleryEnabled) { + return ( + + + + ) + } + return ( - - {({active}) => ( - <> - {!active && !linkDisabled && ( - - )} - {linkDisabled ? ( - - {contents} - - ) : ( - - {contents} - - )} - - )} - - + + + + {({active}) => ( + <> + {!active && !linkDisabled && ( + + )} + {linkDisabled ? ( + + {contents} + + ) : ( + + {contents} + + )} + + )} + + + ) } diff --git a/src/components/images/Gallery/const.ts b/src/components/images/Gallery/const.ts new file mode 100644 index 0000000000..443b65ecb8 --- /dev/null +++ b/src/components/images/Gallery/const.ts @@ -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 diff --git a/src/components/images/Gallery/index.tsx b/src/components/images/Gallery/index.tsx new file mode 100644 index 0000000000..7c3a84e9d0 --- /dev/null +++ b/src/components/images/Gallery/index.tsx @@ -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[], + fetchedDims: (Dimensions | null)[], + ) => void + onPressIn?: (index: number) => void + viewContext?: PostEmbedViewContext +} + +const Context = createContext<{ + bleedRef: React.RefObject + bleedWidth: number +}>({ + bleedRef: {current: null}, + bleedWidth: 0, +}) + +export function GalleryBleed({children}: {children: React.ReactNode}) { + const ref = useRef(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 + + return ( + + {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], + })} + + ) +} + +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(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(null) + const itemWidthsRef = useRef>(new Map()) + const itemRefsRef = useRef>(new Map()) + const containerRefsRef = useRef>>(new Map()) + const thumbDimsRef = useRef>(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 ( + + {images.map((image, index) => ( + + onPress?.(index, [containerRef], [dims]) + } + onPressIn={() => onPressIn?.(index)} + hideBadge={ + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia + } + /> + ))} + + ) + } + + return ( + + + item.thumb + index} + renderItem={({item, index}) => { + return ( + { + 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[] = [] + 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, + }} + /> + + + ) +} + +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) => 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 ( + + 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, + }), + ]}> + { + const ar = getAspectRatio(e.source) + if (ar && ar !== aspectRatio) { + setAspectRatio(ar) + } + onThumbDims(index, { + width: e.source.width, + height: e.source.height, + }) + }} + /> + + {(hasAlt || isCropped) && !hideBadges ? ( + + {isCropped && ( + + + + )} + {hasAlt && ( + + + ALT + + + )} + + ) : null} + + + + + ) +} diff --git a/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts new file mode 100644 index 0000000000..5081b5a2d5 --- /dev/null +++ b/src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts @@ -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( + post.record, + AppBskyFeedPost.isRecord, + ) + ) { + return + } + + /* + * First check if we even have images + */ + const embed = post.record.embed + const isImageEmbed = + embed && + bsky.dangerousIsType( + embed, + AppBskyEmbedImages.isMain, + ) + const isRecordWithMedia = + embed && + bsky.dangerousIsType( + 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( + 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 + : {} +} diff --git a/src/components/images/Gallery/tween.ts b/src/components/images/Gallery/tween.ts new file mode 100644 index 0000000000..4d0042e475 --- /dev/null +++ b/src/components/images/Gallery/tween.ts @@ -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) + } + } +} diff --git a/src/components/images/Gallery/useKeyboardHandlers.ts b/src/components/images/Gallery/useKeyboardHandlers.ts new file mode 100644 index 0000000000..324ea28d2f --- /dev/null +++ b/src/components/images/Gallery/useKeyboardHandlers.ts @@ -0,0 +1,8 @@ +export function useKeyboardHandlers(_args: { + flatListRef: any + itemWidthsRef: any + currentIndexRef: any + scrollTo: any + onSettle: any + imageCount: any +}) {} diff --git a/src/components/images/Gallery/useKeyboardHandlers.web.ts b/src/components/images/Gallery/useKeyboardHandlers.web.ts new file mode 100644 index 0000000000..62cf79c287 --- /dev/null +++ b/src/components/images/Gallery/useKeyboardHandlers.web.ts @@ -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 + itemWidthsRef: React.RefObject> + currentIndexRef: React.RefObject + 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, + ]) +} diff --git a/src/components/images/Gallery/usePointerHandlers.ts b/src/components/images/Gallery/usePointerHandlers.ts new file mode 100644 index 0000000000..661c20b511 --- /dev/null +++ b/src/components/images/Gallery/usePointerHandlers.ts @@ -0,0 +1,8 @@ +export function usePointerHandlers(_args: { + flatListRef: any + itemWidthsRef: any + currentIndexRef: any + scrollTo: any + onSettle: any + imageCount: any +}) {} diff --git a/src/components/images/Gallery/usePointerHandlers.web.ts b/src/components/images/Gallery/usePointerHandlers.web.ts new file mode 100644 index 0000000000..25bebfccbd --- /dev/null +++ b/src/components/images/Gallery/usePointerHandlers.web.ts @@ -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, + 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 + itemWidthsRef: React.RefObject> + currentIndexRef: React.RefObject + 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, + ]) +} diff --git a/src/components/images/Gallery/utils.ts b/src/components/images/Gallery/utils.ts new file mode 100644 index 0000000000..8f5fe481aa --- /dev/null +++ b/src/components/images/Gallery/utils.ts @@ -0,0 +1,22 @@ +import {ITEM_GAP} from '#/components/images/Gallery/const' + +export function getOffsetForIndex( + itemWidths: Map, + 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 +} diff --git a/src/components/images/ImageLayoutGrid.tsx b/src/components/images/ImageLayoutGrid.tsx index 54ee1e0121..320dba70a5 100644 --- a/src/components/images/ImageLayoutGrid.tsx +++ b/src/components/images/ImageLayoutGrid.tsx @@ -6,7 +6,7 @@ import {type AppBskyEmbedImages} from '@atproto/api' import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' import {atoms as a, useBreakpoints} from '#/alf' import {PostEmbedViewContext} from '#/components/Post/Embed/types' -import {GalleryItem} from './Gallery' +import {GalleryItem} from './ImageLayoutGridItem' interface ImageLayoutGridProps { images: AppBskyEmbedImages.ViewImage[] diff --git a/src/components/images/Gallery.tsx b/src/components/images/ImageLayoutGridItem.tsx similarity index 100% rename from src/components/images/Gallery.tsx rename to src/components/images/ImageLayoutGridItem.tsx diff --git a/src/screens/PostThread/components/ThreadItemAnchor.tsx b/src/screens/PostThread/components/ThreadItemAnchor.tsx index 42574c7408..17c8e54be8 100644 --- a/src/screens/PostThread/components/ThreadItemAnchor.tsx +++ b/src/screens/PostThread/components/ThreadItemAnchor.tsx @@ -39,6 +39,7 @@ import {Button} from '#/components/Button' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' +import {GalleryBleed} from '#/components/images/Gallery' import {Link} from '#/components/Link' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' @@ -308,234 +309,243 @@ const ThreadItemAnchorInner = memo(function ThreadItemAnchorInner({ return ( <> - - - - - - - - - + + + + + + + + + + + + {sanitizeDisplayName( + post.author.displayName || + sanitizeHandle(post.author.handle), + moderation.ui('displayName'), + )} + + + + + + - {sanitizeDisplayName( - post.author.displayName || - sanitizeHandle(post.author.handle), - moderation.ui('displayName'), - )} + {sanitizeHandle(post.author.handle, '@')} - - - - - - - {sanitizeHandle(post.author.handle, '@')} - - - - - - - - - - - - - {richText?.text ? ( - - ) : undefined} - - {post.embed && ( - - + - )} - - - {post.repostCount !== 0 || - post.likeCount !== 0 || - post.quoteCount !== 0 || - post.bookmarkCount !== 0 ? ( - // Show this section unless we're *sure* it has no engagement. + + + + + + + + + + {richText?.text ? ( + + ) : undefined} + + {post.embed && ( + + + + )} + + + {post.repostCount !== 0 || + post.likeCount !== 0 || + post.quoteCount !== 0 || + post.bookmarkCount !== 0 ? ( + // Show this section unless we're *sure* it has no engagement. + + {post.repostCount != null && post.repostCount !== 0 ? ( + + + + + {formatPostStatCount(post.repostCount)} + {' '} + + + + + ) : null} + {post.quoteCount != null && + post.quoteCount !== 0 && + !post.viewer?.embeddingDisabled ? ( + + + + + {formatPostStatCount(post.quoteCount)} + {' '} + + + + + ) : null} + {post.likeCount != null && post.likeCount !== 0 ? ( + + + + + {formatPostStatCount(post.likeCount)} + {' '} + + + + + ) : null} + {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( + + + + {formatPostStatCount(post.bookmarkCount)} + {' '} + + + + ) : null} + + ) : null} - {post.repostCount != null && post.repostCount !== 0 ? ( - - - - - {formatPostStatCount(post.repostCount)} - {' '} - - - - - ) : null} - {post.quoteCount != null && - post.quoteCount !== 0 && - !post.viewer?.embeddingDisabled ? ( - - - - - {formatPostStatCount(post.quoteCount)} - {' '} - - - - - ) : null} - {post.likeCount != null && post.likeCount !== 0 ? ( - - - - - {formatPostStatCount(post.likeCount)} - {' '} - - - - - ) : null} - {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( - - - - {formatPostStatCount(post.bookmarkCount)} - {' '} - - - - ) : null} + + + - ) : null} - - - - + - - + ) }) diff --git a/src/screens/PostThread/components/ThreadItemPost.tsx b/src/screens/PostThread/components/ThreadItemPost.tsx index 87aa551330..841c2af745 100644 --- a/src/screens/PostThread/components/ThreadItemPost.tsx +++ b/src/screens/PostThread/components/ThreadItemPost.tsx @@ -32,6 +32,10 @@ import {atoms as a, useTheme} from '#/alf' import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' import {useInteractionState} from '#/components/hooks/useInteractionState' 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 {PostAlerts} from '#/components/moderation/PostAlerts' import {PostHider} from '#/components/moderation/PostHider' @@ -131,18 +135,20 @@ const ThreadItemPostOuterWrapper = memo(function ThreadItemPostOuterWrapper({ !item.ui.showParentReplyLine && overrides?.topBorder !== true return ( - - {children} - + + + {children} + + ) }) @@ -295,7 +301,14 @@ const ThreadItemPostInner = memo(function ThreadItemPostInner({ moderation={moderation} timestamp={post.indexedAt} postHref={postHref} - style={[a.pb_xs]} + style={[ + a.pb_xs, + maybeApplyGalleryOffsetStyles('meta', { + post, + modui: moderation.ui('contentList'), + additionalCauses: additionalPostAlerts, + }), + ]} /> {post.embed && ( - + - {Array.from(Array(indents)).map((_, n: number) => { - const isSkipped = item.ui.skippedIndentIndices.has(n) - return ( - + - ) - })} - {children} - + ], + ]}> + {Array.from(Array(indents)).map((_, n: number) => { + const isSkipped = item.ui.skippedIndentIndices.has(n) + return ( + + ) + })} + {children} + + ) }, ) diff --git a/src/view/com/post/Post.tsx b/src/view/com/post/Post.tsx index aef5457fd4..052de3bab3 100644 --- a/src/view/com/post/Post.tsx +++ b/src/view/com/post/Post.tsx @@ -27,6 +27,10 @@ import {Link} from '#/view/com/util/Link' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a} from '#/alf' +import { + GalleryBleed, + maybeApplyGalleryOffsetStyles, +} from '#/components/images/Gallery' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' @@ -155,89 +159,106 @@ function PostInner({ const [hover, setHover] = useState(false) return ( - { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> - - {showReplyLine && } - - - - - - - {replyAuthorDid !== '' && ( - - )} - - - + { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + {showReplyLine && } + + + - {richText.text ? ( - - - {limitLines && ( - - )} - - ) : undefined} - - {post.embed ? ( - + + + {replyAuthorDid !== '' && ( + + )} + + + - ) : null} - - + {richText.text ? ( + + + {limitLines && ( + + )} + + ) : undefined} + + {post.embed ? ( + + + + ) : null} + + + - - + + ) } diff --git a/src/view/com/posts/PostFeedItem.tsx b/src/view/com/posts/PostFeedItem.tsx index 7184dbb569..c6a365e17c 100644 --- a/src/view/com/posts/PostFeedItem.tsx +++ b/src/view/com/posts/PostFeedItem.tsx @@ -34,6 +34,10 @@ import {Link} from '#/view/com/util/Link' import {PostMeta} from '#/view/com/util/PostMeta' import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' import {atoms as a} from '#/alf' +import { + GalleryBleed, + maybeApplyGalleryOffsetStyles, +} from '#/components/images/Gallery' import {ContentHider} from '#/components/moderation/ContentHider' import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' import {PostAlerts} from '#/components/moderation/PostAlerts' @@ -163,6 +167,7 @@ let FeedItemInner = ({ const queryClient = useQueryClient() const {openComposer} = useOpenComposer() const pal = usePalette('default') + const {currentAccount} = useSession() const [hover, setHover] = useState(false) @@ -293,140 +298,6 @@ let FeedItemInner = ({ } }, [reason]) - return ( - { - setHover(true) - }} - onPointerLeave={() => { - setHover(false) - }}> - - - - {isThreadChild && ( - - )} - - - - {reason && ( - - )} - - - - - - - {isThreadParent && ( - - )} - - - - {showReplyTo && - (parentAuthor || isParentBlocked || isParentNotFound) && ( - - )} - - - - - - - - - ) -} -FeedItemInner = memo(FeedItemInner) - -let PostContent = ({ - post, - moderation, - richText, - postEmbed, - postAuthor, - onOpenEmbed, - threadgateRecord, -}: { - moderation: ModerationDecision - richText: RichTextAPI - postEmbed: AppBskyFeedDefs.PostView['embed'] - postAuthor: AppBskyFeedDefs.PostView['author'] - onOpenEmbed: () => void - post: AppBskyFeedDefs.PostView - threadgateRecord?: AppBskyFeedThreadgate.Record -}): React.ReactNode => { - const {currentAccount} = useSession() - const [limitLines, setLimitLines] = useState( - () => countLines(richText.text) >= MAX_POST_LINES, - ) const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ threadgateRecord, }) @@ -451,6 +322,150 @@ let PostContent = ({ : [] }, [post, currentAccount?.did, threadgateHiddenReplies]) + return ( + + { + setHover(true) + }} + onPointerLeave={() => { + setHover(false) + }}> + + + + {isThreadChild && ( + + )} + + + + {reason && ( + + )} + + + + + + + {isThreadParent && ( + + )} + + + + {showReplyTo && + (parentAuthor || isParentBlocked || isParentNotFound) && ( + + )} + + + + + + + + + + ) +} +FeedItemInner = memo(FeedItemInner) + +let PostContent = ({ + post, + moderation, + richText, + postEmbed, + postAuthor, + onOpenEmbed, + additionalPostAlerts, +}: { + moderation: ModerationDecision + richText: RichTextAPI + postEmbed: AppBskyFeedDefs.PostView['embed'] + postAuthor: AppBskyFeedDefs.PostView['author'] + onOpenEmbed: () => void + post: AppBskyFeedDefs.PostView + additionalPostAlerts?: AppModerationCause[] +}): React.ReactNode => { + const [limitLines, setLimitLines] = useState( + () => countLines(richText.text) >= MAX_POST_LINES, + ) + const record = useMemo( () => bsky.validate(post.record, AppBskyFeedPost.validateRecord) @@ -492,7 +507,15 @@ let PostContent = ({ ) : undefined} {record && } {postEmbed ? ( - +