mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at ruby-v 23 kB view raw
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})