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