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