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