Bluesky app fork with some witchin' additions 馃挮
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}