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"