mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
0
fork

Configure Feed

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

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