Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

[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>

+1683 -497
+1
src/analytics/features/types.ts
··· 13 13 ImageUploadsBlobSize2mbEnabled = 'image_uploads:blob_size_2mb:enabled', 14 14 GroupChatsEnable = 'group_chats:enable', 15 15 DmsNewMessageComposerEnable = 'dms:new_message_composer:enable', 16 + PostGalleryEmbedEnable = 'post_gallery_embed:enable', 16 17 17 18 AATest = 'aa-test', 18 19 }
+15
src/analytics/metrics/types.ts
··· 1046 1046 'profile:associated:germ:click-self-info': {} 1047 1047 'profile:associated:germ:self-disconnect': {} 1048 1048 'profile:associated:germ:self-reconnect': {} 1049 + 1050 + // Gallery carousel events 1051 + 'post:gallery:swipe': { 1052 + fromImage: number 1053 + toImage: number 1054 + totalImages: number 1055 + } 1056 + 'post:gallery:openLightbox': { 1057 + fromImage: number 1058 + totalImages: number 1059 + } 1060 + 'post:gallery:impression': { 1061 + totalImages: number 1062 + postUri: string 1063 + } 1049 1064 }
+17
src/components/Post/Embed/ImageEmbed.tsx
··· 12 12 import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' 13 13 import {atoms as a} from '#/alf' 14 14 import {AutoSizedImage} from '#/components/images/AutoSizedImage' 15 + import {Gallery} from '#/components/images/Gallery' 15 16 import {ImageLayoutGrid} from '#/components/images/ImageLayoutGrid' 16 17 import {PostEmbedViewContext} from '#/components/Post/Embed/types' 18 + import {useAnalytics} from '#/analytics' 17 19 import {type EmbedType} from '#/types/bsky/post' 18 20 import {type CommonProps} from './types' 19 21 ··· 23 25 }: CommonProps & { 24 26 embed: EmbedType<'images'> 25 27 }) { 28 + const ax = useAnalytics() 26 29 const {openLightbox} = useLightboxControls() 27 30 const {images} = embed.view 31 + const galleryEnabled = ax.features.enabled(ax.features.PostGalleryEmbedEnable) 28 32 29 33 if (images.length > 0) { 30 34 const items = images.map(img => ({ ··· 90 94 hideBadge={ 91 95 rest.viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 92 96 } 97 + /> 98 + </View> 99 + ) 100 + } 101 + 102 + if (galleryEnabled) { 103 + return ( 104 + <View style={[a.mt_sm, rest.style]}> 105 + <Gallery 106 + images={images} 107 + onPress={onPress} 108 + onPressIn={onPressIn} 109 + viewContext={rest.viewContext} 93 110 /> 94 111 </View> 95 112 )
+42 -38
src/components/Post/Embed/index.tsx
··· 19 19 import {PostMeta} from '#/view/com/util/PostMeta' 20 20 import {atoms as a, useTheme} from '#/alf' 21 21 import {useInteractionState} from '#/components/hooks/useInteractionState' 22 + import {GalleryBleed} from '#/components/images/Gallery' 22 23 import {ContentHider} from '#/components/moderation/ContentHider' 23 24 import {PostAlerts} from '#/components/moderation/PostAlerts' 24 25 import {RichText} from '#/components/RichText' ··· 308 309 <Embed 309 310 embed={quote.embed} 310 311 moderation={moderation} 312 + viewContext={PostEmbedViewContext.FeedEmbedRecordWithMedia} 311 313 isWithinQuote={parentIsWithinQuote ?? true} 312 314 // already within quote? override nested 313 315 allowNestedQuotes={ ··· 319 321 ) 320 322 321 323 return ( 322 - <View 323 - style={[a.mt_sm]} 324 - onPointerEnter={linkDisabled ? undefined : onPointerEnter} 325 - onPointerLeave={linkDisabled ? undefined : onPointerLeave}> 326 - <ContentHider 327 - modui={moderation?.ui('contentList')} 328 - style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]} 329 - activeStyle={[a.p_md, a.pt_sm]} 330 - childContainerStyle={[a.pt_sm]}> 331 - {({active}) => ( 332 - <> 333 - {!active && !linkDisabled && ( 334 - <SubtleHover 335 - native 336 - hover={hover || pressed} 337 - style={[a.rounded_md]} 338 - /> 339 - )} 340 - {linkDisabled ? ( 341 - <View style={[!active && a.p_md]} pointerEvents="none"> 342 - {contents} 343 - </View> 344 - ) : ( 345 - <Link 346 - style={[!active && a.p_md]} 347 - hoverStyle={t.atoms.border_contrast_high} 348 - href={itemHref} 349 - title={itemTitle} 350 - onBeforePress={onBeforePress} 351 - onPressIn={onPressIn} 352 - onPressOut={onPressOut}> 353 - {contents} 354 - </Link> 355 - )} 356 - </> 357 - )} 358 - </ContentHider> 359 - </View> 324 + <GalleryBleed> 325 + <View 326 + style={[a.mt_sm]} 327 + onPointerEnter={linkDisabled ? undefined : onPointerEnter} 328 + onPointerLeave={linkDisabled ? undefined : onPointerLeave}> 329 + <ContentHider 330 + modui={moderation?.ui('contentList')} 331 + style={[a.rounded_md, a.border, t.atoms.border_contrast_low, style]} 332 + activeStyle={[a.p_md, a.pt_sm]} 333 + childContainerStyle={[a.pt_sm]}> 334 + {({active}) => ( 335 + <> 336 + {!active && !linkDisabled && ( 337 + <SubtleHover 338 + native 339 + hover={hover || pressed} 340 + style={[a.rounded_md]} 341 + /> 342 + )} 343 + {linkDisabled ? ( 344 + <View style={[!active && a.p_md]} pointerEvents="none"> 345 + {contents} 346 + </View> 347 + ) : ( 348 + <Link 349 + style={[!active && a.p_md]} 350 + hoverStyle={t.atoms.border_contrast_high} 351 + href={itemHref} 352 + title={itemTitle} 353 + onBeforePress={onBeforePress} 354 + onPressIn={onPressIn} 355 + onPressOut={onPressOut}> 356 + {contents} 357 + </Link> 358 + )} 359 + </> 360 + )} 361 + </ContentHider> 362 + </View> 363 + </GalleryBleed> 360 364 ) 361 365 }
src/components/images/Gallery.tsx src/components/images/ImageLayoutGridItem.tsx
+3
src/components/images/Gallery/const.ts
··· 1 + export const ITEM_GAP = 8 // tokens.space.sm 2 + export const MIN_ASPECT_RATIO = 2 / 3 // portrait limit 3 + export const MAX_ASPECT_RATIO = 3 / 2 // landscape limit
+531
src/components/images/Gallery/index.tsx
··· 1 + import { 2 + cloneElement, 3 + createContext, 4 + isValidElement, 5 + useContext, 6 + useEffect, 7 + useMemo, 8 + useRef, 9 + useState, 10 + } from 'react' 11 + import {FlatList, Pressable, useWindowDimensions, View} from 'react-native' 12 + import Animated, { 13 + type AnimatedRef, 14 + useAnimatedRef, 15 + } from 'react-native-reanimated' 16 + import {Image} from 'expo-image' 17 + import {type AppBskyEmbedImages} from '@atproto/api' 18 + import {utils} from '@bsky.app/alf' 19 + import {Trans, useLingui} from '@lingui/react/macro' 20 + import debounce from 'lodash.debounce' 21 + 22 + import {type Dimensions} from '#/lib/media/types' 23 + import {mergeRefs} from '#/lib/merge-refs' 24 + import {useA11y} from '#/state/a11y' 25 + import {useLargeAltBadgeEnabled} from '#/state/preferences/large-alt-badge' 26 + import {BlockDrawerGesture} from '#/view/shell/BlockDrawerGesture' 27 + import {atoms as a, useBreakpoints, useTheme, web} from '#/alf' 28 + import {ArrowsDiagonalOut_Stroke2_Corner0_Rounded as Fullscreen} from '#/components/icons/ArrowsDiagonal' 29 + import {AutoSizedImage} from '#/components/images/AutoSizedImage' 30 + import { 31 + ITEM_GAP, 32 + MAX_ASPECT_RATIO, 33 + MIN_ASPECT_RATIO, 34 + } from '#/components/images/Gallery/const' 35 + import {useKeyboardHandlers} from '#/components/images/Gallery/useKeyboardHandlers' 36 + import {usePointerHandlers} from '#/components/images/Gallery/usePointerHandlers' 37 + import {getAspectRatio} from '#/components/images/Gallery/utils' 38 + import {MediaInsetBorder} from '#/components/MediaInsetBorder' 39 + import {PostEmbedViewContext} from '#/components/Post/Embed/types' 40 + import {Text} from '#/components/Typography' 41 + import {useAnalytics} from '#/analytics' 42 + import {IS_WEB} from '#/env' 43 + 44 + export * from './const' 45 + export * from './maybeApplyGalleryOffsetStyles' 46 + 47 + interface GalleryProps { 48 + images: AppBskyEmbedImages.ViewImage[] 49 + onPress?: ( 50 + index: number, 51 + containerRefs: AnimatedRef<any>[], 52 + fetchedDims: (Dimensions | null)[], 53 + ) => void 54 + onPressIn?: (index: number) => void 55 + viewContext?: PostEmbedViewContext 56 + } 57 + 58 + const Context = createContext<{ 59 + bleedRef: React.RefObject<View | null> 60 + bleedWidth: number 61 + }>({ 62 + bleedRef: {current: null}, 63 + bleedWidth: 0, 64 + }) 65 + 66 + export function GalleryBleed({children}: {children: React.ReactNode}) { 67 + const ref = useRef<View>(null) 68 + const [bleedWidth, setBleedWidth] = useState(0) 69 + 70 + if (!isValidElement(children)) { 71 + throw new Error('GalleryBleed children must be a single React element') 72 + } 73 + 74 + const node = children as React.ReactElement<any> 75 + 76 + return ( 77 + <Context.Provider value={{bleedRef: ref, bleedWidth}}> 78 + {cloneElement(node, { 79 + ref: mergeRefs([ref, node?.props?.ref]), 80 + onLayout: (e: {nativeEvent: {layout: {width: number}}}) => { 81 + setBleedWidth(e.nativeEvent.layout.width) 82 + node.props.onLayout?.(e) 83 + }, 84 + style: [node.props.style, a.overflow_hidden], 85 + })} 86 + </Context.Provider> 87 + ) 88 + } 89 + 90 + export function useGalleryBleed() { 91 + return useContext(Context) 92 + } 93 + 94 + export function Gallery({ 95 + images, 96 + onPress, 97 + onPressIn, 98 + viewContext, 99 + }: GalleryProps) { 100 + const {t: l} = useLingui() 101 + const ax = useAnalytics() 102 + const {screenReaderEnabled} = useA11y() 103 + const largeAltBadge = useLargeAltBadgeEnabled() 104 + const bps = useBreakpoints() 105 + const window = useWindowDimensions() 106 + const contentHeight = useMemo(() => { 107 + if (bps.gtMobile) { 108 + return 300 109 + } else if (bps.gtPhone) { 110 + return 260 111 + } else { 112 + return 200 113 + } 114 + }, [bps]) 115 + const isWithinQuote = 116 + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 117 + const hideBadges = isWithinQuote 118 + 119 + /* 120 + * Container overflow styles 121 + * 122 + * Uses measureLayout to get the Gallery's offset relative to the GalleryBleed 123 + * ancestor. This is a layout-relative measurement that doesn't depend on 124 + * scroll position, so it works correctly for off-screen FlatList items. 125 + */ 126 + const {bleedRef, bleedWidth} = useGalleryBleed() 127 + const contentRef = useRef<View>(null) 128 + const [contentDims, setContentDims] = useState<{x: number; width: number}>() 129 + const measure = () => { 130 + if (contentRef.current && bleedRef.current) { 131 + contentRef.current.measureLayout( 132 + bleedRef.current, 133 + (x, _y, w) => { 134 + setContentDims({x, width: w}) 135 + }, 136 + () => {}, 137 + ) 138 + } 139 + } 140 + const width = bleedWidth || Math.min(600, window.width) 141 + const insetLeft = contentDims?.x ?? 0 142 + const insetRight = 143 + bleedWidth > 0 144 + ? bleedWidth - (contentDims?.x ?? 0) - (contentDims?.width ?? 0) 145 + : 0 146 + /* End container overflow styles */ 147 + 148 + const flatListRef = useRef<FlatList>(null) 149 + const itemWidthsRef = useRef<Map<number, number>>(new Map()) 150 + const itemRefsRef = useRef<Map<number, View>>(new Map()) 151 + const containerRefsRef = useRef<Map<number, AnimatedRef<any>>>(new Map()) 152 + const thumbDimsRef = useRef<Map<number, Dimensions>>(new Map()) 153 + const currentIndexRef = useRef(0) 154 + 155 + const emitSwipeMetric = useMemo( 156 + () => 157 + debounce((fromIndex: number, toIndex: number) => { 158 + ax.metric('post:gallery:swipe', { 159 + fromImage: fromIndex + 1, // convert to 1-based index for easier analysis 160 + toImage: toIndex + 1, // convert to 1-based index for easier analysis 161 + totalImages: images.length, 162 + }) 163 + }, 200), 164 + [ax, images.length], 165 + ) 166 + 167 + const setCurrentIndex = (index: number) => { 168 + const prev = currentIndexRef.current 169 + if (prev !== index) { 170 + currentIndexRef.current = index 171 + emitSwipeMetric(prev, index) 172 + } 173 + } 174 + 175 + const scrollTo = (offset: number) => { 176 + flatListRef.current?.scrollToOffset({offset, animated: false}) 177 + } 178 + 179 + const onSettle = (index: number) => { 180 + setCurrentIndex(index) 181 + if (!IS_WEB) return 182 + // Update tabIndex: only the active image is tab-focusable 183 + itemRefsRef.current.forEach((node, i) => { 184 + const el = node as unknown as HTMLElement 185 + el.tabIndex = i === index ? 0 : -1 186 + }) 187 + const el = itemRefsRef.current.get(index) as unknown as HTMLElement | null 188 + el?.focus({preventScroll: true}) 189 + } 190 + 191 + useKeyboardHandlers({ 192 + flatListRef, 193 + itemWidthsRef, 194 + currentIndexRef, 195 + scrollTo, 196 + onSettle, 197 + imageCount: images.length, 198 + }) 199 + 200 + usePointerHandlers({ 201 + flatListRef, 202 + itemWidthsRef, 203 + currentIndexRef, 204 + scrollTo, 205 + onSettle, 206 + imageCount: images.length, 207 + }) 208 + 209 + if (screenReaderEnabled) { 210 + return ( 211 + <View style={[a.relative, a.gap_sm]}> 212 + {images.map((image, index) => ( 213 + <AutoSizedImage 214 + key={image.thumb + index} 215 + crop={ 216 + viewContext === PostEmbedViewContext.ThreadHighlighted 217 + ? 'none' 218 + : viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 219 + ? 'square' 220 + : 'constrained' 221 + } 222 + image={image} 223 + onPress={(containerRef, dims) => 224 + onPress?.(index, [containerRef], [dims]) 225 + } 226 + onPressIn={() => onPressIn?.(index)} 227 + hideBadge={ 228 + viewContext === PostEmbedViewContext.FeedEmbedRecordWithMedia 229 + } 230 + /> 231 + ))} 232 + </View> 233 + ) 234 + } 235 + 236 + return ( 237 + <View 238 + ref={contentRef} 239 + style={[ 240 + a.w_full, 241 + { 242 + height: contentHeight, 243 + overflow: 'visible', 244 + }, 245 + ]} 246 + onLayout={measure}> 247 + <BlockDrawerGesture> 248 + <FlatList 249 + ref={flatListRef} 250 + role="group" 251 + aria-roledescription={l`carousel`} 252 + aria-label={l`Image gallery, ${images.length} images`} 253 + horizontal 254 + pagingEnabled={false} 255 + showsHorizontalScrollIndicator={false} 256 + decelerationRate={0.993} 257 + directionalLockEnabled 258 + nestedScrollEnabled 259 + alwaysBounceVertical={false} 260 + scrollEventThrottle={16} 261 + data={images} 262 + keyExtractor={(item, index) => item.thumb + index} 263 + renderItem={({item, index}) => { 264 + return ( 265 + <GalleryImage 266 + hideBadges={hideBadges} 267 + largeAltBadge={largeAltBadge} 268 + image={item} 269 + contentHeight={contentHeight} 270 + index={index} 271 + imageCount={images.length} 272 + onWidthChange={(i, w) => { 273 + itemWidthsRef.current.set(i, w) 274 + }} 275 + itemRef={node => { 276 + if (node) { 277 + itemRefsRef.current.set(index, node) 278 + } else { 279 + itemRefsRef.current.delete(index) 280 + } 281 + }} 282 + onContainerRef={(i, ref) => { 283 + containerRefsRef.current.set(i, ref) 284 + }} 285 + onThumbDims={(i, dims) => { 286 + thumbDimsRef.current.set(i, dims) 287 + }} 288 + onPress={ 289 + onPress 290 + ? () => { 291 + ax.metric('post:gallery:openLightbox', { 292 + fromImage: index + 1, // convert to 1-based index for easier analysis 293 + totalImages: images.length, 294 + }) 295 + const refs: AnimatedRef<any>[] = [] 296 + const dims: (Dimensions | null)[] = [] 297 + for (let i = 0; i < images.length; i++) { 298 + refs.push(containerRefsRef.current.get(i)!) 299 + dims.push(thumbDimsRef.current.get(i) ?? null) 300 + } 301 + onPress(index, refs, dims) 302 + } 303 + : undefined 304 + } 305 + onPressIn={onPressIn ? () => onPressIn(index) : undefined} 306 + /> 307 + ) 308 + }} 309 + onScroll={e => { 310 + // web handles via onSettle in the web hooks 311 + if (IS_WEB) return 312 + const offsetX = e.nativeEvent.contentOffset.x 313 + let accumulated = 0 314 + for (let i = 0; i < images.length; i++) { 315 + const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP 316 + if (offsetX < accumulated + w / 2) { 317 + setCurrentIndex(i) 318 + break 319 + } 320 + accumulated += w 321 + if (i === images.length - 1) { 322 + setCurrentIndex(i) 323 + } 324 + } 325 + }} 326 + style={[ 327 + { 328 + height: contentHeight, 329 + marginLeft: -insetLeft, 330 + width, 331 + }, 332 + ]} 333 + contentContainerStyle={{ 334 + gap: ITEM_GAP, 335 + paddingLeft: insetLeft, 336 + paddingRight: insetRight, 337 + }} 338 + /> 339 + </BlockDrawerGesture> 340 + </View> 341 + ) 342 + } 343 + 344 + function computeDims({ 345 + height, 346 + aspectRatio, 347 + }: { 348 + height: number 349 + aspectRatio?: number 350 + }) { 351 + /* 352 + * Old images, or images from other clients can sometimes not have 353 + * aspectRatio populated. In these cases, default to square and we'll 354 + * resize once the image loads. 355 + * 356 + * Clamp between MIN_ASPECT_RATIO (portrait) and MAX_ASPECT_RATIO 357 + * (landscape) so items stay a reasonable size in the carousel. 358 + */ 359 + const raw = aspectRatio ?? 1 360 + const clamped = Math.max(MIN_ASPECT_RATIO, Math.min(raw, MAX_ASPECT_RATIO)) 361 + const width = Math.floor(height * clamped) 362 + return {width, height, aspectRatio: clamped, isCropped: raw !== clamped} 363 + } 364 + 365 + function GalleryImage({ 366 + contentHeight: height, 367 + image, 368 + index, 369 + imageCount, 370 + onWidthChange, 371 + itemRef, 372 + hideBadges, 373 + largeAltBadge, 374 + onContainerRef, 375 + onThumbDims, 376 + onPress, 377 + onPressIn, 378 + }: { 379 + contentHeight: number 380 + image: AppBskyEmbedImages.ViewImage 381 + index: number 382 + imageCount: number 383 + onWidthChange: (index: number, width: number) => void 384 + itemRef: (node: View | null) => void 385 + hideBadges?: boolean 386 + largeAltBadge?: boolean 387 + onContainerRef: (index: number, ref: AnimatedRef<any>) => void 388 + onThumbDims: (index: number, dims: Dimensions) => void 389 + onPress?: () => void 390 + onPressIn?: () => void 391 + }) { 392 + const t = useTheme() 393 + const {t: l} = useLingui() 394 + const [focused, setFocused] = useState(false) 395 + const containerRef = useAnimatedRef() 396 + const [aspectRatio, setAspectRatio] = useState(() => 397 + getAspectRatio(image.aspectRatio), 398 + ) 399 + const {isCropped, ...dims} = computeDims({height, aspectRatio}) 400 + const hasAlt = !!image.alt 401 + 402 + useEffect(() => { 403 + onWidthChange(index, dims.width) 404 + }, [index, dims.width, onWidthChange]) 405 + 406 + useEffect(() => { 407 + onContainerRef(index, containerRef) 408 + }, [index, containerRef, onContainerRef]) 409 + 410 + return ( 411 + <Animated.View 412 + ref={containerRef} 413 + collapsable={false} 414 + aria-roledescription={l`slide`} 415 + aria-label={image.alt || l`Image ${index + 1} of ${imageCount}`}> 416 + <Pressable 417 + ref={itemRef} 418 + tabIndex={index === 0 ? 0 : -1} 419 + onPress={onPress} 420 + onPressIn={onPressIn} 421 + onFocus={() => setFocused(true)} 422 + onBlur={() => setFocused(false)} 423 + accessibilityRole="button" 424 + accessibilityLabel={image.alt || l`Image ${index + 1}`} 425 + accessibilityHint={l`Opens full image`} 426 + android_ripple={{ 427 + color: utils.alpha(t.atoms.bg.backgroundColor, 0.2), 428 + foreground: true, 429 + }} 430 + style={[ 431 + a.rounded_md, 432 + a.overflow_hidden, 433 + t.atoms.bg_contrast_25, 434 + web({ 435 + cursor: 'inherit', 436 + outline: 0, 437 + border: 0, 438 + }), 439 + ]}> 440 + <Image 441 + source={{uri: image.thumb}} 442 + contentFit="cover" 443 + accessible={true} 444 + accessibilityLabel={image.alt} 445 + accessibilityHint="" 446 + accessibilityIgnoresInvertColors 447 + loading={index === 0 ? 'eager' : 'lazy'} 448 + style={[dims]} 449 + onLoad={e => { 450 + const ar = getAspectRatio(e.source) 451 + if (ar && ar !== aspectRatio) { 452 + setAspectRatio(ar) 453 + } 454 + onThumbDims(index, { 455 + width: e.source.width, 456 + height: e.source.height, 457 + }) 458 + }} 459 + /> 460 + 461 + {(hasAlt || isCropped) && !hideBadges ? ( 462 + <View 463 + accessible={false} 464 + style={[ 465 + a.absolute, 466 + a.flex_row, 467 + { 468 + bottom: a.p_xs.padding, 469 + right: a.p_xs.padding, 470 + gap: 3, 471 + }, 472 + largeAltBadge && { 473 + gap: 4, 474 + }, 475 + ]}> 476 + {isCropped && ( 477 + <View 478 + style={[ 479 + a.rounded_sm, 480 + a.p_xs, 481 + t.atoms.bg_contrast_25, 482 + { 483 + opacity: 0.8, 484 + }, 485 + largeAltBadge && { 486 + padding: 6, 487 + }, 488 + ]}> 489 + <Fullscreen 490 + fill={t.atoms.text_contrast_high.color} 491 + width={largeAltBadge ? 18 : 12} 492 + /> 493 + </View> 494 + )} 495 + {hasAlt && ( 496 + <View 497 + style={[ 498 + a.justify_center, 499 + a.rounded_sm, 500 + a.p_xs, 501 + t.atoms.bg_contrast_25, 502 + { 503 + opacity: 0.8, 504 + }, 505 + largeAltBadge && { 506 + padding: 6, 507 + }, 508 + ]}> 509 + <Text 510 + style={[ 511 + a.font_bold, 512 + largeAltBadge ? a.text_xs : {fontSize: 8}, 513 + ]}> 514 + <Trans>ALT</Trans> 515 + </Text> 516 + </View> 517 + )} 518 + </View> 519 + ) : null} 520 + 521 + <MediaInsetBorder 522 + style={ 523 + focused && { 524 + borderWidth: 2, 525 + } 526 + } 527 + /> 528 + </Pressable> 529 + </Animated.View> 530 + ) 531 + }
+102
src/components/images/Gallery/maybeApplyGalleryOffsetStyles.ts
··· 1 + import { 2 + AppBskyEmbedImages, 3 + AppBskyEmbedRecordWithMedia, 4 + type AppBskyFeedDefs, 5 + AppBskyFeedPost, 6 + type ModerationCause, 7 + type ModerationUI, 8 + } from '@atproto/api' 9 + 10 + import {unique} from '#/lib/moderation' 11 + import {type AppModerationCause} from '#/components/Pills' 12 + import {Features, features} from '#/analytics/features' 13 + import * as bsky from '#/types/bsky' 14 + 15 + export const POST_META_NO_CONTENT_OFFSET = {paddingTop: 10} 16 + export const POST_EMBED_NO_CONTENT_OFFSET = {paddingTop: 6} 17 + 18 + export function maybeApplyGalleryOffsetStyles( 19 + placement: 'meta' | 'embed', 20 + { 21 + post, 22 + modui, 23 + additionalCauses, 24 + }: { 25 + post: AppBskyFeedDefs.PostView 26 + modui: ModerationUI 27 + additionalCauses?: ModerationCause[] | AppModerationCause[] 28 + }, 29 + ) { 30 + // don't ever check gates like this, except this one time 31 + if (!features.isOn(Features.PostGalleryEmbedEnable)) return 32 + 33 + if ( 34 + !bsky.dangerousIsType<AppBskyFeedPost.Record>( 35 + post.record, 36 + AppBskyFeedPost.isRecord, 37 + ) 38 + ) { 39 + return 40 + } 41 + 42 + /* 43 + * First check if we even have images 44 + */ 45 + const embed = post.record.embed 46 + const isImageEmbed = 47 + embed && 48 + bsky.dangerousIsType<AppBskyEmbedImages.Main>( 49 + embed, 50 + AppBskyEmbedImages.isMain, 51 + ) 52 + const isRecordWithMedia = 53 + embed && 54 + bsky.dangerousIsType<AppBskyEmbedRecordWithMedia.Main>( 55 + embed, 56 + AppBskyEmbedRecordWithMedia.isMain, 57 + ) 58 + let hasImages = false 59 + if (isImageEmbed) { 60 + // one image, not a gallery 61 + if (embed.images.length === 1) return 62 + hasImages = true 63 + } 64 + if (isRecordWithMedia) { 65 + if ( 66 + bsky.dangerousIsType<AppBskyEmbedImages.Main>( 67 + embed.media, 68 + AppBskyEmbedImages.isMain, 69 + ) 70 + ) { 71 + // one image, not a gallery 72 + if (embed.media.images.length === 1) return 73 + } 74 + hasImages = true 75 + } 76 + if (!hasImages) return 77 + 78 + /* 79 + * Then check if we have any text 80 + */ 81 + let hasLabels = false 82 + if (modui.alert) { 83 + hasLabels = modui.alerts.filter(unique).length > 0 84 + } 85 + if (modui.inform) { 86 + hasLabels = hasLabels || modui.informs.filter(unique).length > 0 87 + } 88 + if (additionalCauses?.length) { 89 + hasLabels = true 90 + } 91 + 92 + /* 93 + * If no text or labels, then we need a lil bump 94 + */ 95 + const shouldApplyOffset = !post.record.text && !hasLabels 96 + 97 + return shouldApplyOffset 98 + ? placement === 'meta' 99 + ? POST_META_NO_CONTENT_OFFSET 100 + : POST_EMBED_NO_CONTENT_OFFSET 101 + : {} 102 + }
+40
src/components/images/Gallery/tween.ts
··· 1 + function ease(t: number, b: number, c: number, d: number) { 2 + return t === d ? b + c : c * (-Math.pow(2, (-10 * t) / d) + 1) + b 3 + } 4 + 5 + /** 6 + * Tween from `start` to `end` over `duration` ms using an exponential ease-out. 7 + * Returns a function that starts the tween. That function returns a stop handle. 8 + * 9 + * Adapted from tinkerbell. 10 + */ 11 + export function tween(start: number, end: number, duration: number) { 12 + return function run(cb: (v: number) => void, done?: () => void) { 13 + let ts: number | undefined 14 + let frame: number 15 + 16 + frame = (function tick(last: number) { 17 + return requestAnimationFrame(t => { 18 + if (!ts) ts = t 19 + const te = t - ts 20 + const next = Math.round(ease(te, start, end - start, duration)) 21 + if ( 22 + (end > start 23 + ? next < end && last <= end 24 + : next > end && last >= end) && 25 + te <= duration 26 + ) { 27 + frame = tick(next) 28 + cb(next) 29 + } else { 30 + cb(end) 31 + done?.() 32 + } 33 + }) 34 + })(start) 35 + 36 + return function stop() { 37 + cancelAnimationFrame(frame) 38 + } 39 + } 40 + }
+8
src/components/images/Gallery/useKeyboardHandlers.ts
··· 1 + export function useKeyboardHandlers(_args: { 2 + flatListRef: any 3 + itemWidthsRef: any 4 + currentIndexRef: any 5 + scrollTo: any 6 + onSettle: any 7 + imageCount: any 8 + }) {}
+91
src/components/images/Gallery/useKeyboardHandlers.web.ts
··· 1 + import {useEffect} from 'react' 2 + import {type FlatList} from 'react-native' 3 + 4 + import {tween} from '#/components/images/Gallery/tween' 5 + import {getOffsetForIndex} from '#/components/images/Gallery/utils' 6 + 7 + const SETTLE_DURATION = 700 8 + 9 + export function useKeyboardHandlers({ 10 + flatListRef, 11 + itemWidthsRef, 12 + currentIndexRef, 13 + scrollTo, 14 + onSettle, 15 + imageCount, 16 + }: { 17 + flatListRef: React.RefObject<FlatList | null> 18 + itemWidthsRef: React.RefObject<Map<number, number>> 19 + currentIndexRef: React.RefObject<number> 20 + scrollTo: (offset: number) => void 21 + onSettle: (index: number) => void 22 + imageCount: number 23 + }) { 24 + useEffect(() => { 25 + if (imageCount <= 1) return 26 + 27 + let stopTween: (() => void) | null = null 28 + let pendingIndex: number | null = null 29 + 30 + const onKeyDown = (e: KeyboardEvent) => { 31 + const el = 32 + flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null 33 + if (!el || !el.contains(document.activeElement)) return 34 + 35 + const current = pendingIndex ?? currentIndexRef.current 36 + let targetIndex: number | undefined 37 + 38 + if (e.key === 'ArrowRight') { 39 + if (current < imageCount - 1) { 40 + targetIndex = current + 1 41 + } 42 + } else if (e.key === 'ArrowLeft') { 43 + if (current > 0) { 44 + targetIndex = current - 1 45 + } 46 + } 47 + 48 + if (targetIndex != null) { 49 + e.preventDefault() 50 + 51 + if (stopTween) { 52 + stopTween() 53 + stopTween = null 54 + } 55 + 56 + pendingIndex = targetIndex 57 + const from = el.scrollLeft 58 + const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) 59 + const idx = targetIndex 60 + 61 + stopTween = tween( 62 + from, 63 + to, 64 + SETTLE_DURATION, 65 + )( 66 + v => { 67 + scrollTo(v) 68 + }, 69 + () => { 70 + stopTween = null 71 + pendingIndex = null 72 + onSettle(idx) 73 + }, 74 + ) 75 + } 76 + } 77 + 78 + window.addEventListener('keydown', onKeyDown) 79 + return () => { 80 + window.removeEventListener('keydown', onKeyDown) 81 + if (stopTween) stopTween() 82 + } 83 + }, [ 84 + flatListRef, 85 + itemWidthsRef, 86 + currentIndexRef, 87 + scrollTo, 88 + onSettle, 89 + imageCount, 90 + ]) 91 + }
+8
src/components/images/Gallery/usePointerHandlers.ts
··· 1 + export function usePointerHandlers(_args: { 2 + flatListRef: any 3 + itemWidthsRef: any 4 + currentIndexRef: any 5 + scrollTo: any 6 + onSettle: any 7 + imageCount: any 8 + }) {}
+270
src/components/images/Gallery/usePointerHandlers.web.ts
··· 1 + import {useEffect} from 'react' 2 + import {type FlatList} from 'react-native' 3 + 4 + import {ITEM_GAP} from '#/components/images/Gallery/const' 5 + import {tween} from '#/components/images/Gallery/tween' 6 + import {getOffsetForIndex} from '#/components/images/Gallery/utils' 7 + 8 + const DRAG_THRESHOLD = 3 9 + const FLICK_DECAY = 0.85 10 + const FLICK_MIN_VELOCITY = 0.1 11 + const ADVANCE_THRESHOLD = 0.15 12 + const FRAME_MS = 1000 / 60 13 + const SETTLE_DURATION = 700 14 + const OVERSCROLL_RESISTANCE = 0.4 15 + const BOUNCE_DURATION = 700 16 + 17 + function whichByDistance( 18 + itemWidths: Map<number, number>, 19 + currentIndex: number, 20 + distance: number, 21 + direction: -1 | 1, 22 + imageCount: number, 23 + ): number { 24 + let remaining = distance 25 + let i = currentIndex 26 + 27 + while (remaining > 0 && i >= 0 && i < imageCount) { 28 + const w = (itemWidths.get(i) ?? 0) + ITEM_GAP 29 + if (remaining > w) { 30 + remaining -= w 31 + i -= direction 32 + } else if (remaining > w * ADVANCE_THRESHOLD) { 33 + i -= direction 34 + break 35 + } else { 36 + break 37 + } 38 + } 39 + 40 + return Math.max(0, Math.min(i, imageCount - 1)) 41 + } 42 + 43 + export function usePointerHandlers({ 44 + flatListRef, 45 + itemWidthsRef, 46 + currentIndexRef, 47 + scrollTo, 48 + onSettle, 49 + imageCount, 50 + }: { 51 + flatListRef: React.RefObject<FlatList | null> 52 + itemWidthsRef: React.RefObject<Map<number, number>> 53 + currentIndexRef: React.RefObject<number> 54 + scrollTo: (offset: number) => void 55 + onSettle: (index: number) => void 56 + imageCount: number 57 + }) { 58 + useEffect(() => { 59 + if (imageCount <= 1) return 60 + 61 + const el = 62 + flatListRef.current?.getScrollableNode() as unknown as HTMLElement | null 63 + if (!el) return 64 + 65 + let isDragging = false 66 + let isMouseDown = false 67 + let startX = 0 68 + let dragScrollLeft = 0 69 + let delta = 0 70 + let prevDelta = 0 71 + let velo = 0 72 + let t = 0 73 + let stopTween: (() => void) | null = null 74 + let localIndex = currentIndexRef.current 75 + let overscrollX = 0 76 + 77 + el.style.cursor = 'grab' 78 + 79 + const clearOverscroll = () => { 80 + overscrollX = 0 81 + el.style.transform = '' 82 + } 83 + 84 + const onMouseDown = (e: MouseEvent) => { 85 + e.preventDefault() // prevent native image drag 86 + 87 + // Cancel any in-progress tween 88 + if (stopTween) { 89 + stopTween() 90 + stopTween = null 91 + } 92 + clearOverscroll() 93 + 94 + isMouseDown = true 95 + isDragging = false 96 + localIndex = currentIndexRef.current 97 + startX = e.pageX 98 + dragScrollLeft = el.scrollLeft 99 + delta = 0 100 + prevDelta = 0 101 + velo = 0 102 + t = e.timeStamp 103 + } 104 + 105 + const onMouseMove = (e: MouseEvent) => { 106 + if (!isMouseDown) return 107 + 108 + const x = e.pageX - startX 109 + 110 + // Require minimum movement before starting drag 111 + if (!isDragging && Math.abs(x) < DRAG_THRESHOLD) return 112 + 113 + if (!isDragging) { 114 + isDragging = true 115 + el.style.cursor = 'grabbing' 116 + el.style.userSelect = 'none' 117 + 118 + // Blur focused element within the gallery 119 + if (el.contains(document.activeElement)) { 120 + ;(document.activeElement as HTMLElement)?.blur?.() 121 + } 122 + } 123 + 124 + e.preventDefault() 125 + 126 + // Track velocity 127 + const elapsed = e.timeStamp - t || 1 128 + prevDelta = delta 129 + delta = x 130 + velo = (delta - prevDelta) / (elapsed * FRAME_MS) 131 + t = e.timeStamp 132 + 133 + const desiredScroll = dragScrollLeft - delta 134 + const maxScroll = el.scrollWidth - el.clientWidth 135 + 136 + if (desiredScroll < 0) { 137 + // Overscroll at start — rubber band 138 + scrollTo(0) 139 + overscrollX = desiredScroll * OVERSCROLL_RESISTANCE 140 + el.style.transform = `translateX(${-overscrollX}px)` 141 + } else if (desiredScroll > maxScroll) { 142 + // Overscroll at end — rubber band 143 + scrollTo(maxScroll) 144 + overscrollX = (desiredScroll - maxScroll) * OVERSCROLL_RESISTANCE 145 + el.style.transform = `translateX(${-overscrollX}px)` 146 + } else { 147 + // Normal scroll range 148 + scrollTo(desiredScroll) 149 + if (overscrollX !== 0) clearOverscroll() 150 + } 151 + 152 + // Update local index from scroll position (only in normal range) 153 + if (overscrollX === 0) { 154 + const offsetX = desiredScroll 155 + let accumulated = 0 156 + for (let i = 0; i < imageCount; i++) { 157 + const w = (itemWidthsRef.current.get(i) ?? 0) + ITEM_GAP 158 + if (offsetX < accumulated + w / 2) { 159 + localIndex = i 160 + break 161 + } 162 + accumulated += w 163 + if (i === imageCount - 1) localIndex = i 164 + } 165 + } 166 + } 167 + 168 + const onMouseUp = () => { 169 + if (!isMouseDown) return 170 + 171 + const wasDragging = isDragging 172 + isMouseDown = false 173 + isDragging = false 174 + 175 + el.style.cursor = 'grab' 176 + el.style.userSelect = '' 177 + 178 + if (wasDragging) { 179 + // Suppress the click that follows mouseup after a drag 180 + el.addEventListener('click', e => e.stopPropagation(), { 181 + once: true, 182 + capture: true, 183 + }) 184 + 185 + if (overscrollX !== 0) { 186 + // Bounce back from overscroll 187 + const targetIndex = overscrollX > 0 ? imageCount - 1 : 0 188 + const fromOverscroll = overscrollX 189 + 190 + stopTween = tween( 191 + fromOverscroll, 192 + 0, 193 + BOUNCE_DURATION, 194 + )( 195 + v => { 196 + el.style.transform = `translateX(${-v}px)` 197 + }, 198 + () => { 199 + stopTween = null 200 + clearOverscroll() 201 + onSettle(targetIndex) 202 + }, 203 + ) 204 + } else { 205 + // Normal flick settle 206 + let v = Math.abs(velo) 207 + let restingDistance = 0 208 + while (v > FLICK_MIN_VELOCITY) { 209 + v *= FLICK_DECAY 210 + restingDistance += v 211 + } 212 + 213 + const direction: -1 | 1 = delta < 0 ? -1 : 1 214 + const totalDistance = Math.abs(delta) + restingDistance 215 + 216 + const targetIndex = whichByDistance( 217 + itemWidthsRef.current, 218 + localIndex, 219 + totalDistance, 220 + direction, 221 + imageCount, 222 + ) 223 + 224 + const from = el.scrollLeft 225 + const to = getOffsetForIndex(itemWidthsRef.current, targetIndex) 226 + 227 + if (from === to) { 228 + onSettle(targetIndex) 229 + return 230 + } 231 + 232 + stopTween = tween( 233 + from, 234 + to, 235 + SETTLE_DURATION, 236 + )( 237 + v => { 238 + scrollTo(v) 239 + }, 240 + () => { 241 + stopTween = null 242 + onSettle(targetIndex) 243 + }, 244 + ) 245 + } 246 + } 247 + } 248 + 249 + el.addEventListener('mousedown', onMouseDown) 250 + window.addEventListener('mousemove', onMouseMove) 251 + window.addEventListener('mouseup', onMouseUp) 252 + 253 + return () => { 254 + el.removeEventListener('mousedown', onMouseDown) 255 + window.removeEventListener('mousemove', onMouseMove) 256 + window.removeEventListener('mouseup', onMouseUp) 257 + if (stopTween) stopTween() 258 + clearOverscroll() 259 + el.style.cursor = '' 260 + el.style.userSelect = '' 261 + } 262 + }, [ 263 + flatListRef, 264 + itemWidthsRef, 265 + currentIndexRef, 266 + scrollTo, 267 + onSettle, 268 + imageCount, 269 + ]) 270 + }
+22
src/components/images/Gallery/utils.ts
··· 1 + import {ITEM_GAP} from '#/components/images/Gallery/const' 2 + 3 + export function getOffsetForIndex( 4 + itemWidths: Map<number, number>, 5 + index: number, 6 + ): number { 7 + let offset = 0 8 + for (let i = 0; i < index; i++) { 9 + offset += (itemWidths.get(i) ?? 0) + ITEM_GAP 10 + } 11 + return offset 12 + } 13 + 14 + export function getAspectRatio({ 15 + width, 16 + height, 17 + }: {width?: number; height?: number} = {}) { 18 + if (width && width > 0 && height && height > 0) { 19 + return width / height 20 + } 21 + return undefined 22 + }
+1 -1
src/components/images/ImageLayoutGrid.tsx
··· 6 6 import {type Dimensions} from '#/view/com/lightbox/ImageViewing/@types' 7 7 import {atoms as a, useBreakpoints} from '#/alf' 8 8 import {PostEmbedViewContext} from '#/components/Post/Embed/types' 9 - import {GalleryItem} from './Gallery' 9 + import {GalleryItem} from './ImageLayoutGridItem' 10 10 11 11 interface ImageLayoutGridProps { 12 12 images: AppBskyEmbedImages.ViewImage[]
+216 -206
src/screens/PostThread/components/ThreadItemAnchor.tsx
··· 39 39 import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' 40 40 import {CalendarClock_Stroke2_Corner0_Rounded as CalendarClockIcon} from '#/components/icons/CalendarClock' 41 41 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 42 + import {GalleryBleed} from '#/components/images/Gallery' 42 43 import {Link} from '#/components/Link' 43 44 import {ContentHider} from '#/components/moderation/ContentHider' 44 45 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' ··· 308 309 return ( 309 310 <> 310 311 <ThreadItemAnchorParentReplyLine isRoot={isRoot} /> 311 - <View 312 - testID={`postThreadItem-by-${post.author.handle}`} 313 - style={[ 314 - { 315 - paddingHorizontal: OUTER_SPACE, 316 - }, 317 - isRoot && [a.pt_lg], 318 - ]}> 319 - <View style={[a.flex_row, a.gap_md, a.pb_md]}> 320 - <View collapsable={false}> 321 - <PreviewableUserAvatar 322 - size={42} 323 - profile={post.author} 324 - moderation={moderation.ui('avatar')} 325 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 326 - live={live} 327 - onBeforePress={onOpenAuthor} 328 - /> 329 - </View> 330 - <Link 331 - to={authorHref} 332 - style={[a.flex_1]} 333 - label={sanitizeDisplayName( 334 - post.author.displayName || sanitizeHandle(post.author.handle), 335 - moderation.ui('displayName'), 336 - )} 337 - onPress={onOpenAuthor}> 338 - <View style={[a.flex_1, a.align_start]}> 339 - <ProfileHoverCard did={post.author.did} style={[a.w_full]}> 340 - <View style={[a.flex_row, a.align_center]}> 312 + <GalleryBleed> 313 + <View 314 + testID={`postThreadItem-by-${post.author.handle}`} 315 + style={[ 316 + { 317 + paddingHorizontal: OUTER_SPACE, 318 + }, 319 + isRoot && [a.pt_lg], 320 + ]}> 321 + <View style={[a.flex_row, a.gap_md, a.pb_md]}> 322 + <View collapsable={false}> 323 + <PreviewableUserAvatar 324 + size={42} 325 + profile={post.author} 326 + moderation={moderation.ui('avatar')} 327 + type={post.author.associated?.labeler ? 'labeler' : 'user'} 328 + live={live} 329 + onBeforePress={onOpenAuthor} 330 + /> 331 + </View> 332 + <Link 333 + to={authorHref} 334 + style={[a.flex_1]} 335 + label={sanitizeDisplayName( 336 + post.author.displayName || sanitizeHandle(post.author.handle), 337 + moderation.ui('displayName'), 338 + )} 339 + onPress={onOpenAuthor}> 340 + <View style={[a.flex_1, a.align_start]}> 341 + <ProfileHoverCard did={post.author.did} style={[a.w_full]}> 342 + <View style={[a.flex_row, a.align_center]}> 343 + <Text 344 + emoji 345 + style={[ 346 + a.flex_shrink, 347 + a.text_lg, 348 + a.font_semi_bold, 349 + a.leading_snug, 350 + ]} 351 + numberOfLines={1}> 352 + {sanitizeDisplayName( 353 + post.author.displayName || 354 + sanitizeHandle(post.author.handle), 355 + moderation.ui('displayName'), 356 + )} 357 + </Text> 358 + 359 + <View style={[a.pl_xs]}> 360 + <ProfileBadges 361 + profile={authorShadow} 362 + size="md" 363 + interactive 364 + /> 365 + </View> 366 + </View> 341 367 <Text 342 - emoji 343 368 style={[ 344 - a.flex_shrink, 345 - a.text_lg, 346 - a.font_semi_bold, 369 + a.text_md, 347 370 a.leading_snug, 371 + t.atoms.text_contrast_medium, 348 372 ]} 349 373 numberOfLines={1}> 350 - {sanitizeDisplayName( 351 - post.author.displayName || 352 - sanitizeHandle(post.author.handle), 353 - moderation.ui('displayName'), 354 - )} 374 + {sanitizeHandle(post.author.handle, '@')} 355 375 </Text> 356 - 357 - <View style={[a.pl_xs]}> 358 - <ProfileBadges 359 - profile={authorShadow} 360 - size="md" 361 - interactive 362 - /> 363 - </View> 364 - </View> 365 - <Text 366 - style={[ 367 - a.text_md, 368 - a.leading_snug, 369 - t.atoms.text_contrast_medium, 370 - ]} 371 - numberOfLines={1}> 372 - {sanitizeHandle(post.author.handle, '@')} 373 - </Text> 374 - </ProfileHoverCard> 376 + </ProfileHoverCard> 377 + </View> 378 + </Link> 379 + <View collapsable={false} style={[a.self_center]}> 380 + <ThreadItemAnchorFollowButton 381 + did={post.author.did} 382 + enabled={showFollowButton} 383 + /> 375 384 </View> 376 - </Link> 377 - <View collapsable={false} style={[a.self_center]}> 378 - <ThreadItemAnchorFollowButton 379 - did={post.author.did} 380 - enabled={showFollowButton} 381 - /> 382 385 </View> 383 - </View> 384 - <View style={[a.pb_sm]}> 385 - <LabelsOnMyPost post={post} style={[a.pb_sm]} /> 386 - <ContentHider 387 - modui={moderation.ui('contentView')} 388 - ignoreMute 389 - childContainerStyle={[a.pt_sm]}> 390 - <PostAlerts 386 + <View style={[a.pb_sm]}> 387 + <LabelsOnMyPost post={post} style={[a.pb_sm]} /> 388 + <ContentHider 391 389 modui={moderation.ui('contentView')} 392 - size="lg" 393 - includeMute 394 - style={[a.pb_sm]} 395 - additionalCauses={additionalPostAlerts} 396 - /> 397 - {richText?.text ? ( 398 - <RichText 399 - enableTags 400 - selectable 401 - value={richText} 402 - style={[a.flex_1, a.text_lg]} 403 - authorHandle={post.author.handle} 404 - shouldProxyLinks={true} 390 + ignoreMute 391 + childContainerStyle={[a.pt_sm]}> 392 + <PostAlerts 393 + modui={moderation.ui('contentView')} 394 + size="lg" 395 + includeMute 396 + style={[a.pb_sm]} 397 + additionalCauses={additionalPostAlerts} 405 398 /> 406 - ) : undefined} 407 - <TranslatedPost post={post} postTextStyle={[a.text_lg]} /> 408 - {post.embed && ( 409 - <View style={[a.py_xs]}> 410 - <Embed 411 - embed={post.embed} 412 - moderation={moderation} 413 - viewContext={PostEmbedViewContext.ThreadHighlighted} 414 - onOpen={onOpenEmbed} 399 + {richText?.text ? ( 400 + <RichText 401 + enableTags 402 + selectable 403 + value={richText} 404 + style={[a.flex_1, a.text_lg]} 405 + authorHandle={post.author.handle} 406 + shouldProxyLinks={true} 415 407 /> 416 - </View> 417 - )} 418 - </ContentHider> 419 - <ExpandedPostDetails 420 - post={item.value.post} 421 - isThreadAuthor={isThreadAuthor} 422 - /> 423 - {post.repostCount !== 0 || 424 - post.likeCount !== 0 || 425 - post.quoteCount !== 0 || 426 - post.bookmarkCount !== 0 ? ( 427 - // Show this section unless we're *sure* it has no engagement. 428 - <View 429 - style={[ 430 - a.flex_row, 431 - a.flex_wrap, 432 - a.align_center, 433 - { 434 - rowGap: a.gap_sm.gap, 435 - columnGap: a.gap_lg.gap, 436 - }, 437 - a.border_t, 438 - a.border_b, 439 - a.mt_md, 440 - a.py_md, 441 - t.atoms.border_contrast_low, 442 - ]}> 443 - {post.repostCount != null && post.repostCount !== 0 ? ( 444 - <Link to={repostsHref} label={l`Reposts of this post`}> 445 - <Text 446 - testID="repostCount-expanded" 447 - style={[a.text_md, t.atoms.text_contrast_medium]}> 448 - <Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)"> 449 - <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 450 - {formatPostStatCount(post.repostCount)} 451 - </Text>{' '} 452 - <Plural 453 - value={post.repostCount} 454 - one="repost" 455 - other="reposts" 456 - /> 457 - </Trans> 458 - </Text> 459 - </Link> 460 - ) : null} 461 - {post.quoteCount != null && 462 - post.quoteCount !== 0 && 463 - !post.viewer?.embeddingDisabled ? ( 464 - <Link to={quotesHref} label={l`Quotes of this post`}> 408 + ) : undefined} 409 + <TranslatedPost post={post} postTextStyle={[a.text_lg]} /> 410 + {post.embed && ( 411 + <View style={[richText?.text ? a.py_xs : []]}> 412 + <Embed 413 + embed={post.embed} 414 + moderation={moderation} 415 + viewContext={PostEmbedViewContext.ThreadHighlighted} 416 + onOpen={onOpenEmbed} 417 + /> 418 + </View> 419 + )} 420 + </ContentHider> 421 + <ExpandedPostDetails 422 + post={item.value.post} 423 + isThreadAuthor={isThreadAuthor} 424 + /> 425 + {post.repostCount !== 0 || 426 + post.likeCount !== 0 || 427 + post.quoteCount !== 0 || 428 + post.bookmarkCount !== 0 ? ( 429 + // Show this section unless we're *sure* it has no engagement. 430 + <View 431 + style={[ 432 + a.flex_row, 433 + a.flex_wrap, 434 + a.align_center, 435 + { 436 + rowGap: a.gap_sm.gap, 437 + columnGap: a.gap_lg.gap, 438 + }, 439 + a.border_t, 440 + a.border_b, 441 + a.mt_md, 442 + a.py_md, 443 + t.atoms.border_contrast_low, 444 + ]}> 445 + {post.repostCount != null && post.repostCount !== 0 ? ( 446 + <Link to={repostsHref} label={l`Reposts of this post`}> 447 + <Text 448 + testID="repostCount-expanded" 449 + style={[a.text_md, t.atoms.text_contrast_medium]}> 450 + <Trans comment="Repost count display, the <0> tags enclose the number of reposts in bold (will never be 0)"> 451 + <Text 452 + style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 453 + {formatPostStatCount(post.repostCount)} 454 + </Text>{' '} 455 + <Plural 456 + value={post.repostCount} 457 + one="repost" 458 + other="reposts" 459 + /> 460 + </Trans> 461 + </Text> 462 + </Link> 463 + ) : null} 464 + {post.quoteCount != null && 465 + post.quoteCount !== 0 && 466 + !post.viewer?.embeddingDisabled ? ( 467 + <Link to={quotesHref} label={l`Quotes of this post`}> 468 + <Text 469 + testID="quoteCount-expanded" 470 + style={[a.text_md, t.atoms.text_contrast_medium]}> 471 + <Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)"> 472 + <Text 473 + style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 474 + {formatPostStatCount(post.quoteCount)} 475 + </Text>{' '} 476 + <Plural 477 + value={post.quoteCount} 478 + one="quote" 479 + other="quotes" 480 + /> 481 + </Trans> 482 + </Text> 483 + </Link> 484 + ) : null} 485 + {post.likeCount != null && post.likeCount !== 0 ? ( 486 + <Link to={likesHref} label={l`Likes on this post`}> 487 + <Text 488 + testID="likeCount-expanded" 489 + style={[a.text_md, t.atoms.text_contrast_medium]}> 490 + <Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)"> 491 + <Text 492 + style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 493 + {formatPostStatCount(post.likeCount)} 494 + </Text>{' '} 495 + <Plural 496 + value={post.likeCount} 497 + one="like" 498 + other="likes" 499 + /> 500 + </Trans> 501 + </Text> 502 + </Link> 503 + ) : null} 504 + {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( 465 505 <Text 466 - testID="quoteCount-expanded" 506 + testID="bookmarkCount-expanded" 467 507 style={[a.text_md, t.atoms.text_contrast_medium]}> 468 - <Trans comment="Quote count display, the <0> tags enclose the number of quotes in bold (will never be 0)"> 508 + <Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)"> 469 509 <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 470 - {formatPostStatCount(post.quoteCount)} 510 + {formatPostStatCount(post.bookmarkCount)} 471 511 </Text>{' '} 472 512 <Plural 473 - value={post.quoteCount} 474 - one="quote" 475 - other="quotes" 513 + value={post.bookmarkCount} 514 + one="save" 515 + other="saves" 476 516 /> 477 517 </Trans> 478 518 </Text> 479 - </Link> 480 - ) : null} 481 - {post.likeCount != null && post.likeCount !== 0 ? ( 482 - <Link to={likesHref} label={l`Likes on this post`}> 483 - <Text 484 - testID="likeCount-expanded" 485 - style={[a.text_md, t.atoms.text_contrast_medium]}> 486 - <Trans comment="Like count display, the <0> tags enclose the number of likes in bold (will never be 0)"> 487 - <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 488 - {formatPostStatCount(post.likeCount)} 489 - </Text>{' '} 490 - <Plural value={post.likeCount} one="like" other="likes" /> 491 - </Trans> 492 - </Text> 493 - </Link> 494 - ) : null} 495 - {post.bookmarkCount != null && post.bookmarkCount !== 0 ? ( 496 - <Text 497 - testID="bookmarkCount-expanded" 498 - style={[a.text_md, t.atoms.text_contrast_medium]}> 499 - <Trans comment="Save count display, the <0> tags enclose the number of saves in bold (will never be 0)"> 500 - <Text style={[a.text_md, a.font_semi_bold, t.atoms.text]}> 501 - {formatPostStatCount(post.bookmarkCount)} 502 - </Text>{' '} 503 - <Plural 504 - value={post.bookmarkCount} 505 - one="save" 506 - other="saves" 507 - /> 508 - </Trans> 509 - </Text> 510 - ) : null} 519 + ) : null} 520 + </View> 521 + ) : null} 522 + <View 523 + style={[ 524 + a.pt_sm, 525 + a.pb_2xs, 526 + { 527 + marginLeft: -5, 528 + }, 529 + ]}> 530 + <FeedFeedbackProvider value={feedFeedback}> 531 + <PostControls 532 + big 533 + post={postShadow} 534 + record={record} 535 + richText={richText} 536 + onPressReply={onPressReply} 537 + logContext="PostThreadItem" 538 + threadgateRecord={threadgateRecord} 539 + feedContext={postSource?.post?.feedContext} 540 + reqId={postSource?.post?.reqId} 541 + viaRepost={viaRepost} 542 + /> 543 + </FeedFeedbackProvider> 511 544 </View> 512 - ) : null} 513 - <View 514 - style={[ 515 - a.pt_sm, 516 - a.pb_2xs, 517 - { 518 - marginLeft: -5, 519 - }, 520 - ]}> 521 - <FeedFeedbackProvider value={feedFeedback}> 522 - <PostControls 523 - big 524 - post={postShadow} 525 - record={record} 526 - richText={richText} 527 - onPressReply={onPressReply} 528 - logContext="PostThreadItem" 529 - threadgateRecord={threadgateRecord} 530 - feedContext={postSource?.post?.feedContext} 531 - reqId={postSource?.post?.reqId} 532 - viaRepost={viaRepost} 533 - /> 534 - </FeedFeedbackProvider> 545 + <DebugFieldDisplay subject={post} /> 535 546 </View> 536 - <DebugFieldDisplay subject={post} /> 537 547 </View> 538 - </View> 548 + </GalleryBleed> 539 549 </> 540 550 ) 541 551 })
+35 -14
src/screens/PostThread/components/ThreadItemPost.tsx
··· 32 32 import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' 33 33 import {useInteractionState} from '#/components/hooks/useInteractionState' 34 34 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 35 + import { 36 + GalleryBleed, 37 + maybeApplyGalleryOffsetStyles, 38 + } from '#/components/images/Gallery' 35 39 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 36 40 import {PostAlerts} from '#/components/moderation/PostAlerts' 37 41 import {PostHider} from '#/components/moderation/PostHider' ··· 131 135 !item.ui.showParentReplyLine && overrides?.topBorder !== true 132 136 133 137 return ( 134 - <View 135 - style={[ 136 - showTopBorder && [a.border_t, t.atoms.border_contrast_low], 137 - {paddingHorizontal: OUTER_SPACE}, 138 - // If there's no next child, add a little padding to bottom 139 - !item.ui.showChildReplyLine && 140 - !item.ui.precedesChildReadMore && { 141 - paddingBottom: OUTER_SPACE / 2, 142 - }, 143 - ]}> 144 - {children} 145 - </View> 138 + <GalleryBleed> 139 + <View 140 + style={[ 141 + showTopBorder && [a.border_t, t.atoms.border_contrast_low], 142 + {paddingHorizontal: OUTER_SPACE}, 143 + // If there's no next child, add a little padding to bottom 144 + !item.ui.showChildReplyLine && 145 + !item.ui.precedesChildReadMore && { 146 + paddingBottom: OUTER_SPACE / 2, 147 + }, 148 + ]}> 149 + {children} 150 + </View> 151 + </GalleryBleed> 146 152 ) 147 153 }) 148 154 ··· 295 301 moderation={moderation} 296 302 timestamp={post.indexedAt} 297 303 postHref={postHref} 298 - style={[a.pb_xs]} 304 + style={[ 305 + a.pb_xs, 306 + maybeApplyGalleryOffsetStyles('meta', { 307 + post, 308 + modui: moderation.ui('contentList'), 309 + additionalCauses: additionalPostAlerts, 310 + }), 311 + ]} 299 312 /> 300 313 <LabelsOnMyPost post={post} style={[a.pb_xs]} /> 301 314 <PostAlerts ··· 323 336 ) : undefined} 324 337 <TranslatedPost hideTranslateLink post={post} /> 325 338 {post.embed && ( 326 - <View style={[a.pb_xs]}> 339 + <View 340 + style={[ 341 + maybeApplyGalleryOffsetStyles('embed', { 342 + post, 343 + modui: moderation.ui('contentList'), 344 + additionalCauses: additionalPostAlerts, 345 + }), 346 + a.pb_xs, 347 + ]}> 327 348 <Embed 328 349 embed={post.embed} 329 350 moderation={moderation}
+29 -26
src/screens/PostThread/components/ThreadItemTreePost.tsx
··· 32 32 import {DebugFieldDisplay} from '#/components/DebugFieldDisplay' 33 33 import {useInteractionState} from '#/components/hooks/useInteractionState' 34 34 import {Trash_Stroke2_Corner0_Rounded as TrashIcon} from '#/components/icons/Trash' 35 + import {GalleryBleed} from '#/components/images/Gallery' 35 36 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 36 37 import {PostAlerts} from '#/components/moderation/PostAlerts' 37 38 import {PostHider} from '#/components/moderation/PostHider' ··· 129 130 const indents = Math.max(0, item.ui.indent - 1) 130 131 131 132 return ( 132 - <View 133 - style={[ 134 - a.flex_row, 135 - item.ui.indent === 1 && 136 - !item.ui.showParentReplyLine && [ 137 - a.border_t, 138 - t.atoms.border_contrast_low, 139 - ], 140 - ]}> 141 - {Array.from(Array(indents)).map((_, n: number) => { 142 - const isSkipped = item.ui.skippedIndentIndices.has(n) 143 - return ( 144 - <View 145 - key={`${item.value.post.uri}-padding-${n}`} 146 - style={[ 133 + <GalleryBleed> 134 + <View 135 + style={[ 136 + a.flex_row, 137 + item.ui.indent === 1 && 138 + !item.ui.showParentReplyLine && [ 139 + a.border_t, 147 140 t.atoms.border_contrast_low, 148 - { 149 - borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH, 150 - width: TREE_INDENT + TREE_AVI_WIDTH / 2, 151 - left: 1, 152 - }, 153 - ]} 154 - /> 155 - ) 156 - })} 157 - {children} 158 - </View> 141 + ], 142 + ]}> 143 + {Array.from(Array(indents)).map((_, n: number) => { 144 + const isSkipped = item.ui.skippedIndentIndices.has(n) 145 + return ( 146 + <View 147 + key={`${item.value.post.uri}-padding-${n}`} 148 + style={[ 149 + t.atoms.border_contrast_low, 150 + { 151 + borderRightWidth: isSkipped ? 0 : REPLY_LINE_WIDTH, 152 + width: TREE_INDENT + TREE_AVI_WIDTH / 2, 153 + left: 1, 154 + }, 155 + ]} 156 + /> 157 + ) 158 + })} 159 + {children} 160 + </View> 161 + </GalleryBleed> 159 162 ) 160 163 }, 161 164 )
+101 -80
src/view/com/post/Post.tsx
··· 27 27 import {PostMeta} from '#/view/com/util/PostMeta' 28 28 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 29 29 import {atoms as a} from '#/alf' 30 + import { 31 + GalleryBleed, 32 + maybeApplyGalleryOffsetStyles, 33 + } from '#/components/images/Gallery' 30 34 import {ContentHider} from '#/components/moderation/ContentHider' 31 35 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 32 36 import {PostAlerts} from '#/components/moderation/PostAlerts' ··· 155 159 const [hover, setHover] = useState(false) 156 160 157 161 return ( 158 - <Link 159 - href={itemHref} 160 - style={[ 161 - styles.outer, 162 - pal.border, 163 - !hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth}, 164 - style, 165 - ]} 166 - onBeforePress={onBeforePress} 167 - onPointerEnter={() => { 168 - setHover(true) 169 - }} 170 - onPointerLeave={() => { 171 - setHover(false) 172 - }}> 173 - <SubtleHover hover={hover} /> 174 - {showReplyLine && <View style={styles.replyLine} />} 175 - <View style={styles.layout}> 176 - <View style={styles.layoutAvi}> 177 - <PreviewableUserAvatar 178 - size={42} 179 - profile={post.author} 180 - moderation={moderation.ui('avatar')} 181 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 182 - /> 183 - </View> 184 - <View style={styles.layoutContent}> 185 - <PostMeta 186 - author={post.author} 187 - moderation={moderation} 188 - timestamp={post.indexedAt} 189 - postHref={itemHref} 190 - /> 191 - {replyAuthorDid !== '' && ( 192 - <PostRepliedTo parentAuthor={replyAuthorDid} /> 193 - )} 194 - <LabelsOnMyPost post={post} /> 195 - <ContentHider 196 - modui={moderation.ui('contentView')} 197 - style={styles.contentHider} 198 - childContainerStyle={styles.contentHiderChild}> 199 - <PostAlerts 162 + <GalleryBleed> 163 + <Link 164 + href={itemHref} 165 + style={[ 166 + styles.outer, 167 + pal.border, 168 + !hideTopBorder && {borderTopWidth: StyleSheet.hairlineWidth}, 169 + style, 170 + ]} 171 + onBeforePress={onBeforePress} 172 + onPointerEnter={() => { 173 + setHover(true) 174 + }} 175 + onPointerLeave={() => { 176 + setHover(false) 177 + }}> 178 + <SubtleHover hover={hover} /> 179 + {showReplyLine && <View style={styles.replyLine} />} 180 + <View style={styles.layout}> 181 + <View style={styles.layoutAvi}> 182 + <PreviewableUserAvatar 183 + size={42} 184 + profile={post.author} 185 + moderation={moderation.ui('avatar')} 186 + type={post.author.associated?.labeler ? 'labeler' : 'user'} 187 + /> 188 + </View> 189 + <View 190 + style={[ 191 + styles.layoutContent, 192 + maybeApplyGalleryOffsetStyles('meta', { 193 + post, 194 + modui: moderation.ui('contentList'), 195 + additionalCauses: [], 196 + }), 197 + ]}> 198 + <PostMeta 199 + author={post.author} 200 + moderation={moderation} 201 + timestamp={post.indexedAt} 202 + postHref={itemHref} 203 + /> 204 + {replyAuthorDid !== '' && ( 205 + <PostRepliedTo parentAuthor={replyAuthorDid} /> 206 + )} 207 + <LabelsOnMyPost post={post} /> 208 + <ContentHider 200 209 modui={moderation.ui('contentView')} 201 - style={[a.pb_xs]} 202 - /> 203 - {richText.text ? ( 204 - <View style={[a.mb_2xs]}> 205 - <RichText 206 - enableTags 207 - testID="postText" 208 - value={richText} 209 - numberOfLines={limitLines ? MAX_POST_LINES : undefined} 210 - style={[a.flex_1, a.text_md]} 211 - authorHandle={post.author.handle} 212 - shouldProxyLinks={true} 213 - /> 214 - {limitLines && ( 215 - <ShowMoreTextButton 216 - style={[a.text_md]} 217 - onPress={onPressShowMore} 218 - /> 219 - )} 220 - </View> 221 - ) : undefined} 222 - <TranslatedPost hideTranslateLink post={post} /> 223 - {post.embed ? ( 224 - <Embed 225 - embed={post.embed} 226 - moderation={moderation} 227 - viewContext={PostEmbedViewContext.Feed} 210 + style={styles.contentHider} 211 + childContainerStyle={styles.contentHiderChild}> 212 + <PostAlerts 213 + modui={moderation.ui('contentView')} 214 + style={[a.pb_xs]} 228 215 /> 229 - ) : null} 230 - </ContentHider> 231 - <PostControls 232 - post={post} 233 - record={record} 234 - richText={richText} 235 - onPressReply={onPressReply} 236 - logContext="Post" 237 - /> 216 + {richText.text ? ( 217 + <View style={[a.mb_2xs]}> 218 + <RichText 219 + enableTags 220 + testID="postText" 221 + value={richText} 222 + numberOfLines={limitLines ? MAX_POST_LINES : undefined} 223 + style={[a.flex_1, a.text_md]} 224 + authorHandle={post.author.handle} 225 + shouldProxyLinks={true} 226 + /> 227 + {limitLines && ( 228 + <ShowMoreTextButton 229 + style={[a.text_md]} 230 + onPress={onPressShowMore} 231 + /> 232 + )} 233 + </View> 234 + ) : undefined} 235 + <TranslatedPost hideTranslateLink post={post} /> 236 + {post.embed ? ( 237 + <View 238 + style={maybeApplyGalleryOffsetStyles('embed', { 239 + post, 240 + modui: moderation.ui('contentList'), 241 + additionalCauses: [], 242 + })}> 243 + <Embed 244 + embed={post.embed} 245 + moderation={moderation} 246 + viewContext={PostEmbedViewContext.Feed} 247 + /> 248 + </View> 249 + ) : null} 250 + </ContentHider> 251 + <PostControls 252 + post={post} 253 + record={record} 254 + richText={richText} 255 + onPressReply={onPressReply} 256 + logContext="Post" 257 + /> 258 + </View> 238 259 </View> 239 - </View> 240 - </Link> 260 + </Link> 261 + </GalleryBleed> 241 262 ) 242 263 } 243 264
+151 -132
src/view/com/posts/PostFeedItem.tsx
··· 34 34 import {PostMeta} from '#/view/com/util/PostMeta' 35 35 import {PreviewableUserAvatar} from '#/view/com/util/UserAvatar' 36 36 import {atoms as a} from '#/alf' 37 + import { 38 + GalleryBleed, 39 + maybeApplyGalleryOffsetStyles, 40 + } from '#/components/images/Gallery' 37 41 import {ContentHider} from '#/components/moderation/ContentHider' 38 42 import {LabelsOnMyPost} from '#/components/moderation/LabelsOnMe' 39 43 import {PostAlerts} from '#/components/moderation/PostAlerts' ··· 163 167 const queryClient = useQueryClient() 164 168 const {openComposer} = useOpenComposer() 165 169 const pal = usePalette('default') 170 + const {currentAccount} = useSession() 166 171 167 172 const [hover, setHover] = useState(false) 168 173 ··· 293 298 } 294 299 }, [reason]) 295 300 301 + const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 302 + threadgateRecord, 303 + }) 304 + const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 305 + const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 306 + const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>( 307 + post.record, 308 + AppBskyFeedPost.isRecord, 309 + ) 310 + ? post.record?.reply?.root?.uri || post.uri 311 + : undefined 312 + const isControlledByViewer = 313 + rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did 314 + return isControlledByViewer && isPostHiddenByThreadgate 315 + ? [ 316 + { 317 + type: 'reply-hidden', 318 + source: {type: 'user', did: currentAccount?.did}, 319 + priority: 6, 320 + }, 321 + ] 322 + : [] 323 + }, [post, currentAccount?.did, threadgateHiddenReplies]) 324 + 296 325 return ( 297 - <Link 298 - testID={`feedItem-by-${post.author.handle}`} 299 - style={outerStyles} 300 - href={href} 301 - noFeedback 302 - accessible={false} 303 - onBeforePress={onBeforePress} 304 - dataSet={{feedContext}} 305 - onPointerEnter={() => { 306 - setHover(true) 307 - }} 308 - onPointerLeave={() => { 309 - setHover(false) 310 - }}> 311 - <SubtleHover hover={hover} /> 312 - <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> 313 - <View style={{width: 42}}> 314 - {isThreadChild && ( 315 - <View 316 - style={[ 317 - styles.replyLine, 318 - { 319 - flexGrow: 1, 320 - backgroundColor: pal.colors.replyLine, 321 - marginBottom: 4, 322 - }, 323 - ]} 324 - /> 325 - )} 326 - </View> 326 + <GalleryBleed> 327 + <Link 328 + testID={`feedItem-by-${post.author.handle}`} 329 + style={outerStyles} 330 + href={href} 331 + noFeedback 332 + accessible={false} 333 + onBeforePress={onBeforePress} 334 + dataSet={{feedContext}} 335 + onPointerEnter={() => { 336 + setHover(true) 337 + }} 338 + onPointerLeave={() => { 339 + setHover(false) 340 + }}> 341 + <SubtleHover hover={hover} /> 342 + <View style={{flexDirection: 'row', gap: 10, paddingLeft: 8}}> 343 + <View style={{width: 42}}> 344 + {isThreadChild && ( 345 + <View 346 + style={[ 347 + styles.replyLine, 348 + { 349 + flexGrow: 1, 350 + backgroundColor: pal.colors.replyLine, 351 + marginBottom: 4, 352 + }, 353 + ]} 354 + /> 355 + )} 356 + </View> 327 357 328 - <View style={[a.pt_sm, a.flex_shrink]}> 329 - {reason && ( 330 - <PostFeedReason 331 - reason={reason} 332 - moderation={moderation} 333 - onOpenReposter={onOpenReposter} 334 - /> 335 - )} 358 + <View style={[a.pt_sm, a.flex_shrink]}> 359 + {reason && ( 360 + <PostFeedReason 361 + reason={reason} 362 + moderation={moderation} 363 + onOpenReposter={onOpenReposter} 364 + /> 365 + )} 366 + </View> 336 367 </View> 337 - </View> 338 368 339 - <View style={styles.layout}> 340 - <View style={styles.layoutAvi}> 341 - <PreviewableUserAvatar 342 - size={42} 343 - profile={post.author} 344 - moderation={moderation.ui('avatar')} 345 - type={post.author.associated?.labeler ? 'labeler' : 'user'} 346 - onBeforePress={onOpenAuthor} 347 - live={live} 348 - /> 349 - {isThreadParent && ( 350 - <View 351 - style={[ 352 - styles.replyLine, 353 - { 354 - flexGrow: 1, 355 - backgroundColor: pal.colors.replyLine, 356 - marginTop: live ? 8 : 4, 357 - }, 358 - ]} 369 + <View style={styles.layout}> 370 + <View style={styles.layoutAvi}> 371 + <PreviewableUserAvatar 372 + size={42} 373 + profile={post.author} 374 + moderation={moderation.ui('avatar')} 375 + type={post.author.associated?.labeler ? 'labeler' : 'user'} 376 + onBeforePress={onOpenAuthor} 377 + live={live} 359 378 /> 360 - )} 361 - </View> 362 - <View style={styles.layoutContent}> 363 - <PostMeta 364 - author={post.author} 365 - moderation={moderation} 366 - timestamp={post.indexedAt} 367 - postHref={href} 368 - onOpenAuthor={onOpenAuthor} 369 - /> 370 - {showReplyTo && 371 - (parentAuthor || isParentBlocked || isParentNotFound) && ( 372 - <PostRepliedTo 373 - parentAuthor={parentAuthor} 374 - isParentBlocked={isParentBlocked} 375 - isParentNotFound={isParentNotFound} 379 + {isThreadParent && ( 380 + <View 381 + style={[ 382 + styles.replyLine, 383 + { 384 + flexGrow: 1, 385 + backgroundColor: pal.colors.replyLine, 386 + marginTop: live ? 8 : 4, 387 + }, 388 + ]} 376 389 /> 377 390 )} 378 - <LabelsOnMyPost post={post} /> 379 - <PostContent 380 - moderation={moderation} 381 - richText={richText} 382 - postEmbed={post.embed} 383 - postAuthor={post.author} 384 - onOpenEmbed={onOpenEmbed} 385 - post={post} 386 - threadgateRecord={threadgateRecord} 387 - /> 388 - <PostControls 389 - post={post} 390 - record={record} 391 - richText={richText} 392 - onPressReply={onPressReply} 393 - logContext="FeedItem" 394 - feedContext={feedContext} 395 - reqId={reqId} 396 - threadgateRecord={threadgateRecord} 397 - onShowLess={onShowLess} 398 - viaRepost={viaRepost} 399 - /> 391 + </View> 392 + <View 393 + style={[ 394 + styles.layoutContent, 395 + maybeApplyGalleryOffsetStyles('meta', { 396 + post, 397 + modui: moderation.ui('contentList'), 398 + additionalCauses: additionalPostAlerts, 399 + }), 400 + ]}> 401 + <PostMeta 402 + author={post.author} 403 + moderation={moderation} 404 + timestamp={post.indexedAt} 405 + postHref={href} 406 + onOpenAuthor={onOpenAuthor} 407 + /> 408 + {showReplyTo && 409 + (parentAuthor || isParentBlocked || isParentNotFound) && ( 410 + <PostRepliedTo 411 + parentAuthor={parentAuthor} 412 + isParentBlocked={isParentBlocked} 413 + isParentNotFound={isParentNotFound} 414 + /> 415 + )} 416 + <LabelsOnMyPost post={post} /> 417 + <PostContent 418 + moderation={moderation} 419 + richText={richText} 420 + postEmbed={post.embed} 421 + postAuthor={post.author} 422 + onOpenEmbed={onOpenEmbed} 423 + post={post} 424 + additionalPostAlerts={additionalPostAlerts} 425 + /> 426 + <PostControls 427 + post={post} 428 + record={record} 429 + richText={richText} 430 + onPressReply={onPressReply} 431 + logContext="FeedItem" 432 + feedContext={feedContext} 433 + reqId={reqId} 434 + threadgateRecord={threadgateRecord} 435 + onShowLess={onShowLess} 436 + viaRepost={viaRepost} 437 + /> 438 + </View> 439 + 440 + <DiscoverDebug feedContext={feedContext} /> 400 441 </View> 401 - 402 - <DiscoverDebug feedContext={feedContext} /> 403 - </View> 404 - </Link> 442 + </Link> 443 + </GalleryBleed> 405 444 ) 406 445 } 407 446 FeedItemInner = memo(FeedItemInner) ··· 413 452 postEmbed, 414 453 postAuthor, 415 454 onOpenEmbed, 416 - threadgateRecord, 455 + additionalPostAlerts, 417 456 }: { 418 457 moderation: ModerationDecision 419 458 richText: RichTextAPI ··· 421 460 postAuthor: AppBskyFeedDefs.PostView['author'] 422 461 onOpenEmbed: () => void 423 462 post: AppBskyFeedDefs.PostView 424 - threadgateRecord?: AppBskyFeedThreadgate.Record 463 + additionalPostAlerts?: AppModerationCause[] 425 464 }): React.ReactNode => { 426 - const {currentAccount} = useSession() 427 465 const [limitLines, setLimitLines] = useState( 428 466 () => countLines(richText.text) >= MAX_POST_LINES, 429 467 ) 430 - const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ 431 - threadgateRecord, 432 - }) 433 - const additionalPostAlerts: AppModerationCause[] = useMemo(() => { 434 - const isPostHiddenByThreadgate = threadgateHiddenReplies.has(post.uri) 435 - const rootPostUri = bsky.dangerousIsType<AppBskyFeedPost.Record>( 436 - post.record, 437 - AppBskyFeedPost.isRecord, 438 - ) 439 - ? post.record?.reply?.root?.uri || post.uri 440 - : undefined 441 - const isControlledByViewer = 442 - rootPostUri && new AtUri(rootPostUri).host === currentAccount?.did 443 - return isControlledByViewer && isPostHiddenByThreadgate 444 - ? [ 445 - { 446 - type: 'reply-hidden', 447 - source: {type: 'user', did: currentAccount?.did}, 448 - priority: 6, 449 - }, 450 - ] 451 - : [] 452 - }, [post, currentAccount?.did, threadgateHiddenReplies]) 453 468 454 469 const record = useMemo<AppBskyFeedPost.Record | undefined>( 455 470 () => ··· 492 507 ) : undefined} 493 508 {record && <TranslatedPost hideTranslateLink post={post} />} 494 509 {postEmbed ? ( 495 - <View style={[a.pb_xs]}> 510 + <View 511 + style={[ 512 + a.pb_xs, 513 + maybeApplyGalleryOffsetStyles('embed', { 514 + post, 515 + modui: moderation.ui('contentList'), 516 + additionalCauses: additionalPostAlerts, 517 + }), 518 + ]}> 496 519 <Embed 497 520 embed={postEmbed} 498 521 moderation={moderation} ··· 524 547 layoutAvi: { 525 548 paddingLeft: 8, 526 549 paddingRight: 10, 527 - position: 'relative', 528 - zIndex: 999, 529 550 }, 530 551 layoutContent: { 531 - position: 'relative', 532 552 flex: 1, 533 - zIndex: 0, 534 553 }, 535 554 alert: { 536 555 marginTop: 6,