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 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})