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

Configure Feed

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

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