From 64598ab9d085b9a2825d68400fb93a3b645bb440 Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 7 May 2025 14:21:31 -0500 Subject: [PATCH] Better muted/hidden sorting and handling --- src/state/queries/useGetPostThreadV2.ts | 224 ++++-- src/view/com/post-thread/PostThreadV2.tsx | 899 ---------------------- src/view/screens/PostThread.tsx | 4 +- 3 files changed, 162 insertions(+), 965 deletions(-) delete mode 100644 src/view/com/post-thread/PostThreadV2.tsx diff --git a/src/state/queries/useGetPostThreadV2.ts b/src/state/queries/useGetPostThreadV2.ts index 30f510ca6f..56a041d21b 100644 --- a/src/state/queries/useGetPostThreadV2.ts +++ b/src/state/queries/useGetPostThreadV2.ts @@ -271,18 +271,12 @@ export function filterAndSort( const hidden: Slice[] = [] const muted: Slice[] = [] - const showMuted = shownHiddenReplyKinds.has(HiddenReplyKind.Muted) - const showHidden = shownHiddenReplyKinds.has(HiddenReplyKind.Hidden) - traversal: for (let i = 0; i < thread.length; i++) { const item = thread[i] // ignore unknowns if (!('depth' in item)) continue - const oneUp = thread[i - 1] - const oneDown = thread[i + 1] - if (item.depth < 0) { /* * Parents are ignored until we find the highlighted post, then we walk @@ -299,8 +293,8 @@ export function filterAndSort( slices.push( views.post({ item, - oneUp: oneUp, - oneDown: oneDown, + oneUp: thread[i - 1], + oneDown: thread[i + 1], moderationOpts, }), ) @@ -355,51 +349,55 @@ export function filterAndSort( i = branch.end continue traversal } else if (AppBskyFeedDefs.isThreadItemPost(item)) { - const post = views.post({ + const current = views.post({ item, - oneUp, - oneDown, + oneUp: thread[i - 1], + oneDown: thread[i + 1], moderationOpts, }) - const modui = post.moderation.ui('contentList') - const isBlurred = modui.blur || modui.filter - const isMuted = (modui.blurs[0] || modui.filters[0])?.type === 'muted' - const isHidden = threadgateHiddenReplies.has(item.uri) + const currentMod = getModerationState(current.moderation) + const currentHidden = threadgateHiddenReplies.has(item.uri) + const currentIsTopLevelReply = item.depth === 1 + const isModerated = currentHidden || currentMod.blurred || currentMod.muted - if (isHidden || isBlurred || isMuted) { + if (isModerated) { const branch = getSubBranch(thread, i, item.depth) + const sortArray = currentMod.muted ? muted : hidden - /* - * Top-level replies we re-sort to bottom - */ - if (item.depth === 1) { - for (let ci = branch.start; ci <= branch.end; ci++) { - const next = thread[ci] + if (currentIsTopLevelReply) { + // push sub-branch anchor into sorted array + sortArray.push(current) + // skip sub-branch anchor in sub-branch traversal + const startIndex = branch.start + 1 - if (AppBskyFeedDefs.isThreadItemPost(next)) { - const post = views.post({ - item: next, - oneUp: oneUp, - oneDown: oneDown, + for (let ci = startIndex; ci <= branch.end; ci++) { + const leaf = thread[ci] + + if (AppBskyFeedDefs.isThreadItemPost(leaf)) { + const leafPost = views.post({ + item: leaf, + oneUp: thread[ci - 1], + oneDown: thread[ci + 1], moderationOpts, }) + const leafMod = getModerationState(leafPost.moderation) + const leafHidden = threadgateHiddenReplies.has(leaf.uri) - if (isMuted) { - muted.push(post) + /* + * If a nested post is hidden in any way, drop it an its sub-branch entirely + */ + if (leafMod.blurred || leafMod.muted || leafHidden) { + ci = getSubBranch(thread, ci, leaf.depth).end } else { - hidden.push(post) + sortArray.push(leafPost) } } else { + /* + * Drop the rest of the sub-branch if we hit anything unexpected + */ break } } - } else { - /* - * Nested hidden replies either filter entirely or show in situ - */ - if ((isMuted && showMuted) || showHidden) { - slices.push(post) - } } /* @@ -407,50 +405,148 @@ export function filterAndSort( */ i = branch.end continue traversal + + // /* + // * Top-level replies we re-sort to bottom + // */ + // if (item.depth === 1) { + // for (let ci = branch.start; ci <= branch.end; ci++) { + // const next = thread[ci] + + // if (AppBskyFeedDefs.isThreadItemPost(next)) { + // const post = views.post({ + // item: next, + // oneUp: oneUp, + // oneDown: oneDown, + // moderationOpts, + // }) + + // if (currentMod.muted) { + // muted.push(post) + // } else { + // hidden.push(post) + // } + // } else { + // /* + // * Drop the rest of the branch if we hit anything unexpected + // */ + // break + // } + // } + // } else { + // /* + // * Nested hidden replies either filter entirely or show in situ + // */ + // if ((currentMod.muted && showMuted) || showHidden) { + // for (let ci = branch.start; ci <= branch.end; ci++) { + // const next = thread[ci] + + // if (AppBskyFeedDefs.isThreadItemPost(next)) { + // const post = views.post({ + // item: next, + // oneUp: oneUp, + // oneDown: oneDown, + // moderationOpts, + // }) + // const modui = post.moderation.ui('contentList') + // const isBlurred = modui.blur || modui.filter + // const isMuted = (modui.blurs[0] || modui.filters[0])?.type === 'muted' + + // if (isMuted || isBlurred) { + // console.log(next) + // /* + // * If a post is blurred, drop it and its sub-branch + // */ + // ci = getSubBranch(thread, ci, next.depth).end + // } else { + // /* + // * Otherwise, show the post in situ + // */ + // slices.push(post) + // } + // } else { + // /* + // * Drop the rest of the branch if we hit anything unexpected + // */ + // break + // } + // } + // } + // } } else { /* * Not hidden, so show it */ - slices.push(post) + slices.push(current) } } } } - const [hiddenKind1, hiddenKind2] = Array.from(shownHiddenReplyKinds) + const showMuted = shownHiddenReplyKinds.has(HiddenReplyKind.Muted) + const showHidden = shownHiddenReplyKinds.has(HiddenReplyKind.Hidden) + // const [hiddenKind1, hiddenKind2] = Array.from(shownHiddenReplyKinds) - if (hiddenKind1 === HiddenReplyKind.Hidden) { - slices.push(...hidden) - } - if (hiddenKind1 === HiddenReplyKind.Muted) { - slices.push(...muted) - } - if (hiddenKind2 === HiddenReplyKind.Hidden) { - slices.push(...hidden) - } - if (hiddenKind2 === HiddenReplyKind.Muted) { - slices.push(...muted) - } + // if (hiddenKind1 === HiddenReplyKind.Hidden) { + // slices.push(...hidden) + // } + // if (hiddenKind1 === HiddenReplyKind.Muted) { + // slices.push(...muted) + // } + // if (hiddenKind2 === HiddenReplyKind.Hidden) { + // slices.push(...hidden) + // } + // if (hiddenKind2 === HiddenReplyKind.Muted) { + // slices.push(...muted) + // } - if (muted.length && !showMuted) { - slices.push({ - type: 'showHiddenReplies', - key: 'showMutedReplies', - kind: HiddenReplyKind.Muted, - }) - } + if (hidden.length) { + if (showHidden) { + slices.push(...hidden) - if (hidden.length && !showHidden) { - slices.push({ - type: 'showHiddenReplies', - key: 'showHiddenReplies', - kind: HiddenReplyKind.Hidden, - }) + if (muted.length) { + if (showMuted) { + slices.push(...muted) + } else { + slices.push({ + type: 'showHiddenReplies', + key: 'showMutedReplies', + kind: HiddenReplyKind.Muted, + }) + } + } + } else { + slices.push({ + type: 'showHiddenReplies', + key: 'showHiddenReplies', + kind: HiddenReplyKind.Hidden, + }) + } + } else if (muted.length) { + if (showMuted) { + slices.push(...muted) + } else { + slices.push({ + type: 'showHiddenReplies', + key: 'showMutedReplies', + kind: HiddenReplyKind.Muted, + }) + } } return slices } +export function getModerationState(moderation: ModerationDecision){ + const modui = moderation.ui('contentList') + const blurred = modui.blur || modui.filter + const muted = (modui.blurs[0] || modui.filters[0])?.type === 'muted' + return { + blurred, + muted, + } +} + export enum HiddenReplyKind { Hidden = 'hidden', Muted = 'muted', diff --git a/src/view/com/post-thread/PostThreadV2.tsx b/src/view/com/post-thread/PostThreadV2.tsx deleted file mode 100644 index 9d9db3290b..0000000000 --- a/src/view/com/post-thread/PostThreadV2.tsx +++ /dev/null @@ -1,899 +0,0 @@ -import React, {memo, useRef, useState} from 'react' -import {StyleSheet, useWindowDimensions, View} from 'react-native' -import {runOnJS} from 'react-native-reanimated' -import Animated from 'react-native-reanimated' -import {useSafeAreaInsets} from 'react-native-safe-area-context' -import { - AppBskyFeedDefs, - 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 {useMinimalShellFabTransform} from '#/lib/hooks/useMinimalShellTransform' -import {useSetTitle} from '#/lib/hooks/useSetTitle' -import {useWebMediaQueries} from '#/lib/hooks/useWebMediaQueries' -import {clamp} from '#/lib/numbers' -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 {useModerationOpts} from '#/state/preferences/moderation-opts' -import { - fillThreadModerationCache, - sortThread, - ThreadBlocked, - ThreadModerationCache, - ThreadNode, - ThreadNotFound, - 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 {useComposerControls} from '#/state/shell' -import {useMergedThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies' -import {List, 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_REPLIES -type 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 | undefined}) { - 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(null) - - 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(null) - const highlightedPostRef = useRef(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(), - ) - - const [fetchedAtCache] = React.useState(() => new Map()) - const [randomCache] = React.useState(() => new Map()) - 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, - ]) - console.log({thread, skeleton}) - - 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. - - 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/www.mozzius.dev/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(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} = useComposerControls() - const onPressReply = React.useCallback(() => { - if (thread?.type !== 'post') { - return - } - 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]) - - 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 ( - - {!isMobile && ( - - )} - - ) - } else if (item === SHOW_HIDDEN_REPLIES || item === SHOW_MUTED_REPLIES) { - return ( - - setHiddenRepliesState( - item === SHOW_HIDDEN_REPLIES - ? HiddenRepliesState.Show - : HiddenRepliesState.ShowAndOverridePostHider, - ) - } - hideTopBorder={index === 0} - /> - ) - } else if (isThreadNotFound(item)) { - return ( - - - Deleted post. - - - ) - } else if (isThreadBlocked(item)) { - return ( - - - Blocked post. - - - ) - } 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 - } - - return ( - setDeferParents(false) : undefined}> - 0 - } - onPostReply={onPostReply} - hideTopBorder={index === 0 && !item.ctx.isParentLoading} - /> - - ) - } - return null - } - - if (!thread || !preferences || error) { - return ( - - ) - } - - return ( - <> - - - - - Post - - - - - - - - - - } - initialNumToRender={initialNumToRender} - windowSize={11} - sideBorders={false} - /> - - {isMobile && canReply && hasSession && ( - - )} - - ) -} - -let ThreadMenu = ({ - sortReplies, - treeViewEnabled, - setSortReplies, - setTreeViewEnabled, -}: { - sortReplies: string - treeViewEnabled: boolean - setSortReplies: (newValue: string) => void - setTreeViewEnabled: (newValue: boolean) => void -}): React.ReactNode => { - const {_} = useLingui() - return ( - - - {({props}) => ( - - )} - - - - Show replies as - - - { - setTreeViewEnabled(false) - }}> - - Linear - - - - { - setTreeViewEnabled(true) - }}> - - Threaded - - - - - - - Reply sorting - - - { - setSortReplies('hotness') - }}> - - Hot replies first - - - - { - setSortReplies('oldest') - }}> - - Oldest replies first - - - - { - setSortReplies('newest') - }}> - - Newest replies first - - - - { - setSortReplies('most-likes') - }}> - - Most-liked replies first - - - - { - setSortReplies('random') - }}> - - Random (aka "Poster's Roulette") - - - - - - - ) -} -ThreadMenu = memo(ThreadMenu) - -function MobileComposePrompt({onPressReply}: {onPressReply: () => unknown}) { - const safeAreaInsets = useSafeAreaInsets() - const fabMinimalShellTransform = useMinimalShellFabTransform() - return ( - - - - ) -} - -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, -): 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 { - 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 merge -enum HiddenReplyType { - None = 0, - Muted = 1, - Hidden = 2, -} - -function* flattenThreadReplies( - node: ThreadNode, - currentDid: string | undefined, - treeView: boolean, - modCache: ThreadModerationCache, - showHiddenReplies: boolean, - threadgateRecordHiddenReplies: Set, -): Generator { - 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 -} - -const styles = StyleSheet.create({ - prompt: { - // @ts-ignore web-only - position: isWeb ? 'fixed' : 'absolute', - left: 0, - right: 0, - }, -}) diff --git a/src/view/screens/PostThread.tsx b/src/view/screens/PostThread.tsx index b99a3a3d4a..1b05f1e1e2 100644 --- a/src/view/screens/PostThread.tsx +++ b/src/view/screens/PostThread.tsx @@ -27,7 +27,7 @@ import {ListFooter, ListMaybePlaceholder} from '#/components/Lists' import {PostThreadItem} from '#/view/com/post-thread/PostThreadItem' import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' import {usePreferencesQuery} from '#/state/queries/preferences' -import {useComposerControls} from '#/state/shell' +import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {PostThreadShowHiddenReplies} from '#/view/com/post-thread/PostThreadShowHiddenReplies' const MAINTAIN_VISIBLE_CONTENT_POSITION = { @@ -165,7 +165,7 @@ export function Inner({uri}: {uri: string | undefined}) { [refetch], ) - const {openComposer} = useComposerControls() + const {openComposer} = useOpenComposer() const onReplyToAnchor = () => { const anchorPost = data?.slices.find(slice => slice.type === 'threadSlice' && slice.ui.isAnchor) if (anchorPost?.type !== 'threadSlice') {