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