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 {LoadingScreen} from '../util/LoadingScreen'
12import {List, ListMethods} from '../util/List'
13import {
14 FontAwesomeIcon,
15 FontAwesomeIconStyle,
16} from '@fortawesome/react-native-fontawesome'
17import {PostThreadItem} from './PostThreadItem'
18import {ComposePrompt} from '../composer/Prompt'
19import {ViewHeader} from '../util/ViewHeader'
20import {ErrorMessage} from '../util/error/ErrorMessage'
21import {Text} from '../util/text/Text'
22import {s} from 'lib/styles'
23import {usePalette} from 'lib/hooks/usePalette'
24import {useSetTitle} from 'lib/hooks/useSetTitle'
25import {
26 ThreadNode,
27 ThreadPost,
28 ThreadNotFound,
29 ThreadBlocked,
30 usePostThreadQuery,
31 sortThread,
32} from '#/state/queries/post-thread'
33import {useNavigation} from '@react-navigation/native'
34import {useWebMediaQueries} from 'lib/hooks/useWebMediaQueries'
35import {NavigationProp} from 'lib/routes/types'
36import {sanitizeDisplayName} from 'lib/strings/display-names'
37import {cleanError} from '#/lib/strings/errors'
38import {Trans, msg} from '@lingui/macro'
39import {useLingui} from '@lingui/react'
40import {
41 UsePreferencesQueryResponse,
42 useModerationOpts,
43 usePreferencesQuery,
44} from '#/state/queries/preferences'
45import {useSession} from '#/state/session'
46import {isAndroid, isNative, isWeb} from '#/platform/detection'
47import {moderatePost_wrapped as moderatePost} from '#/lib/moderatePost_wrapped'
48
49// FlatList maintainVisibleContentPosition breaks if too many items
50// are prepended. This seems to be an optimal number based on *shrug*.
51const PARENTS_CHUNK_SIZE = 15
52
53const MAINTAIN_VISIBLE_CONTENT_POSITION = {
54 // We don't insert any elements before the root row while loading.
55 // So the row we want to use as the scroll anchor is the first row.
56 minIndexForVisible: 0,
57}
58
59const TOP_COMPONENT = {_reactKey: '__top_component__'}
60const REPLY_PROMPT = {_reactKey: '__reply__'}
61const CHILD_SPINNER = {_reactKey: '__child_spinner__'}
62const LOAD_MORE = {_reactKey: '__load_more__'}
63const BOTTOM_COMPONENT = {_reactKey: '__bottom_component__'}
64
65type YieldedItem = ThreadPost | ThreadBlocked | ThreadNotFound
66type RowItem =
67 | YieldedItem
68 // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
69 | typeof TOP_COMPONENT
70 | typeof REPLY_PROMPT
71 | typeof CHILD_SPINNER
72 | typeof LOAD_MORE
73 | typeof BOTTOM_COMPONENT
74
75type ThreadSkeletonParts = {
76 parents: YieldedItem[]
77 highlightedPost: ThreadNode
78 replies: YieldedItem[]
79}
80
81export function PostThread({
82 uri,
83 onCanReply,
84 onPressReply,
85}: {
86 uri: string | undefined
87 onCanReply: (canReply: boolean) => void
88 onPressReply: () => void
89}) {
90 const {
91 isLoading,
92 isError,
93 error,
94 refetch,
95 data: thread,
96 } = usePostThreadQuery(uri)
97 const {data: preferences} = usePreferencesQuery()
98
99 const rootPost = thread?.type === 'post' ? thread.post : undefined
100 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
101
102 const moderationOpts = useModerationOpts()
103 const isNoPwi = React.useMemo(() => {
104 const mod =
105 rootPost && moderationOpts
106 ? moderatePost(rootPost, moderationOpts)
107 : undefined
108
109 const cause = mod?.content.cause
110
111 return cause
112 ? cause.type === 'label' && cause.labelDef.id === '!no-unauthenticated'
113 : false
114 }, [rootPost, moderationOpts])
115
116 useSetTitle(
117 rootPost && !isNoPwi
118 ? `${sanitizeDisplayName(
119 rootPost.author.displayName || `@${rootPost.author.handle}`,
120 )}: "${rootPostRecord!.text}"`
121 : '',
122 )
123 useEffect(() => {
124 if (rootPost) {
125 onCanReply(!rootPost.viewer?.replyDisabled)
126 }
127 }, [rootPost, onCanReply])
128
129 if (isError || AppBskyFeedDefs.isNotFoundPost(thread)) {
130 return (
131 <PostThreadError
132 error={error}
133 notFound={AppBskyFeedDefs.isNotFoundPost(thread)}
134 onRefresh={refetch}
135 />
136 )
137 }
138 if (AppBskyFeedDefs.isBlockedPost(thread)) {
139 return <PostThreadBlocked />
140 }
141 if (!thread || isLoading || !preferences) {
142 return <LoadingScreen />
143 }
144 return (
145 <PostThreadLoaded
146 thread={thread}
147 threadViewPrefs={preferences.threadViewPrefs}
148 onRefresh={refetch}
149 onPressReply={onPressReply}
150 />
151 )
152}
153
154function PostThreadLoaded({
155 thread,
156 threadViewPrefs,
157 onRefresh,
158 onPressReply,
159}: {
160 thread: ThreadNode
161 threadViewPrefs: UsePreferencesQueryResponse['threadViewPrefs']
162 onRefresh: () => void
163 onPressReply: () => void
164}) {
165 const {hasSession} = useSession()
166 const {_} = useLingui()
167 const pal = usePalette('default')
168 const {isMobile, isTabletOrMobile} = useWebMediaQueries()
169 const ref = useRef<ListMethods>(null)
170 const highlightedPostRef = useRef<View | null>(null)
171 const [maxParents, setMaxParents] = React.useState(
172 isWeb ? Infinity : PARENTS_CHUNK_SIZE,
173 )
174 const [maxReplies, setMaxReplies] = React.useState(100)
175 const treeView = React.useMemo(
176 () => !!threadViewPrefs.lab_treeViewEnabled && hasBranchingReplies(thread),
177 [threadViewPrefs, thread],
178 )
179
180 // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
181 // This ensures that the first render contains no parents--even if they are already available in the cache.
182 // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen.
183 // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
184 const [deferParents, setDeferParents] = React.useState(isNative)
185
186 const skeleton = React.useMemo(
187 () =>
188 createThreadSkeleton(
189 sortThread(thread, threadViewPrefs),
190 hasSession,
191 treeView,
192 ),
193 [thread, threadViewPrefs, hasSession, treeView],
194 )
195
196 // construct content
197 const posts = React.useMemo(() => {
198 const {parents, highlightedPost, replies} = skeleton
199 let arr: RowItem[] = []
200 if (highlightedPost.type === 'post') {
201 const isRoot =
202 !highlightedPost.parent && !highlightedPost.ctx.isParentLoading
203 if (isRoot) {
204 // No parents to load.
205 arr.push(TOP_COMPONENT)
206 } else {
207 if (highlightedPost.ctx.isParentLoading || deferParents) {
208 // We're loading parents of the highlighted post.
209 // In this case, we don't render anything above the post.
210 // If you add something here, you'll need to update both
211 // maintainVisibleContentPosition and onContentSizeChange
212 // to "hold onto" the correct row instead of the first one.
213 } else {
214 // Everything is loaded
215 let startIndex = Math.max(0, parents.length - maxParents)
216 if (startIndex === 0) {
217 arr.push(TOP_COMPONENT)
218 } else {
219 // When progressively revealing parents, rendering a placeholder
220 // here will cause scrolling jumps. Don't add it unless you test it.
221 // QT'ing this thread is a great way to test all the scrolling hacks:
222 // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
223 }
224 for (let i = startIndex; i < parents.length; i++) {
225 arr.push(parents[i])
226 }
227 }
228 }
229 arr.push(highlightedPost)
230 if (!highlightedPost.post.viewer?.replyDisabled) {
231 arr.push(REPLY_PROMPT)
232 }
233 if (highlightedPost.ctx.isChildLoading) {
234 arr.push(CHILD_SPINNER)
235 } else {
236 for (let i = 0; i < replies.length; i++) {
237 arr.push(replies[i])
238 if (i === maxReplies) {
239 arr.push(LOAD_MORE)
240 break
241 }
242 }
243 arr.push(BOTTOM_COMPONENT)
244 }
245 }
246 return arr
247 }, [skeleton, deferParents, maxParents, maxReplies])
248
249 // This is only used on the web to keep the post in view when its parents load.
250 // On native, we rely on `maintainVisibleContentPosition` instead.
251 const didAdjustScrollWeb = useRef<boolean>(false)
252 const onContentSizeChangeWeb = React.useCallback(() => {
253 // only run once
254 if (didAdjustScrollWeb.current) {
255 return
256 }
257 // wait for loading to finish
258 if (thread.type === 'post' && !!thread.parent) {
259 function onMeasure(pageY: number) {
260 ref.current?.scrollToOffset({
261 animated: false,
262 offset: pageY,
263 })
264 }
265 // Measure synchronously to avoid a layout jump.
266 const domNode = highlightedPostRef.current
267 if (domNode) {
268 const pageY = (domNode as any as Element).getBoundingClientRect().top
269 onMeasure(pageY)
270 }
271 didAdjustScrollWeb.current = true
272 }
273 }, [thread])
274
275 // On native, we reveal parents in chunks. Although they're all already
276 // loaded and FlatList already has its own virtualization, unfortunately FlatList
277 // has a bug that causes the content to jump around if too many items are getting
278 // prepended at once. It also jumps around if items get prepended during scroll.
279 // To work around this, we prepend rows after scroll bumps against the top and rests.
280 const needsBumpMaxParents = React.useRef(false)
281 const onStartReached = React.useCallback(() => {
282 if (maxParents < skeleton.parents.length) {
283 needsBumpMaxParents.current = true
284 }
285 }, [maxParents, skeleton.parents.length])
286 const bumpMaxParentsIfNeeded = React.useCallback(() => {
287 if (!isNative) {
288 return
289 }
290 if (needsBumpMaxParents.current) {
291 needsBumpMaxParents.current = false
292 setMaxParents(n => n + PARENTS_CHUNK_SIZE)
293 }
294 }, [])
295 const onMomentumScrollEnd = bumpMaxParentsIfNeeded
296 const onScrollToTop = bumpMaxParentsIfNeeded
297
298 const renderItem = React.useCallback(
299 ({item, index}: {item: RowItem; index: number}) => {
300 if (item === TOP_COMPONENT) {
301 return isTabletOrMobile ? (
302 <ViewHeader
303 title={_(msg({message: `Post`, context: 'description'}))}
304 />
305 ) : null
306 } else if (item === REPLY_PROMPT && hasSession) {
307 return (
308 <View>
309 {!isMobile && <ComposePrompt onPressCompose={onPressReply} />}
310 </View>
311 )
312 } else if (isThreadNotFound(item)) {
313 return (
314 <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
315 <Text type="lg-bold" style={pal.textLight}>
316 <Trans>Deleted post.</Trans>
317 </Text>
318 </View>
319 )
320 } else if (isThreadBlocked(item)) {
321 return (
322 <View style={[pal.border, pal.viewLight, styles.itemContainer]}>
323 <Text type="lg-bold" style={pal.textLight}>
324 <Trans>Blocked post.</Trans>
325 </Text>
326 </View>
327 )
328 } else if (item === LOAD_MORE) {
329 return (
330 <Pressable
331 onPress={() => setMaxReplies(n => n + 50)}
332 style={[pal.border, pal.view, styles.itemContainer]}
333 accessibilityLabel={_(msg`Load more posts`)}
334 accessibilityHint="">
335 <View
336 style={[
337 pal.viewLight,
338 {paddingHorizontal: 18, paddingVertical: 14, borderRadius: 6},
339 ]}>
340 <Text type="lg-medium" style={pal.text}>
341 <Trans>Load more posts</Trans>
342 </Text>
343 </View>
344 </Pressable>
345 )
346 } else if (item === BOTTOM_COMPONENT) {
347 // HACK
348 // due to some complexities with how flatlist works, this is the easiest way
349 // I could find to get a border positioned directly under the last item
350 // -prf
351 return (
352 <View
353 // @ts-ignore web-only
354 style={{
355 // Leave enough space below that the scroll doesn't jump
356 height: isNative ? 600 : '100vh',
357 borderTopWidth: 1,
358 borderColor: pal.colors.border,
359 }}
360 />
361 )
362 } else if (item === CHILD_SPINNER) {
363 return (
364 <View style={[pal.border, styles.childSpinner]}>
365 <ActivityIndicator />
366 </View>
367 )
368 } else if (isThreadPost(item)) {
369 const prev = isThreadPost(posts[index - 1])
370 ? (posts[index - 1] as ThreadPost)
371 : undefined
372 const next = isThreadPost(posts[index - 1])
373 ? (posts[index - 1] as ThreadPost)
374 : undefined
375 const hasUnrevealedParents =
376 index === 0 && maxParents < skeleton.parents.length
377 return (
378 <View
379 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
380 onLayout={deferParents ? () => setDeferParents(false) : undefined}>
381 <PostThreadItem
382 post={item.post}
383 record={item.record}
384 treeView={treeView}
385 depth={item.ctx.depth}
386 prevPost={prev}
387 nextPost={next}
388 isHighlightedPost={item.ctx.isHighlightedPost}
389 hasMore={item.ctx.hasMore}
390 showChildReplyLine={item.ctx.showChildReplyLine}
391 showParentReplyLine={item.ctx.showParentReplyLine}
392 hasPrecedingItem={
393 !!prev?.ctx.showChildReplyLine || hasUnrevealedParents
394 }
395 onPostReply={onRefresh}
396 />
397 </View>
398 )
399 }
400 return null
401 },
402 [
403 hasSession,
404 isTabletOrMobile,
405 isMobile,
406 onPressReply,
407 pal.border,
408 pal.viewLight,
409 pal.textLight,
410 pal.view,
411 pal.text,
412 pal.colors.border,
413 posts,
414 onRefresh,
415 deferParents,
416 treeView,
417 skeleton.parents.length,
418 maxParents,
419 _,
420 ],
421 )
422
423 return (
424 <List
425 ref={ref}
426 data={posts}
427 keyExtractor={item => item._reactKey}
428 renderItem={renderItem}
429 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
430 onStartReached={onStartReached}
431 onMomentumScrollEnd={onMomentumScrollEnd}
432 onScrollToTop={onScrollToTop}
433 maintainVisibleContentPosition={
434 isNative ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined
435 }
436 style={s.hContentRegion}
437 // @ts-ignore our .web version only -prf
438 desktopFixedHeight
439 removeClippedSubviews={isAndroid ? false : undefined}
440 windowSize={11}
441 />
442 )
443}
444
445function PostThreadBlocked() {
446 const {_} = useLingui()
447 const pal = usePalette('default')
448 const navigation = useNavigation<NavigationProp>()
449
450 const onPressBack = React.useCallback(() => {
451 if (navigation.canGoBack()) {
452 navigation.goBack()
453 } else {
454 navigation.navigate('Home')
455 }
456 }, [navigation])
457
458 return (
459 <CenteredView>
460 <View style={[pal.view, pal.border, styles.notFoundContainer]}>
461 <Text type="title-lg" style={[pal.text, s.mb5]}>
462 <Trans>Post hidden</Trans>
463 </Text>
464 <Text type="md" style={[pal.text, s.mb10]}>
465 <Trans>
466 You have blocked the author or you have been blocked by the author.
467 </Trans>
468 </Text>
469 <TouchableOpacity
470 onPress={onPressBack}
471 accessibilityRole="button"
472 accessibilityLabel={_(msg`Back`)}
473 accessibilityHint="">
474 <Text type="2xl" style={pal.link}>
475 <FontAwesomeIcon
476 icon="angle-left"
477 style={[pal.link as FontAwesomeIconStyle, s.mr5]}
478 size={14}
479 />
480 <Trans context="action">Back</Trans>
481 </Text>
482 </TouchableOpacity>
483 </View>
484 </CenteredView>
485 )
486}
487
488function PostThreadError({
489 onRefresh,
490 notFound,
491 error,
492}: {
493 onRefresh: () => void
494 notFound: boolean
495 error: Error | null
496}) {
497 const {_} = useLingui()
498 const pal = usePalette('default')
499 const navigation = useNavigation<NavigationProp>()
500
501 const onPressBack = React.useCallback(() => {
502 if (navigation.canGoBack()) {
503 navigation.goBack()
504 } else {
505 navigation.navigate('Home')
506 }
507 }, [navigation])
508
509 if (notFound) {
510 return (
511 <CenteredView>
512 <View style={[pal.view, pal.border, styles.notFoundContainer]}>
513 <Text type="title-lg" style={[pal.text, s.mb5]}>
514 <Trans>Post not found</Trans>
515 </Text>
516 <Text type="md" style={[pal.text, s.mb10]}>
517 <Trans>The post may have been deleted.</Trans>
518 </Text>
519 <TouchableOpacity
520 onPress={onPressBack}
521 accessibilityRole="button"
522 accessibilityLabel={_(msg`Back`)}
523 accessibilityHint="">
524 <Text type="2xl" style={pal.link}>
525 <FontAwesomeIcon
526 icon="angle-left"
527 style={[pal.link as FontAwesomeIconStyle, s.mr5]}
528 size={14}
529 />
530 <Trans>Back</Trans>
531 </Text>
532 </TouchableOpacity>
533 </View>
534 </CenteredView>
535 )
536 }
537 return (
538 <CenteredView>
539 <ErrorMessage message={cleanError(error)} onPressTryAgain={onRefresh} />
540 </CenteredView>
541 )
542}
543
544function isThreadPost(v: unknown): v is ThreadPost {
545 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
546}
547
548function isThreadNotFound(v: unknown): v is ThreadNotFound {
549 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
550}
551
552function isThreadBlocked(v: unknown): v is ThreadBlocked {
553 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
554}
555
556function createThreadSkeleton(
557 node: ThreadNode,
558 hasSession: boolean,
559 treeView: boolean,
560): ThreadSkeletonParts {
561 return {
562 parents: Array.from(flattenThreadParents(node, hasSession)),
563 highlightedPost: node,
564 replies: Array.from(flattenThreadReplies(node, hasSession, treeView)),
565 }
566}
567
568function* flattenThreadParents(
569 node: ThreadNode,
570 hasSession: boolean,
571): Generator<YieldedItem, void> {
572 if (node.type === 'post') {
573 if (node.parent) {
574 yield* flattenThreadParents(node.parent, hasSession)
575 }
576 if (!node.ctx.isHighlightedPost) {
577 yield node
578 }
579 } else if (node.type === 'not-found') {
580 yield node
581 } else if (node.type === 'blocked') {
582 yield node
583 }
584}
585
586function* flattenThreadReplies(
587 node: ThreadNode,
588 hasSession: boolean,
589 treeView: boolean,
590): Generator<YieldedItem, void> {
591 if (node.type === 'post') {
592 if (!hasSession && hasPwiOptOut(node)) {
593 return
594 }
595 if (!node.ctx.isHighlightedPost) {
596 yield node
597 }
598 if (node.replies?.length) {
599 for (const reply of node.replies) {
600 yield* flattenThreadReplies(reply, hasSession, treeView)
601 if (!treeView && !node.ctx.isHighlightedPost) {
602 break
603 }
604 }
605 }
606 } else if (node.type === 'not-found') {
607 yield node
608 } else if (node.type === 'blocked') {
609 yield node
610 }
611}
612
613function hasPwiOptOut(node: ThreadPost) {
614 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
615}
616
617function hasBranchingReplies(node: ThreadNode) {
618 if (node.type !== 'post') {
619 return false
620 }
621 if (!node.replies) {
622 return false
623 }
624 if (node.replies.length === 1) {
625 return hasBranchingReplies(node.replies[0])
626 }
627 return true
628}
629
630const styles = StyleSheet.create({
631 notFoundContainer: {
632 margin: 10,
633 paddingHorizontal: 18,
634 paddingVertical: 14,
635 borderRadius: 6,
636 },
637 itemContainer: {
638 borderTopWidth: 1,
639 paddingHorizontal: 18,
640 paddingVertical: 18,
641 },
642 childSpinner: {
643 borderTopWidth: 1,
644 paddingTop: 40,
645 paddingBottom: 200,
646 },
647})