Bluesky app fork with some witchin' additions 馃挮
0
fork

Configure Feed

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

at main 883 lines 31 kB view raw
1import { 2 startTransition, 3 useCallback, 4 useEffect, 5 useMemo, 6 useRef, 7 useState, 8} from 'react' 9import {useWindowDimensions, View} from 'react-native' 10import Animated, {useAnimatedStyle} from 'react-native-reanimated' 11import {Trans} from '@lingui/react/macro' 12 13import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender' 14import {useNonReactiveCallback} from '#/lib/hooks/useNonReactiveCallback' 15import {useOpenComposer} from '#/lib/hooks/useOpenComposer' 16import {usePostViewTracking} from '#/lib/hooks/usePostViewTracking' 17import {useFeedFeedback} from '#/state/feed-feedback' 18import {useAlsoLikedCollapseByDefault} from '#/state/preferences/also-liked-collapse-by-default' 19import {useAlsoLikedFeedEnabled} from '#/state/preferences/also-liked-feed-enabled' 20import {usePostAlsoLikedQuery} from '#/state/queries/post-also-liked' 21import {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' 22import { 23 PostThreadContextProvider, 24 type ThreadItem, 25 usePostThread, 26} from '#/state/queries/usePostThread' 27import {useSession} from '#/state/session' 28import {type OnPostSuccessData} from '#/state/shell/composer' 29import {useShellLayout} from '#/state/shell/shell-layout' 30import {useUnstablePostSource} from '#/state/unstable-post-source' 31import {List, type ListMethods} from '#/view/com/util/List' 32import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' 33import {ThreadAlsoLiked} from '#/screens/PostThread/components/ThreadAlsoLiked' 34import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt' 35import {ThreadError} from '#/screens/PostThread/components/ThreadError' 36import { 37 ThreadItemAnchor, 38 ThreadItemAnchorSkeleton, 39} from '#/screens/PostThread/components/ThreadItemAnchor' 40import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated' 41import { 42 ThreadItemPost, 43 ThreadItemPostSkeleton, 44} from '#/screens/PostThread/components/ThreadItemPost' 45import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated' 46import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone' 47import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore' 48import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp' 49import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer' 50import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies' 51import { 52 ThreadItemTreePost, 53 ThreadItemTreePostSkeleton, 54} from '#/screens/PostThread/components/ThreadItemTreePost' 55import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' 56import * as Layout from '#/components/Layout' 57import {useAnalytics} from '#/analytics' 58import {IS_NATIVE} from '#/env' 59 60const PARENT_CHUNK_SIZE = IS_NATIVE ? 5 : 20 61const CHILDREN_CHUNK_SIZE = 50 62 63export function PostThread({uri}: {uri: string}) { 64 const ax = useAnalytics() 65 const {gtMobile} = useBreakpoints() 66 const {hasSession} = useSession() 67 const initialNumToRender = useInitialNumToRender() 68 const {height: windowHeight} = useWindowDimensions() 69 const anchorPostSource = useUnstablePostSource(uri) 70 const feedFeedback = useFeedFeedback( 71 anchorPostSource?.feedSourceInfo, 72 hasSession, 73 ) 74 75 /* 76 * One query to rule them all 77 */ 78 const thread = usePostThread({anchor: uri}) 79 const {anchor, hasParents} = (() => { 80 let hasParents = false 81 for (const item of thread.data.items) { 82 if (item.type === 'threadPost' && item.depth === 0) { 83 return {anchor: item, hasParents} 84 } 85 hasParents = true 86 } 87 return {hasParents} 88 })() 89 90 // Track post:view event when anchor post is viewed 91 const seenPostUriRef = useRef<string | null>(null) 92 useEffect(() => { 93 if ( 94 anchor?.type === 'threadPost' && 95 anchor.value.post.uri !== seenPostUriRef.current 96 ) { 97 const post = anchor.value.post 98 seenPostUriRef.current = post.uri 99 100 ax.metric('post:view', { 101 uri: post.uri, 102 authorDid: post.author.did, 103 logContext: 'Post', 104 feedDescriptor: feedFeedback.feedDescriptor, 105 }) 106 } 107 }, [ax, anchor, feedFeedback.feedDescriptor]) 108 109 // Track post:view events for parent posts and replies (non-anchor posts) 110 const trackThreadItemView = usePostViewTracking('PostThreadItem') 111 112 const {openComposer} = useOpenComposer() 113 const optimisticOnPostReply = useNonReactiveCallback( 114 (payload: OnPostSuccessData) => { 115 if (payload) { 116 const {replyToUri, posts} = payload 117 if (replyToUri && posts.length) { 118 thread.actions.insertReplies(replyToUri, posts) 119 } 120 } 121 }, 122 ) 123 const onReplyToAnchor = useNonReactiveCallback(() => { 124 if (anchor?.type !== 'threadPost') { 125 return 126 } 127 const post = anchor.value.post 128 openComposer({ 129 replyTo: { 130 uri: anchor.uri, 131 cid: post.cid, 132 text: post.record.text, 133 author: post.author, 134 embed: post.embed, 135 moderation: anchor.moderation, 136 langs: post.record.langs, 137 }, 138 onPostSuccess: optimisticOnPostReply, 139 logContext: 'PostReply', 140 }) 141 142 if (anchorPostSource) { 143 feedFeedback.sendInteraction({ 144 item: post.uri, 145 event: 'app.bsky.feed.defs#interactionReply', 146 feedContext: anchorPostSource.post.feedContext, 147 reqId: anchorPostSource.post.reqId, 148 }) 149 } 150 }) 151 152 const isRoot = !!anchor && anchor.value.post.record.reply === undefined 153 const canReply = !anchor?.value.post?.viewer?.replyDisabled 154 const alsoLikedFeedEnabled = useAlsoLikedFeedEnabled() 155 const alsoLikedCollapseByDefault = useAlsoLikedCollapseByDefault() 156 const alsoLikedAnchorUri = 157 anchor?.type === 'threadPost' && isRoot ? anchor.value.post.uri : undefined 158 const [deferParents, setDeferParents] = useState(true) 159 const [alsoLikedCollapsed, setAlsoLikedCollapsed] = useState( 160 alsoLikedCollapseByDefault, 161 ) 162 useEffect(() => { 163 setAlsoLikedCollapsed(alsoLikedCollapseByDefault) 164 }, [alsoLikedAnchorUri, alsoLikedCollapseByDefault]) 165 const alsoLikedVisible = 166 Boolean(alsoLikedAnchorUri) && 167 alsoLikedFeedEnabled && 168 !thread.state.isPlaceholderData && 169 !deferParents 170 const alsoLikedEnabled = alsoLikedVisible && !alsoLikedCollapsed 171 const alsoLiked = usePostAlsoLikedQuery(alsoLikedAnchorUri, { 172 enabled: alsoLikedEnabled, 173 }) 174 const alsoLikedPosts = useMemo(() => { 175 const seen = new Set<string>() 176 return (alsoLiked.data?.pages ?? []) 177 .flatMap(page => page.posts) 178 .filter(post => { 179 if (seen.has(post.uri)) return false 180 seen.add(post.uri) 181 return true 182 }) 183 }, [alsoLiked.data]) 184 const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE) 185 const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE) 186 const totalParentCount = useRef(0) // recomputed below 187 const totalChildrenCount = useRef(thread.data.items.length) // recomputed below 188 const listRef = useRef<ListMethods>(null) 189 const anchorRef = useRef<View | null>(null) 190 const headerRef = useRef<View | null>(null) 191 const alsoLikedHeaderRef = useRef<View | null>(null) 192 const currentScrollOffsetRef = useRef(0) 193 const scrollStateRequestIdRef = useRef(0) 194 const scrollStateAnimationFrameRef = useRef<number | null>(null) 195 const contentSizeAnimationFrameRef = useRef<number | null>(null) 196 const [isAlsoLikedFocused, setIsAlsoLikedFocused] = useState(false) 197 198 useEffect(() => { 199 if (!alsoLikedVisible || alsoLikedCollapsed) { 200 setIsAlsoLikedFocused(false) 201 } 202 }, [alsoLikedCollapsed, alsoLikedVisible]) 203 204 /* 205 * On a cold load, parents are not prepended until the anchor post has 206 * rendered as the first item in the list. This gives us a consistent 207 * reference point for which to pin the anchor post to the top of the screen. 208 * 209 * We simulate a cold load any time the user changes the view or sort params 210 * so that this handling is consistent. 211 * 212 * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives 213 * us this for free, since the anchor post is the first item in the list. 214 * 215 * On web, `onContentSizeChange` is used to get ahead of next paint and handle 216 * this scrolling. 217 */ 218 /** 219 * Used to flag whether we should scroll to the anchor post. On a cold load, 220 * this is always true. And when a user changes thread parameters, we also 221 * manually set this to true. 222 */ 223 const shouldHandleScroll = useRef(true) 224 /** 225 * Called any time the content size of the list changes. Could be a fresh 226 * render, items being added to the list, or any resize that changes the 227 * scrollable size of the content. 228 * 229 * We want this to fire every time we change params (which will reset 230 * `deferParents` via `onLayout` on the anchor post, due to the key change), 231 * or click into a new post (which will result in a fresh `deferParents` 232 * hook). 233 * 234 * The result being: any intentional change in view by the user will result 235 * in the anchor being pinned as the first item. 236 */ 237 const onContentSizeChangeWebOnly = web( 238 useNonReactiveCallback(() => { 239 if (contentSizeAnimationFrameRef.current !== null) { 240 cancelAnimationFrame(contentSizeAnimationFrameRef.current) 241 } 242 243 contentSizeAnimationFrameRef.current = requestAnimationFrame(() => { 244 contentSizeAnimationFrameRef.current = null 245 const list = listRef.current 246 const anchorElement = anchorRef.current as any as Element 247 const header = headerRef.current as any as Element 248 249 if (list && anchorElement && header && shouldHandleScroll.current) { 250 const anchorOffsetTop = anchorElement.getBoundingClientRect().top 251 const headerHeight = header.getBoundingClientRect().height 252 253 /* 254 * `deferParents` is `true` on a cold load, and always reset to 255 * `true` when params change via `prepareForParamsUpdate`. 256 * 257 * On a cold load or a push to a new post, on the first pass of this 258 * logic, the anchor post is the first item in the list. Therefore 259 * `anchorOffsetTop - headerHeight` will be 0. 260 * 261 * When a user changes thread params, on the first pass of this logic, 262 * the anchor post may not move (if there are no parents above it), or it 263 * may have gone off the screen above, because of the sudden lack of 264 * parents due to `deferParents === true`. This negative value (minus 265 * `headerHeight`) will result in a _negative_ `offset` value, which will 266 * scroll the anchor post _down_ to the top of the screen. 267 * 268 * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user 269 * changes params, the anchor post's offset will actually be equivalent 270 * to the `headerHeight` because of how the DOM is stacked on web. 271 * Therefore, `anchorOffsetTop - headerHeight` will once again be 0, 272 * which means the first pass in this case will result in no scroll. 273 * 274 * Then, once parents are prepended, this will fire again. Now, the 275 * `anchorOffsetTop` will be positive, which minus the header height, 276 * will give us a _positive_ offset, which will scroll the anchor post 277 * back _up_ to the top of the screen. 278 */ 279 const offset = anchorOffsetTop - headerHeight 280 list.scrollToOffset({offset}) 281 282 /* 283 * After we manage to do a positive adjustment, we need to ensure this 284 * doesn't run again until scroll handling is requested again via 285 * `shouldHandleScroll.current === true` and a params change via 286 * `prepareForParamsUpdate`. 287 * 288 * The `isRoot` here is needed because if we're looking at the anchor 289 * post, this handler will not fire after `deferParents` is set to 290 * `false`, since there are no parents to render above it. In this case, 291 * we want to make sure `shouldHandleScroll` is set to `false` right away 292 * so that subsequent size changes unrelated to a params change (like 293 * pagination) do not affect scroll. 294 */ 295 if (offset > 0 || isRoot) shouldHandleScroll.current = false 296 } 297 }) 298 }), 299 ) 300 301 /** 302 * Ditto the above, but for native. 303 */ 304 const onContentSizeChangeNativeOnly = native(() => { 305 const list = listRef.current 306 const anchorElement = anchorRef.current 307 308 if (list && anchorElement && shouldHandleScroll.current) { 309 /* 310 * `prepareForParamsUpdate` is called any time the user changes thread params like 311 * `view` or `sort`, which sets `deferParents(true)` and resets the 312 * scroll to the top of the list. However, there is a split second 313 * where the top of the list is wherever the parents _just were_. So if 314 * there were parents, the anchor is not at the top of the list just 315 * prior to this handler being called. 316 * 317 * Once this handler is called, the anchor post is the first item in 318 * the list (because of `deferParents` being `true`), and so we can 319 * synchronously scroll the list back to the top of the list (which is 320 * 0 on native, no need to handle `headerHeight`). 321 */ 322 list.scrollToOffset({ 323 animated: false, 324 offset: 0, 325 }) 326 327 /* 328 * After this first pass, `deferParents` will be `false`, and those 329 * will render in. However, the anchor post will retain its position 330 * because of `maintainVisibleContentPosition` handling on native. So we 331 * don't need to let this handler run again, like we do on web. 332 */ 333 shouldHandleScroll.current = false 334 } 335 }) 336 337 /** 338 * Called any time the user changes thread params, such as `view` or `sort`. 339 * Prepares the UI for repositioning of the scroll so that the anchor post is 340 * always at the top after a params change. 341 * 342 * No need to handle max parents here, deferParents will handle that and we 343 * want it to re-render with the same items above the anchor. 344 */ 345 const prepareForParamsUpdate = useCallback(() => { 346 /** 347 * Truncate list so that anchor post is the first item in the list. Manual 348 * scroll handling on web is predicated on this, and on native, this allows 349 * `maintainVisibleContentPosition` to do its thing. 350 */ 351 setDeferParents(true) 352 // reset this to a lower value for faster re-render 353 setMaxChildrenCount(CHILDREN_CHUNK_SIZE) 354 // set flag 355 shouldHandleScroll.current = true 356 }, [setDeferParents, setMaxChildrenCount]) 357 358 const setSortWrapped = useCallback( 359 (sort: string) => { 360 prepareForParamsUpdate() 361 thread.actions.setSort(sort) 362 }, 363 [thread, prepareForParamsUpdate], 364 ) 365 366 const setViewWrapped = useCallback( 367 (view: ThreadViewOption) => { 368 prepareForParamsUpdate() 369 thread.actions.setView(view) 370 }, 371 [thread, prepareForParamsUpdate], 372 ) 373 374 const onStartReached = () => { 375 if (thread.state.isFetching) return 376 // can be true after `prepareForParamsUpdate` is called 377 if (deferParents) return 378 // prevent any state mutations if we know we're done 379 if (maxParentCount >= totalParentCount.current) return 380 setMaxParentCount(n => n + PARENT_CHUNK_SIZE) 381 } 382 383 const onEndReached = () => { 384 if (thread.state.isFetching) return 385 // can be true after `prepareForParamsUpdate` is called 386 if (deferParents) return 387 if (maxChildrenCount < totalChildrenCount.current) { 388 setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE) 389 return 390 } 391 if ( 392 alsoLikedAnchorUri && 393 alsoLikedEnabled && 394 !alsoLiked.isLoading && 395 !alsoLiked.isFetchingNextPage && 396 alsoLiked.hasNextPage 397 ) { 398 void alsoLiked.fetchNextPage() 399 } 400 } 401 402 const slices = useMemo(() => { 403 const results: ThreadItem[] = [] 404 405 if (!thread.data.items.length) return results 406 407 /* 408 * Pagination hack, tracks the # of items below the anchor post. 409 */ 410 let childrenCount = 0 411 412 for (let i = 0; i < thread.data.items.length; i++) { 413 const item = thread.data.items[i] 414 /* 415 * Need to check `depth`, since not found or blocked posts are not 416 * `threadPost`s, but still have `depth`. 417 */ 418 const hasDepth = 'depth' in item 419 420 /* 421 * Handle anchor post. 422 */ 423 if (hasDepth && item.depth === 0) { 424 results.push(item) 425 426 // Recalculate total parents current index. 427 totalParentCount.current = i 428 // Recalculate total children using (length - 1) - current index. 429 totalChildrenCount.current = thread.data.items.length - 1 - i 430 431 /* 432 * Walk up the parents, limiting by `maxParentCount` 433 */ 434 if (!deferParents) { 435 const start = i - 1 436 if (start >= 0) { 437 const limit = Math.max(0, start - maxParentCount) 438 for (let pi = start; pi >= limit; pi--) { 439 results.unshift(thread.data.items[pi]) 440 } 441 } 442 } 443 } else { 444 // ignore any parent items 445 if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue 446 // can exit early if we've reached the max children count 447 if (childrenCount > maxChildrenCount) break 448 449 results.push(item) 450 childrenCount++ 451 } 452 } 453 454 return results 455 }, [thread, deferParents, maxParentCount, maxChildrenCount]) 456 457 /** 458 * Defer rendering reply skeletons so that the anchor post (from cache) 459 * can paint without being blocked by skeleton layout work. On mount, 460 * skeletons are filtered out. After the first render, they're added 461 * back via a low-priority transition. 462 */ 463 const [showReplySkeletons, setShowReplySkeletons] = useState(false) 464 useEffect(() => { 465 if (thread.state.isPlaceholderData && !showReplySkeletons) { 466 startTransition(() => { 467 setShowReplySkeletons(true) 468 }) 469 } 470 }, [thread.state.isPlaceholderData, showReplySkeletons]) 471 472 const deferredSlices = useMemo(() => { 473 if (showReplySkeletons) return slices 474 return slices.filter( 475 item => !(item.type === 'skeleton' && item.item === 'reply'), 476 ) 477 }, [slices, showReplySkeletons]) 478 479 const isTombstoneView = useMemo(() => { 480 if (deferredSlices.length > 1) return false 481 return deferredSlices.every( 482 s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound', 483 ) 484 }, [deferredSlices]) 485 486 const renderItem = useCallback( 487 ({item, index}: {item: ThreadItem; index: number}) => { 488 if (item.type === 'threadPost') { 489 if (item.depth < 0) { 490 return ( 491 <ThreadItemPost 492 item={item} 493 threadgateRecord={thread.data.threadgate?.record ?? undefined} 494 overrides={{ 495 topBorder: index === 0, 496 }} 497 onPostSuccess={optimisticOnPostReply} 498 /> 499 ) 500 } else if (item.depth === 0) { 501 return ( 502 /* 503 * Keep this view wrapped so that the anchor post is always index 0 504 * in the list and `maintainVisibleContentPosition` can do its 505 * thing. 506 */ 507 <View collapsable={false}> 508 <View 509 /* 510 * IMPORTANT: this is a load-bearing key on all platforms. We 511 * want to force `onLayout` to fire any time the thread params 512 * change so that `deferParents` is always reset to `false` once 513 * the anchor post is rendered. 514 * 515 * If we ever add additional thread params to this screen, they 516 * will need to be added here. 517 */ 518 key={item.uri + thread.state.view + thread.state.sort} 519 ref={anchorRef} 520 onLayout={() => setDeferParents(false)} 521 /> 522 <ThreadItemAnchor 523 item={item} 524 threadgateRecord={thread.data.threadgate?.record ?? undefined} 525 onPostSuccess={optimisticOnPostReply} 526 postSource={anchorPostSource} 527 /> 528 </View> 529 ) 530 } else { 531 if (thread.state.view === 'tree') { 532 return ( 533 <ThreadItemTreePost 534 item={item} 535 threadgateRecord={thread.data.threadgate?.record ?? undefined} 536 overrides={{ 537 moderation: thread.state.otherItemsVisible && item.depth > 0, 538 }} 539 onPostSuccess={optimisticOnPostReply} 540 /> 541 ) 542 } else { 543 return ( 544 <ThreadItemPost 545 item={item} 546 threadgateRecord={thread.data.threadgate?.record ?? undefined} 547 overrides={{ 548 moderation: thread.state.otherItemsVisible && item.depth > 0, 549 }} 550 onPostSuccess={optimisticOnPostReply} 551 /> 552 ) 553 } 554 } 555 } else if (item.type === 'threadPostNoUnauthenticated') { 556 if (item.depth < 0) { 557 return <ThreadItemPostNoUnauthenticated item={item} /> 558 } else if (item.depth === 0) { 559 return <ThreadItemAnchorNoUnauthenticated /> 560 } 561 } else if (item.type === 'readMore') { 562 return ( 563 <ThreadItemReadMore 564 item={item} 565 view={thread.state.view === 'tree' ? 'tree' : 'linear'} 566 /> 567 ) 568 } else if (item.type === 'readMoreUp') { 569 return <ThreadItemReadMoreUp item={item} /> 570 } else if (item.type === 'threadPostBlocked') { 571 return <ThreadItemPostTombstone type="blocked" /> 572 } else if (item.type === 'threadPostNotFound') { 573 return <ThreadItemPostTombstone type="not-found" /> 574 } else if (item.type === 'replyComposer') { 575 return ( 576 <View> 577 {gtMobile && ( 578 <ThreadComposePrompt onPressCompose={onReplyToAnchor} /> 579 )} 580 </View> 581 ) 582 } else if (item.type === 'showOtherReplies') { 583 return <ThreadItemShowOtherReplies onPress={item.onPress} /> 584 } else if (item.type === 'skeleton') { 585 if (item.item === 'anchor') { 586 return <ThreadItemAnchorSkeleton /> 587 } else if (item.item === 'reply') { 588 if (thread.state.view === 'linear') { 589 return <ThreadItemPostSkeleton index={index} /> 590 } else { 591 return <ThreadItemTreePostSkeleton index={index} /> 592 } 593 } else if (item.item === 'replyComposer') { 594 return <ThreadItemReplyComposerSkeleton /> 595 } 596 } 597 return null 598 }, 599 [ 600 thread, 601 optimisticOnPostReply, 602 onReplyToAnchor, 603 gtMobile, 604 anchorPostSource, 605 ], 606 ) 607 608 const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined 609 const retryAlsoLiked = useCallback(() => { 610 if (alsoLikedPosts.length > 0) { 611 void alsoLiked.fetchNextPage() 612 } else { 613 void alsoLiked.refetch() 614 } 615 }, [alsoLiked, alsoLikedPosts.length]) 616 const toggleAlsoLikedCollapsed = useCallback(() => { 617 setAlsoLikedCollapsed(current => !current) 618 }, []) 619 const runAlsoLikedScrollStateUpdate = useNonReactiveCallback( 620 (requestId: number) => { 621 scrollStateAnimationFrameRef.current = null 622 623 if ( 624 !alsoLikedVisible || 625 alsoLikedCollapsed || 626 !alsoLikedHeaderRef.current || 627 !headerRef.current 628 ) { 629 setIsAlsoLikedFocused(false) 630 return 631 } 632 633 measureViewRect(headerRef.current, headerRect => { 634 if (requestId !== scrollStateRequestIdRef.current) return 635 if (!headerRect) { 636 setIsAlsoLikedFocused(false) 637 return 638 } 639 640 measureViewRect(alsoLikedHeaderRef.current, alsoLikedRect => { 641 if (requestId !== scrollStateRequestIdRef.current) return 642 if (!alsoLikedRect) { 643 setIsAlsoLikedFocused(false) 644 return 645 } 646 647 const headerBottom = headerRect.y + headerRect.height 648 const focused = alsoLikedRect.y <= headerBottom 649 650 setIsAlsoLikedFocused(current => 651 current === focused ? current : focused, 652 ) 653 }) 654 }) 655 }, 656 ) 657 const scheduleAlsoLikedScrollStateUpdate = useNonReactiveCallback(() => { 658 if (scrollStateAnimationFrameRef.current !== null) { 659 cancelAnimationFrame(scrollStateAnimationFrameRef.current) 660 } 661 662 const requestId = scrollStateRequestIdRef.current 663 scrollStateAnimationFrameRef.current = requestAnimationFrame(() => { 664 runAlsoLikedScrollStateUpdate(requestId) 665 }) 666 }) 667 const handleScrollOffsetChange = useNonReactiveCallback((offsetY: number) => { 668 currentScrollOffsetRef.current = offsetY 669 if (offsetY <= 1) { 670 scrollStateRequestIdRef.current += 1 671 setIsAlsoLikedFocused(false) 672 return 673 } 674 if (!alsoLikedVisible || alsoLikedCollapsed) { 675 return 676 } 677 scrollStateRequestIdRef.current += 1 678 scheduleAlsoLikedScrollStateUpdate() 679 }) 680 681 useEffect(() => { 682 return () => { 683 if (scrollStateAnimationFrameRef.current !== null) { 684 cancelAnimationFrame(scrollStateAnimationFrameRef.current) 685 } 686 if (contentSizeAnimationFrameRef.current !== null) { 687 cancelAnimationFrame(contentSizeAnimationFrameRef.current) 688 } 689 } 690 }, []) 691 692 useEffect(() => { 693 scrollStateRequestIdRef.current += 1 694 scheduleAlsoLikedScrollStateUpdate() 695 }, [ 696 alsoLikedCollapsed, 697 alsoLikedPosts.length, 698 alsoLikedVisible, 699 scheduleAlsoLikedScrollStateUpdate, 700 ]) 701 702 return ( 703 <PostThreadContextProvider context={thread.context}> 704 <Layout.Header.Outer headerRef={headerRef}> 705 <Layout.Header.BackButton /> 706 <Layout.Header.Content> 707 <Layout.Header.TitleText> 708 {isAlsoLikedFocused ? ( 709 <Trans>Posts also liked</Trans> 710 ) : ( 711 <Trans context="description">Post</Trans> 712 )} 713 </Layout.Header.TitleText> 714 </Layout.Header.Content> 715 <Layout.Header.Slot> 716 <HeaderDropdown 717 sort={thread.state.sort} 718 setSort={setSortWrapped} 719 view={thread.state.view} 720 setView={setViewWrapped} 721 /> 722 </Layout.Header.Slot> 723 </Layout.Header.Outer> 724 725 {thread.state.error ? ( 726 <ThreadError 727 error={thread.state.error} 728 onRetry={thread.actions.refetch} 729 /> 730 ) : ( 731 <List 732 ref={listRef} 733 data={deferredSlices} 734 renderItem={renderItem} 735 keyExtractor={keyExtractor} 736 onContentSizeChange={platform({ 737 web: onContentSizeChangeWebOnly, 738 default: onContentSizeChangeNativeOnly, 739 })} 740 onStartReached={onStartReached} 741 onEndReached={onEndReached} 742 onEndReachedThreshold={4} 743 onStartReachedThreshold={1} 744 onScrollOffsetChange={handleScrollOffsetChange} 745 onItemSeen={item => { 746 // Track post:view for parent posts and replies (non-anchor posts) 747 if (item.type === 'threadPost' && item.depth !== 0) { 748 trackThreadItemView(item.value.post) 749 } 750 }} 751 /** 752 * NATIVE ONLY 753 * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition} 754 */ 755 maintainVisibleContentPosition={{minIndexForVisible: 0}} 756 desktopFixedHeight 757 sideBorders={false} 758 ListFooterComponent={ 759 <ThreadAlsoLiked 760 posts={alsoLikedPosts} 761 visible={alsoLikedVisible} 762 collapsed={alsoLikedCollapsed} 763 isLoading={alsoLiked.isLoading} 764 showLoadingState={ 765 !alsoLikedCollapsed && 766 !alsoLiked.error && 767 alsoLikedPosts.length === 0 && 768 (!alsoLiked.isFetched || alsoLiked.isLoading) 769 } 770 isFetchingNextPage={alsoLiked.isFetchingNextPage} 771 error={alsoLiked.error} 772 onRetry={retryAlsoLiked} 773 headerRef={alsoLikedHeaderRef} 774 onToggleCollapsed={toggleAlsoLikedCollapsed} 775 spacerHeight={platform({ 776 web: defaultListFooterHeight, 777 default: deferParents 778 ? windowHeight * 2 779 : defaultListFooterHeight, 780 })} 781 isTombstoneView={isTombstoneView} 782 /> 783 } 784 initialNumToRender={initialNumToRender} 785 /** 786 * Default: 21 787 * 788 * Smaller for placeholder data so we don't waste time rendering skeletons 789 */ 790 windowSize={thread.state.isPlaceholderData ? 1 : 7} 791 /** 792 * Default: 10 793 */ 794 maxToRenderPerBatch={5} 795 /** 796 * Default: 50 797 */ 798 updateCellsBatchingPeriod={100} 799 /> 800 )} 801 802 {!gtMobile && canReply && hasSession && ( 803 <MobileComposePrompt onPressReply={onReplyToAnchor} /> 804 )} 805 </PostThreadContextProvider> 806 ) 807} 808 809function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { 810 const {footerHeight} = useShellLayout() 811 812 const animatedStyle = useAnimatedStyle(() => { 813 return { 814 bottom: footerHeight.get(), 815 } 816 }) 817 818 return ( 819 <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> 820 <ThreadComposePrompt onPressCompose={onPressReply} /> 821 </Animated.View> 822 ) 823} 824 825const keyExtractor = (item: ThreadItem) => { 826 return item.key 827} 828 829type ViewRect = { 830 x: number 831 y: number 832 width: number 833 height: number 834} 835 836function measureViewRect( 837 view: View | null, 838 cb: (rect: ViewRect | null) => void, 839) { 840 const target = view as any 841 if (!target) { 842 cb(null) 843 return 844 } 845 846 if (typeof target.measureInWindow === 'function') { 847 target.measureInWindow( 848 (x: number, y: number, width: number, height: number) => { 849 cb({x, y, width, height}) 850 }, 851 ) 852 return 853 } 854 855 if (typeof target.getBoundingClientRect === 'function') { 856 const rect = target.getBoundingClientRect() 857 cb({ 858 x: rect.left, 859 y: rect.top, 860 width: rect.width, 861 height: rect.height, 862 }) 863 return 864 } 865 866 if (typeof target.measure === 'function') { 867 target.measure( 868 ( 869 _x: number, 870 _y: number, 871 width: number, 872 height: number, 873 pageX: number, 874 pageY: number, 875 ) => { 876 cb({x: pageX, y: pageY, width, height}) 877 }, 878 ) 879 return 880 } 881 882 cb(null) 883}