mirror of https://git.lenooby09.tech/LeNooby09/social-app.git
1import React, {memo, useRef, useState} from 'react'
2import {StyleSheet, useWindowDimensions, View} from 'react-native'
3import {runOnJS} from 'react-native-reanimated'
4import Animated from 'react-native-reanimated'
5import {useSafeAreaInsets} from 'react-native-safe-area-context'
6import {
7 AppBskyFeedDefs,
8 AppBskyFeedThreadgate,
9 moderatePost,
10} from '@atproto/api'
11import {msg, Trans} from '@lingui/macro'
12import {useLingui} from '@lingui/react'
13
14import {HITSLOP_10} from '#/lib/constants'
15import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'
16import {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform'
17import {useSetTitle} from '#/lib/hooks/useSetTitle'
18import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'
19import {clamp} from '#/lib/numbers'
20import {ScrollProvider} from '#/lib/ScrollContext'
21import {sanitizeDisplayName} from '#/lib/strings/display-names'
22import {cleanError} from '#/lib/strings/errors'
23import {isAndroid, isNative, isWeb} from '#/platform/detection'
24import {useModerationOpts} from '#/state/preferences/moderation-opts'
25import {
26 fillThreadModerationCache,
27 sortThread,
28 ThreadBlocked,
29 ThreadModerationCache,
30 ThreadNode,
31 ThreadNotFound,
32 ThreadPost,
33 usePostThreadQuery,
34} from '#/state/queries/post-thread'
35import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'
36import {usePreferencesQuery} from '#/state/queries/preferences'
37import {useSession} from '#/state/session'
38import {useComposerControls} from '#/state/shell'
39import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'
40import {List, ListMethods} from '#/view/com/util/List'
41import {atoms as a, useTheme} from '#/alf'
42import {Button, ButtonIcon} from '#/components/Button'
43import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'
44import {Header} from '#/components/Layout'
45import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'
46import * as Menu from '#/components/Menu'
47import {Text} from '#/components/Typography'
48import {PostThreadComposePrompt} from './PostThreadComposePrompt'
49import {PostThreadItem} from './PostThreadItem'
50import {PostThreadLoadMore} from './PostThreadLoadMore'
51import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
52
53// FlatList maintainVisibleContentPosition breaks if too many items
54// are prepended. This seems to be an optimal number based on *shrug*.
55const PARENTS_CHUNK_SIZE = 15
56
57const MAINTAIN_VISIBLE_CONTENT_POSITION = {
58 // We don't insert any elements before the root row while loading.
59 // So the row we want to use as the scroll anchor is the first row.
60 minIndexForVisible: 0,
61}
62
63const REPLY_PROMPT = {_reactKey: '__reply__'}
64const LOAD_MORE = {_reactKey: '__load_more__'}
65const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'}
66const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'}
67
68enum HiddenRepliesState {
69 Hide,
70 Show,
71 ShowAndOverridePostHider,
72}
73
74type YieldedItem =
75 | ThreadPost
76 | ThreadBlocked
77 | ThreadNotFound
78 | typeof SHOW_HIDDEN_REPLIES
79 | typeof SHOW_MUTED_REPLIES
80type RowItem =
81 | YieldedItem
82 // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape.
83 | typeof REPLY_PROMPT
84 | typeof LOAD_MORE
85
86type ThreadSkeletonParts = {
87 parents: YieldedItem[]
88 highlightedPost: ThreadNode
89 replies: YieldedItem[]
90}
91
92const keyExtractor = (item: RowItem) => {
93 return item._reactKey
94}
95
96export function PostThread({uri}: {uri: string | undefined}) {
97 const {hasSession, currentAccount} = useSession()
98 const {_} = useLingui()
99 const t = useTheme()
100 const {isMobile} = useWebMediaQueries()
101 const initialNumToRender = useInitialNumToRender()
102 const {height: windowHeight} = useWindowDimensions()
103 const [hiddenRepliesState, setHiddenRepliesState] = React.useState(
104 HiddenRepliesState.Hide,
105 )
106 const headerRef = React.useRef<View | null>(null)
107
108 const {data: preferences} = usePreferencesQuery()
109 const {
110 isFetching,
111 isError: isThreadError,
112 error: threadError,
113 refetch,
114 data: {thread, threadgate} = {},
115 dataUpdatedAt: fetchedAt,
116 } = usePostThreadQuery(uri)
117
118 // The original source of truth for these are the server settings.
119 const serverPrefs = preferences?.threadViewPrefs
120 const serverPrioritizeFollowedUsers =
121 serverPrefs?.prioritizeFollowedUsers ?? true
122 const serverTreeViewEnabled = serverPrefs?.lab_treeViewEnabled ?? false
123 const serverSortReplies = serverPrefs?.sort ?? 'hotness'
124
125 // However, we also need these to work locally for PWI (without persistance).
126 // So we're mirroring them locally.
127 const prioritizeFollowedUsers = serverPrioritizeFollowedUsers
128 const [treeViewEnabled, setTreeViewEnabled] = useState(serverTreeViewEnabled)
129 const [sortReplies, setSortReplies] = useState(serverSortReplies)
130
131 // We'll reset the local state if new server state flows down to us.
132 const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs)
133 if (prevServerPrefs !== serverPrefs) {
134 setPrevServerPrefs(serverPrefs)
135 setTreeViewEnabled(serverTreeViewEnabled)
136 setSortReplies(serverSortReplies)
137 }
138
139 // And we'll update the local state when mutating the server prefs.
140 const {mutate: mutateThreadViewPrefs} = useSetThreadViewPreferencesMutation()
141 function updateTreeViewEnabled(newTreeViewEnabled: boolean) {
142 setTreeViewEnabled(newTreeViewEnabled)
143 if (hasSession) {
144 mutateThreadViewPrefs({lab_treeViewEnabled: newTreeViewEnabled})
145 }
146 }
147 function updateSortReplies(newSortReplies: string) {
148 setSortReplies(newSortReplies)
149 if (hasSession) {
150 mutateThreadViewPrefs({sort: newSortReplies})
151 }
152 }
153
154 const treeView = React.useMemo(
155 () => treeViewEnabled && hasBranchingReplies(thread),
156 [treeViewEnabled, thread],
157 )
158
159 const rootPost = thread?.type === 'post' ? thread.post : undefined
160 const rootPostRecord = thread?.type === 'post' ? thread.record : undefined
161 const threadgateRecord = threadgate?.record as
162 | AppBskyFeedThreadgate.Record
163 | undefined
164 const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({
165 threadgateRecord,
166 })
167
168 const moderationOpts = useModerationOpts()
169 const isNoPwi = React.useMemo(() => {
170 const mod =
171 rootPost && moderationOpts
172 ? moderatePost(rootPost, moderationOpts)
173 : undefined
174 return !!mod
175 ?.ui('contentList')
176 .blurs.find(
177 cause =>
178 cause.type === 'label' &&
179 cause.labelDef.identifier === '!no-unauthenticated',
180 )
181 }, [rootPost, moderationOpts])
182
183 // Values used for proper rendering of parents
184 const ref = useRef<ListMethods>(null)
185 const highlightedPostRef = useRef<View | null>(null)
186 const [maxParents, setMaxParents] = React.useState(
187 isWeb ? Infinity : PARENTS_CHUNK_SIZE,
188 )
189 const [maxReplies, setMaxReplies] = React.useState(50)
190
191 useSetTitle(
192 rootPost && !isNoPwi
193 ? `${sanitizeDisplayName(
194 rootPost.author.displayName || `@${rootPost.author.handle}`,
195 )}: "${rootPostRecord!.text}"`
196 : '',
197 )
198
199 // On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed.
200 // This ensures that the first render contains no parents--even if they are already available in the cache.
201 // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen.
202 // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead.
203 const [deferParents, setDeferParents] = React.useState(isNative)
204
205 const currentDid = currentAccount?.did
206 const threadModerationCache = React.useMemo(() => {
207 const cache: ThreadModerationCache = new WeakMap()
208 if (thread && moderationOpts) {
209 fillThreadModerationCache(cache, thread, moderationOpts)
210 }
211 return cache
212 }, [thread, moderationOpts])
213
214 const [justPostedUris, setJustPostedUris] = React.useState(
215 () => new Set<string>(),
216 )
217
218 const [fetchedAtCache] = React.useState(() => new Map<string, number>())
219 const [randomCache] = React.useState(() => new Map<string, number>())
220 const skeleton = React.useMemo(() => {
221 if (!thread) return null
222 return createThreadSkeleton(
223 sortThread(
224 thread,
225 {
226 // Prefer local state as the source of truth.
227 sort: sortReplies,
228 lab_treeViewEnabled: treeViewEnabled,
229 prioritizeFollowedUsers,
230 },
231 threadModerationCache,
232 currentDid,
233 justPostedUris,
234 threadgateHiddenReplies,
235 fetchedAtCache,
236 fetchedAt,
237 randomCache,
238 ),
239 currentDid,
240 treeView,
241 threadModerationCache,
242 hiddenRepliesState !== HiddenRepliesState.Hide,
243 threadgateHiddenReplies,
244 )
245 }, [
246 thread,
247 prioritizeFollowedUsers,
248 sortReplies,
249 treeViewEnabled,
250 currentDid,
251 treeView,
252 threadModerationCache,
253 hiddenRepliesState,
254 justPostedUris,
255 threadgateHiddenReplies,
256 fetchedAtCache,
257 fetchedAt,
258 randomCache,
259 ])
260
261 const error = React.useMemo(() => {
262 if (AppBskyFeedDefs.isNotFoundPost(thread)) {
263 return {
264 title: _(msg`Post not found`),
265 message: _(msg`The post may have been deleted.`),
266 }
267 } else if (skeleton?.highlightedPost.type === 'blocked') {
268 return {
269 title: _(msg`Post hidden`),
270 message: _(
271 msg`You have blocked the author or you have been blocked by the author.`,
272 ),
273 }
274 } else if (threadError?.message.startsWith('Post not found')) {
275 return {
276 title: _(msg`Post not found`),
277 message: _(msg`The post may have been deleted.`),
278 }
279 } else if (isThreadError) {
280 return {
281 message: threadError ? cleanError(threadError) : undefined,
282 }
283 }
284
285 return null
286 }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
287
288 // construct content
289 const posts = React.useMemo(() => {
290 if (!skeleton) return []
291
292 const {parents, highlightedPost, replies} = skeleton
293 let arr: RowItem[] = []
294 if (highlightedPost.type === 'post') {
295 // We want to wait for parents to load before rendering.
296 // If you add something here, you'll need to update both
297 // maintainVisibleContentPosition and onContentSizeChange
298 // to "hold onto" the correct row instead of the first one.
299
300 if (!highlightedPost.ctx.isParentLoading && !deferParents) {
301 // When progressively revealing parents, rendering a placeholder
302 // here will cause scrolling jumps. Don't add it unless you test it.
303 // QT'ing this thread is a great way to test all the scrolling hacks:
304 // https://bsky.app/profile/www.mozzius.dev/post/3kjqhblh6qk2o
305
306 // Everything is loaded
307 let startIndex = Math.max(0, parents.length - maxParents)
308 for (let i = startIndex; i < parents.length; i++) {
309 arr.push(parents[i])
310 }
311 }
312 arr.push(highlightedPost)
313 if (!highlightedPost.post.viewer?.replyDisabled) {
314 arr.push(REPLY_PROMPT)
315 }
316 for (let i = 0; i < replies.length; i++) {
317 arr.push(replies[i])
318 if (i === maxReplies) {
319 break
320 }
321 }
322 }
323 return arr
324 }, [skeleton, deferParents, maxParents, maxReplies])
325
326 // This is only used on the web to keep the post in view when its parents load.
327 // On native, we rely on `maintainVisibleContentPosition` instead.
328 const didAdjustScrollWeb = useRef<boolean>(false)
329 const onContentSizeChangeWeb = React.useCallback(() => {
330 // only run once
331 if (didAdjustScrollWeb.current) {
332 return
333 }
334 // wait for loading to finish
335 if (thread?.type === 'post' && !!thread.parent) {
336 // Measure synchronously to avoid a layout jump.
337 const postNode = highlightedPostRef.current
338 const headerNode = headerRef.current
339 if (postNode && headerNode) {
340 let pageY = (postNode as any as Element).getBoundingClientRect().top
341 pageY -= (headerNode as any as Element).getBoundingClientRect().height
342 pageY = Math.max(0, pageY)
343 ref.current?.scrollToOffset({
344 animated: false,
345 offset: pageY,
346 })
347 }
348 didAdjustScrollWeb.current = true
349 }
350 }, [thread])
351
352 // On native, we reveal parents in chunks. Although they're all already
353 // loaded and FlatList already has its own virtualization, unfortunately FlatList
354 // has a bug that causes the content to jump around if too many items are getting
355 // prepended at once. It also jumps around if items get prepended during scroll.
356 // To work around this, we prepend rows after scroll bumps against the top and rests.
357 const needsBumpMaxParents = React.useRef(false)
358 const onStartReached = React.useCallback(() => {
359 if (skeleton?.parents && maxParents < skeleton.parents.length) {
360 needsBumpMaxParents.current = true
361 }
362 }, [maxParents, skeleton?.parents])
363 const bumpMaxParentsIfNeeded = React.useCallback(() => {
364 if (!isNative) {
365 return
366 }
367 if (needsBumpMaxParents.current) {
368 needsBumpMaxParents.current = false
369 setMaxParents(n => n + PARENTS_CHUNK_SIZE)
370 }
371 }, [])
372 const onScrollToTop = bumpMaxParentsIfNeeded
373 const onMomentumEnd = React.useCallback(() => {
374 'worklet'
375 runOnJS(bumpMaxParentsIfNeeded)()
376 }, [bumpMaxParentsIfNeeded])
377
378 const onEndReached = React.useCallback(() => {
379 if (isFetching || posts.length < maxReplies) return
380 setMaxReplies(prev => prev + 50)
381 }, [isFetching, maxReplies, posts.length])
382
383 const onPostReply = React.useCallback(
384 (postUri: string | undefined) => {
385 refetch()
386 if (postUri) {
387 setJustPostedUris(set => {
388 const nextSet = new Set(set)
389 nextSet.add(postUri)
390 return nextSet
391 })
392 }
393 },
394 [refetch],
395 )
396
397 const {openComposer} = useComposerControls()
398 const onPressReply = React.useCallback(() => {
399 if (thread?.type !== 'post') {
400 return
401 }
402 openComposer({
403 replyTo: {
404 uri: thread.post.uri,
405 cid: thread.post.cid,
406 text: thread.record.text,
407 author: thread.post.author,
408 embed: thread.post.embed,
409 moderation: threadModerationCache.get(thread),
410 },
411 onPost: onPostReply,
412 })
413 }, [openComposer, thread, onPostReply, threadModerationCache])
414
415 const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled
416 const hasParents =
417 skeleton?.highlightedPost?.type === 'post' &&
418 (skeleton.highlightedPost.ctx.isParentLoading ||
419 Boolean(skeleton?.parents && skeleton.parents.length > 0))
420
421 const renderItem = ({item, index}: {item: RowItem; index: number}) => {
422 if (item === REPLY_PROMPT && hasSession) {
423 return (
424 <View>
425 {!isMobile && (
426 <PostThreadComposePrompt onPressCompose={onPressReply} />
427 )}
428 </View>
429 )
430 } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) {
431 return (
432 <PostThreadShowHiddenReplies
433 type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'}
434 onPress={() =>
435 setHiddenRepliesState(
436 item === SHOW_HIDDEN_REPLIES
437 ? HiddenRepliesState.Show
438 : HiddenRepliesState.ShowAndOverridePostHider,
439 )
440 }
441 hideTopBorder={index === 0}
442 />
443 )
444 } else if (isThreadNotFound(item)) {
445 return (
446 <View
447 style={[
448 a.p_lg,
449 index !== 0 && a.border_t,
450 t.atoms.border_contrast_low,
451 t.atoms.bg_contrast_25,
452 ]}>
453 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
454 <Trans>Deleted post.</Trans>
455 </Text>
456 </View>
457 )
458 } else if (isThreadBlocked(item)) {
459 return (
460 <View
461 style={[
462 a.p_lg,
463 index !== 0 && a.border_t,
464 t.atoms.border_contrast_low,
465 t.atoms.bg_contrast_25,
466 ]}>
467 <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}>
468 <Trans>Blocked post.</Trans>
469 </Text>
470 </View>
471 )
472 } else if (isThreadPost(item)) {
473 const prev = isThreadPost(posts[index - 1])
474 ? (posts[index - 1] as ThreadPost)
475 : undefined
476 const next = isThreadPost(posts[index + 1])
477 ? (posts[index + 1] as ThreadPost)
478 : undefined
479 const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth
480 const showParentReplyLine =
481 (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1
482 const hasUnrevealedParents =
483 index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
484
485 if (!treeView && prev && item.ctx.hasMoreSelfThread) {
486 return <PostThreadLoadMore post={prev.post} />
487 }
488
489 return (
490 <View
491 ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined}
492 onLayout={deferParents ? () => setDeferParents(false) : undefined}>
493 <PostThreadItem
494 post={item.post}
495 record={item.record}
496 threadgateRecord={threadgateRecord ?? undefined}
497 moderation={threadModerationCache.get(item)}
498 treeView={treeView}
499 depth={item.ctx.depth}
500 prevPost={prev}
501 nextPost={next}
502 isHighlightedPost={item.ctx.isHighlightedPost}
503 hasMore={item.ctx.hasMore}
504 showChildReplyLine={showChildReplyLine}
505 showParentReplyLine={showParentReplyLine}
506 hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents}
507 overrideBlur={
508 hiddenRepliesState ===
509 HiddenRepliesState.ShowAndOverridePostHider &&
510 item.ctx.depth > 0
511 }
512 onPostReply={onPostReply}
513 hideTopBorder={index === 0 && !item.ctx.isParentLoading}
514 />
515 </View>
516 )
517 }
518 return null
519 }
520
521 if (!thread || !preferences || error) {
522 return (
523 <ListMaybePlaceholder
524 isLoading={!error}
525 isError={Boolean(error)}
526 noEmpty
527 onRetry={refetch}
528 errorTitle={error?.title}
529 errorMessage={error?.message}
530 />
531 )
532 }
533
534 return (
535 <>
536 <Header.Outer headerRef={headerRef}>
537 <Header.BackButton />
538 <Header.Content>
539 <Header.TitleText>
540 <Trans context="description">Post</Trans>
541 </Header.TitleText>
542 </Header.Content>
543 <Header.Slot>
544 <ThreadMenu
545 sortReplies={sortReplies}
546 treeViewEnabled={treeViewEnabled}
547 setSortReplies={updateSortReplies}
548 setTreeViewEnabled={updateTreeViewEnabled}
549 />
550 </Header.Slot>
551 </Header.Outer>
552
553 <ScrollProvider onMomentumEnd={onMomentumEnd}>
554 <List
555 ref={ref}
556 data={posts}
557 renderItem={renderItem}
558 keyExtractor={keyExtractor}
559 onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb}
560 onStartReached={onStartReached}
561 onEndReached={onEndReached}
562 onEndReachedThreshold={2}
563 onScrollToTop={onScrollToTop}
564 maintainVisibleContentPosition={
565 isNative && hasParents
566 ? MAINTAIN_VISIBLE_CONTENT_POSITION
567 : undefined
568 }
569 // @ts-ignore our .web version only -prf
570 desktopFixedHeight
571 removeClippedSubviews={isAndroid ? false : undefined}
572 ListFooterComponent={
573 <ListFooter
574 // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on
575 // initial render
576 isFetchingNextPage={isFetching}
577 error={cleanError(threadError)}
578 onRetry={refetch}
579 // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to
580 // work without causing weird jumps on web or glitches on native
581 height={windowHeight - 200}
582 />
583 }
584 initialNumToRender={initialNumToRender}
585 windowSize={11}
586 sideBorders={false}
587 />
588 </ScrollProvider>
589 {isMobile && canReply && hasSession && (
590 <MobileComposePrompt onPressReply={onPressReply} />
591 )}
592 </>
593 )
594}
595
596let ThreadMenu = ({
597 sortReplies,
598 treeViewEnabled,
599 setSortReplies,
600 setTreeViewEnabled,
601}: {
602 sortReplies: string
603 treeViewEnabled: boolean
604 setSortReplies: (newValue: string) => void
605 setTreeViewEnabled: (newValue: boolean) => void
606}): React.ReactNode => {
607 const {_} = useLingui()
608 return (
609 <Menu.Root>
610 <Menu.Trigger label={_(msg`Thread options`)}>
611 {({props}) => (
612 <Button
613 label={_(msg`Thread options`)}
614 size="small"
615 variant="ghost"
616 color="secondary"
617 shape="round"
618 hitSlop={HITSLOP_10}
619 {...props}>
620 <ButtonIcon icon={SettingsSlider} size="md" />
621 </Button>
622 )}
623 </Menu.Trigger>
624 <Menu.Outer>
625 <Menu.LabelText>
626 <Trans>Show replies as</Trans>
627 </Menu.LabelText>
628 <Menu.Group>
629 <Menu.Item
630 label={_(msg`Linear`)}
631 onPress={() => {
632 setTreeViewEnabled(false)
633 }}>
634 <Menu.ItemText>
635 <Trans>Linear</Trans>
636 </Menu.ItemText>
637 <Menu.ItemRadio selected={!treeViewEnabled} />
638 </Menu.Item>
639 <Menu.Item
640 label={_(msg`Threaded`)}
641 onPress={() => {
642 setTreeViewEnabled(true)
643 }}>
644 <Menu.ItemText>
645 <Trans>Threaded</Trans>
646 </Menu.ItemText>
647 <Menu.ItemRadio selected={treeViewEnabled} />
648 </Menu.Item>
649 </Menu.Group>
650 <Menu.Divider />
651 <Menu.LabelText>
652 <Trans>Reply sorting</Trans>
653 </Menu.LabelText>
654 <Menu.Group>
655 <Menu.Item
656 label={_(msg`Hot replies first`)}
657 onPress={() => {
658 setSortReplies('hotness')
659 }}>
660 <Menu.ItemText>
661 <Trans>Hot replies first</Trans>
662 </Menu.ItemText>
663 <Menu.ItemRadio selected={sortReplies === 'hotness'} />
664 </Menu.Item>
665 <Menu.Item
666 label={_(msg`Oldest replies first`)}
667 onPress={() => {
668 setSortReplies('oldest')
669 }}>
670 <Menu.ItemText>
671 <Trans>Oldest replies first</Trans>
672 </Menu.ItemText>
673 <Menu.ItemRadio selected={sortReplies === 'oldest'} />
674 </Menu.Item>
675 <Menu.Item
676 label={_(msg`Newest replies first`)}
677 onPress={() => {
678 setSortReplies('newest')
679 }}>
680 <Menu.ItemText>
681 <Trans>Newest replies first</Trans>
682 </Menu.ItemText>
683 <Menu.ItemRadio selected={sortReplies === 'newest'} />
684 </Menu.Item>
685 <Menu.Item
686 label={_(msg`Most-liked replies first`)}
687 onPress={() => {
688 setSortReplies('most-likes')
689 }}>
690 <Menu.ItemText>
691 <Trans>Most-liked replies first</Trans>
692 </Menu.ItemText>
693 <Menu.ItemRadio selected={sortReplies === 'most-likes'} />
694 </Menu.Item>
695 <Menu.Item
696 label={_(msg`Random (aka "Poster's Roulette")`)}
697 onPress={() => {
698 setSortReplies('random')
699 }}>
700 <Menu.ItemText>
701 <Trans>Random (aka "Poster's Roulette")</Trans>
702 </Menu.ItemText>
703 <Menu.ItemRadio selected={sortReplies === 'random'} />
704 </Menu.Item>
705 </Menu.Group>
706 </Menu.Outer>
707 </Menu.Root>
708 )
709}
710ThreadMenu = memo(ThreadMenu)
711
712function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) {
713 const safeAreaInsets = useSafeAreaInsets()
714 const fabMinimalShellTransform = useMinimalShellFabTransform()
715 return (
716 <Animated.View
717 style={[
718 styles.prompt,
719 fabMinimalShellTransform,
720 {
721 bottom: clamp(safeAreaInsets.bottom, 13, 60),
722 },
723 ]}>
724 <PostThreadComposePrompt onPressCompose={onPressReply} />
725 </Animated.View>
726 )
727}
728
729function isThreadPost(v: unknown): v is ThreadPost {
730 return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'
731}
732
733function isThreadNotFound(v: unknown): v is ThreadNotFound {
734 return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'
735}
736
737function isThreadBlocked(v: unknown): v is ThreadBlocked {
738 return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'
739}
740
741function createThreadSkeleton(
742 node: ThreadNode,
743 currentDid: string | undefined,
744 treeView: boolean,
745 modCache: ThreadModerationCache,
746 showHiddenReplies: boolean,
747 threadgateRecordHiddenReplies: Set<string>,
748): ThreadSkeletonParts | null {
749 if (!node) return null
750
751 return {
752 parents: Array.from(flattenThreadParents(node, !!currentDid)),
753 highlightedPost: node,
754 replies: Array.from(
755 flattenThreadReplies(
756 node,
757 currentDid,
758 treeView,
759 modCache,
760 showHiddenReplies,
761 threadgateRecordHiddenReplies,
762 ),
763 ),
764 }
765}
766
767function* flattenThreadParents(
768 node: ThreadNode,
769 hasSession: boolean,
770): Generator<YieldedItem, void> {
771 if (node.type === 'post') {
772 if (node.parent) {
773 yield* flattenThreadParents(node.parent, hasSession)
774 }
775 if (!node.ctx.isHighlightedPost) {
776 yield node
777 }
778 } else if (node.type === 'not-found') {
779 yield node
780 } else if (node.type === 'blocked') {
781 yield node
782 }
783}
784
785// The enum is ordered to make them easy to merge
786enum HiddenReplyType {
787 None = 0,
788 Muted = 1,
789 Hidden = 2,
790}
791
792function* flattenThreadReplies(
793 node: ThreadNode,
794 currentDid: string | undefined,
795 treeView: boolean,
796 modCache: ThreadModerationCache,
797 showHiddenReplies: boolean,
798 threadgateRecordHiddenReplies: Set<string>,
799): Generator<YieldedItem, HiddenReplyType> {
800 if (node.type === 'post') {
801 // dont show pwi-opted-out posts to logged out users
802 if (!currentDid && hasPwiOptOut(node)) {
803 return HiddenReplyType.None
804 }
805
806 // handle blurred items
807 if (node.ctx.depth > 0) {
808 const modui = modCache.get(node)?.ui('contentList')
809 if (modui?.blur || modui?.filter) {
810 if (!showHiddenReplies || node.ctx.depth > 1) {
811 if ((modui.blurs[0] || modui.filters[0]).type === 'muted') {
812 return HiddenReplyType.Muted
813 }
814 return HiddenReplyType.Hidden
815 }
816 }
817
818 if (!showHiddenReplies) {
819 const hiddenByThreadgate = threadgateRecordHiddenReplies.has(
820 node.post.uri,
821 )
822 const authorIsViewer = node.post.author.did === currentDid
823 if (hiddenByThreadgate && !authorIsViewer) {
824 return HiddenReplyType.Hidden
825 }
826 }
827 }
828
829 if (!node.ctx.isHighlightedPost) {
830 yield node
831 }
832
833 if (node.replies?.length) {
834 let hiddenReplies = HiddenReplyType.None
835 for (const reply of node.replies) {
836 let hiddenReply = yield* flattenThreadReplies(
837 reply,
838 currentDid,
839 treeView,
840 modCache,
841 showHiddenReplies,
842 threadgateRecordHiddenReplies,
843 )
844 if (hiddenReply > hiddenReplies) {
845 hiddenReplies = hiddenReply
846 }
847 if (!treeView && !node.ctx.isHighlightedPost) {
848 break
849 }
850 }
851
852 // show control to enable hidden replies
853 if (node.ctx.depth === 0) {
854 if (hiddenReplies === HiddenReplyType.Muted) {
855 yield SHOW_MUTED_REPLIES
856 } else if (hiddenReplies === HiddenReplyType.Hidden) {
857 yield SHOW_HIDDEN_REPLIES
858 }
859 }
860 }
861 } else if (node.type === 'not-found') {
862 yield node
863 } else if (node.type === 'blocked') {
864 yield node
865 }
866 return HiddenReplyType.None
867}
868
869function hasPwiOptOut(node: ThreadPost) {
870 return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')
871}
872
873function hasBranchingReplies(node?: ThreadNode) {
874 if (!node) {
875 return false
876 }
877 if (node.type !== 'post') {
878 return false
879 }
880 if (!node.replies) {
881 return false
882 }
883 if (node.replies.length === 1) {
884 return hasBranchingReplies(node.replies[0])
885 }
886 return true
887}
888
889const styles = StyleSheet.create({
890 prompt: {
891 // @ts-ignore web-only
892 position: isWeb ? 'fixed' : 'absolute',
893 left: 0,
894 right: 0,
895 },
896})