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