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