From ce776fd050d054e5c5b959f0e02a5eece4af8bab Mon Sep 17 00:00:00 2001 From: Eric Bailey Date: Wed, 4 Jun 2025 10:57:38 -0500 Subject: [PATCH] Checkpoint move state into meta query --- package.json | 2 +- .../PostThread/components/HeaderDropdown.tsx | 39 +++--- src/screens/PostThread/index.tsx | 102 ++++++-------- .../preferences/useThreadPreferences.ts | 80 ++++++++--- src/state/queries/usePostThread/index.ts | 129 +++++++++++------- src/state/queries/usePostThread/queryCache.ts | 9 +- src/state/queries/usePostThread/traversal.ts | 6 +- src/state/queries/usePostThread/types.ts | 10 +- src/types/utils.ts | 5 + yarn.lock | 8 +- 10 files changed, 227 insertions(+), 163 deletions(-) create mode 100644 src/types/utils.ts diff --git a/package.json b/package.json index 97918bdc0f..6e5c3f37e5 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ "icons:optimize": "svgo -f ./assets/icons" }, "dependencies": { - "@atproto/api": "^0.15.11", + "@atproto/api": "^0.15.12", "@bitdrift/react-native": "^0.6.8", "@braintree/sanitize-url": "^6.0.2", "@discord/bottom-sheet": "bluesky-social/react-native-bottom-sheet", diff --git a/src/screens/PostThread/components/HeaderDropdown.tsx b/src/screens/PostThread/components/HeaderDropdown.tsx index a53f5dac75..500f01fb0b 100644 --- a/src/screens/PostThread/components/HeaderDropdown.tsx +++ b/src/screens/PostThread/components/HeaderDropdown.tsx @@ -2,21 +2,20 @@ import {msg, Trans} from '@lingui/macro' import {useLingui} from '@lingui/react' import {HITSLOP_10} from '#/lib/constants' +import {type ThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' import {Button, ButtonIcon} from '#/components/Button' import {SettingsSliderVertical_Stroke2_Corner0_Rounded as SettingsSlider} from '#/components/icons/SettingsSlider' import * as Menu from '#/components/Menu' export function HeaderDropdown({ - sortReplies, - treeViewEnabled, - setSortReplies, - setTreeViewEnabled, -}: { - sortReplies: string - treeViewEnabled: boolean - setSortReplies: (newValue: string) => void - setTreeViewEnabled: (newValue: boolean) => void -}): React.ReactNode { + sort, + view, + setSort, + setView, +}: Pick< + ThreadPreferences, + 'sort' | 'setSort' | 'view' | 'setView' +>): React.ReactNode { const {_} = useLingui() return ( @@ -42,22 +41,22 @@ export function HeaderDropdown({ { - setTreeViewEnabled(false) + setView('linear') }}> Linear - + { - setTreeViewEnabled(true) + setView('tree') }}> Threaded - + @@ -68,32 +67,32 @@ export function HeaderDropdown({ { - setSortReplies('top') + setSort('top') }}> Top replies first - + { - setSortReplies('oldest') + setSort('oldest') }}> Oldest replies first - + { - setSortReplies('newest') + setSort('newest') }}> Newest replies first - + diff --git a/src/screens/PostThread/index.tsx b/src/screens/PostThread/index.tsx index 11dc51178a..19599aabde 100644 --- a/src/screens/PostThread/index.tsx +++ b/src/screens/PostThread/index.tsx @@ -8,7 +8,6 @@ import {useOpenComposer} from '#/lib/hooks/useOpenComposer' import {ScrollProvider} from '#/lib/ScrollContext' import {cleanError} from '#/lib/strings/errors' import {isNative} from '#/platform/detection' -import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' import {type ThreadItem, usePostThread} from '#/state/queries/usePostThread' import {type OnPostSuccessData} from '#/state/shell/composer' import {PostThreadComposePrompt} from '#/view/com/post-thread/PostThreadComposePrompt' @@ -40,45 +39,23 @@ export function Inner({uri}: {uri: string | undefined}) { const initialNumToRender = useInitialNumToRender() const {height: windowHeight} = useWindowDimensions() - const { - isLoaded: isThreadPreferencesLoaded, - sortReplies, - setSortReplies, - prioritizeFollowedUsers, - treeViewEnabled, - setTreeViewEnabled, - } = useThreadPreferences() + /* + * One query to rule them all + */ + const thread = usePostThread({anchor: uri}) - const { - isFetching, - isPlaceholderData, - error, - data, - refetch, - insertReplies, - showHiddenReplies, - } = usePostThread({ - enabled: isThreadPreferencesLoaded, - params: { - anchor: uri, - sort: sortReplies, - view: treeViewEnabled ? 'tree' : 'linear', - prioritizeFollowedUsers, - }, - }) - - const optimisticOnPostReply = (data: OnPostSuccessData) => { - if (data) { - const {replyToUri, posts} = data + const optimisticOnPostReply = (payload: OnPostSuccessData) => { + if (payload) { + const {replyToUri, posts} = payload if (replyToUri && posts.length) { - insertReplies(replyToUri, posts) + thread.actions.insertReplies(replyToUri, posts) } } } const {openComposer} = useOpenComposer() const onReplyToAnchor = () => { - const anchorPost = data?.items.find( + const anchorPost = thread.data.items.find( slice => slice.type === 'threadPost' && slice.ui.isAnchor, ) if (anchorPost?.type !== 'threadPost') { @@ -159,28 +136,28 @@ export function Inner({uri}: {uri: string | undefined}) { const hasExhaustedReplies = useRef(false) const onStartReached = () => { - if (isFetching) return + if (thread.state.isFetching) return // limit to 100 setMaxParentCount(n => Math.min(100, n + PARENT_CHUNK_SIZE)) } const onEndReached = () => { - if (isFetching) return + if (thread.state.isFetching) return // prevent any state mutations if we know we're done if (hasExhaustedReplies.current) return setMaxRepliesCount(prev => prev + REPLIES_CHUNK_SIZE) } - const items = useMemo(() => { + const slices = useMemo(() => { const results: ThreadItem[] = [] - if (!data?.items) return results + if (!thread.data.items.length) return results let repliesCount = 0 let totalRepliesCount = 0 - for (let i = 0; i < data.items.length; i++) { - const item = data.items[i] + for (let i = 0; i < thread.data.items.length; i++) { + const item = thread.data.items[i] if ('depth' in item) { if (item.depth === 0) { @@ -190,7 +167,7 @@ export function Inner({uri}: {uri: string | undefined}) { const start = i - 1 const limit = Math.max(0, start - maxParentCount) for (let pi = start; pi >= limit; pi--) { - results.unshift(data.items[pi]) + results.unshift(thread.data.items[pi]) } } } else if (item.depth > 0) { @@ -207,12 +184,15 @@ export function Inner({uri}: {uri: string | undefined}) { } // TODO should really just count these during traversal, can remove isPlaceholder data after that - if (maxRepliesCount > totalRepliesCount && !isPlaceholderData) { + if ( + maxRepliesCount > totalRepliesCount && + !thread.state.isPlaceholderData + ) { hasExhaustedReplies.current = true } return results - }, [data, deferParents, maxParentCount, maxRepliesCount, isPlaceholderData]) + }, [thread, deferParents, maxParentCount, maxRepliesCount]) const renderItem = ({item, index}: {item: ThreadItem; index: number}) => { if (item.type === 'threadPost') { @@ -221,7 +201,7 @@ export function Inner({uri}: {uri: string | undefined}) { return ( setDeferParents(false) : undefined}> ) } else { - if (treeViewEnabled) { + if (thread.state.view === 'tree') { return ( 0, + moderation: thread.state.hiddenRepliesVisible && item.depth > 0, }} onPostSuccess={optimisticOnPostReply} /> @@ -256,9 +236,9 @@ export function Inner({uri}: {uri: string | undefined}) { return ( 0, + moderation: thread.state.hiddenRepliesVisible && item.depth > 0, }} onPostSuccess={optimisticOnPostReply} /> @@ -266,7 +246,12 @@ export function Inner({uri}: {uri: string | undefined}) { } } } else if (item.type === 'readMore') { - return + return ( + + ) } else if (item.type === 'threadPostBlocked') { return ( - {error ? ( - + {thread.state.error ? ( + ) : ( +export type ThreadViewOption = 'linear' | 'tree' +export type ThreadPreferences = { + isLoaded: boolean + sort: ThreadSortOption + setSort: (sort: ThreadSortOption) => void + view: ThreadViewOption + setView: (view: ThreadViewOption) => void + prioritizeFollowedUsers: boolean + setPrioritizeFollowedUsers: (prioritize: boolean) => void +} + +export function useThreadPreferences(): ThreadPreferences { const {data: preferences} = usePreferencesQuery() const nextThreadPreferences = preferences?.threadViewPrefs /* * Create local state representations of server state */ - const [sortReplies, setSortReplies] = useState( - nextThreadPreferences?.sort ?? 'hotness', + const [sort, setSort] = useState( + migrateFromSortV1(nextThreadPreferences?.sort || 'top'), + ) + const [view, setView] = useState( + computeView({ + treeViewEnabled: !!nextThreadPreferences?.lab_treeViewEnabled, + }), ) const [prioritizeFollowedUsers, setPrioritizeFollowedUsers] = useState( !!nextThreadPreferences?.prioritizeFollowedUsers, ) - const [treeViewEnabled, setTreeViewEnabled] = useState( - !!nextThreadPreferences?.lab_treeViewEnabled, - ) /** * Cache existing and if we get a server update, reset local state @@ -29,9 +48,13 @@ export function useThreadPreferences() { /* * Reset */ - setSortReplies(nextThreadPreferences.sort) + setSort(migrateFromSortV1(nextThreadPreferences.sort)) setPrioritizeFollowedUsers(nextThreadPreferences.prioritizeFollowedUsers) - setTreeViewEnabled(!!nextThreadPreferences.lab_treeViewEnabled) + setView( + computeView({ + treeViewEnabled: !!nextThreadPreferences.lab_treeViewEnabled, + }), + ) } const isLoaded = !!prevServerPrefs @@ -39,21 +62,46 @@ export function useThreadPreferences() { return useMemo( () => ({ isLoaded, - sortReplies, - setSortReplies, + sort, + setSort, prioritizeFollowedUsers, setPrioritizeFollowedUsers, - treeViewEnabled, - setTreeViewEnabled, + view, + setView, }), [ isLoaded, - sortReplies, - setSortReplies, + sort, + setSort, prioritizeFollowedUsers, setPrioritizeFollowedUsers, - treeViewEnabled, - setTreeViewEnabled, + view, + setView, ], ) } + +/** + * Migrates user thread preferences from the old sort values to V2 + */ +function migrateFromSortV1(sort: string): ThreadSortOption { + switch (sort) { + case 'oldest': + return 'oldest' + case 'newest': + return 'newest' + default: + return 'top' + } +} + +/** + * Transforms existing treeViewEnabled preference into a ThreadViewOption + */ +function computeView({ + treeViewEnabled, +}: { + treeViewEnabled: boolean +}): ThreadViewOption { + return treeViewEnabled ? 'tree' : 'linear' +} diff --git a/src/state/queries/usePostThread/index.ts b/src/state/queries/usePostThread/index.ts index 8cea5ee00d..fe0d19801c 100644 --- a/src/state/queries/usePostThread/index.ts +++ b/src/state/queries/usePostThread/index.ts @@ -3,6 +3,7 @@ import {useQuery, useQueryClient} from '@tanstack/react-query' import {wait} from '#/lib/async/wait' import {useModerationOpts} from '#/state/preferences/moderation-opts' +import {useThreadPreferences} from '#/state/queries/preferences/useThreadPreferences' import { createCacheMutator, getThreadPlaceholder, @@ -12,7 +13,6 @@ import { createPostThreadHiddenQueryKey, createPostThreadQueryKey, type ThreadItem, - type UsePostThreadProps, } from '#/state/queries/usePostThread/types' import {getThreadgateRecord} from '#/state/queries/usePostThread/utils' import {useAgent, useSession} from '#/state/session' @@ -20,34 +20,40 @@ import {useMergeThreadgateHiddenReplies} from '#/state/threadgate-hidden-replies export * from '#/state/queries/usePostThread/types' -export function usePostThread({ - enabled: isEnabled, - params, -}: UsePostThreadProps) { +export function usePostThread({anchor}: {anchor?: string}) { const qc = useQueryClient() const agent = useAgent() const {hasSession} = useSession() const moderationOpts = useModerationOpts() const mergeThreadgateHiddenReplies = useMergeThreadgateHiddenReplies() - - const enabled = isEnabled !== false && !!params.anchor && !!moderationOpts - const queryKey = createPostThreadQueryKey({ - params, + const { + isLoaded: isThreadPreferencesLoaded, + sort, + setSort, + view, + setView, + prioritizeFollowedUsers, + } = useThreadPreferences() + const postThreadQueryKey = createPostThreadQueryKey({ + anchor, + sort, + view, + prioritizeFollowedUsers, }) const query = useQuery({ - enabled, - queryKey, + enabled: isThreadPreferencesLoaded && !!anchor && !!moderationOpts, + queryKey: postThreadQueryKey, // gcTime: 0, // TODO faster if we let it cache async queryFn(ctx) { const {data} = await wait( 400, agent.app.bsky.unspecced.getPostThreadV2({ - anchor: params.anchor!, - branchingFactor: params.view === 'linear' ? 1 : undefined, + anchor: anchor!, + branchingFactor: view === 'linear' ? 1 : undefined, below: 4, - sort: params.sort, - prioritizeFollowedUsers: params.prioritizeFollowedUsers, + sort: sort, + prioritizeFollowedUsers: prioritizeFollowedUsers, }), ) @@ -72,8 +78,8 @@ export function usePostThread({ } }, placeholderData() { - if (!params.anchor) return - const placeholder = getThreadPlaceholder(qc, params.anchor) + if (!anchor) return + const placeholder = getThreadPlaceholder(qc, anchor) /* * Always return something here, even empty data, so that * `isPlaceholderData` is always true, which we'll use to insert @@ -95,8 +101,8 @@ export function usePostThread({ }) const hasHiddenReplies = !!query.data?.hasHiddenReplies - const [showHiddenReplies, setShowHiddenReplies] = useState(false) - const [hiddenReplies, setHiddenReplies] = useState([]) + const [hiddenRepliesVisible, setHiddenRepliesVisible] = useState(false) + const [hiddenItems, setHiddenItems] = useState([]) /** * Loads hidden replies for this thread. Any replies that are moderated from @@ -105,19 +111,19 @@ export function usePostThread({ */ const loadHiddenReplies = useCallback(async () => { // immediately show any moderated replies already in memory - setShowHiddenReplies(true) + setHiddenRepliesVisible(true) // add skeletons for the replies that will be loaded - setHiddenReplies( + setHiddenItems( Array.from({length: 2}).map((_, i) => ({ type: 'skeleton', - key: `${params.anchor!}-reply-${i}`, + key: `${anchor!}-reply-${i}`, item: 'reply', })), ) const queryParams = { - anchor: params.anchor!, - prioritizeFollowedUsers: params.prioritizeFollowedUsers, + anchor: anchor!, + prioritizeFollowedUsers: prioritizeFollowedUsers, } const data = await wait( @@ -139,26 +145,28 @@ export function usePostThread({ ), moderationOpts: moderationOpts!, hasSession, - view: params.view, + view, hasHiddenReplies, - showHiddenReplies, + hiddenRepliesVisible, skipHiddenReplyHandling: true, loadHiddenReplies, }) // insert the hidden replies into the state - setHiddenReplies(items) + setHiddenItems(items) }, [ agent, - params, + view, + anchor, + prioritizeFollowedUsers, hasSession, mergeThreadgateHiddenReplies, moderationOpts, qc, query.data?.threadgate?.record, hasHiddenReplies, - showHiddenReplies, - setShowHiddenReplies, + hiddenRepliesVisible, + setHiddenRepliesVisible, ]) const items = useMemo(() => { @@ -168,36 +176,36 @@ export function usePostThread({ ), moderationOpts: moderationOpts!, hasSession, - view: params.view, + view: view, hasHiddenReplies, - showHiddenReplies, + hiddenRepliesVisible, loadHiddenReplies, }) - return results.concat(hiddenReplies) + return results.concat(hiddenItems) }, [ query.data, mergeThreadgateHiddenReplies, moderationOpts, hasSession, - params.view, + view, hasHiddenReplies, - showHiddenReplies, + hiddenRepliesVisible, loadHiddenReplies, - hiddenReplies, + hiddenItems, ]) if (query.isPlaceholderData) { - const anchor = items.at(0) + const anchorPost = items.at(0) const skeletonReplies = - anchor && anchor.type === 'threadPost' - ? anchor?.value.post.replyCount ?? 4 + anchorPost && anchorPost.type === 'threadPost' + ? anchorPost?.value.post.replyCount ?? 4 : 4 if (!items.length) { items.push({ type: 'skeleton', - key: params.anchor!, + key: anchor!, item: 'anchor', }) @@ -213,7 +221,7 @@ export function usePostThread({ for (let i = 0; i < skeletonReplies; i++) { items.push({ type: 'skeleton', - key: `${params.anchor!}-reply-${i}`, + key: `${anchor!}-reply-${i}`, item: 'reply', }) } @@ -222,23 +230,46 @@ export function usePostThread({ const mutator = useMemo( () => createCacheMutator({ - params, - queryKey, + params: { + sort, + view, + }, + queryKey: postThreadQueryKey, queryClient: qc, }), - [qc, params, queryKey], + [qc, sort, view, postThreadQueryKey], ) return useMemo( () => ({ - ...query, + state: { + isFetching: query.isFetching, + isPlaceholderData: query.isPlaceholderData, + error: query.error, + hiddenRepliesVisible, + sort, + view, + }, data: { - items, + items: items || [], threadgate: query.data?.threadgate, }, - showHiddenReplies, - insertReplies: mutator.insertReplies, + actions: { + insertReplies: mutator.insertReplies, + refetch: query.refetch, + setSort, + setView, + }, }), - [query, items, mutator.insertReplies, showHiddenReplies], + [ + query, + items, + mutator.insertReplies, + hiddenRepliesVisible, + sort, + view, + setSort, + setView, + ], ) } diff --git a/src/state/queries/usePostThread/queryCache.ts b/src/state/queries/usePostThread/queryCache.ts index 95219ccf74..e117796cea 100644 --- a/src/state/queries/usePostThread/queryCache.ts +++ b/src/state/queries/usePostThread/queryCache.ts @@ -24,13 +24,14 @@ import {didOrHandleUriMatches, getEmbeddedPost} from '#/state/queries/util' import {embedViewRecordToPostView} from '#/state/queries/util' export function createCacheMutator({ - params, - queryKey, queryClient, + queryKey, + params, }: { - params: PostThreadParams - queryKey: ReturnType queryClient: QueryClient + queryKey: ReturnType + // TODO could clean this up? + params: PostThreadParams }) { return { insertReplies( diff --git a/src/state/queries/usePostThread/traversal.ts b/src/state/queries/usePostThread/traversal.ts index 87add9d885..cb0a9595cf 100644 --- a/src/state/queries/usePostThread/traversal.ts +++ b/src/state/queries/usePostThread/traversal.ts @@ -26,7 +26,7 @@ export function traverse( hasSession, view, hasHiddenReplies, - showHiddenReplies, + hiddenRepliesVisible, skipHiddenReplyHandling, loadHiddenReplies, }: { @@ -35,7 +35,7 @@ export function traverse( hasSession: boolean view: PostThreadParams['view'] hasHiddenReplies: boolean - showHiddenReplies: boolean + hiddenRepliesVisible: boolean skipHiddenReplyHandling?: boolean loadHiddenReplies: () => Promise }, @@ -241,7 +241,7 @@ export function traverse( if (!skipHiddenReplyHandling) { if (hidden.length || hasHiddenReplies) { - if (showHiddenReplies) { + if (hiddenRepliesVisible) { items.push(...hidden) } else { items.push({ diff --git a/src/state/queries/usePostThread/types.ts b/src/state/queries/usePostThread/types.ts index 3afbad3c1a..50ecccd01f 100644 --- a/src/state/queries/usePostThread/types.ts +++ b/src/state/queries/usePostThread/types.ts @@ -14,9 +14,8 @@ export type ApiThreadItem = export const postThreadQueryKeyRoot = 'getPostThreadV2' as const export const postThreadHiddenQueryKeyRoot = 'getPostThreadHiddenV2' as const -export const createPostThreadQueryKey = ( - props: Pick, -) => [postThreadQueryKeyRoot, props] as const +export const createPostThreadQueryKey = (props: PostThreadParams) => + [postThreadQueryKeyRoot, props] as const export const createPostThreadHiddenQueryKey = ( props: AppBskyUnspeccedGetPostThreadHiddenV2.QueryParams, @@ -30,11 +29,6 @@ export type PostThreadParams = Pick< view: 'tree' | 'linear' } -export type UsePostThreadProps = { - enabled?: boolean - params: PostThreadParams -} - export type ThreadItem = | { type: 'threadPost' diff --git a/src/types/utils.ts b/src/types/utils.ts new file mode 100644 index 0000000000..f64922a1f1 --- /dev/null +++ b/src/types/utils.ts @@ -0,0 +1,5 @@ +export type Literal = T extends A + ? string extends T + ? never + : T + : never diff --git a/yarn.lock b/yarn.lock index 678081b094..7c5354c0aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -63,10 +63,10 @@ "@atproto/xrpc" "^0.7.0" "@atproto/xrpc-server" "^0.7.18" -"@atproto/api@^0.15.11": - version "0.15.11" - resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.11.tgz#f3f0718216c00bc405d35e0ce89ad30cedb2fb30" - integrity sha512-+XNOIqNPa1BWXzoi0mw6Qmx6kYlQPo60bhSrlxdhRYYH9CIgFAGmXrtb+MuAJoKgtSKX/2CBPihDsKEKEj8mfw== +"@atproto/api@^0.15.12": + version "0.15.12" + resolved "https://registry.yarnpkg.com/@atproto/api/-/api-0.15.12.tgz#0b86eef80b052194353926327acf85c6299b9c04" + integrity sha512-51IHenZMA+Ekfe2OlZL/mTFqvZQU93jI4xsLvTFhGc4tSQYCHV9r/AJTANPZLFrhm9GfWZ0n90r/9IQl9eicjg== dependencies: "@atproto/common-web" "^0.4.2" "@atproto/lexicon" "^0.4.11"