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

Configure Feed

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

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