mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at rn-stack-repro 647 lines 20 kB view raw
1import React, {useEffect, useRef} from 'react' 2import { 3 ActivityIndicator, 4 Pressable, 5 StyleSheet, 6 TouchableOpacity, 7 View, 8} from 'react-native' 9import {AppBskyFeedDefs} from '@atproto/api' 10import {CenteredView} from '../util/Views' 11import {LoadingScreen} from '../util/LoadingScreen' 12import {List, ListMethods} from '../util/List' 13import { 14 FontAwesomeIcon, 15 FontAwesomeIconStyle, 16} from '@fortawesome/react-native-fontawesome' 17import {PostThreadItem} from './PostThreadItem' 18import {ComposePrompt} from '../composer/Prompt' 19import {ViewHeader} from '../util/ViewHeader' 20import {ErrorMessage} from '../util/error/ErrorMessage' 21import {Text} from '../util/text/Text' 22import {s} from 'lib/styles' 23import {usePalette} from 'lib/hooks/usePalette' 24import {useSetTitle} from 'lib/hooks/useSetTitle' 25import { 26 ThreadNode, 27 ThreadPost, 28 ThreadNotFound, 29 ThreadBlocked, 30 usePostThreadQuery, 31 sortThread, 32} from '#/state/queries/post-thread' 33import {useNavigation} from '@react-navigation/native' 34import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 35import {NavigationProp} from 'lib/routes/types' 36import {sanitizeDisplayName} from 'lib/strings/display-names' 37import {cleanError} from '#/lib/strings/errors' 38import {Trans, msg} from '@lingui/macro' 39import {useLingui} from '@lingui/react' 40import { 41 UsePreferencesQueryResponse, 42 useModerationOpts, 43 usePreferencesQuery, 44} from '#/state/queries/preferences' 45import {useSession} from '#/state/session' 46import {isAndroid, isNative, isWeb} from '#/platform/detection' 47import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 48 49// FlatList maintainVisibleContentPosition breaks if too many items 50// are prepended. This seems to be an optimal number based on *shrug*. 51const PARENTS_CHUNK_SIZE = 15 52 53const MAINTAIN_VISIBLE_CONTENT_POSITION = { 54 // We don't insert any elements before the root row while loading. 55 // So the row we want to use as the scroll anchor is the first row. 56 minIndexForVisible: 0, 57} 58 59const TOP_COMPONENT = {_reactKey: '__top_component__'} 60const REPLY_PROMPT = {_reactKey: '__reply__'} 61const CHILD_SPINNER = {_reactKey: '__child_spinner__'} 62const LOAD_MORE = {_reactKey: '__load_more__'} 63const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'} 64 65type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound 66type RowItem = 67 | YieldedItem 68 // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. 69 | typeof TOP_COMPONENT 70 | typeof REPLY_PROMPT 71 | typeof CHILD_SPINNER 72 | typeof LOAD_MORE 73 | typeof BOTTOM_COMPONENT 74 75type ThreadSkeletonParts = { 76 parents: YieldedItem[] 77 highlightedPost: ThreadNode 78 replies: YieldedItem[] 79} 80 81export function PostThread({ 82 uri, 83 onCanReply, 84 onPressReply, 85}: { 86 uri: string | undefined 87 onCanReply: (canReply: boolean) => void 88 onPressReply: () => void 89}) { 90 const { 91 isLoading, 92 isError, 93 error, 94 refetch, 95 data: thread, 96 } = usePostThreadQuery(uri) 97 const {data: preferences} = usePreferencesQuery() 98 99 const rootPost = thread?.type === 'post' ? thread.post : undefined 100 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined 101 102 const moderationOpts = useModerationOpts() 103 const isNoPwi = React.useMemo(() => { 104 const mod = 105 rootPost && moderationOpts 106 ? moderatePost(rootPost, moderationOpts) 107 : undefined 108 109 const cause = mod?.content.cause 110 111 return cause 112 ? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated' 113 : false 114 }, [rootPost, moderationOpts]) 115 116 useSetTitle( 117 rootPost && !isNoPwi 118 ? `${sanitizeDisplayName( 119 rootPost.author.displayName || `@${rootPost.author.handle}`, 120 )}: "${rootPostRecord!.text}"` 121 : '', 122 ) 123 useEffect(() => { 124 if (rootPost) { 125 onCanReply(!rootPost.viewer?.replyDisabled) 126 } 127 }, [rootPost, onCanReply]) 128 129 if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) { 130 return ( 131 <PostThreadError 132 error={error} 133 notFound={AppBskyFeedDefs.isNotFoundPost(thread)} 134 onRefresh={refetch} 135 /> 136 ) 137 } 138 if (AppBskyFeedDefs.isBlockedPost(thread)) { 139 return <PostThreadBlocked /> 140 } 141 if (!thread || isLoading || !preferences) { 142 return <LoadingScreen /> 143 } 144 return ( 145 <PostThreadLoaded 146 thread={thread} 147 threadViewPrefs={preferences.threadViewPrefs} 148 onRefresh={refetch} 149 onPressReply={onPressReply} 150 /> 151 ) 152} 153 154function PostThreadLoaded({ 155 thread, 156 threadViewPrefs, 157 onRefresh, 158 onPressReply, 159}: { 160 thread: ThreadNode 161 threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs'] 162 onRefresh: () => void 163 onPressReply: () => void 164}) { 165 const {hasSession} = useSession() 166 const {_} = useLingui() 167 const pal = usePalette('default') 168 const {isMobile, isTabletOrMobile} = useWebMediaQueries() 169 const ref = useRef<ListMethods>(null) 170 const highlightedPostRef = useRef<View | null>(null) 171 const [maxParents, setMaxParents] = React.useState( 172 isWeb ? Infinity : PARENTS_CHUNK_SIZE, 173 ) 174 const [maxReplies, setMaxReplies] = React.useState(100) 175 const treeView = React.useMemo( 176 () => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread), 177 [threadViewPrefs, thread], 178 ) 179 180 // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed. 181 // This ensures that the first render contains no parents--even if they are already available in the cache. 182 // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen. 183 // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. 184 const [deferParents, setDeferParents] = React.useState(isNative) 185 186 const skeleton = React.useMemo( 187 () => 188 createThreadSkeleton( 189 sortThread(thread, threadViewPrefs), 190 hasSession, 191 treeView, 192 ), 193 [thread, threadViewPrefs, hasSession, treeView], 194 ) 195 196 // construct content 197 const posts = React.useMemo(() => { 198 const {parents, highlightedPost, replies} = skeleton 199 let arr: RowItem[] = [] 200 if (highlightedPost.type === 'post') { 201 const isRoot = 202 !highlightedPost.parent && !highlightedPost.ctx.isParentLoading 203 if (isRoot) { 204 // No parents to load. 205 arr.push(TOP_COMPONENT) 206 } else { 207 if (highlightedPost.ctx.isParentLoading || deferParents) { 208 // We're loading parents of the highlighted post. 209 // In this case, we don't render anything above the post. 210 // If you add something here, you'll need to update both 211 // maintainVisibleContentPosition and onContentSizeChange 212 // to "hold onto" the correct row instead of the first one. 213 } else { 214 // Everything is loaded 215 let startIndex = Math.max(0, parents.length - maxParents) 216 if (startIndex === 0) { 217 arr.push(TOP_COMPONENT) 218 } else { 219 // When progressively revealing parents, rendering a placeholder 220 // here will cause scrolling jumps. Don't add it unless you test it. 221 // QT'ing this thread is a great way to test all the scrolling hacks: 222 // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o 223 } 224 for (let i = startIndex; i < parents.length; i++) { 225 arr.push(parents[i]) 226 } 227 } 228 } 229 arr.push(highlightedPost) 230 if (!highlightedPost.post.viewer?.replyDisabled) { 231 arr.push(REPLY_PROMPT) 232 } 233 if (highlightedPost.ctx.isChildLoading) { 234 arr.push(CHILD_SPINNER) 235 } else { 236 for (let i = 0; i < replies.length; i++) { 237 arr.push(replies[i]) 238 if (i === maxReplies) { 239 arr.push(LOAD_MORE) 240 break 241 } 242 } 243 arr.push(BOTTOM_COMPONENT) 244 } 245 } 246 return arr 247 }, [skeleton, deferParents, maxParents, maxReplies]) 248 249 // This is only used on the web to keep the post in view when its parents load. 250 // On native, we rely on `maintainVisibleContentPosition` instead. 251 const didAdjustScrollWeb = useRef<boolean>(false) 252 const onContentSizeChangeWeb = React.useCallback(() => { 253 // only run once 254 if (didAdjustScrollWeb.current) { 255 return 256 } 257 // wait for loading to finish 258 if (thread.type === 'post' && !!thread.parent) { 259 function onMeasure(pageY: number) { 260 ref.current?.scrollToOffset({ 261 animated: false, 262 offset: pageY, 263 }) 264 } 265 // Measure synchronously to avoid a layout jump. 266 const domNode = highlightedPostRef.current 267 if (domNode) { 268 const pageY = (domNode as any as Element).getBoundingClientRect().top 269 onMeasure(pageY) 270 } 271 didAdjustScrollWeb.current = true 272 } 273 }, [thread]) 274 275 // On native, we reveal parents in chunks. Although they're all already 276 // loaded and FlatList already has its own virtualization, unfortunately FlatList 277 // has a bug that causes the content to jump around if too many items are getting 278 // prepended at once. It also jumps around if items get prepended during scroll. 279 // To work around this, we prepend rows after scroll bumps against the top and rests. 280 const needsBumpMaxParents = React.useRef(false) 281 const onStartReached = React.useCallback(() => { 282 if (maxParents < skeleton.parents.length) { 283 needsBumpMaxParents.current = true 284 } 285 }, [maxParents, skeleton.parents.length]) 286 const bumpMaxParentsIfNeeded = React.useCallback(() => { 287 if (!isNative) { 288 return 289 } 290 if (needsBumpMaxParents.current) { 291 needsBumpMaxParents.current = false 292 setMaxParents(n => n + PARENTS_CHUNK_SIZE) 293 } 294 }, []) 295 const onMomentumScrollEnd = bumpMaxParentsIfNeeded 296 const onScrollToTop = bumpMaxParentsIfNeeded 297 298 const renderItem = React.useCallback( 299 ({item, index}: {item: RowItem; index: number}) => { 300 if (item === TOP_COMPONENT) { 301 return isTabletOrMobile ? ( 302 <ViewHeader 303 title={_(msg({message: `Post`, context: 'description'}))} 304 /> 305 ) : null 306 } else if (item === REPLY_PROMPT && hasSession) { 307 return ( 308 <View> 309 {!isMobile && <ComposePrompt onPressCompose={onPressReply} />} 310 </View> 311 ) 312 } else if (isThreadNotFound(item)) { 313 return ( 314 <View style={[pal.border, pal.viewLight, styles.itemContainer]}> 315 <Text type="lg-bold" style={pal.textLight}> 316 <Trans>Deleted post.</Trans> 317 </Text> 318 </View> 319 ) 320 } else if (isThreadBlocked(item)) { 321 return ( 322 <View style={[pal.border, pal.viewLight, styles.itemContainer]}> 323 <Text type="lg-bold" style={pal.textLight}> 324 <Trans>Blocked post.</Trans> 325 </Text> 326 </View> 327 ) 328 } else if (item === LOAD_MORE) { 329 return ( 330 <Pressable 331 onPress={() => setMaxReplies(n => n + 50)} 332 style={[pal.border, pal.view, styles.itemContainer]} 333 accessibilityLabel={_(msg`Load more posts`)} 334 accessibilityHint=""> 335 <View 336 style={[ 337 pal.viewLight, 338 {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6}, 339 ]}> 340 <Text type="lg-medium" style={pal.text}> 341 <Trans>Load more posts</Trans> 342 </Text> 343 </View> 344 </Pressable> 345 ) 346 } else if (item === BOTTOM_COMPONENT) { 347 // HACK 348 // due to some complexities with how flatlist works, this is the easiest way 349 // I could find to get a border positioned directly under the last item 350 // -prf 351 return ( 352 <View 353 // @ts-ignore web-only 354 style={{ 355 // Leave enough space below that the scroll doesn't jump 356 height: isNative ? 600 : '100vh', 357 borderTopWidth: 1, 358 borderColor: pal.colors.border, 359 }} 360 /> 361 ) 362 } else if (item === CHILD_SPINNER) { 363 return ( 364 <View style={[pal.border, styles.childSpinner]}> 365 <ActivityIndicator /> 366 </View> 367 ) 368 } else if (isThreadPost(item)) { 369 const prev = isThreadPost(posts[index - 1]) 370 ? (posts[index - 1] as ThreadPost) 371 : undefined 372 const next = isThreadPost(posts[index - 1]) 373 ? (posts[index - 1] as ThreadPost) 374 : undefined 375 const hasUnrevealedParents = 376 index === 0 && maxParents < skeleton.parents.length 377 return ( 378 <View 379 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} 380 onLayout={deferParents ? () => setDeferParents(false) : undefined}> 381 <PostThreadItem 382 post={item.post} 383 record={item.record} 384 treeView={treeView} 385 depth={item.ctx.depth} 386 prevPost={prev} 387 nextPost={next} 388 isHighlightedPost={item.ctx.isHighlightedPost} 389 hasMore={item.ctx.hasMore} 390 showChildReplyLine={item.ctx.showChildReplyLine} 391 showParentReplyLine={item.ctx.showParentReplyLine} 392 hasPrecedingItem={ 393 !!prev?.ctx.showChildReplyLine || hasUnrevealedParents 394 } 395 onPostReply={onRefresh} 396 /> 397 </View> 398 ) 399 } 400 return null 401 }, 402 [ 403 hasSession, 404 isTabletOrMobile, 405 isMobile, 406 onPressReply, 407 pal.border, 408 pal.viewLight, 409 pal.textLight, 410 pal.view, 411 pal.text, 412 pal.colors.border, 413 posts, 414 onRefresh, 415 deferParents, 416 treeView, 417 skeleton.parents.length, 418 maxParents, 419 _, 420 ], 421 ) 422 423 return ( 424 <List 425 ref={ref} 426 data={posts} 427 keyExtractor={item => item._reactKey} 428 renderItem={renderItem} 429 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} 430 onStartReached={onStartReached} 431 onMomentumScrollEnd={onMomentumScrollEnd} 432 onScrollToTop={onScrollToTop} 433 maintainVisibleContentPosition={ 434 isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined 435 } 436 style={s.hContentRegion} 437 // @ts-ignore our .web version only -prf 438 desktopFixedHeight 439 removeClippedSubviews={isAndroid ? false : undefined} 440 windowSize={11} 441 /> 442 ) 443} 444 445function PostThreadBlocked() { 446 const {_} = useLingui() 447 const pal = usePalette('default') 448 const navigation = useNavigation<NavigationProp>() 449 450 const onPressBack = React.useCallback(() => { 451 if (navigation.canGoBack()) { 452 navigation.goBack() 453 } else { 454 navigation.navigate('Home') 455 } 456 }, [navigation]) 457 458 return ( 459 <CenteredView> 460 <View style={[pal.view, pal.border, styles.notFoundContainer]}> 461 <Text type="title-lg" style={[pal.text, s.mb5]}> 462 <Trans>Post hidden</Trans> 463 </Text> 464 <Text type="md" style={[pal.text, s.mb10]}> 465 <Trans> 466 You have blocked the author or you have been blocked by the author. 467 </Trans> 468 </Text> 469 <TouchableOpacity 470 onPress={onPressBack} 471 accessibilityRole="button" 472 accessibilityLabel={_(msg`Back`)} 473 accessibilityHint=""> 474 <Text type="2xl" style={pal.link}> 475 <FontAwesomeIcon 476 icon="angle-left" 477 style={[pal.link as FontAwesomeIconStyle, s.mr5]} 478 size={14} 479 /> 480 <Trans context="action">Back</Trans> 481 </Text> 482 </TouchableOpacity> 483 </View> 484 </CenteredView> 485 ) 486} 487 488function PostThreadError({ 489 onRefresh, 490 notFound, 491 error, 492}: { 493 onRefresh: () => void 494 notFound: boolean 495 error: Error | null 496}) { 497 const {_} = useLingui() 498 const pal = usePalette('default') 499 const navigation = useNavigation<NavigationProp>() 500 501 const onPressBack = React.useCallback(() => { 502 if (navigation.canGoBack()) { 503 navigation.goBack() 504 } else { 505 navigation.navigate('Home') 506 } 507 }, [navigation]) 508 509 if (notFound) { 510 return ( 511 <CenteredView> 512 <View style={[pal.view, pal.border, styles.notFoundContainer]}> 513 <Text type="title-lg" style={[pal.text, s.mb5]}> 514 <Trans>Post not found</Trans> 515 </Text> 516 <Text type="md" style={[pal.text, s.mb10]}> 517 <Trans>The post may have been deleted.</Trans> 518 </Text> 519 <TouchableOpacity 520 onPress={onPressBack} 521 accessibilityRole="button" 522 accessibilityLabel={_(msg`Back`)} 523 accessibilityHint=""> 524 <Text type="2xl" style={pal.link}> 525 <FontAwesomeIcon 526 icon="angle-left" 527 style={[pal.link as FontAwesomeIconStyle, s.mr5]} 528 size={14} 529 /> 530 <Trans>Back</Trans> 531 </Text> 532 </TouchableOpacity> 533 </View> 534 </CenteredView> 535 ) 536 } 537 return ( 538 <CenteredView> 539 <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} /> 540 </CenteredView> 541 ) 542} 543 544function isThreadPost(v: unknown): v is ThreadPost { 545 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' 546} 547 548function isThreadNotFound(v: unknown): v is ThreadNotFound { 549 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found' 550} 551 552function isThreadBlocked(v: unknown): v is ThreadBlocked { 553 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked' 554} 555 556function createThreadSkeleton( 557 node: ThreadNode, 558 hasSession: boolean, 559 treeView: boolean, 560): ThreadSkeletonParts { 561 return { 562 parents: Array.from(flattenThreadParents(node, hasSession)), 563 highlightedPost: node, 564 replies: Array.from(flattenThreadReplies(node, hasSession, treeView)), 565 } 566} 567 568function* flattenThreadParents( 569 node: ThreadNode, 570 hasSession: boolean, 571): Generator<YieldedItem, void> { 572 if (node.type === 'post') { 573 if (node.parent) { 574 yield* flattenThreadParents(node.parent, hasSession) 575 } 576 if (!node.ctx.isHighlightedPost) { 577 yield node 578 } 579 } else if (node.type === 'not-found') { 580 yield node 581 } else if (node.type === 'blocked') { 582 yield node 583 } 584} 585 586function* flattenThreadReplies( 587 node: ThreadNode, 588 hasSession: boolean, 589 treeView: boolean, 590): Generator<YieldedItem, void> { 591 if (node.type === 'post') { 592 if (!hasSession && hasPwiOptOut(node)) { 593 return 594 } 595 if (!node.ctx.isHighlightedPost) { 596 yield node 597 } 598 if (node.replies?.length) { 599 for (const reply of node.replies) { 600 yield* flattenThreadReplies(reply, hasSession, treeView) 601 if (!treeView && !node.ctx.isHighlightedPost) { 602 break 603 } 604 } 605 } 606 } else if (node.type === 'not-found') { 607 yield node 608 } else if (node.type === 'blocked') { 609 yield node 610 } 611} 612 613function hasPwiOptOut(node: ThreadPost) { 614 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') 615} 616 617function hasBranchingReplies(node: ThreadNode) { 618 if (node.type !== 'post') { 619 return false 620 } 621 if (!node.replies) { 622 return false 623 } 624 if (node.replies.length === 1) { 625 return hasBranchingReplies(node.replies[0]) 626 } 627 return true 628} 629 630const styles = StyleSheet.create({ 631 notFoundContainer: { 632 margin: 10, 633 paddingHorizontal: 18, 634 paddingVertical: 14, 635 borderRadius: 6, 636 }, 637 itemContainer: { 638 borderTopWidth: 1, 639 paddingHorizontal: 18, 640 paddingVertical: 18, 641 }, 642 childSpinner: { 643 borderTopWidth: 1, 644 paddingTop: 40, 645 paddingBottom: 200, 646 }, 647})