mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at offline-detection 530 lines 15 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} = 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 highlightedPostRef.current?.measure( 201 (_x, _y, _width, _height, _pageX, pageY) => { 202 ref.current?.scrollToOffset({ 203 animated: false, 204 offset: pageY - (isDesktop ? 0 : 50), 205 }) 206 }, 207 ) 208 needsScrollAdjustment.current = false 209 } 210 }, [thread, isDesktop]) 211 212 const onPTR = React.useCallback(async () => { 213 setIsPTRing(true) 214 try { 215 await onRefresh() 216 } catch (err) { 217 logger.error('Failed to refresh posts thread', {error: err}) 218 } 219 setIsPTRing(false) 220 }, [setIsPTRing, onRefresh]) 221 222 const renderItem = React.useCallback( 223 ({item, index}: {item: YieldedItem; index: number}) => { 224 if (item === TOP_COMPONENT) { 225 return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null 226 } else if (item === PARENT_SPINNER) { 227 return ( 228 <View style={styles.parentSpinner}> 229 <ActivityIndicator /> 230 </View> 231 ) 232 } else if (item === REPLY_PROMPT && hasSession) { 233 return ( 234 <View> 235 {isDesktop && <ComposePrompt onPressCompose={onPressReply} />} 236 </View> 237 ) 238 } else if (item === DELETED) { 239 return ( 240 <View style={[pal.border, pal.viewLight, styles.itemContainer]}> 241 <Text type="lg-bold" style={pal.textLight}> 242 <Trans>Deleted post.</Trans> 243 </Text> 244 </View> 245 ) 246 } else if (item === BLOCKED) { 247 return ( 248 <View style={[pal.border, pal.viewLight, styles.itemContainer]}> 249 <Text type="lg-bold" style={pal.textLight}> 250 <Trans>Blocked post.</Trans> 251 </Text> 252 </View> 253 ) 254 } else if (item === LOAD_MORE) { 255 return ( 256 <Pressable 257 onPress={() => setMaxVisible(n => n + 50)} 258 style={[pal.border, pal.view, styles.itemContainer]} 259 accessibilityLabel={_(msg`Load more posts`)} 260 accessibilityHint=""> 261 <View 262 style={[ 263 pal.viewLight, 264 {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6}, 265 ]}> 266 <Text type="lg-medium" style={pal.text}> 267 <Trans>Load more posts</Trans> 268 </Text> 269 </View> 270 </Pressable> 271 ) 272 } else if (item === BOTTOM_COMPONENT) { 273 // HACK 274 // due to some complexities with how flatlist works, this is the easiest way 275 // I could find to get a border positioned directly under the last item 276 // -prf 277 return ( 278 <View 279 style={{ 280 height: 400, 281 borderTopWidth: 1, 282 borderColor: pal.colors.border, 283 }} 284 /> 285 ) 286 } else if (item === CHILD_SPINNER) { 287 return ( 288 <View style={styles.childSpinner}> 289 <ActivityIndicator /> 290 </View> 291 ) 292 } else if (isThreadPost(item)) { 293 const prev = isThreadPost(posts[index - 1]) 294 ? (posts[index - 1] as ThreadPost) 295 : undefined 296 const next = isThreadPost(posts[index - 1]) 297 ? (posts[index - 1] as ThreadPost) 298 : undefined 299 return ( 300 <View 301 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}> 302 <PostThreadItem 303 post={item.post} 304 record={item.record} 305 treeView={treeView} 306 depth={item.ctx.depth} 307 prevPost={prev} 308 nextPost={next} 309 isHighlightedPost={item.ctx.isHighlightedPost} 310 hasMore={item.ctx.hasMore} 311 showChildReplyLine={item.ctx.showChildReplyLine} 312 showParentReplyLine={item.ctx.showParentReplyLine} 313 hasPrecedingItem={!!prev?.ctx.showChildReplyLine} 314 onPostReply={onRefresh} 315 /> 316 </View> 317 ) 318 } 319 return null 320 }, 321 [ 322 hasSession, 323 isTablet, 324 isDesktop, 325 onPressReply, 326 pal.border, 327 pal.viewLight, 328 pal.textLight, 329 pal.view, 330 pal.text, 331 pal.colors.border, 332 posts, 333 onRefresh, 334 treeView, 335 _, 336 ], 337 ) 338 339 return ( 340 <List 341 ref={ref} 342 data={posts} 343 initialNumToRender={!isNative ? posts.length : undefined} 344 maintainVisibleContentPosition={ 345 !needsScrollAdjustment.current 346 ? MAINTAIN_VISIBLE_CONTENT_POSITION 347 : undefined 348 } 349 keyExtractor={item => item._reactKey} 350 renderItem={renderItem} 351 refreshing={isPTRing} 352 onRefresh={onPTR} 353 onContentSizeChange={onContentSizeChange} 354 style={s.hContentRegion} 355 // @ts-ignore our .web version only -prf 356 desktopFixedHeight 357 /> 358 ) 359} 360 361function PostThreadBlocked() { 362 const {_} = useLingui() 363 const pal = usePalette('default') 364 const navigation = useNavigation<NavigationProp>() 365 366 const onPressBack = React.useCallback(() => { 367 if (navigation.canGoBack()) { 368 navigation.goBack() 369 } else { 370 navigation.navigate('Home') 371 } 372 }, [navigation]) 373 374 return ( 375 <CenteredView> 376 <View style={[pal.view, pal.border, styles.notFoundContainer]}> 377 <Text type="title-lg" style={[pal.text, s.mb5]}> 378 <Trans>Post hidden</Trans> 379 </Text> 380 <Text type="md" style={[pal.text, s.mb10]}> 381 <Trans> 382 You have blocked the author or you have been blocked by the author. 383 </Trans> 384 </Text> 385 <TouchableOpacity 386 onPress={onPressBack} 387 accessibilityRole="button" 388 accessibilityLabel={_(msg`Back`)} 389 accessibilityHint=""> 390 <Text type="2xl" style={pal.link}> 391 <FontAwesomeIcon 392 icon="angle-left" 393 style={[pal.link as FontAwesomeIconStyle, s.mr5]} 394 size={14} 395 /> 396 Back 397 </Text> 398 </TouchableOpacity> 399 </View> 400 </CenteredView> 401 ) 402} 403 404function PostThreadError({ 405 onRefresh, 406 notFound, 407 error, 408}: { 409 onRefresh: () => void 410 notFound: boolean 411 error: Error | null 412}) { 413 const {_} = useLingui() 414 const pal = usePalette('default') 415 const navigation = useNavigation<NavigationProp>() 416 417 const onPressBack = React.useCallback(() => { 418 if (navigation.canGoBack()) { 419 navigation.goBack() 420 } else { 421 navigation.navigate('Home') 422 } 423 }, [navigation]) 424 425 if (notFound) { 426 return ( 427 <CenteredView> 428 <View style={[pal.view, pal.border, styles.notFoundContainer]}> 429 <Text type="title-lg" style={[pal.text, s.mb5]}> 430 <Trans>Post not found</Trans> 431 </Text> 432 <Text type="md" style={[pal.text, s.mb10]}> 433 <Trans>The post may have been deleted.</Trans> 434 </Text> 435 <TouchableOpacity 436 onPress={onPressBack} 437 accessibilityRole="button" 438 accessibilityLabel={_(msg`Back`)} 439 accessibilityHint=""> 440 <Text type="2xl" style={pal.link}> 441 <FontAwesomeIcon 442 icon="angle-left" 443 style={[pal.link as FontAwesomeIconStyle, s.mr5]} 444 size={14} 445 /> 446 <Trans>Back</Trans> 447 </Text> 448 </TouchableOpacity> 449 </View> 450 </CenteredView> 451 ) 452 } 453 return ( 454 <CenteredView> 455 <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} /> 456 </CenteredView> 457 ) 458} 459 460function isThreadPost(v: unknown): v is ThreadPost { 461 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post' 462} 463 464function* flattenThreadSkeleton( 465 node: ThreadNode, 466 hasSession: boolean, 467): Generator<YieldedItem, void> { 468 if (node.type === 'post') { 469 if (node.parent) { 470 yield* flattenThreadSkeleton(node.parent, hasSession) 471 } else if (node.ctx.isParentLoading) { 472 yield PARENT_SPINNER 473 } 474 if (!hasSession && node.ctx.depth > 0 && hasPwiOptOut(node)) { 475 return 476 } 477 yield node 478 if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) { 479 yield REPLY_PROMPT 480 } 481 if (node.replies?.length) { 482 for (const reply of node.replies) { 483 yield* flattenThreadSkeleton(reply, hasSession) 484 } 485 } else if (node.ctx.isChildLoading) { 486 yield CHILD_SPINNER 487 } 488 } else if (node.type === 'not-found') { 489 yield DELETED 490 } else if (node.type === 'blocked') { 491 yield BLOCKED 492 } 493} 494 495function hasPwiOptOut(node: ThreadPost) { 496 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated') 497} 498 499function hasBranchingReplies(node: ThreadNode) { 500 if (node.type !== 'post') { 501 return false 502 } 503 if (!node.replies) { 504 return false 505 } 506 if (node.replies.length === 1) { 507 return hasBranchingReplies(node.replies[0]) 508 } 509 return true 510} 511 512const styles = StyleSheet.create({ 513 notFoundContainer: { 514 margin: 10, 515 paddingHorizontal: 18, 516 paddingVertical: 14, 517 borderRadius: 6, 518 }, 519 itemContainer: { 520 borderTopWidth: 1, 521 paddingHorizontal: 18, 522 paddingVertical: 18, 523 }, 524 parentSpinner: { 525 paddingVertical: 10, 526 }, 527 childSpinner: { 528 paddingBottom: 200, 529 }, 530})