mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useRef} from 'react'
2import {StyleSheet, useWindowDimensions, View} from 'react-native'
3import {runOnJS} from 'react-native-reanimated'
4import Animated from 'react-native-reanimated'
5import {useSafeAreaInsets} from 'react-native-safe-area-context'
6import {AppBskyFeedDefs, AppBskyFeedThreadgate} from '@atproto/api'
7import {msg, Trans} from '@lingui/macro'
8import {useLingui} from '@lingui/react'
9
10import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
11import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
12import {useSetTitle} from '#/lib/hooks/useSetTitle'
13import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
14import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
15import {clamp} from '#/lib/numbers'
16import {ScrollProvider} from '#/lib/ScrollContext'
17import {sanitizeDisplayName} from '#/lib/strings/display-names'
18import {cleanError} from '#/lib/strings/errors'
19import {isAndroid, isNative, isWeb} from '#/platform/detection'
20import {useModerationOpts} from '#/state/preferences/moderation-opts'
21import {
22 fillThreadModerationCache,
23 sortThread,
24 ThreadBlocked,
25 ThreadModerationCache,
26 ThreadNode,
27 ThreadNotFound,
28 ThreadPost,
29 usePostThreadQuery,
30} from '#/state/queries/post-thread'
31import {usePreferencesQuery} from '#/state/queries/preferences'
32import {useSession} from '#/state/session'
33import {useComposerControls} from '#/state/shell'
34import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
35import {atoms as a, useTheme} from '#/alf'
36import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
37import {Text} from '#/components/Typography'
38import {List, ListMethods} from '../util/List'
39import {ViewHeader} from '../util/ViewHeader'
40import {PostThreadComposePrompt} from './PostThreadComposePrompt'
41import {PostThreadItem} from './PostThreadItem'
42import {PostThreadLoadMore} from './PostThreadLoadMore'
43import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
44
45// FlatList maintainVisibleContentPosition breaks if too many items
46// are prepended. This seems to be an optimal number based on *shrug*.
47const PARENTS_CHUNK_SIZE = 15
48
49const MAINTAIN_VISIBLE_CONTENT_POSITION = {
50 // We don't insert any elements before the root row while loading.
51 // So the row we want to use as the scroll anchor is the first row.
52 minIndexForVisible: 0,
53}
54
55const REPLY_PROMPT = {_reactKey: '__reply__'}
56const LOAD_MORE = {_reactKey: '__load_more__'}
57const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'}
58const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'}
59
60enum HiddenRepliesState {
61 Hide,
62 Show,
63 ShowAndOverridePostHider,
64}
65
66type YieldedItem =
67 | ThreadPost
68 | ThreadBlocked
69 | ThreadNotFound
70 | typeof SHOW_HIDDEN_REPLIES
71 | typeof SHOW_MUTED_REPLIES
72type RowItem =
73 | YieldedItem
74 // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
75 | typeof REPLY_PROMPT
76 | typeof LOAD_MORE
77
78type ThreadSkeletonParts = {
79 parents: YieldedItem[]
80 highlightedPost: ThreadNode
81 replies: YieldedItem[]
82}
83
84const keyExtractor = (item: RowItem) => {
85 return item._reactKey
86}
87
88export function PostThread({uri}: {uri: string | undefined}) {
89 const {hasSession, currentAccount} = useSession()
90 const {_} = useLingui()
91 const t = useTheme()
92 const {isMobile, isTabletOrMobile} = useWebMediaQueries()
93 const initialNumToRender = useInitialNumToRender()
94 const {height: windowHeight} = useWindowDimensions()
95 const [hiddenRepliesState, setHiddenRepliesState] = React.useState(
96 HiddenRepliesState.Hide,
97 )
98
99 const {data: preferences} = usePreferencesQuery()
100 const {
101 isFetching,
102 isError: isThreadError,
103 error: threadError,
104 refetch,
105 data: {thread, threadgate} = {},
106 dataUpdatedAt: fetchedAt,
107 } = usePostThreadQuery(uri)
108
109 const treeView = React.useMemo(
110 () =>
111 !!preferences?.threadViewPrefs?.lab_treeViewEnabled &&
112 hasBranchingReplies(thread),
113 [preferences?.threadViewPrefs, thread],
114 )
115 const rootPost = thread?.type === 'post' ? thread.post : undefined
116 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
117 const threadgateRecord = threadgate?.record as
118 | AppBskyFeedThreadgate.Record
119 | undefined
120 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
121 threadgateRecord,
122 })
123
124 const moderationOpts = useModerationOpts()
125 const isNoPwi = React.useMemo(() => {
126 const mod =
127 rootPost && moderationOpts
128 ? moderatePost(rootPost, moderationOpts)
129 : undefined
130 return !!mod
131 ?.ui('contentList')
132 .blurs.find(
133 cause =>
134 cause.type === 'label' &&
135 cause.labelDef.identifier === '!no-unauthenticated',
136 )
137 }, [rootPost, moderationOpts])
138
139 // Values used for proper rendering of parents
140 const ref = useRef<ListMethods>(null)
141 const highlightedPostRef = useRef<View | null>(null)
142 const [maxParents, setMaxParents] = React.useState(
143 isWeb ? Infinity : PARENTS_CHUNK_SIZE,
144 )
145 const [maxReplies, setMaxReplies] = React.useState(50)
146
147 useSetTitle(
148 rootPost && !isNoPwi
149 ? `${sanitizeDisplayName(
150 rootPost.author.displayName || `@${rootPost.author.handle}`,
151 )}: "${rootPostRecord!.text}"`
152 : '',
153 )
154
155 // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
156 // This ensures that the first render contains no parents--even if they are already available in the cache.
157 // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen.
158 // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
159 const [deferParents, setDeferParents] = React.useState(isNative)
160
161 const currentDid = currentAccount?.did
162 const threadModerationCache = React.useMemo(() => {
163 const cache: ThreadModerationCache = new WeakMap()
164 if (thread && moderationOpts) {
165 fillThreadModerationCache(cache, thread, moderationOpts)
166 }
167 return cache
168 }, [thread, moderationOpts])
169
170 const [justPostedUris, setJustPostedUris] = React.useState(
171 () => new Set<string>(),
172 )
173
174 const [fetchedAtCache] = React.useState(() => new Map<string, number>())
175 const [randomCache] = React.useState(() => new Map<string, number>())
176 const skeleton = React.useMemo(() => {
177 const threadViewPrefs = preferences?.threadViewPrefs
178 if (!threadViewPrefs || !thread) return null
179
180 return createThreadSkeleton(
181 sortThread(
182 thread,
183 threadViewPrefs,
184 threadModerationCache,
185 currentDid,
186 justPostedUris,
187 threadgateHiddenReplies,
188 fetchedAtCache,
189 fetchedAt,
190 randomCache,
191 ),
192 currentDid,
193 treeView,
194 threadModerationCache,
195 hiddenRepliesState !== HiddenRepliesState.Hide,
196 threadgateHiddenReplies,
197 )
198 }, [
199 thread,
200 preferences?.threadViewPrefs,
201 currentDid,
202 treeView,
203 threadModerationCache,
204 hiddenRepliesState,
205 justPostedUris,
206 threadgateHiddenReplies,
207 fetchedAtCache,
208 fetchedAt,
209 randomCache,
210 ])
211
212 const error = React.useMemo(() => {
213 if (AppBskyFeedDefs.isNotFoundPost(thread)) {
214 return {
215 title: _(msg`Post not found`),
216 message: _(msg`The post may have been deleted.`),
217 }
218 } else if (skeleton?.highlightedPost.type === 'blocked') {
219 return {
220 title: _(msg`Post hidden`),
221 message: _(
222 msg`You have blocked the author or you have been blocked by the author.`,
223 ),
224 }
225 } else if (threadError?.message.startsWith('Post not found')) {
226 return {
227 title: _(msg`Post not found`),
228 message: _(msg`The post may have been deleted.`),
229 }
230 } else if (isThreadError) {
231 return {
232 message: threadError ? cleanError(threadError) : undefined,
233 }
234 }
235
236 return null
237 }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
238
239 // construct content
240 const posts = React.useMemo(() => {
241 if (!skeleton) return []
242
243 const {parents, highlightedPost, replies} = skeleton
244 let arr: RowItem[] = []
245 if (highlightedPost.type === 'post') {
246 // We want to wait for parents to load before rendering.
247 // If you add something here, you'll need to update both
248 // maintainVisibleContentPosition and onContentSizeChange
249 // to "hold onto" the correct row instead of the first one.
250
251 if (!highlightedPost.ctx.isParentLoading && !deferParents) {
252 // When progressively revealing parents, rendering a placeholder
253 // here will cause scrolling jumps. Don't add it unless you test it.
254 // QT'ing this thread is a great way to test all the scrolling hacks:
255 // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
256
257 // Everything is loaded
258 let startIndex = Math.max(0, parents.length - maxParents)
259 for (let i = startIndex; i < parents.length; i++) {
260 arr.push(parents[i])
261 }
262 }
263 arr.push(highlightedPost)
264 if (!highlightedPost.post.viewer?.replyDisabled) {
265 arr.push(REPLY_PROMPT)
266 }
267 for (let i = 0; i < replies.length; i++) {
268 arr.push(replies[i])
269 if (i === maxReplies) {
270 break
271 }
272 }
273 }
274 return arr
275 }, [skeleton, deferParents, maxParents, maxReplies])
276
277 // This is only used on the web to keep the post in view when its parents load.
278 // On native, we rely on `maintainVisibleContentPosition` instead.
279 const didAdjustScrollWeb = useRef<boolean>(false)
280 const onContentSizeChangeWeb = React.useCallback(() => {
281 // only run once
282 if (didAdjustScrollWeb.current) {
283 return
284 }
285 // wait for loading to finish
286 if (thread?.type === 'post' && !!thread.parent) {
287 function onMeasure(pageY: number) {
288 ref.current?.scrollToOffset({
289 animated: false,
290 offset: pageY,
291 })
292 }
293 // Measure synchronously to avoid a layout jump.
294 const domNode = highlightedPostRef.current
295 if (domNode) {
296 const pageY = (domNode as any as Element).getBoundingClientRect().top
297 onMeasure(pageY)
298 }
299 didAdjustScrollWeb.current = true
300 }
301 }, [thread])
302
303 // On native, we reveal parents in chunks. Although they're all already
304 // loaded and FlatList already has its own virtualization, unfortunately FlatList
305 // has a bug that causes the content to jump around if too many items are getting
306 // prepended at once. It also jumps around if items get prepended during scroll.
307 // To work around this, we prepend rows after scroll bumps against the top and rests.
308 const needsBumpMaxParents = React.useRef(false)
309 const onStartReached = React.useCallback(() => {
310 if (skeleton?.parents && maxParents < skeleton.parents.length) {
311 needsBumpMaxParents.current = true
312 }
313 }, [maxParents, skeleton?.parents])
314 const bumpMaxParentsIfNeeded = React.useCallback(() => {
315 if (!isNative) {
316 return
317 }
318 if (needsBumpMaxParents.current) {
319 needsBumpMaxParents.current = false
320 setMaxParents(n => n + PARENTS_CHUNK_SIZE)
321 }
322 }, [])
323 const onScrollToTop = bumpMaxParentsIfNeeded
324 const onMomentumEnd = React.useCallback(() => {
325 'worklet'
326 runOnJS(bumpMaxParentsIfNeeded)()
327 }, [bumpMaxParentsIfNeeded])
328
329 const onEndReached = React.useCallback(() => {
330 if (isFetching || posts.length < maxReplies) return
331 setMaxReplies(prev => prev + 50)
332 }, [isFetching, maxReplies, posts.length])
333
334 const onPostReply = React.useCallback(
335 (postUri: string | undefined) => {
336 refetch()
337 if (postUri) {
338 setJustPostedUris(set => {
339 const nextSet = new Set(set)
340 nextSet.add(postUri)
341 return nextSet
342 })
343 }
344 },
345 [refetch],
346 )
347
348 const {openComposer} = useComposerControls()
349 const onPressReply = React.useCallback(() => {
350 if (thread?.type !== 'post') {
351 return
352 }
353 openComposer({
354 replyTo: {
355 uri: thread.post.uri,
356 cid: thread.post.cid,
357 text: thread.record.text,
358 author: thread.post.author,
359 embed: thread.post.embed,
360 },
361 onPost: onPostReply,
362 })
363 }, [openComposer, thread, onPostReply])
364
365 const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled
366 const hasParents =
367 skeleton?.highlightedPost?.type === 'post' &&
368 (skeleton.highlightedPost.ctx.isParentLoading ||
369 Boolean(skeleton?.parents && skeleton.parents.length > 0))
370 const showHeader =
371 isNative || (isTabletOrMobile && (!hasParents || !isFetching))
372
373 const renderItem = ({item, index}: {item: RowItem; index: number}) => {
374 if (item === REPLY_PROMPT && hasSession) {
375 return (
376 <View>
377 {!isMobile && (
378 <PostThreadComposePrompt onPressCompose={onPressReply} />
379 )}
380 </View>
381 )
382 } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
383 return (
384 <PostThreadShowHiddenReplies
385 type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
386 onPress={() =>
387 setHiddenRepliesState(
388 item === SHOW_HIDDEN_REPLIES
389 ? HiddenRepliesState.Show
390 : HiddenRepliesState.ShowAndOverridePostHider,
391 )
392 }
393 hideTopBorder={index === 0}
394 />
395 )
396 } else if (isThreadNotFound(item)) {
397 return (
398 <View
399 style={[
400 a.p_lg,
401 index !== 0 && a.border_t,
402 t.atoms.border_contrast_low,
403 t.atoms.bg_contrast_25,
404 ]}>
405 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
406 <Trans>Deleted post.</Trans>
407 </Text>
408 </View>
409 )
410 } else if (isThreadBlocked(item)) {
411 return (
412 <View
413 style={[
414 a.p_lg,
415 index !== 0 && a.border_t,
416 t.atoms.border_contrast_low,
417 t.atoms.bg_contrast_25,
418 ]}>
419 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
420 <Trans>Blocked post.</Trans>
421 </Text>
422 </View>
423 )
424 } else if (isThreadPost(item)) {
425 if (!treeView && item.ctx.hasMoreSelfThread) {
426 return <PostThreadLoadMore post={item.post} />
427 }
428 const prev = isThreadPost(posts[index - 1])
429 ? (posts[index - 1] as ThreadPost)
430 : undefined
431 const next = isThreadPost(posts[index + 1])
432 ? (posts[index + 1] as ThreadPost)
433 : undefined
434 const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth
435 const showParentReplyLine =
436 (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
437 const hasUnrevealedParents =
438 index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
439
440 return (
441 <View
442 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
443 onLayout={deferParents ? () => setDeferParents(false) : undefined}>
444 <PostThreadItem
445 post={item.post}
446 record={item.record}
447 threadgateRecord={threadgateRecord ?? undefined}
448 moderation={threadModerationCache.get(item)}
449 treeView={treeView}
450 depth={item.ctx.depth}
451 prevPost={prev}
452 nextPost={next}
453 isHighlightedPost={item.ctx.isHighlightedPost}
454 hasMore={item.ctx.hasMore}
455 showChildReplyLine={showChildReplyLine}
456 showParentReplyLine={showParentReplyLine}
457 hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents}
458 overrideBlur={
459 hiddenRepliesState ===
460 HiddenRepliesState.ShowAndOverridePostHider &&
461 item.ctx.depth > 0
462 }
463 onPostReply={onPostReply}
464 hideTopBorder={index === 0 && !item.ctx.isParentLoading}
465 />
466 </View>
467 )
468 }
469 return null
470 }
471
472 if (!thread || !preferences || error) {
473 return (
474 <ListMaybePlaceholder
475 isLoading={!error}
476 isError={Boolean(error)}
477 noEmpty
478 onRetry={refetch}
479 errorTitle={error?.title}
480 errorMessage={error?.message}
481 />
482 )
483 }
484
485 return (
486 <>
487 {showHeader && (
488 <ViewHeader
489 title={_(msg({message: `Post`, context: 'description'}))}
490 showBorder
491 />
492 )}
493
494 <ScrollProvider onMomentumEnd={onMomentumEnd}>
495 <List
496 ref={ref}
497 data={posts}
498 renderItem={renderItem}
499 keyExtractor={keyExtractor}
500 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
501 onStartReached={onStartReached}
502 onEndReached={onEndReached}
503 onEndReachedThreshold={2}
504 onScrollToTop={onScrollToTop}
505 maintainVisibleContentPosition={
506 isNative && hasParents
507 ? MAINTAIN_VISIBLE_CONTENT_POSITION
508 : undefined
509 }
510 // @ts-ignore our .web version only -prf
511 desktopFixedHeight
512 removeClippedSubviews={isAndroid ? false : undefined}
513 ListFooterComponent={
514 <ListFooter
515 // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on
516 // initial render
517 isFetchingNextPage={isFetching}
518 error={cleanError(threadError)}
519 onRetry={refetch}
520 // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
521 // work without causing weird jumps on web or glitches on native
522 height={windowHeight - 200}
523 />
524 }
525 initialNumToRender={initialNumToRender}
526 windowSize={11}
527 sideBorders={false}
528 />
529 </ScrollProvider>
530 {isMobile && canReply && hasSession && (
531 <MobileComposePrompt onPressReply={onPressReply} />
532 )}
533 </>
534 )
535}
536
537function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
538 const safeAreaInsets = useSafeAreaInsets()
539 const fabMinimalShellTransform = useMinimalShellFabTransform()
540 return (
541 <Animated.View
542 style={[
543 styles.prompt,
544 fabMinimalShellTransform,
545 {
546 bottom: clamp(safeAreaInsets.bottom, 13, 30),
547 },
548 ]}>
549 <PostThreadComposePrompt onPressCompose={onPressReply} />
550 </Animated.View>
551 )
552}
553
554function isThreadPost(v: unknown): v is ThreadPost {
555 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
556}
557
558function isThreadNotFound(v: unknown): v is ThreadNotFound {
559 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
560}
561
562function isThreadBlocked(v: unknown): v is ThreadBlocked {
563 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
564}
565
566function createThreadSkeleton(
567 node: ThreadNode,
568 currentDid: string | undefined,
569 treeView: boolean,
570 modCache: ThreadModerationCache,
571 showHiddenReplies: boolean,
572 threadgateRecordHiddenReplies: Set<string>,
573): ThreadSkeletonParts | null {
574 if (!node) return null
575
576 return {
577 parents: Array.from(flattenThreadParents(node, !!currentDid)),
578 highlightedPost: node,
579 replies: Array.from(
580 flattenThreadReplies(
581 node,
582 currentDid,
583 treeView,
584 modCache,
585 showHiddenReplies,
586 threadgateRecordHiddenReplies,
587 ),
588 ),
589 }
590}
591
592function* flattenThreadParents(
593 node: ThreadNode,
594 hasSession: boolean,
595): Generator<YieldedItem, void> {
596 if (node.type === 'post') {
597 if (node.parent) {
598 yield* flattenThreadParents(node.parent, hasSession)
599 }
600 if (!node.ctx.isHighlightedPost) {
601 yield node
602 }
603 } else if (node.type === 'not-found') {
604 yield node
605 } else if (node.type === 'blocked') {
606 yield node
607 }
608}
609
610// The enum is ordered to make them easy to merge
611enum HiddenReplyType {
612 None = 0,
613 Muted = 1,
614 Hidden = 2,
615}
616
617function* flattenThreadReplies(
618 node: ThreadNode,
619 currentDid: string | undefined,
620 treeView: boolean,
621 modCache: ThreadModerationCache,
622 showHiddenReplies: boolean,
623 threadgateRecordHiddenReplies: Set<string>,
624): Generator<YieldedItem, HiddenReplyType> {
625 if (node.type === 'post') {
626 // dont show pwi-opted-out posts to logged out users
627 if (!currentDid && hasPwiOptOut(node)) {
628 return HiddenReplyType.None
629 }
630
631 // handle blurred items
632 if (node.ctx.depth > 0) {
633 const modui = modCache.get(node)?.ui('contentList')
634 if (modui?.blur || modui?.filter) {
635 if (!showHiddenReplies || node.ctx.depth > 1) {
636 if ((modui.blurs[0] || modui.filters[0]).type === 'muted') {
637 return HiddenReplyType.Muted
638 }
639 return HiddenReplyType.Hidden
640 }
641 }
642
643 if (!showHiddenReplies) {
644 const hiddenByThreadgate = threadgateRecordHiddenReplies.has(
645 node.post.uri,
646 )
647 const authorIsViewer = node.post.author.did === currentDid
648 if (hiddenByThreadgate && !authorIsViewer) {
649 return HiddenReplyType.Hidden
650 }
651 }
652 }
653
654 if (!node.ctx.isHighlightedPost) {
655 yield node
656 }
657
658 if (node.replies?.length) {
659 let hiddenReplies = HiddenReplyType.None
660 for (const reply of node.replies) {
661 let hiddenReply = yield* flattenThreadReplies(
662 reply,
663 currentDid,
664 treeView,
665 modCache,
666 showHiddenReplies,
667 threadgateRecordHiddenReplies,
668 )
669 if (hiddenReply > hiddenReplies) {
670 hiddenReplies = hiddenReply
671 }
672 if (!treeView && !node.ctx.isHighlightedPost) {
673 break
674 }
675 }
676
677 // show control to enable hidden replies
678 if (node.ctx.depth === 0) {
679 if (hiddenReplies === HiddenReplyType.Muted) {
680 yield SHOW_MUTED_REPLIES
681 } else if (hiddenReplies === HiddenReplyType.Hidden) {
682 yield SHOW_HIDDEN_REPLIES
683 }
684 }
685 }
686 } else if (node.type === 'not-found') {
687 yield node
688 } else if (node.type === 'blocked') {
689 yield node
690 }
691 return HiddenReplyType.None
692}
693
694function hasPwiOptOut(node: ThreadPost) {
695 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
696}
697
698function hasBranchingReplies(node?: ThreadNode) {
699 if (!node) {
700 return false
701 }
702 if (node.type !== 'post') {
703 return false
704 }
705 if (!node.replies) {
706 return false
707 }
708 if (node.replies.length === 1) {
709 return hasBranchingReplies(node.replies[0])
710 }
711 return true
712}
713
714const styles = StyleSheet.create({
715 prompt: {
716 // @ts-ignore web-only
717 position: isWeb ? 'fixed' : 'absolute',
718 left: 0,
719 right: 0,
720 },
721})