Bluesky app fork with some witchin' additions 💫 forked from jollywhoppers.com/witchsky.app
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910import React, {memo, useRef, useState} from 'react'import {useWindowDimensions, View} from 'react-native'import {runOnJS, useAnimatedStyle} from 'react-native-reanimated'import Animated from 'react-native-reanimated'import { AppBskyFeedDefs, type AppBskyFeedThreadgate, moderatePost,} from '@atproto/api'import {msg, Trans} from '@lingui/macro'import {useLingui} from '@lingui/react'
import {HITSLOP_10} from '#/lib/constants'import {useInitialNumToRender} from '#/lib/hooks/useInitialNumToRender'import {useOpenComposer} from '#/lib/hooks/useOpenComposer'import {useSetTitle} from '#/lib/hooks/useSetTitle'import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries'import {ScrollProvider} from '#/lib/ScrollContext'import {sanitizeDisplayName} from '#/lib/strings/display-names'import {cleanError} from '#/lib/strings/errors'import {isAndroid, isNative, isWeb} from '#/platform/detection'import {useFeedFeedback} from '#/state/feed-feedback'import {useModerationOpts} from '#/state/preferences/moderation-opts'import { fillThreadModerationCache, sortThread, type ThreadBlocked, type ThreadModerationCache, type ThreadNode, type ThreadNotFound, type ThreadPost, usePostThreadQuery,} from '#/state/queries/post-thread'import {useSetThreadViewPreferencesMutation} from '#/state/queries/preferences'import {usePreferencesQuery} from '#/state/queries/preferences'import {useSession} from '#/state/session'import {useShellLayout} from '#/state/shell/shell-layout'import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies'import {useUnstablePostSource} from '#/state/unstable-post-source'import {List, type ListMethods} from '#/view/com/util/List'import {atoms as a, useTheme} from '#/alf'import {Button, ButtonIcon} from '#/components/Button'import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider'import {Header} from '#/components/Layout'import {ListFooter, ListMaybePlaceholder} from '#/components/Lists'import * as Menu from '#/components/Menu'import {Text} from '#/components/Typography'import {PostThreadComposePrompt} from './PostThreadComposePrompt'import {PostThreadItem} from './PostThreadItem'import {PostThreadLoadMore} from './PostThreadLoadMore'import {PostThreadShowHiddenReplies} from './PostThreadShowHiddenReplies'
// FlatList maintainVisibleContentPosition breaks if too many items// are prepended. This seems to be an optimal number based on *shrug*.const PARENTS_CHUNK_SIZE = 15
const MAINTAIN_VISIBLE_CONTENT_POSITION = { // We don't insert any elements before the root row while loading. // So the row we want to use as the scroll anchor is the first row. minIndexForVisible: 0,}
const REPLY_PROMPT = {_reactKey: '__reply__'}const LOAD_MORE = {_reactKey: '__load_more__'}const SHOW_HIDDEN_REPLIES = {_reactKey: '__show_hidden_replies__'}const SHOW_MUTED_REPLIES = {_reactKey: '__show_muted_replies__'}
enum HiddenRepliesState { Hide, Show, ShowAndOverridePostHider,}
type YieldedItem = | ThreadPost | ThreadBlocked | ThreadNotFound | typeof SHOW_HIDDEN_REPLIES | typeof SHOW_MUTED_REPLIEStype RowItem = | YieldedItem // TODO: TS doesn't actually enforce it's one of these, it only enforces matching shape. | typeof REPLY_PROMPT | typeof LOAD_MORE
type ThreadSkeletonParts = { parents: YieldedItem[] highlightedPost: ThreadNode replies: YieldedItem[]}
const keyExtractor = (item: RowItem) => { return item._reactKey}
export function PostThread({uri}: {uri: string}) { const {hasSession, currentAccount} = useSession() const {_} = useLingui() const t = useTheme() const {isMobile} = useWebMediaQueries() const initialNumToRender = useInitialNumToRender() const {height: windowHeight} = useWindowDimensions() const [hiddenRepliesState, setHiddenRepliesState] = React.useState( HiddenRepliesState.Hide, ) const headerRef = React.useRef<View | null>(null) const anchorPostSource = useUnstablePostSource(uri) const feedFeedback = useFeedFeedback(anchorPostSource?.feed, hasSession)
const {data: preferences} = usePreferencesQuery() const { isFetching, isError: isThreadError, error: threadError, refetch, data: {thread, threadgate} = {}, dataUpdatedAt: fetchedAt, } = usePostThreadQuery(uri)
// The original source of truth for these are the server settings. const serverPrefs = preferences?.threadViewPrefs const serverPrioritizeFollowedUsers = serverPrefs?.prioritizeFollowedUsers ?? true const serverTreeViewEnabled = serverPrefs?.lab_treeViewEnabled ?? false const serverSortReplies = serverPrefs?.sort ?? 'hotness'
// However, we also need these to work locally for PWI (without persistence). // So we're mirroring them locally. const prioritizeFollowedUsers = serverPrioritizeFollowedUsers const [treeViewEnabled, setTreeViewEnabled] = useState(serverTreeViewEnabled) const [sortReplies, setSortReplies] = useState(serverSortReplies)
// We'll reset the local state if new server state flows down to us. const [prevServerPrefs, setPrevServerPrefs] = useState(serverPrefs) if (prevServerPrefs !== serverPrefs) { setPrevServerPrefs(serverPrefs) setTreeViewEnabled(serverTreeViewEnabled) setSortReplies(serverSortReplies) }
// And we'll update the local state when mutating the server prefs. const {mutate: mutateThreadViewPrefs} = useSetThreadViewPreferencesMutation() function updateTreeViewEnabled(newTreeViewEnabled: boolean) { setTreeViewEnabled(newTreeViewEnabled) if (hasSession) { mutateThreadViewPrefs({lab_treeViewEnabled: newTreeViewEnabled}) } } function updateSortReplies(newSortReplies: string) { setSortReplies(newSortReplies) if (hasSession) { mutateThreadViewPrefs({sort: newSortReplies}) } }
const treeView = React.useMemo( () => treeViewEnabled && hasBranchingReplies(thread), [treeViewEnabled, thread], )
const rootPost = thread?.type === 'post' ? thread.post : undefined const rootPostRecord = thread?.type === 'post' ? thread.record : undefined const threadgateRecord = threadgate?.record as | AppBskyFeedThreadgate.Record | undefined const threadgateHiddenReplies = useMergedThreadgateHiddenReplies({ threadgateRecord, })
const moderationOpts = useModerationOpts() const isNoPwi = React.useMemo(() => { const mod = rootPost && moderationOpts ? moderatePost(rootPost, moderationOpts) : undefined return !!mod ?.ui('contentList') .blurs.find( cause => cause.type === 'label' && cause.labelDef.identifier === '!no-unauthenticated', ) }, [rootPost, moderationOpts])
// Values used for proper rendering of parents const ref = useRef<ListMethods>(null) const highlightedPostRef = useRef<View | null>(null) const [maxParents, setMaxParents] = React.useState( isWeb ? Infinity : PARENTS_CHUNK_SIZE, ) const [maxReplies, setMaxReplies] = React.useState(50)
useSetTitle( rootPost && !isNoPwi ? `${sanitizeDisplayName( rootPost.author.displayName || `@${rootPost.author.handle}`, )}: "${rootPostRecord!.text}"` : '', )
// On native, this is going to start out `true`. We'll toggle it to `false` after the initial render if flushed. // This ensures that the first render contains no parents--even if they are already available in the cache. // We need to delay showing them so that we can use maintainVisibleContentPosition to keep the main post on screen. // On the web this is not necessary because we can synchronously adjust the scroll in onContentSizeChange instead. const [deferParents, setDeferParents] = React.useState(isNative)
const currentDid = currentAccount?.did const threadModerationCache = React.useMemo(() => { const cache: ThreadModerationCache = new WeakMap() if (thread && moderationOpts) { fillThreadModerationCache(cache, thread, moderationOpts) } return cache }, [thread, moderationOpts])
const [justPostedUris, setJustPostedUris] = React.useState( () => new Set<string>(), )
const [fetchedAtCache] = React.useState(() => new Map<string, number>()) const [randomCache] = React.useState(() => new Map<string, number>()) const skeleton = React.useMemo(() => { if (!thread) return null return createThreadSkeleton( sortThread( thread, { // Prefer local state as the source of truth. sort: sortReplies, lab_treeViewEnabled: treeViewEnabled, prioritizeFollowedUsers, }, threadModerationCache, currentDid, justPostedUris, threadgateHiddenReplies, fetchedAtCache, fetchedAt, randomCache, ), currentDid, treeView, threadModerationCache, hiddenRepliesState !== HiddenRepliesState.Hide, threadgateHiddenReplies, ) }, [ thread, prioritizeFollowedUsers, sortReplies, treeViewEnabled, currentDid, treeView, threadModerationCache, hiddenRepliesState, justPostedUris, threadgateHiddenReplies, fetchedAtCache, fetchedAt, randomCache, ])
const error = React.useMemo(() => { if (AppBskyFeedDefs.isNotFoundPost(thread)) { return { title: _(msg`Post not found`), message: _(msg`The post may have been deleted.`), } } else if (skeleton?.highlightedPost.type === 'blocked') { return { title: _(msg`Post hidden`), message: _( msg`You have blocked the author or you have been blocked by the author.`, ), } } else if (threadError?.message.startsWith('Post not found')) { return { title: _(msg`Post not found`), message: _(msg`The post may have been deleted.`), } } else if (isThreadError) { return { message: threadError ? cleanError(threadError) : undefined, } }
return null }, [thread, skeleton?.highlightedPost, isThreadError, _, threadError])
// construct content const posts = React.useMemo(() => { if (!skeleton) return []
const {parents, highlightedPost, replies} = skeleton let arr: RowItem[] = [] if (highlightedPost.type === 'post') { // We want to wait for parents to load before rendering. // If you add something here, you'll need to update both // maintainVisibleContentPosition and onContentSizeChange // to "hold onto" the correct row instead of the first one.
/* * This is basically `!!parents.length`, see notes on `isParentLoading` */ if (!highlightedPost.ctx.isParentLoading && !deferParents) { // When progressively revealing parents, rendering a placeholder // here will cause scrolling jumps. Don't add it unless you test it. // QT'ing this thread is a great way to test all the scrolling hacks: // https://bsky.app/profile/samuel.bsky.team/post/3kjqhblh6qk2o
// Everything is loaded let startIndex = Math.max(0, parents.length - maxParents) for (let i = startIndex; i < parents.length; i++) { arr.push(parents[i]) } } arr.push(highlightedPost) if (!highlightedPost.post.viewer?.replyDisabled) { arr.push(REPLY_PROMPT) } for (let i = 0; i < replies.length; i++) { arr.push(replies[i]) if (i === maxReplies) { break } } } return arr }, [skeleton, deferParents, maxParents, maxReplies])
// This is only used on the web to keep the post in view when its parents load. // On native, we rely on `maintainVisibleContentPosition` instead. const didAdjustScrollWeb = useRef<boolean>(false) const onContentSizeChangeWeb = React.useCallback(() => { // only run once if (didAdjustScrollWeb.current) { return } // wait for loading to finish if (thread?.type === 'post' && !!thread.parent) { // Measure synchronously to avoid a layout jump. const postNode = highlightedPostRef.current const headerNode = headerRef.current if (postNode && headerNode) { let pageY = (postNode as any as Element).getBoundingClientRect().top pageY -= (headerNode as any as Element).getBoundingClientRect().height pageY = Math.max(0, pageY) ref.current?.scrollToOffset({ animated: false, offset: pageY, }) } didAdjustScrollWeb.current = true } }, [thread])
// On native, we reveal parents in chunks. Although they're all already // loaded and FlatList already has its own virtualization, unfortunately FlatList // has a bug that causes the content to jump around if too many items are getting // prepended at once. It also jumps around if items get prepended during scroll. // To work around this, we prepend rows after scroll bumps against the top and rests. const needsBumpMaxParents = React.useRef(false) const onStartReached = React.useCallback(() => { if (skeleton?.parents && maxParents < skeleton.parents.length) { needsBumpMaxParents.current = true } }, [maxParents, skeleton?.parents]) const bumpMaxParentsIfNeeded = React.useCallback(() => { if (!isNative) { return } if (needsBumpMaxParents.current) { needsBumpMaxParents.current = false setMaxParents(n => n + PARENTS_CHUNK_SIZE) } }, []) const onScrollToTop = bumpMaxParentsIfNeeded const onMomentumEnd = React.useCallback(() => { 'worklet' runOnJS(bumpMaxParentsIfNeeded)() }, [bumpMaxParentsIfNeeded])
const onEndReached = React.useCallback(() => { if (isFetching || posts.length < maxReplies) return setMaxReplies(prev => prev + 50) }, [isFetching, maxReplies, posts.length])
const onPostReply = React.useCallback( (postUri: string | undefined) => { refetch() if (postUri) { setJustPostedUris(set => { const nextSet = new Set(set) nextSet.add(postUri) return nextSet }) } }, [refetch], )
const {openComposer} = useOpenComposer() const onReplyToAnchor = React.useCallback(() => { if (thread?.type !== 'post') { return } if (anchorPostSource) { feedFeedback.sendInteraction({ item: thread.post.uri, event: 'app.bsky.feed.defs#interactionReply', feedContext: anchorPostSource.post.feedContext, reqId: anchorPostSource.post.reqId, }) } openComposer({ replyTo: { uri: thread.post.uri, cid: thread.post.cid, text: thread.record.text, author: thread.post.author, embed: thread.post.embed, moderation: threadModerationCache.get(thread), }, onPost: onPostReply, }) }, [ openComposer, thread, onPostReply, threadModerationCache, anchorPostSource, feedFeedback, ])
const canReply = !error && rootPost && !rootPost.viewer?.replyDisabled const hasParents = skeleton?.highlightedPost?.type === 'post' && (skeleton.highlightedPost.ctx.isParentLoading || Boolean(skeleton?.parents && skeleton.parents.length > 0))
const renderItem = ({item, index}: {item: RowItem; index: number}) => { if (item === REPLY_PROMPT && hasSession) { return ( <View> {!isMobile && ( <PostThreadComposePrompt onPressCompose={onReplyToAnchor} /> )} </View> ) } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { return ( <PostThreadShowHiddenReplies type={item === SHOW_HIDDEN_REPLIES ? 'hidden' : 'muted'} onPress={() => setHiddenRepliesState( item === SHOW_HIDDEN_REPLIES ? HiddenRepliesState.Show : HiddenRepliesState.ShowAndOverridePostHider, ) } hideTopBorder={index === 0} /> ) } else if (isThreadNotFound(item)) { return ( <View style={[ a.p_lg, index !== 0 && a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25, ]}> <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> <Trans>Deleted post.</Trans> </Text> </View> ) } else if (isThreadBlocked(item)) { return ( <View style={[ a.p_lg, index !== 0 && a.border_t, t.atoms.border_contrast_low, t.atoms.bg_contrast_25, ]}> <Text style={[a.font_bold, a.text_md, t.atoms.text_contrast_medium]}> <Trans>Blocked post.</Trans> </Text> </View> ) } else if (isThreadPost(item)) { const prev = isThreadPost(posts[index - 1]) ? (posts[index - 1] as ThreadPost) : undefined const next = isThreadPost(posts[index + 1]) ? (posts[index + 1] as ThreadPost) : undefined const showChildReplyLine = (next?.ctx.depth || 0) > item.ctx.depth const showParentReplyLine = (item.ctx.depth < 0 && !!item.parent) || item.ctx.depth > 1 const hasUnrevealedParents = index === 0 && skeleton?.parents && maxParents < skeleton.parents.length
if (!treeView && prev && item.ctx.hasMoreSelfThread) { return <PostThreadLoadMore post={prev.post} /> }
return ( <View ref={item.ctx.isHighlightedPost ? highlightedPostRef : undefined} onLayout={deferParents ? () => setDeferParents(false) : undefined}> <PostThreadItem post={item.post} record={item.record} threadgateRecord={threadgateRecord ?? undefined} moderation={threadModerationCache.get(item)} treeView={treeView} depth={item.ctx.depth} prevPost={prev} nextPost={next} isHighlightedPost={item.ctx.isHighlightedPost} hasMore={item.ctx.hasMore} showChildReplyLine={showChildReplyLine} showParentReplyLine={showParentReplyLine} hasPrecedingItem={showParentReplyLine || !!hasUnrevealedParents} overrideBlur={ hiddenRepliesState === HiddenRepliesState.ShowAndOverridePostHider && item.ctx.depth > 0 } onPostReply={onPostReply} hideTopBorder={index === 0 && !item.ctx.isParentLoading} anchorPostSource={anchorPostSource} /> </View> ) } return null }
if (!thread || !preferences || error) { return ( <ListMaybePlaceholder isLoading={!error} isError={Boolean(error)} noEmpty onRetry={refetch} errorTitle={error?.title} errorMessage={error?.message} /> ) }
return ( <> <Header.Outer headerRef={headerRef}> <Header.BackButton /> <Header.Content> <Header.TitleText> <Trans context="description">Post</Trans> </Header.TitleText> </Header.Content> <Header.Slot> <ThreadMenu sortReplies={sortReplies} treeViewEnabled={treeViewEnabled} setSortReplies={updateSortReplies} setTreeViewEnabled={updateTreeViewEnabled} /> </Header.Slot> </Header.Outer>
<ScrollProvider onMomentumEnd={onMomentumEnd}> <List ref={ref} data={posts} renderItem={renderItem} keyExtractor={keyExtractor} onContentSizeChange={isNative ? undefined : onContentSizeChangeWeb} onStartReached={onStartReached} onEndReached={onEndReached} onEndReachedThreshold={2} onScrollToTop={onScrollToTop} /** * @see https://reactnative.dev/docs/scrollview#maintainvisiblecontentposition */ maintainVisibleContentPosition={ isNative && hasParents ? MAINTAIN_VISIBLE_CONTENT_POSITION : undefined } desktopFixedHeight removeClippedSubviews={isAndroid ? false : undefined} ListFooterComponent={ <ListFooter // Using `isFetching` over `isFetchingNextPage` is done on purpose here so we get the loader on // initial render isFetchingNextPage={isFetching} error={cleanError(threadError)} onRetry={refetch} // 300 is based on the minimum height of a post. This is enough extra height for the `maintainVisPos` to // work without causing weird jumps on web or glitches on native height={windowHeight - 200} /> } initialNumToRender={initialNumToRender} windowSize={11} sideBorders={false} /> </ScrollProvider> {isMobile && canReply && hasSession && ( <MobileComposePrompt onPressReply={onReplyToAnchor} /> )} </> )}
let ThreadMenu = ({ sortReplies, treeViewEnabled, setSortReplies, setTreeViewEnabled,}: { sortReplies: string treeViewEnabled: boolean setSortReplies: (newValue: string) => void setTreeViewEnabled: (newValue: boolean) => void}): React.ReactNode => { const {_} = useLingui() return ( <Menu.Root> <Menu.Trigger label={_(msg`Thread options`)}> {({props}) => ( <Button label={_(msg`Thread options`)} size="small" variant="ghost" color="secondary" shape="round" hitSlop={HITSLOP_10} {...props}> <ButtonIcon icon={SettingsSlider} size="md" /> </Button> )} </Menu.Trigger> <Menu.Outer> <Menu.LabelText> <Trans>Show replies as</Trans> </Menu.LabelText> <Menu.Group> <Menu.Item label={_(msg`Linear`)} onPress={() => { setTreeViewEnabled(false) }}> <Menu.ItemText> <Trans>Linear</Trans> </Menu.ItemText> <Menu.ItemRadio selected={!treeViewEnabled} /> </Menu.Item> <Menu.Item label={_(msg`Threaded`)} onPress={() => { setTreeViewEnabled(true) }}> <Menu.ItemText> <Trans>Threaded</Trans> </Menu.ItemText> <Menu.ItemRadio selected={treeViewEnabled} /> </Menu.Item> </Menu.Group> <Menu.Divider /> <Menu.LabelText> <Trans>Reply sorting</Trans> </Menu.LabelText> <Menu.Group> <Menu.Item label={_(msg`Hot replies first`)} onPress={() => { setSortReplies('hotness') }}> <Menu.ItemText> <Trans>Hot replies first</Trans> </Menu.ItemText> <Menu.ItemRadio selected={sortReplies === 'hotness'} /> </Menu.Item> <Menu.Item label={_(msg`Oldest replies first`)} onPress={() => { setSortReplies('oldest') }}> <Menu.ItemText> <Trans>Oldest replies first</Trans> </Menu.ItemText> <Menu.ItemRadio selected={sortReplies === 'oldest'} /> </Menu.Item> <Menu.Item label={_(msg`Newest replies first`)} onPress={() => { setSortReplies('newest') }}> <Menu.ItemText> <Trans>Newest replies first</Trans> </Menu.ItemText> <Menu.ItemRadio selected={sortReplies === 'newest'} /> </Menu.Item> <Menu.Item label={_(msg`Most-liked replies first`)} onPress={() => { setSortReplies('most-likes') }}> <Menu.ItemText> <Trans>Most-liked replies first</Trans> </Menu.ItemText> <Menu.ItemRadio selected={sortReplies === 'most-likes'} /> </Menu.Item> <Menu.Item label={_(msg`Random (aka "Poster's Roulette")`)} onPress={() => { setSortReplies('random') }}> <Menu.ItemText> <Trans>Random (aka "Poster's Roulette")</Trans> </Menu.ItemText> <Menu.ItemRadio selected={sortReplies === 'random'} /> </Menu.Item> </Menu.Group> </Menu.Outer> </Menu.Root> )}ThreadMenu = memo(ThreadMenu)
function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { const {footerHeight} = useShellLayout()
const animatedStyle = useAnimatedStyle(() => { return { bottom: footerHeight.get(), } })
return ( <Animated.View style={[a.fixed, a.left_0, a.right_0, animatedStyle]}> <PostThreadComposePrompt onPressCompose={onPressReply} /> </Animated.View> )}
function isThreadPost(v: unknown): v is ThreadPost { return !!v && typeof v === 'object' && 'type' in v && v.type === 'post'}
function isThreadNotFound(v: unknown): v is ThreadNotFound { return !!v && typeof v === 'object' && 'type' in v && v.type === 'not-found'}
function isThreadBlocked(v: unknown): v is ThreadBlocked { return !!v && typeof v === 'object' && 'type' in v && v.type === 'blocked'}
function createThreadSkeleton( node: ThreadNode, currentDid: string | undefined, treeView: boolean, modCache: ThreadModerationCache, showHiddenReplies: boolean, threadgateRecordHiddenReplies: Set<string>,): ThreadSkeletonParts | null { if (!node) return null
return { parents: Array.from(flattenThreadParents(node, !!currentDid)), highlightedPost: node, replies: Array.from( flattenThreadReplies( node, currentDid, treeView, modCache, showHiddenReplies, threadgateRecordHiddenReplies, ), ), }}
function* flattenThreadParents( node: ThreadNode, hasSession: boolean,): Generator<YieldedItem, void> { if (node.type === 'post') { if (node.parent) { yield* flattenThreadParents(node.parent, hasSession) } if (!node.ctx.isHighlightedPost) { yield node } } else if (node.type === 'not-found') { yield node } else if (node.type === 'blocked') { yield node }}
// The enum is ordered to make them easy to mergeenum HiddenReplyType { None = 0, Muted = 1, Hidden = 2,}
function* flattenThreadReplies( node: ThreadNode, currentDid: string | undefined, treeView: boolean, modCache: ThreadModerationCache, showHiddenReplies: boolean, threadgateRecordHiddenReplies: Set<string>,): Generator<YieldedItem, HiddenReplyType> { if (node.type === 'post') { // dont show pwi-opted-out posts to logged out users if (!currentDid && hasPwiOptOut(node)) { return HiddenReplyType.None }
// handle blurred items if (node.ctx.depth > 0) { const modui = modCache.get(node)?.ui('contentList') if (modui?.blur || modui?.filter) { if (!showHiddenReplies || node.ctx.depth > 1) { if ((modui.blurs[0] || modui.filters[0]).type === 'muted') { return HiddenReplyType.Muted } return HiddenReplyType.Hidden } }
if (!showHiddenReplies) { const hiddenByThreadgate = threadgateRecordHiddenReplies.has( node.post.uri, ) const authorIsViewer = node.post.author.did === currentDid if (hiddenByThreadgate && !authorIsViewer) { return HiddenReplyType.Hidden } } }
if (!node.ctx.isHighlightedPost) { yield node }
if (node.replies?.length) { let hiddenReplies = HiddenReplyType.None for (const reply of node.replies) { let hiddenReply = yield* flattenThreadReplies( reply, currentDid, treeView, modCache, showHiddenReplies, threadgateRecordHiddenReplies, ) if (hiddenReply > hiddenReplies) { hiddenReplies = hiddenReply } if (!treeView && !node.ctx.isHighlightedPost) { break } }
// show control to enable hidden replies if (node.ctx.depth === 0) { if (hiddenReplies === HiddenReplyType.Muted) { yield SHOW_MUTED_REPLIES } else if (hiddenReplies === HiddenReplyType.Hidden) { yield SHOW_HIDDEN_REPLIES } } } } else if (node.type === 'not-found') { yield node } else if (node.type === 'blocked') { yield node } return HiddenReplyType.None}
function hasPwiOptOut(node: ThreadPost) { return !!node.post.author.labels?.find(l => l.val === '!no-unauthenticated')}
function hasBranchingReplies(node?: ThreadNode) { if (!node) { return false } if (node.type !== 'post') { return false } if (!node.replies) { return false } if (node.replies.length === 1) { return hasBranchingReplies(node.replies[0]) } return true}