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

Configure Feed

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

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