mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
at profile-init 445 lines 12 kB view raw
1import React, {useRef} from 'react' 2import {runInAction} from 'mobx' 3import {observer} from 'mobx-react-lite' 4import { 5 ActivityIndicator, 6 Pressable, 7 RefreshControl, 8 StyleSheet, 9 TouchableOpacity, 10 View, 11} from 'react-native' 12import {AppBskyFeedDefs} from '@atproto/api' 13import {CenteredView, FlatList} from '../util/Views' 14import {PostThreadModel} from 'state/models/content/post-thread' 15import {PostThreadItemModel} from 'state/models/content/post-thread-item' 16import { 17 FontAwesomeIcon, 18 FontAwesomeIconStyle, 19} from '@fortawesome/react-native-fontawesome' 20import {PostThreadItem} from './PostThreadItem' 21import {ComposePrompt} from '../composer/Prompt' 22import {ViewHeader} from '../util/ViewHeader' 23import {ErrorMessage} from '../util/error/ErrorMessage' 24import {Text} from '../util/text/Text' 25import {s} from 'lib/styles' 26import {isNative} from 'platform/detection' 27import {usePalette} from 'lib/hooks/usePalette' 28import {useSetTitle} from 'lib/hooks/useSetTitle' 29import {useNavigation} from '@react-navigation/native' 30import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries' 31import {NavigationProp} from 'lib/routes/types' 32import {sanitizeDisplayName} from 'lib/strings/display-names' 33 34const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2} 35 36const TOP_COMPONENT = { 37 _reactKey: '__top_component__', 38 _isHighlightedPost: false, 39} 40const PARENT_SPINNER = { 41 _reactKey: '__parent_spinner__', 42 _isHighlightedPost: false, 43} 44const REPLY_PROMPT = {_reactKey: '__reply__', _isHighlightedPost: false} 45const DELETED = {_reactKey: '__deleted__', _isHighlightedPost: false} 46const BLOCKED = {_reactKey: '__blocked__', _isHighlightedPost: false} 47const CHILD_SPINNER = { 48 _reactKey: '__child_spinner__', 49 _isHighlightedPost: false, 50} 51const LOAD_MORE = { 52 _reactKey: '__load_more__', 53 _isHighlightedPost: false, 54} 55const BOTTOM_COMPONENT = { 56 _reactKey: '__bottom_component__', 57 _isHighlightedPost: false, 58 _showBorder: true, 59} 60type YieldedItem = 61 | PostThreadItemModel 62 | typeof TOP_COMPONENT 63 | typeof PARENT_SPINNER 64 | typeof REPLY_PROMPT 65 | typeof DELETED 66 | typeof BLOCKED 67 | typeof PARENT_SPINNER 68 69export const PostThread = observer(function PostThread({ 70 uri, 71 view, 72 onPressReply, 73 treeView, 74}: { 75 uri: string 76 view: PostThreadModel 77 onPressReply: () => void 78 treeView: boolean 79}) { 80 const pal = usePalette('default') 81 const {isTablet, isDesktop} = useWebMediaQueries() 82 const ref = useRef<FlatList>(null) 83 const hasScrolledIntoView = useRef<boolean>(false) 84 const [isRefreshing, setIsRefreshing] = React.useState(false) 85 const [maxVisible, setMaxVisible] = React.useState(100) 86 const navigation = useNavigation<NavigationProp>() 87 const posts = React.useMemo(() => { 88 if (view.thread) { 89 let arr = [TOP_COMPONENT].concat(Array.from(flattenThread(view.thread))) 90 if (arr.length > maxVisible) { 91 arr = arr.slice(0, maxVisible).concat([LOAD_MORE]) 92 } 93 if (view.isLoadingFromCache) { 94 if (view.thread?.postRecord?.reply) { 95 arr.unshift(PARENT_SPINNER) 96 } 97 arr.push(CHILD_SPINNER) 98 } else { 99 arr.push(BOTTOM_COMPONENT) 100 } 101 return arr 102 } 103 return [] 104 }, [view.isLoadingFromCache, view.thread, maxVisible]) 105 const highlightedPostIndex = posts.findIndex(post => post._isHighlightedPost) 106 useSetTitle( 107 view.thread?.postRecord && 108 `${sanitizeDisplayName( 109 view.thread.post.author.displayName || 110 `@${view.thread.post.author.handle}`, 111 )}: "${view.thread?.postRecord?.text}"`, 112 ) 113 114 // events 115 // = 116 117 const onRefresh = React.useCallback(async () => { 118 setIsRefreshing(true) 119 try { 120 view?.refresh() 121 } catch (err) { 122 view.rootStore.log.error('Failed to refresh posts thread', err) 123 } 124 setIsRefreshing(false) 125 }, [view, setIsRefreshing]) 126 127 const onContentSizeChange = React.useCallback(() => { 128 // only run once 129 if (hasScrolledIntoView.current) { 130 return 131 } 132 133 // wait for loading to finish 134 if ( 135 !view.hasContent || 136 (view.isFromCache && view.isLoadingFromCache) || 137 view.isLoading 138 ) { 139 return 140 } 141 142 if (highlightedPostIndex !== -1) { 143 ref.current?.scrollToIndex({ 144 index: highlightedPostIndex, 145 animated: false, 146 viewPosition: 0, 147 }) 148 hasScrolledIntoView.current = true 149 } 150 }, [ 151 highlightedPostIndex, 152 view.hasContent, 153 view.isFromCache, 154 view.isLoadingFromCache, 155 view.isLoading, 156 ]) 157 const onScrollToIndexFailed = React.useCallback( 158 (info: { 159 index: number 160 highestMeasuredFrameIndex: number 161 averageItemLength: number 162 }) => { 163 ref.current?.scrollToOffset({ 164 animated: false, 165 offset: info.averageItemLength * info.index, 166 }) 167 }, 168 [ref], 169 ) 170 171 const onPressBack = React.useCallback(() => { 172 if (navigation.canGoBack()) { 173 navigation.goBack() 174 } else { 175 navigation.navigate('Home') 176 } 177 }, [navigation]) 178 179 const renderItem = React.useCallback( 180 ({item, index}: {item: YieldedItem; index: number}) => { 181 if (item === TOP_COMPONENT) { 182 return isTablet ? <ViewHeader title="Post" /> : null 183 } else if (item === PARENT_SPINNER) { 184 return ( 185 <View style={styles.parentSpinner}> 186 <ActivityIndicator /> 187 </View> 188 ) 189 } else if (item === REPLY_PROMPT) { 190 return ( 191 <View> 192 {isDesktop && <ComposePrompt onPressCompose={onPressReply} />} 193 </View> 194 ) 195 } else if (item === DELETED) { 196 return ( 197 <View style={[pal.border, pal.viewLight, styles.itemContainer]}> 198 <Text type="lg-bold" style={pal.textLight}> 199 Deleted post. 200 </Text> 201 </View> 202 ) 203 } else if (item === BLOCKED) { 204 return ( 205 <View style={[pal.border, pal.viewLight, styles.itemContainer]}> 206 <Text type="lg-bold" style={pal.textLight}> 207 Blocked post. 208 </Text> 209 </View> 210 ) 211 } else if (item === LOAD_MORE) { 212 return ( 213 <Pressable 214 onPress={() => setMaxVisible(n => n + 50)} 215 style={[pal.border, pal.view, styles.itemContainer]} 216 accessibilityLabel="Load more posts" 217 accessibilityHint=""> 218 <View 219 style={[ 220 pal.viewLight, 221 {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6}, 222 ]}> 223 <Text type="lg-medium" style={pal.text}> 224 Load more posts 225 </Text> 226 </View> 227 </Pressable> 228 ) 229 } else if (item === BOTTOM_COMPONENT) { 230 // HACK 231 // due to some complexities with how flatlist works, this is the easiest way 232 // I could find to get a border positioned directly under the last item 233 // -prf 234 return ( 235 <View 236 style={{ 237 height: 400, 238 borderTopWidth: 1, 239 borderColor: pal.colors.border, 240 }} 241 /> 242 ) 243 } else if (item === CHILD_SPINNER) { 244 return ( 245 <View style={styles.childSpinner}> 246 <ActivityIndicator /> 247 </View> 248 ) 249 } else if (item instanceof PostThreadItemModel) { 250 const prev = ( 251 index - 1 >= 0 ? posts[index - 1] : undefined 252 ) as PostThreadItemModel 253 return ( 254 <PostThreadItem 255 item={item} 256 onPostReply={onRefresh} 257 hasPrecedingItem={prev?._showChildReplyLine} 258 treeView={treeView} 259 /> 260 ) 261 } 262 return <></> 263 }, 264 [ 265 isTablet, 266 isDesktop, 267 onPressReply, 268 pal.border, 269 pal.viewLight, 270 pal.textLight, 271 pal.view, 272 pal.text, 273 pal.colors.border, 274 posts, 275 onRefresh, 276 treeView, 277 ], 278 ) 279 280 // loading 281 // = 282 if ( 283 !view.hasLoaded || 284 (view.isLoading && !view.isRefreshing) || 285 view.params.uri !== uri 286 ) { 287 return ( 288 <CenteredView> 289 <View style={s.p20}> 290 <ActivityIndicator size="large" /> 291 </View> 292 </CenteredView> 293 ) 294 } 295 296 // error 297 // = 298 if (view.hasError) { 299 if (view.notFound) { 300 return ( 301 <CenteredView> 302 <View style={[pal.view, pal.border, styles.notFoundContainer]}> 303 <Text type="title-lg" style={[pal.text, s.mb5]}> 304 Post not found 305 </Text> 306 <Text type="md" style={[pal.text, s.mb10]}> 307 The post may have been deleted. 308 </Text> 309 <TouchableOpacity 310 onPress={onPressBack} 311 accessibilityRole="button" 312 accessibilityLabel="Back" 313 accessibilityHint=""> 314 <Text type="2xl" style={pal.link}> 315 <FontAwesomeIcon 316 icon="angle-left" 317 style={[pal.link as FontAwesomeIconStyle, s.mr5]} 318 size={14} 319 /> 320 Back 321 </Text> 322 </TouchableOpacity> 323 </View> 324 </CenteredView> 325 ) 326 } 327 return ( 328 <CenteredView> 329 <ErrorMessage message={view.error} onPressTryAgain={onRefresh} /> 330 </CenteredView> 331 ) 332 } 333 if (view.isBlocked) { 334 return ( 335 <CenteredView> 336 <View style={[pal.view, pal.border, styles.notFoundContainer]}> 337 <Text type="title-lg" style={[pal.text, s.mb5]}> 338 Post hidden 339 </Text> 340 <Text type="md" style={[pal.text, s.mb10]}> 341 You have blocked the author or you have been blocked by the author. 342 </Text> 343 <TouchableOpacity 344 onPress={onPressBack} 345 accessibilityRole="button" 346 accessibilityLabel="Back" 347 accessibilityHint=""> 348 <Text type="2xl" style={pal.link}> 349 <FontAwesomeIcon 350 icon="angle-left" 351 style={[pal.link as FontAwesomeIconStyle, s.mr5]} 352 size={14} 353 /> 354 Back 355 </Text> 356 </TouchableOpacity> 357 </View> 358 </CenteredView> 359 ) 360 } 361 362 // loaded 363 // = 364 return ( 365 <FlatList 366 ref={ref} 367 data={posts} 368 initialNumToRender={posts.length} 369 maintainVisibleContentPosition={ 370 isNative && view.isFromCache && view.isCachedPostAReply 371 ? MAINTAIN_VISIBLE_CONTENT_POSITION 372 : undefined 373 } 374 keyExtractor={item => item._reactKey} 375 renderItem={renderItem} 376 refreshControl={ 377 <RefreshControl 378 refreshing={isRefreshing} 379 onRefresh={onRefresh} 380 tintColor={pal.colors.text} 381 titleColor={pal.colors.text} 382 /> 383 } 384 onContentSizeChange={ 385 isNative && view.isFromCache ? undefined : onContentSizeChange 386 } 387 onScrollToIndexFailed={onScrollToIndexFailed} 388 style={s.hContentRegion} 389 // @ts-ignore our .web version only -prf 390 desktopFixedHeight 391 /> 392 ) 393}) 394 395function* flattenThread( 396 post: PostThreadItemModel, 397 isAscending = false, 398): Generator<YieldedItem, void> { 399 if (post.parent) { 400 if (AppBskyFeedDefs.isNotFoundPost(post.parent)) { 401 yield DELETED 402 } else if (AppBskyFeedDefs.isBlockedPost(post.parent)) { 403 yield BLOCKED 404 } else { 405 yield* flattenThread(post.parent as PostThreadItemModel, true) 406 } 407 } 408 yield post 409 if (post._isHighlightedPost) { 410 yield REPLY_PROMPT 411 } 412 if (post.replies?.length) { 413 for (const reply of post.replies) { 414 if (AppBskyFeedDefs.isNotFoundPost(reply)) { 415 yield DELETED 416 } else { 417 yield* flattenThread(reply as PostThreadItemModel) 418 } 419 } 420 } else if (!isAscending && !post.parent && post.post.replyCount) { 421 runInAction(() => { 422 post._hasMore = true 423 }) 424 } 425} 426 427const styles = StyleSheet.create({ 428 notFoundContainer: { 429 margin: 10, 430 paddingHorizontal: 18, 431 paddingVertical: 14, 432 borderRadius: 6, 433 }, 434 itemContainer: { 435 borderTopWidth: 1, 436 paddingHorizontal: 18, 437 paddingVertical: 18, 438 }, 439 parentSpinner: { 440 paddingVertical: 10, 441 }, 442 childSpinner: { 443 paddingBottom: 200, 444 }, 445})