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