From df20ae237eaf434c6ed0fd032f8328cd9b8c352c Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Tue, 26 Aug 2025 09:54:19 -0500 Subject: Threads v2 cleanup (#8902) * Delete root PostThread component * Remove PostThreadItem, migrate DebugMod to use new components * Remove other unused components * Move PostThreadFollowBtn to new home * Move PostThreadComposePrompt to new home * Remove gate * Keep naming in DebugMod * rm v1 prefs --------- Co-authored-by: Samuel Newman --- src/view/com/post-thread/PostThread.tsx | 910 -------------------------------- 1 file changed, 910 deletions(-) delete mode 100644 src/view/com/post-thread/PostThread.tsx (limited to 'src/view/com/post-thread/PostThread.tsx') diff --git a/src/view/com/post-thread/PostThread.tsx b/src/view/com/post-thread/PostThread.tsx deleted file mode 100644 index bbf9f4a20..000000000 --- a/src/view/com/post-thread/PostThread.tsx +++ /dev/null @@ -1,910 +0,0 @@ -import 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_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}) { - 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 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(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, - ]) - - 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(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), - langs: thread.record.langs, - }, - 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 ( - - {!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} - anchorPostSource={anchorPostSource} - /> - - ) - } - 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 {footerHeight} = useShellLayout() - - const animatedStyle = useAnimatedStyle(() => { - return { - bottom: footerHeight.get(), - } - }) - - 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 -} -- cgit 1.4.1