mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {useEffect, useRef} from 'react'
2import {
3 ActivityIndicator,
4 Pressable,
5 StyleSheet,
6 TouchableOpacity,
7 View,
8} from 'react-native'
9import {AppBskyFeedDefs} from '@atproto/api'
10import {CenteredView} from '../util/Views'
11import {List, ListMethods} from '../util/List'
12import {
13 FontAwesomeIcon,
14 FontAwesomeIconStyle,
15} from '@fortawesome/react-native-fontawesome'
16import {PostThreadItem} from './PostThreadItem'
17import {ComposePrompt} from '../composer/Prompt'
18import {ViewHeader} from '../util/ViewHeader'
19import {ErrorMessage} from '../util/error/ErrorMessage'
20import {Text} from '../util/text/Text'
21import {s} from 'lib/styles'
22import {usePalette} from 'lib/hooks/usePalette'
23import {useSetTitle} from 'lib/hooks/useSetTitle'
24import {
25 ThreadNode,
26 ThreadPost,
27 usePostThreadQuery,
28 sortThread,
29} from '#/state/queries/post-thread'
30import {useNavigation} from '@react-navigation/native'
31import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
32import {NavigationProp} from 'lib/routes/types'
33import {sanitizeDisplayName} from 'lib/strings/display-names'
34import {cleanError} from '#/lib/strings/errors'
35import {Trans, msg} from '@lingui/macro'
36import {useLingui} from '@lingui/react'
37import {
38 UsePreferencesQueryResponse,
39 usePreferencesQuery,
40} from '#/state/queries/preferences'
41import {useSession} from '#/state/session'
42import {isNative} from '#/platform/detection'
43import {logger} from '#/logger'
44
45const MAINTAIN_VISIBLE_CONTENT_POSITION = {minIndexForVisible: 2}
46
47const TOP_COMPONENT = {_reactKey: '__top_component__'}
48const PARENT_SPINNER = {_reactKey: '__parent_spinner__'}
49const REPLY_PROMPT = {_reactKey: '__reply__'}
50const DELETED = {_reactKey: '__deleted__'}
51const BLOCKED = {_reactKey: '__blocked__'}
52const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
53const LOAD_MORE = {_reactKey: '__load_more__'}
54const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
55
56type YieldedItem =
57 | ThreadPost
58 | typeof TOP_COMPONENT
59 | typeof PARENT_SPINNER
60 | typeof REPLY_PROMPT
61 | typeof DELETED
62 | typeof BLOCKED
63 | typeof PARENT_SPINNER
64
65export function PostThread({
66 uri,
67 onCanReply,
68 onPressReply,
69}: {
70 uri: string | undefined
71 onCanReply: (canReply: boolean) => void
72 onPressReply: () => void
73}) {
74 const {
75 isLoading,
76 isError,
77 error,
78 refetch,
79 data: thread,
80 } = usePostThreadQuery(uri)
81 const {data: preferences} = usePreferencesQuery()
82 const rootPost = thread?.type === 'post' ? thread.post : undefined
83 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
84
85 useSetTitle(
86 rootPost &&
87 `${sanitizeDisplayName(
88 rootPost.author.displayName || `@${rootPost.author.handle}`,
89 )}: "${rootPostRecord?.text}"`,
90 )
91 useEffect(() => {
92 if (rootPost) {
93 onCanReply(!rootPost.viewer?.replyDisabled)
94 }
95 }, [rootPost, onCanReply])
96
97 if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
98 return (
99 <PostThreadError
100 error={error}
101 notFound={AppBskyFeedDefs.isNotFoundPost(thread)}
102 onRefresh={refetch}
103 />
104 )
105 }
106 if (AppBskyFeedDefs.isBlockedPost(thread)) {
107 return <PostThreadBlocked />
108 }
109 if (!thread || isLoading || !preferences) {
110 return (
111 <CenteredView>
112 <View style={s.p20}>
113 <ActivityIndicator size="large" />
114 </View>
115 </CenteredView>
116 )
117 }
118 return (
119 <PostThreadLoaded
120 thread={thread}
121 threadViewPrefs={preferences.threadViewPrefs}
122 onRefresh={refetch}
123 onPressReply={onPressReply}
124 />
125 )
126}
127
128function PostThreadLoaded({
129 thread,
130 threadViewPrefs,
131 onRefresh,
132 onPressReply,
133}: {
134 thread: ThreadNode
135 threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
136 onRefresh: () => void
137 onPressReply: () => void
138}) {
139 const {hasSession} = useSession()
140 const {_} = useLingui()
141 const pal = usePalette('default')
142 const {isTablet, isDesktop} = useWebMediaQueries()
143 const ref = useRef<ListMethods>(null)
144 const highlightedPostRef = useRef<View | null>(null)
145 const needsScrollAdjustment = useRef<boolean>(
146 !isNative || // web always uses scroll adjustment
147 (thread.type === 'post' && !thread.ctx.isParentLoading), // native only does it when not loading from placeholder
148 )
149 const [maxVisible, setMaxVisible] = React.useState(100)
150 const [isPTRing, setIsPTRing] = React.useState(false)
151 const treeView = React.useMemo(
152 () => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread),
153 [threadViewPrefs, thread],
154 )
155
156 // construct content
157 const posts = React.useMemo(() => {
158 let arr = [TOP_COMPONENT].concat(
159 Array.from(
160 flattenThreadSkeleton(sortThread(thread, threadViewPrefs), hasSession),
161 ),
162 )
163 if (arr.length > maxVisible) {
164 arr = arr.slice(0, maxVisible).concat([LOAD_MORE])
165 }
166 if (arr.indexOf(CHILD_SPINNER) === -1) {
167 arr.push(BOTTOM_COMPONENT)
168 }
169 return arr
170 }, [thread, maxVisible, threadViewPrefs, hasSession])
171
172 /**
173 * NOTE
174 * Scroll positioning
175 *
176 * This callback is run if needsScrollAdjustment.current == true, which is...
177 * - On web: always
178 * - On native: when the placeholder cache is not being used
179 *
180 * It then only runs when viewing a reply, and the goal is to scroll the
181 * reply into view.
182 *
183 * On native, if the placeholder cache is being used then maintainVisibleContentPosition
184 * is a more effective solution, so we use that. Otherwise, typically we're loading from
185 * the react-query cache, so we just need to immediately scroll down to the post.
186 *
187 * On desktop, maintainVisibleContentPosition isn't supported so we just always use
188 * this technique.
189 *
190 * -prf
191 */
192 const onContentSizeChange = React.useCallback(() => {
193 // only run once
194 if (!needsScrollAdjustment.current) {
195 return
196 }
197
198 // wait for loading to finish
199 if (thread.type === 'post' && !!thread.parent) {
200 highlightedPostRef.current?.measure(
201 (_x, _y, _width, _height, _pageX, pageY) => {
202 ref.current?.scrollToOffset({
203 animated: false,
204 offset: pageY - (isDesktop ? 0 : 50),
205 })
206 },
207 )
208 needsScrollAdjustment.current = false
209 }
210 }, [thread, isDesktop])
211
212 const onPTR = React.useCallback(async () => {
213 setIsPTRing(true)
214 try {
215 await onRefresh()
216 } catch (err) {
217 logger.error('Failed to refresh posts thread', {error: err})
218 }
219 setIsPTRing(false)
220 }, [setIsPTRing, onRefresh])
221
222 const renderItem = React.useCallback(
223 ({item, index}: {item: YieldedItem; index: number}) => {
224 if (item === TOP_COMPONENT) {
225 return isTablet ? <ViewHeader title={_(msg`Post`)} /> : null
226 } else if (item === PARENT_SPINNER) {
227 return (
228 <View style={styles.parentSpinner}>
229 <ActivityIndicator />
230 </View>
231 )
232 } else if (item === REPLY_PROMPT && hasSession) {
233 return (
234 <View>
235 {isDesktop && <ComposePrompt onPressCompose={onPressReply} />}
236 </View>
237 )
238 } else if (item === DELETED) {
239 return (
240 <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
241 <Text type="lg-bold" style={pal.textLight}>
242 <Trans>Deleted post.</Trans>
243 </Text>
244 </View>
245 )
246 } else if (item === BLOCKED) {
247 return (
248 <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
249 <Text type="lg-bold" style={pal.textLight}>
250 <Trans>Blocked post.</Trans>
251 </Text>
252 </View>
253 )
254 } else if (item === LOAD_MORE) {
255 return (
256 <Pressable
257 onPress={() => setMaxVisible(n => n + 50)}
258 style={[pal.border, pal.view, styles.itemContainer]}
259 accessibilityLabel={_(msg`Load more posts`)}
260 accessibilityHint="">
261 <View
262 style={[
263 pal.viewLight,
264 {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6},
265 ]}>
266 <Text type="lg-medium" style={pal.text}>
267 <Trans>Load more posts</Trans>
268 </Text>
269 </View>
270 </Pressable>
271 )
272 } else if (item === BOTTOM_COMPONENT) {
273 // HACK
274 // due to some complexities with how flatlist works, this is the easiest way
275 // I could find to get a border positioned directly under the last item
276 // -prf
277 return (
278 <View
279 style={{
280 height: 400,
281 borderTopWidth: 1,
282 borderColor: pal.colors.border,
283 }}
284 />
285 )
286 } else if (item === CHILD_SPINNER) {
287 return (
288 <View style={styles.childSpinner}>
289 <ActivityIndicator />
290 </View>
291 )
292 } else if (isThreadPost(item)) {
293 const prev = isThreadPost(posts[index - 1])
294 ? (posts[index - 1] as ThreadPost)
295 : undefined
296 const next = isThreadPost(posts[index - 1])
297 ? (posts[index - 1] as ThreadPost)
298 : undefined
299 return (
300 <View
301 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}>
302 <PostThreadItem
303 post={item.post}
304 record={item.record}
305 treeView={treeView}
306 depth={item.ctx.depth}
307 prevPost={prev}
308 nextPost={next}
309 isHighlightedPost={item.ctx.isHighlightedPost}
310 hasMore={item.ctx.hasMore}
311 showChildReplyLine={item.ctx.showChildReplyLine}
312 showParentReplyLine={item.ctx.showParentReplyLine}
313 hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
314 onPostReply={onRefresh}
315 />
316 </View>
317 )
318 }
319 return null
320 },
321 [
322 hasSession,
323 isTablet,
324 isDesktop,
325 onPressReply,
326 pal.border,
327 pal.viewLight,
328 pal.textLight,
329 pal.view,
330 pal.text,
331 pal.colors.border,
332 posts,
333 onRefresh,
334 treeView,
335 _,
336 ],
337 )
338
339 return (
340 <List
341 ref={ref}
342 data={posts}
343 initialNumToRender={!isNative ? posts.length : undefined}
344 maintainVisibleContentPosition={
345 !needsScrollAdjustment.current
346 ? MAINTAIN_VISIBLE_CONTENT_POSITION
347 : undefined
348 }
349 keyExtractor={item => item._reactKey}
350 renderItem={renderItem}
351 refreshing={isPTRing}
352 onRefresh={onPTR}
353 onContentSizeChange={onContentSizeChange}
354 style={s.hContentRegion}
355 // @ts-ignore our .web version only -prf
356 desktopFixedHeight
357 />
358 )
359}
360
361function PostThreadBlocked() {
362 const {_} = useLingui()
363 const pal = usePalette('default')
364 const navigation = useNavigation<NavigationProp>()
365
366 const onPressBack = React.useCallback(() => {
367 if (navigation.canGoBack()) {
368 navigation.goBack()
369 } else {
370 navigation.navigate('Home')
371 }
372 }, [navigation])
373
374 return (
375 <CenteredView>
376 <View style={[pal.view, pal.border, styles.notFoundContainer]}>
377 <Text type="title-lg" style={[pal.text, s.mb5]}>
378 <Trans>Post hidden</Trans>
379 </Text>
380 <Text type="md" style={[pal.text, s.mb10]}>
381 <Trans>
382 You have blocked the author or you have been blocked by the author.
383 </Trans>
384 </Text>
385 <TouchableOpacity
386 onPress={onPressBack}
387 accessibilityRole="button"
388 accessibilityLabel={_(msg`Back`)}
389 accessibilityHint="">
390 <Text type="2xl" style={pal.link}>
391 <FontAwesomeIcon
392 icon="angle-left"
393 style={[pal.link as FontAwesomeIconStyle, s.mr5]}
394 size={14}
395 />
396 Back
397 </Text>
398 </TouchableOpacity>
399 </View>
400 </CenteredView>
401 )
402}
403
404function PostThreadError({
405 onRefresh,
406 notFound,
407 error,
408}: {
409 onRefresh: () => void
410 notFound: boolean
411 error: Error | null
412}) {
413 const {_} = useLingui()
414 const pal = usePalette('default')
415 const navigation = useNavigation<NavigationProp>()
416
417 const onPressBack = React.useCallback(() => {
418 if (navigation.canGoBack()) {
419 navigation.goBack()
420 } else {
421 navigation.navigate('Home')
422 }
423 }, [navigation])
424
425 if (notFound) {
426 return (
427 <CenteredView>
428 <View style={[pal.view, pal.border, styles.notFoundContainer]}>
429 <Text type="title-lg" style={[pal.text, s.mb5]}>
430 <Trans>Post not found</Trans>
431 </Text>
432 <Text type="md" style={[pal.text, s.mb10]}>
433 <Trans>The post may have been deleted.</Trans>
434 </Text>
435 <TouchableOpacity
436 onPress={onPressBack}
437 accessibilityRole="button"
438 accessibilityLabel={_(msg`Back`)}
439 accessibilityHint="">
440 <Text type="2xl" style={pal.link}>
441 <FontAwesomeIcon
442 icon="angle-left"
443 style={[pal.link as FontAwesomeIconStyle, s.mr5]}
444 size={14}
445 />
446 <Trans>Back</Trans>
447 </Text>
448 </TouchableOpacity>
449 </View>
450 </CenteredView>
451 )
452 }
453 return (
454 <CenteredView>
455 <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
456 </CenteredView>
457 )
458}
459
460function isThreadPost(v: unknown): v is ThreadPost {
461 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
462}
463
464function* flattenThreadSkeleton(
465 node: ThreadNode,
466 hasSession: boolean,
467): Generator<YieldedItem, void> {
468 if (node.type === 'post') {
469 if (node.parent) {
470 yield* flattenThreadSkeleton(node.parent, hasSession)
471 } else if (node.ctx.isParentLoading) {
472 yield PARENT_SPINNER
473 }
474 if (!hasSession && node.ctx.depth > 0 && hasPwiOptOut(node)) {
475 return
476 }
477 yield node
478 if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) {
479 yield REPLY_PROMPT
480 }
481 if (node.replies?.length) {
482 for (const reply of node.replies) {
483 yield* flattenThreadSkeleton(reply, hasSession)
484 }
485 } else if (node.ctx.isChildLoading) {
486 yield CHILD_SPINNER
487 }
488 } else if (node.type === 'not-found') {
489 yield DELETED
490 } else if (node.type === 'blocked') {
491 yield BLOCKED
492 }
493}
494
495function hasPwiOptOut(node: ThreadPost) {
496 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
497}
498
499function hasBranchingReplies(node: ThreadNode) {
500 if (node.type !== 'post') {
501 return false
502 }
503 if (!node.replies) {
504 return false
505 }
506 if (node.replies.length === 1) {
507 return hasBranchingReplies(node.replies[0])
508 }
509 return true
510}
511
512const styles = StyleSheet.create({
513 notFoundContainer: {
514 margin: 10,
515 paddingHorizontal: 18,
516 paddingVertical: 14,
517 borderRadius: 6,
518 },
519 itemContainer: {
520 borderTopWidth: 1,
521 paddingHorizontal: 18,
522 paddingVertical: 18,
523 },
524 parentSpinner: {
525 paddingVertical: 10,
526 },
527 childSpinner: {
528 paddingBottom: 200,
529 },
530})