mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useEffect, useRef} from 'react' 2import {useWindowDimensions, View} from 'react-native' 3import {runOnJS} from 'react-native-reanimated' 4import {AppBskyFeedDefs} from '@atproto/api' 5import {msg, Trans} from '@lingui/macro' 6import {useLingui} from '@lingui/react' 7 8import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 9import {ScrollProvider} from '#/lib/ScrollContext' 10import {isAndroid, isNative, isWeb} from '#/platform/detection' 11import {useModerationOpts} from '#/state/preferences/moderation-opts' 12import { 13 fillThreadModerationCache, 14 sortThread, 15 ThreadBlocked, 16 ThreadModerationCache, 17 ThreadNode, 18 ThreadNotFound, 19 ThreadPost, 20 usePostThreadQuery, 21} from '#/state/queries/post-thread' 22import {usePreferencesQuery} from '#/state/queries/preferences' 23import {useSession} from '#/state/session' 24import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' 25import {useSetTitle} from 'lib/hooks/useSetTitle' 26import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 27import {sanitizeDisplayName} from 'lib/strings/display-names' 28import {cleanError} from 'lib/strings/errors' 29import {CenteredView} from 'view/com/util/Views' 30import {atoms as a, useTheme} from '#/alf' 31import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 32import {Text} from '#/components/Typography' 33import {ComposePrompt} from '../composer/Prompt' 34import {List, ListMethods} from '../util/List' 35import {ViewHeader} from '../util/ViewHeader' 36import {PostThreadItem} from './PostThreadItem' 37import {PostThreadLoadMore} from './PostThreadLoadMore' 38import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies' 39 40// FlatList maintainVisibleContentPosition breaks if too many items 41// are prepended. This seems to be an optimal number based on *shrug*. 42const PARENTS_CHUNK_SIZE = 15 43 44const MAINTAIN_VISIBLE_CONTENT_POSITION = { 45 // We don't insert any elements before the root row while loading. 46 // So the row we want to use as the scroll anchor is the first row. 47 minIndexForVisible: 0, 48} 49 50const REPLY_PROMPT = {_reactKey: '__reply__'} 51const LOAD_MORE = {_reactKey: '__load_more__'} 52const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'} 53const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'} 54 55enum HiddenRepliesState { 56 Hide, 57 Show, 58 ShowAndOverridePostHider, 59} 60 61type YieldedItem = 62 | ThreadPost 63 | ThreadBlocked 64 | ThreadNotFound 65 | typeof SHOW_HIDDEN_REPLIES 66 | typeof SHOW_MUTED_REPLIES 67type RowItem = 68 | YieldedItem 69 // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. 70 | typeof REPLY_PROMPT 71 | typeof LOAD_MORE 72 73type ThreadSkeletonParts = { 74 parents: YieldedItem[] 75 highlightedPost: ThreadNode 76 replies: YieldedItem[] 77} 78 79const keyExtractor = (item: RowItem) => { 80 return item._reactKey 81} 82 83export function PostThread({ 84 uri, 85 onCanReply, 86 onPressReply, 87}: { 88 uri: string | undefined 89 onCanReply: (canReply: boolean) => void 90 onPressReply: () => unknown 91}) { 92 const {hasSession, currentAccount} = useSession() 93 const {_} = useLingui() 94 const t = useTheme() 95 const {isMobile, isTabletOrMobile} = useWebMediaQueries() 96 const initialNumToRender = useInitialNumToRender() 97 const {height: windowHeight} = useWindowDimensions() 98 const [hiddenRepliesState, setHiddenRepliesState] = React.useState( 99 HiddenRepliesState.Hide, 100 ) 101 102 const {data: preferences} = usePreferencesQuery() 103 const { 104 isFetching, 105 isError: isThreadError, 106 error: threadError, 107 refetch, 108 data: thread, 109 } = usePostThreadQuery(uri) 110 111 const treeView = React.useMemo( 112 () => 113 !!preferences?.threadViewPrefs?.lab_treeViewEnabled && 114 hasBranchingReplies(thread), 115 [preferences?.threadViewPrefs, thread], 116 ) 117 const rootPost = thread?.type === 'post' ? thread.post : undefined 118 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined 119 120 const moderationOpts = useModerationOpts() 121 const isNoPwi = React.useMemo(() => { 122 const mod = 123 rootPost && moderationOpts 124 ? moderatePost(rootPost, moderationOpts) 125 : undefined 126 return !!mod 127 ?.ui('contentList') 128 .blurs.find( 129 cause => 130 cause.type === 'label' && 131 cause.labelDef.identifier === '!no-unauthenticated', 132 ) 133 }, [rootPost, moderationOpts]) 134 135 // Values used for proper rendering of parents 136 const ref = useRef<ListMethods>(null) 137 const highlightedPostRef = useRef<View | null>(null) 138 const [maxParents, setMaxParents] = React.useState( 139 isWeb ? Infinity : PARENTS_CHUNK_SIZE, 140 ) 141 const [maxReplies, setMaxReplies] = React.useState(50) 142 143 useSetTitle( 144 rootPost && !isNoPwi 145 ? `${sanitizeDisplayName( 146 rootPost.author.displayName || `@${rootPost.author.handle}`, 147 )}: "${rootPostRecord!.text}"` 148 : '', 149 ) 150 151 // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed. 152 // This ensures that the first render contains no parents--even if they are already available in the cache. 153 // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen. 154 // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. 155 const [deferParents, setDeferParents] = React.useState(isNative) 156 157 const currentDid = currentAccount?.did 158 const threadModerationCache = React.useMemo(() => { 159 const cache: ThreadModerationCache = new WeakMap() 160 if (thread && moderationOpts) { 161 fillThreadModerationCache(cache, thread, moderationOpts) 162 } 163 return cache 164 }, [thread, moderationOpts]) 165 166 const skeleton = React.useMemo(() => { 167 const threadViewPrefs = preferences?.threadViewPrefs 168 if (!threadViewPrefs || !thread) return null 169 170 return createThreadSkeleton( 171 sortThread(thread, threadViewPrefs, threadModerationCache, currentDid), 172 !!currentDid, 173 treeView, 174 threadModerationCache, 175 hiddenRepliesState !== HiddenRepliesState.Hide, 176 ) 177 }, [ 178 thread, 179 preferences?.threadViewPrefs, 180 currentDid, 181 treeView, 182 threadModerationCache, 183 hiddenRepliesState, 184 ]) 185 186 const error = React.useMemo(() => { 187 if (AppBskyFeedDefs.isNotFoundPost(thread)) { 188 return { 189 title: _(msg`Post not found`), 190 message: _(msg`The post may have been deleted.`), 191 } 192 } else if (skeleton?.highlightedPost.type === 'blocked') { 193 return { 194 title: _(msg`Post hidden`), 195 message: _( 196 msg`You have blocked the author or you have been blocked by the author.`, 197 ), 198 } 199 } else if (threadError?.message.startsWith('Post not found')) { 200 return { 201 title: _(msg`Post not found`), 202 message: _(msg`The post may have been deleted.`), 203 } 204 } else if (isThreadError) { 205 return { 206 message: threadError ? cleanError(threadError) : undefined, 207 } 208 } 209 210 return null 211 }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) 212 213 useEffect(() => { 214 if (error) { 215 onCanReply(false) 216 } else if (rootPost) { 217 onCanReply(!rootPost.viewer?.replyDisabled) 218 } 219 }, [rootPost, onCanReply, error]) 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 hasParents = 317 skeleton?.highlightedPost?.type === 'post' && 318 (skeleton.highlightedPost.ctx.isParentLoading || 319 Boolean(skeleton?.parents && skeleton.parents.length > 0)) 320 const showHeader = 321 isNative || (isTabletOrMobile && (!hasParents || !isFetching)) 322 323 const renderItem = ({item, index}: {item: RowItem; index: number}) => { 324 if (item === REPLY_PROMPT && hasSession) { 325 return ( 326 <View> 327 {!isMobile && <ComposePrompt onPressCompose={onPressReply} />} 328 </View> 329 ) 330 } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { 331 return ( 332 <PostThreadShowHiddenReplies 333 type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'} 334 onPress={() => 335 setHiddenRepliesState( 336 item === SHOW_HIDDEN_REPLIES 337 ? HiddenRepliesState.Show 338 : HiddenRepliesState.ShowAndOverridePostHider, 339 ) 340 } 341 hideTopBorder={index === 0} 342 /> 343 ) 344 } else if (isThreadNotFound(item)) { 345 return ( 346 <View 347 style={[ 348 a.p_lg, 349 index !== 0 && a.border_t, 350 t.atoms.border_contrast_low, 351 t.atoms.bg_contrast_25, 352 ]}> 353 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> 354 <Trans>Deleted post.</Trans> 355 </Text> 356 </View> 357 ) 358 } else if (isThreadBlocked(item)) { 359 return ( 360 <View 361 style={[ 362 a.p_lg, 363 index !== 0 && a.border_t, 364 t.atoms.border_contrast_low, 365 t.atoms.bg_contrast_25, 366 ]}> 367 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> 368 <Trans>Blocked post.</Trans> 369 </Text> 370 </View> 371 ) 372 } else if (isThreadPost(item)) { 373 if (!treeView && item.ctx.hasMoreSelfThread) { 374 return <PostThreadLoadMore post={item.post} /> 375 } 376 const prev = isThreadPost(posts[index - 1]) 377 ? (posts[index - 1] as ThreadPost) 378 : undefined 379 const next = isThreadPost(posts[index + 1]) 380 ? (posts[index + 1] as ThreadPost) 381 : undefined 382 const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth 383 const showParentReplyLine = 384 (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1 385 const hasUnrevealedParents = 386 index === 0 && skeleton?.parents && maxParents < skeleton.parents.length 387 return ( 388 <View 389 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} 390 onLayout={deferParents ? () => setDeferParents(false) : undefined}> 391 <PostThreadItem 392 post={item.post} 393 record={item.record} 394 moderation={threadModerationCache.get(item)} 395 treeView={treeView} 396 depth={item.ctx.depth} 397 prevPost={prev} 398 nextPost={next} 399 isHighlightedPost={item.ctx.isHighlightedPost} 400 hasMore={item.ctx.hasMore} 401 showChildReplyLine={showChildReplyLine} 402 showParentReplyLine={showParentReplyLine} 403 hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents} 404 overrideBlur={ 405 hiddenRepliesState === 406 HiddenRepliesState.ShowAndOverridePostHider && 407 item.ctx.depth > 0 408 } 409 onPostReply={refetch} 410 hideTopBorder={index === 0 && !item.ctx.isParentLoading} 411 /> 412 </View> 413 ) 414 } 415 return null 416 } 417 418 if (!thread || !preferences || error) { 419 return ( 420 <ListMaybePlaceholder 421 isLoading={!error} 422 isError={Boolean(error)} 423 noEmpty 424 onRetry={refetch} 425 errorTitle={error?.title} 426 errorMessage={error?.message} 427 /> 428 ) 429 } 430 431 return ( 432 <CenteredView style={[a.flex_1]} sideBorders={true}> 433 {showHeader && ( 434 <ViewHeader 435 title={_(msg({message: `Post`, context: 'description'}))} 436 showBorder 437 /> 438 )} 439 440 <ScrollProvider onMomentumEnd={onMomentumEnd}> 441 <List 442 ref={ref} 443 data={posts} 444 renderItem={renderItem} 445 keyExtractor={keyExtractor} 446 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} 447 onStartReached={onStartReached} 448 onEndReached={onEndReached} 449 onEndReachedThreshold={2} 450 onScrollToTop={onScrollToTop} 451 maintainVisibleContentPosition={ 452 isNative && hasParents 453 ? MAINTAIN_VISIBLE_CONTENT_POSITION 454 : undefined 455 } 456 // @ts-ignore our .web version only -prf 457 desktopFixedHeight 458 removeClippedSubviews={isAndroid ? false : undefined} 459 ListFooterComponent={ 460 <ListFooter 461 // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on 462 // initial render 463 isFetchingNextPage={isFetching} 464 error={cleanError(threadError)} 465 onRetry={refetch} 466 // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to 467 // work without causing weird jumps on web or glitches on native 468 height={windowHeight - 200} 469 /> 470 } 471 initialNumToRender={initialNumToRender} 472 windowSize={11} 473 sideBorders={false} 474 /> 475 </ScrollProvider> 476 </CenteredView> 477 ) 478} 479 480function isThreadPost(v: unknown): v is ThreadPost { 481 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' 482} 483 484function isThreadNotFound(v: unknown): v is ThreadNotFound { 485 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found' 486} 487 488function isThreadBlocked(v: unknown): v is ThreadBlocked { 489 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked' 490} 491 492function createThreadSkeleton( 493 node: ThreadNode, 494 hasSession: boolean, 495 treeView: boolean, 496 modCache: ThreadModerationCache, 497 showHiddenReplies: boolean, 498): ThreadSkeletonParts | null { 499 if (!node) return null 500 501 return { 502 parents: Array.from(flattenThreadParents(node, hasSession)), 503 highlightedPost: node, 504 replies: Array.from( 505 flattenThreadReplies( 506 node, 507 hasSession, 508 treeView, 509 modCache, 510 showHiddenReplies, 511 ), 512 ), 513 } 514} 515 516function* flattenThreadParents( 517 node: ThreadNode, 518 hasSession: boolean, 519): Generator<YieldedItem, void> { 520 if (node.type === 'post') { 521 if (node.parent) { 522 yield* flattenThreadParents(node.parent, hasSession) 523 } 524 if (!node.ctx.isHighlightedPost) { 525 yield node 526 } 527 } else if (node.type === 'not-found') { 528 yield node 529 } else if (node.type === 'blocked') { 530 yield node 531 } 532} 533 534// The enum is ordered to make them easy to merge 535enum HiddenReplyType { 536 None = 0, 537 Muted = 1, 538 Hidden = 2, 539} 540 541function* flattenThreadReplies( 542 node: ThreadNode, 543 hasSession: boolean, 544 treeView: boolean, 545 modCache: ThreadModerationCache, 546 showHiddenReplies: boolean, 547): Generator<YieldedItem, HiddenReplyType> { 548 if (node.type === 'post') { 549 // dont show pwi-opted-out posts to logged out users 550 if (!hasSession && hasPwiOptOut(node)) { 551 return HiddenReplyType.None 552 } 553 554 // handle blurred items 555 if (node.ctx.depth > 0) { 556 const modui = modCache.get(node)?.ui('contentList') 557 if (modui?.blur || modui?.filter) { 558 if (!showHiddenReplies || node.ctx.depth > 1) { 559 if ((modui.blurs[0] || modui.filters[0]).type === 'muted') { 560 return HiddenReplyType.Muted 561 } 562 return HiddenReplyType.Hidden 563 } 564 } 565 } 566 567 if (!node.ctx.isHighlightedPost) { 568 yield node 569 } 570 571 if (node.replies?.length) { 572 let hiddenReplies = HiddenReplyType.None 573 for (const reply of node.replies) { 574 let hiddenReply = yield* flattenThreadReplies( 575 reply, 576 hasSession, 577 treeView, 578 modCache, 579 showHiddenReplies, 580 ) 581 if (hiddenReply > hiddenReplies) { 582 hiddenReplies = hiddenReply 583 } 584 if (!treeView && !node.ctx.isHighlightedPost) { 585 break 586 } 587 } 588 589 // show control to enable hidden replies 590 if (node.ctx.depth === 0) { 591 if (hiddenReplies === HiddenReplyType.Muted) { 592 yield SHOW_MUTED_REPLIES 593 } else if (hiddenReplies === HiddenReplyType.Hidden) { 594 yield SHOW_HIDDEN_REPLIES 595 } 596 } 597 } 598 } else if (node.type === 'not-found') { 599 yield node 600 } else if (node.type === 'blocked') { 601 yield node 602 } 603 return HiddenReplyType.None 604} 605 606function hasPwiOptOut(node: ThreadPost) { 607 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') 608} 609 610function hasBranchingReplies(node?: ThreadNode) { 611 if (!node) { 612 return false 613 } 614 if (node.type !== 'post') { 615 return false 616 } 617 if (!node.replies) { 618 return false 619 } 620 if (node.replies.length === 1) { 621 return hasBranchingReplies(node.replies[0]) 622 } 623 return true 624}