Bluesky app fork with some witchin' additions 馃挮 witchsky.app
bluesky fork client
119
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}