mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useEffect, useRef} from 'react' 2import {StyleSheet, useWindowDimensions, View} from 'react-native' 3import {AppBskyFeedDefs} from '@atproto/api' 4import {msg, Trans} from '@lingui/macro' 5import {useLingui} from '@lingui/react' 6 7import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped' 8import {isAndroid, isNative, isWeb} from '#/platform/detection' 9import { 10 sortThread, 11 ThreadBlocked, 12 ThreadNode, 13 ThreadNotFound, 14 ThreadPost, 15 usePostThreadQuery, 16} from '#/state/queries/post-thread' 17import { 18 useModerationOpts, 19 usePreferencesQuery, 20} from '#/state/queries/preferences' 21import {useSession} from '#/state/session' 22import {useInitialNumToRender} from 'lib/hooks/useInitialNumToRender' 23import {usePalette} from 'lib/hooks/usePalette' 24import {useSetTitle} from 'lib/hooks/useSetTitle' 25import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 26import {sanitizeDisplayName} from 'lib/strings/display-names' 27import {cleanError} from 'lib/strings/errors' 28import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' 29import {ComposePrompt} from '../composer/Prompt' 30import {List, ListMethods} from '../util/List' 31import {Text} from '../util/text/Text' 32import {ViewHeader} from '../util/ViewHeader' 33import {PostThreadItem} from './PostThreadItem' 34 35// FlatList maintainVisibleContentPosition breaks if too many items 36// are prepended. This seems to be an optimal number based on *shrug*. 37const PARENTS_CHUNK_SIZE = 15 38 39const MAINTAIN_VISIBLE_CONTENT_POSITION = { 40 // We don't insert any elements before the root row while loading. 41 // So the row we want to use as the scroll anchor is the first row. 42 minIndexForVisible: 0, 43} 44 45const TOP_COMPONENT = {_reactKey: '__top_component__'} 46const REPLY_PROMPT = {_reactKey: '__reply__'} 47const LOAD_MORE = {_reactKey: '__load_more__'} 48 49type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound 50type RowItem = 51 | YieldedItem 52 // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. 53 | typeof TOP_COMPONENT 54 | typeof REPLY_PROMPT 55 | typeof LOAD_MORE 56 57type ThreadSkeletonParts = { 58 parents: YieldedItem[] 59 highlightedPost: ThreadNode 60 replies: YieldedItem[] 61} 62 63const keyExtractor = (item: RowItem) => { 64 return item._reactKey 65} 66 67export function PostThread({ 68 uri, 69 onCanReply, 70 onPressReply, 71}: { 72 uri: string | undefined 73 onCanReply: (canReply: boolean) => void 74 onPressReply: () => unknown 75}) { 76 const {hasSession} = useSession() 77 const {_} = useLingui() 78 const pal = usePalette('default') 79 const {isMobile, isTabletOrMobile} = useWebMediaQueries() 80 const initialNumToRender = useInitialNumToRender() 81 const {height: windowHeight} = useWindowDimensions() 82 83 const {data: preferences} = usePreferencesQuery() 84 const { 85 isFetching, 86 isError: isThreadError, 87 error: threadError, 88 refetch, 89 data: thread, 90 } = usePostThreadQuery(uri) 91 92 const treeView = React.useMemo( 93 () => 94 !!preferences?.threadViewPrefs?.lab_treeViewEnabled && 95 hasBranchingReplies(thread), 96 [preferences?.threadViewPrefs, thread], 97 ) 98 const rootPost = thread?.type === 'post' ? thread.post : undefined 99 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined 100 101 const moderationOpts = useModerationOpts() 102 const isNoPwi = React.useMemo(() => { 103 const mod = 104 rootPost && moderationOpts 105 ? moderatePost(rootPost, moderationOpts) 106 : undefined 107 return !!mod 108 ?.ui('contentList') 109 .blurs.find( 110 cause => 111 cause.type === 'label' && 112 cause.labelDef.identifier === '!no-unauthenticated', 113 ) 114 }, [rootPost, moderationOpts]) 115 116 // Values used for proper rendering of parents 117 const ref = useRef<ListMethods>(null) 118 const highlightedPostRef = useRef<View | null>(null) 119 const [maxParents, setMaxParents] = React.useState( 120 isWeb ? Infinity : PARENTS_CHUNK_SIZE, 121 ) 122 const [maxReplies, setMaxReplies] = React.useState(50) 123 124 useSetTitle( 125 rootPost && !isNoPwi 126 ? `${sanitizeDisplayName( 127 rootPost.author.displayName || `@${rootPost.author.handle}`, 128 )}: "${rootPostRecord!.text}"` 129 : '', 130 ) 131 132 // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed. 133 // This ensures that the first render contains no parents--even if they are already available in the cache. 134 // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen. 135 // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. 136 const [deferParents, setDeferParents] = React.useState(isNative) 137 138 const skeleton = React.useMemo(() => { 139 const threadViewPrefs = preferences?.threadViewPrefs 140 if (!threadViewPrefs || !thread) return null 141 142 return createThreadSkeleton( 143 sortThread(thread, threadViewPrefs), 144 hasSession, 145 treeView, 146 ) 147 }, [thread, preferences?.threadViewPrefs, hasSession, treeView]) 148 149 const error = React.useMemo(() => { 150 if (AppBskyFeedDefs.isNotFoundPost(thread)) { 151 return { 152 title: _(msg`Post not found`), 153 message: _(msg`The post may have been deleted.`), 154 } 155 } else if (skeleton?.highlightedPost.type === 'blocked') { 156 return { 157 title: _(msg`Post hidden`), 158 message: _( 159 msg`You have blocked the author or you have been blocked by the author.`, 160 ), 161 } 162 } else if (threadError?.message.startsWith('Post not found')) { 163 return { 164 title: _(msg`Post not found`), 165 message: _(msg`The post may have been deleted.`), 166 } 167 } else if (isThreadError) { 168 return { 169 message: threadError ? cleanError(threadError) : undefined, 170 } 171 } 172 173 return null 174 }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError]) 175 176 useEffect(() => { 177 if (error) { 178 onCanReply(false) 179 } else if (rootPost) { 180 onCanReply(!rootPost.viewer?.replyDisabled) 181 } 182 }, [rootPost, onCanReply, error]) 183 184 // construct content 185 const posts = React.useMemo(() => { 186 if (!skeleton) return [] 187 188 const {parents, highlightedPost, replies} = skeleton 189 let arr: RowItem[] = [] 190 if (highlightedPost.type === 'post') { 191 const isRoot = 192 !highlightedPost.parent && !highlightedPost.ctx.isParentLoading 193 if (isRoot) { 194 // No parents to load. 195 arr.push(TOP_COMPONENT) 196 } else { 197 if (highlightedPost.ctx.isParentLoading || deferParents) { 198 // We're loading parents of the highlighted post. 199 // In this case, we don't render anything above the post. 200 // If you add something here, you'll need to update both 201 // maintainVisibleContentPosition and onContentSizeChange 202 // to "hold onto" the correct row instead of the first one. 203 } else { 204 // Everything is loaded 205 let startIndex = Math.max(0, parents.length - maxParents) 206 if (startIndex === 0) { 207 arr.push(TOP_COMPONENT) 208 } else { 209 // When progressively revealing parents, rendering a placeholder 210 // here will cause scrolling jumps. Don't add it unless you test it. 211 // QT'ing this thread is a great way to test all the scrolling hacks: 212 // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o 213 } 214 for (let i = startIndex; i < parents.length; i++) { 215 arr.push(parents[i]) 216 } 217 } 218 } 219 arr.push(highlightedPost) 220 if (!highlightedPost.post.viewer?.replyDisabled) { 221 arr.push(REPLY_PROMPT) 222 } 223 for (let i = 0; i < replies.length; i++) { 224 arr.push(replies[i]) 225 if (i === maxReplies) { 226 break 227 } 228 } 229 } 230 return arr 231 }, [skeleton, deferParents, maxParents, maxReplies]) 232 233 // This is only used on the web to keep the post in view when its parents load. 234 // On native, we rely on `maintainVisibleContentPosition` instead. 235 const didAdjustScrollWeb = useRef<boolean>(false) 236 const onContentSizeChangeWeb = React.useCallback(() => { 237 // only run once 238 if (didAdjustScrollWeb.current) { 239 return 240 } 241 // wait for loading to finish 242 if (thread?.type === 'post' && !!thread.parent) { 243 function onMeasure(pageY: number) { 244 ref.current?.scrollToOffset({ 245 animated: false, 246 offset: pageY, 247 }) 248 } 249 // Measure synchronously to avoid a layout jump. 250 const domNode = highlightedPostRef.current 251 if (domNode) { 252 const pageY = (domNode as any as Element).getBoundingClientRect().top 253 onMeasure(pageY) 254 } 255 didAdjustScrollWeb.current = true 256 } 257 }, [thread]) 258 259 // On native, we reveal parents in chunks. Although they're all already 260 // loaded and FlatList already has its own virtualization, unfortunately FlatList 261 // has a bug that causes the content to jump around if too many items are getting 262 // prepended at once. It also jumps around if items get prepended during scroll. 263 // To work around this, we prepend rows after scroll bumps against the top and rests. 264 const needsBumpMaxParents = React.useRef(false) 265 const onStartReached = React.useCallback(() => { 266 if (skeleton?.parents && maxParents < skeleton.parents.length) { 267 needsBumpMaxParents.current = true 268 } 269 }, [maxParents, skeleton?.parents]) 270 const bumpMaxParentsIfNeeded = React.useCallback(() => { 271 if (!isNative) { 272 return 273 } 274 if (needsBumpMaxParents.current) { 275 needsBumpMaxParents.current = false 276 setMaxParents(n => n + PARENTS_CHUNK_SIZE) 277 } 278 }, []) 279 const onMomentumScrollEnd = bumpMaxParentsIfNeeded 280 const onScrollToTop = bumpMaxParentsIfNeeded 281 282 const onEndReached = React.useCallback(() => { 283 if (isFetching || posts.length < maxReplies) return 284 setMaxReplies(prev => prev + 50) 285 }, [isFetching, maxReplies, posts.length]) 286 287 const renderItem = React.useCallback( 288 ({item, index}: {item: RowItem; index: number}) => { 289 if (item === TOP_COMPONENT) { 290 return isTabletOrMobile ? ( 291 <ViewHeader 292 title={_(msg({message: `Post`, context: 'description'}))} 293 /> 294 ) : null 295 } else if (item === REPLY_PROMPT && hasSession) { 296 return ( 297 <View> 298 {!isMobile && <ComposePrompt onPressCompose={onPressReply} />} 299 </View> 300 ) 301 } else if (isThreadNotFound(item)) { 302 return ( 303 <View style={[pal.border, pal.viewLight, styles.itemContainer]}> 304 <Text type="lg-bold" style={pal.textLight}> 305 <Trans>Deleted post.</Trans> 306 </Text> 307 </View> 308 ) 309 } else if (isThreadBlocked(item)) { 310 return ( 311 <View style={[pal.border, pal.viewLight, styles.itemContainer]}> 312 <Text type="lg-bold" style={pal.textLight}> 313 <Trans>Blocked post.</Trans> 314 </Text> 315 </View> 316 ) 317 } else if (isThreadPost(item)) { 318 const prev = isThreadPost(posts[index - 1]) 319 ? (posts[index - 1] as ThreadPost) 320 : undefined 321 const next = isThreadPost(posts[index - 1]) 322 ? (posts[index - 1] as ThreadPost) 323 : undefined 324 const hasUnrevealedParents = 325 index === 0 && 326 skeleton?.parents && 327 maxParents < skeleton.parents.length 328 return ( 329 <View 330 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} 331 onLayout={deferParents ? () => setDeferParents(false) : undefined}> 332 <PostThreadItem 333 post={item.post} 334 record={item.record} 335 treeView={treeView} 336 depth={item.ctx.depth} 337 prevPost={prev} 338 nextPost={next} 339 isHighlightedPost={item.ctx.isHighlightedPost} 340 hasMore={item.ctx.hasMore} 341 showChildReplyLine={item.ctx.showChildReplyLine} 342 showParentReplyLine={item.ctx.showParentReplyLine} 343 hasPrecedingItem={ 344 !!prev?.ctx.showChildReplyLine || !!hasUnrevealedParents 345 } 346 onPostReply={refetch} 347 /> 348 </View> 349 ) 350 } 351 return null 352 }, 353 [ 354 hasSession, 355 isTabletOrMobile, 356 _, 357 isMobile, 358 onPressReply, 359 pal.border, 360 pal.viewLight, 361 pal.textLight, 362 posts, 363 skeleton?.parents, 364 maxParents, 365 deferParents, 366 treeView, 367 refetch, 368 ], 369 ) 370 371 if (error || !thread) { 372 return ( 373 <ListMaybePlaceholder 374 isLoading={(!preferences || !thread) && !error} 375 isError={!!error} 376 noEmpty 377 onRetry={refetch} 378 errorTitle={error?.title} 379 errorMessage={error?.message} 380 /> 381 ) 382 } 383 384 return ( 385 <List 386 ref={ref} 387 data={posts} 388 renderItem={renderItem} 389 keyExtractor={keyExtractor} 390 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} 391 onStartReached={onStartReached} 392 onEndReached={onEndReached} 393 onEndReachedThreshold={2} 394 onMomentumScrollEnd={onMomentumScrollEnd} 395 onScrollToTop={onScrollToTop} 396 maintainVisibleContentPosition={ 397 isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined 398 } 399 // @ts-ignore our .web version only -prf 400 desktopFixedHeight 401 removeClippedSubviews={isAndroid ? false : undefined} 402 ListFooterComponent={ 403 <ListFooter 404 // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on 405 // initial render 406 isFetchingNextPage={isFetching} 407 error={cleanError(threadError)} 408 onRetry={refetch} 409 // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to 410 // work without causing weird jumps on web or glitches on native 411 height={windowHeight - 200} 412 /> 413 } 414 initialNumToRender={initialNumToRender} 415 windowSize={11} 416 /> 417 ) 418} 419 420function isThreadPost(v: unknown): v is ThreadPost { 421 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' 422} 423 424function isThreadNotFound(v: unknown): v is ThreadNotFound { 425 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found' 426} 427 428function isThreadBlocked(v: unknown): v is ThreadBlocked { 429 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked' 430} 431 432function createThreadSkeleton( 433 node: ThreadNode, 434 hasSession: boolean, 435 treeView: boolean, 436): ThreadSkeletonParts | null { 437 if (!node) return null 438 439 return { 440 parents: Array.from(flattenThreadParents(node, hasSession)), 441 highlightedPost: node, 442 replies: Array.from(flattenThreadReplies(node, hasSession, treeView)), 443 } 444} 445 446function* flattenThreadParents( 447 node: ThreadNode, 448 hasSession: boolean, 449): Generator<YieldedItem, void> { 450 if (node.type === 'post') { 451 if (node.parent) { 452 yield* flattenThreadParents(node.parent, hasSession) 453 } 454 if (!node.ctx.isHighlightedPost) { 455 yield node 456 } 457 } else if (node.type === 'not-found') { 458 yield node 459 } else if (node.type === 'blocked') { 460 yield node 461 } 462} 463 464function* flattenThreadReplies( 465 node: ThreadNode, 466 hasSession: boolean, 467 treeView: boolean, 468): Generator<YieldedItem, void> { 469 if (node.type === 'post') { 470 if (!hasSession && hasPwiOptOut(node)) { 471 return 472 } 473 if (!node.ctx.isHighlightedPost) { 474 yield node 475 } 476 if (node.replies?.length) { 477 for (const reply of node.replies) { 478 yield* flattenThreadReplies(reply, hasSession, treeView) 479 if (!treeView && !node.ctx.isHighlightedPost) { 480 break 481 } 482 } 483 } 484 } else if (node.type === 'not-found') { 485 yield node 486 } else if (node.type === 'blocked') { 487 yield node 488 } 489} 490 491function hasPwiOptOut(node: ThreadPost) { 492 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') 493} 494 495function hasBranchingReplies(node?: ThreadNode) { 496 if (!node) { 497 return false 498 } 499 if (node.type !== 'post') { 500 return false 501 } 502 if (!node.replies) { 503 return false 504 } 505 if (node.replies.length === 1) { 506 return hasBranchingReplies(node.replies[0]) 507 } 508 return true 509} 510 511const styles = StyleSheet.create({ 512 itemContainer: { 513 borderTopWidth: 1, 514 paddingHorizontal: 18, 515 paddingVertical: 18, 516 }, 517})