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, isTabletOrMobile} = 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 function onMeasure(pageY: number) {
201 let spinnerHeight = 0
202 if (isDesktop) {
203 spinnerHeight = 40
204 } else if (isTabletOrMobile) {
205 spinnerHeight = 82
206 }
207 ref.current?.scrollToOffset({
208 animated: false,
209 offset: pageY - spinnerHeight,
210 })
211 }
212 if (isNative) {
213 highlightedPostRef.current?.measure(
214 (_x, _y, _width, _height, _pageX, pageY) => {
215 onMeasure(pageY)
216 },
217 )
218 } else {
219 // Measure synchronously to avoid a layout jump.
220 const domNode = highlightedPostRef.current
221 if (domNode) {
222 const pageY = (domNode as any as Element).getBoundingClientRect().top
223 onMeasure(pageY)
224 }
225 }
226 needsScrollAdjustment.current = false
227 }
228 }, [thread, isDesktop, isTabletOrMobile])
229
230 const onPTR = React.useCallback(async () => {
231 setIsPTRing(true)
232 try {
233 await onRefresh()
234 } catch (err) {
235 logger.error('Failed to refresh posts thread', {error: err})
236 }
237 setIsPTRing(false)
238 }, [setIsPTRing, onRefresh])
239
240 const renderItem = React.useCallback(
241 ({item, index}: {item: YieldedItem; index: number}) => {
242 if (item === TOP_COMPONENT) {
243 return isTablet ? (
244 <ViewHeader
245 title={_(msg({message: `Post`, context: 'description'}))}
246 />
247 ) : null
248 } else if (item === PARENT_SPINNER) {
249 return (
250 <View style={styles.parentSpinner}>
251 <ActivityIndicator />
252 </View>
253 )
254 } else if (item === REPLY_PROMPT && hasSession) {
255 return (
256 <View>
257 {isDesktop && <ComposePrompt onPressCompose={onPressReply} />}
258 </View>
259 )
260 } else if (item === DELETED) {
261 return (
262 <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
263 <Text type="lg-bold" style={pal.textLight}>
264 <Trans>Deleted post.</Trans>
265 </Text>
266 </View>
267 )
268 } else if (item === BLOCKED) {
269 return (
270 <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
271 <Text type="lg-bold" style={pal.textLight}>
272 <Trans>Blocked post.</Trans>
273 </Text>
274 </View>
275 )
276 } else if (item === LOAD_MORE) {
277 return (
278 <Pressable
279 onPress={() => setMaxVisible(n => n + 50)}
280 style={[pal.border, pal.view, styles.itemContainer]}
281 accessibilityLabel={_(msg`Load more posts`)}
282 accessibilityHint="">
283 <View
284 style={[
285 pal.viewLight,
286 {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6},
287 ]}>
288 <Text type="lg-medium" style={pal.text}>
289 <Trans>Load more posts</Trans>
290 </Text>
291 </View>
292 </Pressable>
293 )
294 } else if (item === BOTTOM_COMPONENT) {
295 // HACK
296 // due to some complexities with how flatlist works, this is the easiest way
297 // I could find to get a border positioned directly under the last item
298 // -prf
299 return (
300 <View
301 style={{
302 height: 400,
303 borderTopWidth: 1,
304 borderColor: pal.colors.border,
305 }}
306 />
307 )
308 } else if (item === CHILD_SPINNER) {
309 return (
310 <View style={styles.childSpinner}>
311 <ActivityIndicator />
312 </View>
313 )
314 } else if (isThreadPost(item)) {
315 const prev = isThreadPost(posts[index - 1])
316 ? (posts[index - 1] as ThreadPost)
317 : undefined
318 const next = isThreadPost(posts[index - 1])
319 ? (posts[index - 1] as ThreadPost)
320 : undefined
321 return (
322 <View
323 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}>
324 <PostThreadItem
325 post={item.post}
326 record={item.record}
327 treeView={treeView}
328 depth={item.ctx.depth}
329 prevPost={prev}
330 nextPost={next}
331 isHighlightedPost={item.ctx.isHighlightedPost}
332 hasMore={item.ctx.hasMore}
333 showChildReplyLine={item.ctx.showChildReplyLine}
334 showParentReplyLine={item.ctx.showParentReplyLine}
335 hasPrecedingItem={!!prev?.ctx.showChildReplyLine}
336 onPostReply={onRefresh}
337 />
338 </View>
339 )
340 }
341 return null
342 },
343 [
344 hasSession,
345 isTablet,
346 isDesktop,
347 onPressReply,
348 pal.border,
349 pal.viewLight,
350 pal.textLight,
351 pal.view,
352 pal.text,
353 pal.colors.border,
354 posts,
355 onRefresh,
356 treeView,
357 _,
358 ],
359 )
360
361 return (
362 <List
363 ref={ref}
364 data={posts}
365 initialNumToRender={!isNative ? posts.length : undefined}
366 maintainVisibleContentPosition={
367 !needsScrollAdjustment.current
368 ? MAINTAIN_VISIBLE_CONTENT_POSITION
369 : undefined
370 }
371 keyExtractor={item => item._reactKey}
372 renderItem={renderItem}
373 refreshing={isPTRing}
374 onRefresh={onPTR}
375 onContentSizeChange={onContentSizeChange}
376 style={s.hContentRegion}
377 // @ts-ignore our .web version only -prf
378 desktopFixedHeight
379 />
380 )
381}
382
383function PostThreadBlocked() {
384 const {_} = useLingui()
385 const pal = usePalette('default')
386 const navigation = useNavigation<NavigationProp>()
387
388 const onPressBack = React.useCallback(() => {
389 if (navigation.canGoBack()) {
390 navigation.goBack()
391 } else {
392 navigation.navigate('Home')
393 }
394 }, [navigation])
395
396 return (
397 <CenteredView>
398 <View style={[pal.view, pal.border, styles.notFoundContainer]}>
399 <Text type="title-lg" style={[pal.text, s.mb5]}>
400 <Trans>Post hidden</Trans>
401 </Text>
402 <Text type="md" style={[pal.text, s.mb10]}>
403 <Trans>
404 You have blocked the author or you have been blocked by the author.
405 </Trans>
406 </Text>
407 <TouchableOpacity
408 onPress={onPressBack}
409 accessibilityRole="button"
410 accessibilityLabel={_(msg`Back`)}
411 accessibilityHint="">
412 <Text type="2xl" style={pal.link}>
413 <FontAwesomeIcon
414 icon="angle-left"
415 style={[pal.link as FontAwesomeIconStyle, s.mr5]}
416 size={14}
417 />
418 <Trans context="action">Back</Trans>
419 </Text>
420 </TouchableOpacity>
421 </View>
422 </CenteredView>
423 )
424}
425
426function PostThreadError({
427 onRefresh,
428 notFound,
429 error,
430}: {
431 onRefresh: () => void
432 notFound: boolean
433 error: Error | null
434}) {
435 const {_} = useLingui()
436 const pal = usePalette('default')
437 const navigation = useNavigation<NavigationProp>()
438
439 const onPressBack = React.useCallback(() => {
440 if (navigation.canGoBack()) {
441 navigation.goBack()
442 } else {
443 navigation.navigate('Home')
444 }
445 }, [navigation])
446
447 if (notFound) {
448 return (
449 <CenteredView>
450 <View style={[pal.view, pal.border, styles.notFoundContainer]}>
451 <Text type="title-lg" style={[pal.text, s.mb5]}>
452 <Trans>Post not found</Trans>
453 </Text>
454 <Text type="md" style={[pal.text, s.mb10]}>
455 <Trans>The post may have been deleted.</Trans>
456 </Text>
457 <TouchableOpacity
458 onPress={onPressBack}
459 accessibilityRole="button"
460 accessibilityLabel={_(msg`Back`)}
461 accessibilityHint="">
462 <Text type="2xl" style={pal.link}>
463 <FontAwesomeIcon
464 icon="angle-left"
465 style={[pal.link as FontAwesomeIconStyle, s.mr5]}
466 size={14}
467 />
468 <Trans>Back</Trans>
469 </Text>
470 </TouchableOpacity>
471 </View>
472 </CenteredView>
473 )
474 }
475 return (
476 <CenteredView>
477 <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
478 </CenteredView>
479 )
480}
481
482function isThreadPost(v: unknown): v is ThreadPost {
483 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
484}
485
486function* flattenThreadSkeleton(
487 node: ThreadNode,
488 hasSession: boolean,
489): Generator<YieldedItem, void> {
490 if (node.type === 'post') {
491 if (node.parent) {
492 yield* flattenThreadSkeleton(node.parent, hasSession)
493 } else if (node.ctx.isParentLoading) {
494 yield PARENT_SPINNER
495 }
496 if (!hasSession && node.ctx.depth > 0 && hasPwiOptOut(node)) {
497 return
498 }
499 yield node
500 if (node.ctx.isHighlightedPost && !node.post.viewer?.replyDisabled) {
501 yield REPLY_PROMPT
502 }
503 if (node.replies?.length) {
504 for (const reply of node.replies) {
505 yield* flattenThreadSkeleton(reply, hasSession)
506 }
507 } else if (node.ctx.isChildLoading) {
508 yield CHILD_SPINNER
509 }
510 } else if (node.type === 'not-found') {
511 yield DELETED
512 } else if (node.type === 'blocked') {
513 yield BLOCKED
514 }
515}
516
517function hasPwiOptOut(node: ThreadPost) {
518 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
519}
520
521function hasBranchingReplies(node: ThreadNode) {
522 if (node.type !== 'post') {
523 return false
524 }
525 if (!node.replies) {
526 return false
527 }
528 if (node.replies.length === 1) {
529 return hasBranchingReplies(node.replies[0])
530 }
531 return true
532}
533
534const styles = StyleSheet.create({
535 notFoundContainer: {
536 margin: 10,
537 paddingHorizontal: 18,
538 paddingVertical: 14,
539 borderRadius: 6,
540 },
541 itemContainer: {
542 borderTopWidth: 1,
543 paddingHorizontal: 18,
544 paddingVertical: 18,
545 },
546 parentSpinner: {
547 paddingVertical: 10,
548 },
549 childSpinner: {
550 paddingBottom: 200,
551 },
552})