Bluesky app fork with some witchin' additions 💫
0
fork

Configure Feed

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

Fix jumps when navigating into long threads (#2878)

* Reveal parents in chunks to fix scroll jumps

Co-authored-by: Hailey <me@haileyok.com>

* Prevent layout jump when navigating to QT due to missing metrics

---------

Co-authored-by: Hailey <me@haileyok.com>

authored by

dan
Hailey
and committed by
GitHub
c5641ac2 e303940e

+74 -40
+60 -26
src/view/com/post-thread/PostThread.tsx
··· 43 43 usePreferencesQuery, 44 44 } from '#/state/queries/preferences' 45 45 import {useSession} from '#/state/session' 46 - import {isAndroid, isNative} from '#/platform/detection' 47 - import {logger} from '#/logger' 46 + import {isAndroid, isNative, isWeb} from '#/platform/detection' 48 47 import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 48 + 49 + // FlatList maintainVisibleContentPosition breaks if too many items 50 + // are prepended. This seems to be an optimal number based on *shrug*. 51 + const PARENTS_CHUNK_SIZE = 15 49 52 50 53 const MAINTAIN_VISIBLE_CONTENT_POSITION = { 51 54 // We don't insert any elements before the root row while loading. ··· 165 168 const {isMobile, isTabletOrMobile} = useWebMediaQueries() 166 169 const ref = useRef<ListMethods>(null) 167 170 const highlightedPostRef = useRef<View | null>(null) 168 - const [maxVisible, setMaxVisible] = React.useState(100) 169 - const [isPTRing, setIsPTRing] = React.useState(false) 171 + const [maxParents, setMaxParents] = React.useState( 172 + isWeb ? Infinity : PARENTS_CHUNK_SIZE, 173 + ) 174 + const [maxReplies, setMaxReplies] = React.useState(100) 170 175 const treeView = React.useMemo( 171 176 () => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread), 172 177 [threadViewPrefs, thread], ··· 206 211 // maintainVisibleContentPosition and onContentSizeChange 207 212 // to "hold onto" the correct row instead of the first one. 208 213 } else { 209 - // Everything is loaded. 210 - arr.push(TOP_COMPONENT) 211 - for (const parent of parents) { 212 - arr.push(parent) 214 + // Everything is loaded 215 + let startIndex = Math.max(0, parents.length - maxParents) 216 + if (startIndex === 0) { 217 + arr.push(TOP_COMPONENT) 218 + } else { 219 + // When progressively revealing parents, rendering a placeholder 220 + // here will cause scrolling jumps. Don't add it unless you test it. 221 + // QT'ing this thread is a great way to test all the scrolling hacks: 222 + // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o 223 + } 224 + for (let i = startIndex; i < parents.length; i++) { 225 + arr.push(parents[i]) 213 226 } 214 227 } 215 228 } ··· 220 233 if (highlightedPost.ctx.isChildLoading) { 221 234 arr.push(CHILD_SPINNER) 222 235 } else { 223 - for (const reply of replies) { 224 - arr.push(reply) 236 + for (let i = 0; i < replies.length; i++) { 237 + arr.push(replies[i]) 238 + if (i === maxReplies) { 239 + arr.push(LOAD_MORE) 240 + break 241 + } 225 242 } 226 243 arr.push(BOTTOM_COMPONENT) 227 244 } 228 245 } 229 - if (arr.length > maxVisible) { 230 - arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) 231 - } 232 246 return arr 233 - }, [skeleton, maxVisible, deferParents]) 247 + }, [skeleton, deferParents, maxParents, maxReplies]) 234 248 235 249 // This is only used on the web to keep the post in view when its parents load. 236 250 // On native, we rely on `maintainVisibleContentPosition` instead. ··· 258 272 } 259 273 }, [thread]) 260 274 261 - const onPTR = React.useCallback(async () => { 262 - setIsPTRing(true) 263 - try { 264 - await onRefresh() 265 - } catch (err) { 266 - logger.error('Failed to refresh posts thread', {message: err}) 275 + // On native, we reveal parents in chunks. Although they're all already 276 + // loaded and FlatList already has its own virtualization, unfortunately FlatList 277 + // has a bug that causes the content to jump around if too many items are getting 278 + // prepended at once. It also jumps around if items get prepended during scroll. 279 + // To work around this, we prepend rows after scroll bumps against the top and rests. 280 + const needsBumpMaxParents = React.useRef(false) 281 + const onStartReached = React.useCallback(() => { 282 + if (maxParents < skeleton.parents.length) { 283 + needsBumpMaxParents.current = true 284 + } 285 + }, [maxParents, skeleton.parents.length]) 286 + const bumpMaxParentsIfNeeded = React.useCallback(() => { 287 + if (!isNative) { 288 + return 289 + } 290 + if (needsBumpMaxParents.current) { 291 + needsBumpMaxParents.current = false 292 + setMaxParents(n => n + PARENTS_CHUNK_SIZE) 267 293 } 268 - setIsPTRing(false) 269 - }, [setIsPTRing, onRefresh]) 294 + }, []) 295 + const onMomentumScrollEnd = bumpMaxParentsIfNeeded 296 + const onScrollToTop = bumpMaxParentsIfNeeded 270 297 271 298 const renderItem = React.useCallback( 272 299 ({item, index}: {item: RowItem; index: number}) => { ··· 301 328 } else if (item === LOAD_MORE) { 302 329 return ( 303 330 <Pressable 304 - onPress={() => setMaxVisible(n => n + 50)} 331 + onPress={() => setMaxReplies(n => n + 50)} 305 332 style={[pal.border, pal.view, styles.itemContainer]} 306 333 accessibilityLabel={_(msg`Load more posts`)} 307 334 accessibilityHint=""> ··· 345 372 const next = isThreadPost(posts[index - 1]) 346 373 ? (posts[index - 1] as ThreadPost) 347 374 : undefined 375 + const hasUnrevealedParents = 376 + index === 0 && maxParents < skeleton.parents.length 348 377 return ( 349 378 <View 350 379 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} ··· 360 389 hasMore={item.ctx.hasMore} 361 390 showChildReplyLine={item.ctx.showChildReplyLine} 362 391 showParentReplyLine={item.ctx.showParentReplyLine} 363 - hasPrecedingItem={!!prev?.ctx.showChildReplyLine} 392 + hasPrecedingItem={ 393 + !!prev?.ctx.showChildReplyLine || hasUnrevealedParents 394 + } 364 395 onPostReply={onRefresh} 365 396 /> 366 397 </View> ··· 383 414 onRefresh, 384 415 deferParents, 385 416 treeView, 417 + skeleton.parents.length, 418 + maxParents, 386 419 _, 387 420 ], 388 421 ) ··· 393 426 data={posts} 394 427 keyExtractor={item => item._reactKey} 395 428 renderItem={renderItem} 396 - refreshing={isPTRing} 397 - onRefresh={onPTR} 398 429 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} 430 + onStartReached={onStartReached} 431 + onMomentumScrollEnd={onMomentumScrollEnd} 432 + onScrollToTop={onScrollToTop} 399 433 maintainVisibleContentPosition={ 400 434 isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined 401 435 }
+14 -14
src/view/com/post-thread/PostThreadItem.tsx
··· 44 44 import {ThreadPost} from '#/state/queries/post-thread' 45 45 import {useSession} from 'state/session' 46 46 import {WhoCanReply} from '../threadgate/WhoCanReply' 47 + import {LoadingPlaceholder} from '../util/LoadingPlaceholder' 47 48 48 49 export function PostThreadItem({ 49 50 post, ··· 164 165 () => countLines(richText?.text) >= MAX_POST_LINES, 165 166 ) 166 167 const {currentAccount} = useSession() 167 - const hasEngagement = post.likeCount || post.repostCount 168 - 169 168 const rootUri = record.reply?.root?.uri || post.uri 170 169 const postHref = React.useMemo(() => { 171 170 const urip = new AtUri(post.uri) ··· 357 356 translatorUrl={translatorUrl} 358 357 needsTranslation={needsTranslation} 359 358 /> 360 - {hasEngagement ? ( 359 + {post.repostCount !== 0 || post.likeCount !== 0 ? ( 360 + // Show this section unless we're *sure* it has no engagement. 361 361 <View style={[styles.expandedInfo, pal.border]}> 362 - {post.repostCount ? ( 362 + {post.repostCount == null && post.likeCount == null && ( 363 + // If we're still loading and not sure, assume this post has engagement. 364 + // This lets us avoid a layout shift for the common case (embedded post with likes/reposts). 365 + // TODO: embeds should include metrics to avoid us having to guess. 366 + <LoadingPlaceholder width={50} height={20} /> 367 + )} 368 + {post.repostCount != null && post.repostCount !== 0 ? ( 363 369 <Link 364 370 style={styles.expandedInfoItem} 365 371 href={repostsHref} ··· 374 380 {pluralize(post.repostCount, 'repost')} 375 381 </Text> 376 382 </Link> 377 - ) : ( 378 - <></> 379 - )} 380 - {post.likeCount ? ( 383 + ) : null} 384 + {post.likeCount != null && post.likeCount !== 0 ? ( 381 385 <Link 382 386 style={styles.expandedInfoItem} 383 387 href={likesHref} ··· 392 396 {pluralize(post.likeCount, 'like')} 393 397 </Text> 394 398 </Link> 395 - ) : ( 396 - <></> 397 - )} 399 + ) : null} 398 400 </View> 399 - ) : ( 400 - <></> 401 - )} 401 + ) : null} 402 402 <View style={[s.pl10, s.pr10, s.pb5]}> 403 403 <PostCtrls 404 404 big