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