mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at no-pointer-events 552 lines 16 kB view raw
1import React, {useEffect, useRef} from 'react' 2import { 3 ActivityIndicator, 4 Pressable, 5 StyleSheet, 6 TouchableOpacity, 7 View, 8} from 'react-native' 9import {AppBskyFeedDefs} from '@atproto/api' 10import {CenteredView} from '../util/Views' 11import {List, ListMethods} from '../util/List' 12import { 13 FontAwesomeIcon, 14 FontAwesomeIconStyle, 15} from '@fortawesome/react-native-fontawesome' 16import {PostThreadItem} from './PostThreadItem' 17import {ComposePrompt} from '../composer/Prompt' 18import {ViewHeader} from '../util/ViewHeader' 19import {ErrorMessage} from '../util/error/ErrorMessage' 20import {Text} from '../util/text/Text' 21import {s} from 'lib/styles' 22import {usePalette} from 'lib/hooks/usePalette' 23import {useSetTitle} from 'lib/hooks/useSetTitle' 24import { 25 ThreadNode, 26 ThreadPost, 27 usePostThreadQuery, 28 sortThread, 29} from '#/state/queries/post-thread' 30import {useNavigation} from '@react-navigation/native' 31import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 32import {NavigationProp} from 'lib/routes/types' 33import {sanitizeDisplayName} from 'lib/strings/display-names' 34import {cleanError} from '#/lib/strings/errors' 35import {Trans, msg} from '@lingui/macro' 36import {useLingui} from '@lingui/react' 37import { 38 UsePreferencesQueryResponse, 39 usePreferencesQuery, 40} from '#/state/queries/preferences' 41import {useSession} from '#/state/session' 42import {isNative} from '#/platform/detection' 43import {logger} from '#/logger' 44 45const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} 46 47const TOP_COMPONENT = {_reactKey: '__top_component__'} 48const PARENT_SPINNER = {_reactKey: '__parent_spinner__'} 49const REPLY_PROMPT = {_reactKey: '__reply__'} 50const DELETED = {_reactKey: '__deleted__'} 51const BLOCKED = {_reactKey: '__blocked__'} 52const CHILD_SPINNER = {_reactKey: '__child_spinner__'} 53const LOAD_MORE = {_reactKey: '__load_more__'} 54const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'} 55 56type YieldedItem = 57 | ThreadPost 58 | typeof TOP_COMPONENT 59 | typeof PARENT_SPINNER 60 | typeof REPLY_PROMPT 61 | typeof DELETED 62 | typeof BLOCKED 63 | typeof PARENT_SPINNER 64 65export function PostThread({ 66 uri, 67 onCanReply, 68 onPressReply, 69}: { 70 uri: string | undefined 71 onCanReply: (canReply: boolean) => void 72 onPressReply: () => void 73}) { 74 const { 75 isLoading, 76 isError, 77 error, 78 refetch, 79 data: thread, 80 } = usePostThreadQuery(uri) 81 const {data: preferences} = usePreferencesQuery() 82 const rootPost = thread?.type === 'post' ? thread.post : undefined 83 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined 84 85 useSetTitle( 86 rootPost && 87 `${sanitizeDisplayName( 88 rootPost.author.displayName || `@${rootPost.author.handle}`, 89 )}: "${rootPostRecord?.text}"`, 90 ) 91 useEffect(() => { 92 if (rootPost) { 93 onCanReply(!rootPost.viewer?.replyDisabled) 94 } 95 }, [rootPost, onCanReply]) 96 97 if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) { 98 return ( 99 <PostThreadError 100 error={error} 101 notFound={AppBskyFeedDefs.isNotFoundPost(thread)} 102 onRefresh={refetch} 103 /> 104 ) 105 } 106 if (AppBskyFeedDefs.isBlockedPost(thread)) { 107 return <PostThreadBlocked /> 108 } 109 if (!thread || isLoading || !preferences) { 110 return ( 111 <CenteredView> 112 <View style={s.p20}> 113 <ActivityIndicator size="large" /> 114 </View> 115 </CenteredView> 116 ) 117 } 118 return ( 119 <PostThreadLoaded 120 thread={thread} 121 threadViewPrefs={preferences.threadViewPrefs} 122 onRefresh={refetch} 123 onPressReply={onPressReply} 124 /> 125 ) 126} 127 128function PostThreadLoaded({ 129 thread, 130 threadViewPrefs, 131 onRefresh, 132 onPressReply, 133}: { 134 thread: ThreadNode 135 threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs'] 136 onRefresh: () => void 137 onPressReply: () => void 138}) { 139 const {hasSession} = useSession() 140 const {_} = useLingui() 141 const pal = usePalette('default') 142 const {isTablet, isDesktop, isTabletOrMobile} = useWebMediaQueries() 143 const ref = useRef<ListMethods>(null) 144 const highlightedPostRef = useRef<View | null>(null) 145 const needsScrollAdjustment = useRef<boolean>( 146 !isNative || // web always uses scroll adjustment 147 (thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder 148 ) 149 const [maxVisible, setMaxVisible] = React.useState(100) 150 const [isPTRing, setIsPTRing] = React.useState(false) 151 const treeView = React.useMemo( 152 () => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread), 153 [threadViewPrefs, thread], 154 ) 155 156 // construct content 157 const posts = React.useMemo(() => { 158 let arr = [TOP_COMPONENT].concat( 159 Array.from( 160 flattenThreadSkeleton(sortThread(thread, threadViewPrefs), hasSession), 161 ), 162 ) 163 if (arr.length > maxVisible) { 164 arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) 165 } 166 if (arr.indexOf(CHILD_SPINNER) === -1) { 167 arr.push(BOTTOM_COMPONENT) 168 } 169 return arr 170 }, [thread, maxVisible, threadViewPrefs, hasSession]) 171 172 /** 173 * NOTE 174 * Scroll positioning 175 * 176 * This callback is run if needsScrollAdjustment.current == true, which is... 177 * - On web: always 178 * - On native: when the placeholder cache is not being used 179 * 180 * It then only runs when viewing a reply, and the goal is to scroll the 181 * reply into view. 182 * 183 * On native, if the placeholder cache is being used then maintainVisibleContentPosition 184 * is a more effective solution, so we use that. Otherwise, typically we're loading from 185 * the react-query cache, so we just need to immediately scroll down to the post. 186 * 187 * On desktop, maintainVisibleContentPosition isn't supported so we just always use 188 * this technique. 189 * 190 * -prf 191 */ 192 const onContentSizeChange = React.useCallback(() => { 193 // only run once 194 if (!needsScrollAdjustment.current) { 195 return 196 } 197 198 // wait for loading to finish 199 if (thread.type === 'post' && !!thread.parent) { 200 function onMeasure(pageY: number) { 201 let spinnerHeight = 0 202 if (isDesktop) { 203 spinnerHeight = 40 204 } else if (isTabletOrMobile) { 205 spinnerHeight = 82 206 } 207 ref.current?.scrollToOffset({ 208 animated: false, 209 offset: pageY - spinnerHeight, 210 }) 211 } 212 if (isNative) { 213 highlightedPostRef.current?.measure( 214 (_x, _y, _width, _height, _pageX, pageY) => { 215 onMeasure(pageY) 216 }, 217 ) 218 } else { 219 // Measure synchronously to avoid a layout jump. 220 const domNode = highlightedPostRef.current 221 if (domNode) { 222 const pageY = (domNode as any as Element).getBoundingClientRect().top 223 onMeasure(pageY) 224 } 225 } 226 needsScrollAdjustment.current = false 227 } 228 }, [thread, isDesktop, isTabletOrMobile]) 229 230 const onPTR = React.useCallback(async () => { 231 setIsPTRing(true) 232 try { 233 await onRefresh() 234 } catch (err) { 235 logger.error('Failed to refresh posts thread', {error: err}) 236 } 237 setIsPTRing(false) 238 }, [setIsPTRing, onRefresh]) 239 240 const renderItem = React.useCallback( 241 ({item, index}: {item: YieldedItem; index: number}) => { 242 if (item === TOP_COMPONENT) { 243 return isTablet ? ( 244 <ViewHeader 245 title={_(msg({message: `Post`, context: 'description'}))} 246 /> 247 ) : null 248 } else if (item === PARENT_SPINNER) { 249 return ( 250 <View style={styles.parentSpinner}> 251 <ActivityIndicator /> 252 </View> 253 ) 254 } else if (item === REPLY_PROMPT && hasSession) { 255 return ( 256 <View> 257 {isDesktop && <ComposePrompt onPressCompose={onPressReply} />} 258 </View> 259 ) 260 } else if (item === DELETED) { 261 return ( 262 <View style={[pal.border, pal.viewLight, styles.itemContainer]}> 263 <Text type="lg-bold" style={pal.textLight}> 264 <Trans>Deleted post.</Trans> 265 </Text> 266 </View> 267 ) 268 } else if (item === BLOCKED) { 269 return ( 270 <View style={[pal.border, pal.viewLight, styles.itemContainer]}> 271 <Text type="lg-bold" style={pal.textLight}> 272 <Trans>Blocked post.</Trans> 273 </Text> 274 </View> 275 ) 276 } else if (item === LOAD_MORE) { 277 return ( 278 <Pressable 279 onPress={() => setMaxVisible(n => n + 50)} 280 style={[pal.border, pal.view, styles.itemContainer]} 281 accessibilityLabel={_(msg`Load more posts`)} 282 accessibilityHint=""> 283 <View 284 style={[ 285 pal.viewLight, 286 {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6}, 287 ]}> 288 <Text type="lg-medium" style={pal.text}> 289 <Trans>Load more posts</Trans> 290 </Text> 291 </View> 292 </Pressable> 293 ) 294 } else if (item === BOTTOM_COMPONENT) { 295 // HACK 296 // due to some complexities with how flatlist works, this is the easiest way 297 // I could find to get a border positioned directly under the last item 298 // -prf 299 return ( 300 <View 301 style={{ 302 height: 400, 303 borderTopWidth: 1, 304 borderColor: pal.colors.border, 305 }} 306 /> 307 ) 308 } else if (item === CHILD_SPINNER) { 309 return ( 310 <View style={styles.childSpinner}> 311 <ActivityIndicator /> 312 </View> 313 ) 314 } else if (isThreadPost(item)) { 315 const prev = isThreadPost(posts[index - 1]) 316 ? (posts[index - 1] as ThreadPost) 317 : undefined 318 const next = isThreadPost(posts[index - 1]) 319 ? (posts[index - 1] as ThreadPost) 320 : undefined 321 return ( 322 <View 323 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}> 324 <PostThreadItem 325 post={item.post} 326 record={item.record} 327 treeView={treeView} 328 depth={item.ctx.depth} 329 prevPost={prev} 330 nextPost={next} 331 isHighlightedPost={item.ctx.isHighlightedPost} 332 hasMore={item.ctx.hasMore} 333 showChildReplyLine={item.ctx.showChildReplyLine} 334 showParentReplyLine={item.ctx.showParentReplyLine} 335 hasPrecedingItem={!!prev?.ctx.showChildReplyLine} 336 onPostReply={onRefresh} 337 /> 338 </View> 339 ) 340 } 341 return null 342 }, 343 [ 344 hasSession, 345 isTablet, 346 isDesktop, 347 onPressReply, 348 pal.border, 349 pal.viewLight, 350 pal.textLight, 351 pal.view, 352 pal.text, 353 pal.colors.border, 354 posts, 355 onRefresh, 356 treeView, 357 _, 358 ], 359 ) 360 361 return ( 362 <List 363 ref={ref} 364 data={posts} 365 initialNumToRender={!isNative ? posts.length : undefined} 366 maintainVisibleContentPosition={ 367 !needsScrollAdjustment.current 368 ? MAINTAIN_VISIBLE_CONTENT_POSITION 369 : undefined 370 } 371 keyExtractor={item => item._reactKey} 372 renderItem={renderItem} 373 refreshing={isPTRing} 374 onRefresh={onPTR} 375 onContentSizeChange={onContentSizeChange} 376 style={s.hContentRegion} 377 // @ts-ignore our .web version only -prf 378 desktopFixedHeight 379 /> 380 ) 381} 382 383function PostThreadBlocked() { 384 const {_} = useLingui() 385 const pal = usePalette('default') 386 const navigation = useNavigation<NavigationProp>() 387 388 const onPressBack = React.useCallback(() => { 389 if (navigation.canGoBack()) { 390 navigation.goBack() 391 } else { 392 navigation.navigate('Home') 393 } 394 }, [navigation]) 395 396 return ( 397 <CenteredView> 398 <View style={[pal.view, pal.border, styles.notFoundContainer]}> 399 <Text type="title-lg" style={[pal.text, s.mb5]}> 400 <Trans>Post hidden</Trans> 401 </Text> 402 <Text type="md" style={[pal.text, s.mb10]}> 403 <Trans> 404 You have blocked the author or you have been blocked by the author. 405 </Trans> 406 </Text> 407 <TouchableOpacity 408 onPress={onPressBack} 409 accessibilityRole="button" 410 accessibilityLabel={_(msg`Back`)} 411 accessibilityHint=""> 412 <Text type="2xl" style={pal.link}> 413 <FontAwesomeIcon 414 icon="angle-left" 415 style={[pal.link as FontAwesomeIconStyle, s.mr5]} 416 size={14} 417 /> 418 <Trans context="action">Back</Trans> 419 </Text> 420 </TouchableOpacity> 421 </View> 422 </CenteredView> 423 ) 424} 425 426function PostThreadError({ 427 onRefresh, 428 notFound, 429 error, 430}: { 431 onRefresh: () => void 432 notFound: boolean 433 error: Error | null 434}) { 435 const {_} = useLingui() 436 const pal = usePalette('default') 437 const navigation = useNavigation<NavigationProp>() 438 439 const onPressBack = React.useCallback(() => { 440 if (navigation.canGoBack()) { 441 navigation.goBack() 442 } else { 443 navigation.navigate('Home') 444 } 445 }, [navigation]) 446 447 if (notFound) { 448 return ( 449 <CenteredView> 450 <View style={[pal.view, pal.border, styles.notFoundContainer]}> 451 <Text type="title-lg" style={[pal.text, s.mb5]}> 452 <Trans>Post not found</Trans> 453 </Text> 454 <Text type="md" style={[pal.text, s.mb10]}> 455 <Trans>The post may have been deleted.</Trans> 456 </Text> 457 <TouchableOpacity 458 onPress={onPressBack} 459 accessibilityRole="button" 460 accessibilityLabel={_(msg`Back`)} 461 accessibilityHint=""> 462 <Text type="2xl" style={pal.link}> 463 <FontAwesomeIcon 464 icon="angle-left" 465 style={[pal.link as FontAwesomeIconStyle, s.mr5]} 466 size={14} 467 /> 468 <Trans>Back</Trans> 469 </Text> 470 </TouchableOpacity> 471 </View> 472 </CenteredView> 473 ) 474 } 475 return ( 476 <CenteredView> 477 <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} /> 478 </CenteredView> 479 ) 480} 481 482function isThreadPost(v: unknown): v is ThreadPost { 483 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' 484} 485 486function* flattenThreadSkeleton( 487 node: ThreadNode, 488 hasSession: boolean, 489): Generator<YieldedItem, void> { 490 if (node.type === 'post') { 491 if (node.parent) { 492 yield* flattenThreadSkeleton(node.parent, hasSession) 493 } else if (node.ctx.isParentLoading) { 494 yield PARENT_SPINNER 495 } 496 if (!hasSession && node.ctx.depth > 0 && hasPwiOptOut(node)) { 497 return 498 } 499 yield node 500 if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) { 501 yield REPLY_PROMPT 502 } 503 if (node.replies?.length) { 504 for (const reply of node.replies) { 505 yield* flattenThreadSkeleton(reply, hasSession) 506 } 507 } else if (node.ctx.isChildLoading) { 508 yield CHILD_SPINNER 509 } 510 } else if (node.type === 'not-found') { 511 yield DELETED 512 } else if (node.type === 'blocked') { 513 yield BLOCKED 514 } 515} 516 517function hasPwiOptOut(node: ThreadPost) { 518 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') 519} 520 521function hasBranchingReplies(node: ThreadNode) { 522 if (node.type !== 'post') { 523 return false 524 } 525 if (!node.replies) { 526 return false 527 } 528 if (node.replies.length === 1) { 529 return hasBranchingReplies(node.replies[0]) 530 } 531 return true 532} 533 534const styles = StyleSheet.create({ 535 notFoundContainer: { 536 margin: 10, 537 paddingHorizontal: 18, 538 paddingVertical: 14, 539 borderRadius: 6, 540 }, 541 itemContainer: { 542 borderTopWidth: 1, 543 paddingHorizontal: 18, 544 paddingVertical: 18, 545 }, 546 parentSpinner: { 547 paddingVertical: 10, 548 }, 549 childSpinner: { 550 paddingBottom: 200, 551 }, 552})