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