this repo has no description
0
fork

Configure Feed

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

at main 660 lines 24 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 {type ThreadViewOption} from '#/state/queries/preferences/useThreadPreferences' 19import { 20 PostThreadContextProvider, 21 type ThreadItem, 22 usePostThread, 23} from '#/state/queries/usePostThread' 24import {useSession} from '#/state/session' 25import {type OnPostSuccessData} from '#/state/shell/composer' 26import {useShellLayout} from '#/state/shell/shell-layout' 27import {useUnstablePostSource} from '#/state/unstable-post-source' 28import {List, type ListMethods} from '#/view/com/util/List' 29import {HeaderDropdown} from '#/screens/PostThread/components/HeaderDropdown' 30import {ThreadComposePrompt} from '#/screens/PostThread/components/ThreadComposePrompt' 31import {ThreadError} from '#/screens/PostThread/components/ThreadError' 32import { 33 ThreadItemAnchor, 34 ThreadItemAnchorSkeleton, 35} from '#/screens/PostThread/components/ThreadItemAnchor' 36import {ThreadItemAnchorNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemAnchorNoUnauthenticated' 37import { 38 ThreadItemPost, 39 ThreadItemPostSkeleton, 40} from '#/screens/PostThread/components/ThreadItemPost' 41import {ThreadItemPostNoUnauthenticated} from '#/screens/PostThread/components/ThreadItemPostNoUnauthenticated' 42import {ThreadItemPostTombstone} from '#/screens/PostThread/components/ThreadItemPostTombstone' 43import {ThreadItemReadMore} from '#/screens/PostThread/components/ThreadItemReadMore' 44import {ThreadItemReadMoreUp} from '#/screens/PostThread/components/ThreadItemReadMoreUp' 45import {ThreadItemReplyComposerSkeleton} from '#/screens/PostThread/components/ThreadItemReplyComposer' 46import {ThreadItemShowOtherReplies} from '#/screens/PostThread/components/ThreadItemShowOtherReplies' 47import { 48 ThreadItemTreePost, 49 ThreadItemTreePostSkeleton, 50} from '#/screens/PostThread/components/ThreadItemTreePost' 51import {atoms as a, native, platform, useBreakpoints, web} from '#/alf' 52import * as Layout from '#/components/Layout' 53import {ListFooter} from '#/components/Lists' 54import {useAnalytics} from '#/analytics' 55 56const PARENT_CHUNK_SIZE = 20 57const CHILDREN_CHUNK_SIZE = 50 58 59export function PostThread({uri}: {uri: string}) { 60 const ax = useAnalytics() 61 const {gtMobile} = useBreakpoints() 62 const {hasSession} = useSession() 63 const initialNumToRender = useInitialNumToRender() 64 const {height: windowHeight} = useWindowDimensions() 65 const anchorPostSource = useUnstablePostSource(uri) 66 const feedFeedback = useFeedFeedback( 67 anchorPostSource?.feedSourceInfo, 68 hasSession, 69 ) 70 71 /* 72 * One query to rule them all 73 */ 74 const thread = usePostThread({anchor: uri}) 75 const {anchor, hasParents} = useMemo(() => { 76 let hasParents = false 77 for (const item of thread.data.items) { 78 if (item.type === 'threadPost' && item.depth === 0) { 79 return {anchor: item, hasParents} 80 } 81 hasParents = true 82 } 83 return {hasParents} 84 }, [thread.data.items]) 85 86 // Track post:view event when anchor post is viewed 87 const seenPostUriRef = useRef<string | null>(null) 88 useEffect(() => { 89 if ( 90 anchor?.type === 'threadPost' && 91 anchor.value.post.uri !== seenPostUriRef.current 92 ) { 93 const post = anchor.value.post 94 seenPostUriRef.current = post.uri 95 96 ax.metric('post:view', { 97 uri: post.uri, 98 authorDid: post.author.did, 99 logContext: 'Post', 100 feedDescriptor: feedFeedback.feedDescriptor, 101 }) 102 } 103 }, [ax, anchor, feedFeedback.feedDescriptor]) 104 105 // Track post:view events for parent posts and replies (non-anchor posts) 106 const trackThreadItemView = usePostViewTracking('PostThreadItem') 107 108 const {openComposer} = useOpenComposer() 109 const optimisticOnPostReply = useNonReactiveCallback( 110 (payload: OnPostSuccessData) => { 111 if (payload) { 112 const {replyToUri, posts} = payload 113 if (replyToUri && posts.length) { 114 thread.actions.insertReplies(replyToUri, posts) 115 } 116 } 117 }, 118 ) 119 const onReplyToAnchor = useNonReactiveCallback(() => { 120 if (anchor?.type !== 'threadPost') { 121 return 122 } 123 const post = anchor.value.post 124 openComposer({ 125 replyTo: { 126 uri: anchor.uri, 127 cid: post.cid, 128 text: post.record.text, 129 author: post.author, 130 embed: post.embed, 131 moderation: anchor.moderation, 132 langs: post.record.langs, 133 }, 134 onPostSuccess: optimisticOnPostReply, 135 logContext: 'PostReply', 136 }) 137 138 if (anchorPostSource) { 139 feedFeedback.sendInteraction({ 140 item: post.uri, 141 event: 'app.bsky.feed.defs#interactionReply', 142 feedContext: anchorPostSource.post.feedContext, 143 reqId: anchorPostSource.post.reqId, 144 }) 145 } 146 }) 147 148 const isRoot = !!anchor && anchor.value.post.record.reply === undefined 149 const canReply = !anchor?.value.post?.viewer?.replyDisabled 150 const [maxParentCount, setMaxParentCount] = useState(PARENT_CHUNK_SIZE) 151 const [maxChildrenCount, setMaxChildrenCount] = useState(CHILDREN_CHUNK_SIZE) 152 const totalParentCount = useRef(0) // recomputed below 153 const totalChildrenCount = useRef(thread.data.items.length) // recomputed below 154 const listRef = useRef<ListMethods>(null) 155 const anchorRef = useRef<View | null>(null) 156 const headerRef = useRef<View | null>(null) 157 158 /* 159 * On a cold load, parents are not prepended until the anchor post has 160 * rendered as the first item in the list. This gives us a consistent 161 * reference point for which to pin the anchor post to the top of the screen. 162 * 163 * We simulate a cold load any time the user changes the view or sort params 164 * so that this handling is consistent. 165 * 166 * On native, `maintainVisibleContentPosition={{minIndexForVisible: 0}}` gives 167 * us this for free, since the anchor post is the first item in the list. 168 * 169 * On web, `onContentSizeChange` is used to get ahead of next paint and handle 170 * this scrolling. 171 */ 172 const [deferParents, setDeferParents] = useState(true) 173 /** 174 * Used to flag whether we should scroll to the anchor post. On a cold load, 175 * this is always true. And when a user changes thread parameters, we also 176 * manually set this to true. 177 */ 178 const shouldHandleScroll = useRef(true) 179 /** 180 * Called any time the content size of the list changes. Could be a fresh 181 * render, items being added to the list, or any resize that changes the 182 * scrollable size of the content. 183 * 184 * We want this to fire every time we change params (which will reset 185 * `deferParents` via `onLayout` on the anchor post, due to the key change), 186 * or click into a new post (which will result in a fresh `deferParents` 187 * hook). 188 * 189 * The result being: any intentional change in view by the user will result 190 * in the anchor being pinned as the first item. 191 */ 192 const onContentSizeChangeWebOnly = web(() => { 193 const list = listRef.current 194 const anchorElement = anchorRef.current as any as Element 195 const header = headerRef.current as any as Element 196 197 if (list && anchorElement && header && shouldHandleScroll.current) { 198 const anchorOffsetTop = anchorElement.getBoundingClientRect().top 199 const headerHeight = header.getBoundingClientRect().height 200 201 /* 202 * `deferParents` is `true` on a cold load, and always reset to 203 * `true` when params change via `prepareForParamsUpdate`. 204 * 205 * On a cold load or a push to a new post, on the first pass of this 206 * logic, the anchor post is the first item in the list. Therefore 207 * `anchorOffsetTop - headerHeight` will be 0. 208 * 209 * When a user changes thread params, on the first pass of this logic, 210 * the anchor post may not move (if there are no parents above it), or it 211 * may have gone off the screen above, because of the sudden lack of 212 * parents due to `deferParents === true`. This negative value (minus 213 * `headerHeight`) will result in a _negative_ `offset` value, which will 214 * scroll the anchor post _down_ to the top of the screen. 215 * 216 * However, `prepareForParamsUpdate` also resets scroll to `0`, so when a user 217 * changes params, the anchor post's offset will actually be equivalent 218 * to the `headerHeight` because of how the DOM is stacked on web. 219 * Therefore, `anchorOffsetTop - headerHeight` will once again be 0, 220 * which means the first pass in this case will result in no scroll. 221 * 222 * Then, once parents are prepended, this will fire again. Now, the 223 * `anchorOffsetTop` will be positive, which minus the header height, 224 * will give us a _positive_ offset, which will scroll the anchor post 225 * back _up_ to the top of the screen. 226 */ 227 const offset = anchorOffsetTop - headerHeight 228 list.scrollToOffset({offset}) 229 230 /* 231 * After we manage to do a positive adjustment, we need to ensure this 232 * doesn't run again until scroll handling is requested again via 233 * `shouldHandleScroll.current === true` and a params change via 234 * `prepareForParamsUpdate`. 235 * 236 * The `isRoot` here is needed because if we're looking at the anchor 237 * post, this handler will not fire after `deferParents` is set to 238 * `false`, since there are no parents to render above it. In this case, 239 * we want to make sure `shouldHandleScroll` is set to `false` right away 240 * so that subsequent size changes unrelated to a params change (like 241 * pagination) do not affect scroll. 242 */ 243 if (offset > 0 || isRoot) shouldHandleScroll.current = false 244 } 245 }) 246 247 /** 248 * Ditto the above, but for native. 249 */ 250 const onContentSizeChangeNativeOnly = native(() => { 251 const list = listRef.current 252 const anchorElement = anchorRef.current 253 254 if (list && anchorElement && shouldHandleScroll.current) { 255 /* 256 * `prepareForParamsUpdate` is called any time the user changes thread params like 257 * `view` or `sort`, which sets `deferParents(true)` and resets the 258 * scroll to the top of the list. However, there is a split second 259 * where the top of the list is wherever the parents _just were_. So if 260 * there were parents, the anchor is not at the top of the list just 261 * prior to this handler being called. 262 * 263 * Once this handler is called, the anchor post is the first item in 264 * the list (because of `deferParents` being `true`), and so we can 265 * synchronously scroll the list back to the top of the list (which is 266 * 0 on native, no need to handle `headerHeight`). 267 */ 268 list.scrollToOffset({ 269 animated: false, 270 offset: 0, 271 }) 272 273 /* 274 * After this first pass, `deferParents` will be `false`, and those 275 * will render in. However, the anchor post will retain its position 276 * because of `maintainVisibleContentPosition` handling on native. So we 277 * don't need to let this handler run again, like we do on web. 278 */ 279 shouldHandleScroll.current = false 280 } 281 }) 282 283 /** 284 * Called any time the user changes thread params, such as `view` or `sort`. 285 * Prepares the UI for repositioning of the scroll so that the anchor post is 286 * always at the top after a params change. 287 * 288 * No need to handle max parents here, deferParents will handle that and we 289 * want it to re-render with the same items above the anchor. 290 */ 291 const prepareForParamsUpdate = useCallback(() => { 292 /** 293 * Truncate list so that anchor post is the first item in the list. Manual 294 * scroll handling on web is predicated on this, and on native, this allows 295 * `maintainVisibleContentPosition` to do its thing. 296 */ 297 setDeferParents(true) 298 // reset this to a lower value for faster re-render 299 setMaxChildrenCount(CHILDREN_CHUNK_SIZE) 300 // set flag 301 shouldHandleScroll.current = true 302 }, [setDeferParents, setMaxChildrenCount]) 303 304 const setSortWrapped = useCallback( 305 (sort: string) => { 306 prepareForParamsUpdate() 307 thread.actions.setSort(sort) 308 }, 309 [thread, prepareForParamsUpdate], 310 ) 311 312 const setViewWrapped = useCallback( 313 (view: ThreadViewOption) => { 314 prepareForParamsUpdate() 315 thread.actions.setView(view) 316 }, 317 [thread, prepareForParamsUpdate], 318 ) 319 320 const onStartReached = () => { 321 if (thread.state.isFetching) return 322 // can be true after `prepareForParamsUpdate` is called 323 if (deferParents) return 324 // prevent any state mutations if we know we're done 325 if (maxParentCount >= totalParentCount.current) return 326 setMaxParentCount(n => n + PARENT_CHUNK_SIZE) 327 } 328 329 const onEndReached = () => { 330 if (thread.state.isFetching) return 331 // can be true after `prepareForParamsUpdate` is called 332 if (deferParents) return 333 // prevent any state mutations if we know we're done 334 if (maxChildrenCount >= totalChildrenCount.current) return 335 setMaxChildrenCount(prev => prev + CHILDREN_CHUNK_SIZE) 336 } 337 338 const slices = useMemo(() => { 339 const results: ThreadItem[] = [] 340 341 if (!thread.data.items.length) return results 342 343 /* 344 * Pagination hack, tracks the # of items below the anchor post. 345 */ 346 let childrenCount = 0 347 348 for (let i = 0; i < thread.data.items.length; i++) { 349 const item = thread.data.items[i] 350 /* 351 * Need to check `depth`, since not found or blocked posts are not 352 * `threadPost`s, but still have `depth`. 353 */ 354 const hasDepth = 'depth' in item 355 356 /* 357 * Handle anchor post. 358 */ 359 if (hasDepth && item.depth === 0) { 360 results.push(item) 361 362 // Recalculate total parents current index. 363 totalParentCount.current = i 364 // Recalculate total children using (length - 1) - current index. 365 totalChildrenCount.current = thread.data.items.length - 1 - i 366 367 /* 368 * Walk up the parents, limiting by `maxParentCount` 369 */ 370 if (!deferParents) { 371 const start = i - 1 372 if (start >= 0) { 373 const limit = Math.max(0, start - maxParentCount) 374 for (let pi = start; pi >= limit; pi--) { 375 results.unshift(thread.data.items[pi]) 376 } 377 } 378 } 379 } else { 380 // ignore any parent items 381 if (item.type === 'readMoreUp' || (hasDepth && item.depth < 0)) continue 382 // can exit early if we've reached the max children count 383 if (childrenCount > maxChildrenCount) break 384 385 results.push(item) 386 childrenCount++ 387 } 388 } 389 390 return results 391 }, [thread, deferParents, maxParentCount, maxChildrenCount]) 392 393 /** 394 * Defer rendering reply skeletons so that the anchor post (from cache) 395 * can paint without being blocked by skeleton layout work. On mount, 396 * skeletons are filtered out. After the first render, they're added 397 * back via a low-priority transition. 398 */ 399 const [showReplySkeletons, setShowReplySkeletons] = useState(false) 400 useEffect(() => { 401 if (thread.state.isPlaceholderData && !showReplySkeletons) { 402 startTransition(() => { 403 setShowReplySkeletons(true) 404 }) 405 } 406 }, [thread.state.isPlaceholderData, showReplySkeletons]) 407 408 const deferredSlices = useMemo(() => { 409 if (showReplySkeletons) return slices 410 return slices.filter( 411 item => !(item.type === 'skeleton' && item.item === 'reply'), 412 ) 413 }, [slices, showReplySkeletons]) 414 415 const isTombstoneView = useMemo(() => { 416 if (deferredSlices.length > 1) return false 417 return deferredSlices.every( 418 s => s.type === 'threadPostBlocked' || s.type === 'threadPostNotFound', 419 ) 420 }, [deferredSlices]) 421 422 const renderItem = useCallback( 423 ({item, index}: {item: ThreadItem; index: number}) => { 424 if (item.type === 'threadPost') { 425 if (item.depth < 0) { 426 return ( 427 <ThreadItemPost 428 item={item} 429 threadgateRecord={thread.data.threadgate?.record ?? undefined} 430 overrides={{ 431 topBorder: index === 0, 432 }} 433 onPostSuccess={optimisticOnPostReply} 434 /> 435 ) 436 } else if (item.depth === 0) { 437 return ( 438 /* 439 * Keep this view wrapped so that the anchor post is always index 0 440 * in the list and `maintainVisibleContentPosition` can do its 441 * thing. 442 */ 443 <View collapsable={false}> 444 <View 445 /* 446 * IMPORTANT: this is a load-bearing key on all platforms. We 447 * want to force `onLayout` to fire any time the thread params 448 * change so that `deferParents` is always reset to `false` once 449 * the anchor post is rendered. 450 * 451 * If we ever add additional thread params to this screen, they 452 * will need to be added here. 453 */ 454 key={item.uri + thread.state.view + thread.state.sort} 455 ref={anchorRef} 456 onLayout={() => setDeferParents(false)} 457 /> 458 <ThreadItemAnchor 459 item={item} 460 threadgateRecord={thread.data.threadgate?.record ?? undefined} 461 onPostSuccess={optimisticOnPostReply} 462 postSource={anchorPostSource} 463 /> 464 </View> 465 ) 466 } else { 467 if (thread.state.view === 'tree') { 468 return ( 469 <ThreadItemTreePost 470 item={item} 471 threadgateRecord={thread.data.threadgate?.record ?? undefined} 472 overrides={{ 473 moderation: thread.state.otherItemsVisible && item.depth > 0, 474 }} 475 onPostSuccess={optimisticOnPostReply} 476 /> 477 ) 478 } else { 479 return ( 480 <ThreadItemPost 481 item={item} 482 threadgateRecord={thread.data.threadgate?.record ?? undefined} 483 overrides={{ 484 moderation: thread.state.otherItemsVisible && item.depth > 0, 485 }} 486 onPostSuccess={optimisticOnPostReply} 487 /> 488 ) 489 } 490 } 491 } else if (item.type === 'threadPostNoUnauthenticated') { 492 if (item.depth < 0) { 493 return <ThreadItemPostNoUnauthenticated item={item} /> 494 } else if (item.depth === 0) { 495 return <ThreadItemAnchorNoUnauthenticated /> 496 } 497 } else if (item.type === 'readMore') { 498 return ( 499 <ThreadItemReadMore 500 item={item} 501 view={thread.state.view === 'tree' ? 'tree' : 'linear'} 502 /> 503 ) 504 } else if (item.type === 'readMoreUp') { 505 return <ThreadItemReadMoreUp item={item} /> 506 } else if (item.type === 'threadPostBlocked') { 507 return <ThreadItemPostTombstone type="blocked" /> 508 } else if (item.type === 'threadPostNotFound') { 509 return <ThreadItemPostTombstone type="not-found" /> 510 } else if (item.type === 'replyComposer') { 511 return ( 512 <View> 513 {gtMobile && ( 514 <ThreadComposePrompt onPressCompose={onReplyToAnchor} /> 515 )} 516 </View> 517 ) 518 } else if (item.type === 'showOtherReplies') { 519 return <ThreadItemShowOtherReplies onPress={item.onPress} /> 520 } else if (item.type === 'skeleton') { 521 if (item.item === 'anchor') { 522 return <ThreadItemAnchorSkeleton /> 523 } else if (item.item === 'reply') { 524 if (thread.state.view === 'linear') { 525 return <ThreadItemPostSkeleton index={index} /> 526 } else { 527 return <ThreadItemTreePostSkeleton index={index} /> 528 } 529 } else if (item.item === 'replyComposer') { 530 return <ThreadItemReplyComposerSkeleton /> 531 } 532 } 533 return null 534 }, 535 [ 536 thread, 537 optimisticOnPostReply, 538 onReplyToAnchor, 539 gtMobile, 540 anchorPostSource, 541 ], 542 ) 543 544 const defaultListFooterHeight = hasParents ? windowHeight - 200 : undefined 545 546 return ( 547 <PostThreadContextProvider context={thread.context}> 548 <Layout.Header.Outer headerRef={headerRef}> 549 <Layout.Header.BackButton /> 550 <Layout.Header.Content> 551 <Layout.Header.TitleText> 552 <Trans context="description">Post</Trans> 553 </Layout.Header.TitleText> 554 </Layout.Header.Content> 555 <Layout.Header.Slot> 556 <HeaderDropdown 557 sort={thread.state.sort} 558 setSort={setSortWrapped} 559 view={thread.state.view} 560 setView={setViewWrapped} 561 /> 562 </Layout.Header.Slot> 563 </Layout.Header.Outer> 564 565 {thread.state.error ? ( 566 <ThreadError 567 error={thread.state.error} 568 onRetry={thread.actions.refetch} 569 /> 570 ) : ( 571 <List 572 ref={listRef} 573 data={deferredSlices} 574 renderItem={renderItem} 575 keyExtractor={keyExtractor} 576 onContentSizeChange={platform({ 577 web: onContentSizeChangeWebOnly, 578 default: onContentSizeChangeNativeOnly, 579 })} 580 onStartReached={onStartReached} 581 onEndReached={onEndReached} 582 onEndReachedThreshold={4} 583 onStartReachedThreshold={1} 584 onItemSeen={item => { 585 // Track post:view for parent posts and replies (non-anchor posts) 586 if (item.type === 'threadPost' && item.depth !== 0) { 587 trackThreadItemView(item.value.post) 588 } 589 }} 590 /** 591 * NATIVE ONLY 592 * {@link https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition} 593 */ 594 maintainVisibleContentPosition={{minIndexForVisible: 0}} 595 desktopFixedHeight 596 sideBorders={false} 597 ListFooterComponent={ 598 <ListFooter 599 /* 600 * On native, if `deferParents` is true, we need some extra buffer to 601 * account for the `on*ReachedThreshold` values. 602 * 603 * Otherwise, and on web, this value needs to be the height of 604 * the viewport _minus_ a sensible min-post height e.g. 200, so 605 * that there's enough scroll remaining to get the anchor post 606 * back to the top of the screen when handling scroll. 607 */ 608 height={platform({ 609 web: defaultListFooterHeight, 610 default: deferParents 611 ? windowHeight * 2 612 : defaultListFooterHeight, 613 })} 614 style={isTombstoneView ? {borderTopWidth: 0} : undefined} 615 /> 616 } 617 initialNumToRender={initialNumToRender} 618 /** 619 * Default: 21 620 * 621 * Smaller for placeholder data so we don't waste time rendering skeletons 622 */ 623 windowSize={thread.state.isPlaceholderData ? 1 : 7} 624 /** 625 * Default: 10 626 */ 627 maxToRenderPerBatch={5} 628 /** 629 * Default: 50 630 */ 631 updateCellsBatchingPeriod={100} 632 /> 633 )} 634 635 {!gtMobile && canReply && hasSession && ( 636 <MobileComposePrompt onPressReply={onReplyToAnchor} /> 637 )} 638 </PostThreadContextProvider> 639 ) 640} 641 642function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { 643 const {footerHeight} = useShellLayout() 644 645 const animatedStyle = useAnimatedStyle(() => { 646 return { 647 bottom: footerHeight.get(), 648 } 649 }) 650 651 return ( 652 <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> 653 <ThreadComposePrompt onPressCompose={onPressReply} /> 654 </Animated.View> 655 ) 656} 657 658const keyExtractor = (item: ThreadItem) => { 659 return item.key 660}