mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
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})