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